Skip to content

插件:目录约定与发现

源码版本v2026.6.11

职责

插件 (plugin) 是 OpenClaw 的扩展包——一个按目录约定摆放的 npm 包或源码目录,声明自己提供哪些工具、技能、渠道 (channel)、provider、命令、钩子 (hook)、RPC 方法。系统在启动时按多个根目录扫描发现 (discovery) 候选插件,加载 (loading) 后汇成一个 PluginRegistry,后续主循环、网关、agent session 都从这张注册表里取扩展能力。

插件系统本身不"执行"业务,它只做三件事:扫描多个根目录找候选、解析清单 (manifest) 与 bundle 格式、注册到统一 registry。真正干活的是插件注册进来的工具、provider、渠道——这些能力被装进 PluginRegistry 后,就和其他来源(内置、SDK 自定义)的能力平起平坐。

OpenClaw 同时支持几种 bundle 格式:.codex-plugin/plugin.json(Codex 风格)、.claude-plugin/plugin.json(Claude 风格)、.cursor-plugin/plugin.json(Cursor 风格),以及原生的 plugin.json。这让 OpenClaw 能直接复用其他生态的插件包,不必强制作者重新打包。

设计动机

为什么用目录约定而不是显式 import?因为插件来源五花八门:有的是 npm 包,有的是 git clone 的源码目录,有的是 bundled 的 dist 产物,有的是用户在 ~/.openclaw/plugins/ 下手动放一个文件夹。显式 import 要求路径和模块系统就绪,而目录约定只需要"在这个目录下有清单文件"就能识别。这让发现 (discovery) 阶段可以纯文件系统操作,不必加载模块——加载模块成本高(要走 Node ESM 解析、可能要 tsx 编译),把识别和加载分离能极大加快冷启动。

bundle-manifest.ts(bundle-manifest.ts 相对路径常量:L21-L24) 定义了三种 bundle 格式的相对路径:

typescript
/** Relative manifest path for Codex-style plugin bundles. */
export const CODEX_BUNDLE_MANIFEST_RELATIVE_PATH = ".codex-plugin/plugin.json";
export const CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH = ".claude-plugin/plugin.json";
export const CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH = ".cursor-plugin/plugin.json";

注意它们都是目录内的隐藏子目录 + plugin.json——这样插件包自己可以是一个普通的 npm 包,有 package.jsonREADME.md、源码,同时携带一个 .codex-plugin/plugin.json 声明 OpenClaw 关心的部分(skills/hooks/capabilities)。

BundlePluginManifest 类型(src/plugins/bundle-manifest.ts:L27-L39)把多种格式归一:

typescript
export type BundlePluginManifest = {
  id: string;
  name?: string;
  description?: string;
  version?: string;
  skills: string[];
  settingsFiles?: string[];
  hooks: string[];
  bundleFormat: PluginBundleFormat;
  activation?: PluginManifestActivation;
  capabilities: string[];
};

bundleFormat 字段标记来源格式,skills / hooks / capabilities 是 OpenClaw 真正关心的能力声明——这些是相对插件根的路径数组。这种"归一"让后续 loader 不必关心插件来自哪种生态。

加载阶段 loadOpenClawPlugins(loader.ts loadOpenClawPlugins:L1821-L1895) 还有几个关键设计:缓存复用(相同 cacheKey 直接返回已加载的 registry,避免重复加载模块)、激活态分离(shouldActivate 控制是否清空之前的运行时状态,非激活加载只读不写副作用)、懒创建模块加载器(所有插件都禁用时不创建 module loader,省冷启动成本)。

PluginRegistry(registry-types.ts PluginRegistry:L434-L484) 是一个大对象,每种能力一个数组:

