cc-viewer 1.6.299 → 1.6.300

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-DWYtIFvu.js"></script>
24
+ <script type="module" crossorigin src="/assets/index-BXb1GO0R.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/findcc.js CHANGED
@@ -26,6 +26,18 @@ export function getClaudeConfigDir() {
26
26
  const raw = envDir.trim();
27
27
  return raw.startsWith('~/') ? join(homedir(), raw.slice(2)) : resolve(raw);
28
28
  }
29
+ // ████████ 测试隔离铁闸 L1b —— 绝对不可移除(2026-06-06 数据事故防再犯)████████
30
+ // CCV_LOG_DIR=tmp 只重定向 LOG_DIR,管不到本函数:updater.js 的 CACHE_DIR=
31
+ // join(getClaudeConfigDir(),'cc-viewer') 在测试里直指真实 ~/.claude/cc-viewer,
32
+ // branch-lib-updater.test.js 的 rmSync(CACHE_DIR,{recursive}) 曾因此把用户 40GB
33
+ // 历史日志整树删除(2026-06-06 第 1/4 次事故确证真凶)。settings.json、ensure-hooks、
34
+ // ~/.claude/* 展开全部派生于此 —— 测试态(node:test 注入 NODE_TEST_CONTEXT)未显式
35
+ // 设 CLAUDE_CONFIG_DIR 时,一律走进程私有临时目录,绝不解析到真实 ~/.claude。
36
+ // 单测:test/logdir-test-guard.test.js。
37
+ if (process.env.NODE_TEST_CONTEXT) {
38
+ return join(tmpdir(), 'cc-viewer-test', `guard-cfg-${process.pid}-${threadId}`);
39
+ }
40
+ // ████████████████████████████████████████████████████████████████████████████
29
41
  return join(homedir(), '.claude');
30
42
  }
31
43
 
@@ -40,6 +52,13 @@ function resolveLogDir() {
40
52
  const expanded = raw.startsWith('~/') ? join(homedir(), raw.slice(2)) : raw;
41
53
  return resolve(expanded);
42
54
  }
55
+ // 测试隔离铁闸:node:test 环境(NODE_TEST_CONTEXT 由测试 runner 自动注入,spread env
56
+ // 的子进程会继承)下若未显式设 CCV_LOG_DIR,绝不解析到真实用户目录——强制进程私有临时
57
+ // 目录。2026-06-06 事故:无 env 的测试探针把真实 ~/.claude/cc-viewer 当 LOG_DIR,
58
+ // 清理逻辑将用户数据整树删除。任何测试运行不允许触碰在用的文件体系和本地存储。
59
+ if (process.env.NODE_TEST_CONTEXT) {
60
+ return join(tmpdir(), 'cc-viewer-test', `guard-${process.pid}-${threadId}`);
61
+ }
43
62
  return join(getClaudeConfigDir(), 'cc-viewer');
44
63
  }
45
64
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.6.299",
3
+ "version": "1.6.300",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -22,9 +22,9 @@
22
22
  "pretest": "npm run lint:control-bytes",
23
23
  "pretest:coverage": "npm run lint:control-bytes",
24
24
  "pretest:coverage:html": "npm run lint:control-bytes",
25
- "test": "CCV_LOG_DIR=tmp node --test --test-force-exit",
26
- "test:coverage": "CCV_LOG_DIR=tmp node --test --test-force-exit --experimental-test-coverage --test-coverage-include='server/**/*.js' --test-coverage-include='src/utils/**/*.js' --test-coverage-include='*.js'",
27
- "test:coverage:html": "CCV_LOG_DIR=tmp c8 --reporter=text-summary --reporter=html node --test",
25
+ "test": "CCV_LOG_DIR=tmp CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 node --test --test-force-exit --test-timeout=120000",
26
+ "test:coverage": "CCV_LOG_DIR=tmp CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 node --test --test-force-exit --test-timeout=120000 --experimental-test-coverage --test-coverage-include='server/**/*.js' --test-coverage-include='src/utils/**/*.js' --test-coverage-include='*.js'",
27
+ "test:coverage:html": "CCV_LOG_DIR=tmp CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 c8 --reporter=text-summary --reporter=html node --test --test-force-exit --test-timeout=120000",
28
28
  "prepublishOnly": "npm run build",
29
29
  "electron:dev": "electron electron/main.js",
30
30
  "electron:build": "npm run build && electron-builder",
