cc-viewer 1.6.293 → 1.6.295

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 (110) hide show
  1. package/cli.js +7 -2
  2. package/dist/assets/App-Br-u2TKk.js +2 -0
  3. package/dist/assets/App-eFrjLzF_.css +1 -0
  4. package/dist/assets/{MdxEditorPanel-Cf01KF6Z.js → MdxEditorPanel-Cy4egsQx.js} +1 -1
  5. package/dist/assets/{Mobile-BJlGkvAP.js → Mobile-ZHF74GQs.js} +1 -1
  6. package/dist/assets/{_baseUniq-CPUnJ5bQ.js → _baseUniq-r3p3rodd.js} +1 -1
  7. package/dist/assets/{arc-WhuJ-oY5.js → arc-CjTV5gxc.js} +1 -1
  8. package/dist/assets/{architectureDiagram-Q4EWVU46-CWx77Yhd.js → architectureDiagram-Q4EWVU46-BqzjXpCq.js} +1 -1
  9. package/dist/assets/{blockDiagram-DXYQGD6D-D7AQLCoj.js → blockDiagram-DXYQGD6D-CLyFfeHh.js} +1 -1
  10. package/dist/assets/{c4Diagram-AHTNJAMY-BoPHNqCF.js → c4Diagram-AHTNJAMY-BaO-0tuc.js} +1 -1
  11. package/dist/assets/{channel-B9Ja6Xkc.js → channel-yOyhvOLV.js} +1 -1
  12. package/dist/assets/{chunk-4BX2VUAB-B-b0RYab.js → chunk-4BX2VUAB-CMTnvZkS.js} +1 -1
  13. package/dist/assets/{chunk-4TB4RGXK-BK_V34yf.js → chunk-4TB4RGXK-QI41m9WP.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-D-kMbu-2.js → chunk-55IACEB6-C4ZO8bM3.js} +1 -1
  15. package/dist/assets/{chunk-EDXVE4YY-CEtSkZzd.js → chunk-EDXVE4YY-Bo8P4o65.js} +1 -1
  16. package/dist/assets/{chunk-FMBD7UC4-BXa_7Pn3.js → chunk-FMBD7UC4-CTHLGcHh.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-tvM_OApS.js → chunk-OYMX7WX6-D0OHxKGd.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-DrEmcVHf.js → chunk-QZHKN3VN-CoYnjUpS.js} +1 -1
  19. package/dist/assets/{chunk-YZCP3GAM-D2M9T_R5.js → chunk-YZCP3GAM-BY71mTXM.js} +1 -1
  20. package/dist/assets/classDiagram-6PBFFD2Q-C9o5ip5q.js +1 -0
  21. package/dist/assets/classDiagram-v2-HSJHXN6E-C9o5ip5q.js +1 -0
  22. package/dist/assets/clone-GDqN3kwT.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-H7bkwu5F.js → cose-bilkent-S5V4N54A-DUNsA_MT.js} +1 -1
  24. package/dist/assets/{dagre-KV5264BT-DKXEGN18.js → dagre-KV5264BT-BzlT2Exr.js} +1 -1
  25. package/dist/assets/{diagram-5BDNPKRD-DZFhwpI3.js → diagram-5BDNPKRD-CiqQK3Ci.js} +1 -1
  26. package/dist/assets/{diagram-G4DWMVQ6-Crg9GlIk.js → diagram-G4DWMVQ6-BciK18tQ.js} +1 -1
  27. package/dist/assets/{diagram-MMDJMWI5-B8Qn1fKP.js → diagram-MMDJMWI5-C1WH1vfU.js} +1 -1
  28. package/dist/assets/{diagram-TYMM5635-BHE1LjtY.js → diagram-TYMM5635-CR5RzJ6u.js} +1 -1
  29. package/dist/assets/{erDiagram-SMLLAGMA-BaEqFWLd.js → erDiagram-SMLLAGMA-NJQKXu51.js} +1 -1
  30. package/dist/assets/{flowDiagram-DWJPFMVM-b2ukTawV.js → flowDiagram-DWJPFMVM-Cjx5t_1H.js} +1 -1
  31. package/dist/assets/{ganttDiagram-T4ZO3ILL-D5quyFgK.js → ganttDiagram-T4ZO3ILL-YFTDBBiU.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-UUTBAWPF-BE1H5_fN.js → gitGraphDiagram-UUTBAWPF-C2muKahz.js} +1 -1
  33. package/dist/assets/{graph-D_JLoOax.js → graph-I1olozIg.js} +1 -1
  34. package/dist/assets/{index-Cx8bk0Tp.js → index-7vxIrUNA.js} +1 -1
  35. package/dist/assets/{index-BDUs32pN.css → index-Be9T-kDq.css} +1 -1
  36. package/dist/assets/{index-CtrY6gFZ.js → index-C1RNAzAB.js} +1 -1
  37. package/dist/assets/{index-CQrdpZQb.js → index-Cf4FBg-V.js} +1 -1
  38. package/dist/assets/{index-B8UmlA4F.js → index-D-HPuqxB.js} +1 -1
  39. package/dist/assets/{index-k0AH8cvI.js → index-D2QUxu18.js} +1 -1
  40. package/dist/assets/index-DMuCrfTo.js +2 -0
  41. package/dist/assets/{index-DiZ9CErG.js → index-DhzoJ5wE.js} +1 -1
  42. package/dist/assets/{index-CWjqMDrs.js → index-fhI0i2p3.js} +1 -1
  43. package/dist/assets/{infoDiagram-42DDH7IO-DQKlrVkw.js → infoDiagram-42DDH7IO-C9bza97c.js} +1 -1
  44. package/dist/assets/{ishikawaDiagram-UXIWVN3A-BchFlpPc.js → ishikawaDiagram-UXIWVN3A-BtZGipfW.js} +1 -1
  45. package/dist/assets/{journeyDiagram-VCZTEJTY-Dg1mt4df.js → journeyDiagram-VCZTEJTY-CKTp590c.js} +1 -1
  46. package/dist/assets/{jszip.min-LIb2SFoK.js → jszip.min-DDU-_oA-.js} +1 -1
  47. package/dist/assets/{kanban-definition-6JOO6SKY-226va2PS.js → kanban-definition-6JOO6SKY-BHLNWfr5.js} +1 -1
  48. package/dist/assets/{layout-rSa8rcPi.js → layout-DBmqcl9N.js} +1 -1
  49. package/dist/assets/{linear-BeARi8nH.js → linear-Br9n7mCI.js} +1 -1
  50. package/dist/assets/{mermaid.core-CDgdx9l7.js → mermaid.core-BV3ugHFm.js} +2 -2
  51. package/dist/assets/{min-B9yebCuj.js → min-D-YA3MGY.js} +1 -1
  52. package/dist/assets/{mindmap-definition-QFDTVHPH-C3apVbdg.js → mindmap-definition-QFDTVHPH-CzrYj3cB.js} +1 -1
  53. package/dist/assets/{pieDiagram-DEJITSTG-xjOQoQeL.js → pieDiagram-DEJITSTG-BAvtfiT3.js} +1 -1
  54. package/dist/assets/{quadrantDiagram-34T5L4WZ-Dq8x_VN2.js → quadrantDiagram-34T5L4WZ-i4zhnBJq.js} +1 -1
  55. package/dist/assets/{requirementDiagram-MS252O5E-CLmO1Gai.js → requirementDiagram-MS252O5E-Cb2wX9Sk.js} +1 -1
  56. package/dist/assets/{sankeyDiagram-XADWPNL6-BuUP1Eqq.js → sankeyDiagram-XADWPNL6-CcpbP6z5.js} +1 -1
  57. package/dist/assets/seqResourceLoaders-C7X23dCJ.js +2 -0
  58. package/dist/assets/{seqResourceLoaders-DWKAvGtj.css → seqResourceLoaders-De_-fYhE.css} +2 -2
  59. package/dist/assets/{sequenceDiagram-FGHM5R23-B18koU20.js → sequenceDiagram-FGHM5R23-BcbUxMmI.js} +1 -1
  60. package/dist/assets/{stateDiagram-FHFEXIEX-Cj57OCcO.js → stateDiagram-FHFEXIEX-CpIa1qoO.js} +1 -1
  61. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-C01a2p--.js → stateDiagram-v2-QKLJ7IA2-d3GoyW9S.js} +1 -1
  62. package/dist/assets/{timeline-definition-GMOUNBTQ-cOlsEN_F.js → timeline-definition-GMOUNBTQ-BfQPSOuT.js} +1 -1
  63. package/dist/assets/{vendor-antd-DqFS7Zj9.js → vendor-antd-Bur5ZxWE.js} +1 -1
  64. package/dist/assets/{vendor-codemirror-B_pF4DrA.js → vendor-codemirror-Si44UqBp.js} +1 -1
  65. package/dist/assets/{vendor-mdxeditor-B_IrHcWH.js → vendor-mdxeditor-Cco3AQJS.js} +2 -2
  66. package/dist/assets/{vendor-qrcode-C4PneAS5.js → vendor-qrcode-Dn3GYC4l.js} +1 -1
  67. package/dist/assets/{vendor-virtuoso-CEGeJyDP.js → vendor-virtuoso-CW9EqKMt.js} +1 -1
  68. package/dist/assets/{vennDiagram-DHZGUBPP-BCjdwiDk.js → vennDiagram-DHZGUBPP-hTgiYDQL.js} +1 -1
  69. package/dist/assets/{wardley-RL74JXVD-CRmLlBwn.js → wardley-RL74JXVD-ByDpAPp1.js} +1 -1
  70. package/dist/assets/{wardleyDiagram-NUSXRM2D-BJYVDJ4F.js → wardleyDiagram-NUSXRM2D-D7LJTuWq.js} +1 -1
  71. package/dist/assets/{xychartDiagram-5P7HB3ND-el5C4S1Z.js → xychartDiagram-5P7HB3ND-MW_KOomO.js} +1 -1
  72. package/dist/index.html +5 -5
  73. package/findcc.js +3 -3
  74. package/package.json +1 -1
  75. package/server/i18n.js +224 -8
  76. package/server/interceptor.js +21 -18
  77. package/server/lib/adapters/dingtalk-adapter.js +69 -0
  78. package/server/lib/adapters/discord-adapter.js +44 -1
  79. package/server/lib/adapters/feishu-adapter.js +56 -0
  80. package/server/lib/adapters/wecom-adapter.js +4 -0
  81. package/server/lib/ask-store.js +19 -90
  82. package/server/lib/async-file-lock.js +123 -0
  83. package/server/lib/async-write-queue.js +131 -0
  84. package/server/lib/git-diff.js +4 -1
  85. package/server/lib/im-bridge-core.js +178 -21
  86. package/server/lib/im-claude-md.js +37 -1
  87. package/server/lib/im-config.js +11 -6
  88. package/server/lib/im-process-manager.js +1 -1
  89. package/server/lib/im-senders.js +73 -0
  90. package/server/lib/jsonl-archive.js +0 -1
  91. package/server/lib/log-watcher.js +224 -177
  92. package/server/lib/plugin-manager.js +1 -1
  93. package/server/lib/updater.js +4 -2
  94. package/server/pty-manager.js +1 -1
  95. package/server/routes/ask-perm.js +2 -2
  96. package/server/routes/dingtalk.js +2 -0
  97. package/server/routes/files-fs.js +4 -4
  98. package/server/routes/im.js +117 -3
  99. package/server/routes/project-meta.js +18 -1
  100. package/server/routes/skills.js +180 -165
  101. package/server/routes/workspaces.js +7 -10
  102. package/server/server.js +23 -20
  103. package/server/workspace-registry.js +9 -53
  104. package/dist/assets/App-DRvRd96X.css +0 -1
  105. package/dist/assets/App-OM2oqZRW.js +0 -1
  106. package/dist/assets/classDiagram-6PBFFD2Q-CCwGJXEA.js +0 -1
  107. package/dist/assets/classDiagram-v2-HSJHXN6E-CCwGJXEA.js +0 -1
  108. package/dist/assets/clone-BuQbTPQO.js +0 -1
  109. package/dist/assets/index-CnWSVlWW.js +0 -2
  110. package/dist/assets/seqResourceLoaders-BZ6M3Jb-.js +0 -2
