cc-viewer 1.6.232 → 1.6.234
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/concepts/ar/CustomUltraplanExpert.md +165 -0
- package/concepts/ar/UltraPlan.md +101 -0
- package/concepts/da/CustomUltraplanExpert.md +165 -0
- package/concepts/da/UltraPlan.md +101 -0
- package/concepts/de/CustomUltraplanExpert.md +165 -0
- package/concepts/de/UltraPlan.md +101 -0
- package/concepts/en/CustomUltraplanExpert.md +4 -0
- package/concepts/en/UltraPlan.md +40 -22
- package/concepts/es/CustomUltraplanExpert.md +165 -0
- package/concepts/es/UltraPlan.md +101 -0
- package/concepts/fr/CustomUltraplanExpert.md +165 -0
- package/concepts/fr/UltraPlan.md +101 -0
- package/concepts/it/CustomUltraplanExpert.md +165 -0
- package/concepts/it/UltraPlan.md +101 -0
- package/concepts/ja/CustomUltraplanExpert.md +165 -0
- package/concepts/ja/UltraPlan.md +101 -0
- package/concepts/ko/CustomUltraplanExpert.md +165 -0
- package/concepts/ko/UltraPlan.md +101 -0
- package/concepts/no/CustomUltraplanExpert.md +165 -0
- package/concepts/no/UltraPlan.md +101 -0
- package/concepts/pl/CustomUltraplanExpert.md +165 -0
- package/concepts/pl/UltraPlan.md +101 -0
- package/concepts/pt-BR/CustomUltraplanExpert.md +165 -0
- package/concepts/pt-BR/UltraPlan.md +101 -0
- package/concepts/ru/CustomUltraplanExpert.md +165 -0
- package/concepts/ru/UltraPlan.md +101 -0
- package/concepts/th/CustomUltraplanExpert.md +165 -0
- package/concepts/th/UltraPlan.md +101 -0
- package/concepts/tr/CustomUltraplanExpert.md +165 -0
- package/concepts/tr/UltraPlan.md +101 -0
- package/concepts/uk/CustomUltraplanExpert.md +165 -0
- package/concepts/uk/UltraPlan.md +101 -0
- package/concepts/zh/CustomUltraplanExpert.md +4 -0
- package/concepts/zh/UltraPlan.md +40 -22
- package/concepts/zh-TW/CustomUltraplanExpert.md +162 -0
- package/concepts/zh-TW/UltraPlan.md +101 -0
- package/dist/assets/App-4jFW5l1v.js +1 -0
- package/dist/assets/MdxEditorPanel-BzxNt12G.js +1 -0
- package/dist/assets/{AppHeader-shbV-3zU.css → MemoryDetailModal-C8FQroxH.css} +2 -2
- package/dist/assets/MemoryDetailModal-DGdR16rY.js +2 -0
- package/dist/assets/Mobile-CDimWgfU.js +1 -0
- package/dist/assets/{_baseUniq-_yALeOHi.js → _baseUniq-7WyLOfOD.js} +1 -1
- package/dist/assets/{arc-B5pRnfqS.js → arc-BqtQ2TR5.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-DthmH4OR.js → architectureDiagram-Q4EWVU46-DNsON_uI.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-DcTaGOKa.js → blockDiagram-DXYQGD6D-BAnmw6RY.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-BQKUCu8F.js → c4Diagram-AHTNJAMY-BVTMAIlF.js} +1 -1
- package/dist/assets/{channel-BWJQxTbE.js → channel-DpzUDUvG.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-DYZKWvRi.js → chunk-4BX2VUAB-63nvqcse.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-CS19SHdi.js → chunk-4TB4RGXK-DscRWqiR.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-Rs-HDnQc.js → chunk-55IACEB6-DzsFGeoV.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-hXx6TNZ7.js → chunk-EDXVE4YY-ZgTnVGTc.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-Dg-nxK_6.js → chunk-FMBD7UC4-DIKVKEfO.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-C6d9RMbp.js → chunk-OYMX7WX6-w_HztfnV.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-BNOQ1kBe.js → chunk-QZHKN3VN-BCvptAWT.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-D_ANME8r.js → chunk-YZCP3GAM-C-nVBIjq.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-50wMH0oC.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-50wMH0oC.js +1 -0
- package/dist/assets/clone-Cr4FyA_-.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-CTmbOA5L.js → cose-bilkent-S5V4N54A-r4X4a6GB.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-LfeX0c6P.js → dagre-KV5264BT-BHPzoGQS.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-C8bE96Vc.js → diagram-5BDNPKRD-Czb-PWGQ.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-CEpwug4a.js → diagram-G4DWMVQ6-DwjmplbB.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-B4IUGPuz.js → diagram-MMDJMWI5-CX8iQI58.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-BOT2RZZr.js → diagram-TYMM5635-BKLl1-2F.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-CLF596PW.js → erDiagram-SMLLAGMA-Dr0QHMWl.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-MBM9yHXX.js → flowDiagram-DWJPFMVM-BUQN9yen.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-C0UBNZaj.js → ganttDiagram-T4ZO3ILL-lOHwk9xE.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-CxHPXW7u.js → gitGraphDiagram-UUTBAWPF-DcNim81F.js} +1 -1
- package/dist/assets/{graph-olBhWx_l.js → graph-BkFt1bfK.js} +1 -1
- package/dist/assets/{index-BSqibSQz.js → index-B2Kc9sK3.js} +1 -1
- package/dist/assets/{index-CyPMm5q-.js → index-COwu7beD.js} +1 -1
- package/dist/assets/{index-DsEqauU9.js → index-CeM_RmDZ.js} +1 -1
- package/dist/assets/index-CqJkuXsS.js +2 -0
- package/dist/assets/{index-DJMCeDYQ.js → index-DCgVszdq.js} +1 -1
- package/dist/assets/{index-DrQ6M-Gu.js → index-DdYkqLM-.js} +1 -1
- package/dist/assets/{index-yzMYxfsJ.js → index-Df4X98RK.js} +1 -1
- package/dist/assets/{index-hcbJYwvG.js → index-DxgQkYpd.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-CqV9mkMZ.js → infoDiagram-42DDH7IO-BYux_n61.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-Czpfcqi6.js → ishikawaDiagram-UXIWVN3A-COJ7k4kv.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-CH10JTQU.js → journeyDiagram-VCZTEJTY-CNTmiD7P.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-C9Q4Fy8z.js → kanban-definition-6JOO6SKY-D6gAzHkR.js} +1 -1
- package/dist/assets/{layout-BI-B9yUD.js → layout-CLLpxzZt.js} +1 -1
- package/dist/assets/{linear-Dw3iBwM1.js → linear-De2FOjos.js} +1 -1
- package/dist/assets/{mermaid.core-B_VD4Hvj.js → mermaid.core-Cydvnbop.js} +2 -2
- package/dist/assets/{min-Du9adfPH.js → min-svWQ_itm.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-CaMicHMI.js → mindmap-definition-QFDTVHPH-DB_mVE41.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-DCJQvpAG.js → pieDiagram-DEJITSTG-BPsEiPgY.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-CAM_s8Xc.js → quadrantDiagram-34T5L4WZ-B0vwxgTT.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-DF3NSseQ.js → requirementDiagram-MS252O5E-Dh5h8Iim.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-BPdFQNTe.js → sankeyDiagram-XADWPNL6-DJd-MIyc.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-Du8-vUmN.js → sequenceDiagram-FGHM5R23-DL8FmWmM.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-B9CfSrrU.js → stateDiagram-FHFEXIEX-BvM-YO9H.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-De-L2Tw-.js → stateDiagram-v2-QKLJ7IA2-DrW9i5SO.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-CX-WGfJI.js → timeline-definition-GMOUNBTQ-Cmgo8NKr.js} +1 -1
- package/dist/assets/{vendor-antd-DW2QvF0l.js → vendor-antd-COAwO2n0.js} +1 -1
- package/dist/assets/{vendor-codemirror-Bh_IP9SJ.js → vendor-codemirror-B_arK_ec.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-DmzrSr0n.js → vendor-mdxeditor-lhz8HD2R.js} +2 -2
- package/dist/assets/{vendor-qrcode-CXOKgQeD.js → vendor-qrcode-CMjqs6Gh.js} +1 -1
- package/dist/assets/{vendor-virtuoso-CU5wFM1_.js → vendor-virtuoso-CR72uTTv.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-BDTC-m_V.js → vennDiagram-DHZGUBPP-fSI5n23s.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-c78hCql7.js → wardley-RL74JXVD-DYGzf-CJ.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-CMiF49Ci.js → wardleyDiagram-NUSXRM2D-BTrxJ754.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-DrfnrMDg.js → xychartDiagram-5P7HB3ND-HzTx9Jn2.js} +1 -1
- package/dist/index.html +4 -4
- package/interceptor.js +21 -11
- package/lib/enrich-plan-input.js +109 -0
- package/lib/log-stream.js +1 -1
- package/lib/log-watcher.js +72 -23
- package/lib/session-transcript-reader.js +242 -0
- package/package.json +1 -1
- package/server.js +59 -12
- package/dist/assets/App-Do67d04Y.js +0 -1
- package/dist/assets/AppHeader.module-DAis3JIf.js +0 -2
- package/dist/assets/MdxEditorPanel-DTjCB4JK.js +0 -1
- package/dist/assets/Mobile-BtHBEqlu.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-D7FdAnCO.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-D7FdAnCO.js +0 -1
- package/dist/assets/clone-CkOXLPKP.js +0 -1
- package/dist/assets/index-CHO5znSo.js +0 -2
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enrich Plan Input
|
|
3
|
+
*
|
|
4
|
+
* 在 cc-viewer 出 SSE/REST 之前,把 ExitPlanMode tool_use 的空 input 用 session 转写
|
|
5
|
+
* 里 normalizeToolInput 写进去的 plan / planFilePath 补全。
|
|
6
|
+
*
|
|
7
|
+
* 不修改 ExitPlanMode 之外的工具;不覆盖已有 input 字段。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { lookupToolUseInput } from './session-transcript-reader.js';
|
|
11
|
+
|
|
12
|
+
const EMPTY_INPUT_SUBSTR = '"name":"ExitPlanMode","input":{}';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 廉价子串预过滤:原始 JSON 字符串里有没有「ExitPlanMode + 空 input」的字节序列。
|
|
16
|
+
* 实测当前 CC 的 interceptor 用 default JSON.stringify(body),零空格,恒定字节序。
|
|
17
|
+
*
|
|
18
|
+
* @param {string} raw
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
export function rawHasEmptyExitPlanMode(raw) {
|
|
22
|
+
if (typeof raw !== 'string' || !raw) return false;
|
|
23
|
+
return raw.indexOf(EMPTY_INPUT_SUBSTR) !== -1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function findEmptyExitPlanModeBlocks(content, out) {
|
|
27
|
+
if (!Array.isArray(content)) return;
|
|
28
|
+
for (const blk of content) {
|
|
29
|
+
if (!blk || blk.type !== 'tool_use' || blk.name !== 'ExitPlanMode') continue;
|
|
30
|
+
const inp = blk.input;
|
|
31
|
+
if (!inp || typeof inp !== 'object' || Array.isArray(inp)) continue;
|
|
32
|
+
if (Object.keys(inp).length !== 0) continue;
|
|
33
|
+
if (typeof blk.id !== 'string' || !blk.id) continue;
|
|
34
|
+
out.push(blk);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 遍历 entry 中所有 ExitPlanMode tool_use(response.body.content 当前轮 +
|
|
40
|
+
* body.messages[*].content[] 历史轮),把空 input 用 session 转写补全。
|
|
41
|
+
*
|
|
42
|
+
* 设计说明:
|
|
43
|
+
* - 早返回:缺 sessionId header(旧版 CC 无该 header)→ 不查表,不计 missed。
|
|
44
|
+
* - 早返回:sub-agent / 非 mainAgent 条目不 enrich——子代理 transcript 在另一目录
|
|
45
|
+
* (`<sid>/subagents/agent-<hash>.jsonl`),按主 transcript 的 tool_use.id 查表
|
|
46
|
+
* 理论上不会命中,但显式守卫避免万一同 id 撞库。
|
|
47
|
+
* - Header 名规范:interceptor 走 fetch Headers.entries() 全小写(WHATWG 规范),
|
|
48
|
+
* 只查小写键即可。
|
|
49
|
+
* - In-place mutation by design:用 Object.assign(blk.input, patch) 而非
|
|
50
|
+
* `blk.input = {...}`。增量重建器 (lib/delta-reconstructor.js) 会让同一 tool_use
|
|
51
|
+
* block 在多个 entry 间共享对象引用;in-place mutate 让后续 entry 的「Object.keys
|
|
52
|
+
* (inp).length === 0」预检自动跳过——正是我们想要的 (相同 plan,不重新查盘)。
|
|
53
|
+
* 注意:此优化仅在 SSE / live 路径(共享对象)生效;REST 路径 raw → JSON.parse
|
|
54
|
+
* 每条独立对象,每条仍走一次 lookup(命中走 LRU,O(1))。
|
|
55
|
+
*
|
|
56
|
+
* @param {object} entry - 已 JSON.parse 的日志条目
|
|
57
|
+
* @returns {{ enriched: number, missed: number }}
|
|
58
|
+
*/
|
|
59
|
+
export function enrichEntry(entry) {
|
|
60
|
+
if (!entry || typeof entry !== 'object') return { enriched: 0, missed: 0 };
|
|
61
|
+
if (entry.mainAgent === false) return { enriched: 0, missed: 0 }; // sub-agent 不补
|
|
62
|
+
const sid = entry.headers?.['x-claude-code-session-id'] || null;
|
|
63
|
+
if (!sid) return { enriched: 0, missed: 0 };
|
|
64
|
+
const projectHint = typeof entry.project === 'string' ? entry.project : undefined;
|
|
65
|
+
|
|
66
|
+
const candidates = [];
|
|
67
|
+
const respContent = entry.response?.body?.content;
|
|
68
|
+
findEmptyExitPlanModeBlocks(respContent, candidates);
|
|
69
|
+
const msgs = entry.body?.messages;
|
|
70
|
+
if (Array.isArray(msgs)) {
|
|
71
|
+
for (const m of msgs) {
|
|
72
|
+
if (m && Array.isArray(m.content)) findEmptyExitPlanModeBlocks(m.content, candidates);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (candidates.length === 0) return { enriched: 0, missed: 0 };
|
|
76
|
+
|
|
77
|
+
let enriched = 0, missed = 0;
|
|
78
|
+
for (const blk of candidates) {
|
|
79
|
+
const found = lookupToolUseInput(sid, blk.id, projectHint);
|
|
80
|
+
if (found && (found.plan || found.planFilePath)) {
|
|
81
|
+
const patch = {};
|
|
82
|
+
if (typeof found.plan === 'string') patch.plan = found.plan;
|
|
83
|
+
if (typeof found.planFilePath === 'string') patch.planFilePath = found.planFilePath;
|
|
84
|
+
Object.assign(blk.input, patch);
|
|
85
|
+
enriched++;
|
|
86
|
+
} else {
|
|
87
|
+
missed++;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { enriched, missed };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 服务端三处接入点共用:raw 字符串预过滤 → 命中才 parse + enrich + stringify。
|
|
95
|
+
*
|
|
96
|
+
* 设计原则:保持 lib/log-stream.js 的「原始字符串透传」哲学,只对真正需要补全的
|
|
97
|
+
* 条目做 parse / stringify,其它一律按 raw 透传。
|
|
98
|
+
*
|
|
99
|
+
* @param {string} raw - 一条日志条目的原始 JSON 字符串
|
|
100
|
+
* @returns {string} - enriched JSON 字符串,或原始 raw
|
|
101
|
+
*/
|
|
102
|
+
export function enrichRawIfNeeded(raw) {
|
|
103
|
+
if (!rawHasEmptyExitPlanMode(raw)) return raw;
|
|
104
|
+
let entry;
|
|
105
|
+
try { entry = JSON.parse(raw); } catch { return raw; }
|
|
106
|
+
const { enriched } = enrichEntry(entry);
|
|
107
|
+
if (enriched === 0) return raw;
|
|
108
|
+
try { return JSON.stringify(entry); } catch { return raw; }
|
|
109
|
+
}
|
package/lib/log-stream.js
CHANGED
|
@@ -241,7 +241,7 @@ export async function streamRawEntriesAsync(filePath, onRawEntry, opts = {}) {
|
|
|
241
241
|
if (ts && ts < sinceFilter) continue;
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
-
onRawEntry(raw);
|
|
244
|
+
await onRawEntry(raw);
|
|
245
245
|
sentCount++;
|
|
246
246
|
if (sentCount % YIELD_INTERVAL === 0) {
|
|
247
247
|
await new Promise(resolve => setImmediate(resolve));
|
package/lib/log-watcher.js
CHANGED
|
@@ -3,6 +3,7 @@ import { isMainAgentEntry, extractCachedContent } from './kv-cache-analyzer.js';
|
|
|
3
3
|
import { buildContextWindowEvent, getContextSizeForModel } from './context-watcher.js';
|
|
4
4
|
import { reconstructEntries, createIncrementalReconstructor } from './delta-reconstructor.js';
|
|
5
5
|
import { countLogEntries, streamReconstructedEntries } from './log-stream.js';
|
|
6
|
+
import { enrichEntry } from './enrich-plan-input.js';
|
|
6
7
|
|
|
7
8
|
// 跟踪所有被 watch 的日志文件
|
|
8
9
|
const watchedFiles = new Map();
|
|
@@ -40,19 +41,58 @@ export function readLogFile(logFile) {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
// SSE 单客户端 backpressure 容忍上限:连续未排空 > 此时长则视为 dead 客户端剔除。
|
|
45
|
+
// 与 server.js 同名常量值保持一致(避免循环依赖,此处单独 mirror)。
|
|
46
|
+
const SSE_BACKPRESSURE_TIMEOUT_MS = 5000;
|
|
47
|
+
|
|
48
|
+
function _removeClient(clients, client) {
|
|
49
|
+
const idx = clients.indexOf(client);
|
|
50
|
+
if (idx !== -1) clients.splice(idx, 1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 向单个 SSE client 安全写入 payload。
|
|
55
|
+
* - 写错或 client.destroyed/!writable:立即从 clients 数组移除
|
|
56
|
+
* - write 返回 false(写缓冲满):标记时间戳,超过 SSE_BACKPRESSURE_TIMEOUT_MS 仍未排空则剔除并 end()
|
|
57
|
+
* - drain 后重置 _sseBackpressureSince=0,下次 backpressure 重新计时
|
|
58
|
+
*/
|
|
59
|
+
function _safeSseWrite(clients, client, payload) {
|
|
60
|
+
// 仅在显式标记 destroyed/writable=false 时剔除;undefined(如老 mock)按"活"处理。
|
|
61
|
+
if (client.destroyed === true || client.writable === false) {
|
|
62
|
+
_removeClient(clients, client);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
let ok;
|
|
66
|
+
try {
|
|
67
|
+
ok = client.write(payload);
|
|
68
|
+
} catch {
|
|
69
|
+
_removeClient(clients, client);
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
if (!ok) {
|
|
73
|
+
if (!client._sseBackpressureSince) {
|
|
74
|
+
client._sseBackpressureSince = Date.now();
|
|
75
|
+
client.once('drain', () => { client._sseBackpressureSince = 0; });
|
|
76
|
+
} else if (Date.now() - client._sseBackpressureSince > SSE_BACKPRESSURE_TIMEOUT_MS) {
|
|
77
|
+
_removeClient(clients, client);
|
|
78
|
+
try { client.end(); } catch {}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
43
85
|
/**
|
|
44
86
|
* Send an SSE entry to all connected clients.
|
|
45
87
|
* @param {Array} clients - SSE client array
|
|
46
88
|
* @param {object} entry - parsed log entry
|
|
47
89
|
*/
|
|
48
90
|
export function sendToClients(clients, entry) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
});
|
|
91
|
+
const payload = `data: ${JSON.stringify(entry)}\n\n`;
|
|
92
|
+
// 倒序遍历允许循环内安全 splice
|
|
93
|
+
for (let i = clients.length - 1; i >= 0; i--) {
|
|
94
|
+
_safeSseWrite(clients, clients[i], payload);
|
|
95
|
+
}
|
|
56
96
|
}
|
|
57
97
|
|
|
58
98
|
/**
|
|
@@ -62,11 +102,22 @@ export function sendToClients(clients, entry) {
|
|
|
62
102
|
* @param {object} data - event payload
|
|
63
103
|
*/
|
|
64
104
|
export function sendEventToClients(clients, eventName, data) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
105
|
+
const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
106
|
+
for (let i = clients.length - 1; i >= 0; i--) {
|
|
107
|
+
_safeSseWrite(clients, clients[i], payload);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 旋转处理器专用:发送已序列化的 load_chunk segment 数据。
|
|
113
|
+
* @param {Array} clients - SSE client array
|
|
114
|
+
* @param {string} dataJson - segment 已被调用方 JSON.stringify
|
|
115
|
+
*/
|
|
116
|
+
export function sendChunkToClients(clients, dataJson) {
|
|
117
|
+
const payload = `event: load_chunk\ndata: ${dataJson}\n\n`;
|
|
118
|
+
for (let i = clients.length - 1; i >= 0; i--) {
|
|
119
|
+
_safeSseWrite(clients, clients[i], payload);
|
|
120
|
+
}
|
|
70
121
|
}
|
|
71
122
|
|
|
72
123
|
/**
|
|
@@ -111,20 +162,13 @@ export function watchLogFile(opts) {
|
|
|
111
162
|
unwatchFile(logFile);
|
|
112
163
|
watchedFiles.delete(logFile);
|
|
113
164
|
|
|
114
|
-
// 流式分段广播,避免全量加载 OOM
|
|
165
|
+
// 流式分段广播,避免全量加载 OOM;走 _safeSseWrite 包装做 backpressure / dead-client 清理
|
|
115
166
|
const rotTotal = countLogEntries(currentLogFile);
|
|
116
|
-
clients
|
|
117
|
-
try { client.write(`event: load_start\ndata: ${JSON.stringify({ total: rotTotal, incremental: false })}\n\n`); } catch { }
|
|
118
|
-
});
|
|
167
|
+
sendEventToClients(clients, 'load_start', { total: rotTotal, incremental: false });
|
|
119
168
|
streamReconstructedEntries(currentLogFile, (segment) => {
|
|
120
|
-
|
|
121
|
-
clients.forEach(client => {
|
|
122
|
-
try { client.write(`event: load_chunk\ndata: ${data}\n\n`); } catch { }
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
clients.forEach(client => {
|
|
126
|
-
try { client.write(`event: load_end\ndata: {}\n\n`); } catch { }
|
|
169
|
+
sendChunkToClients(clients, JSON.stringify(segment));
|
|
127
170
|
});
|
|
171
|
+
sendEventToClients(clients, 'load_end', {});
|
|
128
172
|
watchLogFile({ ...opts, logFile: currentLogFile });
|
|
129
173
|
return;
|
|
130
174
|
}
|
|
@@ -172,6 +216,11 @@ export function watchLogFile(opts) {
|
|
|
172
216
|
}
|
|
173
217
|
// Delta storage: reconstruct before push — 确保前端收到完整 messages
|
|
174
218
|
_reconstructor.reconstruct(parsed);
|
|
219
|
+
// ExitPlanMode V2 input 服务端补全(详见 enrich-plan-input.js#enrichEntry JSDoc)。
|
|
220
|
+
// 同步实现:候选扫描天然廉价(无 ExitPlanMode 块直接 0ms 返回);命中
|
|
221
|
+
// 路径由 transcript 64MB 上限 + miss 30s TTL + path mtime 校验三层兜
|
|
222
|
+
// 住,最坏 ~150ms。如未来 hit 比例显著上升再考虑 setImmediate 拆分。
|
|
223
|
+
try { enrichEntry(parsed); } catch { /* 静默回退 */ }
|
|
175
224
|
sendToClients(clients, parsed);
|
|
176
225
|
runParallelHook('onNewEntry', parsed).catch(() => {});
|
|
177
226
|
if (isMainAgentEntry(parsed) && !parsed.inProgress) {
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Transcript Reader
|
|
3
|
+
*
|
|
4
|
+
* 从本地 Claude Code session 转写文件(<projectsDir>/<encoded-cwd>/<sessionId>.jsonl)
|
|
5
|
+
* 按 tool_use.id 抽取 ExitPlanMode 的 input.plan / planFilePath。
|
|
6
|
+
*
|
|
7
|
+
* 用途:CC 2.x ExitPlanModeV2Tool 在 API 网线送 input:{},plan 内容仅在 CC 客户端
|
|
8
|
+
* normalizeToolInput 时写入 session 转写。cc-viewer 出 SSE/REST 前用本模块补全。
|
|
9
|
+
*
|
|
10
|
+
* 设计决策:
|
|
11
|
+
* - 边界放置在 server egress 而非 fs.watch + 独立 SSE 事件——前端零改动 +
|
|
12
|
+
* tool_use.id 已在 wire 上同步可用;缺点是历史回放每次重扫,由 LRU + 文件大小
|
|
13
|
+
* 上限 + miss TTL 兜住。
|
|
14
|
+
* - projectsDir 走 findcc.getClaudeConfigDir(),与 CLAUDE_CONFIG_DIR 重定向对齐。
|
|
15
|
+
* - 内存:流式 1MB 分块读 + 行级 indexOf 双子串预过滤 + 半写入行 try/catch 跳过。
|
|
16
|
+
* - 缓存:transcript 路径 LRU(64) + tool_use input LRU(5000);命中后用 mtime 校验
|
|
17
|
+
* detect transcript 被覆写。
|
|
18
|
+
* - miss 短 TTL:30s 让 race(CC 还没 flush)后自动重试。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, statSync, openSync, readSync, closeSync, readdirSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { getClaudeConfigDir } from '../findcc.js';
|
|
24
|
+
|
|
25
|
+
function projectsDir() {
|
|
26
|
+
return process.env.CCV_PROJECTS_DIR || join(getClaudeConfigDir(), 'projects');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const READ_CHUNK_SIZE = 1024 * 1024;
|
|
30
|
+
const MAX_TRANSCRIPT_BYTES = 64 * 1024 * 1024; // 异常 GB 级文件防御
|
|
31
|
+
const MISS_TTL_MS = 30 * 1000; // miss 路径短 TTL,让 race 后自动重试
|
|
32
|
+
|
|
33
|
+
const PATH_CACHE_MAX = 64;
|
|
34
|
+
const INPUT_CACHE_MAX = 5000;
|
|
35
|
+
|
|
36
|
+
const transcriptPathCache = new Map(); // key → { path, mtimeMs } | { path: null, expireAt }
|
|
37
|
+
const toolUseInputCache = new Map(); // `${path}:${tuId}` → { plan?, planFilePath? }(仅命中入缓存)
|
|
38
|
+
|
|
39
|
+
function lruSet(map, key, value, max) {
|
|
40
|
+
if (map.has(key)) map.delete(key);
|
|
41
|
+
map.set(key, value);
|
|
42
|
+
if (map.size > max) {
|
|
43
|
+
const oldest = map.keys().next().value;
|
|
44
|
+
map.delete(oldest);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function lruGet(map, key) {
|
|
49
|
+
if (!map.has(key)) return undefined;
|
|
50
|
+
const v = map.get(key);
|
|
51
|
+
map.delete(key);
|
|
52
|
+
map.set(key, v);
|
|
53
|
+
return v;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function pickByMtime(arr) {
|
|
57
|
+
return arr.reduce((a, b) => a.mtimeMs >= b.mtimeMs ? a : b);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* sessionId → 转写文件绝对路径。多匹配时按 entry.project 反向匹配编码目录尾段,
|
|
62
|
+
* 仍多匹配取 mtimeMs 最大者。命中后 LRU(64) 缓存(带 mtime);miss 短 TTL 缓存。
|
|
63
|
+
*
|
|
64
|
+
* @param {string} sessionId
|
|
65
|
+
* @param {string} [projectHint] - interceptor 写入的 entry.project(basename of cwd, sanitized)
|
|
66
|
+
* @returns {string | null}
|
|
67
|
+
*/
|
|
68
|
+
export function findTranscriptPath(sessionId, projectHint) {
|
|
69
|
+
if (!sessionId) return null;
|
|
70
|
+
const cacheKey = projectHint ? `${sessionId}|${projectHint}` : sessionId;
|
|
71
|
+
const cached = lruGet(transcriptPathCache, cacheKey);
|
|
72
|
+
if (cached) {
|
|
73
|
+
if (cached.path === null) {
|
|
74
|
+
// miss 缓存:TTL 内继续返回 null,过期则重新探测
|
|
75
|
+
if (cached.expireAt && cached.expireAt > Date.now()) return null;
|
|
76
|
+
} else {
|
|
77
|
+
// hit 缓存:用 mtime 校验文件未被覆写
|
|
78
|
+
try {
|
|
79
|
+
const st = statSync(cached.path);
|
|
80
|
+
if (st.isFile() && st.mtimeMs === cached.mtimeMs) return cached.path;
|
|
81
|
+
} catch {}
|
|
82
|
+
// mtime 变了或文件没了 → fall through 重扫
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const root = projectsDir();
|
|
87
|
+
let dirs;
|
|
88
|
+
try { dirs = readdirSync(root); }
|
|
89
|
+
catch {
|
|
90
|
+
lruSet(transcriptPathCache, cacheKey, { path: null, expireAt: Date.now() + MISS_TTL_MS }, PATH_CACHE_MAX);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const matches = [];
|
|
95
|
+
for (const d of dirs) {
|
|
96
|
+
const p = join(root, d, `${sessionId}.jsonl`);
|
|
97
|
+
let st;
|
|
98
|
+
try { st = statSync(p); } catch { continue; }
|
|
99
|
+
if (!st.isFile()) continue;
|
|
100
|
+
matches.push({ dir: d, path: p, mtimeMs: st.mtimeMs });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (matches.length === 0) {
|
|
104
|
+
lruSet(transcriptPathCache, cacheKey, { path: null, expireAt: Date.now() + MISS_TTL_MS }, PATH_CACHE_MAX);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let chosen;
|
|
109
|
+
if (matches.length === 1) {
|
|
110
|
+
chosen = matches[0];
|
|
111
|
+
} else if (projectHint) {
|
|
112
|
+
const hint = String(projectHint);
|
|
113
|
+
const hintMatch = matches.filter(m => m.dir.endsWith('-' + hint) || m.dir === hint);
|
|
114
|
+
chosen = pickByMtime(hintMatch.length ? hintMatch : matches);
|
|
115
|
+
} else {
|
|
116
|
+
chosen = pickByMtime(matches);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
lruSet(transcriptPathCache, cacheKey, { path: chosen.path, mtimeMs: chosen.mtimeMs }, PATH_CACHE_MAX);
|
|
120
|
+
return chosen.path;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 单行扫描:行级双子串预过滤后 JSON.parse 抽取目标 ExitPlanMode 块的 input。
|
|
125
|
+
*
|
|
126
|
+
* @param {string} line
|
|
127
|
+
* @param {string} toolUseId
|
|
128
|
+
* @returns {{ ok: 'hit', value: { plan?: string, planFilePath?: string } }
|
|
129
|
+
* | { ok: 'unknown-shape' }
|
|
130
|
+
* | { ok: 'miss' }}
|
|
131
|
+
*/
|
|
132
|
+
function scanLineForToolUse(line, toolUseId) {
|
|
133
|
+
if (!line) return { ok: 'miss' };
|
|
134
|
+
if (line.indexOf('"name":"ExitPlanMode"') === -1) return { ok: 'miss' };
|
|
135
|
+
if (line.indexOf(toolUseId) === -1) return { ok: 'miss' };
|
|
136
|
+
|
|
137
|
+
let entry;
|
|
138
|
+
try { entry = JSON.parse(line); } catch { return { ok: 'miss' }; }
|
|
139
|
+
const content = entry?.message?.content;
|
|
140
|
+
if (!Array.isArray(content)) return { ok: 'miss' };
|
|
141
|
+
|
|
142
|
+
let unknownShape = false;
|
|
143
|
+
for (const blk of content) {
|
|
144
|
+
if (blk?.type !== 'tool_use' || blk?.name !== 'ExitPlanMode') continue;
|
|
145
|
+
if (blk?.id !== toolUseId) continue;
|
|
146
|
+
const inp = blk.input || {};
|
|
147
|
+
const value = {};
|
|
148
|
+
if (typeof inp.plan === 'string') value.plan = inp.plan;
|
|
149
|
+
if (typeof inp.planFilePath === 'string') value.planFilePath = inp.planFilePath;
|
|
150
|
+
if (value.plan || value.planFilePath) return { ok: 'hit', value };
|
|
151
|
+
if (Object.keys(inp).length > 0) unknownShape = true;
|
|
152
|
+
}
|
|
153
|
+
return unknownShape ? { ok: 'unknown-shape' } : { ok: 'miss' };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 流式扫 transcript 文件,按 tool_use.id 找 ExitPlanMode 块的 input。
|
|
158
|
+
* 命中即 break;半写入行 try/catch 跳过。最大文件大小由 MAX_TRANSCRIPT_BYTES 兜底。
|
|
159
|
+
*
|
|
160
|
+
* @param {string} filePath
|
|
161
|
+
* @param {string} toolUseId
|
|
162
|
+
* @returns {{ result: { plan?: string, planFilePath?: string } | null, unknownShape: boolean }}
|
|
163
|
+
*/
|
|
164
|
+
function scanTranscriptFile(filePath, toolUseId) {
|
|
165
|
+
let unknownShape = false;
|
|
166
|
+
try {
|
|
167
|
+
const fileSize = statSync(filePath).size;
|
|
168
|
+
if (fileSize === 0) return { result: null, unknownShape: false };
|
|
169
|
+
if (fileSize > MAX_TRANSCRIPT_BYTES) {
|
|
170
|
+
try {
|
|
171
|
+
process.stderr.write(`[enrichPlan] transcript too large (${fileSize} bytes > ${MAX_TRANSCRIPT_BYTES}); skipped\n`);
|
|
172
|
+
} catch {}
|
|
173
|
+
return { result: null, unknownShape: false };
|
|
174
|
+
}
|
|
175
|
+
const fd = openSync(filePath, 'r');
|
|
176
|
+
const buf = Buffer.alloc(Math.min(READ_CHUNK_SIZE, fileSize));
|
|
177
|
+
let offset = 0;
|
|
178
|
+
let pending = '';
|
|
179
|
+
try {
|
|
180
|
+
while (offset < fileSize) {
|
|
181
|
+
const toRead = Math.min(buf.length, fileSize - offset);
|
|
182
|
+
const bytesRead = readSync(fd, buf, 0, toRead, offset);
|
|
183
|
+
if (bytesRead === 0) break;
|
|
184
|
+
offset += bytesRead;
|
|
185
|
+
const text = pending + buf.toString('utf-8', 0, bytesRead);
|
|
186
|
+
const lines = text.split('\n');
|
|
187
|
+
pending = lines.pop() ?? '';
|
|
188
|
+
for (const line of lines) {
|
|
189
|
+
const r = scanLineForToolUse(line, toolUseId);
|
|
190
|
+
if (r.ok === 'hit') return { result: r.value, unknownShape };
|
|
191
|
+
if (r.ok === 'unknown-shape') unknownShape = true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (pending) {
|
|
195
|
+
const r = scanLineForToolUse(pending, toolUseId);
|
|
196
|
+
if (r.ok === 'hit') return { result: r.value, unknownShape };
|
|
197
|
+
if (r.ok === 'unknown-shape') unknownShape = true;
|
|
198
|
+
}
|
|
199
|
+
} finally {
|
|
200
|
+
closeSync(fd);
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
return { result: null, unknownShape };
|
|
204
|
+
}
|
|
205
|
+
return { result: null, unknownShape };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 按 sessionId + tool_use.id 查 ExitPlanMode 的 input.plan / planFilePath。
|
|
210
|
+
* 仅缓存命中;miss 路径靠 findTranscriptPath 的短 TTL miss 缓存兜底。
|
|
211
|
+
*
|
|
212
|
+
* @param {string} sessionId
|
|
213
|
+
* @param {string} toolUseId
|
|
214
|
+
* @param {string} [projectHint]
|
|
215
|
+
* @returns {{ plan?: string, planFilePath?: string } | null}
|
|
216
|
+
*/
|
|
217
|
+
export function lookupToolUseInput(sessionId, toolUseId, projectHint) {
|
|
218
|
+
if (!sessionId || !toolUseId) return null;
|
|
219
|
+
const filePath = findTranscriptPath(sessionId, projectHint);
|
|
220
|
+
if (!filePath || !existsSync(filePath)) return null;
|
|
221
|
+
|
|
222
|
+
const cacheKey = `${filePath}:${toolUseId}`;
|
|
223
|
+
const cached = lruGet(toolUseInputCache, cacheKey);
|
|
224
|
+
if (cached) return cached;
|
|
225
|
+
|
|
226
|
+
const { result, unknownShape } = scanTranscriptFile(filePath, toolUseId);
|
|
227
|
+
|
|
228
|
+
if (!result && unknownShape) {
|
|
229
|
+
try {
|
|
230
|
+
process.stderr.write(`[enrichPlan] schema drift: ExitPlanMode tool_use ${toolUseId} has input but no plan/planFilePath (sid=${sessionId})\n`);
|
|
231
|
+
} catch {}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (result) lruSet(toolUseInputCache, cacheKey, result, INPUT_CACHE_MAX);
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** 测试用:清掉两个 LRU。 */
|
|
239
|
+
export function clearCache() {
|
|
240
|
+
transcriptPathCache.clear();
|
|
241
|
+
toolUseInputCache.clear();
|
|
242
|
+
}
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -48,6 +48,7 @@ import { watchLogFile, startWatching, getWatchedFiles, sendEventToClients, sendT
|
|
|
48
48
|
import { isMainAgentEntry, extractCachedContent } from './lib/kv-cache-analyzer.js';
|
|
49
49
|
import { listLocalLogs, deleteLogFiles, mergeLogFiles } from './lib/log-management.js';
|
|
50
50
|
import { countLogEntries, streamRawEntriesAsync, readPagedEntries } from './lib/log-stream.js';
|
|
51
|
+
import { enrichRawIfNeeded } from './lib/enrich-plan-input.js';
|
|
51
52
|
import { buildTeamStatusResponse } from './lib/team-runtime.js';
|
|
52
53
|
|
|
53
54
|
|
|
@@ -185,6 +186,13 @@ export function initPostLaunch() {
|
|
|
185
186
|
// Global POST body size limit (10MB) to prevent OOM from malicious/buggy clients
|
|
186
187
|
const MAX_POST_BODY = 10 * 1024 * 1024;
|
|
187
188
|
|
|
189
|
+
// /events 默认重放窗口:bare 请求(无 since、无 limit、无 cc)时使用,
|
|
190
|
+
// 防止长会话把数十 MB 历史一次性灌进浏览器导致 renderer OOM。
|
|
191
|
+
// 用户显式 ?limit=0 可恢复全量加载(power-user 逃生口)。
|
|
192
|
+
const DEFAULT_EVENTS_LIMIT = 1000;
|
|
193
|
+
// SSE 单客户端 backpressure 容忍上限:连续未排空 > 此时长则视为 dead 客户端剔除。
|
|
194
|
+
const SSE_BACKPRESSURE_TIMEOUT_MS = 5000;
|
|
195
|
+
|
|
188
196
|
|
|
189
197
|
|
|
190
198
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -963,24 +971,57 @@ async function handleRequest(req, res) {
|
|
|
963
971
|
const projectMatch = !projectParam || projectParam === (_projectName || '');
|
|
964
972
|
const useIncremental = !!(sinceParam && ccParam > 0 && projectMatch && !isNaN(new Date(sinceParam).getTime()));
|
|
965
973
|
|
|
966
|
-
//
|
|
967
|
-
|
|
968
|
-
|
|
974
|
+
// 分页参数:
|
|
975
|
+
// - mobile 首次加载传 ?limit=200
|
|
976
|
+
// - bare desktop 请求(无任何 query 参数)默认套 DEFAULT_EVENTS_LIMIT
|
|
977
|
+
// - 显式 ?limit=0 表示"我要全量"(保留旧行为入口)
|
|
978
|
+
const limitParamRaw = parsedUrl.searchParams.get('limit');
|
|
979
|
+
const limitParamGiven = limitParamRaw !== null;
|
|
980
|
+
const limitParamNum = parseInt(limitParamRaw, 10);
|
|
981
|
+
let effectiveLimit = 0;
|
|
982
|
+
if (!useIncremental) {
|
|
983
|
+
if (limitParamGiven) {
|
|
984
|
+
effectiveLimit = Number.isFinite(limitParamNum) && limitParamNum > 0 ? limitParamNum : 0;
|
|
985
|
+
} else {
|
|
986
|
+
effectiveLimit = DEFAULT_EVENTS_LIMIT;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
const useLimit = effectiveLimit > 0;
|
|
969
990
|
|
|
970
991
|
// KV-Cache / context_window 追踪(扫描全量条目,不受 since 过滤影响)
|
|
971
992
|
let latestKvCache = null;
|
|
972
993
|
let latestContextWindow = null;
|
|
973
994
|
let pushedContextWindow = false;
|
|
974
995
|
|
|
975
|
-
await streamRawEntriesAsync(LOG_FILE, (raw) => {
|
|
996
|
+
await streamRawEntriesAsync(LOG_FILE, async (raw) => {
|
|
976
997
|
// 直接发送原始 JSON 字符串,不做 parse/reconstruct/stringify
|
|
998
|
+
// ExitPlanMode V2 空 input 的条目按需补全 plan / planFilePath,其它原样透传
|
|
999
|
+
if (res.destroyed || !res.writable) return;
|
|
1000
|
+
const out = enrichRawIfNeeded(raw);
|
|
977
1001
|
// SSE data 字段不允许裸换行,去除 pretty-printed JSON 的换行
|
|
978
|
-
res.write
|
|
979
|
-
res.
|
|
980
|
-
|
|
1002
|
+
// 写入路径整体 try-catch 兜底:连接在 res.write 之间被对端 RST/destroy 时不至于
|
|
1003
|
+
// 把 EPIPE 抛穿 async callback;res.on('close'|'error') 已会做 clients 数组清理。
|
|
1004
|
+
let drained = true;
|
|
1005
|
+
try {
|
|
1006
|
+
res.write('event: load_chunk\ndata: [');
|
|
1007
|
+
drained = res.write(out.includes('\n') ? out.replace(/\n/g, '') : out);
|
|
1008
|
+
res.write(']\n\n');
|
|
1009
|
+
} catch {
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
// 写缓冲满则等 drain(或 close/error/超时任一 fulfill),防止浏览器侧 renderer OOM
|
|
1013
|
+
if (!drained) {
|
|
1014
|
+
await new Promise((resolve) => {
|
|
1015
|
+
const t = setTimeout(resolve, SSE_BACKPRESSURE_TIMEOUT_MS);
|
|
1016
|
+
const done = () => { clearTimeout(t); resolve(); };
|
|
1017
|
+
res.once('drain', done);
|
|
1018
|
+
res.once('close', done);
|
|
1019
|
+
res.once('error', done);
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
981
1022
|
}, {
|
|
982
1023
|
since: useIncremental ? sinceParam : undefined,
|
|
983
|
-
limit: useLimit ?
|
|
1024
|
+
limit: useLimit ? effectiveLimit : undefined,
|
|
984
1025
|
onScan: (raw) => {
|
|
985
1026
|
// 轻量追踪最新 MainAgent 的 KV-Cache 和 context_window(仅 regex 检测)
|
|
986
1027
|
if (raw.includes('"mainAgent":true') || raw.includes('"mainAgent": true')) {
|
|
@@ -1045,11 +1086,16 @@ async function handleRequest(req, res) {
|
|
|
1045
1086
|
// 这样 watcher 的 sendToClients 不会在 load 阶段向该客户端推送 live entry。
|
|
1046
1087
|
clients.push(res);
|
|
1047
1088
|
|
|
1048
|
-
req.on('close'
|
|
1089
|
+
// req.on('close') 在某些异常断连时不一定立即触发;res 端 close/error 兜底保证
|
|
1090
|
+
// 不会在 clients 数组里留下幽灵 res,防止 sendToClients 后续写入触发慢泄漏。
|
|
1091
|
+
const removeFromClients = () => {
|
|
1049
1092
|
clearInterval(pingTimer);
|
|
1050
1093
|
const idx = clients.indexOf(res);
|
|
1051
1094
|
if (idx !== -1) clients.splice(idx, 1);
|
|
1052
|
-
}
|
|
1095
|
+
};
|
|
1096
|
+
req.on('close', removeFromClients);
|
|
1097
|
+
res.on('close', removeFromClients);
|
|
1098
|
+
res.on('error', removeFromClients);
|
|
1053
1099
|
return;
|
|
1054
1100
|
}
|
|
1055
1101
|
|
|
@@ -1061,7 +1107,7 @@ async function handleRequest(req, res) {
|
|
|
1061
1107
|
let first = true;
|
|
1062
1108
|
await streamRawEntriesAsync(LOG_FILE, (raw) => {
|
|
1063
1109
|
if (!first) res.write(',');
|
|
1064
|
-
res.write(raw);
|
|
1110
|
+
res.write(enrichRawIfNeeded(raw));
|
|
1065
1111
|
first = false;
|
|
1066
1112
|
});
|
|
1067
1113
|
res.write(']');
|
|
@@ -1081,8 +1127,9 @@ async function handleRequest(req, res) {
|
|
|
1081
1127
|
try {
|
|
1082
1128
|
const result = readPagedEntries(LOG_FILE, { before, limit: limitVal });
|
|
1083
1129
|
// entries 是原始 JSON 字符串数组,parse 后返回给客户端
|
|
1130
|
+
// ExitPlanMode V2 空 input 的条目用 enrichRawIfNeeded 在 raw 阶段补全
|
|
1084
1131
|
const entries = result.entries.map(raw => {
|
|
1085
|
-
try { return JSON.parse(raw); } catch { return null; }
|
|
1132
|
+
try { return JSON.parse(enrichRawIfNeeded(raw)); } catch { return null; }
|
|
1086
1133
|
}).filter(Boolean);
|
|
1087
1134
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1088
1135
|
res.end(JSON.stringify({
|