@coclaw/openclaw-coclaw 0.19.2 → 0.20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.19.2",
3
+ "version": "0.20.1",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw CoClaw channel plugin for remote chat",
@@ -39,7 +39,10 @@
39
39
  "openclaw": {
40
40
  "extensions": [
41
41
  "./index.js"
42
- ]
42
+ ],
43
+ "install": {
44
+ "minHostVersion": ">=2026.2.19"
45
+ }
43
46
  },
44
47
  "scripts": {
45
48
  "build": "echo 'No build step needed (pure ES modules)'",
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * state.js — upgrade-state.json 与 upgrade-log.jsonl 读写
3
3
  *
4
- * 状态文件存储在 OpenClaw state 目录下(~/.openclaw/coclaw/),
5
- * bindings.json 共享同一目录。路径解析优先级:
4
+ * 例外:本文件 gateway 主进程与 auto-upgrade worker 子进程共用,worker 没 runtime
5
+ * 注入,故保留独立的双轨解析(不走 claw-paths.js):
6
6
  * 1. runtime.state.resolveStateDir()(gateway 进程内)
7
- * 2. OPENCLAW_STATE_DIR 环境变量(worker 进程,由 spawner 传入)
7
+ * 2. OPENCLAW_STATE_DIR 环境变量(worker 子进程,由 spawner 传入)
8
8
  * 3. ~/.openclaw(兜底默认值)
9
9
  */
10
10
  import fs from 'node:fs/promises';
@@ -1,13 +1,18 @@
1
1
  import fs from 'node:fs/promises';
2
+ import nodeFs from 'node:fs';
2
3
  import nodePath from 'node:path';
3
4
 
4
5
  import { checkForUpdate } from './updater-check.js';
5
6
  import { spawnUpgradeWorker } from './updater-spawn.js';
6
7
  import { readState, resolveStateDir, writeState } from './state.js';
7
- import { getRuntime } from '../runtime.js';
8
+ import { getClawConfig } from '../claw-config.js';
8
9
  import { remoteLog } from '../remote-log.js';
9
10
  import { atomicWriteFile } from '../utils/atomic-write.js';
10
11
 
12
+ // OpenClaw ≥ 2026.4.25 起把插件安装记录从 openclaw.json 的 plugins.installs
13
+ // 迁移到独立账本文件,并在 loadConfig() 返回前剥掉 plugins.installs。
14
+ const INSTALLS_LEDGER_RELATIVE_PATH = nodePath.join('plugins', 'installs.json');
15
+
11
16
  // 首次检查延迟较长:失败时由 worker 触发 gateway restart,scheduler 重启后会重新计时;
12
17
  // 60 分钟基线(实际随机 60-120 分钟)能把"失败→重启→再次检查"的循环周期拉长,
13
18
  // 避免连续升级失败时 gateway 在短时间内反复被打扰。
@@ -109,6 +114,80 @@ export async function writeUpgradeLock(pid) {
109
114
  );
110
115
  }
111
116
 
