Both Yonexus.Client and Yonexus.Server used module-level variables as hot-reload guards, which reset on every hot-reload (new VM context). Fix submodule pointers to the corrected plugin index.ts commits. Also add LESSONS_LEARNED.md and OPENCLAW_PLUGIN_DEV.md (copied from Dirigent) with three new lessons from this session (§11 connection-plugin hot-reload trap, §12 transport message routing race, §13 re-hello on session race) and updated plugin dev guide (§2.2 connection plugin entry pattern, §6 state table, §9 checklist, §10 cross-plugin globalThis API). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
16 KiB
OpenClaw 插件开发规范与流程
基于 Dirigent 插件的实际开发经验整理,适用于任何 OpenClaw 插件。
一、插件项目结构
proj-root/ # 插件项目根目录
plugin/ # 插件本体(安装时复制到 ~/.openclaw/plugins/<id>/)
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/只放纯逻辑,不 importopenclaw/plugin-sdk,便于单元测试- Hook 注册逻辑独立在
hooks/目录,不写在index.ts里
二、插件入口(index.ts)
2.1 Hook 型插件(常见场景)
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
// ── 全局生命周期保护 ──
const _G = globalThis as Record<string, unknown>;
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(服务端)。
const _G = globalThis as Record<string, unknown>;
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)
{
"$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。
// ── 去重 ──
const _DEDUP_KEY = "_myPluginBMRDedup";
if (!(_G[_DEDUP_KEY] instanceof WeakSet)) _G[_DEDUP_KEY] = new WeakSet<object>();
const dedup = _G[_DEDUP_KEY] as WeakSet<object>;
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 一轮对话结束后触发,用于推进状态、发送下一轮触发消息。
// ── 去重 ──
const _DEDUP_KEY = "_myPluginAgentEndDedup";
if (!(_G[_DEDUP_KEY] instanceof Set)) _G[_DEDUP_KEY] = new Set<string>();
const dedup = _G[_DEDUP_KEY] as Set<string>;
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 新消息时触发。
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 注册规范
// 无需 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 命名约定:
_<pluginId>PluginXxx # 内部状态,例如 _yonexusClientPluginStarted
__<pluginId> # 跨插件公共 API,例如 __yonexusClient
内部状态用单下划线前缀,跨插件 API 用双下划线前缀,防止和其他插件冲突。
七、安装脚本规范(scripts/install.mjs)
每个插件应提供标准安装脚本,支持 --install / --uninstall / --update。
install 做的事:
1. 构建 dist(复制 plugin/ 和 services/ 到 dist/)
2. 复制 dist 到 ~/.openclaw/plugins/<plugin-id>/
3. 安装 skills(支持合并已有 skill 数据)
4. 配置 plugins.entries.<id>.enabled = true
5. 设置默认 config 字段(setIfMissing,不覆盖已有值,不触碰敏感字段)
6. 添加到 plugins.allow 列表
7. 配置 model provider(如有 sidecar)
uninstall 做的事:
1. 从 plugins.allow 移除
2. 删除 plugins.entries.<id>
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 里声明
八、开发调试流程
日常开发循环
# 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 类型检查
make check # tsc --noEmit
make check-rules # 验证 rule fixture
make check-files # 验证必要文件存在
Sidecar smoke test
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)有
globalThisflag 保护 - 业务状态(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)
// 每次 register() 都更新暴露的对象(使 sendXxx 闭包始终指向最新 runtime)
// 但注意 registry / callbacks 用 globalThis 保证跨热重载稳定
const _G = globalThis as Record<string, unknown>;
// 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)
export default function register(_api) {
const provider = (globalThis as Record<string, unknown>)["__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 字段时:
- 先改
openclaw.plugin.json(schema 是 source of truth) - 改
plugin/index.ts中的PluginConfig类型和normalizeConfig() - 改安装脚本(
scripts/install.mjs)中的setIfMissing调用 - 更新
README.md中的 config 表格 - 如果是重命名,需要告知用户手动迁移现有
openclaw.json中的 config key
重命名示例(noReplyPort → sideCarPort):
# 用户侧迁移
openclaw config unset plugins.entries.dirigent.config.noReplyPort
openclaw config set plugins.entries.dirigent.config.sideCarPort 8787