@clawrent/openclaw-channel 0.6.0 → 0.7.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
@@ -135,11 +135,11 @@ See [guardrails.example.md](guardrails.example.md) for a full example. Recommend
135
135
 
136
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
- 3. Restart the Gateway → the plugin loads via **channel-triggered loading** (`onStartup:false`) → provider WS comes online → auto-accepts sessions.
138
+ 3. Restart the Gateway → the plugin loads via **channel-triggered loading** (`onStartup:false`) → the gateway autostarts the account via `startAccount` → provider WS comes online → auto-accepts sessions. With `channels.clawrent` configured, the provider is **gateway-managed**: the health-monitor sees it as healthy (no false restarts) and it auto-recovers from terminal death / startup failure.
139
139
 
140
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
- 3. 重启 Gateway → plugin 走 **channel 触发式加载**(`onStartup:false`)→ provider WS 上线 → 自动接单。
142
+ 3. 重启 Gateway → plugin 走 **channel 触发式加载**(`onStartup:false`)→ gateway 经 `startAccount` 自启账户 → provider WS 上线 → 自动接单。配了 `channels.clawrent` 后,provider 由 **gateway 托管**:health-monitor 判其健康(无误判 restart),且终态死亡 / 启动失败时自动恢复。
143
143
 
144
144
  > ⚠️ **Do NOT add `plugins.allow: ["clawrent"]`.** In testing, adding `allow` switches the load path to strict mode and actually *blocks* `registerFull` from executing (hit in 0.3.1). Staying in auto-load (no `allow`) is the config that works in 0.3.2.
145
145
  >
