@coclaw/openclaw-coclaw 0.21.4 → 0.22.1
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 +1 -1
- package/index.js +121 -42
- package/openclaw.plugin.json +1 -1
- package/package.json +5 -4
- package/src/auto-upgrade/updater.js +8 -0
- package/src/channel-plugin.js +1 -1
- package/src/chat-history-manager/manager.js +99 -21
- package/src/claw-paths.js +14 -0
- package/src/cli-registrar.js +84 -4
- package/src/common/gateway-notify.js +4 -4
- package/src/common/messages.js +42 -4
- package/src/device-identity.js +7 -0
- package/src/model-default/handlers.js +177 -0
- package/src/model-default/index.js +106 -0
- package/src/model-default/persist.js +115 -0
- package/src/model-default/resolve.js +88 -0
- package/src/provider-auth/handlers.js +180 -0
- package/src/provider-auth/index.js +78 -0
- package/src/realtime-bridge.js +108 -10
- package/src/session-manager/manager.js +90 -168
- package/src/topic-manager/manager.js +12 -2
- package/src/utils/text-line-stream.js +60 -0
- package/src/webrtc/webrtc-peer.js +410 -217
package/README.md
CHANGED
|
@@ -92,7 +92,7 @@ pnpm run release:versions # 显示所有已发布版本
|
|
|
92
92
|
| `coclaw.files.delete` | 删除工作区文件/目录 |
|
|
93
93
|
| `coclaw.files.mkdir` | 创建工作区目录 |
|
|
94
94
|
| `coclaw.files.create` | 创建空文件 |
|
|
95
|
-
| `nativeui.sessions.listAll` | 列出所有 session
|
|
95
|
+
| `nativeui.sessions.listAll` | 列出所有 session(分页) |
|
|
96
96
|
| `nativeui.sessions.get` | 获取 session 原始 JSONL 行(分页) |
|
|
97
97
|
|
|
98
98
|
## Gateway Services
|
package/index.js
CHANGED
|
@@ -17,6 +17,8 @@ import { createFileHandler } from './src/file-manager/handler.js';
|
|
|
17
17
|
import { abortAgentRun } from './src/agent-abort.js';
|
|
18
18
|
import { decideCancelResponse } from './src/agent-cancel-heuristic.js';
|
|
19
19
|
import { remoteLog } from './src/remote-log.js';
|
|
20
|
+
import { registerProviderAuthHandlers } from './src/provider-auth/index.js';
|
|
21
|
+
import { registerModelDefaultHandlers } from './src/model-default/index.js';
|
|
20
22
|
|
|
21
23
|
import { getPluginVersion, __resetPluginVersion } from './src/plugin-version.js';
|
|
22
24
|
export { getPluginVersion, __resetPluginVersion };
|
|
@@ -132,7 +134,7 @@ function respondInvalid(respond, message) {
|
|
|
132
134
|
const plugin = {
|
|
133
135
|
id: 'openclaw-coclaw',
|
|
134
136
|
name: 'CoClaw',
|
|
135
|
-
description: 'OpenClaw
|
|
137
|
+
description: 'OpenClaw plugin for remote chat over WebRTC',
|
|
136
138
|
register(api) {
|
|
137
139
|
// 按 OpenClaw SDK 入口模式分叉(参照 defineChannelPluginEntry,见上游 plugin-sdk/core.ts 的
|
|
138
140
|
// defineChannelPluginEntry 实现 与 docs/plugins/sdk-entrypoints.md):
|
|
@@ -177,32 +179,94 @@ const plugin = {
|
|
|
177
179
|
logger.warn?.(`[coclaw] chat history manager load failed: ${String(err?.message ?? err)}`);
|
|
178
180
|
});
|
|
179
181
|
|
|
180
|
-
// 追踪 chat 因 reset
|
|
182
|
+
// 追踪 chat 因 reset 产生的 session 流水。双源回调(hook + sessions.changed)共用 helper。
|
|
183
|
+
// recordSessionTransition 内部已 __reloadFromDisk + mutex,外层无需再 cache.has + load。
|
|
184
|
+
//
|
|
185
|
+
// agentId 解析:hook 路径有 ctx.agentId(显式契约,优先用);sessions.changed 路径
|
|
186
|
+
// gateway broadcast payload 不含 agentId(见 openclaw-repo emitSessionsChanged),
|
|
187
|
+
// 只能 fallback 切 sessionKey(`agent:<agentId>:*` → parts[1])。当前 sessionKey schema
|
|
188
|
+
// 下两路径解析结果等价;多 agent topic 启用后若上游 sessionKey schema 加前缀会需复评。
|
|
189
|
+
//
|
|
190
|
+
// archivedSessionId 解析:hook 路径来自 event.resumedFrom;sessions.changed payload
|
|
191
|
+
// 不带(无 previousSessionId 字段)→ 进 manager 时为 undefined。manager 不会去
|
|
192
|
+
// "推断字段值",而是把文件首位未归档头直接翻为归档(补 archivedAt)后再 unshift
|
|
193
|
+
// 新头——等价于"以文件首位 sessionId 作为前任"。
|
|
194
|
+
//
|
|
195
|
+
// 该 helper 可直接作为 bridge.onSessionCreated 回调(签名兼容;缺失字段走兜底:
|
|
196
|
+
// agentId 走 parts[1] fallback、archivedSessionId 走 manager 翻 head 路径)。
|
|
197
|
+
async function handleSessionCreated({ agentId, sessionKey, sessionId, archivedSessionId }) {
|
|
198
|
+
if (!sessionKey || !sessionId) {
|
|
199
|
+
// 早返值得警惕:上游事件 schema 异常,或 topic(无 sessionKey)误入双源链路。
|
|
200
|
+
// 打 log + remoteLog 让运维能定位事件源;不影响其他通道。
|
|
201
|
+
logger.warn?.(
|
|
202
|
+
`[coclaw] chat history early-return: missing sessionKey/sessionId`,
|
|
203
|
+
);
|
|
204
|
+
remoteLog(
|
|
205
|
+
`chat-history.missing-keys sessionKey=${sessionKey ?? 'null'} sessionId=${sessionId ?? 'null'}`,
|
|
206
|
+
);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
// topic 上游伪造的 explicit fake sessionKey(形态 `agent:<agentId>:explicit:<sid>`)
|
|
210
|
+
// 不属于 chat 流水范畴:CoClaw 自管 topic 元信息,不应进 chat-history 桶。
|
|
211
|
+
// 当前 F1 实验已证明该路径不触发本回调,此守卫属防御性兜底。
|
|
212
|
+
// 前提假设:(a) sessionKey 首段是 `agent`;(b) `explicit` 占第 3 段(即 parts[2],
|
|
213
|
+
// 0-indexed 数)。两条同时成立才命中本守卫;若上游 schema 演进(如挪位置 / 增前缀 /
|
|
214
|
+
// 改首段名),需复评本守卫。
|
|
215
|
+
const parts = sessionKey.split(':');
|
|
216
|
+
if (parts[0] === 'agent' && parts[2] === 'explicit') {
|
|
217
|
+
remoteLog(`chat-history.skip-explicit sessionKey=${sessionKey}`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// subagent 是 OpenClaw 程序自起的子任务 run(mode=run 一次性 / mode=session 持久绑定),
|
|
221
|
+
// 形态 `agent:<id>:subagent:<uuid>`,嵌套子代理为 `agent:<id>:subagent:<uuid>:subagent:<uuid2>`。
|
|
222
|
+
// 它不是人机对话流;父 agent 的 transcript 里已含子代理最终输出(作为 user message 回流),
|
|
223
|
+
// 因此不入 chat-history。
|
|
224
|
+
// 判定从 parts[2] 起找 'subagent' 段,避免 agentId 恰好叫 'subagent' 时误伤。
|
|
225
|
+
if (parts[0] === 'agent' && parts.indexOf('subagent', 2) >= 0) {
|
|
226
|
+
remoteLog(`chat-history.skip-subagent sessionKey=${sessionKey}`);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
let resolvedAgentId = agentId;
|
|
230
|
+
if (!resolvedAgentId) {
|
|
231
|
+
resolvedAgentId = (parts[0] === 'agent' && parts[1]) ? parts[1] : 'main';
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
await chatHistoryManager.recordSessionTransition({
|
|
235
|
+
agentId: resolvedAgentId,
|
|
236
|
+
sessionKey,
|
|
237
|
+
currentSessionId: sessionId,
|
|
238
|
+
archivedSessionId,
|
|
239
|
+
});
|
|
240
|
+
} catch (err) {
|
|
241
|
+
logger.warn?.(`[coclaw] chat history record failed: ${String(err?.message ?? err)}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
181
244
|
if (typeof api.on === 'function') {
|
|
182
245
|
api.on('session_start', async (event, ctx) => {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
246
|
+
// event.sessionId 是新 sid(必填),event.resumedFrom 是旧 sid(可选),ctx.agentId 可信
|
|
247
|
+
await handleSessionCreated({
|
|
248
|
+
agentId: ctx?.agentId,
|
|
249
|
+
sessionKey: event?.sessionKey,
|
|
250
|
+
sessionId: event?.sessionId,
|
|
251
|
+
archivedSessionId: event?.resumedFrom,
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// bridge 启动/重启的闭包 helper:把 onSessionCreated 接到 handleSessionCreated。
|
|
257
|
+
// 所有 restartRealtimeBridge 调用必须走这个 helper,避免漏接回调。
|
|
258
|
+
async function restartBridge() {
|
|
259
|
+
await restartRealtimeBridge({
|
|
260
|
+
logger,
|
|
261
|
+
pluginConfig: api.pluginConfig,
|
|
262
|
+
onSessionCreated: handleSessionCreated,
|
|
199
263
|
});
|
|
200
264
|
}
|
|
201
265
|
|
|
202
266
|
api.registerService({
|
|
203
267
|
id: 'coclaw-realtime-bridge',
|
|
204
268
|
async start() {
|
|
205
|
-
await
|
|
269
|
+
await restartBridge();
|
|
206
270
|
},
|
|
207
271
|
async stop() {
|
|
208
272
|
await stopRealtimeBridge();
|
|
@@ -239,11 +303,11 @@ const plugin = {
|
|
|
239
303
|
});
|
|
240
304
|
} catch (err) {
|
|
241
305
|
// bind 失败时恢复 bridge(best-effort,不覆盖原始错误)
|
|
242
|
-
await
|
|
306
|
+
await restartBridge().catch(() => {});
|
|
243
307
|
throw err;
|
|
244
308
|
}
|
|
245
309
|
// bind 已持久化,restart 失败不影响结果
|
|
246
|
-
await
|
|
310
|
+
await restartBridge().catch((err) => {
|
|
247
311
|
logger.warn?.(`[coclaw] bridge restart failed after bind: ${err?.message ?? err}`);
|
|
248
312
|
});
|
|
249
313
|
return result;
|
|
@@ -276,11 +340,9 @@ const plugin = {
|
|
|
276
340
|
serverUrl: params?.serverUrl,
|
|
277
341
|
});
|
|
278
342
|
respond(true, {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
previousClawId: result.previousClawId,
|
|
283
|
-
},
|
|
343
|
+
clawId: result.clawId,
|
|
344
|
+
rebound: result.rebound,
|
|
345
|
+
previousClawId: result.previousClawId,
|
|
284
346
|
});
|
|
285
347
|
}
|
|
286
348
|
catch (err) {
|
|
@@ -296,7 +358,7 @@ const plugin = {
|
|
|
296
358
|
return;
|
|
297
359
|
}
|
|
298
360
|
const result = await doUnbind({ serverUrl: params?.serverUrl });
|
|
299
|
-
respond(true, {
|
|
361
|
+
respond(true, { clawId: result.clawId });
|
|
300
362
|
}
|
|
301
363
|
catch (err) {
|
|
302
364
|
respondError(respond, err);
|
|
@@ -325,12 +387,10 @@ const plugin = {
|
|
|
325
387
|
|
|
326
388
|
// 立即返回认领码给 CLI
|
|
327
389
|
respond(true, {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
expiresMinutes,
|
|
333
|
-
},
|
|
390
|
+
code: result.code,
|
|
391
|
+
appUrl: result.appUrl,
|
|
392
|
+
expiresAt: result.expiresAt,
|
|
393
|
+
expiresMinutes,
|
|
334
394
|
});
|
|
335
395
|
|
|
336
396
|
// 后台 fire-and-forget:等待认领并保存 config + 启 bridge
|
|
@@ -341,7 +401,7 @@ const plugin = {
|
|
|
341
401
|
signal: abortController.signal,
|
|
342
402
|
}).then(async () => {
|
|
343
403
|
if (abortController.signal.aborted) return;
|
|
344
|
-
await
|
|
404
|
+
await restartBridge();
|
|
345
405
|
logger.info?.('[coclaw] enroll completed, bridge restarted');
|
|
346
406
|
}).catch((err) => {
|
|
347
407
|
if (abortController.signal.aborted) return;
|
|
@@ -363,21 +423,21 @@ const plugin = {
|
|
|
363
423
|
// best-effort ensure:失败不阻断 listAll
|
|
364
424
|
try { await ensureAgentSession(agentId); }
|
|
365
425
|
catch {}
|
|
366
|
-
respond(true, manager.listAll(params ?? {}));
|
|
426
|
+
respond(true, await manager.listAll(params ?? {}));
|
|
367
427
|
}
|
|
368
428
|
catch (err) {
|
|
369
429
|
respondError(respond, err);
|
|
370
430
|
}
|
|
371
431
|
});
|
|
372
432
|
|
|
373
|
-
api.registerGatewayMethod('nativeui.sessions.get', ({ params, respond }) => {
|
|
433
|
+
api.registerGatewayMethod('nativeui.sessions.get', async ({ params, respond }) => {
|
|
374
434
|
try {
|
|
375
435
|
const sessionId = params?.sessionId;
|
|
376
436
|
if (typeof sessionId !== 'string' || sessionId.trim().length === 0) {
|
|
377
437
|
respondInvalid(respond, 'sessionId required');
|
|
378
438
|
return;
|
|
379
439
|
}
|
|
380
|
-
respond(true, manager.get(params ?? {}));
|
|
440
|
+
respond(true, await manager.get(params ?? {}));
|
|
381
441
|
}
|
|
382
442
|
catch (err) {
|
|
383
443
|
respondError(respond, err);
|
|
@@ -474,7 +534,7 @@ const plugin = {
|
|
|
474
534
|
}
|
|
475
535
|
});
|
|
476
536
|
|
|
477
|
-
api.registerGatewayMethod('coclaw.topics.getHistory', ({ params, respond }) => {
|
|
537
|
+
api.registerGatewayMethod('coclaw.topics.getHistory', async ({ params, respond }) => {
|
|
478
538
|
try {
|
|
479
539
|
const topicId = params?.topicId?.trim?.();
|
|
480
540
|
if (!topicId) {
|
|
@@ -483,7 +543,7 @@ const plugin = {
|
|
|
483
543
|
}
|
|
484
544
|
const agentId = params?.agentId?.trim?.() || 'main';
|
|
485
545
|
// 直接复用 session-manager 的 get(),topicId 即 sessionId
|
|
486
|
-
respond(true, manager.get({ agentId, sessionId: topicId }));
|
|
546
|
+
respond(true, await manager.get({ agentId, sessionId: topicId }));
|
|
487
547
|
}
|
|
488
548
|
catch (err) {
|
|
489
549
|
respondError(respond, err);
|
|
@@ -577,7 +637,7 @@ const plugin = {
|
|
|
577
637
|
});
|
|
578
638
|
|
|
579
639
|
// TODO: coclaw.topics.getHistory 未来可废弃,UI 改用 coclaw.sessions.getById
|
|
580
|
-
api.registerGatewayMethod('coclaw.sessions.getById', ({ params, respond }) => {
|
|
640
|
+
api.registerGatewayMethod('coclaw.sessions.getById', async ({ params, respond }) => {
|
|
581
641
|
try {
|
|
582
642
|
const sessionId = params?.sessionId?.trim?.();
|
|
583
643
|
if (!sessionId) {
|
|
@@ -586,7 +646,7 @@ const plugin = {
|
|
|
586
646
|
}
|
|
587
647
|
const agentId = params?.agentId?.trim?.() || 'main';
|
|
588
648
|
const limit = params?.limit;
|
|
589
|
-
respond(true, manager.getById({ agentId, sessionId, limit }));
|
|
649
|
+
respond(true, await manager.getById({ agentId, sessionId, limit }));
|
|
590
650
|
}
|
|
591
651
|
catch (err) {
|
|
592
652
|
respondError(respond, err);
|
|
@@ -691,6 +751,25 @@ const plugin = {
|
|
|
691
751
|
}
|
|
692
752
|
});
|
|
693
753
|
|
|
754
|
+
// provider 认证管理 RPC(API key 写入 / 列表 / 撤销)。SDK 走懒加载 dynamic import,
|
|
755
|
+
// 不增加本插件 cold-load 开销,也让测试环境无需 openclaw 包就能加载 index.js。
|
|
756
|
+
// loadSdk 字面量必须留在本入口源码里:OpenClaw plugin loader 只扫入口文件识别
|
|
757
|
+
// `openclaw/plugin-sdk/*` 字符串字面量、命中后才把整张依赖图过 jiti 改写到自家 dist;
|
|
758
|
+
// 字面量留在子模块里 loader 看不到 → 整张图走原生 Node 解析必败(plugin 部署目录不带 openclaw 包)
|
|
759
|
+
registerProviderAuthHandlers(api, {
|
|
760
|
+
loadSdk: () => import('openclaw/plugin-sdk/provider-auth'),
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
// 模型默认配置 RPC(coclaw.model.set / list)。三个 SDK 子入口的字面量
|
|
764
|
+
// dynamic import 必须留在本入口源码——OpenClaw plugin loader 只扫入口源码
|
|
765
|
+
// 命中 `openclaw/plugin-sdk/*` 字面量并触发 jiti 重写;藏在子模块的字面量
|
|
766
|
+
// loader 看不到 → 原生 Node 解析必败。
|
|
767
|
+
registerModelDefaultHandlers(api, {
|
|
768
|
+
loadConfigMutation: () => import('openclaw/plugin-sdk/config-mutation'),
|
|
769
|
+
loadModelsProviderRuntime: () => import('openclaw/plugin-sdk/models-provider-runtime'),
|
|
770
|
+
loadProviderAuth: () => import('openclaw/plugin-sdk/provider-auth'),
|
|
771
|
+
});
|
|
772
|
+
|
|
694
773
|
const scheduler = new AutoUpgradeScheduler({ pluginId: api.id, logger });
|
|
695
774
|
api.registerService({
|
|
696
775
|
id: 'coclaw-auto-upgrade',
|
|
@@ -738,7 +817,7 @@ const plugin = {
|
|
|
738
817
|
signal: abortController.signal,
|
|
739
818
|
}).then(async () => {
|
|
740
819
|
if (abortController.signal.aborted) return;
|
|
741
|
-
await
|
|
820
|
+
await restartBridge();
|
|
742
821
|
logger.info?.('[coclaw] enroll completed via slash command, bridge restarted');
|
|
743
822
|
}).catch((err) => {
|
|
744
823
|
if (abortController.signal.aborted) return;
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-coclaw",
|
|
3
3
|
"name": "CoClaw",
|
|
4
|
-
"description": "OpenClaw
|
|
4
|
+
"description": "OpenClaw plugin for remote chat over WebRTC. Run `openclaw coclaw enroll` after install.",
|
|
5
5
|
"activation": {
|
|
6
6
|
"onStartup": true
|
|
7
7
|
},
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coclaw/openclaw-coclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
|
-
"description": "OpenClaw
|
|
6
|
+
"description": "OpenClaw plugin for remote chat over WebRTC. Run `openclaw coclaw enroll` after install.",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "https://github.com/coclaw/coclaw.git",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"./index.js"
|
|
42
42
|
],
|
|
43
43
|
"install": {
|
|
44
|
-
"minHostVersion": ">=2026.
|
|
44
|
+
"minHostVersion": ">=2026.3.22"
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
47
|
"scripts": {
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"check": "pnpm lint && pnpm typecheck",
|
|
52
52
|
"test:plugin": "node --test src/plugin-mode.test.js",
|
|
53
53
|
"test": "c8 --check-coverage --lines 100 --functions 100 --branches 95 --statements 100 --reporter=text --reporter=lcov bash -c 'for f in src/**/*.test.js src/*.test.js index.test.js; do node --test \"$f\" || exit 1; done'",
|
|
54
|
+
"e2e": "node --test --test-concurrency=1 e2e/*.e2e.spec.js",
|
|
54
55
|
"verify": "pnpm check && pnpm test",
|
|
55
56
|
"link": "bash ./scripts/link.sh",
|
|
56
57
|
"unlink": "bash ./scripts/unlink.sh",
|
|
@@ -62,7 +63,7 @@
|
|
|
62
63
|
"release:versions": "npm view @coclaw/openclaw-coclaw versions --json --registry=https://registry.npmjs.org/ && npm view @coclaw/openclaw-coclaw versions --json"
|
|
63
64
|
},
|
|
64
65
|
"dependencies": {
|
|
65
|
-
"@coclaw/pion-node": "^0.
|
|
66
|
+
"@coclaw/pion-node": "^0.4.0",
|
|
66
67
|
"werift": "^0.19.0",
|
|
67
68
|
"ws": "^8.19.0"
|
|
68
69
|
},
|
|
@@ -131,6 +131,14 @@ export async function writeUpgradeLock(pid) {
|
|
|
131
131
|
* `upgrade.ledger-read-failed` / `upgrade.ledger-parse-failed`),避免运维只
|
|
132
132
|
* 看到 start() 那条 "Skipping: not an npm-installed plugin" 时误判方向。
|
|
133
133
|
*
|
|
134
|
+
* 注:内部 `readFileSync` 为同步 IO,**有意保留**——只在升级周期决策时读一次
|
|
135
|
+
* 账本(整个进程生命周期通常一锤子)。改 async 必须沿 `shouldSkipAutoUpgrade`
|
|
136
|
+
* 等调用链向上传播,收益不抵成本。
|
|
137
|
+
*
|
|
138
|
+
* 另:OpenClaw plugin SDK 当前未暴露查询 installRecords 的 API,只能直接读
|
|
139
|
+
* `<state-dir>/plugins/installs.json`(与上游 `manifest-metadata-scan` 等
|
|
140
|
+
* 内部模块同源做法)。如果上游后续开放官方接口,可切换并删除直读分支。
|
|
141
|
+
*
|
|
134
142
|
* @param {string} pluginId
|
|
135
143
|
* @returns {object|null}
|
|
136
144
|
*/
|
package/src/channel-plugin.js
CHANGED
|
@@ -17,7 +17,7 @@ export const coclawChannelPlugin = {
|
|
|
17
17
|
label: 'CoClaw',
|
|
18
18
|
selectionLabel: 'CoClaw',
|
|
19
19
|
docsPath: 'https://docs.coclaw.net',
|
|
20
|
-
blurb: 'CoClaw channel plugin for remote chat',
|
|
20
|
+
blurb: 'CoClaw channel plugin for remote chat over WebRTC',
|
|
21
21
|
},
|
|
22
22
|
capabilities: {
|
|
23
23
|
chatTypes: ['direct'],
|
|
@@ -4,6 +4,7 @@ import nodePath from 'node:path';
|
|
|
4
4
|
import { agentSessionsDir } from '../claw-paths.js';
|
|
5
5
|
import { atomicWriteJsonFile } from '../utils/atomic-write.js';
|
|
6
6
|
import { createMutex } from '../utils/mutex.js';
|
|
7
|
+
import { remoteLog } from '../remote-log.js';
|
|
7
8
|
|
|
8
9
|
const HISTORY_FILE = 'coclaw-chat-history.json';
|
|
9
10
|
|
|
@@ -12,18 +13,25 @@ function emptyStore() {
|
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
|
-
* Chat History 管理器:追踪 chat(sessionKey
|
|
16
|
+
* Chat History 管理器:追踪 chat(sessionKey)下的 session 流水。
|
|
16
17
|
*
|
|
17
18
|
* 每个 agentId 对应一份 coclaw-chat-history.json,按需懒加载到内存。
|
|
18
19
|
* 写操作通过 mutex + atomicWriteJsonFile 保证一致性。
|
|
19
20
|
*
|
|
20
|
-
*
|
|
21
|
+
* 文件结构示例(archivedAt 是 Date.now() 落的 13 位毫秒时间戳):
|
|
21
22
|
* {
|
|
22
23
|
* "version": 1,
|
|
23
24
|
* "agent:main:main": [
|
|
24
|
-
* { "sessionId": "
|
|
25
|
+
* { "sessionId": "current-sid" }, // 首位:未归档头 = 当前活跃 session
|
|
26
|
+
* { "sessionId": "older", "archivedAt": 1742003000000 } // 第二位起:已归档(新→旧)
|
|
25
27
|
* ]
|
|
26
28
|
* }
|
|
29
|
+
*
|
|
30
|
+
* 详见 plugins/openclaw/docs/architecture.md
|
|
31
|
+
*
|
|
32
|
+
* 双源事件供给:
|
|
33
|
+
* - session_start hook:event 同时含新 sid (currentSessionId) + 旧 sid (archivedSessionId)
|
|
34
|
+
* - sessions.changed reason=create:payload 含 sessionKey + 新 sid,旧 sid 从文件首位推断
|
|
27
35
|
*/
|
|
28
36
|
export class ChatHistoryManager {
|
|
29
37
|
/**
|
|
@@ -87,14 +95,29 @@ export class ChatHistoryManager {
|
|
|
87
95
|
this.__cache.set(agentId, data);
|
|
88
96
|
return;
|
|
89
97
|
}
|
|
90
|
-
} catch {
|
|
91
|
-
|
|
98
|
+
} catch (err) {
|
|
99
|
+
this.__reportLoadError(filePath, err, '__doLoad');
|
|
92
100
|
}
|
|
93
101
|
this.__cache.set(agentId, emptyStore());
|
|
94
102
|
}
|
|
95
103
|
|
|
104
|
+
/**
|
|
105
|
+
* 读盘失败分流:ENOENT 是正常情况(首次启动文件不存在)静默;
|
|
106
|
+
* 其他错误(权限、磁盘损坏、JSON 破损)有诊断价值,打 warn + remoteLog 标识可疑。
|
|
107
|
+
*/
|
|
108
|
+
__reportLoadError(filePath, err, callsite) {
|
|
109
|
+
if (err?.code === 'ENOENT') return;
|
|
110
|
+
const fname = nodePath.basename(filePath);
|
|
111
|
+
this.__logger.warn?.(
|
|
112
|
+
`[coclaw] chat-history ${callsite} read failed for ${fname}: ${String(err?.message ?? err)}`,
|
|
113
|
+
);
|
|
114
|
+
remoteLog(
|
|
115
|
+
`chat-history.reload-error site=${callsite} file=${fname} msg=${String(err?.message ?? err)}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
96
119
|
__ensureLoaded(agentId) {
|
|
97
|
-
/* c8 ignore start --
|
|
120
|
+
/* c8 ignore start -- recordSessionTransition/list 均先 __reloadFromDisk,此分支为防御性守卫 */
|
|
98
121
|
if (!this.__cache.has(agentId)) {
|
|
99
122
|
throw new Error(`ChatHistoryManager: agent "${agentId}" not loaded, call load() first`);
|
|
100
123
|
}
|
|
@@ -112,11 +135,32 @@ export class ChatHistoryManager {
|
|
|
112
135
|
}
|
|
113
136
|
|
|
114
137
|
/**
|
|
115
|
-
*
|
|
116
|
-
*
|
|
138
|
+
* 记录一次 session 转换。双源事件共用:
|
|
139
|
+
* - session_start hook:
|
|
140
|
+
* currentSessionId = event.sessionId,
|
|
141
|
+
* archivedSessionId = event.resumedFrom
|
|
142
|
+
* - sessions.changed reason=create:
|
|
143
|
+
* currentSessionId = payload.sessionId,
|
|
144
|
+
* archivedSessionId 不提供(从文件首位推断)
|
|
145
|
+
*
|
|
146
|
+
* 文件首位(list[0])维护当前活跃 session(未归档),其后是已归档 item(新→旧)。
|
|
147
|
+
*
|
|
148
|
+
* 幂等:head 已是 currentSessionId 且
|
|
149
|
+
* - 无 archivedSessionId,或
|
|
150
|
+
* - archivedSessionId 已存在于 list 中
|
|
151
|
+
* 时整体 no-op(不写盘)。
|
|
152
|
+
*
|
|
153
|
+
* @param {{ agentId: string, sessionKey: string, currentSessionId: string, archivedSessionId?: string }} params
|
|
117
154
|
*/
|
|
118
|
-
async
|
|
119
|
-
if (!sessionKey || !
|
|
155
|
+
async recordSessionTransition({ agentId, sessionKey, currentSessionId, archivedSessionId }) {
|
|
156
|
+
if (!sessionKey || !currentSessionId) return;
|
|
157
|
+
// 规范化:archivedSessionId 与 currentSessionId 相同属上游契约异常(resumedFrom 不应等于 sessionId),
|
|
158
|
+
// 丢弃避免在空 list 起手时写出"同 sid 既是头又是归档"的双份记录。打 remoteLog 暴露信号
|
|
159
|
+
// 让运维捕捉到上游可能的回归——只在真触发时打一次,正常路径噪声为零。
|
|
160
|
+
if (archivedSessionId === currentSessionId) {
|
|
161
|
+
remoteLog(`chat-history.archived-equals-current sessionKey=${sessionKey} sid=${currentSessionId}`);
|
|
162
|
+
archivedSessionId = undefined;
|
|
163
|
+
}
|
|
120
164
|
await this.__mutex(agentId).withLock(async () => {
|
|
121
165
|
// 从磁盘重载确保最新状态:list() 无锁覆写 __cache 可能导致缓存过期
|
|
122
166
|
await this.__reloadFromDisk(agentId);
|
|
@@ -124,23 +168,57 @@ export class ChatHistoryManager {
|
|
|
124
168
|
if (!Array.isArray(store[sessionKey])) {
|
|
125
169
|
store[sessionKey] = [];
|
|
126
170
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
sessionId
|
|
132
|
-
|
|
133
|
-
|
|
171
|
+
const list = store[sessionKey];
|
|
172
|
+
const head = list[0];
|
|
173
|
+
const headIsCurrent = head && !head.archivedAt && head.sessionId === currentSessionId;
|
|
174
|
+
const archivedAlreadyInList = archivedSessionId
|
|
175
|
+
&& list.some((it) => it.sessionId === archivedSessionId);
|
|
176
|
+
// 完全 no-op:head 已是 current 且无新 archived 要追加
|
|
177
|
+
if (headIsCurrent && (!archivedSessionId || archivedAlreadyInList)) return;
|
|
178
|
+
|
|
179
|
+
// head 已是 current(双源到达:第二个事件提供了之前未带的 archivedSessionId)
|
|
180
|
+
// → 不动 head,仅在第二位插入 archivedSessionId
|
|
181
|
+
if (headIsCurrent) {
|
|
182
|
+
list.splice(1, 0, { sessionId: archivedSessionId, archivedAt: Date.now() });
|
|
183
|
+
await this.__persist(agentId);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// stale 事件防御:currentSessionId 已存在于 list 其他位置(即已被归档)。
|
|
188
|
+
// 此时若继续走"翻 head"会把真正活跃的头错翻成归档,并让该 sid 在 list 中重复出现。
|
|
189
|
+
// 触发场景:A→B→C 快速连续 reset 时,hook 与 sessions.changed 跨通道乱序到达,
|
|
190
|
+
// 旧 transition 的事件晚于新 transition 的事件被处理。
|
|
191
|
+
if (list.some((it) => it.sessionId === currentSessionId)) return;
|
|
192
|
+
|
|
193
|
+
// 一般路径:翻 head 为归档(若未归档),然后处理 archivedSessionId,最后头插新 head
|
|
194
|
+
// head 已归档(老格式磁盘数据 / 已正常归档的 list)→ 跳过翻动直接 unshift
|
|
195
|
+
if (head && !head.archivedAt) {
|
|
196
|
+
head.archivedAt = Date.now();
|
|
197
|
+
}
|
|
198
|
+
// archivedSessionId 与 head 不同且不在 list → 在第二位追加(保证不丢前任记录)
|
|
199
|
+
if (archivedSessionId
|
|
200
|
+
&& archivedSessionId !== head?.sessionId
|
|
201
|
+
&& !list.some((it) => it.sessionId === archivedSessionId)) {
|
|
202
|
+
list.splice(1, 0, { sessionId: archivedSessionId, archivedAt: Date.now() });
|
|
203
|
+
}
|
|
204
|
+
list.unshift({ sessionId: currentSessionId });
|
|
134
205
|
await this.__persist(agentId);
|
|
135
206
|
});
|
|
136
207
|
}
|
|
137
208
|
|
|
138
209
|
/**
|
|
139
|
-
* 获取指定 chat
|
|
210
|
+
* 获取指定 chat 的 session 列表(原始数组:首位可能是未归档的当前活跃 session)。
|
|
140
211
|
* 每次调用从磁盘重载,确保跨模块实例一致性
|
|
141
212
|
* (OpenClaw 的 hook 和 gateway method 可能在不同 ESM 模块实例中运行)。
|
|
213
|
+
*
|
|
214
|
+
* RPC 契约:`coclaw.chatHistory.list` 直接透传本返回值,不做服务端过滤;调用方
|
|
215
|
+
* (UI / 其它消费者)按 `archivedAt != null` 自行过滤未归档头与孤儿历史段。
|
|
216
|
+
*
|
|
217
|
+
* **返回值是 cache 引用,调用方禁止 mutate**(不要 splice / sort / 改 item 字段);
|
|
218
|
+
* RPC handler 立刻 JSON 序列化所以无副作用,进程内消费者若需要修改请先 deep copy。
|
|
219
|
+
*
|
|
142
220
|
* @param {{ agentId: string, sessionKey: string }} params
|
|
143
|
-
* @returns {Promise<{ history: { sessionId: string, archivedAt
|
|
221
|
+
* @returns {Promise<{ history: { sessionId: string, archivedAt?: number }[] }>}
|
|
144
222
|
*/
|
|
145
223
|
async list({ agentId, sessionKey }) {
|
|
146
224
|
await this.__reloadFromDisk(agentId);
|
|
@@ -162,8 +240,8 @@ export class ChatHistoryManager {
|
|
|
162
240
|
this.__cache.set(agentId, data);
|
|
163
241
|
return;
|
|
164
242
|
}
|
|
165
|
-
} catch {
|
|
166
|
-
|
|
243
|
+
} catch (err) {
|
|
244
|
+
this.__reportLoadError(filePath, err, '__reloadFromDisk');
|
|
167
245
|
}
|
|
168
246
|
if (!this.__cache.has(agentId)) {
|
|
169
247
|
this.__cache.set(agentId, emptyStore());
|
package/src/claw-paths.js
CHANGED
|
@@ -16,6 +16,7 @@ import nodePath from 'node:path';
|
|
|
16
16
|
import { getRuntime } from './runtime.js';
|
|
17
17
|
|
|
18
18
|
const CHANNEL_ID = 'coclaw';
|
|
19
|
+
const MAIN_AGENT_ID = 'main';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* OpenClaw 真实 state 目录
|
|
@@ -81,4 +82,17 @@ export function sessionTranscriptPath(sessionId, agentId, entry) {
|
|
|
81
82
|
return nodePath.join(agentSessionsDir(agentId), `${sessionId}.jsonl`);
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
/**
|
|
86
|
+
* main agent 的 agentDir(auth-profiles.json / auth-state.json 等凭据文件所在目录)
|
|
87
|
+
*
|
|
88
|
+
* OpenClaw provider-auth helper 的 `agentDir` 入参约定**含 `/agent` 子目录**
|
|
89
|
+
* (上游 `resolveOpenClawAgentDir` 内部就是拼到这一层),见 mental-model 陷阱 #14。
|
|
90
|
+
* 凭据相关 RPC(provider-auth setApiKey / list / remove)一律走 main agent
|
|
91
|
+
* 一份就够——所有 agent 通过 OpenClaw 的层叠合并自动可见(mental-model § 4.2-4.3)。
|
|
92
|
+
* @returns {string}
|
|
93
|
+
*/
|
|
94
|
+
export function mainAgentDir() {
|
|
95
|
+
return nodePath.join(clawStateDir(), 'agents', MAIN_AGENT_ID, 'agent');
|
|
96
|
+
}
|
|
97
|
+
|
|
84
98
|
export { CHANNEL_ID };
|