@clawrent/openclaw-channel 0.3.7 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -133,11 +133,11 @@ See [guardrails.example.md](guardrails.example.md) for a full example. Recommend
133
133
 
134
134
  ## Enable / 启用
135
135
 
136
- 1. `openclaw plugins install @clawrent/openclaw-channel` (npm, recommended). For development, `openclaw plugins install --link <this dir>`.
136
+ 1. `openclaw plugins install clawhub:@clawrent/openclaw-channel` (ClawHub, primary registry) — or `openclaw plugins install @clawrent/openclaw-channel` (npm). For development, `openclaw plugins install --link <this dir>`.
137
137
  2. Configure two blocks in `~/.openclaw/openclaw.json`: `plugins.entries.clawrent.config` (read at runtime) + `channels.clawrent` (channel-instance detection), see the example above; put the token in `~/.clawrent/config.json`.
138
138
  3. Restart the Gateway → the plugin loads via **channel-triggered loading** (`onStartup:false`) → provider WS comes online → auto-accepts sessions.
139
139
 
140
- 1. `openclaw plugins install @clawrent/openclaw-channel`(npm,推荐)。开发可用 `openclaw plugins install --link <本目录>`。
140
+ 1. `openclaw plugins install clawhub:@clawrent/openclaw-channel`(ClawHub,主渠道)—— 或 `openclaw plugins install @clawrent/openclaw-channel`(npm)。开发可用 `openclaw plugins install --link <本目录>`。
141
141
  2. 在 `~/.openclaw/openclaw.json` 配置两块:`plugins.entries.clawrent.config`(plugin 运行时读)+ `channels.clawrent`(channel 实例检测),见上方示例;token 放 `~/.clawrent/config.json`。
142
142
  3. 重启 Gateway → plugin 走 **channel 触发式加载**(`onStartup:false`)→ provider WS 上线 → 自动接单。
143
143
 
@@ -145,9 +145,9 @@ See [guardrails.example.md](guardrails.example.md) for a full example. Recommend
145
145
  >
146
146
  > ⚠️ **不要加 `plugins.allow: ["clawrent"]`**。实测加 `allow` 会把加载路径切到严格模式,反而阻止 `registerFull` 执行(0.3.1 撞过)。保持 auto-load(无 allow)是 0.3.2 跑通的配置。
147
147
 