@@ -149,6 +149,10 @@ See [guardrails.example.md](guardrails.example.md) for a full example. Recommend
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
+ > ℹ️ **(0.7.0) Startup `missing register/activate` — the real cause was `setupEntry`.** Through 0.6.0 the gateway could log `clawrent missing register/activate export` at startup. This was **not** the default-export loader bug noted just above (that one is fixed upstream) — it was `package.json`'s `openclaw.setupEntry` pointing at `dist/setup/setup.js`: in setup-only plans the loader loaded `setup.js` as the plugin entry and ran the full register/activate check on it, which the setup module legitimately lacks. 0.7.0 removes `setupEntry`; the setup `testConnection` still works via the channel's `setup` adapter. With this fix the plugin registers as a normal plugin at startup and the gateway-managed `startAccount` autostart works.
153
+ >
154
+ > ℹ️ **(0.7.0)启动报 `missing register/activate` —— 真因是 `setupEntry`。** 直到 0.6.0,gateway 启动时可能报 `clawrent missing register/activate export`。这**不是**上一条说的 default-export loader bug(那条上游已修)—— 而是 `package.json` 的 `openclaw.setupEntry` 指向 `dist/setup/setup.js`:setup-only plan 下 loader 把 `setup.js` 当 plugin 入口加载并跑完整 register/activate 校验,而 setup 模块本就没有这些导出。0.7.0 删除 `setupEntry`;setup 的 `testConnection` 仍经 channel 的 `setup` adapter 可用。修复后 plugin 启动时正常注册,gateway 托管的 `startAccount` 自启才得以工作。
155
+
152
156
  > ⚠️ **`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
157
  >
154
158
  > ⚠️ **`channels status --deep` 在无 `plugins.allow` 模式下可能误报 `not-running` / `disabled`**(OpenClaw CLI 显示层 bug,非 plugin 问题)。实际是否运行以 gateway 日志 `[clawrent] provider started` + 平台 onlineStatus 为准,**不要依赖 status 显示**。
@@ -161,9 +165,9 @@ See [guardrails.example.md](guardrails.example.md) for a full example. Recommend
161
165
  >
162
166
  > manifest 的 `configSchema.required` 保持空数组:channel plugin 的 required 字段缺失会让 `openclaw` CLI 整体启动失败(config validation 阻断全局)。字段改为可选 + 运行时 warn。
163
167
 
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.)
168
+ > 💡 **Gateway-managed provider lifecycle (≥ 0.7.0)**: since `@clawrent/openclaw-channel@0.7.0`, the plugin implements OpenClaw's `gateway.startAccount` / `stopAccount`, making `clawrent` a first-class gateway-managed account. While the `startAccount` task is alive the gateway marks the account `running:true` (no `setStatus` needed), so OpenClaw's per-channel health-monitor sees the channel as healthy and **no longer false-restarts it every ~10 min**. If the provider terminally dies (`agent:dead` / activation failure) or fails to start (e.g. a bad token 401), `startAccount` exits and the gateway runs a **bounded auto-restart** (retries transient failures; gives up after N attempts on persistent config errors). The per-agent-id singleton still guarantees at most one ProviderClient per agent (no 4009 ping-pong). This requires the `channels.clawrent` block to be configured without it the plugin falls back to legacy `registerFull` startup, which has no gateway health/restart integration. You do **not** need `gateway.channelHealthCheckMinutes: 0`. (Pre-0.7.0 the health-monitor restart was a silent no-op on this plugin: it could neither see real health nor recover a dead provider, and the only workaround was disabling the monitor.)
165
169
  >
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 配置文档查准确的键名。)
170
+ > 💡 **gateway 托管的 provider 生命周期(≥ 0.7.0)**:`@clawrent/openclaw-channel@0.7.0` 起实现 OpenClaw 的 `gateway.startAccount` / `stopAccount`,`clawrent` 成为 gateway 托管的一等账户。`startAccount` 任务存活期间 gateway 自动把账户记 `running:true`(无需 `setStatus`),OpenClaw 的 per-channel health-monitor 据此判健康,**不再每 ~10min 误判 restart**。provider 终态死亡(`agent:dead` / 激活失败)或启动失败(如坏 token → 401)时 `startAccount` 退出,gateway **bounded auto-restart**(瞬态故障重试;持久配置错误给足次数后放弃)。per-agent-id 单例仍保证每 agent 至多一个 ProviderClient(无 4009 乒乓)。**需配置 `channels.clawrent` 块** —— 缺失则走 legacy `registerFull` 启动,无 gateway 健康/重启集成。**无需 `gateway.channelHealthCheckMinutes: 0`**。(0.7.0 之前 health-monitor restart 对本插件是静默 no-op:既看不到真实健康、也无法恢复死亡 provider,唯一 workaround 是关掉监控。)
167
171
  >
168
172
  > ⚠️ **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
173
  >
package/dist/index.js CHANGED
@@ -12,18 +12,18 @@ const CHANNEL_ID = "clawrent";
12
12
  * 模块级 provider 注册表:每个 agentId 一条串行化 chain + 一个 ProviderHandle,
13
13
  * 确保同一 agent 任何时刻只有一个 ProviderClient 连 /ws/agent(或 /ws/group)。
14
14
  *
15
- * 背景:若 registerFull 被调用多次(OpenClaw 双加载 / startup 与 channel 双触发 /
16
- * health-monitor restart 叠加),而每次都 `void startProvider(...)`,会叠加多个
17
- * ProviderClient 用同一 agentToken 连。后端 registerAgentClient 的语义是
15
+ * 背景:若同一 agent 的 startProvider 被并发调用多次(OpenClaw 双加载 / startup 与
16
+ * channel 双触发 / health-monitor restart 叠加),每次都 `void startProvider(...)` 会
17
+ * 叠加多个 ProviderClient 用同一 agentToken 连。后端 registerAgentClient 的语义是
18
18
  * 「新连接 4009 踢旧连接」,provider 侧 4009 走「非终态 → 无限重连」—— 两个实例互相
19
19
  * 踢、互相重连,形成稳态乒乓振荡(后端日志指纹:connected → ~350ms disconnected →
20
20
  * 2~3s connected 循环;provider 侧表现为 `presence reconnecting in 1000ms` 高频循环)。
21
21
  * 后果:presence 立不住 + reconnect 间隙漏接会话消息。
22
22
  *
23
23
  * 解法:按 agentId 分链(chain Map),每条链把该 agent 的 startProvider 串行化 ——
24
- * 先干净停掉该 agent 的上一个实例(若有),再起新实例。无论 registerFull 调几次,
25
- * 每个 agentId 任何时刻只有一个 ProviderClient 占着连接,4009 互踢消失。
26
- * 不同 agent 持不同 agentToken,彼此不会 4009 互踢(可安全共驻,spec §6.4)。
24
+ * 先干净停掉该 agent 的上一个实例(若有),再起新实例。无论谁发起启动(startAccount
25
+ * legacy registerFull)、调几次,每个 agentId 任何时刻只有一个 ProviderClient 占着
26
+ * 连接,4009 互踢消失。不同 agent 持不同 agentToken,彼此不会 4009 互踢(spec §6.4)。
27
27
  * 单实例下 provider 0.3.0+ 的自愈稳定(heartbeat 25s < 后端 40s 阈值)。
28
28
  * 注意:这只能防「同进程内」同 agent 双实例;跨进程(同 token 跑 plugin + MCP
29
29
  * start_serving / 第二个网关)仍会互踢 —— 那是部署规范(同一 agent 同时只一个 provider serve)。
@@ -47,142 +47,243 @@ const base = createChannelPluginBase({
47
47
  }),
48
48
  },
49
49
  });
50
- const plugin = createChatChannelPlugin({
51
- base: base,
52
- });
50
+ /** Resolve the plugin config object from the gateway runtime cfg. */
51
+ function pluginConfigFromCfg(cfg) {
52
+ return cfg?.plugins?.entries?.[CHANNEL_ID]?.config ?? cfg?.plugins?.[CHANNEL_ID]?.config ?? {};
53
+ }
54
+ /** Read token from ~/.clawrent/config.json when not supplied inline. */
55
+ function readFallbackToken(log) {
56
+ try {
57
+ const crPath = path.join(homedir(), ".clawrent", "config.json");
58
+ let raw = readFileSync(crPath, "utf8");
59
+ if (raw.charCodeAt(0) === 0xfeff)
60
+ raw = raw.slice(1); // strip BOM
61
+ const cr = JSON.parse(raw);
62
+ return cr.token ?? cr.agentToken;
63
+ }
64
+ catch (e) {
65
+ log?.(`[clawrent] token fallback read failed: ${e?.message ?? e}`);
66
+ return undefined;
67
+ }
68
+ }
69
+ /**
70
+ * Resolve the agents to serve from plugin config. Returns null when no usable
71
+ * token is configured (channel should stay inactive).
72
+ * - multi: config.agents:[{agentId,token}] → one ProviderClient per agent
73
+ * - single: config.token (+ optional config.agentId), ~/.clawrent/config.json fallback
74
+ */
75
+ function resolveEntries(config, log) {
76
+ if (Array.isArray(config.agents) && config.agents.length > 0) {
77
+ const entries = config.agents
78
+ .filter((a) => a && typeof a.token === "string" && a.token)
79
+ .map((a) => ({
80
+ agentId: typeof a.agentId === "string" ? a.agentId : undefined,
81
+ token: a.token,
82
+ }));
83
+ if (entries.length === 0) {
84
+ log?.("[clawrent] agents array configured but has no valid entries; channel inactive");
85
+ return null;
86
+ }
87
+ return entries;
88
+ }
89
+ let token = config.token;
90
+ if (!token)
91
+ token = readFallbackToken(log);
92
+ if (!token) {
93
+ log?.("[clawrent] no token configured; channel inactive");
94
+ return null;
95
+ }
96
+ return [{ agentId: config.agentId, token }];
97
+ }
98
+ /**
99
+ * Start (or replace) one ProviderClient per resolved agent entry on its per-agentId
100
+ * chain. The chain guarantees ≤1 ProviderClient per agent at any instant (4009 guard).
101
+ * Handles are tracked in activeProviders.
102
+ */
103
+ function serveAgents(opts) {
104
+ const entries = resolveEntries(opts.config, opts.onLog);
105
+ if (!entries)
106
+ return;
107
+ const multi = entries.length > 1;
108
+ const apiBaseUrl = opts.config.apiBaseUrl;
109
+ const wsUrl = opts.config.wsUrl;
110
+ const autoApprove = opts.config.autoApproveSessions ?? true;
111
+ const guardrailsFile = opts.config.guardrailsFile;
112
+ const useGroupChannel = opts.config.useGroupChannel ?? true;
113
+ const triagePrefilter = opts.config.triagePrefilter;
114
+ const triageTwoPhase = opts.config.triageTwoPhase;
115
+ const baseCursorPath = opts.config.cursorPath ?? path.join(homedir(), ".clawrent", "openclaw-provider-cursor.json");
116
+ for (const entry of entries) {
117
+ const agentKey = entry.agentId ?? "default";
118
+ const cursorPath = multi ? `${baseCursorPath}.${agentKey}` : baseCursorPath;
119
+ const accountId = entry.agentId ?? "clawrent-provider";
120
+ const start = () => startProvider({
121
+ agentToken: entry.token,
122
+ apiBaseUrl,
123
+ wsUrl,
124
+ cursorPath,
125
+ agentId: entry.agentId,
126
+ autoApprove,
127
+ guardrailsFile,
128
+ channelId: CHANNEL_ID,
129
+ accountId,
130
+ cfg: opts.cfg,
131
+ deps: opts.deps,
132
+ onLog: opts.onLog,
133
+ onTerminal: opts.onTerminal,
134
+ abortSignal: opts.abortSignal,
135
+ useGroupChannel,
136
+ triagePrefilter,
137
+ triageTwoPhase,
138
+ }).catch((e) => {
139
+ opts.onLog(`[clawrent] startProvider failed (agent=${agentKey}): ${String(e)}`);
140
+ return null;
141
+ });
142
+ const prevChain = providerChains.get(agentKey) ?? Promise.resolve();
143
+ providerChains.set(agentKey, prevChain.then(async () => {
144
+ const prev = activeProviders.get(agentKey);
145
+ if (prev) {
146
+ try {
147
+ await prev.stop();
148
+ }
149
+ catch { }
150
+ activeProviders.delete(agentKey);
151
+ }
152
+ const handle = await start();
153
+ if (handle)
154
+ activeProviders.set(agentKey, handle);
155
+ }));
156
+ }
157
+ }
158
+ /**
159
+ * Stop every active provider, each on its own chain so we stop the final live
160
+ * instance. Returns a promise that resolves once all current chains settle.
161
+ */
162
+ function stopAllAgents() {
163
+ const waits = [];
164
+ for (const key of Array.from(providerChains.keys())) {
165
+ const prev = providerChains.get(key) ?? Promise.resolve();
166
+ const next = prev.then(async () => {
167
+ const h = activeProviders.get(key);
168
+ if (h) {
169
+ try {
170
+ await h.stop();
171
+ }
172
+ catch { }
173
+ activeProviders.delete(key);
174
+ }
175
+ });
176
+ providerChains.set(key, next);
177
+ waits.push(next.catch(() => { }));
178
+ }
179
+ return Promise.all(waits).then(() => { });
180
+ }
181
+ const pluginDeps = {
182
+ runChannelInboundEvent,
183
+ recordInboundSession: recordInboundSession,
184
+ dispatchReplyWithBufferedBlockDispatcher: dispatchReplyWithBufferedBlockDispatcher,
185
+ };
186
+ /**
187
+ * Gateway-account lifecycle adapter. When `channels.clawrent` is configured, the
188
+ * gateway autostarts the account at boot and on every health-monitor restart by
189
+ * calling startAccount; stopAccount is called on stop/restart. This is what makes
190
+ * the provider recover after a terminal death WITHOUT disabling health-monitor.
191
+ *
192
+ * Why no setStatus: the gateway sets `running:true` while the startAccount task is
193
+ * alive and `running:false` (setStoppedRuntime) when it exits. evaluateChannelHealth
194
+ * keys off `running`, so simply keeping this task alive while the provider is up is
195
+ * enough to be seen as healthy (no false-positive restart). When the provider
196
+ * terminally dies (onTerminal), we exit; the gateway then auto-restarts us with a
197
+ * bounded backoff (server-channels restartAttempts). We must NOT set
198
+ * `terminalDisconnect` or the gateway would skip the restart.
199
+ */
200
+ const gatewayAdapter = {
201
+ async startAccount(ctx) {
202
+ const config = pluginConfigFromCfg(ctx.cfg);
203
+ const log = (m) => ctx.log?.info?.(m);
204
+ const entries = resolveEntries(config, log);
205
+ if (!entries) {
206
+ ctx.log?.warn?.("[clawrent] startAccount: no token configured; staying stopped");
207
+ return; // exit cleanly → gateway marks not-running (not an error)
208
+ }
209
+ if (ctx.abortSignal?.aborted)
210
+ return;
211
+ // Hold this task alive until a provider terminally dies or the account is
212
+ // aborted. On exit (finally), stop everything; the gateway then restarts us.
213
+ let release;
214
+ const exited = new Promise((resolve) => { release = resolve; });
215
+ let exitedOnce = false;
216
+ const exitOnce = (reason) => {
217
+ if (exitedOnce)
218
+ return;
219
+ exitedOnce = true;
220
+ ctx.log?.error?.(`[clawrent] exiting account for restart: ${String(reason)}`);
221
+ try {
222
+ release();
223
+ }
224
+ catch { }
225
+ };
226
+ if (ctx.abortSignal) {
227
+ ctx.abortSignal.addEventListener("abort", () => exitOnce("account aborted"), { once: true });
228
+ }
229
+ serveAgents({
230
+ config,
231
+ cfg: ctx.cfg,
232
+ deps: pluginDeps,
233
+ onLog: log,
234
+ onTerminal: (agentKey, reason) => exitOnce(`provider terminally dead (agent=${agentKey}): ${String(reason)}`),
235
+ abortSignal: ctx.abortSignal,
236
+ });
237
+ log("[clawrent] startAccount: provider(s) started; holding account alive");
238
+ try {
239
+ await exited;
240
+ }
241
+ finally {
242
+ log("[clawrent] startAccount: stopping provider(s) before exit");
243
+ await stopAllAgents();
244
+ }
245
+ },
246
+ async stopAccount(_ctx) {
247
+ // The gateway also aborts startAccount's abortSignal (which stops clients via
248
+ // startProvider); this explicit stop is the idempotent belt-and-suspenders path
249
+ // for flows that call stopAccount directly.
250
+ await stopAllAgents();
251
+ },
252
+ };
253
+ // Attach the gateway-account adapter. createChannelPluginBase/createChatChannelPlugin
254
+ // don't expose a `gateway` option today, but ChannelPlugin carries it and the runtime
255
+ // reads `plugin.gateway.startAccount/stopAccount`, so we attach it post-construction.
256
+ const chatPlugin = createChatChannelPlugin({ base: base });
257
+ const plugin = { ...chatPlugin, gateway: gatewayAdapter };
53
258
  const entry = defineChannelPluginEntry({
54
259
  id: CHANNEL_ID,
55
260
  name: "ClawRent",
56
261
  description: "ClawRent rental-session channel: turns ClawRent rental sessions into native OpenClaw conversations so a local provider agent can answer tenants autonomously with its own model and identity. Uses @clawrent/provider for push-based inbound (no CLI daemon).",
57
262
  plugin: plugin,
58
- // 注意: 不在 entry 里注册 CLI command —— registerCliMetadata 阶段的 registerCommand
263
+ // 不在 entry 里注册 CLI command —— registerCliMetadata 阶段的 registerCommand
59
264
  // 要求 command.name 字段, 多余且易错; channel 插件无需自定义 command。
60
265
  registerFull(ctx) {
61
266
  try {
62
- const config = ctx.config ?? {};
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
- // D3: triage switches (optional; prefilter defaults on at the provider, twoPhase off).
68
- const triagePrefilter = config.triagePrefilter;
69
- const triageTwoPhase = config.triageTwoPhase;
70
- // --- resolve the agents to serve (multi-agent array, else single token) ---
71
- // multi: config.agents:[{agentId,token}] → one ProviderClient per agent.
72
- // single: config.token (+ optional config.agentId), with ~/.clawrent/config.json
73
- // fallback so secrets need not live in openclaw.json.
74
- const resolveSingleToken = () => {
75
- let token = config.token;
76
- if (!token) {
77
- try {
78
- const crPath = path.join(homedir(), ".clawrent", "config.json");
79
- let raw = readFileSync(crPath, "utf8");
80
- if (raw.charCodeAt(0) === 0xfeff)
81
- raw = raw.slice(1); // strip BOM
82
- const cr = JSON.parse(raw);
83
- token = cr.token ?? cr.agentToken;
84
- }
85
- catch (e) {
86
- ctx.logger?.warn?.(`[clawrent] token fallback read failed: ${e?.message ?? e}`);
87
- }
88
- }
89
- return token;
90
- };
91
- let entries;
92
- if (Array.isArray(config.agents) && config.agents.length > 0) {
93
- entries = config.agents
94
- .filter((a) => a && typeof a.token === "string" && a.token)
95
- .map((a) => ({
96
- agentId: typeof a.agentId === "string" ? a.agentId : undefined,
97
- token: a.token,
98
- }));
99
- if (entries.length === 0) {
100
- ctx.logger?.warn?.("[clawrent] agents array configured but has no valid entries; channel inactive");
101
- return;
102
- }
103
- }
104
- else {
105
- const token = resolveSingleToken();
106
- if (!token) {
107
- ctx.logger?.warn?.("[clawrent] no token configured; channel inactive");
108
- return;
109
- }
110
- entries = [{ agentId: config.agentId, token }];
111
- }
112
- const apiBaseUrl = config.apiBaseUrl;
113
- const wsUrl = config.wsUrl;
114
- const autoApprove = config.autoApproveSessions ?? true;
115
- const guardrailsFile = config.guardrailsFile;
116
- const baseCursorPath = config.cursorPath ?? path.join(homedir(), ".clawrent", "openclaw-provider-cursor.json");
117
267
  const runtime = ctx.runtime;
118
268
  const cfg = typeof runtime?.config?.current === "function"
119
269
  ? runtime.config.current()
120
270
  : runtime?.cfg ?? {};
121
- // (Re)start one ProviderClient per agent, each on its OWN per-agentId chain so the
122
- // same agent never has two concurrent clients (4009 protection; see module doc).
123
- // Different agents use different tokens and never 4009 each other (spec §6.4).
124
- for (const entry of entries) {
125
- const agentKey = entry.agentId ?? "default";
126
- // per-agent cursor file when serving multiple agents (independent dedupe state)
127
- const cursorPath = entries.length > 1 ? `${baseCursorPath}.${agentKey}` : baseCursorPath;
128
- const accountId = entry.agentId ?? "clawrent-provider";
129
- const start = () => startProvider({
130
- agentToken: entry.token,
131
- apiBaseUrl,
132
- wsUrl,
133
- cursorPath,
134
- agentId: entry.agentId,
135
- autoApprove,
136
- guardrailsFile,
137
- channelId: CHANNEL_ID,
138
- accountId,
139
- cfg,
140
- deps: {
141
- runChannelInboundEvent,
142
- recordInboundSession: recordInboundSession,
143
- dispatchReplyWithBufferedBlockDispatcher: dispatchReplyWithBufferedBlockDispatcher,
144
- },
145
- onLog: (m) => ctx.logger?.info?.(`[clawrent] ${m}`),
146
- useGroupChannel,
147
- triagePrefilter,
148
- triageTwoPhase,
149
- }).catch((e) => {
150
- ctx.logger?.error?.(`[clawrent] startProvider failed (agent=${agentKey}): ${String(e)}`);
151
- return null;
152
- });
153
- const prevChain = providerChains.get(agentKey) ?? Promise.resolve();
154
- providerChains.set(agentKey, prevChain.then(async () => {
155
- const prev = activeProviders.get(agentKey);
156
- if (prev) {
157
- try {
158
- await prev.stop();
159
- }
160
- catch { }
161
- activeProviders.delete(agentKey);
162
- }
163
- const handle = await start();
164
- if (handle)
165
- activeProviders.set(agentKey, handle);
166
- }));
271
+ // Adaptive start: when `channels.clawrent` is configured, the gateway owns the
272
+ // account lifecycle (autostart + health-monitor restart via startAccount). Don't
273
+ // start the provider here startAccount will. Otherwise (no channels.clawrent)
274
+ // fall back to starting from registerFull so the channel still works.
275
+ if (cfg?.channels?.[CHANNEL_ID]) {
276
+ ctx.logger?.info?.("[clawrent] channels.clawrent configured gateway owns account lifecycle; provider starts via startAccount");
277
+ return;
167
278
  }
168
- // shutdown 只注册一次(多次 registerFull 不叠加);停掉所有 active provider,每个走自己的 chain,
169
- // 保证停的是最终存活的实例。
279
+ const config = ctx.config ?? pluginConfigFromCfg(cfg) ?? {};
280
+ ctx.logger?.info?.("[clawrent] no channels.clawrent — starting provider from registerFull (legacy)");
281
+ serveAgents({ config, cfg, deps: pluginDeps, onLog: (m) => ctx.logger?.info?.(m) });
282
+ // shutdown 只注册一次(多次 registerFull 不叠加);停掉所有 active provider。
170
283
  if (!shutdownRegistered) {
171
284
  shutdownRegistered = true;
172
285
  ctx.registerShutdown?.(() => {
173
- for (const key of Array.from(providerChains.keys())) {
174
- const prev = providerChains.get(key) ?? Promise.resolve();
175
- providerChains.set(key, prev.then(async () => {
176
- const h = activeProviders.get(key);
177
- if (h) {
178
- try {
179
- await h.stop();
180
- }
181
- catch { }
182
- activeProviders.delete(key);
183
- }
184
- }));
185
- }
286
+ void stopAllAgents();
186
287
  });
187
288
  }
188
289
  }
@@ -21,6 +21,18 @@ export interface StartProviderOptions {
21
21
  /** D3: triage switches. prefilter default on; twoPhase default off. */
22
22
  triagePrefilter?: boolean;
23
23
  triageTwoPhase?: boolean;
24
+ /**
25
+ * Fired once when this provider terminally dies (ProviderClient emits
26
+ * `agent:dead` / `agent:activation:failed`). The gateway-account lifecycle
27
+ * (index.ts startAccount) uses this to exit the account task so the gateway
28
+ * auto-restarts it (bounded backoff). Optional — absent in legacy mode.
29
+ */
30
+ onTerminal?: (agentKey: string, reason: unknown) => void;
31
+ /**
32
+ * Account abort signal. When aborted (gateway stopping the account), stop the
33
+ * ProviderClient. Removed again in the returned handle's stop() for cleanliness.
34
+ */
35
+ abortSignal?: AbortSignal;
24
36
  }
25
37
  export interface ProviderHandle {
26
38
  stop: () => Promise<void> | void;
package/dist/provider.js CHANGED
@@ -56,10 +56,40 @@ export async function startProvider(opts) {
56
56
  // Surface presence self-heal events (SDK EventEmitter). These replace the dead
57
57
  // onDisconnect/onError args previously passed to client.start() — ProviderCallbacks
58
58
  // never had those fields, so they were silently ignored.
59
+ const agentKey = opts.agentId ?? "default";
60
+ // Terminal death is fire-once: notifies the account lifecycle (onTerminal) so the
61
+ // gateway can auto-restart the account. Guarded so multiple terminal events don't
62
+ // re-fire.
63
+ let terminated = false;
64
+ const fireTerminal = (reason) => {
65
+ if (terminated)
66
+ return;
67
+ terminated = true;
68
+ opts.onTerminal?.(agentKey, reason);
69
+ };
59
70
  client.on('agent:reconnecting', (delay) => log(`presence reconnecting in ${delay}ms`));
60
71
  client.on('agent:activated', () => log(`presence activated (online)`));
61
- client.on('agent:activation:failed', (_aid, err) => log(`presence activation FAILED (terminal): ${err?.message ?? err}`));
62
- client.on('agent:dead', (_aid, reason) => log(`presence DEAD terminal close, will not reconnect: ${reason}`));
72
+ client.on('agent:activation:failed', (_aid, err) => {
73
+ log(`presence activation FAILED (terminal): ${err?.message ?? err}`);
74
+ fireTerminal(err);
75
+ });
76
+ client.on('agent:dead', (_aid, reason) => {
77
+ log(`presence DEAD — terminal close, will not reconnect: ${reason}`);
78
+ fireTerminal(reason);
79
+ });
80
+ // Stop the client when the gateway aborts the account (stopChannel/restart).
81
+ const onAbort = () => {
82
+ try {
83
+ client.stop();
84
+ }
85
+ catch { /* best-effort */ }
86
+ };
87
+ if (opts.abortSignal) {
88
+ if (opts.abortSignal.aborted)
89
+ onAbort();
90
+ else
91
+ opts.abortSignal.addEventListener("abort", onAbort, { once: true });
92
+ }
63
93
  // Phase 3: pause/resume observability (ProviderClient 0.4.0 emits these).
64
94
  // Pause = consumer paused this agent (4020 close, no reconnect); resume = consumer
65
95
  // resumed → ProviderClient reconnects /ws/group. The agent simply goes idle while
@@ -82,7 +112,13 @@ export async function startProvider(opts) {
82
112
  if (total > 0)
83
113
  usageBySession.set(sid, total);
84
114
  });
85
- await client.start({
115
+ // client.start() resolves once the ProviderClient is set up. If initial setup
116
+ // throws (e.g. 401 auth, unreachable API), treat it as terminal so the account
117
+ // lifecycle (startAccount) exits and the gateway runs its bounded auto-restart
118
+ // (retries transient failures; gives up on persistent config errors). Without
119
+ // this, a setup failure would leave startAccount holding the task alive with no
120
+ // live provider and no recovery.
121
+ const startError = await client.start({
86
122
  agentId: opts.agentId,
87
123
  onPendingApproval: async (session) => {
88
124
  // 平台基线为 false(不自动批准);批准动作只在此端侧一道门发生。
@@ -268,7 +304,22 @@ export async function startProvider(opts) {
268
304
  log(`runChannelInboundEvent failed session=${sessionId}: ${String(e)}`);
269
305
  }
270
306
  },
271
- });
307
+ }).then(() => undefined, (e) => e);
308
+ if (startError !== undefined) {
309
+ log(`provider failed to start (terminal): ${String(startError)}`);
310
+ fireTerminal(startError);
311
+ return {
312
+ stop: () => {
313
+ if (opts.abortSignal)
314
+ opts.abortSignal.removeEventListener("abort", onAbort);
315
+ // client.start() threw before setup completed (e.g. 401 auth, unreachable
316
+ // API). Do NOT call client.stop() here — the SDK has an internal pending
317
+ // firstActivation promise that stop() would reject with "Provider stopped",
318
+ // and that rejection is unhandled and crashes the host gateway. The client
319
+ // never finished starting, so there is nothing to stop.
320
+ },
321
+ };
322
+ }
272
323
  log(`provider started (agentId=${opts.agentId ?? "auto"} autoApprove=${opts.autoApprove})`);
273
324
  return {
274
325
  stop: () => {
@@ -278,6 +329,8 @@ export async function startProvider(opts) {
278
329
  catch {
279
330
  /* diagnostic unsubscribe best-effort */
280
331
  }
332
+ if (opts.abortSignal)
333
+ opts.abortSignal.removeEventListener("abort", onAbort);
281
334
  try {
282
335
  client.stop();
283
336
  }
@@ -7,7 +7,3 @@ export declare function testConnection(config: {
7
7
  token?: string;
8
8
  apiBaseUrl?: string;
9
9
  }): Promise<ClawRentConnectionResult>;
10
- declare const _default: {
11
- testConnection: typeof testConnection;
12
- };
13
- export default _default;
@@ -19,4 +19,3 @@ export async function testConnection(config) {
19
19
  return { ok: false, error: String(e?.message ?? e) };
20
20
  }
21
21
  }
22
- export default { testConnection };
@@ -45,6 +45,20 @@
45
45
  normalize default / module-wrapper 导出,接受 register/activate;PinkBo 在 2026.7.1-2
46
46
  复测 `onStartup:true` + `defineChannelPluginEntry`(含纯 default-export 变体)干净加载、
47
47
  不复现(openclaw/openclaw#111344 关闭为 "already implemented")。
48
+ **⚠️ 但 clawrent 直到 0.6.0 仍在启动报 `missing register/activate`——真因是 #7(setupEntry),不是本条。** 本条(default-export loader bug)与 clawrent 实际踩到的validation 失败是**两条不同的路径**,别混。
49
+ 7. **`openclaw.setupEntry` 被当 plugin 入口校验(2026-08-07 实测,0.7.0 已绕过)**:
50
+ `package.json` 的 `openclaw.setupEntry` / `runtimeSetupEntry` 指向 `./dist/setup/setup.js`
51
+ 时,loader 在 setup-only / prefer-setup registration plan 下会把 setup.js 当作主入口加载
52
+ (`loader-svIpMF0d.js:1915` `loadEntry = loadSetupEntry && runtimeSetupEntry ? runtimeSetupEntry : runtimeCandidateEntry`),
53
+ 再对它跑完整 plugin 的 register/activate 校验。而 setup.js 是 setup 接口(导出 `{testConnection}`),
54
+ 合法地没有 register/activate → 误报 `clawrent missing register/activate export` → plugin
55
+ 启动 validation 失败、不进注册 plugin 列表(只剩 channel 触发式加载的 registerFull 兜底,所以显得"非阻塞")。
56
+ 诊断法:`OPENCLAW_PLUGIN_LOAD_DEBUG=1` 起 gateway,看 `module shape: export:object keys=__esModule,default,testConnection`
57
+ (= setup.js 形态,非 index.js 的 entry)即铁证。`describePluginModuleExportShape`(loader:392)输出。
58
+ **plugin 侧修法**:删 `package.json` 的 `setupEntry`/`runtimeSetupEntry`(setup 向导 testConnection
59
+ 由 `createChannelPluginBase({setup})` adapter 覆盖;setup-only 模式 fallback 加载 index.js,registerFull
60
+ 只 full 模式起 provider、setup-only 不起,安全)。**建议回馈上游**:loader 不应对 setupEntry 跑完整
61
+ register/activate 校验(setup entry 是 `defineSetupPluginEntry`/setup 契约,非 plugin entry)。
48
62
  6. **`channelConfigs` manifest 字段文档不足**:install 警告
49
63
  "channel plugin manifest declares clawrent without channelConfigs metadata",
50
64
  但 manifest 已声明 `channelConfigs`。需明确该字段的精确期望结构。
@@ -2,11 +2,15 @@
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.6.0",
6
- "channels": ["clawrent"],
5
+ "version": "0.7.0",
6
+ "channels": [
7
+ "clawrent"
8
+ ],
7
9
  "activation": {
8
10
  "onStartup": false,
9
- "onChannels": ["clawrent"]
11
+ "onChannels": [
12
+ "clawrent"
13
+ ]
10
14
  },
11
15
  "channelConfigs": {
12
16
  "clawrent": {
@@ -69,10 +73,17 @@
69
73
  "items": {
70
74
  "type": "object",
71
75
  "properties": {
72
- "agentId": { "type": "string" },
73
- "token": { "type": "string" }
76
+ "agentId": {
77
+ "type": "string"
78
+ },
79
+ "token": {
80
+ "type": "string"
81
+ }
74
82
  },
75
- "required": ["agentId", "token"]
83
+ "required": [
84
+ "agentId",
85
+ "token"
86
+ ]
76
87
  }
77
88
  }
78
89
  }
@@ -161,10 +172,17 @@
161
172
  "items": {
162
173
  "type": "object",
163
174
  "properties": {
164
- "agentId": { "type": "string" },
165
- "token": { "type": "string" }
175
+ "agentId": {
176
+ "type": "string"
177
+ },
178
+ "token": {
179
+ "type": "string"
180
+ }
166
181
  },
167
- "required": ["agentId", "token"]
182
+ "required": [
183
+ "agentId",
184
+ "token"
185
+ ]
168
186
  }
169
187
  }
170
188
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawrent/openclaw-channel",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -33,8 +33,6 @@
33
33
  "runtimeExtensions": [
34
34
  "./dist/index.js"
35
35
  ],
36
- "setupEntry": "./dist/setup/setup.js",
37
- "runtimeSetupEntry": "./dist/setup/setup.js",
38
36
  "install": {
39
37
  "clawhubSpec": "clawhub:@clawrent/openclaw-channel",
40
38
  "defaultChoice": "clawhub",