@coclaw/openclaw-coclaw 0.22.2 → 0.22.3

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 CHANGED
@@ -23,6 +23,15 @@ import { registerModelDefaultHandlers } from './src/model-default/index.js';
23
23
  import { getPluginVersion, __resetPluginVersion } from './src/plugin-version.js';
24
24
  export { getPluginVersion, __resetPluginVersion };
25
25
 
26
+ // 收纳 register() 在 full 模式启动的 fire-and-forget 初始化任务(topic / chat-history
27
+ // load + reconcile)的完成信号。默认 Promise.resolve() 让 awaitPluginInit() 在 register
28
+ // 未跑或非 full 模式时立即返回。每次 full register 都重置——多次 register 互不串扰。
29
+ let __pluginInitDone = Promise.resolve();
30
+
31
+ export function awaitPluginInit() {
32
+ return __pluginInitDone;
33
+ }
34
+
26
35
  // 侧门注册表观测:patch OpenClaw embeddedRunState.activeRuns 的 set/delete,
27
36
  // 用于跟踪 sessionId 何时注册/注销(agent 取消流程实际读取的就是这张表)。
28
37
  // OpenClaw 侧门形状变化时(缺失 / 抛异常),通过 remoteLog 上报为升级契约变更的早期信号。
@@ -171,16 +180,19 @@ const plugin = {
171
180
  const topicManager = new TopicManager({ logger });
172
181
  const chatHistoryManager = new ChatHistoryManager({ logger });
173
182
 
174
- // 懒加载 topic / chat history 数据(best-effort,不阻断注册)
175
- topicManager.load('main').catch((err) => {
183
+ // 懒加载 topic / chat history 数据(best-effort,不阻断注册)。
184
+ // 两条 promise 收成一个 bundle 挂到 __pluginInitDone,让测试 / 关心 done 时机的
185
+ // caller 通过 awaitPluginInit() 显式等待——生产 gateway 不调即原 fire-and-forget 语义
186
+ const topicLoadP = topicManager.load('main').catch((err) => {
176
187
  logger.warn?.(`[coclaw] topic manager load failed: ${String(err?.message ?? err)}`);
177
188
  });
178
- chatHistoryManager.load('main')
189
+ const chatHistoryLoadP = chatHistoryManager.load('main')
179
190
  .then(() => manager.listAllEntries('main'))
180
191
  .then((entries) => chatHistoryManager.reconcileAll('main', entries))
181
192
  .catch((err) => {
182
193
  logger.warn?.(`[coclaw] chat history manager load/reconcile failed: ${String(err?.message ?? err)}`);
183
194
  });
195
+ __pluginInitDone = Promise.all([topicLoadP, chatHistoryLoadP]);
184
196
 
185
197
  // 追踪 chat 因 reset 产生的 session 流水。双源回调(hook + sessions.changed)共用 helper。
186
198
  // recordSessionTransition 内部已 __reloadFromDisk + mutex,外层无需再 cache.has + load。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.22.2",
3
+ "version": "0.22.3",
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.",
@@ -76,7 +76,8 @@ export class ChatHistoryManager {
76
76
  constructor(opts = {}) {
77
77
  this.__resolveSessionsDir = opts.resolveSessionsDir ?? agentSessionsDir;
78
78
  this.__logger = opts.logger ?? console;
79
- /* c8 ignore next 2 -- ?? fallback:测试始终注入 */
79
+ // readFile / writeJsonFile DI 注入点用于精细 mock;不注入时默认走 fs.readFile + atomicWriteJsonFile。
80
+ // 默认构造路径由"通过 setRuntime 端到端落盘"测试覆盖。
80
81
  this.__readFile = opts.readFile ?? fs.readFile;
81
82
  this.__writeJsonFile = opts.writeJsonFile ?? atomicWriteJsonFile;
82
83
  // 内存缓存:agentId -> { version, [sessionKey]: [...] }
@@ -796,8 +796,10 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
796
796
  // done 已收到但 drain 未完成 — finishUpload 中会检测 dcClosed 并清理 tmp
797
797
  if (!finishing) finishUpload();
798
798
  } else {
799
+ // 等 ws 关闭后再 unlink——fopen 未完成时直接 safeUnlink 会扑空被吞,
800
+ // 随后 fopen 完成创建文件却没人清,留下孤儿 tmp
801
+ ws.on('close', () => safeUnlink(tmpPath));
799
802
  ws.destroy();
800
- safeUnlink(tmpPath);
801
803
  const elapsed = Date.now() - startTime;
802
804
  remoteLog(`file.up.fail ${logTag}id=${transferId} reason=dc-closed received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
803
805
  log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=dc-closed received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
@@ -810,8 +812,9 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
810
812
  wsError = true;
811
813
  draining = false;
812
814
  pendingQueue.length = 0;
815
+ // 同上:等 ws 关闭后再 unlink,避开 fopen-vs-unlink race
816
+ ws.on('close', () => safeUnlink(tmpPath));
813
817
  ws.destroy();
814
- safeUnlink(tmpPath);
815
818
  const elapsed = Date.now() - startTime;
816
819
  /* c8 ignore next -- ?? fallback for non-Error throw */
817
820
  const errMsg = err?.message ?? String(err);
@@ -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,
@@ -1717,6 +1717,13 @@ export class RealtimeBridge {
1717
1717
  // restart(opts) — 无论当前状态,确保 bridge 以给定 opts 运行(幂等)
1718
1718
  // stop() — 停止并销毁 singleton
1719
1719
  // 调用方无需感知 singleton 是否为 null,选"要运行"或"要停止"即可。
1720
+ //
1721
+ // link-UNSAFE 警告:以下 singleton 状态与所有读 singleton 的 export(restart /
1722
+ // stop / waitForSessionsReady / ensureAgentSession / gatewayAgentRpc /
1723
+ // broadcastPluginEvent)在 `--link` 安装模式下,hook 路径与 RPC 路径可能拿
1724
+ // 到不同 ESM 模块实例 → 两份独立 singleton。**不要在 api.on(...) hook 回调内
1725
+ // 调用本文件的任何 export**。hook 内若需触发 bridge 副作用,请走 RPC
1726
+ // (api.callGatewayMethod('coclaw.xxx', ...))。详见 docs/module-boundaries.md。
1720
1727
 
1721
1728
  let singleton = null;
1722
1729
 
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
- /* c8 ignore next */
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
- /* c8 ignore next -- ?? fallback */
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
  }
@@ -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 字符集(PRE-EXISTING 契约):上游 server 分配 connId 形如 `c_<digits>`;
1000
- // FBQ / MemoryQueue 共用 `^[A-Za-z0-9._-]+$` 校验。若 server 将来引入特殊字符,
1001
- // queue 构造会抛 TypeError,由 __setupDataChannel 的 .catch 兜底 warn——非 B9b 引入。
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