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.
Files changed (119) hide show
  1. package/concepts/ar/CustomUltraplanExpert.md +165 -0
  2. package/concepts/ar/UltraPlan.md +101 -0
  3. package/concepts/da/CustomUltraplanExpert.md +165 -0
  4. package/concepts/da/UltraPlan.md +101 -0
  5. package/concepts/de/CustomUltraplanExpert.md +165 -0
  6. package/concepts/de/UltraPlan.md +101 -0
  7. package/concepts/en/CustomUltraplanExpert.md +4 -0
  8. package/concepts/en/UltraPlan.md +40 -22
  9. package/concepts/es/CustomUltraplanExpert.md +165 -0
  10. package/concepts/es/UltraPlan.md +101 -0
  11. package/concepts/fr/CustomUltraplanExpert.md +165 -0
  12. package/concepts/fr/UltraPlan.md +101 -0
  13. package/concepts/it/CustomUltraplanExpert.md +165 -0
  14. package/concepts/it/UltraPlan.md +101 -0
  15. package/concepts/ja/CustomUltraplanExpert.md +165 -0
  16. package/concepts/ja/UltraPlan.md +101 -0
  17. package/concepts/ko/CustomUltraplanExpert.md +165 -0
  18. package/concepts/ko/UltraPlan.md +101 -0
  19. package/concepts/no/CustomUltraplanExpert.md +165 -0
  20. package/concepts/no/UltraPlan.md +101 -0
  21. package/concepts/pl/CustomUltraplanExpert.md +165 -0
  22. package/concepts/pl/UltraPlan.md +101 -0
  23. package/concepts/pt-BR/CustomUltraplanExpert.md +165 -0
  24. package/concepts/pt-BR/UltraPlan.md +101 -0
  25. package/concepts/ru/CustomUltraplanExpert.md +165 -0
  26. package/concepts/ru/UltraPlan.md +101 -0
  27. package/concepts/th/CustomUltraplanExpert.md +165 -0
  28. package/concepts/th/UltraPlan.md +101 -0
  29. package/concepts/tr/CustomUltraplanExpert.md +165 -0
  30. package/concepts/tr/UltraPlan.md +101 -0
  31. package/concepts/uk/CustomUltraplanExpert.md +165 -0
  32. package/concepts/uk/UltraPlan.md +101 -0
  33. package/concepts/zh/CustomUltraplanExpert.md +4 -0
  34. package/concepts/zh/UltraPlan.md +40 -22
  35. package/concepts/zh-TW/CustomUltraplanExpert.md +162 -0
  36. package/concepts/zh-TW/UltraPlan.md +101 -0
  37. package/dist/assets/App-4jFW5l1v.js +1 -0
  38. package/dist/assets/MdxEditorPanel-BzxNt12G.js +1 -0
  39. package/dist/assets/{AppHeader-shbV-3zU.css → MemoryDetailModal-C8FQroxH.css} +2 -2
  40. package/dist/assets/MemoryDetailModal-DGdR16rY.js +2 -0
  41. package/dist/assets/Mobile-CDimWgfU.js +1 -0
  42. package/dist/assets/{_baseUniq-_yALeOHi.js → _baseUniq-7WyLOfOD.js} +1 -1
  43. package/dist/assets/{arc-B5pRnfqS.js → arc-BqtQ2TR5.js} +1 -1
  44. package/dist/assets/{architectureDiagram-Q4EWVU46-DthmH4OR.js → architectureDiagram-Q4EWVU46-DNsON_uI.js} +1 -1
  45. package/dist/assets/{blockDiagram-DXYQGD6D-DcTaGOKa.js → blockDiagram-DXYQGD6D-BAnmw6RY.js} +1 -1
  46. package/dist/assets/{c4Diagram-AHTNJAMY-BQKUCu8F.js → c4Diagram-AHTNJAMY-BVTMAIlF.js} +1 -1
  47. package/dist/assets/{channel-BWJQxTbE.js → channel-DpzUDUvG.js} +1 -1
  48. package/dist/assets/{chunk-4BX2VUAB-DYZKWvRi.js → chunk-4BX2VUAB-63nvqcse.js} +1 -1
  49. package/dist/assets/{chunk-4TB4RGXK-CS19SHdi.js → chunk-4TB4RGXK-DscRWqiR.js} +1 -1
  50. package/dist/assets/{chunk-55IACEB6-Rs-HDnQc.js → chunk-55IACEB6-DzsFGeoV.js} +1 -1
  51. package/dist/assets/{chunk-EDXVE4YY-hXx6TNZ7.js → chunk-EDXVE4YY-ZgTnVGTc.js} +1 -1
  52. package/dist/assets/{chunk-FMBD7UC4-Dg-nxK_6.js → chunk-FMBD7UC4-DIKVKEfO.js} +1 -1
  53. package/dist/assets/{chunk-OYMX7WX6-C6d9RMbp.js → chunk-OYMX7WX6-w_HztfnV.js} +1 -1
  54. package/dist/assets/{chunk-QZHKN3VN-BNOQ1kBe.js → chunk-QZHKN3VN-BCvptAWT.js} +1 -1
  55. package/dist/assets/{chunk-YZCP3GAM-D_ANME8r.js → chunk-YZCP3GAM-C-nVBIjq.js} +1 -1
  56. package/dist/assets/classDiagram-6PBFFD2Q-50wMH0oC.js +1 -0
  57. package/dist/assets/classDiagram-v2-HSJHXN6E-50wMH0oC.js +1 -0
  58. package/dist/assets/clone-Cr4FyA_-.js +1 -0
  59. package/dist/assets/{cose-bilkent-S5V4N54A-CTmbOA5L.js → cose-bilkent-S5V4N54A-r4X4a6GB.js} +1 -1
  60. package/dist/assets/{dagre-KV5264BT-LfeX0c6P.js → dagre-KV5264BT-BHPzoGQS.js} +1 -1
  61. package/dist/assets/{diagram-5BDNPKRD-C8bE96Vc.js → diagram-5BDNPKRD-Czb-PWGQ.js} +1 -1
  62. package/dist/assets/{diagram-G4DWMVQ6-CEpwug4a.js → diagram-G4DWMVQ6-DwjmplbB.js} +1 -1
  63. package/dist/assets/{diagram-MMDJMWI5-B4IUGPuz.js → diagram-MMDJMWI5-CX8iQI58.js} +1 -1
  64. package/dist/assets/{diagram-TYMM5635-BOT2RZZr.js → diagram-TYMM5635-BKLl1-2F.js} +1 -1
  65. package/dist/assets/{erDiagram-SMLLAGMA-CLF596PW.js → erDiagram-SMLLAGMA-Dr0QHMWl.js} +1 -1
  66. package/dist/assets/{flowDiagram-DWJPFMVM-MBM9yHXX.js → flowDiagram-DWJPFMVM-BUQN9yen.js} +1 -1
  67. package/dist/assets/{ganttDiagram-T4ZO3ILL-C0UBNZaj.js → ganttDiagram-T4ZO3ILL-lOHwk9xE.js} +1 -1
  68. package/dist/assets/{gitGraphDiagram-UUTBAWPF-CxHPXW7u.js → gitGraphDiagram-UUTBAWPF-DcNim81F.js} +1 -1
  69. package/dist/assets/{graph-olBhWx_l.js → graph-BkFt1bfK.js} +1 -1
  70. package/dist/assets/{index-BSqibSQz.js → index-B2Kc9sK3.js} +1 -1
  71. package/dist/assets/{index-CyPMm5q-.js → index-COwu7beD.js} +1 -1
  72. package/dist/assets/{index-DsEqauU9.js → index-CeM_RmDZ.js} +1 -1
  73. package/dist/assets/index-CqJkuXsS.js +2 -0
  74. package/dist/assets/{index-DJMCeDYQ.js → index-DCgVszdq.js} +1 -1
  75. package/dist/assets/{index-DrQ6M-Gu.js → index-DdYkqLM-.js} +1 -1
  76. package/dist/assets/{index-yzMYxfsJ.js → index-Df4X98RK.js} +1 -1
  77. package/dist/assets/{index-hcbJYwvG.js → index-DxgQkYpd.js} +1 -1
  78. package/dist/assets/{infoDiagram-42DDH7IO-CqV9mkMZ.js → infoDiagram-42DDH7IO-BYux_n61.js} +1 -1
  79. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Czpfcqi6.js → ishikawaDiagram-UXIWVN3A-COJ7k4kv.js} +1 -1
  80. package/dist/assets/{journeyDiagram-VCZTEJTY-CH10JTQU.js → journeyDiagram-VCZTEJTY-CNTmiD7P.js} +1 -1
  81. package/dist/assets/{kanban-definition-6JOO6SKY-C9Q4Fy8z.js → kanban-definition-6JOO6SKY-D6gAzHkR.js} +1 -1
  82. package/dist/assets/{layout-BI-B9yUD.js → layout-CLLpxzZt.js} +1 -1
  83. package/dist/assets/{linear-Dw3iBwM1.js → linear-De2FOjos.js} +1 -1
  84. package/dist/assets/{mermaid.core-B_VD4Hvj.js → mermaid.core-Cydvnbop.js} +2 -2
  85. package/dist/assets/{min-Du9adfPH.js → min-svWQ_itm.js} +1 -1
  86. package/dist/assets/{mindmap-definition-QFDTVHPH-CaMicHMI.js → mindmap-definition-QFDTVHPH-DB_mVE41.js} +1 -1
  87. package/dist/assets/{pieDiagram-DEJITSTG-DCJQvpAG.js → pieDiagram-DEJITSTG-BPsEiPgY.js} +1 -1
  88. package/dist/assets/{quadrantDiagram-34T5L4WZ-CAM_s8Xc.js → quadrantDiagram-34T5L4WZ-B0vwxgTT.js} +1 -1
  89. package/dist/assets/{requirementDiagram-MS252O5E-DF3NSseQ.js → requirementDiagram-MS252O5E-Dh5h8Iim.js} +1 -1
  90. package/dist/assets/{sankeyDiagram-XADWPNL6-BPdFQNTe.js → sankeyDiagram-XADWPNL6-DJd-MIyc.js} +1 -1
  91. package/dist/assets/{sequenceDiagram-FGHM5R23-Du8-vUmN.js → sequenceDiagram-FGHM5R23-DL8FmWmM.js} +1 -1
  92. package/dist/assets/{stateDiagram-FHFEXIEX-B9CfSrrU.js → stateDiagram-FHFEXIEX-BvM-YO9H.js} +1 -1
  93. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-De-L2Tw-.js → stateDiagram-v2-QKLJ7IA2-DrW9i5SO.js} +1 -1
  94. package/dist/assets/{timeline-definition-GMOUNBTQ-CX-WGfJI.js → timeline-definition-GMOUNBTQ-Cmgo8NKr.js} +1 -1
  95. package/dist/assets/{vendor-antd-DW2QvF0l.js → vendor-antd-COAwO2n0.js} +1 -1
  96. package/dist/assets/{vendor-codemirror-Bh_IP9SJ.js → vendor-codemirror-B_arK_ec.js} +1 -1
  97. package/dist/assets/{vendor-mdxeditor-DmzrSr0n.js → vendor-mdxeditor-lhz8HD2R.js} +2 -2
  98. package/dist/assets/{vendor-qrcode-CXOKgQeD.js → vendor-qrcode-CMjqs6Gh.js} +1 -1
  99. package/dist/assets/{vendor-virtuoso-CU5wFM1_.js → vendor-virtuoso-CR72uTTv.js} +1 -1
  100. package/dist/assets/{vennDiagram-DHZGUBPP-BDTC-m_V.js → vennDiagram-DHZGUBPP-fSI5n23s.js} +1 -1
  101. package/dist/assets/{wardley-RL74JXVD-c78hCql7.js → wardley-RL74JXVD-DYGzf-CJ.js} +1 -1
  102. package/dist/assets/{wardleyDiagram-NUSXRM2D-CMiF49Ci.js → wardleyDiagram-NUSXRM2D-BTrxJ754.js} +1 -1
  103. package/dist/assets/{xychartDiagram-5P7HB3ND-DrfnrMDg.js → xychartDiagram-5P7HB3ND-HzTx9Jn2.js} +1 -1
  104. package/dist/index.html +4 -4
  105. package/interceptor.js +21 -11
  106. package/lib/enrich-plan-input.js +109 -0
  107. package/lib/log-stream.js +1 -1
  108. package/lib/log-watcher.js +72 -23
  109. package/lib/session-transcript-reader.js +242 -0
  110. package/package.json +1 -1
  111. package/server.js +59 -12
  112. package/dist/assets/App-Do67d04Y.js +0 -1
  113. package/dist/assets/AppHeader.module-DAis3JIf.js +0 -2
  114. package/dist/assets/MdxEditorPanel-DTjCB4JK.js +0 -1
  115. package/dist/assets/Mobile-BtHBEqlu.js +0 -1
  116. package/dist/assets/classDiagram-6PBFFD2Q-D7FdAnCO.js +0 -1
  117. package/dist/assets/classDiagram-v2-HSJHXN6E-D7FdAnCO.js +0 -1
  118. package/dist/assets/clone-CkOXLPKP.js +0 -1
  119. 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));
@@ -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
- clients.forEach(client => {
50
- try {
51
- client.write(`data: ${JSON.stringify(entry)}\n\n`);
52
- } catch (err) {
53
- // Client disconnected
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
- clients.forEach(client => {
66
- try {
67
- client.write(`event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`);
68
- } catch (err) {}
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.forEach(client => {
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
- const data = JSON.stringify(segment);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.6.232",
3
+ "version": "1.6.234",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
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
- // 分页参数:移动端首次加载传 limit=200,与 since 互斥
967
- const limitParam = parseInt(parsedUrl.searchParams.get('limit'), 10) || 0;
968
- const useLimit = !useIncremental && limitParam > 0;
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('event: load_chunk\ndata: [');
979
- res.write(raw.includes('\n') ? raw.replace(/\n/g, '') : raw);
980
- res.write(']\n\n');
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 ? limitParam : undefined,
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({