@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 +8 -8
- package/dist/group-routing.d.ts +25 -0
- package/dist/group-routing.js +49 -0
- package/dist/index.js +113 -73
- package/dist/provider.d.ts +2 -0
- package/dist/provider.js +45 -2
- package/docs/openclaw-sdk-notes.md +5 -1
- package/openclaw.plugin.json +43 -33
- package/package.json +6 -4
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
|
|
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
|
|
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
|
-
> ⚠️
|
|
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
|
-
> ⚠️
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
// shutdown 只注册一次(多次 registerFull 不叠加
|
|
128
|
-
//
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
});
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
141
181
|
});
|
|
142
182
|
}
|
|
143
183
|
}
|
package/dist/provider.d.ts
CHANGED
|
@@ -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: {
|
|
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`。需明确该字段的精确期望结构。
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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
|
-
"
|
|
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": "
|
|
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
|
-
"
|
|
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": "
|
|
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
|
+
"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.
|
|
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
|
}
|