@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 +8 -4
- package/dist/index.js +227 -126
- package/dist/provider.d.ts +12 -0
- package/dist/provider.js +57 -4
- package/dist/setup/setup.d.ts +0 -4
- package/dist/setup/setup.js +0 -1
- package/docs/openclaw-sdk-notes.md +14 -0
- package/openclaw.plugin.json +27 -9
- package/package.json +1 -3
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
|
-
> 💡 **
|
|
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
|
-
> 💡 **
|
|
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
|
-
*
|
|
16
|
-
* health-monitor restart 叠加)
|
|
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 的上一个实例(若有)
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/provider.d.ts
CHANGED
|
@@ -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) =>
|
|
62
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/setup/setup.d.ts
CHANGED
package/dist/setup/setup.js
CHANGED
|
@@ -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`。需明确该字段的精确期望结构。
|
package/openclaw.plugin.json
CHANGED
|
@@ -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
|
-
"channels": [
|
|
5
|
+
"version": "0.7.0",
|
|
6
|
+
"channels": [
|
|
7
|
+
"clawrent"
|
|
8
|
+
],
|
|
7
9
|
"activation": {
|
|
8
10
|
"onStartup": false,
|
|
9
|
-
"onChannels": [
|
|
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": {
|
|
73
|
-
|
|
76
|
+
"agentId": {
|
|
77
|
+
"type": "string"
|
|
78
|
+
},
|
|
79
|
+
"token": {
|
|
80
|
+
"type": "string"
|
|
81
|
+
}
|
|
74
82
|
},
|
|
75
|
-
"required": [
|
|
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": {
|
|
165
|
-
|
|
175
|
+
"agentId": {
|
|
176
|
+
"type": "string"
|
|
177
|
+
},
|
|
178
|
+
"token": {
|
|
179
|
+
"type": "string"
|
|
180
|
+
}
|
|
166
181
|
},
|
|
167
|
-
"required": [
|
|
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.
|
|
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",
|