cc-viewer 1.6.294 → 1.6.296

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 (40) hide show
  1. package/cli.js +7 -2
  2. package/dist/assets/App-BeCGow-I.js +2 -0
  3. package/dist/assets/{MdxEditorPanel-B8xrlDZJ.js → MdxEditorPanel-D52b5qxi.js} +1 -1
  4. package/dist/assets/{Mobile-fsi8-Lpb.js → Mobile-8fflztx7.js} +1 -1
  5. package/dist/assets/index-DtpelJc4.js +2 -0
  6. package/dist/assets/seqResourceLoaders-DM-48tr-.js +2 -0
  7. package/dist/index.html +1 -1
  8. package/findcc.js +3 -3
  9. package/package.json +1 -1
  10. package/server/i18n.js +224 -8
  11. package/server/interceptor.js +23 -19
  12. package/server/lib/adapters/dingtalk-adapter.js +62 -0
  13. package/server/lib/adapters/discord-adapter.js +35 -0
  14. package/server/lib/adapters/feishu-adapter.js +37 -0
  15. package/server/lib/ask-store.js +19 -90
  16. package/server/lib/async-file-lock.js +123 -0
  17. package/server/lib/async-write-queue.js +131 -0
  18. package/server/lib/git-diff.js +4 -1
  19. package/server/lib/im-bridge-core.js +119 -14
  20. package/server/lib/im-config.js +11 -6
  21. package/server/lib/im-process-manager.js +1 -1
  22. package/server/lib/jsonl-archive.js +0 -1
  23. package/server/lib/log-management.js +46 -99
  24. package/server/lib/log-stream.js +102 -8
  25. package/server/lib/log-watcher.js +231 -178
  26. package/server/lib/plugin-manager.js +1 -1
  27. package/server/lib/updater.js +4 -2
  28. package/server/pty-manager.js +1 -1
  29. package/server/routes/ask-perm.js +2 -2
  30. package/server/routes/dingtalk.js +2 -0
  31. package/server/routes/events.js +3 -3
  32. package/server/routes/files-fs.js +4 -4
  33. package/server/routes/logs.js +5 -5
  34. package/server/routes/project-meta.js +18 -1
  35. package/server/routes/workspaces.js +10 -13
  36. package/server/server.js +33 -25
  37. package/server/workspace-registry.js +26 -72
  38. package/dist/assets/App-C66LoBEz.js +0 -2
  39. package/dist/assets/index-BTZqk5O5.js +0 -2
  40. package/dist/assets/seqResourceLoaders-6k4uXcNn.js +0 -2
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { existsSync, statSync, openSync, readSync, closeSync } from 'node:fs';
14
+ import { open as fsOpen, stat as fsStat } from 'node:fs/promises';
14
15
  import { isCheckpointEntry, isDeltaEntry, reconstructSegment } from './delta-reconstructor.js';
15
16
  import { resolveJsonlPath } from './jsonl-archive.js';
16
17
 
@@ -56,15 +57,55 @@ function* iterateRawEntries(filePath) {
56
57
  }
57
58
  }
58
59
 
60
+ /**
61
+ * 异步 Generator:分块读取 JSONL 文件,逐条 yield 原始 JSON 字符串。
62
+ * 使用 fs.promises.open + fileHandle.read,不阻塞事件循环。
63
+ */
64
+ async function* iterateRawEntriesAsync(filePath) {
65
+ filePath = resolveJsonlPath(filePath);
66
+ let fh;
67
+ try {
68
+ const st = await fsStat(filePath);
69
+ if (st.size === 0) return;
70
+ fh = await fsOpen(filePath, 'r');
71
+ const fileSize = st.size;
72
+ const buf = Buffer.alloc(Math.min(READ_CHUNK_SIZE, fileSize));
73
+ let offset = 0;
74
+ let pending = '';
75
+
76
+ while (offset < fileSize) {
77
+ const toRead = Math.min(buf.length, fileSize - offset);
78
+ const { bytesRead } = await fh.read(buf, 0, toRead, offset);
79
+ if (bytesRead === 0) break;
80
+ offset += bytesRead;
81
+
82
+ const raw = pending + buf.toString('utf-8', 0, bytesRead);
83
+ const parts = raw.split(SEPARATOR);
84
+ pending = parts.pop() || '';
85
+
86
+ for (const part of parts) {
87
+ const trimmed = part.trim();
88
+ if (trimmed) yield trimmed;
89
+ }
90
+ }
91
+
92
+ if (pending.trim()) {
93
+ yield pending.trim();
94
+ }
95
+ } finally {
96
+ if (fh) await fh.close();
97
+ }
98
+ }
99
+
59
100
  /**
60
101
  * 轻量预扫描:统计条目总数(原始条目数,不去重)。
61
102
  * 用于 SSE load_start 的 total 字段(进度显示)。
62
103
  */
