@coclaw/openclaw-coclaw 0.22.2 → 0.22.4
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/index.js +43 -9
- package/package.json +2 -2
- package/src/auto-upgrade/updater.js +5 -1
- package/src/chat-history-manager/manager.js +7 -1
- package/src/file-manager/handler.js +52 -11
- package/src/model-default/handlers.js +4 -2
- package/src/model-default/index.js +3 -0
- package/src/provider-auth/index.js +3 -0
- package/src/realtime-bridge.js +22 -9
- package/src/remote-log.js +5 -0
- package/src/runtime.js +5 -1
- package/src/session-manager/manager.js +3 -9
- package/src/topic-manager/manager.js +1 -0
- package/src/utils/memory-queue.js +4 -1
- package/src/webrtc/dc-chunking.js +0 -23
- package/src/webrtc/rpc-dc-sender.js +4 -1
- package/src/webrtc/webrtc-peer.js +4 -3
package/index.js
CHANGED
|
@@ -19,10 +19,29 @@ import { decideCancelResponse } from './src/agent-cancel-heuristic.js';
|
|
|
19
19
|
import { remoteLog } from './src/remote-log.js';
|
|
20
20
|
import { registerProviderAuthHandlers } from './src/provider-auth/index.js';
|
|
21
21
|
import { registerModelDefaultHandlers } from './src/model-default/index.js';
|
|
22
|
+
import { getClawConfig } from './src/claw-config.js';
|
|
22
23
|
|
|
23
24
|
import { getPluginVersion, __resetPluginVersion } from './src/plugin-version.js';
|
|
24
25
|
export { getPluginVersion, __resetPluginVersion };
|
|
25
26
|
|
|
27
|
+
// 收纳 register() 在 full 模式启动的 fire-and-forget 初始化任务(topic / chat-history
|
|
28
|
+
// load + reconcile)的完成信号。默认 Promise.resolve() 让 awaitPluginInit() 在 register
|
|
29
|
+
// 未跑或非 full 模式时立即返回。每次 full register 都重置——多次 register 互不串扰。
|
|
30
|
+
let __pluginInitDone = Promise.resolve();
|
|
31
|
+
|
|
32
|
+
export function awaitPluginInit() {
|
|
33
|
+
return __pluginInitDone;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// abort-threw 远端日志节流:gateway 进程活动期间最多上报一次,避免上游 handle.abort
|
|
37
|
+
// 持续抛错时 500ms tick 重试把 remote-log 刷爆。logger.info 已对 abort-threw 静默
|
|
38
|
+
// (见 coclaw.agent.abort handler 注释),但完全失去诊断信号会让运维瞎子,故保留首条。
|
|
39
|
+
let __abortThrewReported = false;
|
|
40
|
+
|
|
41
|
+
export function __resetAbortThrewReported() {
|
|
42
|
+
__abortThrewReported = false;
|
|
43
|
+
}
|
|
44
|
+
|
|
26
45
|
// 侧门注册表观测:patch OpenClaw embeddedRunState.activeRuns 的 set/delete,
|
|
27
46
|
// 用于跟踪 sessionId 何时注册/注销(agent 取消流程实际读取的就是这张表)。
|
|
28
47
|
// OpenClaw 侧门形状变化时(缺失 / 抛异常),通过 remoteLog 上报为升级契约变更的早期信号。
|
|
@@ -61,8 +80,9 @@ function patchMapLogging(map, label, logger) {
|
|
|
61
80
|
}
|
|
62
81
|
const origSet = map.set.bind(map);
|
|
63
82
|
const origDel = map.delete.bind(map);
|
|
64
|
-
//
|
|
65
|
-
//
|
|
83
|
+
// AGENTS.md §"日志器"一般规则是"调用点不要再包 try/catch",此处特例:safeLog/safeSize
|
|
84
|
+
// 是绑到 OpenClaw 内部 Map.set/delete 路径上的诊断仪器,上游若把 Map 换成 throwing-getter
|
|
85
|
+
// Proxy 或 logger 自身异常都不能把宿主的 set/delete 流程带崩,故保留 swallow 兜底
|
|
66
86
|
const safeLog = (msg) => {
|
|
67
87
|
try { logger?.info?.(msg); } catch { /* swallow — diag log 不得影响主流程 */ }
|
|
68
88
|
};
|
|
@@ -171,16 +191,19 @@ const plugin = {
|
|
|
171
191
|
const topicManager = new TopicManager({ logger });
|
|
172
192
|
const chatHistoryManager = new ChatHistoryManager({ logger });
|
|
173
193
|
|
|
174
|
-
// 懒加载 topic / chat history 数据(best-effort
|
|
175
|
-
|
|
194
|
+
// 懒加载 topic / chat history 数据(best-effort,不阻断注册)。
|
|
195
|
+
// 两条 promise 收成一个 bundle 挂到 __pluginInitDone,让测试 / 关心 done 时机的
|
|
196
|
+
// caller 通过 awaitPluginInit() 显式等待——生产 gateway 不调即原 fire-and-forget 语义
|
|
197
|
+
const topicLoadP = topicManager.load('main').catch((err) => {
|
|
176
198
|
logger.warn?.(`[coclaw] topic manager load failed: ${String(err?.message ?? err)}`);
|
|
177
199
|
});
|
|
178
|
-
chatHistoryManager.load('main')
|
|
200
|
+
const chatHistoryLoadP = chatHistoryManager.load('main')
|
|
179
201
|
.then(() => manager.listAllEntries('main'))
|
|
180
202
|
.then((entries) => chatHistoryManager.reconcileAll('main', entries))
|
|
181
203
|
.catch((err) => {
|
|
182
204
|
logger.warn?.(`[coclaw] chat history manager load/reconcile failed: ${String(err?.message ?? err)}`);
|
|
183
205
|
});
|
|
206
|
+
__pluginInitDone = Promise.all([topicLoadP, chatHistoryLoadP]);
|
|
184
207
|
|
|
185
208
|
// 追踪 chat 因 reset 产生的 session 流水。双源回调(hook + sessions.changed)共用 helper。
|
|
186
209
|
// recordSessionTransition 内部已 __reloadFromDisk + mutex,外层无需再 cache.has + load。
|
|
@@ -680,8 +703,8 @@ const plugin = {
|
|
|
680
703
|
const runDuration = typeof params?.runDuration === 'number' ? params.runDuration : undefined;
|
|
681
704
|
const abortDuration = typeof params?.abortDuration === 'number' ? params.abortDuration : undefined;
|
|
682
705
|
const result = decideCancelResponse(abortResult, { runDuration, abortDuration });
|
|
683
|
-
// not-found
|
|
684
|
-
if (result.reason !== 'not-found') {
|
|
706
|
+
// not-found(注册空窗)与 abort-threw(上游 handle.abort 持续抛)都是 UI 500ms tick 重试期常态,跳过 info 日志避免洪水;其余分支保留 info
|
|
707
|
+
if (result.reason !== 'not-found' && result.reason !== 'abort-threw') {
|
|
685
708
|
logger.info?.(`[coclaw.agent.abort] result sessionId=${sessionId} ok=${result.ok}${result.reason ? ` reason=${result.reason}` : ''}${result.error ? ` error=${result.error}` : ''}`);
|
|
686
709
|
}
|
|
687
710
|
if (result.ok) {
|
|
@@ -695,6 +718,12 @@ const plugin = {
|
|
|
695
718
|
// 启发升格:双闸均达阈值,把 not-found 升格为 gone,让 UI 主动 settleByCancel
|
|
696
719
|
remoteLog(`abort.gone sid=${sessionId} runDur=${runDuration} abortDur=${abortDuration}`);
|
|
697
720
|
}
|
|
721
|
+
else if (result.reason === 'abort-threw' && !__abortThrewReported) {
|
|
722
|
+
// 一次性:上游 handle.abort 首次抛错时上报,让运维知道这个 gateway 实例发生过;
|
|
723
|
+
// 后续 tick 重试不再上报,避免被 UI 500ms 拍频刷爆远端日志
|
|
724
|
+
__abortThrewReported = true;
|
|
725
|
+
remoteLog(`abort.threw sid=${sessionId} error=${result.error || ''}`);
|
|
726
|
+
}
|
|
698
727
|
respond(true, result);
|
|
699
728
|
}
|
|
700
729
|
catch (err) {
|
|
@@ -717,7 +746,7 @@ const plugin = {
|
|
|
717
746
|
|
|
718
747
|
const fileHandler = createFileHandler({
|
|
719
748
|
resolveWorkspace: (agentId) => {
|
|
720
|
-
const cfg =
|
|
749
|
+
const cfg = getClawConfig();
|
|
721
750
|
const dir = api.runtime?.agent?.resolveAgentWorkspaceDir(cfg, agentId);
|
|
722
751
|
if (!dir) {
|
|
723
752
|
const err = new Error('Cannot resolve workspace: runtime not available');
|
|
@@ -799,8 +828,13 @@ const plugin = {
|
|
|
799
828
|
|
|
800
829
|
try {
|
|
801
830
|
if (action === 'bind') {
|
|
831
|
+
// 先校验非空 code,避免无效输入触发 doBind 内的 cancelActiveEnroll 副作用
|
|
832
|
+
const code = positionals[0];
|
|
833
|
+
if (typeof code !== 'string' || code.length === 0) {
|
|
834
|
+
return { text: 'Error: binding code is required' };
|
|
835
|
+
}
|
|
802
836
|
const result = await doBind({
|
|
803
|
-
code
|
|
837
|
+
code,
|
|
804
838
|
serverUrl: options.server,
|
|
805
839
|
});
|
|
806
840
|
return { text: bindOk(result) };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coclaw/openclaw-coclaw",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"description": "OpenClaw plugin for remote chat over WebRTC. Run `openclaw coclaw enroll` after install.",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"typecheck": "echo \"No typecheck configured yet (coclaw openclaw plugin)\"",
|
|
51
51
|
"check": "pnpm lint && pnpm typecheck",
|
|
52
52
|
"test:plugin": "node --test src/plugin-mode.test.js",
|
|
53
|
-
"test": "c8 --check-coverage --lines 100 --functions 100 --branches 95 --statements 100 --reporter=text --reporter=lcov bash -c '
|
|
53
|
+
"test": "c8 --check-coverage --lines 100 --functions 100 --branches 95 --statements 100 --reporter=text --reporter=lcov bash -c 'node --test src/**/*.test.js src/*.test.js index.test.js'",
|
|
54
54
|
"e2e": "node --test --test-concurrency=1 e2e/*.e2e.spec.js",
|
|
55
55
|
"verify": "pnpm check && pnpm test",
|
|
56
56
|
"link": "bash ./scripts/link.sh",
|
|
@@ -191,7 +191,11 @@ function loadInstallRecordFromLegacyConfig(pluginId) {
|
|
|
191
191
|
const config = getClawConfig();
|
|
192
192
|
return config?.plugins?.installs?.[pluginId] ?? null;
|
|
193
193
|
}
|
|
194
|
-
catch {
|
|
194
|
+
catch (err) {
|
|
195
|
+
// 与同函数其他 catch 风格对齐:旧版账本读取异常也外推诊断信号,
|
|
196
|
+
// 否则下游只能看到笼统的 "Skipping: not an npm-installed plugin",无定位线索
|
|
197
|
+
/* c8 ignore next -- ?? fallback:err 字段缺省的兜底分支不强制覆盖 */
|
|
198
|
+
remoteLog(`upgrade.legacy-config-read-failed msg=${err?.message ?? String(err)}`);
|
|
195
199
|
return null;
|
|
196
200
|
}
|
|
197
201
|
}
|
|
@@ -39,6 +39,8 @@ export function classifyChatHistorySessionKey(sessionKey) {
|
|
|
39
39
|
// 由 cron 守卫挡住即可,避免与 IM per-account DM accountId="cron"/"subagent" 形态冲突
|
|
40
40
|
// (accountId 仅按 [a-z0-9_-]{1,64} 校验,"cron"/"subagent" 都是合法账户名)。
|
|
41
41
|
if (parts[2] === 'explicit') return { ok: false, reason: 'explicit' };
|
|
42
|
+
// agent:<id>:cron:<jobId>:subagent:<uuid> 形态命中 cron 守卫返回 reason='cron',
|
|
43
|
+
// 丢一层嵌套语义;上报信号目前无下游按 reason 细分计数/告警,留作识别到才说
|
|
42
44
|
if (parts[2] === 'cron') return { ok: false, reason: 'cron' };
|
|
43
45
|
if (parts[2] === 'subagent') return { ok: false, reason: 'subagent' };
|
|
44
46
|
return { ok: true, reason: null };
|
|
@@ -76,7 +78,8 @@ export class ChatHistoryManager {
|
|
|
76
78
|
constructor(opts = {}) {
|
|
77
79
|
this.__resolveSessionsDir = opts.resolveSessionsDir ?? agentSessionsDir;
|
|
78
80
|
this.__logger = opts.logger ?? console;
|
|
79
|
-
|
|
81
|
+
// readFile / writeJsonFile DI 注入点用于精细 mock;不注入时默认走 fs.readFile + atomicWriteJsonFile。
|
|
82
|
+
// 默认构造路径由"通过 setRuntime 端到端落盘"测试覆盖。
|
|
80
83
|
this.__readFile = opts.readFile ?? fs.readFile;
|
|
81
84
|
this.__writeJsonFile = opts.writeJsonFile ?? atomicWriteJsonFile;
|
|
82
85
|
// 内存缓存:agentId -> { version, [sessionKey]: [...] }
|
|
@@ -192,6 +195,7 @@ export class ChatHistoryManager {
|
|
|
192
195
|
for (let i = 1; i < list.length; i++) {
|
|
193
196
|
const item = list[i];
|
|
194
197
|
if (!item || typeof item !== 'object' || item.archivedAt) continue;
|
|
198
|
+
// 复用 archivedAt 写补登时间;事后与正常归档无法区分,线下分析靠下方 chat-history.sanitize-coerce 上报信号
|
|
195
199
|
item.archivedAt = now;
|
|
196
200
|
this.__logger.warn?.(
|
|
197
201
|
`[coclaw] chat-history sanitize: non-head unarchived entry coerced sessionKey=${sessionKey} sid=${item.sessionId}`,
|
|
@@ -351,6 +355,8 @@ export class ChatHistoryManager {
|
|
|
351
355
|
} catch (err) {
|
|
352
356
|
this.__reportLoadError(filePath, err, '__reloadFromDisk');
|
|
353
357
|
}
|
|
358
|
+
// 读盘失败 + cache 已热身 → 保留旧 cache(best-effort 容忍)。下次写盘会把旧数据回灌磁盘自愈;
|
|
359
|
+
// 但若长期无新写入,磁盘上的损坏内容不会被主动修复
|
|
354
360
|
if (!this.__cache.has(agentId)) {
|
|
355
361
|
this.__cache.set(agentId, emptyStore());
|
|
356
362
|
}
|
|
@@ -643,6 +643,17 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
643
643
|
let wsBackpressureCount = 0;
|
|
644
644
|
let wsError = false;
|
|
645
645
|
let finishing = false;
|
|
646
|
+
// 一次性 cleanup attach 守卫:dc.onerror 后 pion 通常紧跟 dc.onclose,
|
|
647
|
+
// 两条 cleanup 路径若都向 ws 挂 'close' 监听会重复 safeUnlink。
|
|
648
|
+
// 用 flag 而不是 wsError 守卫,是为了不破坏 SIZE_EXCEEDED 等"先同步 unlink、
|
|
649
|
+
// 再触发 dc.close → dc.onclose"路径:那条路径同样设 wsError=true 但**没**
|
|
650
|
+
// attach 过,仍需 dc.onclose 兜底 attach(fopen 完成后才删到真文件)
|
|
651
|
+
let cleanupAttached = false;
|
|
652
|
+
function attachTmpCleanupOnce() {
|
|
653
|
+
if (cleanupAttached) return;
|
|
654
|
+
cleanupAttached = true;
|
|
655
|
+
ws.on('close', () => safeUnlink(tmpPath));
|
|
656
|
+
}
|
|
646
657
|
|
|
647
658
|
// --- 受控写入:中间缓冲 + drain 循环 ---
|
|
648
659
|
const pendingQueue = [];
|
|
@@ -672,9 +683,11 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
672
683
|
draining = false;
|
|
673
684
|
pendingQueue.length = 0;
|
|
674
685
|
log.warn?.(`[coclaw/file] drainLoop write error: ${err.message}`);
|
|
686
|
+
// 走安全网而非同步 safeUnlink:fopen 未完成时 sync unlink 扑空被吞,
|
|
687
|
+
// fopen 后留孤儿。attach 在 destroy 之前确保 'close' emit 时 listener 已就位
|
|
688
|
+
attachTmpCleanupOnce();
|
|
675
689
|
ws.destroy();
|
|
676
690
|
if (!dcClosed) sendError(dc, 'WRITE_FAILED', err.message);
|
|
677
|
-
safeUnlink(tmpPath);
|
|
678
691
|
const elapsed = Date.now() - startTime;
|
|
679
692
|
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=drain-write-error err=${err.message} received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms`);
|
|
680
693
|
return;
|
|
@@ -692,8 +705,17 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
692
705
|
function finishUpload() {
|
|
693
706
|
if (finishing) return;
|
|
694
707
|
finishing = true;
|
|
695
|
-
ws.end(async () => {
|
|
708
|
+
ws.end(async (err) => {
|
|
696
709
|
const elapsed = Date.now() - startTime;
|
|
710
|
+
// ws 在 'finish' 之前被 destroy 时,Node Writable 会用 err 调 pending end cb
|
|
711
|
+
//(destroy(e) 用 e;destroy() 用 ERR_STREAM_DESTROYED)。任何 ws 中途夭折的
|
|
712
|
+
// 产线路径都已经在 ws.on('error') / dc.onclose / dc.onerror / drainLoop catch
|
|
713
|
+
// 里 attach 过清理 + 打过更具体的 fail log;这里只需兜底 attach 一次(idempotent)
|
|
714
|
+
// 防御未来路径漏挂,并跳过 rename / size 校验 / 成功响应
|
|
715
|
+
if (err) {
|
|
716
|
+
attachTmpCleanupOnce();
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
697
719
|
if (dcClosed) {
|
|
698
720
|
safeUnlink(tmpPath);
|
|
699
721
|
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=dc-closed-before-flush received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
@@ -795,13 +817,20 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
795
817
|
if (doneReceived) {
|
|
796
818
|
// done 已收到但 drain 未完成 — finishUpload 中会检测 dcClosed 并清理 tmp
|
|
797
819
|
if (!finishing) finishUpload();
|
|
798
|
-
|
|
799
|
-
ws.destroy();
|
|
800
|
-
safeUnlink(tmpPath);
|
|
801
|
-
const elapsed = Date.now() - startTime;
|
|
802
|
-
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=dc-closed received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
803
|
-
log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=dc-closed received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
820
|
+
return;
|
|
804
821
|
}
|
|
822
|
+
// 等 ws 关闭后再 unlink——fopen 未完成时直接 safeUnlink 会扑空被吞,
|
|
823
|
+
// 随后 fopen 完成创建文件却没人清,留下孤儿 tmp。flag 守卫保证 dc.onerror
|
|
824
|
+
// 已 attach 过时不会重挂第二个 listener
|
|
825
|
+
attachTmpCleanupOnce();
|
|
826
|
+
ws.destroy();
|
|
827
|
+
// dc.onerror 已打过 reason=dc-error fail 日志 → 跳过 reason=dc-closed
|
|
828
|
+
// 避免双触发时 fail 日志重复(SIZE_EXCEEDED 路径打的是 reason=size-exceeded
|
|
829
|
+
// 的 reject 日志,不在此 fail 命名空间,不受影响)
|
|
830
|
+
if (wsError) return;
|
|
831
|
+
const elapsed = Date.now() - startTime;
|
|
832
|
+
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=dc-closed received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
833
|
+
log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=dc-closed received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
805
834
|
};
|
|
806
835
|
|
|
807
836
|
// pion 异步 send 错误经此回调上报;触发已有清理路径
|
|
@@ -810,8 +839,9 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
810
839
|
wsError = true;
|
|
811
840
|
draining = false;
|
|
812
841
|
pendingQueue.length = 0;
|
|
842
|
+
// 同上:等 ws 关闭后再 unlink,避开 fopen-vs-unlink race
|
|
843
|
+
attachTmpCleanupOnce();
|
|
813
844
|
ws.destroy();
|
|
814
|
-
safeUnlink(tmpPath);
|
|
815
845
|
const elapsed = Date.now() - startTime;
|
|
816
846
|
/* c8 ignore next -- ?? fallback for non-Error throw */
|
|
817
847
|
const errMsg = err?.message ?? String(err);
|
|
@@ -829,11 +859,16 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
829
859
|
draining = false;
|
|
830
860
|
pendingQueue.length = 0;
|
|
831
861
|
log.warn?.(`[coclaw/file] write stream error: ${err.message}`);
|
|
862
|
+
// 安全网先装:sendError 会同步 dc.close → dc.onclose,doneReceived=true 且
|
|
863
|
+
// !finishing 时会同步重入 finishUpload → ws.end,整条 sync 链全跑完才回到这里。
|
|
864
|
+
// 与 drainLoop catch 顺序对齐。显式 ws.destroy() 不依赖 Node autoDestroy
|
|
865
|
+
// (注入的非标准 stream 可能不触发)触发 ws 关闭流程;listener 在 'close' emit 时 fire
|
|
866
|
+
attachTmpCleanupOnce();
|
|
867
|
+
ws.destroy();
|
|
832
868
|
if (!dcClosed) {
|
|
833
869
|
const code = err.code === 'ENOSPC' ? 'DISK_FULL' : 'WRITE_FAILED';
|
|
834
870
|
sendError(dc, code, err.message);
|
|
835
871
|
}
|
|
836
|
-
safeUnlink(tmpPath);
|
|
837
872
|
const elapsed = Date.now() - startTime;
|
|
838
873
|
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=write-error err=${err.code || err.message} received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms`);
|
|
839
874
|
log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=write-error received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms err=${err.code || err.message}`);
|
|
@@ -901,7 +936,13 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
901
936
|
}
|
|
902
937
|
|
|
903
938
|
function safeUnlink(filePath) {
|
|
904
|
-
|
|
939
|
+
// ENOENT 静默:tmp 可能尚未创建(fopen 未完成)或已被另一路径删过,均属预期。
|
|
940
|
+
// 其余错(EACCES / EBUSY / EIO)若完全无声会让孤儿 tmp 悄悄积累——warn 一下,
|
|
941
|
+
// 仍保留 fire-and-forget 语义;scheduleTmpCleanup 仍是最终兜底
|
|
942
|
+
_unlink(filePath).catch((err) => {
|
|
943
|
+
if (err?.code === 'ENOENT') return;
|
|
944
|
+
log.warn?.(`[coclaw/file] safeUnlink failed: ${filePath} ${err?.code || err?.message}`);
|
|
945
|
+
});
|
|
905
946
|
}
|
|
906
947
|
|
|
907
948
|
return {
|
|
@@ -105,11 +105,13 @@ export function buildModelDefaultHandlers({ sdk, loadConfig, resolveAgentDir })
|
|
|
105
105
|
respondInvalid(respond, 'primary is required');
|
|
106
106
|
return;
|
|
107
107
|
}
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
108
|
+
const rawPrimary = params.primary;
|
|
109
|
+
if (rawPrimary !== null && typeof rawPrimary !== 'string') {
|
|
110
110
|
respondInvalid(respond, 'primary must be a string or null');
|
|
111
111
|
return;
|
|
112
112
|
}
|
|
113
|
+
// 复制粘贴常带前后空白;trim 后再做形态/凭据/catalog 校验,并按 trim 后的值落盘
|
|
114
|
+
const primary = rawPrimary === null ? null : rawPrimary.trim();
|
|
113
115
|
if (primary !== null && primary.length === 0) {
|
|
114
116
|
respondInvalid(respond, 'primary must be a non-empty string or null');
|
|
115
117
|
return;
|
|
@@ -17,6 +17,9 @@ import { buildModelDefaultHandlers } from './handlers.js';
|
|
|
17
17
|
import { mainAgentDir } from '../claw-paths.js';
|
|
18
18
|
import { getClawConfig } from '../claw-config.js';
|
|
19
19
|
|
|
20
|
+
// link-UNSAFE:模块级 dedup 缓存。`--link` 模式下两实例各自 lazy-load 一次
|
|
21
|
+
// SDK(结果一致、运行无伤但去重失效)。当前仅 RPC handler 走该路径——
|
|
22
|
+
// 不要在 hook 回调里访问本模块的 export。详见 docs/module-boundaries.md。
|
|
20
23
|
let _configMutationP;
|
|
21
24
|
let _modelsP;
|
|
22
25
|
let _providerAuthP;
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
import { buildProviderAuthHandlers } from './handlers.js';
|
|
11
11
|
import { mainAgentDir } from '../claw-paths.js';
|
|
12
12
|
|
|
13
|
+
// link-UNSAFE:模块级 dedup 缓存。`--link` 模式下两实例各自 lazy-load 一次
|
|
14
|
+
// SDK(结果一致、运行无伤但去重失效)。当前仅 RPC handler 走该路径——
|
|
15
|
+
// 不要在 hook 回调里访问本模块的 export。详见 docs/module-boundaries.md。
|
|
13
16
|
let _sdkPromise;
|
|
14
17
|
|
|
15
18
|
// 默认 loader 仅作 fallback:生产路径必须由入口(plugins/openclaw/index.js)注入 loadSdk,
|
package/src/realtime-bridge.js
CHANGED
|
@@ -112,6 +112,7 @@ export function defaultResolveGatewayAuthToken() {
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
catch (err) {
|
|
115
|
+
// register 早期 / runtime 未注入完成时也可能被触达,注入 logger 此时未必可用,用 console.warn 兜底
|
|
115
116
|
console.warn?.(`[coclaw] resolve gateway auth token failed: ${String(err?.message ?? err)}`);
|
|
116
117
|
}
|
|
117
118
|
const envToken = process.env.OPENCLAW_GATEWAY_TOKEN?.trim();
|
|
@@ -584,12 +585,11 @@ export class RealtimeBridge {
|
|
|
584
585
|
const hostName = getHostName();
|
|
585
586
|
const pluginVersion = await getPluginVersion();
|
|
586
587
|
const agentModels = await this.__collectAgentModels();
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
});
|
|
588
|
+
// 采集失败时漏报 agentModels(而非显式 null):server / UI 走 patch 语义
|
|
589
|
+
// 保留旧值,避免 admin 仪表盘瞬时清空(OpenClaw manifest cache 偶发卡顿时易触发)
|
|
590
|
+
const payload = { name, hostName, pluginVersion };
|
|
591
|
+
if (agentModels !== null) payload.agentModels = agentModels;
|
|
592
|
+
broadcastPluginEvent('coclaw.info.updated', payload);
|
|
593
593
|
}
|
|
594
594
|
catch (err) {
|
|
595
595
|
/* c8 ignore next 2 -- 防御性兜底 */
|
|
@@ -603,7 +603,9 @@ export class RealtimeBridge {
|
|
|
603
603
|
*/
|
|
604
604
|
async __collectAgentModels() {
|
|
605
605
|
try {
|
|
606
|
-
|
|
606
|
+
// OpenClaw agents.list 走 manifest cache 加载链路,偶发 cache mismatch 时 ~10s 才回
|
|
607
|
+
// (issue #80697);timeout 给到 30s 让本地 cache miss 有恢复窗口
|
|
608
|
+
const result = await this.__gatewayRpc('agents.list', {}, { timeoutMs: 30000 });
|
|
607
609
|
if (result?.ok !== true) return null;
|
|
608
610
|
const agents = result?.response?.payload?.agents;
|
|
609
611
|
if (!Array.isArray(agents)) return null;
|
|
@@ -1128,9 +1130,11 @@ export class RealtimeBridge {
|
|
|
1128
1130
|
this.logger.warn?.(
|
|
1129
1131
|
`[coclaw] dc gateway req invalid: id=${typeof payload?.id} method=${typeof payload?.method}`,
|
|
1130
1132
|
);
|
|
1131
|
-
// 有合法 id 时回 INVALID_REQUEST 让发起方尽快放弃等待;id 不合法时只能 drop
|
|
1133
|
+
// 有合法 id 时回 INVALID_REQUEST 让发起方尽快放弃等待;id 不合法时只能 drop。
|
|
1134
|
+
// 单播:connId 来自每条连接的闭包必非空,只发给发出乱码请求的连接,
|
|
1135
|
+
// 不打扰其他客户端(旁边 OFFLINE / SEND_FAILED 分支属系统级公告保留广播语义)
|
|
1132
1136
|
if (hasValidId) {
|
|
1133
|
-
this.webrtcPeer?.
|
|
1137
|
+
await this.webrtcPeer?.sendTo(connId, {
|
|
1134
1138
|
type: 'res',
|
|
1135
1139
|
id: payload.id,
|
|
1136
1140
|
ok: false,
|
|
@@ -1604,6 +1608,8 @@ export class RealtimeBridge {
|
|
|
1604
1608
|
});
|
|
1605
1609
|
this.__runEventRoutes.init();
|
|
1606
1610
|
// 外线(plugin↔远端 server)先发起 connectIfNeeded:仅创建 WebSocket 即返回,不阻塞内线。
|
|
1611
|
+
// 不包 try/catch:__connectIfNeeded → getBindingsPath → pluginDir 链路依赖 runtime 已注入,
|
|
1612
|
+
// register full 模式下 setRuntime 必然先于 service.start 完成,pluginDir 不会抛
|
|
1607
1613
|
await this.__connectIfNeeded();
|
|
1608
1614
|
// 三条线各自独立启动:内线(plugin↔本机 gateway)由 start() 主动触发,
|
|
1609
1615
|
// 不再依附于外线 open。即便外线建连失败/未配置 token,内线仍能起来支撑 DC RPC。
|
|
@@ -1717,6 +1723,13 @@ export class RealtimeBridge {
|
|
|
1717
1723
|
// restart(opts) — 无论当前状态,确保 bridge 以给定 opts 运行(幂等)
|
|
1718
1724
|
// stop() — 停止并销毁 singleton
|
|
1719
1725
|
// 调用方无需感知 singleton 是否为 null,选"要运行"或"要停止"即可。
|
|
1726
|
+
//
|
|
1727
|
+
// link-UNSAFE 警告:以下 singleton 状态与所有读 singleton 的 export(restart /
|
|
1728
|
+
// stop / waitForSessionsReady / ensureAgentSession / gatewayAgentRpc /
|
|
1729
|
+
// broadcastPluginEvent)在 `--link` 安装模式下,hook 路径与 RPC 路径可能拿
|
|
1730
|
+
// 到不同 ESM 模块实例 → 两份独立 singleton。**不要在 api.on(...) hook 回调内
|
|
1731
|
+
// 调用本文件的任何 export**。hook 内若需触发 bridge 副作用,请走 RPC
|
|
1732
|
+
// (api.callGatewayMethod('coclaw.xxx', ...))。详见 docs/module-boundaries.md。
|
|
1720
1733
|
|
|
1721
1734
|
let singleton = null;
|
|
1722
1735
|
|
package/src/remote-log.js
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 将诊断日志缓冲并通过 WS 通道推送到 CoClaw server。
|
|
5
5
|
* 单例模式——各模块直接 import { remoteLog } 使用。
|
|
6
|
+
*
|
|
7
|
+
* link-UNSAFE:buffer / sender / flushing 都是模块级状态。`--link` 模式下
|
|
8
|
+
* hook 实例可能从未被 setSender 注入 → hook 路径调 remoteLog 会落到没装
|
|
9
|
+
* sender 的实例 → 静默丢日志。**不要在 hook 回调内调用 remoteLog**;
|
|
10
|
+
* 要发就用 hook 入参里的 logger。详见 docs/module-boundaries.md。
|
|
6
11
|
*/
|
|
7
12
|
|
|
8
13
|
const MAX_BUFFER = 1000;
|
package/src/runtime.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
// runtime 单例:在 plugin 模式下由 register()
|
|
1
|
+
// runtime 单例:在 plugin 模式下由 register() 注入。
|
|
2
|
+
// link-UNSAFE:`--link` 安装模式下 hook 与 RPC handler 可能跑在不同 ESM 实例 →
|
|
3
|
+
// 两份独立 runtime;hook 实例从未被 setRuntime → getRuntime() 返回 null。
|
|
4
|
+
// **不要在 hook 回调内调用 getRuntime()**——hook 入参 `api` 已带 runtime。
|
|
5
|
+
// 详见 docs/module-boundaries.md。
|
|
2
6
|
let runtime = null;
|
|
3
7
|
|
|
4
8
|
export function setRuntime(rt) {
|
|
@@ -83,24 +83,22 @@ function shouldReplaceByPriority(current, next) {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
export function createSessionManager(options = {}) {
|
|
86
|
-
/* c8 ignore next */
|
|
87
86
|
const logger = options.logger ?? console;
|
|
88
87
|
const resolveSessionsDir = options.resolveSessionsDir ?? agentSessionsDir;
|
|
89
88
|
const resolveStorePath = options.resolveStorePath ?? sessionStorePath;
|
|
90
89
|
const resolveTranscriptPath = options.resolveTranscriptPath ?? sessionTranscriptPath;
|
|
91
90
|
|
|
92
91
|
function sessionsDir(agentId = 'main') {
|
|
93
|
-
/* c8 ignore next */
|
|
94
92
|
const aid = typeof agentId === 'string' && agentId.trim() ? agentId.trim() : 'main';
|
|
95
93
|
return resolveSessionsDir(aid);
|
|
96
94
|
}
|
|
97
95
|
|
|
98
96
|
async function readIndex(agentId = 'main') {
|
|
99
|
-
/* c8 ignore next */
|
|
100
97
|
const aid = typeof agentId === 'string' && agentId.trim() ? agentId.trim() : 'main';
|
|
101
98
|
const file = resolveStorePath(aid);
|
|
102
99
|
const data = await readJsonSafe(file, {});
|
|
103
|
-
|
|
100
|
+
// readJsonSafe 抛错时返回 {}(已是 object),此处兜底 sessions.json 内容是合法 JSON
|
|
101
|
+
// 但非 object(number / string / boolean / null / array 由下游 listAllEntries 单独处理)
|
|
104
102
|
if (!data || typeof data !== 'object') return {};
|
|
105
103
|
return data;
|
|
106
104
|
}
|
|
@@ -176,7 +174,6 @@ export function createSessionManager(options = {}) {
|
|
|
176
174
|
// 补充 sessions.json 中有索引但无 transcript 文件的 session(如 reset 后未对话、新建 session)
|
|
177
175
|
for (const [sessionKey, entry] of Object.entries(index)) {
|
|
178
176
|
const sid = entry?.sessionId;
|
|
179
|
-
/* c8 ignore next -- !sid 防御性检查 */
|
|
180
177
|
if (!sid || grouped.has(sid)) continue;
|
|
181
178
|
grouped.set(sid, {
|
|
182
179
|
sessionId: sid,
|
|
@@ -184,7 +181,7 @@ export function createSessionManager(options = {}) {
|
|
|
184
181
|
indexed: true,
|
|
185
182
|
archiveType: 'live',
|
|
186
183
|
fileName: null,
|
|
187
|
-
|
|
184
|
+
// entry.updatedAt 缺失或非数字时回落 0;UI 端按 updatedAt 排序时无 transcript 项排到末位
|
|
188
185
|
updatedAt: entry.updatedAt ?? 0,
|
|
189
186
|
size: 0,
|
|
190
187
|
});
|
|
@@ -284,12 +281,10 @@ export function createSessionManager(options = {}) {
|
|
|
284
281
|
all.push(JSON.parse(line));
|
|
285
282
|
}
|
|
286
283
|
catch (err) {
|
|
287
|
-
/* c8 ignore next -- ?./?? fallback */
|
|
288
284
|
logger.warn?.(`[session-manager] bad json line skipped: ${String(err?.message ?? err)}`);
|
|
289
285
|
}
|
|
290
286
|
}
|
|
291
287
|
const messages = all.slice(cursor, cursor + limit);
|
|
292
|
-
/* c8 ignore next */
|
|
293
288
|
const nextCursor = cursor + limit < all.length ? String(cursor + limit) : null;
|
|
294
289
|
return {
|
|
295
290
|
agentId,
|
|
@@ -332,7 +327,6 @@ export function createSessionManager(options = {}) {
|
|
|
332
327
|
messages.push(row);
|
|
333
328
|
}
|
|
334
329
|
catch (err) {
|
|
335
|
-
/* c8 ignore next -- ?./?? fallback */
|
|
336
330
|
logger.warn?.(`[session-manager] bad json line skipped: ${String(err?.message ?? err)}`);
|
|
337
331
|
}
|
|
338
332
|
}
|
|
@@ -229,6 +229,7 @@ export class TopicManager {
|
|
|
229
229
|
const srcPath = nodePath.join(this.__sessionsDir(agentId), `${topicId}.jsonl`);
|
|
230
230
|
const tempId = randomUUID();
|
|
231
231
|
const tempPath = nodePath.join(this.__sessionsDir(agentId), `${tempId}.jsonl`);
|
|
232
|
+
// fs.copyFile 非 atomic;崩在半路的临时 *.jsonl 可能被 session-manager 扫到列为幽灵会话,用户重试导出即恢复
|
|
232
233
|
await this.__copyFile(srcPath, tempPath);
|
|
233
234
|
return { tempId, tempPath };
|
|
234
235
|
}
|
|
@@ -282,8 +282,11 @@ class MemoryQueue {
|
|
|
282
282
|
return this.tag ? ` ${this.tag}` : '';
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
// AGENTS.md §"日志器" 一般规则是"调用点不要再包 try/catch",此处特例:
|
|
286
|
+
// __safeWarn 是 enqueue 同步入口的兜底,enqueue 对外承诺不抛业务无关错误,
|
|
287
|
+
// 哪怕 logger 自身坏了也不能传染到 RPC 数据通路,故保留 swallow
|
|
285
288
|
__safeWarn(msg) {
|
|
286
|
-
try { this.logger.warn?.(`[rpc-queue${this.__tagSuffix()}] ${msg}`); } catch { /*
|
|
289
|
+
try { this.logger.warn?.(`[rpc-queue${this.__tagSuffix()}] ${msg}`); } catch { /* swallow */ }
|
|
287
290
|
}
|
|
288
291
|
}
|
|
289
292
|
|
|
@@ -60,29 +60,6 @@ export function buildChunks(jsonStr, maxMessageSize, getNextMsgId) {
|
|
|
60
60
|
return chunks;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/**
|
|
64
|
-
* 按需分片并发送消息(薄包装:buildChunks + dc.send)
|
|
65
|
-
* 注意:无应用层流控;生产路径请使用 MemoryQueue + RpcDcSender
|
|
66
|
-
* @param {object} dc - DataChannel
|
|
67
|
-
* @param {string} jsonStr - 已序列化的 JSON 字符串
|
|
68
|
-
* @param {number} maxMessageSize - 对端声明的 maxMessageSize
|
|
69
|
-
* @param {() => number} getNextMsgId - 获取下一个 msgId
|
|
70
|
-
* @param {object} [logger] - 可选 logger
|
|
71
|
-
*/
|
|
72
|
-
export function chunkAndSend(dc, jsonStr, maxMessageSize, getNextMsgId, logger) {
|
|
73
|
-
const chunks = buildChunks(jsonStr, maxMessageSize, getNextMsgId);
|
|
74
|
-
if (!chunks) {
|
|
75
|
-
dc.send(jsonStr);
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
const msgId = chunks[0].readUInt32BE(1);
|
|
79
|
-
const totalBytes = chunks.reduce((n, c) => n + (c.length - HEADER_SIZE), 0);
|
|
80
|
-
logger?.info?.(`[dc-chunking] chunking msgId=${msgId}: ${totalBytes} bytes → ${chunks.length} chunks (maxMsgSize=${maxMessageSize})`);
|
|
81
|
-
for (const chunk of chunks) {
|
|
82
|
-
dc.send(chunk);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
63
|
/**
|
|
87
64
|
* 创建分片重组器
|
|
88
65
|
* @param {(jsonStr: string) => void} onComplete - 完整消息回调
|
|
@@ -160,8 +160,11 @@ class RpcDcSender {
|
|
|
160
160
|
return this.tag ? ` ${this.tag}` : '';
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
// AGENTS.md §"日志器" 一般规则是"调用点不要再包 try/catch",此处特例:
|
|
164
|
+
// __safeWarn 在 send/BAL 热路径里调,logger 异常不能升级为协议错(caller 只
|
|
165
|
+
// 等 RPC 应答,不该收到 logger 自身的 stack),故保留 swallow
|
|
163
166
|
__safeWarn(msg) {
|
|
164
|
-
try { this.logger.warn?.(`[rpc-dc-sender${this.__tagSuffix()}] ${msg}`); } catch { /*
|
|
167
|
+
try { this.logger.warn?.(`[rpc-dc-sender${this.__tagSuffix()}] ${msg}`); } catch { /* swallow */ }
|
|
165
168
|
}
|
|
166
169
|
|
|
167
170
|
__safeRemoteLog(text) {
|
|
@@ -996,9 +996,10 @@ export class WebRtcPeer {
|
|
|
996
996
|
// 'fbq' 模式下若 queueDir 不可用则降级到 mem,避免阻塞装配。
|
|
997
997
|
// 同 connId race 隔离(决策 4):FBQ id 加唯一后缀 ${connId}-${ts}-${nonce},
|
|
998
998
|
// 让新旧实例文件名物理不同,destroy/init 期间互不踩踏。MemoryQueue 不碰 fs,无此需求。
|
|
999
|
-
// connId
|
|
1000
|
-
//
|
|
1001
|
-
// queue
|
|
999
|
+
// connId 字符集契约:FBQ / MemoryQueue 共用 `^[A-Za-z0-9._-]+$` 校验。
|
|
1000
|
+
// 上游 server 分配 connId 形如 `c_<digits>` 符合契约;若 server 引入特殊字符,
|
|
1001
|
+
// queue 构造抛 TypeError,由 __setupDataChannel 的 .catch 兜底 warn。
|
|
1002
|
+
// 完整契约 / 违反后果 / 修复方向见 docs/connid-contract.md
|
|
1002
1003
|
const useFbq = this.__rpcQueueImpl === 'fbq' && !!this.__queueDir;
|
|
1003
1004
|
const fbqFallback = !useFbq && this.__rpcQueueImpl === 'fbq';
|
|
1004
1005
|
const queue = useFbq
|