148
- > ⚠️ **`activation.onStartup` must be `false`** (this is the default; do not change it). OpenClaw 2026.7.1's startup validation does not recognize the export shape produced by `defineChannelPluginEntry` (reports `missing register/activate`); `onStartup:true` hits this loader bug and the plugin fails to load. `onStartup:false` takes the channel-triggered path and bypasses it (this is exactly why 0.1.0 worked before handoff; flipping it to `true` in 0.2.6–0.3.1 was the regression). After changing `onStartup` in the manifest you **must reinstall** (the install record caches the old onStartup snapshot; `registry --refresh` is not enough). See [docs/openclaw-sdk-notes.md](docs/openclaw-sdk-notes.md).
148
+ > ⚠️ **Keep `activation.onStartup` as `false`** (the default don't change it). It's the cross-version-safe choice: the channel-triggered load path (the gateway detects the `channels.clawrent` block and fires `registerFull`) works on every OpenClaw build. Historically 0.2.6–0.3.1 set this to `true` and hit an OpenClaw startup-validation loader bug that mis-reported `missing register/activate` for `defineChannelPluginEntry`'s export shape, so the plugin failed to load. **That loader bug is fixed upstream as of OpenClaw 2026.7.1** (`resolvePluginModuleExport` now normalizes default / module-wrapper exports, so `true` loads cleanly too) but there's no reason to switch: `false` works everywhere, while `true` would re-trip the bug for users still on pre-fix builds. After changing `onStartup` in the manifest you **must reinstall** (the install record caches the old onStartup snapshot; `registry --refresh` is not enough). See [docs/openclaw-sdk-notes.md](docs/openclaw-sdk-notes.md).
149
149
  >
150
- > ⚠️ **`activation.onStartup` 必须为 `false`**(默认即如此,勿改)。OpenClaw 2026.7.1 startup validation 不认 `defineChannelPluginEntry` 产出的导出形态(报 `missing register/activate`),`onStartup:true` 会撞这个 loader bug 导致 plugin 不加载。`onStartup:false` channel 触发式路径绕开它(这正是 0.1.0 移交前能跑的原因;0.2.6–0.3.1 改成 true 是回归元凶)。改 manifest 的 onStartup 后**必须 reinstall**(install record 缓存了旧 onStartup 快照,`registry --refresh` 不够)。详见 [docs/openclaw-sdk-notes.md](docs/openclaw-sdk-notes.md)。
150
+ > ⚠️ **保持 `activation.onStartup` `false`**(默认即如此,勿改)。这是跨版本安全的保守选择:channel 触发式加载路径(gateway 检测到 `channels.clawrent` 块即触发 `registerFull`)在所有 OpenClaw build 上都通。历史上 0.2.6–0.3.1 曾设为 `true`,撞上 OpenClaw startup validation loader bug(不认 `defineChannelPluginEntry` 产出的导出形态,误报 `missing register/activate`),导致 plugin 不加载。**该 loader bug 已在 OpenClaw 2026.7.1 上游修复**(`resolvePluginModuleExport` normalize default / module-wrapper 导出,`true` 现在也能干净加载)—— 但没必要换:`false` 全 build 通用,换 `true` 会让仍在修复前 build 的用户重新踩坑。改 manifest 的 onStartup 后**必须 reinstall**(install record 缓存了旧 onStartup 快照,`registry --refresh` 不够)。详见 [docs/openclaw-sdk-notes.md](docs/openclaw-sdk-notes.md)。
151
151
 
152
152
  > ⚠️ **`channels status --deep` may falsely report `not-running` / `disabled` when there is no `plugins.allow`** (an OpenClaw CLI display-layer bug, not a plugin issue). Whether the plugin is actually running should be judged by the gateway log `[clawrent] provider started` + the platform `onlineStatus`, **not** the status display.
153
153
  >
@@ -161,9 +161,9 @@ See [guardrails.example.md](guardrails.example.md) for a full example. Recommend
161
161
  >
162
162
  > manifest 的 `configSchema.required` 保持空数组:channel plugin 的 required 字段缺失会让 `openclaw` CLI 整体启动失败(config validation 阻断全局)。字段改为可选 + 运行时 warn。
163
163
 
164
- > 💡 **Health-monitor restart no longer causes oscillation (≥ 0.3.6)**: since `@clawrent/openclaw-channel@0.3.6`, the plugin guards against OpenClaw's per-channel health-monitor restart causing presence oscillation — if the health-monitor restarts the `clawrent` channel, the plugin's idempotent singleton stops the previous provider before starting a new one, so at most one ProviderClient holds `/ws/agent` at any time (no 4009 "Replaced by new connection" ping-pong). You do **not** need to disable the health-monitor for this. (An earlier version of this note suggested `channels.clawrent.healthMonitor.enabled: false` — that was **wrong**: the path is rejected by the plugin's strict channel schema (`additionalProperties:false`); the health-monitor is an OpenClaw gateway-core knob, not a plugin channel-config field. If you ever want to disable it for other reasons, look for the exact key in the OpenClaw gateway config docs.)
164
+ > 💡 **Health-monitor restart no longer causes oscillation (≥ 0.3.7)**: since `@clawrent/openclaw-channel@0.3.7`, the plugin guards against OpenClaw's per-channel health-monitor restart causing presence oscillation — if the health-monitor restarts the `clawrent` channel, the plugin's idempotent singleton stops the previous provider before starting a new one, so at most one ProviderClient holds `/ws/agent` at any time (no 4009 "Replaced by new connection" ping-pong). You do **not** need to disable the health-monitor for this. (An earlier version of this note suggested `channels.clawrent.healthMonitor.enabled: false` — that was **wrong**: the path is rejected by the plugin's strict channel schema (`additionalProperties:false`); the health-monitor is an OpenClaw gateway-core knob, not a plugin channel-config field. If you ever want to disable it for other reasons, look for the exact key in the OpenClaw gateway config docs.)
165
165
  >
166
- > 💡 **health-monitor 重启不再造成振荡(≥ 0.3.6)**:`@clawrent/openclaw-channel@0.3.6` 起,插件已防 OpenClaw per-channel health-monitor 重启导致的 presence 振荡 —— health-monitor 重启 `clawrent` channel 时,插件的幂等单例会先停上一个 provider 再起新的,任何时刻只有一个 ProviderClient 连 `/ws/agent`(不会 4009「Replaced by new connection」乒乓)。**无需为此关闭 health-monitor**。(本说明早先版本建议设 `channels.clawrent.healthMonitor.enabled: false` —— **那是错的**:该路径被插件 strict channel schema(`additionalProperties:false`)拒绝;health-monitor 是 OpenClaw gateway 核心旋钮,不是插件 channel-config 字段。若因别的原因想关,请到 OpenClaw gateway 配置文档查准确的键名。)
166
+ > 💡 **health-monitor 重启不再造成振荡(≥ 0.3.7)**:`@clawrent/openclaw-channel@0.3.7` 起,插件已防 OpenClaw per-channel health-monitor 重启导致的 presence 振荡 —— health-monitor 重启 `clawrent` channel 时,插件的幂等单例会先停上一个 provider 再起新的,任何时刻只有一个 ProviderClient 连 `/ws/agent`(不会 4009「Replaced by new connection」乒乓)。**无需为此关闭 health-monitor**。(本说明早先版本建议设 `channels.clawrent.healthMonitor.enabled: false` —— **那是错的**:该路径被插件 strict channel schema(`additionalProperties:false`)拒绝;health-monitor 是 OpenClaw gateway 核心旋钮,不是插件 channel-config 字段。若因别的原因想关,请到 OpenClaw gateway 配置文档查准确的键名。)
167
167
  >
168
168
  > ⚠️ **One provider per agent token**: the idempotent guard above only prevents **in-process** double-load (OpenClaw calling `registerFull` twice, or a health-monitor restart overlap). It **cannot** prevent two **separate processes** from using the same agent token — e.g. running this OpenClaw plugin **and** an MCP `clawrent_start_serving` on the same token, or two OpenClaw gateways. Two processes are invisible to each other and will 4009-kick each other into a stable oscillation (presence reconnecting every 1–2s, messages dropped in the gaps). Run exactly one provider per agent token.
169
169
  >
@@ -213,11 +213,11 @@ While the provider agent generates a reply, it sends a `dialogue.typing` control
213
213
 
214
214
  provider agent 驱动回复时,向 consumer 发送 `dialogue.typing` 控制信号,consumer 侧显示「provider 正在输入」,填补 provider 生成回复前的 UX 空窗。
215
215
 
216
- - **Depends on** `@clawrent/provider@^0.1.1` (`ProviderClient.sendTyping`) + ClawRent backend typing short-circuit (`dialogue.typing` is short-circuited before `validateMessage`, not persisted, not metered).
216
+ - **Depends on** `@clawrent/provider@^0.2.0` (`ProviderClient.sendTyping`) + ClawRent backend typing short-circuit (`dialogue.typing` is short-circuited before `validateMessage`, not persisted, not metered).
217
217
  - **Trigger**: fires once at `runDispatch` entry, then heartbeats every **800ms** during generation (the SDK has a built-in 500ms/session debounce; the 800ms interval ensures each heartbeat actually sends); `clearInterval` stops when the reply is sent or dispatch errors.
218
218
  - **WS-only**: `sendTyping` only goes over WS. Silently returns `false` if WS is not connected, without affecting the reply main path (`client.send` still does WS+REST fallback). REST `POST /messages` persists messages, so it is **not** used for typing.
219
219
  - The consumer-side typing indicator typically fades ~3s after the last typing signal; an 800ms heartbeat keeps it alive, and it is naturally replaced when the reply arrives.
220
- - **依赖** `@clawrent/provider@^0.1.1`(`ProviderClient.sendTyping`)+ ClawRent 后端 typing 短路(`dialogue.typing` 在 `validateMessage` 之前短路转发,不持久化、不计 metering)。
220
+ - **依赖** `@clawrent/provider@^0.2.0`(`ProviderClient.sendTyping`)+ ClawRent 后端 typing 短路(`dialogue.typing` 在 `validateMessage` 之前短路转发,不持久化、不计 metering)。
221
221
  - **触发**:`runDispatch` 入口立即发一次,生成期间每 **800ms** 心跳重发(SDK 内置 500ms/session 防抖,800ms 间隔保证每次都真发);回复发出或 dispatch 出错即 `clearInterval` 停止。
222
222
  - **WS-only**:`sendTyping` 只走 WS。WS 未连接时静默返回 `false`,不影响回复主路(`client.send` 仍走 WS+REST fallback)。REST `POST /messages` 会持久化消息,**不**用于 typing。
223
223
  - consumer 侧 typing 指示器通常在收到最后一条 typing 后约 3s 消失;800ms 心跳足以保活,回复到达后自然替换。
@@ -0,0 +1,25 @@
1
+ /** @-routing policy (spec §6.3 + Plan 4b-2.2).
2
+ *
3
+ * Decides whether THIS provider agent should wake (drive the OpenClaw bot) for an
4
+ * inbound group message:
5
+ * - No `mentions` → wake. Backward-compatible with the single-agent /ws/session
6
+ * plugin (where every consumer message is answered). Also the sensible default
7
+ * when a session has a single provider agent.
8
+ * - `mentions` present → wake ONLY if this agent's participantId is in the list.
9
+ * Non-@-ed agents stay silent (spec §6.3). If our participantId is unknown
10
+ * (handshake not yet cached), we cannot match → sleep (safer than guessing).
11
+ *
12
+ * Note: in legacy /ws/session mode there is no top-level `mentions` and
13
+ * `myParticipantId` is undefined → always wakes (unchanged behavior). */
14
+ export declare function shouldWakeAgent(myParticipantId: string | undefined, message: Record<string, unknown>): boolean;
15
+ /** Reduce a `model.usage` diagnostic `usage` object to a single token total.
16
+ * Preference: `total` → `promptTokens + output` → `input + output`. 0 if none. */
17
+ export declare function computeUsageTotal(usage: {
18
+ total?: number;
19
+ promptTokens?: number;
20
+ input?: number;
21
+ output?: number;
22
+ } | undefined | null): number;
23
+ /** Map a clawrent routeSessionKey (`<channel>:<sessionId>`) back to the sessionId.
24
+ * Returns null if the key is missing or not for this channel. */
25
+ export declare function sessionIdFromKey(sessionKey: string | undefined, channel?: string): string | null;
@@ -0,0 +1,49 @@
1
+ // group-routing.ts — Plan 4b pure helpers for /ws/group participant-scoped mode.
2
+ //
3
+ // Kept dependency-free + side-effect-free so the @-routing and token-usage logic
4
+ // can be unit-tested without standing up an OpenClaw runtime or a live WS server.
5
+ /** @-routing policy (spec §6.3 + Plan 4b-2.2).
6
+ *
7
+ * Decides whether THIS provider agent should wake (drive the OpenClaw bot) for an
8
+ * inbound group message:
9
+ * - No `mentions` → wake. Backward-compatible with the single-agent /ws/session
10
+ * plugin (where every consumer message is answered). Also the sensible default
11
+ * when a session has a single provider agent.
12
+ * - `mentions` present → wake ONLY if this agent's participantId is in the list.
13
+ * Non-@-ed agents stay silent (spec §6.3). If our participantId is unknown
14
+ * (handshake not yet cached), we cannot match → sleep (safer than guessing).
15
+ *
16
+ * Note: in legacy /ws/session mode there is no top-level `mentions` and
17
+ * `myParticipantId` is undefined → always wakes (unchanged behavior). */
18
+ export function shouldWakeAgent(myParticipantId, message) {
19
+ const raw = message["mentions"];
20
+ const mentions = Array.isArray(raw) ? raw : [];
21
+ if (mentions.length === 0)
22
+ return true;
23
+ if (typeof myParticipantId !== "string")
24
+ return false;
25
+ return mentions.includes(myParticipantId);
26
+ }
27
+ /** Reduce a `model.usage` diagnostic `usage` object to a single token total.
28
+ * Preference: `total` → `promptTokens + output` → `input + output`. 0 if none. */
29
+ export function computeUsageTotal(usage) {
30
+ if (!usage)
31
+ return 0;
32
+ if (typeof usage.total === "number")
33
+ return usage.total;
34
+ const output = typeof usage.output === "number" ? usage.output : 0;
35
+ // Discriminate on whether promptTokens is present (not on output>0, which would
36
+ // wrongly short-circuit the input+output fallback when only output is reported).
37
+ if (typeof usage.promptTokens === "number")
38
+ return usage.promptTokens + output;
39
+ const input = typeof usage.input === "number" ? usage.input : 0;
40
+ return input + output;
41
+ }
42
+ /** Map a clawrent routeSessionKey (`<channel>:<sessionId>`) back to the sessionId.
43
+ * Returns null if the key is missing or not for this channel. */
44
+ export function sessionIdFromKey(sessionKey, channel = "clawrent") {
45
+ if (typeof sessionKey !== "string")
46
+ return null;
47
+ const prefix = `${channel}:`;
48
+ return sessionKey.startsWith(prefix) ? sessionKey.slice(prefix.length) : null;
49
+ }
package/dist/index.js CHANGED
@@ -9,25 +9,27 @@ import { startProvider } from "./provider.js";
9
9
  import { testConnection } from "./setup/setup.js";
10
10
  const CHANNEL_ID = "clawrent";
11
11
  /**
12
- * 模块级 provider 单例:同一进程内确保任何时刻只有一个 ProviderClient /ws/agent。
12
+ * 模块级 provider 注册表:每个 agentId 一条串行化 chain + 一个 ProviderHandle,
13
+ * 确保同一 agent 任何时刻只有一个 ProviderClient 连 /ws/agent(或 /ws/group)。
13
14
  *
14
15
  * 背景:若 registerFull 被调用多次(OpenClaw 双加载 / startup 与 channel 双触发 /
15
16
  * health-monitor restart 叠加),而每次都 `void startProvider(...)`,会叠加多个
16
- * ProviderClient 用同一 agentToken /ws/agent。后端 registerAgentClient 的语义是
17
+ * ProviderClient 用同一 agentToken 连。后端 registerAgentClient 的语义是
17
18
  * 「新连接 4009 踢旧连接」,provider 侧 4009 走「非终态 → 无限重连」—— 两个实例互相
18
19
  * 踢、互相重连,形成稳态乒乓振荡(后端日志指纹:connected → ~350ms disconnected →
19
20
  * 2~3s connected 循环;provider 侧表现为 `presence reconnecting in 1000ms` 高频循环)。
20
21
  * 后果:presence 立不住 + reconnect 间隙漏接会话消息。
21
22
  *
22
- * 解法:用 chain 把所有 startProvider 串行化 —— 先干净停掉上一个实例(若有),再起新
23
- * 实例。无论 registerFull 被调几次,任何时刻只有一个 ProviderClient 占着 /ws/agent,
24
- * 4009 互踢消失。单实例下 provider 0.2.1+ 的自愈本就稳定(heartbeat 25s < 后端 40s 阈值,
25
- * 不会被 heartbeat 断)。注意:这只能防「同进程内」双实例;若用户在同 token 上同时跑
26
- * plugin 进程 + 另一个 provider(MCP start_serving / 第二个 OpenClaw 网关),仍会跨进程
27
- * 互踢 —— 那是部署规范(同一 agent 同时只能一个 provider serve)。
23
+ * 解法:按 agentId 分链(chain Map),每条链把该 agent 的 startProvider 串行化 ——
24
+ * 先干净停掉该 agent 的上一个实例(若有),再起新实例。无论 registerFull 调几次,
25
+ * 每个 agentId 任何时刻只有一个 ProviderClient 占着连接,4009 互踢消失。
26
+ * 不同 agent 持不同 agentToken,彼此不会 4009 互踢(可安全共驻,spec §6.4)。
27
+ * 单实例下 provider 0.3.0+ 的自愈稳定(heartbeat 25s < 后端 40s 阈值)
28
+ * 注意:这只能防「同进程内」同 agent 双实例;跨进程( token plugin + MCP
29
+ * start_serving / 第二个网关)仍会互踢 —— 那是部署规范(同一 agent 同时只一个 provider serve)。
28
30
  */
29
- let providerChain = Promise.resolve();
30
- let activeProvider = null;
31
+ const providerChains = new Map();
32
+ const activeProviders = new Map();
31
33
  let shutdownRegistered = false;
32
34
  // Minimal setup adapter required by createChannelPluginBase.
33
35
  const setupAdapter = {
@@ -58,86 +60,124 @@ const entry = defineChannelPluginEntry({
58
60
  registerFull(ctx) {
59
61
  try {
60
62
  const config = ctx.config ?? {};
61
- // token 优先取 openclaw 配置;缺失时回退读 ~/.clawrent/config.json,避免密钥写入 openclaw.json。
62
- let token = config.token;
63
- if (!token) {
64
- try {
65
- const crPath = path.join(homedir(), ".clawrent", "config.json");
66
- let raw = readFileSync(crPath, "utf8");
67
- if (raw.charCodeAt(0) === 0xfeff)
68
- raw = raw.slice(1); // strip BOM
69
- const cr = JSON.parse(raw);
70
- token = cr.token ?? cr.agentToken;
63
+ // Plan 4b: /ws/group participant-scoped channel is the default. Set false to
64
+ // fall back to legacy /ws/session (e.g. before the server auto-provisions the
65
+ // provider-agent participant — Plan 4b Part A — else new sessions reject 4013).
66
+ const useGroupChannel = config.useGroupChannel ?? true;
67
+ // --- resolve the agents to serve (multi-agent array, else single token) ---
68
+ // multi: config.agents:[{agentId,token}] → one ProviderClient per agent.
69
+ // single: config.token (+ optional config.agentId), with ~/.clawrent/config.json
70
+ // fallback so secrets need not live in openclaw.json.
71
+ const resolveSingleToken = () => {
72
+ let token = config.token;
73
+ if (!token) {
74
+ try {
75
+ const crPath = path.join(homedir(), ".clawrent", "config.json");
76
+ let raw = readFileSync(crPath, "utf8");
77
+ if (raw.charCodeAt(0) === 0xfeff)
78
+ raw = raw.slice(1); // strip BOM
79
+ const cr = JSON.parse(raw);
80
+ token = cr.token ?? cr.agentToken;
81
+ }
82
+ catch (e) {
83
+ ctx.logger?.warn?.(`[clawrent] token fallback read failed: ${e?.message ?? e}`);
84
+ }
71
85
  }
72
- catch (e) {
73
- ctx.logger?.warn?.(`[clawrent] token fallback read failed: ${e?.message ?? e}`);
86
+ return token;
87
+ };
88
+ let entries;
89
+ if (Array.isArray(config.agents) && config.agents.length > 0) {
90
+ entries = config.agents
91
+ .filter((a) => a && typeof a.token === "string" && a.token)
92
+ .map((a) => ({
93
+ agentId: typeof a.agentId === "string" ? a.agentId : undefined,
94
+ token: a.token,
95
+ }));
96
+ if (entries.length === 0) {
97
+ ctx.logger?.warn?.("[clawrent] agents array configured but has no valid entries; channel inactive");
98
+ return;
74
99
  }
75
100
  }
76
- if (!token) {
77
- ctx.logger?.warn?.("[clawrent] no token configured; channel inactive");
78
- return;
101
+ else {
102
+ const token = resolveSingleToken();
103
+ if (!token) {
104
+ ctx.logger?.warn?.("[clawrent] no token configured; channel inactive");
105
+ return;
106
+ }
107
+ entries = [{ agentId: config.agentId, token }];
79
108
  }
80
109
  const apiBaseUrl = config.apiBaseUrl;
81
110
  const wsUrl = config.wsUrl;
82
- const agentId = config.agentId;
83
111
  const autoApprove = config.autoApproveSessions ?? true;
84
112
  const guardrailsFile = config.guardrailsFile;
85
- const accountId = agentId ?? "clawrent-provider";
86
- const cursorPath = config.cursorPath ??
87
- path.join(homedir(), ".clawrent", "openclaw-provider-cursor.json");
113
+ const baseCursorPath = config.cursorPath ?? path.join(homedir(), ".clawrent", "openclaw-provider-cursor.json");
88
114
  const runtime = ctx.runtime;
89
115
  const cfg = typeof runtime?.config?.current === "function"
90
116
  ? runtime.config.current()
91
117
  : runtime?.cfg ?? {};
92
- // 串行化:先停上一个 provider 实例(幂等保护,防 registerFull 多次调用叠加多个
93
- // ProviderClient 4009 乒乓),再起新实例。任何时刻只有一个 ProviderClient
94
- // /ws/agent(见模块级 providerChain 注释)。chain 排队也消除了「startProvider 异步
95
- // 启动期间第二次 registerClean 进入」的竞态(那时 activeProvider 尚未赋值)。
96
- const start = () => startProvider({
97
- agentToken: token,
98
- apiBaseUrl,
99
- wsUrl,
100
- cursorPath,
101
- agentId,
102
- autoApprove,
103
- guardrailsFile,
104
- channelId: CHANNEL_ID,
105
- accountId,
106
- cfg,
107
- deps: {
108
- runChannelInboundEvent,
109
- recordInboundSession: recordInboundSession,
110
- dispatchReplyWithBufferedBlockDispatcher: dispatchReplyWithBufferedBlockDispatcher,
111
- },
112
- onLog: (m) => ctx.logger?.info?.(`[clawrent] ${m}`),
113
- }).catch((e) => {
114
- ctx.logger?.error?.(`[clawrent] startProvider failed: ${String(e)}`);
115
- return null;
116
- });
117
- providerChain = providerChain.then(async () => {
118
- if (activeProvider) {
119
- try {
120
- await activeProvider.stop();
118
+ // (Re)start one ProviderClient per agent, each on its OWN per-agentId chain so the
119
+ // same agent never has two concurrent clients (4009 protection; see module doc).
120
+ // Different agents use different tokens and never 4009 each other (spec §6.4).
121
+ for (const entry of entries) {
122
+ const agentKey = entry.agentId ?? "default";
123
+ // per-agent cursor file when serving multiple agents (independent dedupe state)
124
+ const cursorPath = entries.length > 1 ? `${baseCursorPath}.${agentKey}` : baseCursorPath;
125
+ const accountId = entry.agentId ?? "clawrent-provider";
126
+ const start = () => startProvider({
127
+ agentToken: entry.token,
128
+ apiBaseUrl,
129
+ wsUrl,
130
+ cursorPath,
131
+ agentId: entry.agentId,
132
+ autoApprove,
133
+ guardrailsFile,
134
+ channelId: CHANNEL_ID,
135
+ accountId,
136
+ cfg,
137
+ deps: {
138
+ runChannelInboundEvent,
139
+ recordInboundSession: recordInboundSession,
140
+ dispatchReplyWithBufferedBlockDispatcher: dispatchReplyWithBufferedBlockDispatcher,
141
+ },
142
+ onLog: (m) => ctx.logger?.info?.(`[clawrent] ${m}`),
143
+ useGroupChannel,
144
+ }).catch((e) => {
145
+ ctx.logger?.error?.(`[clawrent] startProvider failed (agent=${agentKey}): ${String(e)}`);
146
+ return null;
147
+ });
148
+ const prevChain = providerChains.get(agentKey) ?? Promise.resolve();
149
+ providerChains.set(agentKey, prevChain.then(async () => {
150
+ const prev = activeProviders.get(agentKey);
151
+ if (prev) {
152
+ try {
153
+ await prev.stop();
154
+ }
155
+ catch { }
156
+ activeProviders.delete(agentKey);
121
157
  }
122
- catch { }
123
- activeProvider = null;
124
- }
125
- activeProvider = await start();
126
- });
127
- // shutdown 只注册一次(多次 registerFull 不叠加 shutdown 回调);回调本身停当前
128
- // activeProvider 并入队 chain,保证停的是最终存活的那个实例。
158
+ const handle = await start();
159
+ if (handle)
160
+ activeProviders.set(agentKey, handle);
161
+ }));
162
+ }
163
+ // shutdown 只注册一次(多次 registerFull 不叠加);停掉所有 active provider,每个走自己的 chain,
164
+ // 保证停的是最终存活的实例。
129
165
  if (!shutdownRegistered) {
130
166
  shutdownRegistered = true;
131
167
  ctx.registerShutdown?.(() => {
132
- providerChain = providerChain.then(async () => {
133
- if (activeProvider) {
134
- try {
135
- await activeProvider.stop();
168
+ for (const key of Array.from(providerChains.keys())) {
169
+ const prev = providerChains.get(key) ?? Promise.resolve();
170
+ providerChains.set(key, prev.then(async () => {
171
+ const h = activeProviders.get(key);
172
+ if (h) {
173
+ try {
174
+ await h.stop();
175
+ }
176
+ catch { }
177
+ activeProviders.delete(key);
136
178
  }
137
- catch { }
138
- activeProvider = null;
139
- }
140
- });
179
+ }));
180
+ }
141
181
  });
142
182
  }
143
183
  }
@@ -16,6 +16,8 @@ export interface StartProviderOptions {
16
16
  cfg: any;
17
17
  deps: ProviderRuntimeDeps;
18
18
  onLog?: (msg: string) => void;
19
+ /** Plan 4b: opt into /ws/group (participant-scoped) instead of /ws/session. */
20
+ useGroupChannel: boolean;
19
21
  }
20
22
  export interface ProviderHandle {
21
23
  stop: () => Promise<void> | void;
package/dist/provider.js CHANGED
@@ -17,6 +17,8 @@
17
17
  // - dispatchReplyWithBufferedBlockDispatcher ← openclaw/plugin-sdk/reply-dispatch-runtime
18
18
  // - runChannelInboundEvent ← openclaw/plugin-sdk/channel-inbound
19
19
  import { checkGuardrails, loadGuardrails } from "./guardrails.js";
20
+ import { shouldWakeAgent, computeUsageTotal, sessionIdFromKey } from "./group-routing.js";
21
+ import { onDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
20
22
  const CHANNEL = "clawrent";
21
23
  /**
22
24
  * 从原始 ClawRent WS 帧提取对话文本。仅 dialogue.* 类型进入 agent;
@@ -49,6 +51,7 @@ export async function startProvider(opts) {
49
51
  ...(opts.wsUrl ? { wsUrl: opts.wsUrl } : {}),
50
52
  cursorStore: new FileCursorStore(opts.cursorPath),
51
53
  autoApprove: opts.autoApprove,
54
+ useGroupChannel: opts.useGroupChannel,
52
55
  });
53
56
  // Surface presence self-heal events (SDK EventEmitter). These replace the dead
54
57
  // onDisconnect/onError args previously passed to client.start() — ProviderCallbacks
@@ -57,6 +60,28 @@ export async function startProvider(opts) {
57
60
  client.on('agent:activated', () => log(`presence activated (online)`));
58
61
  client.on('agent:activation:failed', (_aid, err) => log(`presence activation FAILED (terminal): ${err?.message ?? err}`));
59
62
  client.on('agent:dead', (_aid, reason) => log(`presence DEAD — terminal close, will not reconnect: ${reason}`));
63
+ // Phase 3: pause/resume observability (ProviderClient 0.4.0 emits these).
64
+ // Pause = consumer paused this agent (4020 close, no reconnect); resume = consumer
65
+ // resumed → ProviderClient reconnects /ws/group. The agent simply goes idle while
66
+ // paused (no inbound messages arrive) — no extra plugin-side gating needed.
67
+ client.on('session:paused', (sid, reason) => log(`session PAUSED sid=${sid} (consumer paused this agent; will not reconnect until resumed): ${reason}`));
68
+ client.on('session:resumed', (sid) => log(`session RESUMED sid=${sid} — reconnecting /ws/group`));
69
+ // Plan 4b-2.3: capture per-session model token usage from OpenClaw diagnostics so
70
+ // outbound replies can carry payload.usage.totalTokens for per_token metering.
71
+ // Best-effort — if OpenClaw doesn't tag model.usage with a clawrent sessionKey the
72
+ // cache stays empty and totalTokens is omitted (backend skips → backward compatible).
73
+ const usageBySession = new Map();
74
+ const unsubDiagnostic = onDiagnosticEvent((evt) => {
75
+ if (evt.type !== 'model.usage')
76
+ return;
77
+ const sid = sessionIdFromKey(evt.sessionKey) ??
78
+ (evt.channel === CHANNEL ? evt.sessionId : undefined);
79
+ if (typeof sid !== 'string')
80
+ return;
81
+ const total = computeUsageTotal(evt.usage);
82
+ if (total > 0)
83
+ usageBySession.set(sid, total);
84
+ });
60
85
  await client.start({
61
86
  agentId: opts.agentId,
62
87
  onPendingApproval: async (session) => {
@@ -94,6 +119,12 @@ export async function startProvider(opts) {
94
119
  log(`skip empty content session=${sessionId}`);
95
120
  return;
96
121
  }
122
+ // Plan 4b-2.2: @-routing — only wake when this agent is addressed (spec §6.3).
123
+ // /ws/session mode (no mentions, no participantId) always wakes (unchanged).
124
+ if (!shouldWakeAgent(session?.participantId, message)) {
125
+ log(`skip not-@-ed session=${sessionId} (mentions do not include this agent)`);
126
+ return;
127
+ }
97
128
  // 护栏:危险指令直接拦截,不驱动 agent。
98
129
  const guard = checkGuardrails(content);
99
130
  if (guard.blocked) {
@@ -163,11 +194,17 @@ export async function startProvider(opts) {
163
194
  const replyText = payload?.text ?? payload?.content ?? "";
164
195
  if (!replyText.trim())
165
196
  return;
197
+ // Plan 4b-2.3: attach captured per_token usage (if any) so the
198
+ // backend accumulates participant.totalTokens for this reply.
199
+ const capturedUsage = usageBySession.get(sessionId);
166
200
  await client.send(sessionId, {
167
201
  type: "dialogue.message",
168
- payload: { content: replyText },
202
+ payload: {
203
+ content: replyText,
204
+ ...(capturedUsage ? { usage: { totalTokens: capturedUsage } } : {}),
205
+ },
169
206
  });
170
- log(`replied session=${sessionId} (${replyText.length} chars)`);
207
+ log(`replied session=${sessionId} (${replyText.length} chars${capturedUsage ? `, ${capturedUsage} tokens` : ""})`);
171
208
  },
172
209
  // 模型跑失败时,把错误回发给租户(不静默),便于人工发现。
173
210
  onError: async (err) => {
@@ -200,6 +237,12 @@ export async function startProvider(opts) {
200
237
  log(`provider started (agentId=${opts.agentId ?? "auto"} autoApprove=${opts.autoApprove})`);
201
238
  return {
202
239
  stop: () => {
240
+ try {
241
+ unsubDiagnostic();
242
+ }
243
+ catch {
244
+ /* diagnostic unsubscribe best-effort */
245
+ }
203
246
  try {
204
247
  client.stop();
205
248
  }
@@ -18,7 +18,7 @@
18
18
  | `defineSetupPluginEntry` | 未强调 | 存在于 `channel-core`,供独立 setup-entry 使用 |
19
19
  | manifest `configSchema.required` | 示例多为可选 | 若把字段标 `required` 且 `plugins.entries.<id>.config` 未填,**整个 openclaw CLI 启动失败**(config validation 阻断全局)。应改为可选 + 运行时 warn |
20
20
  | `plugins install --link` | 开发期 link | 会尝试改写 `openclaw.json` 增加 `plugins.entries.<id>`;若写入导致 size-drop 过大,OpenClaw 拒绝写入并生成 `.rejected` 备份(安全机制,主文件不破坏) |
21
- | install 入口校验 | — | 报 `missing register/activate export` 与 `channelConfigs` 警告,即使使用 `defineChannelPluginEntry` 默认导出。install 校验与 defineChannelPluginEntry 约定存在偏差 |
21
+ | install 入口校验 | — | 报 `missing register/activate export` 与 `channelConfigs` 警告,即使使用 `defineChannelPluginEntry` 默认导出。install 校验与 defineChannelPluginEntry 约定存在偏差(**2026.7.1 已修,见下方问题清单 #5**) |
22
22
 
23
23
  ---
24
24
 
@@ -41,6 +41,10 @@
41
41
  5. **`plugins install --link` 的入口校验与 `defineChannelPluginEntry` 不一致**:
42
42
  install 报 `missing register/activate export`,但 `defineChannelPluginEntry` 的
43
43
  返回值已含 `register`。建议统一 install 校验逻辑,或文档说明入口文件的导出约定。
44
+ **✅ 已修复(OpenClaw 2026.7.1)**:`resolvePluginModuleExport`(loader.ts:295)已
45
+ normalize default / module-wrapper 导出,接受 register/activate;PinkBo 在 2026.7.1-2
46
+ 复测 `onStartup:true` + `defineChannelPluginEntry`(含纯 default-export 变体)干净加载、
47
+ 不复现(openclaw/openclaw#111344 关闭为 "already implemented")。
44
48
  6. **`channelConfigs` manifest 字段文档不足**:install 警告
45
49
  "channel plugin manifest declares clawrent without channelConfigs metadata",
46
50
  但 manifest 已声明 `channelConfigs`。需明确该字段的精确期望结构。
@@ -2,7 +2,7 @@
2
2
  "id": "clawrent",
3
3
  "name": "ClawRent Channel",
4
4
  "description": "OpenClaw channel plugin that turns ClawRent rental sessions into native OpenClaw conversations, so a local ClawRent provider agent can answer tenants autonomously with its own model and identity. / OpenClaw 频道插件:把 ClawRent 租赁会话桥接成 OpenClaw 原生对话,让本地 ClawRent provider 智能体用自有模型与身份自动应答租户。",
5
- "version": "0.3.7",
5
+ "version": "0.4.0",
6
6
  "channels": ["clawrent"],
7
7
  "activation": {
8
8
  "onStartup": false,
@@ -35,16 +35,6 @@
35
35
  "type": "string",
36
36
  "title": "ClawRent agent id"
37
37
  },
38
- "pollIntervalMs": {
39
- "type": "integer",
40
- "title": "Poll interval (ms)",
41
- "minimum": 0,
42
- "maximum": 9007199254740991
43
- },
44
- "autoAnswer": {
45
- "type": "boolean",
46
- "title": "Auto-answer rental sessions"
47
- },
48
38
  "autoApproveSessions": {
49
39
  "type": "boolean",
50
40
  "title": "Auto-approve incoming sessions"
@@ -53,14 +43,27 @@
53
43
  "type": "string",
54
44
  "title": "Path to guardrails markdown"
55
45
  },
56
- "personaFiles": {
57
- "type": "array",
58
- "title": "Persona files to load into context",
59
- "items": { "type": "string" }
60
- },
61
- "scope": {
46
+ "cursorPath": {
62
47
  "type": "string",
63
- "title": "Workspace scope label for tenant isolation"
48
+ "title": "Message cursor storage path"
49
+ },
50
+ "useGroupChannel": {
51
+ "type": "boolean",
52
+ "title": "Use /ws/group participant-scoped channel (Plan 4b)",
53
+ "description": "true (default) = connect /ws/group (participant-scoped, @-routing, per_token usage). false = legacy /ws/session (fallback). Requires the ClawRent server to auto-provision the provider-agent participant (Plan 4b Part A) — else new sessions reject with 4013."
54
+ },
55
+ "agents": {
56
+ "type": "array",
57
+ "title": "Multiple provider agents (multi-agent mode)",
58
+ "description": "Serve multiple ClawRent provider agents from one gateway. Each entry starts an independent ProviderClient. Omit to serve a single agent via `token`.",
59
+ "items": {
60
+ "type": "object",
61
+ "properties": {
62
+ "agentId": { "type": "string" },
63
+ "token": { "type": "string" }
64
+ },
65
+ "required": ["agentId", "token"]
66
+ }
64
67
  }
65
68
  }
66
69
  },
@@ -102,18 +105,14 @@
102
105
  "type": "string",
103
106
  "title": "ClawRent API base URL"
104
107
  },
108
+ "wsUrl": {
109
+ "type": "string",
110
+ "title": "ClawRent WebSocket URL"
111
+ },
105
112
  "agentId": {
106
113
  "type": "string",
107
114
  "title": "ClawRent agent id"
108
115
  },
109
- "pollIntervalMs": {
110
- "type": "integer",
111
- "title": "Poll interval (ms)"
112
- },
113
- "autoAnswer": {
114
- "type": "boolean",
115
- "title": "Auto-answer rental sessions"
116
- },
117
116
  "autoApproveSessions": {
118
117
  "type": "boolean",
119
118
  "title": "Auto-approve incoming sessions"
@@ -122,14 +121,25 @@
122
121
  "type": "string",
123
122
  "title": "Path to guardrails markdown"
124
123
  },
125
- "personaFiles": {
126
- "type": "array",
127
- "items": { "type": "string" },
128
- "title": "Persona files to load into context"
129
- },
130
- "scope": {
124
+ "cursorPath": {
131
125
  "type": "string",
132
- "title": "Workspace scope label for tenant isolation"
126
+ "title": "Message cursor storage path"
127
+ },
128
+ "useGroupChannel": {
129
+ "type": "boolean",
130
+ "title": "Use /ws/group participant-scoped channel (Plan 4b)"
131
+ },
132
+ "agents": {
133
+ "type": "array",
134
+ "title": "Multiple provider agents (multi-agent mode)",
135
+ "items": {
136
+ "type": "object",
137
+ "properties": {
138
+ "agentId": { "type": "string" },
139
+ "token": { "type": "string" }
140
+ },
141
+ "required": ["agentId", "token"]
142
+ }
133
143
  }
134
144
  },
135
145
  "required": []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawrent/openclaw-channel",
3
- "version": "0.3.7",
3
+ "version": "0.5.0",
4
4
  "description": "OpenClaw channel plugin that turns ClawRent rental sessions into native OpenClaw conversations, so a local ClawRent provider agent can answer tenants autonomously with its own model and identity. / OpenClaw 频道插件:把 ClawRent 租赁会话桥接成 OpenClaw 原生对话,让本地 ClawRent provider 智能体用自有模型与身份自动应答租户。",
5
5
  "license": "ISC",
6
6
  "type": "module",
@@ -51,12 +51,13 @@
51
51
  }
52
52
  },
53
53
  "scripts": {
54
- "build": "tsc -p tsconfig.json",
54
+ "build": "tsc -p tsconfig.build.json",
55
55
  "typecheck": "tsc -p tsconfig.json --noEmit",
56
+ "test": "vitest run",
56
57
  "prepublishOnly": "npm run build"
57
58
  },
58
59
  "dependencies": {
59
- "@clawrent/provider": "^0.2.0"
60
+ "@clawrent/provider": "^0.4.0"
60
61
  },
61
62
  "peerDependencies": {
62
63
  "openclaw": ">=2026.6.11"
@@ -69,6 +70,7 @@
69
70
  "devDependencies": {
70
71
  "openclaw": "2026.7.1",
71
72
  "@types/node": "^20",
72
- "typescript": "*"
73
+ "typescript": "*",
74
+ "vitest": "^3.0.0"
73
75
  }
74
76
  }