# OpenClaw 插件开发规范与流程 > 基于 Dirigent 插件的实际开发经验整理,适用于任何 OpenClaw 插件。 --- ## 一、插件项目结构 ``` proj-root/ # 插件项目根目录 plugin/ # 插件本体(安装时复制到 ~/.openclaw/plugins//) index.ts # 插件入口,export default { id, name, register } openclaw.plugin.json # 插件 config schema 声明 package.json # name、version、type: module hooks/ before-model-resolve.ts agent-end.ts message-received.ts tools/ register-tools.ts commands/ my-command.ts core/ # 纯业务逻辑,不依赖 plugin-sdk,便于单元测试 my-store.ts web/ # HTTP 路由(可选) my-api.ts services/ # 插件管理的 sidecar 进程(随插件一起安装) main.mjs # sidecar 入口 sub-service/ index.mjs skills/ # 插件提供的 OpenClaw skill my-skill/ SKILL.md scripts/ # 安装、测试、开发辅助脚本 install.mjs # --install / --uninstall smoke-test.sh docs/ # 文档 IMPLEMENTATION.md dist/ # 构建产物(gitignore),install 脚本生成 ``` **约定**: - 文件名用 kebab-case,导出函数用 camelCase - `plugin/core/` 只放纯逻辑,不 import `openclaw/plugin-sdk`,便于单元测试 - Hook 注册逻辑独立在 `hooks/` 目录,不写在 `index.ts` 里 --- ## 二、插件入口(index.ts) ### 2.1 Hook 型插件(常见场景) ```typescript import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; // ── 全局生命周期保护 ── const _G = globalThis as Record; const LIFECYCLE_KEY = "_myPluginGatewayLifecycleRegistered"; export default { id: "my-plugin", name: "My Plugin", register(api: OpenClawPluginApi) { const config = normalizeConfig(api); // Gateway 生命周期:只注册一次 if (!_G[LIFECYCLE_KEY]) { _G[LIFECYCLE_KEY] = true; // 启动 sidecar、初始化全局资源等 api.on("gateway_stop", () => { /* 清理 */ }); } // Agent 会话 hook:每次 register() 都注册,event-level dedup 防重复处理 registerBeforeModelResolveHook({ api, config }); registerAgentEndHook({ api, config }); registerMessageReceivedHook({ api, config }); // Tools / Commands / Web registerMyTools(api, config); api.logger.info("my-plugin: registered"); }, }; ``` ### 2.2 连接型插件(管理持久 WebSocket / TCP 连接) 插件本身作为 WebSocket 客户端或服务端时,必须把**启动 flag、runtime 引用、所有共享状态**全部挂在 `globalThis`。模块级 `let _started = false` 在热重载后新 VM 上下文中重置,导致第二个连接被建立(客户端)或端口被二次 bind(服务端)。 ```typescript const _G = globalThis as Record; const STARTED_KEY = "_myConnPluginStarted"; const RUNTIME_KEY = "_myConnPluginRuntime"; const REGISTRY_KEY = "_myConnPluginRuleRegistry"; const CALLBACKS_KEY = "_myConnPluginOnReadyCallbacks"; export function createPlugin(api: { rootDir: string; pluginConfig: unknown }): void { // 每次 register() 都把最新的 registry / callbacks 挂到 globalThis, // 供其他插件通过 __myConnPlugin 引用 if (!(_G[REGISTRY_KEY] instanceof MyRuleRegistry)) { _G[REGISTRY_KEY] = createRuleRegistry(); } if (!Array.isArray(_G[CALLBACKS_KEY])) { _G[CALLBACKS_KEY] = []; } const registry = _G[REGISTRY_KEY] as MyRuleRegistry; const callbacks = _G[CALLBACKS_KEY] as Array<() => void>; // 暴露跨插件 API(每次都覆写,使 sendRule 等闭包捕获的 runtimeRef 是最新的) _G["__myConnPlugin"] = { registry, onReady: callbacks, sendMessage: (msg: string) => (_G[RUNTIME_KEY] as MyRuntime | undefined)?.send(msg) ?? false, }; // 只启动一次——不管热重载多少次 if (_G[STARTED_KEY]) return; _G[STARTED_KEY] = true; const runtime = createRuntime({ registry, onReady: (id) => callbacks.forEach(cb => cb()) }); _G[RUNTIME_KEY] = runtime; process.once("SIGTERM", () => runtime.stop().catch(console.error)); runtime.start().catch(console.error); } ``` **关键区别**: - `STARTED_KEY` 检查放在**最后**,在暴露 API 之后。这样热重载时 API 对象仍被更新(新模块的闭包),但 runtime 不会重复启动。 - `sendMessage` 闭包通过 `_G[RUNTIME_KEY]` 访问 runtime,不依赖模块级变量。 --- ## 三、Config Schema(openclaw.plugin.json) ```json { "$schema": "https://openclaw.ai/schemas/plugin-config.json", "configSchema": { "type": "object", "additionalProperties": false, "properties": { "myToken": { "type": "string" }, "myFlag": { "type": "boolean", "default": false }, "myPort": { "type": "number", "default": 9000 } } } } ``` **注意**: - `additionalProperties: false` 是强制的——OpenClaw 会用 schema 验证 config,多余字段报错 - 删除废弃字段时必须同步从 schema 里移除,否则旧 config 会导致 gateway 启动失败 - 敏感字段(token、key)不要设 `default`,让用户手动配置 --- ## 四、Hook 注册规范 ### 4.1 before_model_resolve **用途**:在模型调用前干预,可以覆盖 model/provider。 ```typescript // ── 去重 ── const _DEDUP_KEY = "_myPluginBMRDedup"; if (!(_G[_DEDUP_KEY] instanceof WeakSet)) _G[_DEDUP_KEY] = new WeakSet(); const dedup = _G[_DEDUP_KEY] as WeakSet; api.on("before_model_resolve", async (event, ctx) => { if (dedup.has(event as object)) return; dedup.add(event as object); const sessionKey = ctx.sessionKey; if (!sessionKey) return; // 返回 modelOverride 即覆盖,无返回值则不干预 return { modelOverride: "no-reply", providerOverride: "dirigent" }; }); ``` **规则**: - 必须有 WeakSet dedup(挂 globalThis) - 返回值是 `{ modelOverride, providerOverride }` 或 `undefined` - 异步操作(Discord API 调用等)尽量 try/catch,避免 unhandled rejection ### 4.2 agent_end **用途**:agent 一轮对话结束后触发,用于推进状态、发送下一轮触发消息。 ```typescript // ── 去重 ── const _DEDUP_KEY = "_myPluginAgentEndDedup"; if (!(_G[_DEDUP_KEY] instanceof Set)) _G[_DEDUP_KEY] = new Set(); const dedup = _G[_DEDUP_KEY] as Set; api.on("agent_end", async (event, ctx) => { const runId = (event as any).runId as string; if (runId) { if (dedup.has(runId)) return; dedup.add(runId); if (dedup.size > 500) dedup.delete(dedup.values().next().value!); } // 提取 agent 最终回复文本 const messages = (event as any).messages as unknown[] ?? []; const finalText = extractFinalText(messages); // 找最后一条 role=assistant 的文本 // ... }); ``` **规则**: - 用 runId + Set 去重(WeakSet 不适合,runId 是 string) - Set 要有上限淘汰(防内存泄漏) - 提取 finalText 要从 messages 数组末尾向前找 `role === "assistant"` ### 4.3 message_received **用途**:收到 Discord 新消息时触发。 ```typescript api.on("message_received", async (event, ctx) => { try { // channelId 提取逻辑(多个来源,兼容性处理) const channelId = extractChannelId(ctx, event); if (!channelId) return; // ... } catch (err) { api.logger.warn(`my-plugin: message_received error: ${err}`); } }); ``` --- ## 五、Tool 注册规范 ```typescript // 无需 ctx 的工具 api.registerTool({ name: "my-tool", description: "Does something", inputSchema: { type: "object", properties: { param: { type: "string", description: "..." }, }, required: ["param"], }, execute: async (toolCallId, params) => { const { param } = params as { param: string }; return { result: "ok" }; }, }); // 需要 ctx(agentId 等)的工具:工厂函数形式 api.registerTool((ctx) => ({ name: "my-contextual-tool", description: "...", inputSchema: { /* ... */ }, execute: async (toolCallId, params) => { const agentId = ctx.agentId; // ... return { result: agentId }; }, })); ``` **注意**:接口是 `execute: async (toolCallId, params)` 而不是 `handler:`。 --- ## 六、State 管理规范 | 数据类型 | 存放位置 | 原因 | |---|---|---| | 跨请求的业务状态(turn state 等) | `globalThis` | 热重载后模块变量重置 | | Event dedup Set/WeakSet | `globalThis` | 同上 | | 全局初始化 flag(gateway_start/stop) | `globalThis` | 防重复注册 | | 连接型插件:启动 flag | `globalThis` | 热重载后模块变量重置,否则重复建连 | | 连接型插件:runtime 引用 | `globalThis` | sendXxx 闭包需要访问仍在运行的实例 | | 连接型插件:rule registry / 回调数组 | `globalThis` | 热重载后需与 runtime 共享同一实例 | | 跨插件公共 API 对象(`__pluginId`) | `globalThis` | 其他插件通过 globalThis 访问 | | 无状态工具函数 | 模块级 | 无需持久化 | | 文件持久化数据(channel store 等) | 文件 + 内存缓存 | 需要跨 gateway 重启持久化 | **globalThis 命名约定**: ``` _PluginXxx # 内部状态,例如 _yonexusClientPluginStarted __ # 跨插件公共 API,例如 __yonexusClient ``` 内部状态用单下划线前缀,跨插件 API 用双下划线前缀,防止和其他插件冲突。 --- ## 七、安装脚本规范(scripts/install.mjs) 每个插件应提供标准安装脚本,支持 `--install` / `--uninstall` / `--update`。 ``` install 做的事: 1. 构建 dist(复制 plugin/ 和 services/ 到 dist/) 2. 复制 dist 到 ~/.openclaw/plugins// 3. 安装 skills(支持合并已有 skill 数据) 4. 配置 plugins.entries..enabled = true 5. 设置默认 config 字段(setIfMissing,不覆盖已有值,不触碰敏感字段) 6. 添加到 plugins.allow 列表 7. 配置 model provider(如有 sidecar) uninstall 做的事: 1. 从 plugins.allow 移除 2. 删除 plugins.entries. 3. 删除 plugins.load.paths 中的条目 4. 删除安装目录 5. 删除 skills ``` **关键细节**: - 安装前先 `fs.rmSync(distDir, { recursive: true })` 清空旧 dist,防止残留文件 - `setIfMissing`:只写入 undefined/null 的字段,不覆盖用户已设置的值 - 敏感字段(token、secret)**绝对不要**在安装脚本中 set,注释说明需手动配置 - schema 里有 `additionalProperties: false` 时,安装脚本写入的每个 config key 都必须在 schema 里声明 --- ## 八、开发调试流程 ### 日常开发循环 ```bash # 1. 修改代码(plugin/ 或 services/) # 2. 重新安装 node scripts/install.mjs --install # 3. 重启 gateway(必须!ChannelStore 等有文件缓存) openclaw gateway restart # 4. 观察日志 openclaw logs --follow # 或 tail -f /tmp/openclaw/openclaw-$(date +%F).log # 5. 发送测试消息验证 ``` ### 日志关键词速查 | 关键词 | 说明 | |---|---| | `plugin registered` | register() 执行完毕 | | `startSideCar called` / `already running` | sidecar 启动/已存在 | | `before_model_resolve anchor set` | 当前 speaker 正常走到模型调用 | | `before_model_resolve suppressing` | 非 speaker 被 suppress | | `agent_end skipping stale turn` | stale NO_REPLY 被正确过滤 | | `triggered next speaker` | 下一轮触发成功 | | `entered dormant` | channel 进入休眠 | | `moderator-callback woke dormant` | 休眠被外部消息唤醒 | | `must NOT have additional properties` | schema 与实际 config 不一致 | ### TypeScript 类型检查 ```bash make check # tsc --noEmit make check-rules # 验证 rule fixture make check-files # 验证必要文件存在 ``` ### Sidecar smoke test ```bash make smoke # 测试 no-reply API 是否正常响应 # 等价于: curl -s http://127.0.0.1:8787/no-reply/v1/chat/completions \ -X POST -H "Content-Type: application/json" \ -d '{"model":"no-reply","messages":[{"role":"user","content":"hi"}]}' ``` --- ## 九、常见陷阱 Checklist 在提 PR 或部署前,检查以下项目: **通用** - [ ] 所有 hook handler 有 event dedup(WeakSet for before_model_resolve,Set+runId for agent_end) - [ ] dedup 结构挂在 `globalThis`,不是模块级变量 - [ ] gateway 生命周期事件(gateway_start/stop)有 `globalThis` flag 保护 - [ ] 业务状态(Map/Set)挂在 `globalThis` - [ ] `openclaw.plugin.json` 里的 schema 与实际使用的 config 字段完全对齐 - [ ] 安装脚本没有 set 任何 schema 中不存在的 config 字段 - [ ] 敏感字段(token)不在安装脚本中 set,有注释说明手动配置方式 - [ ] 安装前有 `fs.rmSync(distDir)` 清理旧文件 - [ ] 新增 channel 后需要 `openclaw gateway restart`(文档或 CLI 提示) - [ ] Discord permission overwrite 用 `type: 1`(member),不是 `type: 0`(role) - [ ] Sidecar 有锁文件防重复启动 - [ ] `agent_end` 的 Set 有上限淘汰(`size > 500` 时删 oldest) **连接型插件(WebSocket / TCP)** - [ ] 启动 flag 用 `globalThis` 而非模块级 `let`,防热重载重复建连 - [ ] runtime 引用存 `globalThis`,send 相关闭包通过 `_G[RUNTIME_KEY]` 访问 - [ ] `ruleRegistry`、回调数组等共享对象存 `globalThis`,首次不存在时才初始化 - [ ] 跨插件 API 对象(`__pluginId`)**每次** `register()` 都覆写(更新闭包),但 runtime 只启动一次 - [ ] 消费方插件(注册进 registry 的插件)做好"provider 未加载"的防御判断 - [ ] `.env` 文件加入 `.gitignore`,提交 `.env.example` 作为模板 --- ## 十、跨插件 GlobalThis API 模式 当一个插件需要向同进程内的其他插件暴露功能(如 rule registry、send 接口、事件回调)时,使用 `globalThis.__pluginId` 约定。 ### 提供方(Provider) ```typescript // 每次 register() 都更新暴露的对象(使 sendXxx 闭包始终指向最新 runtime) // 但注意 registry / callbacks 用 globalThis 保证跨热重载稳定 const _G = globalThis as Record; // 1. 确保 registry 和 callbacks 只初始化一次 if (!(_G["_myPluginRegistry"] instanceof MyRegistry)) { _G["_myPluginRegistry"] = new MyRegistry(); } if (!Array.isArray(_G["_myPluginCallbacks"])) { _G["_myPluginCallbacks"] = []; } // 2. 覆写公共 API 对象(闭包捕获最新 runtime) _G["__myPlugin"] = { registry: _G["_myPluginRegistry"] as MyRegistry, onEvent: _G["_myPluginCallbacks"] as Array<(data: unknown) => void>, send: (msg: string): boolean => (_G["_myPluginRuntime"] as MyRuntime | undefined)?.send(msg) ?? false, }; ``` ### 消费方(Consumer) ```typescript export default function register(_api) { const provider = (globalThis as Record)["__myPlugin"]; if (!provider) { console.error("[my-consumer] __myPlugin not found — ensure provider loads first"); return; } // 注册 rule (provider as { registry: MyRegistry }).registry.registerRule("my_rule", handler); // 订阅事件 (provider as { onEvent: Array<() => void> }).onEvent.push(() => { // ... }); } ``` ### 加载顺序 `plugins.allow` 数组中 provider 必须排在 consumer **之前**,OpenClaw 按顺序加载插件。consumer 应在 `register()` 入口做 `if (!provider) return` 防御,避免 provider 未加载时崩溃。 --- ## 十一、Config 变更流程 当需要新增、重命名或删除 config 字段时: 1. **先改 `openclaw.plugin.json`**(schema 是 source of truth) 2. 改 `plugin/index.ts` 中的 `PluginConfig` 类型和 `normalizeConfig()` 3. 改安装脚本(`scripts/install.mjs`)中的 `setIfMissing` 调用 4. 更新 `README.md` 中的 config 表格 5. 如果是重命名,需要告知用户手动迁移现有 `openclaw.json` 中的 config key **重命名示例**(`noReplyPort` → `sideCarPort`): ```bash # 用户侧迁移 openclaw config unset plugins.entries.dirigent.config.noReplyPort openclaw config set plugins.entries.dirigent.config.sideCarPort 8787 ```