外掛:目錄約定與發現
職責
外掛 (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 格式的相對路徑:
/** 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.json、README.md、原始碼,同時攜帶一個 .codex-plugin/plugin.json 聲明 OpenClaw 關心的部分(skills/hooks/capabilities)。
BundlePluginManifest 型別(src/plugins/bundle-manifest.ts:L27-L39)把多種格式歸一:
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) 是一個大物件,每種能力一個陣列:
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 讓發現/載入過程中產生的警告和錯誤可以一路帶到執行時,而不是「載入失敗就丟」。
關鍵檔案
bundle-manifest.ts 三種格式:L21-L24— Codex/Claude/Cursor bundle 相對路徑常量。bundle-manifest.ts BundlePluginManifest:L27-L39— 歸一後的 bundle 清單型別。discovery.ts discoverOpenClawPlugins:L1432-L1510— 發現主入口,掃多個根 + extraPaths。discovery.ts workspace 約束:L1484-L1505— workspace 自動發現限制在 extensions root,避免誤掃整個工作區。discovery.ts discoverFromPath:L1210-L1259— 單路徑發現:檔案/目錄分支處理。loader.ts PluginLoadOptions:L181-L232— 載入選項全集,含 cache/onlyPluginIds/mode 等。loader.ts loadOpenClawPlugins:L1821-L1903— 載入主入口,快取復用 + 啟用態分離。registry-types.ts PluginRegistry:L434-L484— 登錄表型別,按能力分桶。registry-types.ts PluginToolRegistration:L76-L86— 工具登錄項範例。discovery.ts bundled 別名拒絕:L1459-L1470— extraPaths 指向 bundled root 時只 warn 不重複掃。
資料流
發現從多個根開始(discovery.ts discoverOpenClawPlugins:L1432-L1510):
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):
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) 走快取復用 + 啟用態分離:
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)。漏一個就會導致工具/指令「看不見」。 - 啟用態分離:
shouldActivatefalse 時不清空之前登錄的執行時狀態——這是為快照載入(validate 模式 / parallel discovery)設計的,避免誤清其他外掛的指令。 - 模組懶載入:所有外掛都停用時,
createPluginModuleLoader根本不被呼叫(src/plugins/loader.ts:L1904-L1908)——單測裡常見場景,省冷啟動成本。 - diagnostics 不丟:
PluginDiscoveryResult和PluginRegistry都帶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。