typescript
export type PluginRegistry = {
  plugins: PluginRecord[];
  tools: PluginToolRegistration[];
  hooks: PluginHookRegistration[];
  typedHooks: TypedPluginHookRegistration[];
  channels: PluginChannelRegistration[];
  channelSetups: PluginChannelSetupRegistration[];
  providers: PluginProviderRegistration[];
  modelCatalogProviders: PluginModelCatalogProviderRegistration[];
  // ... 还有十几个 provider 类型和注册项
  gatewayHandlers: GatewayRequestHandlers;
  httpRoutes: PluginHttpRouteRegistration[];
  cliRegistrars: PluginCliRegistration[];
  commands: PluginCommandRegistration[];
  // ...
  diagnostics: PluginDiagnostic[];
};

注意它不是 Map,而是按能力分桶的数组对象。原因是不同能力的注册维度不同:工具有 name+executor,渠道有 channelId+pluginId,provider 有 api+providerId——硬塞成 Map<string, T> 反而要编一个伪 key。数组+diagnostics 让发现/加载过程中产生的告警和错误可以一路带到运行时,而不是"加载失败就丢"。

关键文件

数据流

发现从多个根开始(discovery.ts discoverOpenClawPlugins:L1432-L1510):

typescript
export function discoverOpenClawPlugins(params: {
  workspaceDir?: string;
  extraPaths?: string[];
  installRecords?: Record<string, PluginInstallRecord>;
  ownershipUid?: number | null;
  env?: NodeJS.ProcessEnv;
}): PluginDiscoveryResult {
  const env = params.env ?? process.env;
  const workspaceDir = normalizeOptionalString(params.workspaceDir);
  const workspaceRoot = workspaceDir ? resolveUserPath(workspaceDir, env) : undefined;
  const roots = resolvePluginSourceRoots({ workspaceDir: workspaceRoot, env });
  // ...
  for (const extraPath of extra) {
    // ...
    const bundledAlias = resolvePackagedBundledLoadPathAlias({
      bundledRoot: roots.stock,
      loadPath: resolveUserPath(trimmed, env),
    });
    if (bundledAlias) {
      result.diagnostics.push({
        level: "warn",
        source: trimmed,
        message: `ignored plugins.load.paths entry that points at OpenClaw's ${bundledAlias.kind} bundled plugin directory; remove this redundant path or run openclaw doctor --fix`,
      });
      continue;
    }
    discoverFromPath({ rawPath: trimmed, origin: "config", /* ... */ });
  }

注意 bundled 别名检测——如果用户在 plugins.load.paths 里指向了 OpenClaw 内置 bundled 插件目录,只 warn 不重复扫,因为那个目录本来就在扫描范围里。这是防止"双重加载同一个插件导致重名报错"的预防针。

workspace 自动发现有一段关键注释(src/plugins/discovery.ts:L1489-L1505):

typescript
if (roots.workspace && workspaceRoot && !workspaceMatchesBundledRoot) {
  // Keep workspace auto-discovery constrained to the OpenClaw extensions root.
  // Recursively scanning the full workspace treats arbitrary project folders as
  // plugin candidates and causes noisy "plugin manifest not found" validation failures.
  discoverInDirectory({
    dir: roots.workspace,
    origin: "workspace",
    // ...
  });
}

这是个曾经踩过的坑:如果递归扫整个 workspace,任何普通的 node_modules 子目录或源码文件夹都会被当成插件候选,然后报"plugin manifest not found"——噪声会淹没真正的错误。所以 workspace 发现被限制在 extensions root 内。

加载阶段(loader.ts 缓存+激活:L1862-L1903) 走缓存复用 + 激活态分离:

typescript
const cacheEnabled = options.cache !== false && options.resolveRawConfigEnvVars !== true;
if (cacheEnabled) {
  const cached = getReusableCachedPluginRegistry({
    cacheKey,
    onlyPluginIds,
    runtimeSubagentMode,
    options,
  });
  if (cached) {
    if (shouldActivate) {
      restoreRegisteredAgentHarnesses(cached.state.agentHarnesses);
      restorePluginCommands(cached.state.commands ?? []);
      // ... 恢复一堆运行时状态
      activatePluginRegistry(
        cached.state.registry,
        cached.cacheKey,
        cached.runtimeSubagentMode,
        options.workspaceDir,
      );
    }
    return cached.state.registry;
  }
}
pluginLoaderCacheState.beginLoad(cacheKey);
try {
  if (shouldActivate) {
    clearActivatedPluginRuntimeState();
  }
  const loadPluginModule = createPluginModuleLoader({
    devSourceRoot,
    pluginSdkResolution: options.pluginSdkResolution,
  });

缓存复用要恢复的不止 registry 本身,还有 agentHarnesses、commands、compactionProviders、interactiveHandlers、embeddingProviders 等一堆全局副作用——因为插件加载时这些副作用会注册到全局表,重新激活时得把它们恢复到对应位置。clearActivatedPluginRuntimeState 在新加载前清空旧状态,避免上一轮注册的工具/命令"漏"到这一轮。

bundle 清单解析在 bundle-manifest.ts 加载文件:L92-L110:用 readRootStructuredFileSync 在 root 边界读 JSON,失败转 { ok: false, error, manifestPath },不抛错。

边界与失败

  • 三种 bundle 格式并存:.codex-plugin / .claude-plugin / .cursor-plugin 都是隐藏子目录 + plugin.json(src/plugins/bundle-manifest.ts:L21-L24)。一个插件可以同时声明多种格式——loader 会选第一个匹配的格式归一成 BundlePluginManifest
  • workspace 发现范围受限:自动发现只扫 extensions root,不递归整个工作区(src/plugins/discovery.ts:L1489-L1505)。否则会爆"plugin manifest not found"噪声。
  • bundled 目录不重复扫:用户在 plugins.load.paths 指向 bundled root 只 warn 不重复扫(src/plugins/discovery.ts:L1459-L1470)。建议跑 openclaw doctor --fix 自动清掉冗余配置。
  • 缓存复用恢复副作用:命中缓存时不止返回 registry,还要 restoreRegisteredAgentHarnesses / restorePluginCommands / restoreRegisteredEmbeddingProviders 等十几个全局恢复(src/plugins/loader.ts:L1872-L1885)。漏一个就会导致工具/命令"看不见"。
  • 激活态分离:shouldActivate false 时不清空之前注册的运行时状态——这是为快照加载(validate 模式 / parallel discovery)设计的,避免误清其他插件的命令。
  • 模块懒加载:所有插件都禁用时,createPluginModuleLoader 根本不被调用(src/plugins/loader.ts:L1904-L1908)——单测里常见场景,省冷启动成本。
  • diagnostics 不丢:PluginDiscoveryResultPluginRegistry 都带 diagnostics: PluginDiagnostic[] 数组,发现/加载过程产生的 warn/error 一路带到运行时。openclaw doctor 读取这些 diagnostics 给出修复建议。
  • 插件不是工具:插件可以注册工具,但插件本身是一个包。PluginRegistry.tools 是工具数组,每个 PluginToolRegistration 引用插件 ID + 工具定义——这些工具最终会被汇进 工具系统 的 Map。同样,插件可以带 skills/SKILL.md 字段(见 技能),但它本身是按目录约定发现的扩展包,不是技能本身。

小结

插件是按目录约定发现的扩展包:.codex-plugin/plugin.json 等隐藏子目录声明能力,discovery 扫多根 + extraPaths 找候选,loader 归一成 PluginRegistry 按能力分桶。缓存复用恢复十几个全局副作用,激活态分离让快照加载不清空之前的运行时状态。三种 bundle 格式让 OpenClaw 能直接吃 Codex/Claude/Cursor 生态的插件。和 工具 的区别:工具是 Map 里的函数,插件是产出工具的来源之一;和 技能 的区别:技能是 Markdown 指引,插件可以携带技能作为内容。渠道注册和 RPC 方法注册都走 渠道注册表 那条线。

对照官方资料:Plugins 文档 · README