117
+ /**
118
+ * 读取本插件的安装记录(兼容新旧 OpenClaw 契约)
119
+ *
120
+ * - 新版(OpenClaw ≥ 2026.4.25):账本文件 `<state-dir>/plugins/installs.json`
121
+ * 下的 `installRecords[pluginId]` 是来源真相;`loadConfig()` 返回的对象里
122
+ * `plugins.installs` 已被剥离。
123
+ * - 旧版(OpenClaw ≤ 2026.4.24):账本文件不存在,
124
+ * `loadConfig().plugins.installs[pluginId]` 是来源真相。
125
+ *
126
+ * 兼容策略:先尝试账本文件;ENOENT(文件不存在)→ 回落到旧字段;
127
+ * 其它失败(权限/JSON 损坏/缺记录)→ 视为账本不可用,按"无来源信息"处理,不回落。
128
+ * 这两条互斥(新 gateway 必有账本、旧 gateway 必无)能让两个分支天然分流。
129
+ *
130
+ * 失败路径会通过 `remoteLog` 外推诊断信号(`upgrade.state-dir-failed` /
131
+ * `upgrade.ledger-read-failed` / `upgrade.ledger-parse-failed`),避免运维只
132
+ * 看到 start() 那条 "Skipping: not an npm-installed plugin" 时误判方向。
133
+ *
134
+ * @param {string} pluginId
135
+ * @returns {object|null}
136
+ */
137
+ function loadInstallRecord(pluginId) {
138
+ let ledgerPath;
139
+ try {
140
+ ledgerPath = nodePath.join(resolveStateDir(), INSTALLS_LEDGER_RELATIVE_PATH);
141
+ }
142
+ catch (err) {
143
+ // 极少触发:host runtime 的 state resolver 自身异常
144
+ /* c8 ignore next -- ?? fallback:err 字段缺省的兜底分支不强制覆盖 */
145
+ remoteLog(`upgrade.state-dir-failed msg=${err?.message ?? String(err)}`);
146
+ return null;
147
+ }
148
+ let raw;
149
+ try {
150
+ raw = nodeFs.readFileSync(ledgerPath, 'utf8');
151
+ }
152
+ catch (err) {
153
+ if (err?.code === 'ENOENT') {
154
+ return loadInstallRecordFromLegacyConfig(pluginId);
155
+ }
156
+ // 账本应该可读但读不到(权限/EISDIR/IO 错误):不回落到旧字段,避免误判老路径
157
+ // 静默返回 null 会让 start() 打 "Skipping: not an npm-installed plugin",对运维毫无指向;
158
+ // 把诊断信号外推到 server,便于定位
159
+ /* c8 ignore next -- ?? fallback:err 字段缺省的兜底分支不强制覆盖 */
160
+ remoteLog(`upgrade.ledger-read-failed code=${err?.code ?? 'unknown'} msg=${err?.message ?? String(err)}`);
161
+ return null;
162
+ }
163
+ let parsed;
164
+ try {
165
+ parsed = JSON.parse(raw);
166
+ }
167
+ catch (err) {
168
+ // 账本损坏:同样不回落,并外推诊断信号
169
+ /* c8 ignore next -- ?? fallback:err 字段缺省的兜底分支不强制覆盖 */
170
+ remoteLog(`upgrade.ledger-parse-failed msg=${err?.message ?? String(err)}`);
171
+ return null;
172
+ }
173
+ return parsed?.installRecords?.[pluginId] ?? null;
174
+ }
175
+
176
+ /**
177
+ * 旧版 OpenClaw(≤ 2026.4.24)账本路径:openclaw.json 的 plugins.installs。
178
+ * @param {string} pluginId
179
+ * @returns {object|null}
180
+ */
181
+ function loadInstallRecordFromLegacyConfig(pluginId) {
182
+ try {
183
+ const config = getClawConfig();
184
+ return config?.plugins?.installs?.[pluginId] ?? null;
185
+ }
186
+ catch {
187
+ return null;
188
+ }
189
+ }
190
+
112
191
  /**
113
192
  * 判断是否应跳过自动升级
114
193
  *
@@ -122,16 +201,7 @@ export async function writeUpgradeLock(pid) {
122
201
  * @returns {boolean} true 表示应跳过自动升级
123
202
  */
124
203
  export function shouldSkipAutoUpgrade(pluginId) {
125
- const rt = getRuntime();
126
- if (!rt?.config?.loadConfig) return true;
127
- try {
128
- const config = rt.config.loadConfig();
129
- const installInfo = config?.plugins?.installs?.[pluginId];
130
- return installInfo?.source !== 'npm';
131
- }
132
- catch {
133
- return true;
134
- }
204
+ return loadInstallRecord(pluginId)?.source !== 'npm';
135
205
  }
136
206
 
137
207
  /**
@@ -140,15 +210,7 @@ export function shouldSkipAutoUpgrade(pluginId) {
140
210
  * @returns {string|null}
141
211
  */
142
212
  export function getPluginInstallPath(pluginId) {
143
- const rt = getRuntime();
144
- if (!rt?.config?.loadConfig) return null;
145
- try {
146
- const config = rt.config.loadConfig();
147
- return config?.plugins?.installs?.[pluginId]?.installPath ?? null;
148
- }
149
- catch {
150
- return null;
151
- }
213
+ return loadInstallRecord(pluginId)?.installPath ?? null;
152
214
  }
153
215
 
154
216
  /**
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
- import os from 'node:os';
3
2
  import nodePath from 'node:path';
4
3
 
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
7
 
@@ -28,13 +28,13 @@ function emptyStore() {
28
28
  export class ChatHistoryManager {
29
29
  /**
30
30
  * @param {object} [opts]
31
- * @param {string} [opts.rootDir] - agents 根目录,默认 ~/.openclaw/agents
32
31
  * @param {object} [opts.logger]
32
+ * @param {Function} [opts.resolveSessionsDir] - 测试注入:自定义 sessions 目录解析
33
33
  * @param {Function} [opts.readFile] - 测试注入
34
34
  * @param {Function} [opts.writeJsonFile] - 测试注入
35
35
  */
