插件系统总览
OpenClaw 是插件优先(plugin-first)的运行时:仓库内有 141 个扩展、16 个包。Provider、消息渠道、工具、MCP 服务器、外部 Agent Harness、记忆后端——几乎所有能力都不是硬编码进核心,而是通过插件注册进来的。
把内核看作一个极薄的协调层:它只负责加载 manifest、构造一份精简过的 API 对象、再把插件注册进来的能力汇聚到一个全局注册表中。所有「OpenClaw 能做什么」的答案,最终都落在某个插件的注册调用上。这种设计让模型供应商、消息平台、工具集可以各自独立演进、独立发版,而无需触碰内核。
插件系统的核心由四个源文件构成,它们的职责边界非常清晰:types.ts 定义对插件暴露的公共 API 面,manifest.ts 负责 openclaw.plugin.json 的解析与缓存,api-builder.ts 按需裁剪并构造每个插件实际拿到的 API 对象,而 runtime.ts 则在 globalThis 上维护一个进程级的全局注册表,协调热替换与回收。理解这四个文件,就理解了整个插件层。
types.ts
公共 API 面定义——OpenClawPluginApi 接口与全部 register* 方法签名。
manifest.ts
解析 openclaw.plugin.json,带 SLRU 缓存与 mtime/ctime 失效跟踪。
api-builder.ts
用 BuildPluginApiParams 按需裁剪并构造插件实际持有的 API 对象。
runtime.ts
挂在 globalThis 上的全局 RegistryState 协调器,管理热替换与回收。
OpenClawPluginApi 公共接口
插件能调用的一切,都收敛在 src/plugins/types.ts 定义的 OpenClawPluginApi 接口上;而 api-builder.ts 则用 BuildPluginApiParams 决定每个插件实际拿到哪些方法。
export type BuildPluginApiParams = {
id: string;
name: string;
version?: string;
source: string;
registrationMode: OpenClawPluginApi["registrationMode"];
config: OpenClawConfig;
runtime: PluginRuntime;
logger: PluginLogger;
handlers?: Partial<Pick<OpenClawPluginApi,
| "registerTool" // 注册工具
| "registerHook" // 注册生命周期钩子
| "registerHttpRoute" // 注册 HTTP 路由
| "registerChannel" // 注册消息渠道
| "registerGatewayMethod" // 注册 Gateway 方法
| "registerCli" // 注册 CLI 命令
| "registerProvider" // 注册 Provider(模型/语音/图像等)
| "registerAgentHarness" // 注册外部 Agent Harness(ACP)
| "registerMcpServer" // 注册 MCP 服务器
| ...(共 30+ 项)
>>;
};
注意 handlers 的类型:Partial<Pick<OpenClawPluginApi, ...>>。这意味着插件只需实现自己关心的注册方法——一个纯 Provider 插件可能只提供 registerProvider,宿主会据此裁剪它实际拿到的 API 面,避免把无关能力暴露给插件。下表按能力域归纳了这 30+ 个注册方法。
| 能力域 | 注册方法 | 作用 |
|---|---|---|
| 模型与执行 | registerTool · registerProvider · registerMcpServer | 暴露工具、接入模型/语音/图像 provider、挂载 MCP server |
| 外部 Agent | registerAgentHarness |
把 Claude Code / Codex 等 ACP harness 接进来 |
| 消息与路由 | registerChannel · registerHttpRoute · registerGatewayMethod |
新增渠道、HTTP 端点、Gateway RPC 方法 |
| 生命周期与 CLI | registerHook · registerCli |
注入 start/stop/enable/disable 钩子、扩展 CLI 命令 |
handlers 是 Partial<Pick<...>>——插件只实现它关心的那几个注册方法,宿主据此收缩 API 面。这既限制了插件的能力边界,也让类型系统在编译期就能反映「这个插件能做什么」。Manifest 解析与缓存
每个插件都由一份 openclaw.plugin.json 描述。src/plugins/manifest.ts 负责解析这份清单,并用一套带失效跟踪的 SLRU 缓存避免重复读盘与重复解析。
export const PLUGIN_MANIFEST_FILENAME = "openclaw.plugin.json";
export const MAX_PLUGIN_MANIFEST_BYTES = 256 * 1024;
// manifest 解析缓存(SLRU,512 条目)
const pluginManifestLoadCache = new PluginLruCache<PluginManifestLoadCacheEntry>(
MAX_PLUGIN_MANIFEST_LOAD_CACHE_ENTRIES,
);
export function createPluginCacheKey(...): PluginCacheKey;
export function clearPluginManifestLoadCache(): void;
manifest 承载了插件的关键元数据:版本号、入口点、配置 schema、命令列表、对外暴露的 provider、以及生命周期 hook。解析结果会进入一个最多 512 条目的 SLRU 缓存(PluginLruCache);每条缓存项都记录源文件的 mtimeMs 与 ctimeMs,一旦文件被修改即自动失效并重解析。文件体积有 256 KiB 的硬上限(MAX_PLUGIN_MANIFEST_BYTES),防止异常清单拖垮加载。
文件名固定
约定文件名 openclaw.plugin.json,由 PLUGIN_MANIFEST_FILENAME 常量统一引用。
256 KiB 上限
MAX_PLUGIN_MANIFEST_BYTES = 256 * 1024,超限即拒绝解析。
SLRU · 512
解析结果进入 512 条目的 SLRU 缓存,复用读盘与解析成本。
mtime + ctime
每条缓存项跟踪 mtimeMs/ctimeMs,文件变更即失效。
createPluginCacheKey() 暴露缓存键的构造逻辑,clearPluginManifestLoadCache() 则用于在测试或热重载场景下整体清空缓存——它们是这套缓存对外的两个显式接缝。运行时协调器
src/plugins/runtime.ts 在 globalThis 上维护唯一的 RegistryState——一份进程级的全局注册表,所有插件注册进来的能力都汇聚于此,并由它协调热替换与回收。
// runtime.ts — 全局 RegistryState 保存在 globalThis 上
const state: RegistryState = (() => {
const globalState = globalThis as typeof globalThis & {
[PLUGIN_REGISTRY_STATE]?: RegistryState;
};
let registryState = globalState[PLUGIN_REGISTRY_STATE];
if (!registryState) {
registryState = {
activeRegistry: null,
activeVersion: 0,
httpRoute: { registry: null, pinned: false, version: 0 },
channel: { registry: null, pinned: false, version: 0 },
importedPluginIds: new Set<string>(),
runtimeSubagentMode: "default",
};
globalState[PLUGIN_REGISTRY_STATE] = registryState;
}
return registryState;
})();
function asPluginRegistry(registry: RegistryState["activeRegistry"]): PluginRegistry | null;
async function cleanupPreviousPluginHostRegistry(params: {...}): Promise<void>;
function isRegistryLive(registry: PluginRegistry): boolean;
function isRegistryPinned(registry: PluginRegistry): boolean;
关键设计是每进程一份全局注册表:状态挂在 globalThis[PLUGIN_REGISTRY_STATE] 上,即使模块被多次加载也只会有一份。在这份注册表之上有三个视角,它们指向同一份底层数据但各自带版本号:activeRegistry(配 activeVersion,每次热替换递增)、httpRoute 与 channel(各自带 pinned 与 version)。pinned 标记用来保护「正在被使用」的注册表不被回收。
activeRegistry 主视角
当前生效的注册表,配 activeVersion,每次热替换自增以标记代际。
httpRoute 路由视角
HTTP 路由维度的注册表视图,独立持有 pinned 与 version。
channel 渠道视角
消息渠道维度的注册表视图,同样独立追踪 pinned 与 version。
cleanupPreviousPluginHostRegistry() 在回收旧的宿主注册表前,会先用 isRegistryLive() 与 isRegistryPinned() 双重确认——被 pin 住(仍在服务请求)的注册表会被跳过,避免把还在用的能力拆掉。冷控制面与激活边界
深读源码时浮现出一条关键设计线——插件激活边界(plugin-activation-boundary):manifest 解析、模型目录、ID 归一化这些路径,刻意不导入插件运行时代码。
这意味着「选哪个模型」「列出有哪些能力」这类操作,可以在插件真正激活之前完成。冷路径不触碰 runtime,因此既快又稳定,还有一个额外好处:因为这些前置步骤不引入随插件激活而变化的状态,它们对提示缓存(prompt caching)友好——模型目录与归一化结果在激活前后保持一致,缓存命中不被打破。
而真正的「热路径」发生在激活时:runtime 的 registerApiProvider() 把每个 provider 的 API 类型字符串(如 anthropic-messages / openai-completions / google-generative-ai ……)连同它的 stream 函数一起登记进一张表。只有走到这一步,provider 才真正具备发起流式请求的能力。
冷路径 · 不触发激活
manifest 解析、模型目录、ID 归一化——均不导入插件 runtime,可在激活前运行,且对 prompt caching 友好。
热路径 · registerApiProvider
激活时 registerApiProvider() 注册 API 类型字符串与 stream 函数,provider 至此才能真正发流。
插件生态规模
把镜头拉远看数字:141 个扩展覆盖了从模型供应到消息渠道、记忆后端、基础设施的全部能力——而这一切都建立在前面那套统一的注册接口之上。
在这 141 个扩展中,约 68 个是 provider(模型/语音/图像供应方),约 20 个是 channel(消息渠道),其余为记忆后端、工具与基础设施类。供应侧有 30+ 家厂商,但它们最终收敛到约 10 个 API 家族;工具 schema 会按家族做归一化——例如 Gemini 会剥掉 pattern/minLength,OpenAI 则把工具包进 strict JSON。
| 类型 | 数量 | 说明 |
|---|---|---|
| Provider(模型/语音/图像) | ~68 | 30+ 厂商收敛到 ~10 个 API 家族,schema 按家族归一化 |
| Channel(消息渠道) | ~20 | 新增渠道经 registerChannel 接入 |
| 记忆 / 工具 / 基础设施 | 其余 | memory 后端、工具集与底层基础设施扩展 |
| 合计 | 141 | 分布在 16 个包中,全部经统一注册接口接入 |