63
- export function countLogEntries(filePath) {
104
+ export async function countLogEntries(filePath) {
64
105
  filePath = resolveJsonlPath(filePath);
65
106
  if (!existsSync(filePath)) return 0;
66
107
  let count = 0;
67
- for (const _ of iterateRawEntries(filePath)) { count++; }
108
+ for await (const _ of iterateRawEntriesAsync(filePath)) { count++; }
68
109
  return count;
69
110
  }
70
111
 
@@ -157,6 +198,61 @@ export function streamReconstructedEntries(filePath, onSegment, opts = {}) {
157
198
  return sentCount;
158
199
  }
159
200
 
201
+ export async function streamReconstructedEntriesAsync(filePath, onSegment, opts = {}) {
202
+ filePath = resolveJsonlPath(filePath);
203
+ if (!existsSync(filePath)) return 0;
204
+ try {
205
+ const st = await fsStat(filePath);
206
+ if (st.size === 0) return 0;
207
+ } catch { return 0; }
208
+
209
+ const sinceMs = opts.since ? new Date(opts.since).getTime() : 0;
210
+ let currentSegment = [];
211
+ let dedup = new Map();
212
+ let sentCount = 0;
213
+
214
+ async function flushSegment(nextCp) {
215
+ if (currentSegment.length === 0) return;
216
+ const dedupedSegment = Array.from(dedup.values());
217
+ reconstructSegment(dedupedSegment, nextCp);
218
+
219
+ let toSend = dedupedSegment;
220
+ if (sinceMs) {
221
+ toSend = dedupedSegment.filter(e => {
222
+ const ts = e.timestamp ? new Date(e.timestamp).getTime() : 0;
223
+ return ts > sinceMs;
224
+ });
225
+ }
226
+ if (toSend.length > 0) {
227
+ await onSegment(toSend);
228
+ sentCount += toSend.length;
229
+ }
230
+ currentSegment = [];
231
+ dedup = new Map();
232
+ }
233
+
234
+ for await (const rawEntry of iterateRawEntriesAsync(filePath)) {
235
+ let entry;
236
+ try { entry = JSON.parse(rawEntry); } catch { continue; }
237
+
238
+ if (isSegmentBoundary(entry) && currentSegment.length > 0) {
239
+ const key = `${entry.timestamp}|${entry.url}`;
240
+ const last = currentSegment[currentSegment.length - 1];
241
+ const lastKey = `${last.timestamp}|${last.url}`;
242
+ if (key !== lastKey) {
243
+ await flushSegment(entry);
244
+ }
245
+ }
246
+
247
+ const key = `${entry.timestamp}|${entry.url}`;
248
+ dedup.set(key, entry);
249
+ currentSegment.push(entry);
250
+ }
251
+
252
+ await flushSegment(null);
253
+ return sentCount;
254
+ }
255
+
160
256
  // ============================================================================
161
257
  // 异步 API — 用于 SSE/HTTP:不做重建,直接发原始 JSON 字符串
162
258
  // ============================================================================
@@ -192,16 +288,15 @@ export async function streamRawEntriesAsync(filePath, onRawEntry, opts = {}) {
192
288
  const onScan = opts.onScan || null;
193
289
  const onReady = opts.onReady || null;
194
290
 
195
- // 第一遍:generator 逐条读取 → dedup Map 存原始字符串(不 parse)
291
+ // 第一遍:异步 generator 逐条读取 → dedup Map 存原始字符串(不 parse)
196
292
  // 内存 = 去重后的原始字符串总量 ≈ 文件大小的一半(inProgress 被 completed 覆盖)
197
293
  const dedup = new Map();
198
- for (const raw of iterateRawEntries(filePath)) {
294
+ for await (const raw of iterateRawEntriesAsync(filePath)) {
199
295
  if (onScan) onScan(raw);
200
296
  const key = extractDedupKey(raw);
201
297
  if (key) {
202
298
  dedup.set(key, raw);
203
299
  } else {
204
- // 无法提取 key 的条目直接保留(用自增 id 避免被覆盖)
205
300
  dedup.set(`__nokey_${dedup.size}`, raw);
206
301
  }
207
302
  }
@@ -270,15 +365,14 @@ export async function streamRawEntriesAsync(filePath, onRawEntry, opts = {}) {
270
365
  * @param {{ before: string, limit: number }} opts
271
366
  * @returns {{ entries: string[], hasMore: boolean, oldestTimestamp: string, count: number }}
272
367
  */
273
- export function readPagedEntries(filePath, { before, limit }) {
368
+ export async function readPagedEntries(filePath, { before, limit }) {
274
369
  filePath = resolveJsonlPath(filePath);
275
370
  if (!existsSync(filePath)) return { entries: [], hasMore: false, oldestTimestamp: '', count: 0 };
276
371
  const stat = statSync(filePath);
277
372
  if (stat.size === 0) return { entries: [], hasMore: false, oldestTimestamp: '', count: 0 };
278
373
 
279
- // 去重
280
374
  const dedup = new Map();
281
- for (const raw of iterateRawEntries(filePath)) {
375
+ for await (const raw of iterateRawEntriesAsync(filePath)) {
282
376
  const key = extractDedupKey(raw);
283
377
  if (key) {
284
378
  dedup.set(key, raw);
@@ -1,22 +1,33 @@
1
- import { readFileSync, existsSync, watchFile, unwatchFile, openSync, readSync, closeSync, statSync } from 'node:fs';
1
+ import { readFileSync, existsSync, watch, watchFile, unwatchFile, statSync } from 'node:fs';
2
+ import { open as fsOpen, stat as fsStat } from 'node:fs/promises';
3
+ import { dirname, basename } from 'node:path';
2
4
  import { isMainAgentEntry, extractCachedContent } from './kv-cache-analyzer.js';
3
5
  import { buildContextWindowEvent, getContextSizeForModel } from './context-watcher.js';
4
6
  import { reconstructEntries, createIncrementalReconstructor } from './delta-reconstructor.js';
5
- import { countLogEntries, streamReconstructedEntries } from './log-stream.js';
7
+ import { countLogEntries, streamReconstructedEntriesAsync } from './log-stream.js';
6
8
  import { enrichEntry } from './enrich-plan-input.js';
7
9
  import { resolveJsonlPath } from './jsonl-archive.js';
8
10
 
9
- // 跟踪所有被 watch 的日志文件
11
+ // 跟踪所有被 watch 的日志文件。value: fileState 对象(外部只用 .has()/.keys())
10
12
  const watchedFiles = new Map();
11
13
 
14
+ // 目录级 fs.watch 实例注册表(事件驱动,替代 per-file 轮询)
15
+ const _dirWatchers = new Map();
16
+
17
+ // Windows 单次 appendFileSync 触发 2+ 事件,防抖合并
18
+ const FSWATCH_DEBOUNCE_MS = 80;
19
+
20
+ // 安全网慢轮询:fs.watch 可能漏事件(buffer overflow 等),冷 fallback 兜底
21
+ const SAFETY_POLL_MS = 5000;
22
+
23
+ const FORCE_POLL = process.env.CCV_FORCE_POLL === '1';
24
+
12
25
  /**
13
26
  * Read and parse a JSONL log file.
14
27
  * @param {string} logFile - absolute path to the log file
15
28
  * @returns {Array} parsed and deduplicated entries
16
29
  */
17
30
  export function readLogFile(logFile) {
18
- // 透明支持归档后的 .jsonl.zip。active log(被 watchLogFile 追加写入的文件)不会被归档
19
- // (archive 拒绝最新文件),watchLogFile 内部的 openSync/readSync 因此无需走 resolveJsonlPath。
20
31
  logFile = resolveJsonlPath(logFile);
21
32
  if (!existsSync(logFile)) {
22
33
  return [];
@@ -24,8 +35,6 @@ export function readLogFile(logFile) {
24
35
 
25
36
  try {
26
37
  const content = readFileSync(logFile, 'utf-8');
27
- // Windows 上若 writer 使用 os.EOL,分隔符会变 \r\n---\r\n。固定 LF 切会失败 → 整文件
28
- // 解析成一条乱码或漏。CRLF-tolerant split 把两边都 cover 住。
29
38
  const entries = content.split(/\r?\n---\r?\n/).filter(line => line.trim());
30
39
  const parsed = entries.map(entry => {
31
40
  try {
@@ -34,7 +43,6 @@ export function readLogFile(logFile) {
34
43
  return null;
35
44
  }
36
45
  }).filter(Boolean);
37
- // 去重:同一 timestamp+url 的条目,后出现的(带 response)覆盖先出现的(在途)
38
46
  const map = new Map();
39
47
  for (const entry of parsed) {
40
48
  const key = `${entry.timestamp}|${entry.url}`;
@@ -47,8 +55,6 @@ export function readLogFile(logFile) {
47
55
  }
48
56
  }
49
57
 
50
- // SSE 单客户端 backpressure 容忍上限:连续未排空 > 此时长则视为 dead 客户端剔除。
51
- // 与 server.js 同名常量值保持一致(避免循环依赖,此处单独 mirror)。
52
58
  const SSE_BACKPRESSURE_TIMEOUT_MS = 5000;
53
59
 
54
60
  function _removeClient(clients, client) {
@@ -56,14 +62,7 @@ function _removeClient(clients, client) {
56
62
  if (idx !== -1) clients.splice(idx, 1);
57
63
  }
58
64
 
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
65
  function _safeSseWrite(clients, client, payload) {
66
- // 仅在显式标记 destroyed/writable=false 时剔除;undefined(如老 mock)按"活"处理。
67
66
  if (client.destroyed === true || client.writable === false) {
68
67
  _removeClient(clients, client);
69
68
  return false;
@@ -88,25 +87,13 @@ function _safeSseWrite(clients, client, payload) {
88
87
  return true;
89
88
  }
90
89
 
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
90
  export function sendToClients(clients, entry) {
97
91
  const payload = `data: ${JSON.stringify(entry)}\n\n`;
98
- // 倒序遍历允许循环内安全 splice
99
92
  for (let i = clients.length - 1; i >= 0; i--) {
100
93
  _safeSseWrite(clients, clients[i], payload);
101
94
  }
102
95
  }
103
96
 
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
97
  export function sendEventToClients(clients, eventName, data) {
111
98
  const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
112
99
  for (let i = clients.length - 1; i >= 0; i--) {
@@ -114,11 +101,6 @@ export function sendEventToClients(clients, eventName, data) {
114
101
  }
115
102
  }
116
103
 
117
- /**
118
- * 旋转处理器专用:发送已序列化的 load_chunk segment 数据。
119
- * @param {Array} clients - SSE client array
120
- * @param {string} dataJson - segment 已被调用方 JSON.stringify
121
- */
122
104
  export function sendChunkToClients(clients, dataJson) {
123
105
  const payload = `event: load_chunk\ndata: ${dataJson}\n\n`;
124
106
  for (let i = clients.length - 1; i >= 0; i--) {
@@ -126,24 +108,185 @@ export function sendChunkToClients(clients, dataJson) {
126
108
  }
127
109
  }
128
110
 
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
- */
111
+ // --- 轮转切换(抽取公共逻辑) ---
112
+
113
+ async function _switchToRotatedFile(logFile, currentLogFile, clients, opts) {
114
+ _unwatchSingleFile(logFile);
115
+ const total = await countLogEntries(currentLogFile);
116
+ sendEventToClients(clients, 'load_start', { total, incremental: false });
117
+ await streamReconstructedEntriesAsync(currentLogFile, (segment) => {
118
+ sendChunkToClients(clients, JSON.stringify(segment));
119
+ });
120
+ sendEventToClients(clients, 'load_end', {});
121
+ watchLogFile({ ...opts, logFile: currentLogFile });
122
+ }
123
+
124
+ // --- 增量读 + 解析 + 广播(独立于触发机制) ---
125
+
126
+ async function _readDelta(state) {
127
+ if (state._reading) return; // 防止并发调用(debounce + safetyTimer 可能重叠)
128
+ state._reading = true;
129
+ const { logFile, opts, reconstructor } = state;
130
+ const { clients, getClaudePid, runParallelHook, notifyStatsWorker, getLogFile } = opts;
131
+ try {
132
+ const st = await fsStat(logFile);
133
+ const currentSize = st.size;
134
+
135
+ if (currentSize < state.lastByteOffset) {
136
+ state.lastByteOffset = 0;
137
+ state.pendingTail = '';
138
+ reconstructor.reset();
139
+
140
+ const currentLogFile = getLogFile();
141
+ if (currentLogFile !== logFile && !watchedFiles.has(currentLogFile)) {
142
+ await _switchToRotatedFile(logFile, currentLogFile, clients, opts);
143
+ return;
144
+ }
145
+ }
146
+
147
+ if (currentSize <= state.lastByteOffset) return;
148
+
149
+ const bytesToRead = currentSize - state.lastByteOffset;
150
+ const buf = Buffer.alloc(bytesToRead);
151
+ const fh = await fsOpen(logFile, 'r');
152
+ try {
153
+ await fh.read(buf, 0, bytesToRead, state.lastByteOffset);
154
+ } finally {
155
+ await fh.close();
156
+ }
157
+ state.lastByteOffset = currentSize;
158
+
159
+ const raw = state.pendingTail + buf.toString('utf-8');
160
+ const parts = raw.split('\n---\n');
161
+ state.pendingTail = parts.pop() || '';
162
+
163
+ if (parts.length === 0 && state.pendingTail.trim()) {
164
+ try {
165
+ JSON.parse(state.pendingTail);
166
+ parts.push(state.pendingTail);
167
+ state.pendingTail = '';
168
+ } catch {}
169
+ }
170
+
171
+ const validParts = parts.filter(p => p.trim());
172
+ if (validParts.length > 0) {
173
+ validParts.forEach(entry => {
174
+ try {
175
+ const parsed = JSON.parse(entry);
176
+ if (!parsed.pid) parsed.pid = getClaudePid();
177
+ reconstructor.reconstruct(parsed);
178
+ try { enrichEntry(parsed); } catch {}
179
+ sendToClients(clients, parsed);
180
+ runParallelHook('onNewEntry', parsed).catch(() => {});
181
+ if (isMainAgentEntry(parsed) && !parsed.inProgress) {
182
+ const cached = extractCachedContent(parsed);
183
+ if (cached) sendEventToClients(clients, 'kv_cache_content', cached);
184
+ const usage = parsed.response?.body?.usage;
185
+ if (usage) {
186
+ const contextSize = getContextSizeForModel(parsed.body?.model);
187
+ const cwData = buildContextWindowEvent(usage, contextSize);
188
+ if (cwData) sendEventToClients(clients, 'context_window', cwData);
189
+ }
190
+ }
191
+ } catch {}
192
+ });
193
+ notifyStatsWorker(logFile);
194
+ }
195
+
196
+ const currentLogFile = getLogFile();
197
+ if (currentLogFile !== logFile && !watchedFiles.has(currentLogFile)) {
198
+ await _switchToRotatedFile(logFile, currentLogFile, clients, opts);
199
+ }
200
+ } catch {
201
+ // File not yet created or transient read error
202
+ } finally {
203
+ state._reading = false;
204
+ }
205
+ }
206
+
207
+ // --- 目录级 fs.watch 管理 ---
208
+
209
+ function _getOrCreateDirWatcher(dir) {
210
+ if (_dirWatchers.has(dir)) return _dirWatchers.get(dir);
211
+
212
+ try {
213
+ const files = new Map();
214
+ const watcher = watch(dir, (eventType, filename) => {
215
+ if (!filename) {
216
+ for (const [, fileState] of files) {
217
+ _scheduleDebouncedRead(fileState);
218
+ }
219
+ return;
220
+ }
221
+ const fileState = files.get(filename);
222
+ if (fileState) _scheduleDebouncedRead(fileState);
223
+ });
224
+
225
+ watcher.on('error', () => {
226
+ for (const [, fileState] of files) {
227
+ _fallbackToPolling(fileState);
228
+ }
229
+ try { watcher.close(); } catch {}
230
+ _dirWatchers.delete(dir);
231
+ });
232
+
233
+ const entry = { watcher, files };
234
+ _dirWatchers.set(dir, entry);
235
+ return entry;
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ function _scheduleDebouncedRead(fileState) {
242
+ if (fileState.debounceTimer) return;
243
+ fileState.debounceTimer = setTimeout(() => {
244
+ fileState.debounceTimer = null;
245
+ _readDelta(fileState);
246
+ }, FSWATCH_DEBOUNCE_MS);
247
+ }
248
+
249
+ function _fallbackToPolling(fileState) {
250
+ if (fileState.polling) return;
251
+ fileState.polling = true;
252
+ watchFile(fileState.logFile, { interval: 500 }, () => {
253
+ _readDelta(fileState);
254
+ });
255
+ }
256
+
257
+ function _unwatchSingleFile(logFile) {
258
+ const fileState = watchedFiles.get(logFile);
259
+ watchedFiles.delete(logFile);
260
+
261
+ if (!fileState) return;
262
+
263
+ if (fileState.debounceTimer) clearTimeout(fileState.debounceTimer);
264
+ if (fileState.safetyTimer) clearInterval(fileState.safetyTimer);
265
+
266
+ if (fileState.polling) {
267
+ try { unwatchFile(logFile); } catch {}
268
+ return;
269
+ }
270
+
271
+ const dir = dirname(logFile);
272
+ const filename = basename(logFile);
273
+ const dirEntry = _dirWatchers.get(dir);
274
+ if (dirEntry) {
275
+ dirEntry.files.delete(filename);
276
+ if (dirEntry.files.size === 0) {
277
+ try { dirEntry.watcher.close(); } catch {}
278
+ _dirWatchers.delete(dir);
279
+ }
280
+ }
281
+ }
282
+
283
+ // --- 公开 API ---
284
+
139
285
  export function watchLogFile(opts) {
140
- const { logFile, clients, getClaudePid, runParallelHook, notifyStatsWorker, getLogFile } = opts;
286
+ const { logFile } = opts;
141
287
  if (watchedFiles.has(logFile)) return;
142
288
 
143
- // Track byte offset instead of string length — avoids full-file re-read on every poll
144
289
  let lastByteOffset = 0;
145
- let pendingTail = ''; // incomplete entry carried across polls
146
- // Delta storage: 增量重建器,用于逐条重建 mainAgent delta 条目
147
290
  const _reconstructor = createIncrementalReconstructor();
148
291
  try {
149
292
  if (existsSync(logFile)) {
@@ -151,150 +294,60 @@ export function watchLogFile(opts) {
151
294
  }
152
295
  } catch {}
153
296
 
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
- }
297
+ const fileState = {
298
+ logFile,
299
+ opts,
300
+ reconstructor: _reconstructor,
301
+ lastByteOffset,
302
+ pendingTail: '',
303
+ debounceTimer: null,
304
+ safetyTimer: null,
305
+ polling: false,
306
+ };
182
307
 
183
- if (currentSize <= lastByteOffset) return;
308
+ watchedFiles.set(logFile, fileState);
184
309
 
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;
310
+ if (FORCE_POLL) {
311
+ _fallbackToPolling(fileState);
312
+ return;
313
+ }
195
314
 
196
- const raw = pendingTail + buf.toString('utf-8');
197
- const parts = raw.split('\n---\n');
315
+ const dir = dirname(logFile);
316
+ const filename = basename(logFile);
317
+ const dirEntry = _getOrCreateDirWatcher(dir);
198
318
 
199
- // Last part may be incomplete — keep it for next poll
200
- pendingTail = parts.pop() || '';
319
+ if (!dirEntry) {
320
+ _fallbackToPolling(fileState);
321
+ return;
322
+ }
201
323
 
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
- }
324
+ dirEntry.files.set(filename, fileState);
214
325
 
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
- }
326
+ fileState.safetyTimer = setInterval(() => {
327
+ _readDelta(fileState);
328
+ }, SAFETY_POLL_MS);
329
+ }
252
330
 
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
- });
331
+ export function unwatchLogFile(logFile) {
332
+ _unwatchSingleFile(logFile);
333
+ }
334
+
335
+ export function unwatchAll() {
336
+ for (const logFile of watchedFiles.keys()) {
337
+ _unwatchSingleFile(logFile);
338
+ }
339
+ for (const [, entry] of _dirWatchers) {
340
+ try { entry.watcher.close(); } catch {}
341
+ }
342
+ _dirWatchers.clear();
343
+ watchedFiles.clear();
280
344
  }
281
345
 
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
346
  export function startWatching(opts) {
293
347
  const { clients, ...watchOpts } = opts;
294
348
  watchLogFile({ ...watchOpts, clients });
295
349
  }
296
350
 
297
- /** Get the watchedFiles Map (for cleanup in stopViewer). */
298
351
  export function getWatchedFiles() {
299
352
  return watchedFiles;
300
353
  }
@@ -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
  });