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