插件:目录约定与发现
职责
插件 (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。