@@ -1,4 +1,5 @@
1
- import { readFileSync, existsSync, watchFile, unwatchFile, openSync, readSync, closeSync, statSync } from 'node:fs';
1
+ import { readFileSync, existsSync, watch, watchFile, unwatchFile, openSync, readSync, closeSync, statSync } from 'node:fs';
2
+ import { dirname, basename } from 'node:path';
2
3
  import { isMainAgentEntry, extractCachedContent } from './kv-cache-analyzer.js';
3
4
  import { buildContextWindowEvent, getContextSizeForModel } from './context-watcher.js';
4
5
  import { reconstructEntries, createIncrementalReconstructor } from './delta-reconstructor.js';
@@ -6,17 +7,26 @@ import { countLogEntries, streamReconstructedEntries } from './log-stream.js';
6
7
  import { enrichEntry } from './enrich-plan-input.js';
7
8
  import { resolveJsonlPath } from './jsonl-archive.js';
8
9
 
9
- // 跟踪所有被 watch 的日志文件
10
+ // 跟踪所有被 watch 的日志文件。value: fileState 对象(外部只用 .has()/.keys())
10
11
  const watchedFiles = new Map();
11
12
 
13
+ // 目录级 fs.watch 实例注册表(事件驱动,替代 per-file 轮询)
14
+ const _dirWatchers = new Map();
15
+
16
+ // Windows 单次 appendFileSync 触发 2+ 事件,防抖合并
17
+ const FSWATCH_DEBOUNCE_MS = 80;
18
+
19
+ // 安全网慢轮询:fs.watch 可能漏事件(buffer overflow 等),冷 fallback 兜底
20
+ const SAFETY_POLL_MS = 5000;
21
+
22
+ const FORCE_POLL = process.env.CCV_FORCE_POLL === '1';
23
+
12
24
  /**
13
25
  * Read and parse a JSONL log file.
14
26
  * @param {string} logFile - absolute path to the log file
15
27
  * @returns {Array} parsed and deduplicated entries
16
28
  */
