From 7be370bd3e0cd345018f8da9788e77b10b10cd2c Mon Sep 17 00:00:00 2001 From: hzhang Date: Fri, 10 Apr 2026 20:41:40 +0100 Subject: [PATCH] fix: move plugin startup guards and shared state to globalThis; update docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- LESSONS_LEARNED.md | 323 ++++++++++++++++++++++++++++ OPENCLAW_PLUGIN_DEV.md | 478 +++++++++++++++++++++++++++++++++++++++++ Yonexus.Client | 2 +- Yonexus.Server | 2 +- 4 files changed, 803 insertions(+), 2 deletions(-) create mode 100644 LESSONS_LEARNED.md create mode 100644 OPENCLAW_PLUGIN_DEV.md diff --git a/LESSONS_LEARNED.md b/LESSONS_LEARNED.md new file mode 100644 index 0000000..115c20d --- /dev/null +++ b/LESSONS_LEARNED.md @@ -0,0 +1,323 @@ +# OpenClaw 插件开发经验教训 + +> 记录插件开发过程中踩过的坑,供后续迭代参考。最初源自 Dirigent,后续经验来自 Yonexus。 + +--- + +## 1. OpenClaw 热重载与模块状态 + +**问题**:OpenClaw 每次热重载(hot-reload)会把插件模块放入新的 VM 隔离上下文,模块级变量全部重置。 + +```typescript +// ❌ 错误:热重载后 Map 被清空,turn 状态丢失 +const channelStates = new Map(); +``` + +**解法**:把需要跨热重载持久化的状态挂在 `globalThis` 上。 + +```typescript +// ✅ 正确:globalThis 绑定在 Node.js 进程层面,热重载不影响 +function channelStates(): Map { + if (!(_G._tmChannelStates instanceof Map)) + _G._tmChannelStates = new Map(); + return _G._tmChannelStates as Map; +} +``` + +**规则**: +- 业务状态(turn state、speaker list、pending turns)→ `globalThis` +- 热重载内部的临时变量(局部锁、dedup set)→ `globalThis`(理由同上) +- 无状态工具函数 → 普通模块变量即可 + +--- + +## 2. Hook 事件重复触发(Event Deduplication) + +**问题**:OpenClaw 热重载会把新的 handler 叠加在旧的 handler 上,同一事件(如 `agent_end`、`before_model_resolve`)被多个 handler 实例处理,导致: +- Turn 被推进两次 +- Speaker 被重复 suppress +- Schedule trigger 重复发送 + +**解法**:用挂在 `globalThis` 上的 `WeakSet`(事件对象)或 `Set`(runId)做去重。 + +```typescript +// before_model_resolve:事件对象去重(WeakSet 自动 GC) +const processed = new WeakSet(); +api.on("before_model_resolve", async (event) => { + if (processed.has(event as object)) return; + processed.add(event as object); + // ... +}); + +// agent_end:runId 去重(Set + 上限淘汰) +const processedRunIds = new Set(); +api.on("agent_end", async (event) => { + const runId = (event as any).runId; + if (processedRunIds.has(runId)) return; + processedRunIds.add(runId); + if (processedRunIds.size > 500) { + processedRunIds.delete(processedRunIds.values().next().value); + } + // ... +}); +``` + +**规则**:所有 hook handler 必须有去重逻辑,dedup 结构本身也要挂在 `globalThis`。 + +--- + +## 3. Gateway 生命周期事件与 Agent 会话事件的区别 + +**问题**:`gateway_start` / `gateway_stop` 是全局事件,只触发一次。但 `register()` 每次热重载都会被调用,导致 `gateway_start` handler 被重复注册,sidecar 被重复启动。 + +**解法**:用 `globalThis` flag 保证只注册一次。 + +```typescript +const _G = globalThis as Record; +const LIFECYCLE_KEY = "_dirigentGatewayLifecycleRegistered"; + +if (!_G[LIFECYCLE_KEY]) { + _G[LIFECYCLE_KEY] = true; + startSideCar(...); + api.on("gateway_stop", () => stopSideCar(...)); +} +``` + +**规则**: +- `gateway_start` / `gateway_stop` handler → `globalThis` flag 保护 +- `before_model_resolve` / `agent_end` / `message_received` → 每次 `register()` 都注册,但靠 event dedup 防止重复处理 + +--- + +## 4. ChannelStore 文件缓存陷阱 + +**问题**:`ChannelStore` 懒加载文件(第一次读后设 `loaded=true` 不再重读)。如果在 gateway 运行期间直接编辑 `dirigent-channels.json`,已存在的 `ChannelStore` 实例不会感知变化,`getMode()` 对新增 channel 返回 `"none"`,导致 turn management 完全失效(before_model_resolve 看到 `mode === "none"` 直接 return,不做任何 suppress)。 + +**现象**:新 channel 里所有 agent 同时响应,日志里没有任何 `before_model_resolve` 的 suppressing 或 anchor set 日志。 + +**解法(当前)**:编辑 `dirigent-channels.json` 后必须 `openclaw gateway restart`。 + +**更好的长期方案**:`ChannelStore` 应该在 `setMode()`/`setLockedMode()` 时通知所有实例,或改用 `fs.watch()` 监听文件变化,或每次 `getMode()` 都从文件读(对 read 频率低的场景可以接受)。 + +--- + +## 5. Discord 权限 Overwrite 的 type 字段 + +**问题**:设置 channel permission overwrite 时,`type` 字段含义: +- `type: 0` → 针对 **role**(角色) +- `type: 1` → 针对 **member**(成员/用户) + +将 bot 用户 ID 作为 member overwrite 时必须用 `type: 1`,用 `type: 0` 会返回错误或静默失败(Discord 会把 ID 当 role 处理)。 + +```typescript +// ✅ 正确 +{ id: botUserId, type: 1, allow: "68608", deny: "0" } +``` + +**常用 permission bitmask**: +- VIEW_CHANNEL = 1024 (1 << 10) +- SEND_MESSAGES = 2048 (1 << 11) +- READ_MESSAGE_HISTORY = 65536 (1 << 16) +- 三者合计 = 68608 + +--- + +## 6. AgentTool 的 execute API(非 handler) + +**问题**:OpenClaw Plugin SDK 要求 tool 使用 `execute: async (toolCallId, params) => {}` 接口,不是 `handler:`。如果需要 `ctx.agentId`,要使用工厂函数形式。 + +```typescript +// ✅ 正确 +api.registerTool({ + name: "my-tool", + // ...schema... + execute: async (toolCallId, params) => { + // toolCallId 是 string,params 是入参对象 + return { result: "ok" }; + }, +}); + +// ✅ 需要 agentId 时 +api.registerTool((ctx) => ({ + name: "my-tool", + execute: async (toolCallId, params) => { + const agentId = ctx.agentId; + // ... + }, +})); +``` + +--- + +## 7. Sidecar 锁文件防重复启动 + +**问题**:gateway 重启或热重载时 `startSideCar()` 可能被多次调用,导致多个 sidecar 进程竞争同一端口。 + +**解法**:写 lock 文件(`/tmp/dirigent-sidecar.lock`),启动前检查文件是否存在且对应进程仍在运行。 + +```typescript +const lockFile = "/tmp/dirigent-sidecar.lock"; +if (fs.existsSync(lockFile)) { + const pid = Number(fs.readFileSync(lockFile, "utf8").trim()); + if (isProcessAlive(pid)) { + logger.info("sidecar already running, skipping"); + return; + } +} +// 启动 sidecar,写 lock file +``` + +--- + +## 8. 并发 advanceSpeaker 竞争 + +**问题**:两个 VM 上下文的 `agent_end` handler 可能同时执行,两者都通过了 runId 去重(runId 不同),都调用 `advanceSpeaker`,导致 speaker index 被推进两次。 + +**解法**:在 `advanceSpeaker` 入口加 per-channel 锁(`Set` 挂在 `globalThis`)。 + +```typescript +if (advancingChannels.has(channelId)) return; // 已有并发调用,跳过 +advancingChannels.add(channelId); +try { + await advanceSpeaker(...); +} finally { + advancingChannels.delete(channelId); +} +``` + +--- + +## 9. isTurnPending 的生命周期边界 + +**问题**:`clearTurnPending` 的位置影响正确性: +- 太早(在 `advanceSpeaker` 前清除)→ 下一个 wakeup 可能被误判为合法 turn,在 cycle boundary 期间 index 尚未更新导致 speaker 错误 +- 太晚无问题,但在 `pollForTailMatch` 期间必须保持 `isTurnPending=true`,否则 re-trigger 会被当作合法 turn 重入 + +**正确位置**:`advanceSpeaker` 完成后、`triggerNextSpeaker` 前。 + +--- + +## 10. Discord Gateway 重连后的消息丢失 + +**问题**:Gateway 重启后,bot 重新连接 Discord WS 有延迟(10–30s)。如果在 bot 完全连接前就发送 schedule trigger(`<@bot_id>➡️`),bot 会错过该消息(WS 不推送历史消息)。 + +**现象**:发送了 trigger,channel 里能看到消息,但 bot 没有响应。 + +**解法**: +1. Gateway 重启后等待所有 bot 的 `discord client initialized` 日志出现再发种子消息 +2. 或手动补发 trigger + +**长期方案**:sidecar 可以暴露一个 `/status` 接口,等待所有 Discord 账号连接就绪后再允许外部发消息。 + +--- + +## 11. 连接型插件的热重载陷阱(Yonexus) + +**问题**:Yonexus.Client / Yonexus.Server 是"连接型插件"——插件本身管理一条持久 WebSocket 连接(或监听端口)。如果用模块级变量做启动防重复保护: + +```typescript +// ❌ 错误:热重载后新 VM 上下文重置,_started = false → 第二个 runtime 被创建 +let _started = false; +export function createPlugin(api) { + if (_started) return; + _started = true; + const runtime = createRuntime(...); + runtime.start(); +} +``` + +热重载后: +- **服务端**:第二个 runtime 尝试 bind 同一端口 → EADDRINUSE → `runtime.start()` 抛出 → 被 `.catch` 静默吞掉,但 `globalThis.__yonexusServer` 已被覆盖为指向新的(未启动的)transport → `sendRule()` 永远返回 false +- **客户端**:第二个 runtime 成功建立了新的 WebSocket 连接,与旧连接并存,产生重复认证 + +**解法**: +```typescript +// ✅ 正确:用 globalThis 保护,热重载后新 VM 上下文也能看到 flag +const _G = globalThis as Record; +const STARTED_KEY = "_yonexusClientStarted"; + +export function createPlugin(api) { + if (_G[STARTED_KEY]) { + // 热重载时更新 __yonexusClient 指向仍在运行的旧 runtime(存在 globalThis 上) + // 无需重新启动 + return; + } + _G[STARTED_KEY] = true; + // ... 创建并启动 runtime +} +``` + +如果需要让热重载后新注册的 hook/rule 生效,还需把 `ruleRegistry`、`onXxxCallbacks` 等也存到 `globalThis`,而不是在函数体内每次新建。 + +**规则**: +- 任何管理持久连接/监听端口的插件,其启动 flag 必须放 `globalThis` +- 相关的 registry、回调数组也应放 `globalThis`,否则热重载后 `__pluginId` API 对象被覆盖,旧 runtime 的回调数组失去引用 + +--- + +## 12. WebSocket 服务端 Transport 的消息路由竞态(Yonexus) + +**问题**:Server transport 在 `ws.on("message")` 里通过 identifier 查 `_connections` 得到 `ClientConnection`: + +```typescript +// ❌ 危险:当 ws_new 还在 tempConnections,但 _connections["test-client"] 指向即将关闭的 ws_old 时 +const connection = identifier ? this._connections.get(identifier) ?? tempConn : tempConn; +``` + +**场景**: +1. `ws_old`(外部测试脚本)已认证,`_connections["test-client"] = ws_old` +2. `ws_new`(插件重连)发 hello → 进入 tempConnections,assignedIdentifier = "test-client" +3. 插件发 `auth_request` → message handler 查 `_connections.get("test-client")` → 返回 ws_old +4. `promoteToAuthenticated("test-client", ws_old)` → ws_old 不在 tempConnections → 返回 false +5. `onClientAuthenticated` 仍然触发 → `_connections.get("test-client")` = ws_old(已关闭)→ `sendRule` 返回 false + +**解法**:消息路由时,如果发送方 `ws` 仍在 `tempConnections`,直接用 `tempConn`(持有正确 ws 引用的本地对象),**不再** fallback 到 `_connections`: + +```typescript +// ✅ 正确:按 ws 引用路由,不按 identifier 路由 +if (this.tempConnections.has(ws)) { + this.options.onMessage(tempConn, message); + return; +} +// ws 已 promote,从 _connections 中找 +let connection = tempConn; +for (const [, conn] of this._connections) { + if (conn.ws === ws) { connection = conn; break; } +} +this.options.onMessage(connection, message); +``` + +**附加修复**:`promoteToAuthenticated` 的返回值不应被忽略。只有 promote 成功时才触发 `onClientAuthenticated`: + +```typescript +const promoted = transport.promoteToAuthenticated(identifier, connection.ws); +if (promoted) { + options.onClientAuthenticated?.(identifier); +} +``` + +**规则**:WebSocket 服务端的消息路由应始终以**发送方的 ws 对象引用**为准,不以 identifier 查映射表。identifier 可能在 tempConnections 和 _connections 之间的过渡期产生歧义。 + +--- + +## 13. 服务端 Session 竞态 → 客户端 re-hello 恢复(Yonexus) + +**问题**:服务端在已认证连接关闭时(`onDisconnect`)删除对应的 session。如果另一个客户端连接(同 identifier)的 `auth_request` 恰好在 session 被删除之后到达,服务端返回 `auth_failed("not_paired")`,即使客户端持有有效 secret。 + +**场景**: +1. 测试脚本 ws_1 已认证 → session["test-client"] 存在 +2. 插件 ws_2 发送 hello → session["test-client"] 被覆写(socket = ws_2) +3. 测试脚本 ws_1 关闭 → `handleDisconnect("test-client")` → `sessions.delete("test-client")` +4. 插件 ws_2 发 `auth_request` → session 不存在 → `auth_failed("not_paired")` +5. 插件有 secret,但 `auth_required` 状态没有 re-hello 逻辑 → 永远卡住 + +**解法**:客户端收到 `auth_failed("not_paired")` 且持有有效 secret 时,重新发送 hello 以在服务端创建新 session,然后重试认证: + +```typescript +if (payload.reason === "not_paired" && hasClientSecret(this.clientState)) { + this.sendHello(); // 重建 session,触发 hello_ack("auth_required") → sendAuthRequest() + return; +} +``` + +**规则**:客户端凡是遇到"自己有凭据但服务端找不到 session"的错误,都应尝试重走 hello 流程,而不是直接进入 `auth_required` 等待用户干预。 diff --git a/OPENCLAW_PLUGIN_DEV.md b/OPENCLAW_PLUGIN_DEV.md new file mode 100644 index 0000000..17d0830 --- /dev/null +++ b/OPENCLAW_PLUGIN_DEV.md @@ -0,0 +1,478 @@ +# 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 +``` diff --git a/Yonexus.Client b/Yonexus.Client index 8824e76..4adb187 160000 --- a/Yonexus.Client +++ b/Yonexus.Client @@ -1 +1 @@ -Subproject commit 8824e768fb511508888c0a96071f9f25d11df58f +Subproject commit 4adb1873311a15ccf74c1789cc2b8e51273e8e14 diff --git a/Yonexus.Server b/Yonexus.Server index 59d5b26..07c670c 160000 --- a/Yonexus.Server +++ b/Yonexus.Server @@ -1 +1 @@ -Subproject commit 59d5b26aff7cd5659c665991d3fc243dd8e4e324 +Subproject commit 07c670c27271feddaeb69b1ce5eb80c7cc9602a0