cc-viewer 1.6.295 → 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.
package/dist/index.html CHANGED
@@ -21,7 +21,7 @@
21
21
  // 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
22
22
  // electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
23
23
  </script>
24
- <script type="module" crossorigin src="/assets/index-DMuCrfTo.js"></script>
24
+ <script type="module" crossorigin src="/assets/index-DtpelJc4.js"></script>
25
25
  <link rel="modulepreload" crossorigin href="/assets/vendor-antd-Bur5ZxWE.js">
26
26
  <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-Si44UqBp.js">
27
27
  <link rel="modulepreload" crossorigin href="/assets/vendor-mdxeditor-Cco3AQJS.js">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.6.295",
3
+ "version": "1.6.296",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -407,7 +407,8 @@ export function resetWorkspace() {
407
407
  _loadProxyProfile(); // workspace 上下文消失,回落到 profile.json.active
408
408
  }
409
409
 
410
- const MAX_LOG_SIZE = 300 * 1024 * 1024; // 300MB
410
+ // Windows NTFS + Defender 下大文件 I/O 代价远高于 Mac/Linux,降低分割阈值减轻压力
411
+ const MAX_LOG_SIZE = (process.platform === 'win32' ? 150 : 300) * 1024 * 1024;
411
412
 
412
413
  async function checkAndRotateLogFile() {
413
414
  // Teammate 不做日志轮转,由 leader 负责
@@ -1,17 +1,12 @@
1
- import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, unlinkSync, realpathSync, appendFileSync } from 'node:fs';
1
+ import { existsSync, realpathSync, unlinkSync, readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
2
+ import { readFile, writeFile, appendFile, stat, readdir } from 'node:fs/promises';
3
+ import { randomBytes } from 'node:crypto';
2
4
  import { renameSyncWithRetry } from './file-api.js';
3
5
  import { join } from 'node:path';
4
6
  import { reconstructEntries } from './delta-reconstructor.js';
5
- import { streamReconstructedEntries } from './log-stream.js';
7
+ import { streamReconstructedEntriesAsync } from './log-stream.js';
6
8
  import { archiveJsonl, resolveJsonlPath } from './jsonl-archive.js';
7
9
 
8
- /**
9
- * Validate that a resolved file path is contained within logDir.
10
- * Throws on invalid path (not found or path traversal).
11
- * @param {string} logDir - base log directory
12
- * @param {string} file - relative file path (e.g. "project/file.jsonl")
13
- * @returns {string} the real (resolved) path
14
- */
15
10
  export function validateLogPath(logDir, file) {
16
11
  const filePath = join(logDir, file);
17
12
  if (!existsSync(filePath)) {
@@ -33,65 +28,51 @@ function isLogFileName(name) {
33
28
  return name.endsWith('.jsonl') || name.endsWith('.jsonl.zip');
34
29
  }
35
30
 
36
- /**
37
- * List local log files grouped by project.
38
- * @param {string} logDir - base log directory
39
- * @param {string} currentProjectName - current project name (may be empty)
40
- * @returns {{ [project: string]: Array, _currentProject: string }}
41
- */
42
- export function listLocalLogs(logDir, currentProjectName) {
31
+ export async function listLocalLogs(logDir, currentProjectName) {
43
32
  const grouped = {};
44
- if (existsSync(logDir)) {
45
- const entries = readdirSync(logDir, { withFileTypes: true });
46
- for (const entry of entries) {
47
- if (!entry.isDirectory()) continue;
48
- const project = entry.name;
49
- const projectDir = join(logDir, project);
50
- const files = readdirSync(projectDir)
51
- .filter(isLogFileName)
52
- .sort()
53
- .reverse();
54
- // 从项目统计缓存中读取 per-file 数据,避免逐文件扫描
55
- let statsFiles = null;
56
- try {
57
- const statsFile = join(projectDir, `${project}.json`);
58
- if (existsSync(statsFile)) {
59
- statsFiles = JSON.parse(readFileSync(statsFile, 'utf-8')).files;
60
- }
61
- } catch { }
62
- for (const f of files) {
63
- const match = f.match(/^(.+?)_(\d{8}_\d{6})\.jsonl(\.zip)?$/);
64
- if (!match) continue;
65
- const ts = match[2];
66
- const archived = !!match[3];
67
- const filePath = join(projectDir, f);
68
- const size = statSync(filePath).size;
69
- if (size === 0) continue; // 跳过空文件
70
- // 归档前的统计缓存 key 是 `.jsonl`;归档后切到 `.jsonl.zip`;两种都尝试
71
- const stats = statsFiles?.[f] || (archived ? statsFiles?.[f.slice(0, -4)] : null);
72
- const turns = stats?.summary?.sessionCount || 0;
73
- if (!grouped[project]) grouped[project] = [];
74
- grouped[project].push({ file: `${project}/${f}`, timestamp: ts, size, turns, preview: stats?.preview || [], archived });
33
+ if (!existsSync(logDir)) return { ...grouped, _currentProject: currentProjectName || '' };
34
+
35
+ const entries = await readdir(logDir, { withFileTypes: true });
36
+ for (const entry of entries) {
37
+ if (!entry.isDirectory()) continue;
38
+ const project = entry.name;
39
+ const projectDir = join(logDir, project);
40
+ const files = (await readdir(projectDir))
41
+ .filter(isLogFileName)
42
+ .sort()
43
+ .reverse();
44
+ let statsFiles = null;
45
+ try {
46
+ const statsFile = join(projectDir, `${project}.json`);
47
+ if (existsSync(statsFile)) {
48
+ statsFiles = JSON.parse(await readFile(statsFile, 'utf-8')).files;
75
49
  }
50
+ } catch { }
51
+ for (const f of files) {
52
+ const match = f.match(/^(.+?)_(\d{8}_\d{6})\.jsonl(\.zip)?$/);
53
+ if (!match) continue;
54
+ const ts = match[2];
55
+ const archived = !!match[3];
56
+ const filePath = join(projectDir, f);
57
+ let size;
58
+ try { size = (await stat(filePath)).size; } catch { continue; }
59
+ if (size === 0) continue;
60
+ const stats = statsFiles?.[f] || (archived ? statsFiles?.[f.slice(0, -4)] : null);
61
+ const turns = stats?.summary?.sessionCount || 0;
62
+ if (!grouped[project]) grouped[project] = [];
63
+ grouped[project].push({ file: `${project}/${f}`, timestamp: ts, size, turns, preview: stats?.preview || [], archived });
76
64
  }
77
65
  }
78
66
  return { ...grouped, _currentProject: currentProjectName || '' };
79
67
  }
80
68
 
81
- /**
82
- * Read and parse a local log file.
83
- * @param {string} logDir - base log directory
84
- * @param {string} file - relative file path (e.g. "project/file.jsonl")
85
- * @returns {Array<Object>} parsed entries
86
- */
87
- export function readLocalLog(logDir, file) {
69
+ export async function readLocalLog(logDir, file) {
88
70
  validateLogPath(logDir, file);
89
71
  const filePath = resolveJsonlPath(join(logDir, file));
90
- const content = readFileSync(filePath, 'utf-8');
72
+ const content = await readFile(filePath, 'utf-8');
91
73
  const parsed = content.split('\n---\n').filter(line => line.trim()).map(entry => {
92
74
  try { return JSON.parse(entry); } catch { return null; }
93
75
  }).filter(Boolean);
94
- // Delta storage: 先去重(timestamp|url),再重建 delta 条目
95
76
  const map = new Map();
96
77
  for (const entry of parsed) {
97
78
  const key = `${entry.timestamp}|${entry.url}`;
@@ -100,12 +81,6 @@ export function readLocalLog(logDir, file) {
100
81
  return reconstructEntries(Array.from(map.values()));
101
82
  }
102
83
 
103
- /**
104
- * Delete log files. Returns per-file results.
105
- * @param {string} logDir - base log directory
106
- * @param {string[]} files - array of relative file paths
107
- * @returns {Array<{ file: string, ok?: boolean, error?: string }>}
108
- */
109
84
  export function deleteLogFiles(logDir, files) {
110
85
  const results = [];
111
86
  for (const file of files) {
@@ -134,21 +109,12 @@ export function deleteLogFiles(logDir, files) {
134
109
  return results;
135
110
  }
136
111
 
137
- /**
138
- * Merge multiple log files into the first one, deleting the rest.
139
- * @param {string} logDir - base log directory
140
- * @param {string[]} files - array of relative file paths (at least 2, same project, chronological order)
141
- * @returns {string} the merged target file path (relative)
142
- */
143
- export function mergeLogFiles(logDir, files) {
112
+ export async function mergeLogFiles(logDir, files) {
144
113
  if (!Array.isArray(files) || files.length < 2) {
145
114
  const err = new Error('At least 2 files required');
146
115
  err.code = 'INVALID_INPUT';
147
116
  throw err;
148
117
  }
149
- // 拒绝归档文件参与合并:mergeLogFiles 会以 files[0] 路径写入 plain jsonl 内容,若该路径
150
- // 是 .jsonl.zip 会把 zip 文件覆写成裸文本破坏归档;且合并产物语义上应该是可继续追加的
151
- // 活动文件,与"归档=只读快照"语义冲突。前端 UI 已 disabled,此处后端兜底。
152
118
  for (const f of files) {
153
119
  if (typeof f === 'string' && f.endsWith('.jsonl.zip')) {
154
120
  const err = new Error('Cannot merge archived (.jsonl.zip) files');
@@ -156,15 +122,12 @@ export function mergeLogFiles(logDir, files) {
156
122
  throw err;
157
123
  }
158
124
  }
159
- // 校验所有文件属于同一 project
160
- // 兼容 Win backslash:files 内部可能是 `project\log.json`,按两种 sep 都切才能拿 project 段。
161
125
  const projects = new Set(files.map(f => f.split(/[\\/]/)[0]));
162
126
  if (projects.size !== 1) {
163
127
  const err = new Error('All files must belong to the same project');
164
128
  err.code = 'INVALID_INPUT';
165
129
  throw err;
166
130
  }
167
- // 校验文件存在且无路径穿越
168
131
  for (const f of files) {
169
132
  if (f.includes('..')) {
170
133
  const err = new Error('Invalid file path');
@@ -177,26 +140,23 @@ export function mergeLogFiles(logDir, files) {
177
140
  throw err;
178
141
  }
179
142
  }
180
- // 校验合并后总大小不超过 400MB
181
143
  const MAX_MERGE_SIZE = 400 * 1024 * 1024;
182
144
  let totalSize = 0;
183
145
  for (const f of files) {
184
- totalSize += statSync(join(logDir, f)).size;
146
+ totalSize += (await stat(join(logDir, f))).size;
185
147
  }
186
148
  if (totalSize > MAX_MERGE_SIZE) {
187
149
  const err = new Error(`Merged size (${(totalSize / 1024 / 1024).toFixed(1)}MB) exceeds ${MAX_MERGE_SIZE / 1024 / 1024}MB limit`);
188
150
  err.code = 'INVALID_INPUT';
189
151
  throw err;
190
152
  }
191
- // Delta storage: 流式合并 — 逐文件分段重建并直接写入目标文件,避免全量加载 OOM
192
153
  const targetFile = files[0];
193
154
  const targetPath = join(logDir, targetFile);
194
- // 先写到临时文件,成功后再覆盖目标
195
- const tmpPath = targetPath + '.merge-tmp';
196
- writeFileSync(tmpPath, ''); // 创建空临时文件
155
+ const tmpPath = `${targetPath}.merge-tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
156
+ await writeFile(tmpPath, '');
197
157
  for (const f of files) {
198
158
  const filePath = join(logDir, f);
199
- streamReconstructedEntries(filePath, (segment) => {
159
+ await streamReconstructedEntriesAsync(filePath, async (segment) => {
200
160
  let chunk = '';
201
161
  for (const entry of segment) {
202
162
  delete entry._deltaFormat;
@@ -205,12 +165,10 @@ export function mergeLogFiles(logDir, files) {
205
165
  delete entry._isCheckpoint;
206
166
  chunk += JSON.stringify(entry) + '\n---\n';
207
167
  }
208
- appendFileSync(tmpPath, chunk);
168
+ await appendFile(tmpPath, chunk);
209
169
  });
210
170
  }
211
- // 临时文件写入成功后原子覆盖目标(POSIX renameSync 自动替换;Windows reader 持锁时 retry)
212
171
  renameSyncWithRetry(tmpPath, targetPath);
213
- // 删除其余文件
214
172
  for (let i = 1; i < files.length; i++) {
215
173
  unlinkSync(join(logDir, files[i]));
216
174
  }
@@ -224,33 +182,23 @@ function migrateStatsCacheKey(projectDir, projectName, oldFileName, newFileName)
224
182
  const stats = JSON.parse(readFileSync(statsFile, 'utf-8'));
225
183
  if (stats?.files?.[oldFileName]) {
226
184
  const entry = stats.files[oldFileName];
227
- // 同步用归档后 .zip 的 size / mtime 覆写 entry,避免 stats-worker 下次扫描时
228
- // 因 size/mtime 不匹配判定 cache stale 触发整文件重解析(大 jsonl 数秒 CPU)。
229
185
  try {
230
186
  const zipStat = statSync(join(projectDir, newFileName));
231
187
  entry.size = zipStat.size;
232
188
  entry.lastModified = zipStat.mtime.toISOString();
233
- } catch { /* zip 不可 stat 时不更新,让 stats 自然重建 */ }
189
+ } catch {}
234
190
  stats.files[newFileName] = entry;
235
191
  delete stats.files[oldFileName];
236
192
  writeFileSync(statsFile, JSON.stringify(stats, null, 2));
237
193
  }
238
- } catch { /* tolerant */ }
194
+ } catch {}
239
195
  }
240
196
 
241
- /**
242
- * 压缩归档多个 .jsonl 文件。每个 project 的最新文件(按文件名 desc 排序后的 logs[0])
243
- * 被拒绝,复用 mergeLogFiles 的"最新不允许"语义。
244
- * @param {string} logDir
245
- * @param {string[]} files - 形如 "project/<name>.jsonl"
246
- * @returns {{ archived: string[], skipped: Array<{file:string,reason:string}>, failed: Array<{file:string,reason:string}> }}
247
- */
248
197
  export function archiveLogFiles(logDir, files) {
249
198
  const archived = [];
250
199
  const skipped = [];
251
200
  const failed = [];
252
201
 
253
- // 按 project 分组以判定最新文件
254
202
  const byProject = new Map();
255
203
  for (const f of files) {
256
204
  if (!f || typeof f !== 'string' || f.includes('..') || !f.endsWith('.jsonl')) {
@@ -280,7 +228,7 @@ export function archiveLogFiles(logDir, files) {
280
228
  .sort()
281
229
  .reverse();
282
230
  latest = projectEntries[0] || null;
283
- } catch { /* directory missing => downstream calls will fail */ }
231
+ } catch {}
284
232
 
285
233
  for (const f of projectFiles) {
286
234
  const fileName = f.split(/[\\/]/).slice(1).join('/');
@@ -309,7 +257,6 @@ export function archiveLogFiles(logDir, files) {
309
257
  } else if (result.skipped) {
310
258
  skipped.push({ file: f, reason: result.skipped });
311
259
  } else {
312
- // archiveJsonl 内部已在 unlink 失败时回滚 zip,此处 fail 即原状态完整保留,用户可重试
313
260
  failed.push({ file: f, reason: result.error || 'archive failed' });
314
261
  }
315
262
  }
@@ -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,9 +1,10 @@
1
- import { readFileSync, existsSync, watch, 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';
2
3
  import { dirname, basename } from 'node:path';
3
4
  import { isMainAgentEntry, extractCachedContent } from './kv-cache-analyzer.js';
4
5
  import { buildContextWindowEvent, getContextSizeForModel } from './context-watcher.js';
5
6
  import { reconstructEntries, createIncrementalReconstructor } from './delta-reconstructor.js';
6
- import { countLogEntries, streamReconstructedEntries } from './log-stream.js';
7
+ import { countLogEntries, streamReconstructedEntriesAsync } from './log-stream.js';
7
8
  import { enrichEntry } from './enrich-plan-input.js';
8
9
  import { resolveJsonlPath } from './jsonl-archive.js';
9
10
 
@@ -109,11 +110,11 @@ export function sendChunkToClients(clients, dataJson) {
109
110
 
110
111
  // --- 轮转切换(抽取公共逻辑) ---
111
112
 
112
- function _switchToRotatedFile(logFile, currentLogFile, clients, opts) {
113
+ async function _switchToRotatedFile(logFile, currentLogFile, clients, opts) {
113
114
  _unwatchSingleFile(logFile);
114
- const total = countLogEntries(currentLogFile);
115
+ const total = await countLogEntries(currentLogFile);
115
116
  sendEventToClients(clients, 'load_start', { total, incremental: false });
116
- streamReconstructedEntries(currentLogFile, (segment) => {
117
+ await streamReconstructedEntriesAsync(currentLogFile, (segment) => {
117
118
  sendChunkToClients(clients, JSON.stringify(segment));
118
119
  });
119
120
  sendEventToClients(clients, 'load_end', {});
@@ -122,11 +123,14 @@ function _switchToRotatedFile(logFile, currentLogFile, clients, opts) {
122
123
 
123
124
  // --- 增量读 + 解析 + 广播(独立于触发机制) ---
124
125
 
125
- function _readDelta(state) {
126
+ async function _readDelta(state) {
127
+ if (state._reading) return; // 防止并发调用(debounce + safetyTimer 可能重叠)
128
+ state._reading = true;
126
129
  const { logFile, opts, reconstructor } = state;
127
130
  const { clients, getClaudePid, runParallelHook, notifyStatsWorker, getLogFile } = opts;
128
131
  try {
129
- const currentSize = statSync(logFile).size;
132
+ const st = await fsStat(logFile);
133
+ const currentSize = st.size;
130
134
 
131
135
  if (currentSize < state.lastByteOffset) {
132
136
  state.lastByteOffset = 0;
@@ -135,7 +139,7 @@ function _readDelta(state) {
135
139
 
136
140
  const currentLogFile = getLogFile();
137
141
  if (currentLogFile !== logFile && !watchedFiles.has(currentLogFile)) {
138
- _switchToRotatedFile(logFile, currentLogFile, clients, opts);
142
+ await _switchToRotatedFile(logFile, currentLogFile, clients, opts);
139
143
  return;
140
144
  }
141
145
  }
@@ -144,11 +148,11 @@ function _readDelta(state) {
144
148
 
145
149
  const bytesToRead = currentSize - state.lastByteOffset;
146
150
  const buf = Buffer.alloc(bytesToRead);
147
- const fd = openSync(logFile, 'r');
151
+ const fh = await fsOpen(logFile, 'r');
148
152
  try {
149
- readSync(fd, buf, 0, bytesToRead, state.lastByteOffset);
153
+ await fh.read(buf, 0, bytesToRead, state.lastByteOffset);
150
154
  } finally {
151
- closeSync(fd);
155
+ await fh.close();
152
156
  }
153
157
  state.lastByteOffset = currentSize;
154
158
 
@@ -191,10 +195,12 @@ function _readDelta(state) {
191
195
 
192
196
  const currentLogFile = getLogFile();
193
197
  if (currentLogFile !== logFile && !watchedFiles.has(currentLogFile)) {
194
- _switchToRotatedFile(logFile, currentLogFile, clients, opts);
198
+ await _switchToRotatedFile(logFile, currentLogFile, clients, opts);
195
199
  }
196
200
  } catch {
197
201
  // File not yet created or transient read error
202
+ } finally {
203
+ state._reading = false;
198
204
  }
199
205
  }
200
206
 
@@ -96,7 +96,7 @@ function resumeChoice(req, res, parsedUrl, isLocal, deps) {
96
96
  } catch { }
97
97
  });
98
98
  // 流式分段广播 full_reload,避免全量加载 OOM
99
- const reloadTotal = countLogEntries(LOG_FILE);
99
+ const reloadTotal = await countLogEntries(LOG_FILE);
100
100
  deps.clients.forEach(client => {
101
101
  try { client.write(`event: load_start\ndata: ${JSON.stringify({ total: reloadTotal, incremental: false })}\n\n`); } catch { }
102
102
  });
@@ -313,7 +313,7 @@ async function requests(req, res) {
313
313
  }
314
314
 
315
315
  // 分页历史条目端点:移动端"加载更多"按需拉取
316
- function entriesPage(req, res, parsedUrl) {
316
+ async function entriesPage(req, res, parsedUrl) {
317
317
  const before = parsedUrl.searchParams.get('before');
318
318
  const limitVal = Math.min(parseInt(parsedUrl.searchParams.get('limit'), 10) || 100, 500);
319
319
  if (!before || isNaN(new Date(before).getTime())) {
@@ -322,7 +322,7 @@ function entriesPage(req, res, parsedUrl) {
322
322
  return;
323
323
  }
324
324
  try {
325
- const result = readPagedEntries(LOG_FILE, { before, limit: limitVal });
325
+ const result = await readPagedEntries(LOG_FILE, { before, limit: limitVal });
326
326
  // entries 是原始 JSON 字符串数组,parse 后返回给客户端
327
327
  // ExitPlanMode V2 空 input 的条目用 enrichRawIfNeeded 在 raw 阶段补全
328
328
  const entries = result.entries.map(raw => {
@@ -6,9 +6,9 @@ import { _projectName } from '../interceptor.js';
6
6
  import { listLocalLogs, deleteLogFiles, mergeLogFiles, archiveLogFiles, validateLogPath } from '../lib/log-management.js';
7
7
  import { countLogEntries, streamRawEntriesAsync } from '../lib/log-stream.js';
8
8
 
9
- function localLogs(req, res) {
9
+ async function localLogs(req, res) {
10
10
  try {
11
- const result = listLocalLogs(LOG_DIR, _projectName);
11
+ const result = await listLocalLogs(LOG_DIR, _projectName);
12
12
  res.writeHead(200, { 'Content-Type': 'application/json' });
13
13
  res.end(JSON.stringify(result));
14
14
  } catch (err) {
@@ -96,7 +96,7 @@ async function localLog(req, res, parsedUrl) {
96
96
  // 独立 SSE 流:直接向请求方返回 event-stream,不走 /events 广播
97
97
  validateLogPath(LOG_DIR, file);
98
98
  const filePath = join(LOG_DIR, file);
99
- const total = countLogEntries(filePath);
99
+ const total = await countLogEntries(filePath);
100
100
 
101
101
  res.writeHead(200, {
102
102
  'Content-Type': 'text/event-stream',
@@ -151,10 +151,10 @@ function deleteLogs(req, res, parsedUrl, isLocal, deps) {
151
151
  function mergeLogs(req, res, parsedUrl, isLocal, deps) {
152
152
  let body = '';
153
153
  req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
154
- req.on('end', () => {
154
+ req.on('end', async () => {
155
155
  try {
156
156
  const { files } = JSON.parse(body);
157
- const merged = mergeLogFiles(LOG_DIR, files);
157
+ const merged = await mergeLogFiles(LOG_DIR, files);
158
158
  res.writeHead(200, { 'Content-Type': 'application/json' });
159
159
  res.end(JSON.stringify({ ok: true, merged }));
160
160
  } catch (err) {
@@ -7,8 +7,8 @@ import { readClaudeProjectModel } from '../lib/context-watcher.js';
7
7
  import { countLogEntries, streamRawEntriesAsync } from '../lib/log-stream.js';
8
8
 
9
9
  function workspacesList(req, res, parsedUrl, isLocal, deps) {
10
- import('../workspace-registry.js').then(({ getWorkspaces }) => {
11
- const workspaces = getWorkspaces();
10
+ import('../workspace-registry.js').then(async ({ getWorkspaces }) => {
11
+ const workspaces = await getWorkspaces();
12
12
  res.writeHead(200, { 'Content-Type': 'application/json' });
13
13
  res.end(JSON.stringify({ workspaces, workspaceMode: deps.isWorkspaceMode && !deps.workspaceLaunched }));
14
14
  }).catch(err => {
@@ -73,7 +73,7 @@ function workspacesLaunch(req, res, parsedUrl, isLocal, deps) {
73
73
  });
74
74
 
75
75
  // 流式分段广播以刷新会话区域,避免全量加载 OOM
76
- const wsReloadTotal = countLogEntries(LOG_FILE);
76
+ const wsReloadTotal = await countLogEntries(LOG_FILE);
77
77
  deps.clients.forEach(client => {
78
78
  try { client.write(`event: load_start\ndata: ${JSON.stringify({ total: wsReloadTotal, incremental: false })}\n\n`); } catch {}
79
79
  });
package/server/server.js CHANGED
@@ -86,6 +86,7 @@ function getPrefsFile() { return join(LOG_DIR, 'preferences.json'); }
86
86
  let claudeSettings = {};
87
87
  // SSR theme 注入自检状态:模板缺 data-theme 时仅首次 warn(避免高 QPS 刷屏)
88
88
  let _ssrThemeAttrWarned = false;
89
+ let _indexHtmlCache = null; // { html: string, mtime: number }
89
90
  try {
90
91
  const settingsPath = join(getClaudeConfigDir(), 'settings.json');
91
92
  if (existsSync(settingsPath)) {
@@ -685,16 +686,20 @@ async function handleRequest(req, res) {
685
686
  const serveIndexHtml = () => {
686
687
  try {
687
688
  const indexPath = join(DIST_DIR, 'index.html');
688
- let html = readFileSync(indexPath, 'utf-8');
689
- let themeColor = 'light';
689
+ // mtime 缓存:避免每次请求都 readFileSync(Windows Defender 下每次读 5-50ms)
690
+ let st;
691
+ try { st = statSync(indexPath); } catch { return false; }
692
+ if (!_indexHtmlCache || _indexHtmlCache.mtime !== st.mtimeMs) {
693
+ _indexHtmlCache = { html: readFileSync(indexPath, 'utf-8'), mtime: st.mtimeMs };
694
+ }
695
+ let html = _indexHtmlCache.html;
696
+ let themeColor = process.platform === 'win32' ? 'dark' : 'light';
690
697
  try {
691
698
  if (existsSync(getPrefsFile())) {
692
699
  const prefs = JSON.parse(readFileSync(getPrefsFile(), 'utf-8'));
693
700
  if (prefs.themeColor === 'dark' || prefs.themeColor === 'light') themeColor = prefs.themeColor;
694
701
  }
695
- } catch { /* 读 prefs 失败就走默认 light */ }
696
- // 自检:模板里没有 <html ... data-theme="..."> 时 replace 静默 no-op,SSR 优化失效但不报错。
697
- // 仅首次 warn 避免高 QPS 刷屏(_ssrThemeAttrWarned 单进程一次性)。
702
+ } catch {}
698
703
  if (!_ssrThemeAttrWarned && !/<html[^>]*data-theme="[^"]*"/.test(html)) {
699
704
  _ssrThemeAttrWarned = true;
700
705
  console.warn('[serveIndexHtml] dist/index.html 没有 <html data-theme="..."> 属性,SSR theme 注入将不生效。检查 index.html 模板。');