Files
Yonexus/OPENCLAW_PLUGIN_DEV.md
hzhang 7be370bd3e fix: move plugin startup guards and shared state to globalThis; update docs
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>
2026-04-10 20:41:40 +01:00

16 KiB
Raw Permalink Blame History

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/                           # 构建产物gitignoreinstall 脚本生成

约定

  • 文件名用 kebab-case导出函数用 camelCase
  • plugin/core/ 只放纯逻辑,不 import openclaw/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 Schemaopenclaw.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" };
  },
});

// 需要 ctxagentId 等)的工具:工厂函数形式
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 同上
全局初始化 flaggateway_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 dedupWeakSet for before_model_resolveSet+runId for agent_end
  • dedup 结构挂在 globalThis,不是模块级变量
  • gateway 生命周期事件gateway_start/stopglobalThis 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: 1member不是 type: 0role
  • Sidecar 有锁文件防重复启动
  • agent_end 的 Set 有上限淘汰(size > 500 时删 oldest

连接型插件WebSocket / TCP

  • 启动 flag 用 globalThis 而非模块级 let,防热重载重复建连
  • runtime 引用存 globalThissend 相关闭包通过 _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 字段时:

  1. 先改 openclaw.plugin.jsonschema 是 source of truth
  2. plugin/index.ts 中的 PluginConfig 类型和 normalizeConfig()
  3. 改安装脚本(scripts/install.mjs)中的 setIfMissing 调用
  4. 更新 README.md 中的 config 表格
  5. 如果是重命名,需要告知用户手动迁移现有 openclaw.json 中的 config key

重命名示例noReplyPortsideCarPort

# 用户侧迁移
openclaw config unset plugins.entries.dirigent.config.noReplyPort
openclaw config set plugins.entries.dirigent.config.sideCarPort 8787