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/assets/{App-COezHOeh.js → App-CE9-RsMp.js} +1 -1
- package/dist/assets/{App-eFrjLzF_.css → App-DfsHLalW.css} +1 -1
- package/dist/assets/{MdxEditorPanel-BkJKua3-.js → MdxEditorPanel-CDLhUUIj.js} +1 -1
- package/dist/assets/{Mobile-BtNceCJC.js → Mobile-BkZFYw5X.js} +1 -1
- package/dist/assets/index-BDK4eIE5.js +2 -0
- package/dist/assets/{index-Be9T-kDq.css → index-Cy7Xfu81.css} +1 -1
- package/dist/assets/{seqResourceLoaders-De_-fYhE.css → seqResourceLoaders-D-2EA12R.css} +2 -2
- package/dist/assets/seqResourceLoaders-aRa4CORe.js +2 -0
- package/dist/index.html +2 -2
- package/findcc.js +21 -1
- package/package.json +1 -1
- package/server/lib/enrich-plan-input.js +9 -3
- package/server/lib/enrich-workflow.js +95 -0
- package/server/lib/log-watcher.js +2 -0
- package/server/lib/pty-flood-coalescer.js +76 -12
- package/server/lib/resync-nudge-gate.js +33 -0
- package/server/lib/session-transcript-reader.js +66 -7
- package/server/lib/workflow-journal.js +155 -0
- package/server/lib/workflow-live.js +255 -0
- package/server/lib/workflow-watcher.js +280 -0
- package/server/routes/files-content.js +16 -6
- package/server/routes/workflow-journal.js +78 -0
- package/server/routes/workspaces.js +2 -0
- package/server/server.js +43 -3
- package/dist/assets/index-DSz9bNGm.js +0 -2
- package/dist/assets/seqResourceLoaders-C8gBbhlC.js +0 -2
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-
|
|
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-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
107
|
-
if (
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
* @
|
|
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 =
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
}
|