@@ -16,7 +16,7 @@ import { homedir } from 'node:os';
16
16
  import { fileURLToPath, pathToFileURL } from 'node:url';
17
17
  import { dirname, join, basename } from 'node:path';
18
18
  import { LOG_DIR } from '../findcc.js';
19
- import { assembleStreamMessage, createStreamAssembler, cleanupTempFiles, findRecentLog, isAnthropicApiPath, isMainAgentRequest, rotateLogFile, fingerprintMsg } from './lib/interceptor-core.js';
19
+ import { assembleStreamMessage, createStreamAssembler, cleanupTempFiles, findRecentLog, isAnthropicApiPath, isMainAgentRequest, rotateLogFile, fingerprintMsg, replaceTopLevelModel } from './lib/interceptor-core.js';
20
20
 
21
21
 
22
22
 
@@ -780,15 +780,31 @@ export function setupInterceptor() {
780
780
  }
781
781
  }
782
782
  }
783
- // 3. Model 替换
783
+ // 3. Model 替换 —— 避免对整条 wire body(-c 重启后全量 checkpoint 可达数十 MB)
784
+ // 二次 JSON.parse + 全量 re-stringify:用 L557 已解析的 body 读旧值,对原始
785
+ // 字符串做有界定向替换(唯一非歧义匹配才生效);定位失败回退旧 parse 路径,
786
+ // 最坏退化为现状。注意不能复用 requestEntry.body 重建 wire —— delta 路径
787
+ // (上方 needsCheckpoint=false 分支)已把它的 messages 改成增量切片。
784
788
  if (_activeProfile.activeModel && _fetchOpts?.body) {
785
- try {
786
- const _b = JSON.parse(_fetchOpts.body);
787
- if (_b.model) {
788
- _b.model = _activeProfile.activeModel;
789
- _fetchOpts = { ..._fetchOpts, body: JSON.stringify(_b) };
789
+ const _oldModel = (body && typeof body === 'object') ? body.model : undefined;
790
+ if (_oldModel === _activeProfile.activeModel) {
791
+ // 已是目标 model,跳过(旧路径会原样 re-stringify,对上游等价)
792
+ } else {
793
+ const _replaced = (typeof _fetchOpts.body === 'string' && typeof _oldModel === 'string')
794
+ ? replaceTopLevelModel(_fetchOpts.body, _oldModel, _activeProfile.activeModel)
795
+ : null;
796
+ if (_replaced !== null) {
797
+ _fetchOpts = { ..._fetchOpts, body: _replaced };
798
+ } else {
799
+ try {
800
+ const _b = JSON.parse(_fetchOpts.body);
801
+ if (_b.model) {
802
+ _b.model = _activeProfile.activeModel;
803
+ _fetchOpts = { ..._fetchOpts, body: JSON.stringify(_b) };
804
+ }
805
+ } catch { }
790
806
  }
791
- } catch { }
807
+ }
792
808
  }
793
809
  // 记录 proxy 信息到日志条目
794
810
  requestEntry.proxyProfile = _activeProfile.name;
@@ -4,13 +4,15 @@
4
4
  import { appendFile } from 'node:fs/promises';
5
5
  import { appendFileSync } from 'node:fs';
6
6
 
7
- const HIGH_WATER_MARK = 50 * 1024 * 1024; // 50MB — 超过此值降级为同步写入
7
+ const HIGH_WATER_MARK = 50 * 1024 * 1024; // 50MB — backlog 超过此值降级为同步写入
8
+ const WRITE_CHUNK_BYTES = 8 * 1024 * 1024; // 8MB — 单次 async append 上限,巨条分块让出 FS handle
8
9
 
9
10
  export class AsyncWriteQueue {
10
11
  /**
11
12
  * @param {string|(() => string)} pathOrGetter - 文件路径或返回路径的函数(支持动态路径)
12
13
  * @param {object} [opts]
13
14
  * @param {boolean} [opts.syncMode] - 强制同步模式
15
+ * @param {number} [opts.highWaterMark] - backlog 同步降级阈值(默认 50MB;测试用)
14
16
  */
15
17
  constructor(pathOrGetter, opts = {}) {
16
18
  this._pathOrGetter = pathOrGetter;
@@ -21,6 +23,9 @@ export class AsyncWriteQueue {
21
23
  this._closed = false;
22
24
  this._flushResolvers = []; // resolve() callbacks waiting for flush()
23
25
  this._syncMode = opts.syncMode || !!process.env.CCV_SYNC_WRITES;
26
+ // typeof 防御:字符串 "1000" 能通过 > 0 协转检查,但后续字节比较会退化为字典序
27
+ this._highWaterMark = (typeof opts.highWaterMark === 'number' && opts.highWaterMark > 0)
28
+ ? opts.highWaterMark : HIGH_WATER_MARK;
24
29
  }
25
30
 
26
31
  _getPath() {
@@ -38,13 +43,21 @@ export class AsyncWriteQueue {
38
43
  return;
39
44
  }
40
45
 
41
- if (this._syncMode || this._pendingBytes >= HIGH_WATER_MARK) {
46
+ // 同步降级是 backlog 的内存压力保护,不是按条规则:
47
+ // - 单条超大 buffer(-c 重启后的全量 checkpoint,可达数十 MB)必须走异步 ——
48
+ // 对它 appendFileSync 正是 Windows event loop 卡死点;其内存代价 ≈ 字符串本就在内存。
49
+ // - drain 在途时绝不抢同步(正确性硬约束):_drain 现按 8MB 分块写,appendFileSync
50
+ // 插队会落在分块中间、把条目撕裂进另一条 JSON 内部。又因"队列非空 ⇒ 必已 scheduleDrain
51
+ // ⇒ _draining=true",这条 backlog 同步分支实际只是防御性兜底,正常运行不会触发。
52
+ const byteLen = Buffer.byteLength(data);
53
+ const wouldExceed = this._pendingBytes + byteLen >= this._highWaterMark;
54
+ const oversizedSingle = byteLen >= this._highWaterMark;
55
+ if (this._syncMode || (wouldExceed && !oversizedSingle && !this._draining)) {
42
56
  try { appendFileSync(path, data); } catch {}
43
57
  if (onDone) try { onDone(); } catch {}
44
58
  return;
45
59
  }
46
60
 
47
- const byteLen = Buffer.byteLength(data);
48
61
  this._queue.push({ path, data, onDone });
49
62
  this._pendingBytes += byteLen;
50
63
  this._scheduleDrain();
@@ -108,7 +121,20 @@ export class AsyncWriteQueue {
108
121
  }
109
122
 
110
123
  try {
111
- await appendFile(path, combined);
124
+ // 巨条分块:单次 async append 超过 8MB 时按字节切片顺序写,
125
+ // 避免一次巨型写独占 FS handle(Windows NTFS 上拖垮其它 I/O)。
126
+ // 必须按 Buffer 字节切(而非字符串 slice),否则多字节 UTF-8 字符
127
+ // 跨界会被 per-call 编码撕裂成乱码。顺序 await 保证落盘顺序。
128
+ // Buffer.from 对超大 combined 有一次性内存翻倍(30MB 串 → +30MB Buffer),
129
+ // 循环结束即释放,等价于字符串自身的量级,可接受。
130
+ const buf = Buffer.from(combined, 'utf-8');
131
+ if (buf.length <= WRITE_CHUNK_BYTES) {
132
+ await appendFile(path, buf);
133
+ } else {
134
+ for (let pos = 0; pos < buf.length; pos += WRITE_CHUNK_BYTES) {
135
+ await appendFile(path, buf.subarray(pos, pos + WRITE_CHUNK_BYTES));
136
+ }
137
+ }
112
138
  } catch {}
113
139
  for (const cb of callbacks) {
114
140
  try { cb(); } catch {}
@@ -0,0 +1,54 @@
1
+ // 启动期配置备份 — 2026-06-06 LOG_DIR 整树删除事故防再犯。
2
+ // preferences.json(auth/IM token/偏好)、profile.json(代理热切换)、workspaces.json(工作区注册表)
3
+ // 是无法从日志/会话重建的"静态设置";本模块在 server 启动时把它们备份到 LOG_DIR **之外**
4
+ // 的兄弟目录(默认 ~/.claude/cc-viewer-config-backups/<时间戳>/),滚动保留最近 KEEP 份。
5
+ // 全程 best-effort:任何失败只返回 {ok:false},绝不抛错阻塞启动。
6
+ import { existsSync, mkdirSync, copyFileSync, readdirSync, rmSync, chmodSync } from 'node:fs';
7
+ import { join, dirname } from 'node:path';
8
+ import { LOG_DIR } from '../../findcc.js';
9
+
10
+ const CONFIG_FILES = ['preferences.json', 'profile.json', 'workspaces.json'];
11
+ const KEEP = 10;
12
+ // 备份子目录名:严格时间戳形态。prune 只删匹配此形态的目录,绝不波及其它内容。
13
+ const STAMP_RE = /^\d{8}_\d{6}$/;
14
+
15
+ export function getBackupRoot(logDir = LOG_DIR) {
16
+ return join(dirname(logDir), 'cc-viewer-config-backups');
17
+ }
18
+
19
+ /**
20
+ * 备份 logDir 下的配置文件到 getBackupRoot()/<YYYYMMDD_HHMMSS>/,并滚动清理。
21
+ * @param {string} [logDir] 默认 findcc 的 LOG_DIR(live binding)
22
+ * @param {Date} [now] 注入时钟,便于测试
23
+ * @returns {{ok:boolean, dir?:string, copied?:string[], pruned?:number, error?:string}}
24
+ */
25
+ export function backupConfigs(logDir = LOG_DIR, now = new Date()) {
26
+ try {
27
+ const candidates = CONFIG_FILES.filter((f) => existsSync(join(logDir, f)));
28
+ if (!candidates.length) return { ok: true, copied: [], pruned: 0 };
29
+ const stamp = now.toISOString().replace(/[-:]/g, '').replace('T', '_').slice(0, 15);
30
+ const root = getBackupRoot(logDir);
31
+ const dir = join(root, stamp);
32
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
33
+ const copied = [];
34
+ for (const f of candidates) {
35
+ try {
36
+ copyFileSync(join(logDir, f), join(dir, f));
37
+ // preferences 携带密钥,备份份保持 0600(copyFileSync 跟随 umask,需显式收紧)
38
+ chmodSync(join(dir, f), 0o600);
39
+ copied.push(f);
40
+ } catch { /* 单文件失败不影响其它 */ }
41
+ }
42
+ let pruned = 0;
43
+ try {
44
+ const entries = readdirSync(root).filter((d) => STAMP_RE.test(d)).sort();
45
+ while (entries.length > KEEP) {
46
+ rmSync(join(root, entries.shift()), { recursive: true, force: true });
47
+ pruned++;
48
+ }
49
+ } catch { /* prune 失败不影响本次备份 */ }
50
+ return { ok: true, dir, copied, pruned };
51
+ } catch (err) {
52
+ return { ok: false, error: err?.message || String(err) };
53
+ }
54
+ }
@@ -60,6 +60,20 @@ export function buildChildEnv(id, base = process.env) {
60
60
  * @returns {{ pid:number|undefined, dir:string, outLog:string }}
61
61
  */
62
62
  export function spawnImProcess(id, opts = {}) {
63
+ // ████████ 测试隔离铁闸 L4 —— 绝对不可移除(2026-06-06 数据事故防再犯)████████
64
+ // 单元测试【绝不允许】拉起真实 detached IM worker:
65
+ // 1) detached worker 脱离测试生命周期常驻(PPID=1),测试结束后仍在运行;
66
+ // 2) buildChildEnv 按设计剥离 CCV_*,worker 子链最终把真实 ~/.claude/cc-viewer 当 LOG_DIR;
67
+ // 3) 测试与用户真实 worker 在锁/端口(7050-7099)/配置上互相干扰——
68
+ // 2026-06-06 该链条曾三次把用户 40GB 历史日志整树删除。
69
+ // 注入 spawnImpl 的纯单测(假 spawn)不受影响;确需真实 spawn 的集成测试必须显式
70
+ // CCV_TEST_ALLOW_IM_SPAWN=1 并自行负责完全隔离(私有 CCV_LOG_DIR + 私有端口窗)。
71
+ if (process.env.NODE_TEST_CONTEXT && !opts.spawnImpl && process.env.CCV_TEST_ALLOW_IM_SPAWN !== '1') {
72
+ const dir = imDir(id);
73
+ console.warn(`[im-process-manager] 测试环境拒绝真实 spawn IM worker '${id}'(L4 铁闸;如确需请设 CCV_TEST_ALLOW_IM_SPAWN=1)`);
74
+ return { pid: undefined, dir, outLog: join(dir, 'process.out.log'), blockedByTestGuard: true };
75
+ }
76
+ // ████████████████████████████████████████████████████████████████████████
63
77
  const spawnImpl = opts.spawnImpl || nodeSpawn;
64
78
  const dir = imDir(id);
65
79
  mkdirSync(dir, { recursive: true });
@@ -400,3 +400,43 @@ export function rotateLogFile(currentFile, newFile, maxSize) {
400
400
  } catch { }
401
401
  return { rotated: false };
402
402
  }
403
+
404
+ /**
405
+ * 在原始 JSON 字符串上定向替换顶层 "model" 字段的值,避免对巨型 wire body
406
+ * (`-c` 重启后的全量 checkpoint 请求可达数十 MB)做二次 JSON.parse + 全量 re-stringify。
407
+ *
408
+ * 安全性依据:
409
+ * - JSON 字符串值内的引号必然转义为 \",裸 `"model":"<old>"` 字节序列只能出现在真实结构处;
410
+ * - 候选必须满足成员边界(前一个非空白字符是 `{` 或 `,`);
411
+ * - 顶层 model 恒存在恒命中 → 嵌套对象若有同值 model 键则候选 ≥2 → 返回 null 由调用方
412
+ * 回退 parse/stringify 旧路径(最坏退化为现状,绝不误改)。
413
+ *
414
+ * @param {string} jsonStr - 原始 wire body(紧凑或带单空格的 JSON 字符串)
415
+ * @param {string} oldModel - 当前顶层 model 值(来自已解析的 body.model)
416
+ * @param {string} newModel - 目标 model 值
417
+ * @returns {string|null} 替换后的字符串;无法唯一定位时返回 null(调用方回退)
418
+ */
419
+ export function replaceTopLevelModel(jsonStr, oldModel, newModel) {
420
+ if (typeof jsonStr !== 'string' || typeof oldModel !== 'string' || !oldModel ||
421
+ typeof newModel !== 'string' || !newModel) return null;
422
+ const oldVal = JSON.stringify(oldModel);
423
+ // 覆盖紧凑(JSON.stringify 默认)与冒号后单空格两种序列化形态;其它形态 → 0 候选 → 回退
424
+ const needles = [`"model":${oldVal}`, `"model": ${oldVal}`];
425
+ const candidates = [];
426
+ for (const needle of needles) {
427
+ let idx = jsonStr.indexOf(needle);
428
+ while (idx !== -1) {
429
+ // 成员边界校验:前一个非空白字符必须是 { 或 ,
430
+ let p = idx - 1;
431
+ while (p >= 0 && (jsonStr[p] === ' ' || jsonStr[p] === '\t' || jsonStr[p] === '\n' || jsonStr[p] === '\r')) p--;
432
+ if (p >= 0 && (jsonStr[p] === '{' || jsonStr[p] === ',')) {
433
+ candidates.push({ idx, needle });
434
+ }
435
+ idx = jsonStr.indexOf(needle, idx + 1);
436
+ }
437
+ }
438
+ if (candidates.length !== 1) return null;
439
+ const { idx, needle } = candidates[0];
440
+ const replaced = needle.slice(0, needle.length - oldVal.length) + JSON.stringify(newModel);
441
+ return jsonStr.slice(0, idx) + replaced + jsonStr.slice(idx + needle.length);
442
+ }
@@ -19,6 +19,10 @@ try {
19
19
  console.warn('[SDK] Agent SDK not available:', err.message);
20
20
  }
21
21
 
22
+ // Test seam — inject a fake query() (仿 im-bridge-core.js 的 __setFetchForTests 惯例).
23
+ // 仅供单测注入 fake async-generator,不改任何业务逻辑。
24
+ export function __setQueryForTests(fn) { _query = fn; }
25
+
22
26
  // Interactive tool names — filtered from entries, handled via canUseTool → WS
23
27
  const INTERACTIVE_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode']);
24
28
 
@@ -140,7 +140,7 @@ export function isAnyCcvBusy({ currentPid, busy, portRange, lsofImpl } = {}) {
140
140
  // 却被当显式传值,从而绕过自动检测,让 brew 用户被错误地走 npm 路径升级。
141
141
  //
142
142
  // 返回 status:
143
- // disabled | skipped | latest | major_available | deferred_busy
143
+ // disabled | skipped | skipped_test_context | latest | major_available | deferred_busy
144
144
  // | brew_managed | upgrading_in_background | error
145
145
  export async function checkAndUpdate(options = {}) {
146
146
  const fetchImpl = options.fetchImpl || fetch;
@@ -164,6 +164,23 @@ export async function checkAndUpdate(options = {}) {
164
164
  return { status: 'skipped', currentVersion, remoteVersion: null };
165
165
  }
166
166
 
167
+ // ████████ 测试隔离铁闸 L5 —— 绝对不可移除(2026-06-06 数据事故防再犯)████████
168
+ // 单元测试【绝不允许】发送真实网络请求:起真实 server 的测试,启动链的 30s 定时器
169
+ // (server.js startViewer)会走到这里,真打 registry.npmjs.org,同大版本空闲时甚至
170
+ // 触发 detached `npm install` 真实自更新——测试能改写用户的 cc-viewer 安装。
171
+ // 条件说明:
172
+ // - NODE_TEST_CONTEXT:node:test runner 自动注入,覆盖测试进程及 spread env 子进程;
173
+ // - NODE_ENV==='test' 兜底:覆盖测试用 spawnSync 起的孙进程(NODE_TEST_CONTEXT 跨代
174
+ // 传递不保证,如 branch-server.test.js 的 runScenario);
175
+ // - 注入 fetchImpl 的 updater 单测(假网络)不受影响,走正常逻辑;
176
+ // - 闸位必须在 disabled/skipped 早退【之后】、真实 fetch【之前】:放函数入口会劫持
177
+ // 存量无-fetchImpl 用例对 disabled/skipped 的断言。
178
+ // 守卫单测:test/updater-test-guard.test.js。
179
+ if ((process.env.NODE_TEST_CONTEXT || process.env.NODE_ENV === 'test') && !options.fetchImpl) {
180
+ return { status: 'skipped_test_context', currentVersion, remoteVersion: null };
181
+ }
182
+ // ████████████████████████████████████████████████████████████████████████████
183
+
167
184
  try {
168
185
  const res = await fetchImpl('https://registry.npmjs.org/cc-viewer');
169
186
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -194,6 +194,15 @@ async function events(req, res, parsedUrl, isLocal, deps) {
194
194
  let latestKvCache = null;
195
195
  let latestContextWindow = null;
196
196
  let pushedContextWindow = false;
197
+ // 只记忆最后 K 条 mainAgent 候选原始字符串,Pass 1 结束后 newest-first 结构化校验
198
+ // (isMainAgentEntry),最多 parse K 次。旧逻辑在 onScan 里对每条 mainAgent raw 全量
199
+ // JSON.parse —— `-c` 大会话日志里多个数十 MB 的 checkpoint 会让每次 SSE 连接阻塞
200
+ // event loop 数秒(Windows 卡死主因之一)。
201
+ // ring=3 的容错意义:团队会话末尾常有连续 teammate 伪 mainAgent 条目,单记忆位会被
202
+ // 挤掉真实 mainAgent;子串预过滤的理论误伤(键/值恰为 "teammate" 的真实条目)也由
203
+ // 环内更早候选兜底。
204
+ const MAINAGENT_SCAN_RING = 3;
205
+ const mainAgentRawRing = [];
197
206
 
198
207
  await streamRawEntriesAsync(LOG_FILE, async (raw) => {
199
208
  // 直接发送原始 JSON 字符串,不做 parse/reconstruct/stringify
@@ -221,21 +230,13 @@ async function events(req, res, parsedUrl, isLocal, deps) {
221
230
  since: useIncremental ? sinceParam : undefined,
222
231
  limit: useLimit ? effectiveLimit : undefined,
223
232
  onScan: (raw) => {
224
- // 轻量追踪最新 MainAgent KV-Cache context_window(仅 regex 检测)
225
- if (raw.includes('"mainAgent":true') || raw.includes('"mainAgent": true')) {
226
- try {
227
- const entry = JSON.parse(raw);
228
- if (isMainAgentEntry(entry)) {
229
- const cached = extractCachedContent(entry);
230
- if (cached) latestKvCache = cached;
231
- const usage = entry.response?.body?.usage;
232
- if (usage) {
233
- const contextSize = getContextSizeForModel(entry.body?.model);
234
- const cw = buildContextWindowEvent(usage, contextSize);
235
- if (cw) latestContextWindow = cw;
236
- }
237
- }
238
- } catch { }
233
+ // 只做子串检测 + 入环,不 parse(巨型 checkpoint 逐条 parse 会阻塞 event loop)。
234
+ // teammate 条目恒带 "teammate" 字段,子串预过滤减少环污染;结构化判定留给
235
+ // Pass 1 结束后的 newest-first 校验(isMainAgentEntry),预过滤误伤由环容错。
236
+ if ((raw.includes('"mainAgent":true') || raw.includes('"mainAgent": true')) &&
237
+ !raw.includes('"teammate"')) {
238
+ mainAgentRawRing.push(raw);
239
+ if (mainAgentRawRing.length > MAINAGENT_SCAN_RING) mainAgentRawRing.shift();
239
240
  }
240
241
  },
241
242
  onReady: ({ totalCount, hasMore, oldestTs }) => {
@@ -253,6 +254,27 @@ async function events(req, res, parsedUrl, isLocal, deps) {
253
254
 
254
255
  res.write(`event: load_end\ndata: {}\n\n`);
255
256
 
257
+ // Pass 1 入环的候选 newest-first 校验 + parse(≤K 次)。kv_cache 与 context_window
258
+ // 各自取"最新一条能提供该值的真实 mainAgent"—— 与旧版逐条覆盖语义等价(环深度内)。
259
+ for (let ri = mainAgentRawRing.length - 1; ri >= 0 && (!latestKvCache || !latestContextWindow); ri--) {
260
+ try {
261
+ const entry = JSON.parse(mainAgentRawRing[ri]);
262
+ if (!isMainAgentEntry(entry)) continue;
263
+ if (!latestKvCache) {
264
+ const cached = extractCachedContent(entry);
265
+ if (cached) latestKvCache = cached;
266
+ }
267
+ if (!latestContextWindow) {
268
+ const usage = entry.response?.body?.usage;
269
+ if (usage) {
270
+ const contextSize = getContextSizeForModel(entry.body?.model);
271
+ const cw = buildContextWindowEvent(usage, contextSize);
272
+ if (cw) latestContextWindow = cw;
273
+ }
274
+ }
275
+ } catch { }
276
+ }
277
+
256
278
  // 发送最新 MainAgent 的 KV-Cache 和 context_window
257
279
  if (latestKvCache) {
258
280
  res.write(`event: kv_cache_content\ndata: ${JSON.stringify(latestKvCache)}\n\n`);
@@ -28,6 +28,9 @@ function preferencesGet(req, res, parsedUrl, isLocal, deps) {
28
28
  delete prefs.authByProject;
29
29
  stripImConfigs(prefs); // dingtalk / feishu / … — admin-only, never to a LAN client
30
30
  prefs.logDir = LOG_DIR; // 始终返回当前运行时的日志目录
31
+ // 日志设置出厂默认"继承":键缺失(从未设置过)才注入;显式关闭持久化的是 null(键存在),不覆盖。
32
+ // 虚拟默认 —— 仅注入回包不落盘(GET 不写文件),直接读 preferences.json 的代码看不到该默认。
33
+ if (!('resumeAutoChoice' in prefs)) prefs.resumeAutoChoice = 'continue';
31
34
  // home-friendly 展示形态:设了 CLAUDE_CONFIG_DIR 的用户看到真实路径,默认用户看到 "~/.claude"
32
35
  // join() 而非字符串拼接,避免 Windows 分隔符不匹配导致比较失败
33
36
  const _cDir = getClaudeConfigDir();
@@ -111,6 +114,8 @@ function preferencesPost(req, res, parsedUrl, isLocal, deps) {
111
114
  delete prefs.authByProject;
112
115
  stripImConfigs(prefs);
113
116
  prefs.logDir = LOG_DIR;
117
+ // 与 GET 一致:回显里补齐 resumeAutoChoice 虚拟默认(已在上方落盘,文件不含该默认值)
118
+ if (!('resumeAutoChoice' in prefs)) prefs.resumeAutoChoice = 'continue';
114
119
  res.writeHead(200, { 'Content-Type': 'application/json' });
115
120
  res.end(JSON.stringify(prefs));
116
121
  } catch {
package/server/server.js CHANGED
@@ -77,6 +77,7 @@ import { loadPlugins, runWaterfallHook, runParallelHook } from './lib/plugin-loa
77
77
  import { CONTEXT_WINDOW_FILE, readModelContextSize } from './lib/context-watcher.js';
78
78
  import { watchLogFile, startWatching, unwatchAll, sendEventToClients, sendToClients } from './lib/log-watcher.js';
79
79
  import { cleanupExtractCache } from './lib/jsonl-archive.js';
80
+ import { backupConfigs } from './lib/config-backup.js';
80
81
  import { createBackpressureGate } from './lib/ws-backpressure.js';
81
82
 
82
83
 
@@ -804,6 +805,10 @@ export async function startViewer() {
804
805
  // 清理过期解压缓存(fire-and-forget;任何错误吞掉)
805
806
  setImmediate(() => { try { cleanupExtractCache(); } catch { /* ignore */ } });
806
807
 
808
+ // 启动期配置备份:preferences/profile/workspaces → LOG_DIR 外的 cc-viewer-config-backups/
809
+ // (滚动留 10 份)。2026-06-06 事故:配置随 LOG_DIR 整树丢失后无处可恢复。fire-and-forget。
810
+ setImmediate(() => { try { backupConfigs(); } catch { /* ignore */ } });
811
+
807
812
  // 启动时清理磁盘上 ASK_HOOK_TIMEOUT_MS 之前的 ask 条目(兜底防泄漏)。
808
813
  // 内存 Map 不 hydrate:旧 res 已死、新 ask-bridge 重连同 toolUseId 会自动复用槽位
809
814
  // (server.js 已有"旧 res 已断 → 复用"分支),无需在这里主动重建内存态。
@@ -1883,6 +1888,10 @@ export function broadcastTurnEnd(sessionId = null, ts = Date.now()) {
1883
1888
  // 流式状态 SSE 推送定时器:检测 streamingState 变化并广播给所有客户端。
1884
1889
  // rising-edge → turn_end flush 由 _observeStreamingTick 统一处理。
1885
1890
  let _streamingStatusTimer = null;
1891
+ // 启动后 30s 的更新检查 timer 句柄。必须可清理:
1892
+ // - .unref() 防止它把事件循环 keep-alive 30s(测试进程靠 --test-force-exit 兜底是时序侥幸);
1893
+ // - _doStop 里 clearTimeout 防止 stop/start 循环(Electron tab / 测试)泄漏多个 pending 检查。
1894
+ let _updateCheckTimer = null;
1886
1895
  function startStreamingStatusTimer() {
1887
1896
  if (_streamingStatusTimer) return;
1888
1897
  _streamingStatusTimer = setInterval(() => {
@@ -1957,6 +1966,10 @@ async function _doStop() {
1957
1966
  clearInterval(_streamingStatusTimer);
1958
1967
  _streamingStatusTimer = null;
1959
1968
  }
1969
+ if (_updateCheckTimer) {
1970
+ clearTimeout(_updateCheckTimer);
1971
+ _updateCheckTimer = null;
1972
+ }
1960
1973
  resetStreamingState();
1961
1974
  // 清 interceptor 的 live-port,避免 stop/start 循环(Electron tab 切换 / 测试)间隙内
1962
1975
  // 早期请求向已关闭的端口 POST 丢包。新 startViewer 的 listen 回调会再次 setLivePort
@@ -2036,7 +2049,9 @@ if (!isWorkspaceMode) {
2036
2049
  // 为什么是 30s 而非 3s:空闲/忙判断的核心是 `clients.length`(SSE 已连) + PTY + SDK。
2037
2050
  // 3s 时大多数 client 还没连上 → busy 恒 false → 升级照打断用户。30s 给"活跃会话"留出进入窗口。
2038
2051
  // 同大版本直接后台 detached npm install(不阻塞事件循环);跨大版本 / 忙时 → 仅广播 banner,用户下次启动再升。
2039
- setTimeout(async () => {
2052
+ // 句柄必须保存:_doStop 要 clearTimeout(stop/start 循环防泄漏);.unref() keep-alive。
2053
+ // 测试侧三重防护:此处 unref + updater.js 的 L5 铁闸 + npm test 脚本 DISABLE_NONESSENTIAL_TRAFFIC。
2054
+ _updateCheckTimer = setTimeout(async () => {
2040
2055
  let ptyRunning = false;
2041
2056
  try {
2042
2057
  const { getPtyState } = await import('./pty-manager.js');
@@ -2061,6 +2076,7 @@ if (!isWorkspaceMode) {
2061
2076
  }
2062
2077
  } catch { /* update check 失败静默 */ }
2063
2078
  }, 30_000);
2079
+ _updateCheckTimer.unref();
2064
2080
  }).catch(err => {
2065
2081
  console.error('Failed to start CC Viewer:', err);
2066
2082
  });