cc-viewer 1.6.302 → 1.6.304

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/dist/index.html CHANGED
@@ -21,11 +21,11 @@
21
21
  // 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
22
22
  // electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
23
23
  </script>
24
- <script type="module" crossorigin src="/assets/index-DSz9bNGm.js"></script>
24
+ <script type="module" crossorigin src="/assets/index-BDK4eIE5.js"></script>
25
25
  <link rel="modulepreload" crossorigin href="/assets/vendor-antd-Bur5ZxWE.js">
26
26
  <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-Si44UqBp.js">
27
27
  <link rel="modulepreload" crossorigin href="/assets/vendor-mdxeditor-Cco3AQJS.js">
28
- <link rel="stylesheet" crossorigin href="/assets/index-Be9T-kDq.css">
28
+ <link rel="stylesheet" crossorigin href="/assets/index-Cy7Xfu81.css">
29
29
  </head>
30
30
  <body>
31
31
  <div id="root"><div style="display:flex;align-items:center;justify-content:center;height:100vh;color:#888;font-family:system-ui">Loading...</div></div>
package/findcc.js CHANGED
@@ -201,6 +201,20 @@ export function resolveNpmClaudePath() {
201
201
  return null;
202
202
  }
203
203
 
204
+ /**
205
+ * 从 which/where 的原始输出中挑出能直接 CreateProcess/exec 的候选行。
206
+ * Windows 的 `where` 会列出 PATH 中全部同名匹配——npm 全局安装时第一行往往是给
207
+ * git-bash 用的**无扩展名 sh shim**(#!/bin/sh 文本文件),其后是 .cmd/.ps1,都不是
208
+ * PE:node-pty/ConPTY 直接 spawn 会抛 "Cannot create process, error code: 193"
209
+ * (ERROR_BAD_EXE_FORMAT)。win32 只接受 .exe 行;POSIX 取第一行。
210
+ * 导出供单测;生产代码经 resolveNativePath 调用。
211
+ */
212
+ export function pickSpawnableLookupResult(rawOut, platform = process.platform) {
213
+ const lines = String(rawOut || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
214
+ if (platform === 'win32') return lines.find((l) => l.toLowerCase().endsWith('.exe')) || null;
215
+ return lines[0] || null;
216
+ }
217
+
204
218
  export function resolveNativePath() {
205
219
  const globalRoot = getGlobalNodeModulesDir();
206
220
 
@@ -220,7 +234,8 @@ export function resolveNativePath() {
220
234
  for (const cmd of lookupCmds) {
221
235
  try {
222
236
  const rawOut = execSync(cmd, { encoding: 'utf-8', shell: true, env: process.env, windowsHide: true });
223
- const result = rawOut.split(/\r?\n/)[0].trim();
237
+ // win32 过滤掉 sh shim / .cmd / .ps1,只取 .exe(否则 ConPTY spawn 报 error 193)
238
+ const result = pickSpawnableLookupResult(rawOut);
224
239
  if (result && existsSync(result)) {
225
240
  // 只排除 .js 文件(老版本 npm 分发的 cli.js,需要 node 运行,
226
241
  // 由 resolveNpmClaudePath 处理)。Claude Code 2.x+ 的 npm 包内
@@ -249,6 +264,11 @@ export function resolveNativePath() {
249
264
  if (existsSync(p)) {
250
265
  return p;
251
266
  }
267
+ // Windows 原生安装器(install.ps1)落的是 claude.exe(如 ~/.local/bin/claude.exe),
268
+ // 无扩展名候选在 win32 上永远 miss,这里补查 .exe 变体。
269
+ if (process.platform === 'win32' && existsSync(p + '.exe')) {
270
+ return p + '.exe';
271
+ }
252
272
  }
253
273
 
254
274
  // 4. 兜底:wrapper 包的 bin/claude(.exe)(可能是 postinstall 后的真实二进制,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.6.302",
3
+ "version": "1.6.304",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { lookupToolUseInput } from './session-transcript-reader.js';
11
+ import { enrichEntry as enrichWorkflowEntry, rawHasWorkflowToolResult } from './enrich-workflow.js';
11
12
 
12
13
  const EMPTY_INPUT_SUBSTR = '"name":"ExitPlanMode","input":{}';
13
14
 
@@ -92,6 +93,7 @@ export function enrichEntry(entry) {
92
93
 
93
94
  /**
94
95
  * 服务端三处接入点共用:raw 字符串预过滤 → 命中才 parse + enrich + stringify。
96
+ * 一次 parse 上同时跑 ExitPlanMode plan 补全与 Workflow _ccvWorkflow 注入两道 enricher。
95
97
  *
96
98
  * 设计原则:保持 server/lib/log-stream.js 的「原始字符串透传」哲学,只对真正需要补全的
97
99
  * 条目做 parse / stringify,其它一律按 raw 透传。
@@ -100,10 +102,14 @@ export function enrichEntry(entry) {
100
102
  * @returns {string} - enriched JSON 字符串,或原始 raw
101
103
  */
102
104
  export function enrichRawIfNeeded(raw) {
103
- if (!rawHasEmptyExitPlanMode(raw)) return raw;
105
+ const needPlan = rawHasEmptyExitPlanMode(raw);
106
+ const needWorkflow = rawHasWorkflowToolResult(raw);
107
+ if (!needPlan && !needWorkflow) return raw;
104
108
  let entry;
105
109
  try { entry = JSON.parse(raw); } catch { return raw; }
106
- const { enriched } = enrichEntry(entry);
107
- if (enriched === 0) return raw;
110
+ let changed = 0;
111
+ if (needPlan) { try { changed += enrichEntry(entry).enriched; } catch {} }
112
+ if (needWorkflow) { try { changed += enrichWorkflowEntry(entry).enriched; } catch {} }
113
+ if (changed === 0) return raw;
108
114
  try { return JSON.stringify(entry); } catch { return raw; }
109
115
  }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Enrich Workflow
3
+ *
4
+ * 在 cc-viewer 出 SSE/REST 之前,给 Workflow 工具的 tool_result block 补一个
5
+ * `_ccvWorkflow = { runId, taskId, sessionId, project }` 标记,供前端定位并拉取
6
+ * workflow run journal(`<sessionDir>/workflows/<runId>.json`)渲染工作流面板。
7
+ *
8
+ * 数据来源:
9
+ * - taskId:API wire 上 tool_result 文本 "Workflow launched in background. Task ID: <id>"。
10
+ * - runId:仅在 CC transcript 行顶层 toolUseResult.runId,按 tool_use_id 反查
11
+ * (session-transcript-reader.lookupToolUseResult),wire 上没有。
12
+ *
13
+ * 不修改 Workflow 之外的工具;不覆盖已有 _ccvWorkflow(共享引用幂等)。
14
+ */
15
+
16
+ import { lookupToolUseResult } from './session-transcript-reader.js';
17
+
18
+ const WF_RESULT_SUBSTR = 'Workflow launched in background. Task ID:';
19
+ const TASK_ID_RE = /Task ID:\s*([A-Za-z0-9_-]+)/;
20
+
21
+ /**
22
+ * 廉价子串预过滤:原始 JSON 字符串里有没有 Workflow 工具结果文本。
23
+ *
24
+ * @param {string} raw
25
+ * @returns {boolean}
26
+ */
27
+ export function rawHasWorkflowToolResult(raw) {
28
+ if (typeof raw !== 'string' || !raw) return false;
29
+ return raw.indexOf(WF_RESULT_SUBSTR) !== -1;
30
+ }
31
+
32
+ function getResultText(block) {
33
+ const c = block.content;
34
+ if (typeof c === 'string') return c;
35
+ if (Array.isArray(c)) {
36
+ return c.map(p => (typeof p === 'string' ? p : (p && typeof p.text === 'string' ? p.text : ''))).join('');
37
+ }
38
+ return '';
39
+ }
40
+
41
+ function findWorkflowResultBlocks(content, out) {
42
+ if (!Array.isArray(content)) return;
43
+ for (const blk of content) {
44
+ if (!blk || blk.type !== 'tool_result') continue;
45
+ if (typeof blk.tool_use_id !== 'string' || !blk.tool_use_id) continue;
46
+ if (blk._ccvWorkflow) continue; // 已补(共享引用幂等)
47
+ const txt = getResultText(blk);
48
+ if (txt.indexOf(WF_RESULT_SUBSTR) === -1) continue;
49
+ out.push({ blk, txt });
50
+ }
51
+ }
52
+
53
+ /**
54
+ * 遍历 entry 的 body.messages[*].content[] 找 Workflow tool_result,注入 _ccvWorkflow。
55
+ * tool_result 只出现在 user 轮,不扫当前轮 response。
56
+ *
57
+ * @param {object} entry - 已 JSON.parse 的日志条目
58
+ * @returns {{ enriched: number, missed: number }}
59
+ */
60
+ export function enrichEntry(entry) {
61
+ if (!entry || typeof entry !== 'object') return { enriched: 0, missed: 0 };
62
+ if (entry.mainAgent === false) return { enriched: 0, missed: 0 }; // sub-agent 不补
63
+ const sid = entry.headers?.['x-claude-code-session-id'] || null;
64
+ if (!sid) return { enriched: 0, missed: 0 };
65
+ const projectHint = typeof entry.project === 'string' ? entry.project : undefined;
66
+
67
+ const candidates = [];
68
+ const msgs = entry.body?.messages;
69
+ if (Array.isArray(msgs)) {
70
+ for (const m of msgs) {
71
+ if (m && Array.isArray(m.content)) findWorkflowResultBlocks(m.content, candidates);
72
+ }
73
+ }
74
+ if (candidates.length === 0) return { enriched: 0, missed: 0 };
75
+
76
+ let enriched = 0, missed = 0;
77
+ for (const { blk, txt } of candidates) {
78
+ const m = TASK_ID_RE.exec(txt);
79
+ const textTaskId = m ? m[1] : null;
80
+ const found = lookupToolUseResult(sid, blk.tool_use_id, projectHint);
81
+ const runId = found?.runId || null;
82
+ const taskId = found?.taskId || textTaskId;
83
+ if (runId || taskId) {
84
+ const marker = { sessionId: sid };
85
+ if (runId) marker.runId = runId;
86
+ if (taskId) marker.taskId = taskId;
87
+ if (projectHint) marker.project = projectHint;
88
+ blk._ccvWorkflow = marker;
89
+ enriched++;
90
+ } else {
91
+ missed++;
92
+ }
93
+ }
94
+ return { enriched, missed };
95
+ }
@@ -6,6 +6,7 @@ import { buildContextWindowEvent, getContextSizeForModel } from './context-watch
6
6
  import { reconstructEntries, createIncrementalReconstructor } from './delta-reconstructor.js';
7
7
  import { countLogEntries, streamReconstructedEntriesAsync } from './log-stream.js';
8
8
  import { enrichEntry } from './enrich-plan-input.js';
9
+ import { enrichEntry as enrichWorkflowEntry } from './enrich-workflow.js';
9
10
  import { resolveJsonlPath } from './jsonl-archive.js';
10
11
 
11
12
  // 跟踪所有被 watch 的日志文件。value: fileState 对象(外部只用 .has()/.keys())
@@ -179,6 +180,7 @@ async function _readDelta(state) {
179
180
  if (!parsed.pid) parsed.pid = getClaudePid();
180
181
  reconstructor.reconstruct(parsed);
181
182
  try { enrichEntry(parsed); } catch {}
183
+ try { enrichWorkflowEntry(parsed); } catch {}
182
184
  sendToClients(clients, parsed);
183
185
  runParallelHook('onNewEntry', parsed).catch(() => {});
184
186
  if (isMainAgentEntry(parsed) && !parsed.inProgress) {
@@ -7,9 +7,14 @@
7
7
  * (bufferedAmount > 1MB)介入,快 LAN 上洪泛字节全量到达前端,xterm 逐帧解析渲染
8
8
  * 稠密 SGR+CJK 重绘把主线程打满。本器在发送侧限流:
9
9
  *
10
- * - 直通态:chunk 立即 send(零延迟,不起 timer)。字节率按固定 flushMs 桶统计,
11
- * 当前桶累计超 floodThresholdBytesPerWin 进入限流态。打字回显 / 正常 token
12
- * 流远低于阈值(≈256KB/s),不受影响。
10
+ * - 直通态:leading-edge 微合并——空窗期首 chunk 立即 send(回显零延迟)并开
11
+ * ptCoalesceMs(默认 16ms)窗,同窗后续 chunk 并入 ptBuffer、到点合为一条 send
12
+ * (上限 2 条/窗 = 1000ms÷16ms×2 ≈125 msg/s)。低于洪泛阈值的持续小 chunk 流(/plugins 菜单导航
13
+ * 等 ConPTY 重绘)每 chunk 单发会打出数百条 ws 消息/秒,客户端逐条 MessageEvent
14
+ * 分发 + JSON.parse + xterm 主线程解析,**消息数风暴**即可锁死页面——字节率
15
+ * 限流(下方限流态)封不住这个维度。字节率仍按固定 flushMs 桶统计,
16
+ * 当前桶累计超 floodThresholdBytesPerWin → 进入限流态(ptBuffer 按序折入 pending)。
17
+ * 打字回显 / 正常 token 流是稀疏 chunk,每条都走 leading 立即发,不受影响。
13
18
  * - 限流态:chunk 剥掉自带的 DEC 2026 标记后追加进 pending(pending 内部因此
14
19
  * **绝无 2026 标记**,截断永不切坏配对);每 flushMs 把 pending 用单对
15
20
  * SYNC_BEGIN/END 重新包裹成**一条** send 发出并清空(无论下游是否跳发,
@@ -37,10 +42,23 @@ const SYNC_END = '\x1b[?2026l';
37
42
  const SYNC_MARKS_RE = /\x1b\[\?2026[hl]/g;
38
43
 
39
44
  // 默认常量可经 CCV_FLOOD_* 环境变量覆盖(仿 CCV_FORCE_POLL 先例),便于 Windows
40
- // 实机排障时调参而不改源码。非法/非正整数值回落默认。
41
- function envInt(name, fallback) {
42
- const v = parseInt(process.env[name], 10);
43
- return Number.isFinite(v) && v > 0 ? v : fallback;
45
+ // 实机排障时调参而不改源码。严格十进制白名单:parseInt 遇非数字字符即截停——
46
+ // '1e9' 解析成 1、'0x10' 解析成 0,静默生效远比回落默认值危险,非纯数字一律回落。
47
+ // 位数上限 15(< Number.MAX_SAFE_INTEGER):超长数字串 parseInt 溢出为 Infinity 会
48
+ // 穿透 v>0 判断,setTimeout(fn, Infinity) Node 钳到 1ms——33ms 桶宽静默变 1ms。
49
+ // 导出供 server.js 等复用(knob 解析逻辑收敛在此,不再各处内联)。
50
+ export function envInt(name, fallback) {
51
+ const s = (process.env[name] ?? '').trim();
52
+ if (!/^\d{1,15}$/.test(s)) return fallback;
53
+ const v = parseInt(s, 10);
54
+ return v > 0 ? v : fallback;
55
+ }
56
+
57
+ // 同 envInt 但接受 0(0 = 关闭该功能的逃生口,envInt 的 v>0 会把 0 误回落默认值)。
58
+ export function envIntAllowZero(name, fallback) {
59
+ const s = (process.env[name] ?? '').trim();
60
+ if (!/^\d{1,15}$/.test(s)) return fallback;
61
+ return parseInt(s, 10);
44
62
  }
45
63
 
46
64
  const DEFAULT_FLUSH_MS = envInt('CCV_FLOOD_FLUSH_MS', 33); // 限流态合并窗口 = 字节率统计桶宽
@@ -50,6 +68,12 @@ const DEFAULT_PENDING_CAP = envInt('CCV_FLOOD_PENDING_CAP', 256 * 1024);
50
68
  const DEFAULT_TRIM_TO = envInt('CCV_FLOOD_TRIM_TO', 128 * 1024); // pendingCap 截断后保留的尾部量
51
69
  // 单次 flush 发送预算 = 真速率上限:64KB / 33ms ≈ 1.9MB/s,与前端 32KB/帧消化速率同量级
52
70
  const DEFAULT_FLUSH_BUDGET = envInt('CCV_FLOOD_FLUSH_BUDGET', 64 * 1024);
71
+ // 直通态微合并窗口:低于洪泛阈值的持续小 chunk 流(如 /plugins 菜单导航的 ConPTY 重绘)
72
+ // 每 chunk 单发会打出每秒数百条 ws 消息——客户端每条都付 MessageEvent 分发 + JSON.parse +
73
+ // xterm 主线程同步解析,**消息数风暴**(非字节率)即可锁死页面(xterm.js#3368)。
74
+ // leading-edge 立即发(回显零延迟)+ 同窗后续合并 trailing 一条
75
+ // → 上限 2 条/窗 ≈125 msg/s(1000ms ÷ 16ms × 2 条)。0 = 禁用。
76
+ const DEFAULT_PT_COALESCE_MS = envIntAllowZero('CCV_FLOOD_PT_COALESCE_MS', 16);
53
77
 
54
78
  /**
55
79
  * @param {object} opts
@@ -63,6 +87,7 @@ const DEFAULT_FLUSH_BUDGET = envInt('CCV_FLOOD_FLUSH_BUDGET', 64 * 1024);
63
87
  * @param {number} [opts.pendingCap]
64
88
  * @param {number} [opts.trimTo]
65
89
  * @param {number} [opts.flushBudgetBytes]
90
+ * @param {number} [opts.ptCoalesceMs] - 直通态微合并窗口(0 = 禁用,每 chunk 单发)
66
91
  * @param {(fn: Function, ms: number) => any} [opts.setTimer] - 测试注入
67
92
  * @param {(t: any) => void} [opts.clearTimer] - 测试注入
68
93
  * @returns {{ offer: (chunk: string) => void, reset: () => void, dispose: () => void, isFlooding: () => boolean }}
@@ -78,6 +103,7 @@ export function createFloodCoalescer({
78
103
  pendingCap = DEFAULT_PENDING_CAP,
79
104
  trimTo = DEFAULT_TRIM_TO,
80
105
  flushBudgetBytes = DEFAULT_FLUSH_BUDGET,
106
+ ptCoalesceMs = DEFAULT_PT_COALESCE_MS,
81
107
  setTimer = setTimeout,
82
108
  clearTimer = clearTimeout,
83
109
  }) {
@@ -86,6 +112,8 @@ export function createFloodCoalescer({
86
112
  let winBytes = 0; // 当前桶累计字节(直通态由 offer 累计,限流态由 flush 结算)
87
113
  let calmWins = 0; // 连续低于阈值的桶数
88
114
  let flushTimer = null; // 限流态周期 flush;直通态下亦作为桶边界 timer(见 offer)
115
+ let ptBuffer = ''; // 直通态微合并缓冲(窗口开启期间到达的后续 chunk)
116
+ let ptTimer = null; // 直通态微合并窗口 timer(16ms),与 flushTimer(33ms 字节桶)并存、职责正交
89
117
  let disposed = false;
90
118
 
91
119
  const stopTimer = () => {
@@ -95,6 +123,24 @@ export function createFloodCoalescer({
95
123
  }
96
124
  };
97
125
 
126
+ const stopPtTimer = () => {
127
+ if (ptTimer) {
128
+ clearTimer(ptTimer);
129
+ ptTimer = null;
130
+ }
131
+ };
132
+
133
+ // 微合并窗口到点:缓冲非空则一条发出,窗口关闭(不自动续约——下一 chunk 重新 leading 立即发)。
134
+ // ptBuffer 不剥 SYNC 标记:terminal 路径每 chunk 经 pty-manager flushBatch 已自配平(拼接守恒),
135
+ // scratch 路径 chunk 本就无标记(拼接平凡守恒);只有洪泛路径(有截断)才需要剥。
136
+ const onPtFlush = () => {
137
+ ptTimer = null;
138
+ if (disposed || flooding || !ptBuffer) return;
139
+ const out = ptBuffer;
140
+ ptBuffer = '';
141
+ try { send(out); } catch { }
142
+ };
143
+
98
144
  // 直通态的桶边界:到点清零计数。无流量时 timer 不存在,零常驻开销。
99
145
  const armPassthroughWindow = () => {
100
146
  if (flushTimer) return;
@@ -143,21 +189,35 @@ export function createFloodCoalescer({
143
189
  /** 每条 PTY chunk 调用。直通态立即 send;限流态进 pending 等周期 flush。 */
144
190
  offer(chunk) {
145
191
  if (disposed || !chunk) return;
146
- winBytes += chunk.length;
192
+ winBytes += chunk.length; // 缓冲与直发都全量计账:微合并不致盲洪泛判定
147
193
  if (!flooding) {
148
194
  if (winBytes > floodThresholdBytesPerWin) {
149
- // 进入限流态:当前 chunk 是压垮桶的那条,一并纳入 pending(含它之前
150
- // 本桶已直通的部分不回收——它们已发出,量级在阈值内)。
195
+ // 进入限流态:当前 chunk 是压垮桶的那条,连同微合并缓冲中未发的旧 chunk
196
+ // 按序一并纳入 pending(已 leading 发出的部分不回收——量级在阈值内)。
197
+ // 注意 stopTimer 只清 flushTimer,ptTimer 须显式清,否则残留窗口会在洪泛
198
+ // 期间触发 onPtFlush(虽有 flooding 守卫兜底,仍以显式清为准)。
151
199
  flooding = true;
152
200
  calmWins = 0;
153
201
  stopTimer();
154
- pending = chunk.replace(SYNC_MARKS_RE, '');
202
+ stopPtTimer();
203
+ pending = (ptBuffer + chunk).replace(SYNC_MARKS_RE, '');
204
+ ptBuffer = '';
155
205
  flushTimer = setTimer(onFloodTick, flushMs);
156
206
  flushTimer.unref?.();
157
207
  try { onFloodStart?.(winBytes); } catch { }
158
208
  return;
159
209
  }
160
210
  armPassthroughWindow();
211
+ if (ptCoalesceMs > 0) {
212
+ // 微合并:窗口开启(ptTimer 在跑)→ 追加缓冲不发;窗口关闭 → leading 立即发
213
+ // (单次回显零延迟)并开窗。上限 2 条/窗(leading + trailing flush)。
214
+ if (ptTimer) {
215
+ ptBuffer += chunk;
216
+ return;
217
+ }
218
+ ptTimer = setTimer(onPtFlush, ptCoalesceMs);
219
+ ptTimer.unref?.();
220
+ }
161
221
  try { send(chunk); } catch { }
162
222
  return;
163
223
  }
@@ -170,10 +230,12 @@ export function createFloodCoalescer({
170
230
  pending = pending.slice(safeStart);
171
231
  }
172
232
  },
173
- /** bpGate onBehind/onResume 时调用:resync 快照是唯一真相源,清掉旧 pending 防回灌。 */
233
+ /** bpGate onBehind/onResume 时调用:resync 快照是唯一真相源,清掉旧 pending/ptBuffer 防回灌。 */
174
234
  reset() {
175
235
  stopTimer();
236
+ stopPtTimer();
176
237
  pending = '';
238
+ ptBuffer = '';
177
239
  winBytes = 0;
178
240
  calmWins = 0;
179
241
  flooding = false;
@@ -185,7 +247,9 @@ export function createFloodCoalescer({
185
247
  dispose() {
186
248
  disposed = true;
187
249
  stopTimer();
250
+ stopPtTimer();
188
251
  pending = '';
252
+ ptBuffer = '';
189
253
  },
190
254
  };
191
255
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * resync 重绘 nudge 冷却门(防 behind→resume 死循环的退避器)。
3
+ *
4
+ * 背景:ws-backpressure onResume 在发 data-resync 快照后会做一次重绘 nudge
5
+ * (POSIX SIGWINCH / Windows resize 抖动),让 claude TUI 全屏重绘以免画面停在
6
+ * 快照静止态。但 nudge 本身让 ConPTY 再吐 1~2 次全屏重绘 = 新洪泛燃料:客户端
7
+ * 仍慢 → bufferedAmount 再越线 → behind → resume → 再 nudge → 死循环,客户端
8
+ * 每轮 terminal.reset + 重放快照,表现为永久冻结。
9
+ *
10
+ * 语义:快照每次 resume 仍无条件发(修复 behind 期间被跳发的数据,不能省);
11
+ * 只有 nudge 走冷却——紧循环中 PTY 输出仍在流动,"画面停在快照"的风险不存在,
12
+ * 该风险只在 resume 稀疏时成立,而稀疏 resume 必然过冷却期、照常 nudge。
13
+ *
14
+ * 纯逻辑、时钟可注入(now),便于单测。仿 pty-flood-coalescer.js 惯例。
15
+ *
16
+ * @param {object} [opts]
17
+ * @param {number} [opts.cooldownMs=3000] - 两次 nudge 最小间隔;0 = 不冷却(恒放行,逃生口)
18
+ * @param {() => number} [opts.now=Date.now] - 测试注入
19
+ * @returns {{ shouldNudge: () => boolean }}
20
+ */
21
+ export function createResyncNudgeGate({ cooldownMs = 3000, now = Date.now } = {}) {
22
+ let lastNudgeAt = -Infinity; // 首次必放行
23
+ return {
24
+ /** resume 时调用:放行则记账并返回 true,冷却期内返回 false(调用方跳过 nudge)。 */
25
+ shouldNudge() {
26
+ if (cooldownMs <= 0) return true;
27
+ const t = now();
28
+ if (t - lastNudgeAt < cooldownMs) return false;
29
+ lastNudgeAt = t;
30
+ return true;
31
+ },
32
+ };
33
+ }
@@ -35,6 +35,7 @@ const INPUT_CACHE_MAX = 5000;
35
35
 
36
36
  const transcriptPathCache = new Map(); // key → { path, mtimeMs } | { path: null, expireAt }
37
37
  const toolUseInputCache = new Map(); // `${path}:${tuId}` → { plan?, planFilePath? }(仅命中入缓存)
38
+ const toolUseResultCache = new Map(); // `${path}:${tuId}` → { runId?, taskId? }(仅命中入缓存)
38
39
 
39
40
  function lruSet(map, key, value, max) {
40
41
  if (map.has(key)) map.delete(key);
@@ -154,14 +155,15 @@ function scanLineForToolUse(line, toolUseId) {
154
155
  }
155
156
 
156
157
  /**
157
- * 流式扫 transcript 文件,按 tool_use.id ExitPlanMode 块的 input。
158
+ * 流式扫 transcript 文件,按 tool_use.id 用传入的 scanLine 抽取目标字段。
158
159
  * 命中即 break;半写入行 try/catch 跳过。最大文件大小由 MAX_TRANSCRIPT_BYTES 兜底。
159
160
  *
160
161
  * @param {string} filePath
161
162
  * @param {string} toolUseId
162
- * @returns {{ result: { plan?: string, planFilePath?: string } | null, unknownShape: boolean }}
163
+ * @param {(line: string, toolUseId: string) => { ok: 'hit', value: object } | { ok: 'unknown-shape' } | { ok: 'miss' }} scanLine
164
+ * @returns {{ result: object | null, unknownShape: boolean }}
163
165
  */
164
- function scanTranscriptFile(filePath, toolUseId) {
166
+ function scanTranscriptFile(filePath, toolUseId, scanLine) {
165
167
  let unknownShape = false;
166
168
  try {
167
169
  const fileSize = statSync(filePath).size;
@@ -186,13 +188,13 @@ function scanTranscriptFile(filePath, toolUseId) {
186
188
  const lines = text.split('\n');
187
189
  pending = lines.pop() ?? '';
188
190
  for (const line of lines) {
189
- const r = scanLineForToolUse(line, toolUseId);
191
+ const r = scanLine(line, toolUseId);
190
192
  if (r.ok === 'hit') return { result: r.value, unknownShape };
191
193
  if (r.ok === 'unknown-shape') unknownShape = true;
192
194
  }
193
195
  }
194
196
  if (pending) {
195
- const r = scanLineForToolUse(pending, toolUseId);
197
+ const r = scanLine(pending, toolUseId);
196
198
  if (r.ok === 'hit') return { result: r.value, unknownShape };
197
199
  if (r.ok === 'unknown-shape') unknownShape = true;
198
200
  }
@@ -223,7 +225,7 @@ export function lookupToolUseInput(sessionId, toolUseId, projectHint) {
223
225
  const cached = lruGet(toolUseInputCache, cacheKey);
224
226
  if (cached) return cached;
225
227
 
226
- const { result, unknownShape } = scanTranscriptFile(filePath, toolUseId);
228
+ const { result, unknownShape } = scanTranscriptFile(filePath, toolUseId, scanLineForToolUse);
227
229
 
228
230
  if (!result && unknownShape) {
229
231
  try {
@@ -235,8 +237,65 @@ export function lookupToolUseInput(sessionId, toolUseId, projectHint) {
235
237
  return result;
236
238
  }
237
239
 
238
- /** 测试用:清掉两个 LRU。 */
240
+ /**
241
+ * 单行扫描:找 type:"user" 行里 tool_use_id 匹配的 tool_result 块,
242
+ * 取该行顶层 toolUseResult 的 { runId, taskId }(Workflow 工具的 async_launched 结果)。
243
+ *
244
+ * @param {string} line
245
+ * @param {string} toolUseId
246
+ * @returns {{ ok: 'hit', value: { runId?: string, taskId?: string } }
247
+ * | { ok: 'unknown-shape' }
248
+ * | { ok: 'miss' }}
249
+ */
250
+ function scanLineForToolUseResult(line, toolUseId) {
251
+ if (!line) return { ok: 'miss' };
252
+ if (line.indexOf('"runId":') === -1) return { ok: 'miss' };
253
+ if (line.indexOf(toolUseId) === -1) return { ok: 'miss' };
254
+
255
+ let entry;
256
+ try { entry = JSON.parse(line); } catch { return { ok: 'miss' }; }
257
+ const content = entry?.message?.content;
258
+ if (!Array.isArray(content)) return { ok: 'miss' };
259
+
260
+ const matched = content.some(blk => blk?.type === 'tool_result' && blk?.tool_use_id === toolUseId);
261
+ if (!matched) return { ok: 'miss' };
262
+
263
+ const tur = entry.toolUseResult;
264
+ if (!tur || typeof tur !== 'object') return { ok: 'miss' };
265
+ const value = {};
266
+ if (typeof tur.runId === 'string') value.runId = tur.runId;
267
+ if (typeof tur.taskId === 'string') value.taskId = tur.taskId;
268
+ if (value.runId || value.taskId) return { ok: 'hit', value };
269
+ return { ok: 'unknown-shape' };
270
+ }
271
+
272
+ /**
273
+ * 按 sessionId + tool_use.id 查 Workflow 工具结果的 { runId, taskId }。
274
+ * runId 直接对应 journal 文件名 `<sessionDir>/workflows/<runId>.json`。
275
+ * 仅缓存命中;miss 路径靠 findTranscriptPath 的短 TTL miss 缓存兜底。
276
+ *
277
+ * @param {string} sessionId
278
+ * @param {string} toolUseId
279
+ * @param {string} [projectHint]
280
+ * @returns {{ runId?: string, taskId?: string } | null}
281
+ */
282
+ export function lookupToolUseResult(sessionId, toolUseId, projectHint) {
283
+ if (!sessionId || !toolUseId) return null;
284
+ const filePath = findTranscriptPath(sessionId, projectHint);
285
+ if (!filePath || !existsSync(filePath)) return null;
286
+
287
+ const cacheKey = `${filePath}:${toolUseId}`;
288
+ const cached = lruGet(toolUseResultCache, cacheKey);
289
+ if (cached) return cached;
290
+
291
+ const { result } = scanTranscriptFile(filePath, toolUseId, scanLineForToolUseResult);
292
+ if (result) lruSet(toolUseResultCache, cacheKey, result, INPUT_CACHE_MAX);
293
+ return result;
294
+ }
295
+
296
+ /** 测试用:清掉三个 LRU。 */
239
297
  export function clearCache() {
240
298
  transcriptPathCache.clear();
241
299
  toolUseInputCache.clear();
300
+ toolUseResultCache.clear();
242
301
  }