17
29
  export function readLogFile(logFile) {
18
- // 透明支持归档后的 .jsonl.zip。active log(被 watchLogFile 追加写入的文件)不会被归档
19
- // (archive 拒绝最新文件),watchLogFile 内部的 openSync/readSync 因此无需走 resolveJsonlPath。
20
30
  logFile = resolveJsonlPath(logFile);
21
31
  if (!existsSync(logFile)) {
22
32
  return [];
@@ -24,8 +34,6 @@ export function readLogFile(logFile) {
24
34
 
25
35
  try {
26
36
  const content = readFileSync(logFile, 'utf-8');
27
- // Windows 上若 writer 使用 os.EOL,分隔符会变 \r\n---\r\n。固定 LF 切会失败 → 整文件
28
- // 解析成一条乱码或漏。CRLF-tolerant split 把两边都 cover 住。
29
37
  const entries = content.split(/\r?\n---\r?\n/).filter(line => line.trim());
30
38
  const parsed = entries.map(entry => {
31
39
  try {
@@ -34,7 +42,6 @@ export function readLogFile(logFile) {
34
42
  return null;
35
43
  }
36
44
  }).filter(Boolean);
37
- // 去重:同一 timestamp+url 的条目,后出现的(带 response)覆盖先出现的(在途)
38
45
  const map = new Map();
39
46
  for (const entry of parsed) {
40
47
  const key = `${entry.timestamp}|${entry.url}`;
@@ -47,8 +54,6 @@ export function readLogFile(logFile) {
47
54
  }
48
55
  }
49
56
 
50
- // SSE 单客户端 backpressure 容忍上限:连续未排空 > 此时长则视为 dead 客户端剔除。
51
- // 与 server.js 同名常量值保持一致(避免循环依赖,此处单独 mirror)。
52
57
  const SSE_BACKPRESSURE_TIMEOUT_MS = 5000;
53
58
 
54
59
  function _removeClient(clients, client) {
@@ -56,14 +61,7 @@ function _removeClient(clients, client) {
56
61
  if (idx !== -1) clients.splice(idx, 1);
57
62
  }
58
63
 
59
- /**
60
- * 向单个 SSE client 安全写入 payload。
61
- * - 写错或 client.destroyed/!writable:立即从 clients 数组移除
62
- * - write 返回 false(写缓冲满):标记时间戳,超过 SSE_BACKPRESSURE_TIMEOUT_MS 仍未排空则剔除并 end()
63
- * - drain 后重置 _sseBackpressureSince=0,下次 backpressure 重新计时
64
- */
65
64
  function _safeSseWrite(clients, client, payload) {
66
- // 仅在显式标记 destroyed/writable=false 时剔除;undefined(如老 mock)按"活"处理。
67
65
  if (client.destroyed === true || client.writable === false) {
68
66
  _removeClient(clients, client);
69
67
  return false;
@@ -88,25 +86,13 @@ function _safeSseWrite(clients, client, payload) {
88
86
  return true;
89
87
  }
90
88
 
91
- /**
92
- * Send an SSE entry to all connected clients.
93
- * @param {Array} clients - SSE client array
94
- * @param {object} entry - parsed log entry
95
- */
96
89
  export function sendToClients(clients, entry) {
97
90
  const payload = `data: ${JSON.stringify(entry)}\n\n`;
98
- // 倒序遍历允许循环内安全 splice
99
91
  for (let i = clients.length - 1; i >= 0; i--) {
100
92
  _safeSseWrite(clients, clients[i], payload);
101
93
  }
102
94
  }
103
95
 
104
- /**
105
- * Send a named SSE event to all connected clients.
106
- * @param {Array} clients - SSE client array
107
- * @param {string} eventName - SSE event name
108
- * @param {object} data - event payload
109
- */
110
96
  export function sendEventToClients(clients, eventName, data) {
111
97
  const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
112
98
  for (let i = clients.length - 1; i >= 0; i--) {
@@ -114,11 +100,6 @@ export function sendEventToClients(clients, eventName, data) {
114
100
  }
115
101
  }
116
102
 
117
- /**
118
- * 旋转处理器专用:发送已序列化的 load_chunk segment 数据。
119
- * @param {Array} clients - SSE client array
120
- * @param {string} dataJson - segment 已被调用方 JSON.stringify
121
- */
122
103
  export function sendChunkToClients(clients, dataJson) {
123
104
  const payload = `event: load_chunk\ndata: ${dataJson}\n\n`;
124
105
  for (let i = clients.length - 1; i >= 0; i--) {
@@ -126,24 +107,180 @@ export function sendChunkToClients(clients, dataJson) {
126
107
  }
127
108
  }
128
109
 
129
- /**
130
- * Watch a log file for changes and broadcast new entries.
131
- * @param {object} opts
132
- * @param {string} opts.logFile - log file to watch
133
- * @param {Array} opts.clients - SSE clients array
134
- * @param {Function} opts.getClaudePid - returns Claude process PID
135
- * @param {Function} opts.runParallelHook - plugin hook runner
136
- * @param {Function} opts.notifyStatsWorker - stats worker notifier
137
- * @param {Function} opts.getLogFile - returns current LOG_FILE value
138
- */
110
+ // --- 轮转切换(抽取公共逻辑) ---
111
+
112
+ function _switchToRotatedFile(logFile, currentLogFile, clients, opts) {
113
+ _unwatchSingleFile(logFile);
114
+ const total = countLogEntries(currentLogFile);
115
+ sendEventToClients(clients, 'load_start', { total, incremental: false });
116
+ streamReconstructedEntries(currentLogFile, (segment) => {
117
+ sendChunkToClients(clients, JSON.stringify(segment));
118
+ });
119
+ sendEventToClients(clients, 'load_end', {});
120
+ watchLogFile({ ...opts, logFile: currentLogFile });
121
+ }
122
+
123
+ // --- 增量读 + 解析 + 广播(独立于触发机制) ---
124
+
125
+ function _readDelta(state) {
126
+ const { logFile, opts, reconstructor } = state;
127
+ const { clients, getClaudePid, runParallelHook, notifyStatsWorker, getLogFile } = opts;
128
+ try {
129
+ const currentSize = statSync(logFile).size;
130
+
131
+ if (currentSize < state.lastByteOffset) {
132
+ state.lastByteOffset = 0;
133
+ state.pendingTail = '';
134
+ reconstructor.reset();
135
+
136
+ const currentLogFile = getLogFile();
137
+ if (currentLogFile !== logFile && !watchedFiles.has(currentLogFile)) {
138
+ _switchToRotatedFile(logFile, currentLogFile, clients, opts);
139
+ return;
140
+ }
141
+ }
142
+
143
+ if (currentSize <= state.lastByteOffset) return;
144
+
145
+ const bytesToRead = currentSize - state.lastByteOffset;
146
+ const buf = Buffer.alloc(bytesToRead);
147
+ const fd = openSync(logFile, 'r');
148
+ try {
149
+ readSync(fd, buf, 0, bytesToRead, state.lastByteOffset);
150
+ } finally {
151
+ closeSync(fd);
152
+ }
153
+ state.lastByteOffset = currentSize;
154
+
155
+ const raw = state.pendingTail + buf.toString('utf-8');
156
+ const parts = raw.split('\n---\n');
157
+ state.pendingTail = parts.pop() || '';
158
+
159
+ if (parts.length === 0 && state.pendingTail.trim()) {
160
+ try {
161
+ JSON.parse(state.pendingTail);
162
+ parts.push(state.pendingTail);
163
+ state.pendingTail = '';
164
+ } catch {}
165
+ }
166
+
167
+ const validParts = parts.filter(p => p.trim());
168
+ if (validParts.length > 0) {
169
+ validParts.forEach(entry => {
170
+ try {
171
+ const parsed = JSON.parse(entry);
172
+ if (!parsed.pid) parsed.pid = getClaudePid();
173
+ reconstructor.reconstruct(parsed);
174
+ try { enrichEntry(parsed); } catch {}
175
+ sendToClients(clients, parsed);
176
+ runParallelHook('onNewEntry', parsed).catch(() => {});
177
+ if (isMainAgentEntry(parsed) && !parsed.inProgress) {
178
+ const cached = extractCachedContent(parsed);
179
+ if (cached) sendEventToClients(clients, 'kv_cache_content', cached);
180
+ const usage = parsed.response?.body?.usage;
181
+ if (usage) {
182
+ const contextSize = getContextSizeForModel(parsed.body?.model);
183
+ const cwData = buildContextWindowEvent(usage, contextSize);
184
+ if (cwData) sendEventToClients(clients, 'context_window', cwData);
185
+ }
186
+ }
187
+ } catch {}
188
+ });
189
+ notifyStatsWorker(logFile);
190
+ }
191
+
192
+ const currentLogFile = getLogFile();
193
+ if (currentLogFile !== logFile && !watchedFiles.has(currentLogFile)) {
194
+ _switchToRotatedFile(logFile, currentLogFile, clients, opts);
195
+ }
196
+ } catch {
197
+ // File not yet created or transient read error
198
+ }
199
+ }
200
+
201
+ // --- 目录级 fs.watch 管理 ---
202
+
203
+ function _getOrCreateDirWatcher(dir) {
204
+ if (_dirWatchers.has(dir)) return _dirWatchers.get(dir);
205
+
206
+ try {
207
+ const files = new Map();
208
+ const watcher = watch(dir, (eventType, filename) => {
209
+ if (!filename) {
210
+ for (const [, fileState] of files) {
211
+ _scheduleDebouncedRead(fileState);
212
+ }
213
+ return;
214
+ }
215
+ const fileState = files.get(filename);
216
+ if (fileState) _scheduleDebouncedRead(fileState);
217
+ });
218
+
219
+ watcher.on('error', () => {
220
+ for (const [, fileState] of files) {
221
+ _fallbackToPolling(fileState);
222
+ }
223
+ try { watcher.close(); } catch {}
224
+ _dirWatchers.delete(dir);
225
+ });
226
+
227
+ const entry = { watcher, files };
228
+ _dirWatchers.set(dir, entry);
229
+ return entry;
230
+ } catch {
231
+ return null;
232
+ }
233
+ }
234
+
235
+ function _scheduleDebouncedRead(fileState) {
236
+ if (fileState.debounceTimer) return;
237
+ fileState.debounceTimer = setTimeout(() => {
238
+ fileState.debounceTimer = null;
239
+ _readDelta(fileState);
240
+ }, FSWATCH_DEBOUNCE_MS);
241
+ }
242
+
243
+ function _fallbackToPolling(fileState) {
244
+ if (fileState.polling) return;
245
+ fileState.polling = true;
246
+ watchFile(fileState.logFile, { interval: 500 }, () => {
247
+ _readDelta(fileState);
248
+ });
249
+ }
250
+
251
+ function _unwatchSingleFile(logFile) {
252
+ const fileState = watchedFiles.get(logFile);
253
+ watchedFiles.delete(logFile);
254
+
255
+ if (!fileState) return;
256
+
257
+ if (fileState.debounceTimer) clearTimeout(fileState.debounceTimer);
258
+ if (fileState.safetyTimer) clearInterval(fileState.safetyTimer);
259
+
260
+ if (fileState.polling) {
261
+ try { unwatchFile(logFile); } catch {}
262
+ return;
263
+ }
264
+
265
+ const dir = dirname(logFile);
266
+ const filename = basename(logFile);
267
+ const dirEntry = _dirWatchers.get(dir);
268
+ if (dirEntry) {
269
+ dirEntry.files.delete(filename);
270
+ if (dirEntry.files.size === 0) {
271
+ try { dirEntry.watcher.close(); } catch {}
272
+ _dirWatchers.delete(dir);
273
+ }
274
+ }
275
+ }
276
+
277
+ // --- 公开 API ---
278
+
139
279
  export function watchLogFile(opts) {
140
- const { logFile, clients, getClaudePid, runParallelHook, notifyStatsWorker, getLogFile } = opts;
280
+ const { logFile } = opts;
141
281
  if (watchedFiles.has(logFile)) return;
142
282
 
143
- // Track byte offset instead of string length — avoids full-file re-read on every poll
144
283
  let lastByteOffset = 0;
145
- let pendingTail = ''; // incomplete entry carried across polls
146
- // Delta storage: 增量重建器,用于逐条重建 mainAgent delta 条目
147
284
  const _reconstructor = createIncrementalReconstructor();
148
285
  try {
149
286
  if (existsSync(logFile)) {
@@ -151,150 +288,60 @@ export function watchLogFile(opts) {
151
288
  }
152
289
  } catch {}
153
290
 
154
- watchedFiles.set(logFile, true);
155
- watchFile(logFile, { interval: 500 }, () => {
156
- try {
157
- const currentSize = statSync(logFile).size;
158
-
159
- // File truncated (rotation or clear) — reset offset and check rotation immediately
160
- if (currentSize < lastByteOffset) {
161
- lastByteOffset = 0;
162
- pendingTail = '';
163
- _reconstructor.reset();
164
-
165
- // 文件被清空可能是轮转信号,立即检查是否已切换到新文件
166
- const currentLogFile = getLogFile();
167
- if (currentLogFile !== logFile && !watchedFiles.has(currentLogFile)) {
168
- unwatchFile(logFile);
169
- watchedFiles.delete(logFile);
170
-
171
- // 流式分段广播,避免全量加载 OOM;走 _safeSseWrite 包装做 backpressure / dead-client 清理
172
- const rotTotal = countLogEntries(currentLogFile);
173
- sendEventToClients(clients, 'load_start', { total: rotTotal, incremental: false });
174
- streamReconstructedEntries(currentLogFile, (segment) => {
175
- sendChunkToClients(clients, JSON.stringify(segment));
176
- });
177
- sendEventToClients(clients, 'load_end', {});
178
- watchLogFile({ ...opts, logFile: currentLogFile });
179
- return;
180
- }
181
- }
291
+ const fileState = {
292
+ logFile,
293
+ opts,
294
+ reconstructor: _reconstructor,
295
+ lastByteOffset,
296
+ pendingTail: '',
297
+ debounceTimer: null,
298
+ safetyTimer: null,
299
+ polling: false,
300
+ };
182
301
 
183
- if (currentSize <= lastByteOffset) return;
302
+ watchedFiles.set(logFile, fileState);
184
303
 
185
- // Read only the new bytes
186
- const bytesToRead = currentSize - lastByteOffset;
187
- const buf = Buffer.alloc(bytesToRead);
188
- const fd = openSync(logFile, 'r');
189
- try {
190
- readSync(fd, buf, 0, bytesToRead, lastByteOffset);
191
- } finally {
192
- closeSync(fd);
193
- }
194
- lastByteOffset = currentSize;
304
+ if (FORCE_POLL) {
305
+ _fallbackToPolling(fileState);
306
+ return;
307
+ }
195
308
 
196
- const raw = pendingTail + buf.toString('utf-8');
197
- const parts = raw.split('\n---\n');
309
+ const dir = dirname(logFile);
310
+ const filename = basename(logFile);
311
+ const dirEntry = _getOrCreateDirWatcher(dir);
198
312
 
199
- // Last part may be incomplete — keep it for next poll
200
- pendingTail = parts.pop() || '';
313
+ if (!dirEntry) {
314
+ _fallbackToPolling(fileState);
315
+ return;
316
+ }
201
317
 
202
- // If there's only the tail and no complete entries, check if tail is a complete entry
203
- // (happens when the file ends without a trailing \n---\n)
204
- if (parts.length === 0 && pendingTail.trim()) {
205
- try {
206
- JSON.parse(pendingTail);
207
- // Valid JSON — treat as complete entry
208
- parts.push(pendingTail);
209
- pendingTail = '';
210
- } catch {
211
- // Incomplete — keep in pendingTail for next poll
212
- }
213
- }
318
+ dirEntry.files.set(filename, fileState);
214
319
 
215
- const validParts = parts.filter(p => p.trim());
216
- if (validParts.length > 0) {
217
- validParts.forEach(entry => {
218
- try {
219
- const parsed = JSON.parse(entry);
220
- if (!parsed.pid) {
221
- parsed.pid = getClaudePid();
222
- }
223
- // Delta storage: reconstruct before push — 确保前端收到完整 messages
224
- _reconstructor.reconstruct(parsed);
225
- // ExitPlanMode V2 input 服务端补全(详见 enrich-plan-input.js#enrichEntry JSDoc)。
226
- // 同步实现:候选扫描天然廉价(无 ExitPlanMode 块直接 0ms 返回);命中
227
- // 路径由 transcript 64MB 上限 + miss 30s TTL + path mtime 校验三层兜
228
- // 住,最坏 ~150ms。如未来 hit 比例显著上升再考虑 setImmediate 拆分。
229
- try { enrichEntry(parsed); } catch { /* 静默回退 */ }
230
- sendToClients(clients, parsed);
231
- runParallelHook('onNewEntry', parsed).catch(() => {});
232
- if (isMainAgentEntry(parsed) && !parsed.inProgress) {
233
- const cached = extractCachedContent(parsed);
234
- if (cached) {
235
- sendEventToClients(clients, 'kv_cache_content', cached);
236
- }
237
- const usage = parsed.response?.body?.usage;
238
- if (usage) {
239
- const contextSize = getContextSizeForModel(parsed.body?.model);
240
- const cwData = buildContextWindowEvent(usage, contextSize);
241
- if (cwData) {
242
- sendEventToClients(clients, 'context_window', cwData);
243
- }
244
- }
245
- }
246
- } catch (err) {
247
- // Skip invalid entries
248
- }
249
- });
250
- notifyStatsWorker(logFile);
251
- }
320
+ fileState.safetyTimer = setInterval(() => {
321
+ _readDelta(fileState);
322
+ }, SAFETY_POLL_MS);
323
+ }
252
324
 
253
- // 检测日志文件是否已轮转到新文件
254
- const currentLogFile = getLogFile();
255
- if (currentLogFile !== logFile && !watchedFiles.has(currentLogFile)) {
256
- // Unwatch old file to prevent watcher leak on rotation
257
- unwatchFile(logFile);
258
- watchedFiles.delete(logFile);
259
-
260
- // 流式分段广播,避免全量加载 OOM
261
- const endRotTotal = countLogEntries(currentLogFile);
262
- clients.forEach(client => {
263
- try { client.write(`event: load_start\ndata: ${JSON.stringify({ total: endRotTotal, incremental: false })}\n\n`); } catch { }
264
- });
265
- streamReconstructedEntries(currentLogFile, (segment) => {
266
- const data = JSON.stringify(segment);
267
- clients.forEach(client => {
268
- try { client.write(`event: load_chunk\ndata: ${data}\n\n`); } catch { }
269
- });
270
- });
271
- clients.forEach(client => {
272
- try { client.write(`event: load_end\ndata: {}\n\n`); } catch { }
273
- });
274
- watchLogFile({ ...opts, logFile: currentLogFile });
275
- }
276
- } catch (err) {
277
- // File not yet created, will retry on next poll
278
- }
279
- });
325
+ export function unwatchLogFile(logFile) {
326
+ _unwatchSingleFile(logFile);
327
+ }
328
+
329
+ export function unwatchAll() {
330
+ for (const logFile of watchedFiles.keys()) {
331
+ _unwatchSingleFile(logFile);
332
+ }
333
+ for (const [, entry] of _dirWatchers) {
334
+ try { entry.watcher.close(); } catch {}
335
+ }
336
+ _dirWatchers.clear();
337
+ watchedFiles.clear();
280
338
  }
281
339
 
282
- /**
283
- * Start watching the current log file + install statusLine + context window.
284
- * @param {object} opts
285
- * @param {string} opts.logFile - current LOG_FILE
286
- * @param {Array} opts.clients - SSE clients array
287
- * @param {Function} opts.getClaudePid
288
- * @param {Function} opts.runParallelHook
289
- * @param {Function} opts.notifyStatsWorker
290
- * @param {Function} opts.getLogFile
291
- */
292
340
  export function startWatching(opts) {
293
341
  const { clients, ...watchOpts } = opts;
294
342
  watchLogFile({ ...watchOpts, clients });
295
343
  }
296
344
 
297
- /** Get the watchedFiles Map (for cleanup in stopViewer). */
298
345
  export function getWatchedFiles() {
299
346
  return watchedFiles;
300
347
  }
@@ -75,7 +75,7 @@ export async function installPluginFromUrl(pluginsDir, fileUrl, extractNameScrip
75
75
  writeFileSync(tmpFile, content, 'utf-8');
76
76
  try {
77
77
  const result = await new Promise((resolve, reject) => {
78
- execFile('node', [extractNameScript, tmpFile], { timeout: 5000 }, (err, stdout) => {
78
+ execFile('node', [extractNameScript, tmpFile], { timeout: 5000, windowsHide: true }, (err, stdout) => {
79
79
  if (err) return reject(err);
80
80
  resolve(stdout);
81
81
  });
@@ -107,7 +107,7 @@ export function isAnyCcvBusy({ currentPid, busy, portRange, lsofImpl } = {}) {
107
107
 
108
108
  const [start, end] = Array.isArray(portRange) && portRange.length === 2 ? portRange : [7008, 7099];
109
109
  const pid = typeof currentPid === 'number' ? currentPid : process.pid;
110
- const runLsof = lsofImpl || ((cmd) => execSync(cmd, { timeout: 2000, encoding: 'utf-8' }));
110
+ const runLsof = lsofImpl || ((cmd) => execSync(cmd, { timeout: 2000, encoding: 'utf-8', windowsHide: true }));
111
111
 
112
112
  try {
113
113
  const out = String(runLsof(`lsof -iTCP:${start}-${end} -sTCP:LISTEN -P -n -Fp`));
@@ -216,7 +216,9 @@ export async function checkAndUpdate(options = {}) {
216
216
  const child = spawnImpl(
217
217
  'npm',
218
218
  ['install', '-g', `cc-viewer@${remoteVersion}`, '--no-audit', '--no-fund'],
219
- { detached: true, stdio: 'ignore', shell: process.platform === 'win32' }
219
+ // windowsHide:Windows shell 模式经 cmd.exe npm.cmd(console-subsystem),
220
+ // 不隐藏会在后台更新期间常驻一个可见控制台窗口;POSIX 上为 no-op。
221
+ { detached: true, stdio: 'ignore', shell: process.platform === 'win32', windowsHide: true }
220
222
  );
221
223
  if (child && typeof child.unref === 'function') child.unref();
222
224
  } catch (err) {
@@ -178,7 +178,7 @@ export async function spawnClaude(proxyPort, cwd, extraArgs = [], claudePath = n
178
178
  if (process.versions.electron) {
179
179
  const { execSync } = await import('node:child_process');
180
180
  try {
181
- nodePath = execSync(process.platform === 'win32' ? 'where node' : 'which node', { encoding: 'utf-8' }).trim();
181
+ nodePath = execSync(process.platform === 'win32' ? 'where node' : 'which node', { encoding: 'utf-8', windowsHide: true }).trim();
182
182
  if (process.platform === 'win32') nodePath = nodePath.split('\n')[0].trim();
183
183
  } catch {
184
184
  nodePath = process.platform === 'win32' ? 'node' : '/usr/local/bin/node';
@@ -223,7 +223,7 @@ function askHook(req, res, parsedUrl, isLocal, deps) {
223
223
  // Phase 3: short-poll handoff endpoint. ask-bridge GET /api/ask-hook/:id/result?wait=30000
224
224
  // 在 wait ms 内若答案/cancel 到达 → 立即返;否则返 204 让 client 重发。
225
225
  // 内存有 entry → 注册 listener;内存无 → 查 disk consume(server 重启场景)。
226
- function askHookResult(req, res, parsedUrl, isLocal, deps) {
226
+ async function askHookResult(req, res, parsedUrl, isLocal, deps) {
227
227
  const url = parsedUrl.pathname;
228
228
  try {
229
229
  // URL 形如 /api/ask-hook/<id>/result?wait=30000;id 受白名单约束(与 POST 同源)
@@ -242,7 +242,7 @@ function askHookResult(req, res, parsedUrl, isLocal, deps) {
242
242
  // 用 consumeIfFinal 单次 withLock 内判 status 决定是否 delete —— 旧设计的
243
243
  // "consume + 若 pending 再 setEntry 写回" 两段是 race window:中间被 markAnswered 命中后,
244
244
  // setEntry 走 status guard 已经不会覆盖;但不删的 pending 也无须重写一遍。
245
- const diskEntry = askStoreConsumeIfFinal(id);
245
+ const diskEntry = await askStoreConsumeIfFinal(id);
246
246
  if (diskEntry && diskEntry.status === 'answered') {
247
247
  res.writeHead(200, { 'Content-Type': 'application/json' });
248
248
  res.end(JSON.stringify({ answers: diskEntry.answers || {} }));
@@ -80,6 +80,8 @@ function dingtalkConfigPost(req, res, parsedUrl, isLocal, deps) {
80
80
  allowStaffIds: incoming.allowStaffIds,
81
81
  maxChunkChars: incoming.maxChunkChars,
82
82
  blockOnSkipPermissions: incoming.blockOnSkipPermissions,
83
+ ackCard: incoming.ackCard,
84
+ cardTemplateId: incoming.cardTemplateId,
83
85
  });
84
86
  // 驱动进程管理器(替代旧的在进程 reloadBridge):启用→重启 worker,停用→停 worker。
85
87
  try {
@@ -614,7 +614,7 @@ function openFile(req, res, parsedUrl, isLocal, deps) {
614
614
  if (plat === 'darwin') {
615
615
  execFile('open', [fullPath], () => {});
616
616
  } else if (plat === 'win32') {
617
- execFile('cmd.exe', ['/c', 'start', '', fullPath], () => {});
617
+ execFile('cmd.exe', ['/c', 'start', '', fullPath], { windowsHide: true }, () => {});
618
618
  } else {
619
619
  execFile('xdg-open', [fullPath], () => {});
620
620
  }
@@ -842,7 +842,7 @@ function createDir(req, res, parsedUrl, isLocal, deps) {
842
842
  function openLogDir(req, res) {
843
843
  const dir = LOG_FILE ? dirname(LOG_FILE) : LOG_DIR;
844
844
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
845
- execFile(cmd, [dir], () => {});
845
+ execFile(cmd, [dir], { windowsHide: true }, () => {});
846
846
  res.writeHead(200, { 'Content-Type': 'application/json' });
847
847
  res.end(JSON.stringify({ ok: true, dir }));
848
848
  }
@@ -851,7 +851,7 @@ function openProfileDir(req, res) {
851
851
  const dir = dirname(PROFILE_PATH);
852
852
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
853
853
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
854
- execFile(cmd, [dir], () => {});
854
+ execFile(cmd, [dir], { windowsHide: true }, () => {});
855
855
  res.writeHead(200, { 'Content-Type': 'application/json' });
856
856
  res.end(JSON.stringify({ ok: true, dir }));
857
857
  }
@@ -859,7 +859,7 @@ function openProfileDir(req, res) {
859
859
  function openProjectDir(req, res) {
860
860
  const dir = process.env.CCV_PROJECT_DIR || process.cwd();
861
861
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
862
- execFile(cmd, [dir], () => {});
862
+ execFile(cmd, [dir], { windowsHide: true }, () => {});
863
863
  res.writeHead(200, { 'Content-Type': 'application/json' });
864
864
  res.end(JSON.stringify({ ok: true, dir }));
865
865
  }