36
36
  constructor(opts = {}) {
37
- this.__rootDir = opts.rootDir ?? nodePath.join(os.homedir(), '.openclaw', 'agents');
37
+ this.__resolveSessionsDir = opts.resolveSessionsDir ?? agentSessionsDir;
38
38
  this.__logger = opts.logger ?? console;
39
39
  /* c8 ignore next 2 -- ?? fallback:测试始终注入 */
40
40
  this.__readFile = opts.readFile ?? fs.readFile;
@@ -48,7 +48,7 @@ export class ChatHistoryManager {
48
48
  }
49
49
 
50
50
  __sessionsDir(agentId) {
51
- return nodePath.join(this.__rootDir, agentId, 'sessions');
51
+ return this.__resolveSessionsDir(agentId);
52
52
  }
53
53
 
54
54
  __historyFilePath(agentId) {
@@ -0,0 +1,27 @@
1
+ /**
2
+ * claw-config.js — OpenClaw runtime config 的统一访问入口
3
+ *
4
+ * 设计原则:
5
+ * - 业务代码只调 getClawConfig(),不直接摸 rt.config,新旧 API 切换全在此处兜底
6
+ * - OpenClaw v2026.4.27+ 新 API `config.current()`;老 API `config.loadConfig()` 仍可用但触发 deprecation 警告
7
+ * - 两个 API 内部都返回同一个 getRuntimeConfig() 快照,字段语义一致
8
+ * - 异常不在此处吞,让调用方按需处理(取 token 与读账本兜底策略不同)
9
+ *
10
+ * 拆分触发:本文件超约 200 行,或某一类 host 适配独立成块且 ≥ 100 行时再拆出去;
11
+ * 否则 path 之外的 host 适配优先往本文件加(必要时改名 claw-host.js)
12
+ */
13
+ import { getRuntime } from './runtime.js';
14
+
15
+ /**
16
+ * 读取当前 OpenClaw 运行时配置快照
17
+ *
18
+ * 优先 `config.current()`(v2026.4.27+),缺失时回落到 `config.loadConfig()`。
19
+ *
20
+ * @returns {object|null} runtime 未注入或缺 config 访问 API 时返回 null
21
+ */
22
+ export function getClawConfig() {
23
+ const rt = getRuntime();
24
+ const reader = rt?.config?.current ?? rt?.config?.loadConfig;
25
+ if (!reader) return null;
26
+ return reader();
27
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * claw-paths.js — OpenClaw 路径解析的唯一入口(gateway 主进程内)
3
+ *
4
+ * 设计原则:
5
+ * - clawStateDir:高度稳定的 OpenClaw API(自 2026-02-19 起注入 runtime),直接信任
6
+ * - session 三件套(store / transcript / sessions dir):自 2026-03-16 起才注入 runtime,
7
+ * 做防御性 fallback,回退到 OpenClaw 自家长期稳定的固定布局
8
+ * - 不读 OPENCLAW_STATE_DIR 环境变量;不回退到 ~/.openclaw 家目录
9
+ * - runtime 缺失或字段缺失(除 session helper 外)即抛错,bug 早暴露
10
+ *
11
+ * 例外:auto-upgrade/state.js 是 gateway 与 worker 子进程共用的,worker 没 runtime,
12
+ * 故那个文件保留独立的 env 兜底,不走本模块。
13
+ */
14
+ import nodePath from 'node:path';
15
+
16
+ import { getRuntime } from './runtime.js';
17
+
18
+ const CHANNEL_ID = 'coclaw';
19
+
20
+ /**
21
+ * OpenClaw 真实 state 目录
22
+ * @returns {string}
23
+ */
24
+ export function clawStateDir() {
25
+ const rt = getRuntime();
26
+ if (!rt?.state?.resolveStateDir) {
27
+ throw new Error('claw-paths: runtime not injected; cannot resolve state dir');
28
+ }
29
+ return rt.state.resolveStateDir();
30
+ }
31
+
32
+ /**
33
+ * CoClaw 自管文件根目录(bindings / settings / device-identity / rpc-queues)
34
+ * @returns {string}
35
+ */
36
+ export function pluginDir() {
37
+ return nodePath.join(clawStateDir(), CHANNEL_ID);
38
+ }
39
+
40
+ /**
41
+ * sessions.json 全路径(session-manager 读会话索引用)
42
+ *
43
+ * 优先 runtime helper(自 2026-03-16 起),允许跟随 OpenClaw 自定义 store 配置;
44
+ * runtime 没注入 helper 时回退到固定布局。
45
+ * @param {string} agentId
46
+ * @returns {string}
47
+ */
48
+ export function sessionStorePath(agentId) {
49
+ const rt = getRuntime();
50
+ const helper = rt?.agent?.session?.resolveStorePath;
51
+ if (helper) {
52
+ return helper(undefined, { agentId });
53
+ }
54
+ return nodePath.join(clawStateDir(), 'agents', agentId, 'sessions', 'sessions.json');
55
+ }
56
+
57
+ /**
58
+ * sessions 所在目录(topic / chat-history 写自己的扩展文件用)
59
+ *
60
+ * 通过 sessionStorePath 反推 dirname,使 CoClaw 扩展文件随 OpenClaw 真实存储位置走。
61
+ * @param {string} agentId
62
+ * @returns {string}
63
+ */
64
+ export function agentSessionsDir(agentId) {
65
+ return nodePath.dirname(sessionStorePath(agentId));
66
+ }
67
+
68
+ /**
69
+ * 单条 session 的 JSONL transcript 全路径(session-manager 读单会话用)
70
+ * @param {string} sessionId
71
+ * @param {string} agentId
72
+ * @param {{ sessionFile?: string }} [entry] - sessions.json 索引条目,可能含 sessionFile 覆盖
73
+ * @returns {string}
74
+ */
75
+ export function sessionTranscriptPath(sessionId, agentId, entry) {
76
+ const rt = getRuntime();
77
+ const helper = rt?.agent?.session?.resolveSessionFilePath;
78
+ if (helper) {
79
+ return helper(sessionId, entry, { agentId });
80
+ }
81
+ return nodePath.join(agentSessionsDir(agentId), `${sessionId}.jsonl`);
82
+ }
83
+
84
+ export { CHANNEL_ID };
package/src/config.js CHANGED
@@ -1,27 +1,16 @@
1
1
  import fs from 'node:fs/promises';
2
- import os from 'node:os';
3
2
  import nodePath from 'node:path';
4
3
 
5
- import { getRuntime } from './runtime.js';
4
+ import { CHANNEL_ID, pluginDir } from './claw-paths.js';
6
5
  import { atomicWriteJsonFile } from './utils/atomic-write.js';
7
6
  import { createMutex } from './utils/mutex.js';
8
7
 
9
8
  export const DEFAULT_ACCOUNT_ID = 'default';
10
- export const CHANNEL_ID = 'coclaw';
9
+ export { CHANNEL_ID };
11
10
  const BINDINGS_FILENAME = 'bindings.json';
12
11
 
13
- export function resolveStateDir() {
14
- const rt = getRuntime();
15
- if (rt?.state?.resolveStateDir) {
16
- return rt.state.resolveStateDir();
17
- }
18
- return process.env.OPENCLAW_STATE_DIR
19
- ? nodePath.resolve(process.env.OPENCLAW_STATE_DIR)
20
- : nodePath.join(os.homedir(), '.openclaw');
21
- }
22
-
23
12
  export function getBindingsPath() {
24
- return nodePath.join(resolveStateDir(), CHANNEL_ID, BINDINGS_FILENAME);
13
+ return nodePath.join(pluginDir(), BINDINGS_FILENAME);
25
14
  }
26
15
 
27
16
  function toRecord(value) {
@@ -1,12 +1,10 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
- import os from 'node:os';
4
3
  import nodePath from 'node:path';
5
4
 
6
- import { getRuntime } from './runtime.js';
5
+ import { pluginDir } from './claw-paths.js';
7
6
  import { atomicWriteFileSync } from './utils/atomic-write.js';
8
7
 
9
- const CHANNEL_ID = 'coclaw';
10
8
  const IDENTITY_FILENAME = 'device-identity.json';
11
9
 
12
10
  // Ed25519 SPKI 前缀(固定 12 字节),公钥裸字节从 SPKI DER 中截取
@@ -27,22 +25,12 @@ function normalizeMetadataForAuth(value) {
27
25
  return trimmed ? toLowerAscii(trimmed) : '';
28
26
  }
29
27
 
30
- function resolveStateDir() {
31
- const rt = getRuntime();
32
- if (rt?.state?.resolveStateDir) {
33
- return rt.state.resolveStateDir();
34
- }
35
- return process.env.OPENCLAW_STATE_DIR
36
- ? nodePath.resolve(process.env.OPENCLAW_STATE_DIR)
37
- : nodePath.join(os.homedir(), '.openclaw');
38
- }
39
-
40
28
  /**
41
29
  * 获取身份文件路径
42
30
  * @returns {string}
43
31
  */
44
32
  export function getIdentityPath() {
45
- return nodePath.join(resolveStateDir(), CHANNEL_ID, IDENTITY_FILENAME);
33
+ return nodePath.join(pluginDir(), IDENTITY_FILENAME);
46
34
  }
47
35
 
48
36
  /**
@@ -89,7 +77,7 @@ function generateIdentity() {
89
77
  * 加载或创建设备身份(Ed25519 密钥对)
90
78
  *
91
79
  * 存储格式与 OpenClaw device-identity.ts 保持一致。
92
- * @param {string} [filePath] - 自定义路径,默认 ~/.openclaw/coclaw/device-identity.json
80
+ * @param {string} [filePath] - 自定义路径,默认 &lt;state-dir&gt;/coclaw/device-identity.json
93
81
  * @returns {{ deviceId: string, publicKeyPem: string, privateKeyPem: string }}
94
82
  */
95
83
  export function loadOrCreateDeviceIdentity(filePath) {
@@ -1,9 +1,10 @@
1
- import fs from 'node:fs';
2
- import os from 'node:os';
3
1
  import nodePath from 'node:path';
4
2
  import { WebSocket as WsWebSocket } from 'ws';
5
3
 
4
+ import { getClawConfig } from './claw-config.js';
5
+ import { pluginDir } from './claw-paths.js';
6
6
  import { clearConfig, getBindingsPath, readConfig } from './config.js';
7
+ import { cleanupResiduals as defaultCleanupResiduals, measureDiskCap as defaultMeasureDiskCap } from './rpc-queue-startup.js';
7
8
  import { getHostName, readSettings } from './settings.js';
8
9
  import {
9
10
  loadOrCreateDeviceIdentity,
@@ -15,6 +16,7 @@ import { getRuntime } from './runtime.js';
15
16
  import { setSender as setRemoteLogSender, remoteLog } from './remote-log.js';
16
17
  import { getPluginVersion } from './plugin-version.js';
17
18
  import { getPlatformInfoLine } from './platform-info.js';
19
+ import { RunEventRoutes, DEFAULT_TTL_MS as RUN_EVENT_DEFAULT_TTL_MS, DEFAULT_SCAN_MS as RUN_EVENT_DEFAULT_SCAN_MS } from './rpc-routing/run-event-routes.js';
18
20
 
19
21
  const DEFAULT_GATEWAY_WS_URL = `ws://127.0.0.1:${process.env.OPENCLAW_GATEWAY_PORT || '18789'}`;
20
22
  const RECONNECT_MS = 10_000;
@@ -87,24 +89,14 @@ function maskUrlToken(url) {
87
89
  return url.replace(/([?&]token=)[^&]+/, '$1***');
88
90
  }
89
91
 
90
- /* c8 ignore start -- 仅在未注入 resolveGatewayAuthToken 时使用,依赖 runtime/env/文件系统 */
91
- function defaultResolveGatewayAuthToken() {
92
+ // 仅在未注入 resolveGatewayAuthToken 时使用,依赖 runtime / 环境变量
93
+ export function defaultResolveGatewayAuthToken() {
92
94
  const envToken = process.env.OPENCLAW_GATEWAY_TOKEN?.trim();
93
95
  if (envToken) {
94
96
  return envToken;
95
97
  }
96
98
  try {
97
- const rt = getRuntime();
98
- if (rt?.config?.loadConfig) {
99
- const cfg = rt.config.loadConfig();
100
- const token = cfg?.gateway?.auth?.token;
101
- return typeof token === 'string' && token.trim() ? token.trim() : '';
102
- }
103
- const cfgPath = process.env.OPENCLAW_CONFIG_PATH
104
- ? nodePath.resolve(process.env.OPENCLAW_CONFIG_PATH)
105
- : nodePath.join(os.homedir(), '.openclaw', 'openclaw.json');
106
- const raw = fs.readFileSync(cfgPath, 'utf8');
107
- const cfg = JSON.parse(raw);
99
+ const cfg = getClawConfig();
108
100
  const token = cfg?.gateway?.auth?.token;
109
101
  return typeof token === 'string' && token.trim() ? token.trim() : '';
110
102
  }
@@ -113,7 +105,6 @@ function defaultResolveGatewayAuthToken() {
113
105
  return '';
114
106
  }
115
107
  }
116
- /* c8 ignore stop */
117
108
 
118
109
  /**
119
110
  * WebSocket 桥接器:CoClaw server ↔ OpenClaw gateway
@@ -132,6 +123,8 @@ export class RealtimeBridge {
132
123
  * @param {number} [deps.gatewayReadyTimeoutMs] - __waitGatewayReady 默认超时(测试可注入短值)
133
124
  * @param {number} [deps.dcReqTtlMs] - UI 转发 RPC 路由表条目 TTL(测试可注入短值)
134
125
  * @param {number} [deps.dcReqScanMs] - UI 转发 RPC 路由表周期扫描间隔(测试可注入短值)
126
+ * @param {number} [deps.runEventRoutesTtlMs] - runId → connId 路由表条目 TTL(测试可注入短值)
127
+ * @param {number} [deps.runEventRoutesScanMs] - runId → connId 路由表周期扫描间隔(测试可注入短值)
135
128
  */
136
129
  constructor(deps = {}) {
137
130
  this.__readConfig = deps.readConfig ?? readConfig;
@@ -145,6 +138,11 @@ export class RealtimeBridge {
145
138
  this.__gatewayReadyTimeoutMs = deps.gatewayReadyTimeoutMs ?? 1500;
146
139
  this.__dcReqTtlMs = deps.dcReqTtlMs ?? DC_REQ_TTL_MS;
147
140
  this.__dcReqScanMs = deps.dcReqScanMs ?? DC_REQ_SCAN_MS;
141
+ this.__runEventRoutesTtlMs = deps.runEventRoutesTtlMs ?? RUN_EVENT_DEFAULT_TTL_MS;
142
+ this.__runEventRoutesScanMs = deps.runEventRoutesScanMs ?? RUN_EVENT_DEFAULT_SCAN_MS;
143
+ // rpc-queues/ 启动期预热钩子(B-stage1 plan-2)。仅供测试覆盖错误分支注入;生产路径走默认。
144
+ this.__cleanupRpcQueueResiduals = deps.cleanupRpcQueueResiduals ?? defaultCleanupResiduals;
145
+ this.__measureRpcQueueDiskCap = deps.measureRpcQueueDiskCap ?? defaultMeasureDiskCap;
148
146
 
149
147
  this.serverWs = null;
150
148
  this.gatewayWs = null;
@@ -179,6 +177,14 @@ export class RealtimeBridge {
179
177
  // 用于 res 帧按发起方单播;查不到时回退广播兜底(兼容旧 UI / 撞号 / 上游新增中间态字符串等)
180
178
  this.__dcPendingRequests = new Map();
181
179
  this.__dcPendingScanTimer = null;
180
+ // runId → connId 路由表:用于 event:agent 帧按发起方单播。
181
+ // 实例延迟到 start() 真 logger 到位时再 new;stop() destroy 后置 null。
182
+ this.__runEventRoutes = null;
183
+ // rpc DC 文件回退队列的磁盘容量(B-stage1 plan-2 探测,B9b 在装配 FBQ 时取)
184
+ this.__diskCap = null;
185
+ // rpc DC 文件回退队列根目录(B-stage1 plan-2 已 cleanupResiduals 过;B9b 装配 FBQ 时取)。
186
+ // prep 失败时 null → webrtc-peer 装配点自动降级到 MemoryQueue
187
+ this.__queueDir = null;
182
188
  }
183
189
 
184
190
  __resolveWebSocket() {
@@ -287,6 +293,8 @@ export class RealtimeBridge {
287
293
  this.gatewayPendingRequests.clear();
288
294
  // 清空 UI 转发 RPC 路由表:gateway 已断,不会再有响应回来;不主动通知 UI,由 UI 30/60s 超时兜底
289
295
  this.__dcPendingRequests.clear();
296
+ // 同步清空 runId 路由表(gateway 已断,不会再有 event:agent 推过来)
297
+ this.__runEventRoutes?.clear();
290
298
  }
291
299
 
292
300
  /** 懒加载 WebRtcPeer(promise 锁防并发重复创建) */
@@ -321,6 +329,10 @@ export class RealtimeBridge {
321
329
  PeerConnection,
322
330
  impl: this.__ndcPreloadResult?.impl,
323
331
  logger: this.logger,
332
+ // B9b:webrtc-peer 装配 FBQ 时取 disk 容量 + 队列根目录;
333
+ // __queueDir 为 null 时(plan-2 prep 失败)自动降级到 MemoryQueue
334
+ getDiskCap: () => this.__diskCap,
335
+ queueDir: this.__queueDir,
324
336
  });
325
337
  }
326
338
  /* c8 ignore stop */
@@ -842,10 +854,28 @@ export class RealtimeBridge {
842
854
  if (payload.type === 'res' && typeof payload.id === 'string') {
843
855
  const info = this.__dcPendingRequests.get(payload.id);
844
856
  if (info) {
845
- // 终态才清条目;accepted 类中间态保留等下一帧
857
+ // runId 路由表维护:accepted 时 add(首发优先),非 accepted 时 remove。
858
+ // 写入要求 reqId 表命中以拿 connId;删除嵌在 reqId 命中分支内——
859
+ // 因 reqId 表 miss 意味着写入也未发生过(设计上等价 no-op),
860
+ // 极端错位(reqId 表先 TTL 过期、runId 表条目仍存)由 24h TTL 兜底。
861
+ const runId = payload.payload?.runId;
862
+ if (typeof runId === 'string' && runId) {
863
+ if (payload.payload?.status === 'accepted') {
864
+ this.__runEventRoutes?.add(runId, info.connId, payload.id);
865
+ this.logger.debug?.(`[coclaw/run-event-route] add runId=${runId} connId=${info.connId} reqId=${payload.id}`);
866
+ }
867
+ else {
868
+ this.__runEventRoutes?.remove(runId, payload.id);
869
+ this.logger.debug?.(`[coclaw/run-event-route] remove runId=${runId} reqId=${payload.id}`);
870
+ }
871
+ }
872
+ // 终态才清条目;accepted 类中间态保留等下一帧
846
873
  if (isFinalResMsg(payload)) {
847
874
  this.__dcPendingRequests.delete(payload.id);
875
+ this.logger.debug?.(`[coclaw/rpc-res-route] remove reqId=${payload.id} reason=final-res`);
848
876
  }
877
+ /* c8 ignore next -- TODO: 2026-05-20 后删除 */
878
+ this.logger.debug?.(`[coclaw/rpc-res-route] hit, reqId=${payload.id} → connId=${info.connId}`);
849
879
  // sendTo 阶段 1 改为 async(admission 决策 await);外层 listener 已是 async
850
880
  const delivered = await this.webrtcPeer?.sendTo(info.connId, payload);
851
881
  if (!delivered) {
@@ -856,6 +886,24 @@ export class RealtimeBridge {
856
886
  }
857
887
  return;
858
888
  }
889
+ /* c8 ignore next -- TODO: 2026-05-20 后删除 */
890
+ this.logger.debug?.(`[coclaw/rpc-res-route] miss, broadcast, reqId=${payload.id}`);
891
+ }
892
+ // (c2) agent event 按 runId 单播:命中即送达,不退兜底广播;miss 走 (d) 兜底
893
+ if (payload.type === 'event' && payload.event === 'agent') {
894
+ const runId = payload.payload?.runId;
895
+ if (typeof runId === 'string' && runId) {
896
+ const connId = this.__runEventRoutes?.lookup(runId);
897
+ if (connId !== undefined) {
898
+ /* c8 ignore next -- TODO: 2026-05-20 后删除 */
899
+ this.logger.debug?.(`[coclaw/run-event-route] hit, runId=${runId} → connId=${connId}`);
900
+ // sendTo 失败不打 log(PC 状态翻转日志已足够,drop 是正确语义)
901
+ await this.webrtcPeer?.sendTo(connId, payload);
902
+ return;
903
+ }
904
+ }
905
+ /* c8 ignore next -- TODO: 2026-05-20 后删除 */
906
+ this.logger.debug?.(`[coclaw/run-event-route] miss, broadcast, runId=${runId ?? '<missing>'}`);
859
907
  }
860
908
  // (d) 兜底广播:覆盖 event 类型 / 映射未命中场景
861
909
  this.webrtcPeer?.broadcast(payload);
@@ -894,6 +942,8 @@ export class RealtimeBridge {
894
942
  this.gatewayPendingRequests.clear();
895
943
  // 同步清空 UI 转发 RPC 路由表(同 __closeGatewayWs 语义)
896
944
  this.__dcPendingRequests.clear();
945
+ // 同步清空 runId 路由表(gateway 已断,不会再有 event:agent 推过来)
946
+ this.__runEventRoutes?.clear();
897
947
  // 调度下一次尝试:仅在 bridge 仍活着、未 gave-up、server WS 健康时;
898
948
  // 其他场景(如 bridge stop、server WS 已断)由上游流程兜底,不参与 gateway 重试。
899
949
  if (this.started && !this.__gatewayGaveUp
@@ -1013,6 +1063,7 @@ export class RealtimeBridge {
1013
1063
  connId,
1014
1064
  expireAt: Date.now() + this.__dcReqTtlMs,
1015
1065
  });
1066
+ this.logger.debug?.(`[coclaw/rpc-res-route] add reqId=${id} connId=${connId}`);
1016
1067
  }
1017
1068
  try {
1018
1069
  this.__logDebug(`gateway req -> id=${id} method=${payload.method}`);
@@ -1030,7 +1081,10 @@ export class RealtimeBridge {
1030
1081
  catch {
1031
1082
  // SEND_FAILED:撤回映射后广播错误响应
1032
1083
  if (typeof id === 'string') {
1033
- this.__dcPendingRequests.delete(id);
1084
+ const removed = this.__dcPendingRequests.delete(id);
1085
+ if (removed) {
1086
+ this.logger.debug?.(`[coclaw/rpc-res-route] remove reqId=${id} reason=send-failed`);
1087
+ }
1034
1088
  }
1035
1089
  this.webrtcPeer?.broadcast({
1036
1090
  type: 'res',
@@ -1336,6 +1390,28 @@ export class RealtimeBridge {
1336
1390
  this.logger = logger ?? console;
1337
1391
  this.pluginConfig = pluginConfig ?? {};
1338
1392
  this.started = true;
1393
+ // rpc DC 文件回退队列的启动期预热(B-stage1 plan-2):清残留 *.jsonl + 探测磁盘容量。
1394
+ // 远早于第一条 rpc DC 建立(dump 设计);__diskCap 暂存供 B-stage2 切 FBQ 时取用。
1395
+ // 整块包 try/catch:模块自身不抛,但仍可能进入 catch 的路径——pluginDir() 同步抛
1396
+ // (runtime 未注入 / nodePath.join 参数异常 / 测试注入的 stub 抛错)。任何路径都不能把
1397
+ // bridge.start 卡死。
1398
+ try {
1399
+ const queueDir = nodePath.join(pluginDir(), 'rpc-queues');
1400
+ await this.__cleanupRpcQueueResiduals(queueDir, { logger: this.logger });
1401
+ this.__diskCap = await this.__measureRpcQueueDiskCap(queueDir, { logger: this.logger });
1402
+ // 只有 cleanupResiduals 成功 + measureDiskCap 完成后才暴露 queueDir 给 webrtc 装配;
1403
+ // 任一抛错都让 __queueDir 留 null,下游自动降级到 MemoryQueue
1404
+ this.__queueDir = queueDir;
1405
+ }
1406
+ catch (err) {
1407
+ /* c8 ignore next -- ?./?? fallback:err 总是 Error,logger.warn 总存在 */
1408
+ this.logger.warn?.(`[coclaw] rpc-queues startup prep failed (skipped): ${err?.message ?? err}`);
1409
+ this.__diskCap = null;
1410
+ this.__queueDir = null;
1411
+ }
1412
+ // race 守卫:cleanup/measure 期间若 stop() 已执行,不应再启动 native WebRTC 进程。
1413
+ // preload 后还有一道 started 检查兜底(含 pion cleanup),这里先挡住一次无意义的 preload。
1414
+ if (!this.started) return;
1339
1415
  // 先完成 WebRTC 实现加载,再建立连接,避免 UI 发来 offer 时 RTC 包未就绪
1340
1416
  // 优先级:pion → ndc → werift → none
1341
1417
  const preloadResult = await this.__preloadWebrtc();
@@ -1378,6 +1454,13 @@ export class RealtimeBridge {
1378
1454
  }
1379
1455
  }, this.__dcReqScanMs);
1380
1456
  this.__dcPendingScanTimer.unref?.();
1457
+ // 启动 runId → connId 路由表(agent event 单播)。延迟到 start 才 new,确保拿到真 logger。
1458
+ this.__runEventRoutes = new RunEventRoutes({
1459
+ logger: this.logger,
1460
+ ttlMs: this.__runEventRoutesTtlMs,
1461
+ scanMs: this.__runEventRoutesScanMs,
1462
+ });
1463
+ this.__runEventRoutes.init();
1381
1464
  await this.__connectIfNeeded();
1382
1465
  }
1383
1466
 
@@ -1428,6 +1511,11 @@ export class RealtimeBridge {
1428
1511
  clearInterval(this.__dcPendingScanTimer);
1429
1512
  this.__dcPendingScanTimer = null;
1430
1513
  }
1514
+ // 销毁 runId 路由表(停 timer + clear + 标 destroyed);refresh 时会重建
1515
+ if (this.__runEventRoutes) {
1516
+ this.__runEventRoutes.destroy();
1517
+ this.__runEventRoutes = null;
1518
+ }
1431
1519
  this.__closeGatewayWs();
1432
1520
  if (this.webrtcPeer) {
1433
1521
  await this.webrtcPeer.closeAll().catch(() => {});