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/cli.js +0 -73
- package/dist/assets/{App-CCSG-Uk4.js → App-DO_8O7_4.js} +1 -1
- package/dist/assets/{MdxEditorPanel-CEwcZJSb.js → MdxEditorPanel-kMkQ3-Hd.js} +1 -1
- package/dist/assets/{Mobile-EHy38ALw.js → Mobile-DYGGuCPh.js} +1 -1
- package/dist/assets/{index-DWYtIFvu.js → index-BXb1GO0R.js} +2 -2
- package/dist/assets/seqResourceLoaders-BJ_EK_tf.js +2 -0
- package/dist/index.html +1 -1
- package/findcc.js +19 -0
- package/package.json +4 -4
- package/server/interceptor.js +24 -8
- package/server/lib/async-write-queue.js +30 -4
- package/server/lib/config-backup.js +54 -0
- package/server/lib/im-process-manager.js +14 -0
- package/server/lib/interceptor-core.js +40 -0
- package/server/lib/sdk-manager.js +4 -0
- package/server/lib/updater.js +18 -1
- package/server/routes/events.js +37 -15
- package/server/routes/preferences.js +5 -0
- package/server/server.js +17 -1
- package/dist/assets/seqResourceLoaders-DYyvUcvx.js +0 -2
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-
|
|
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.
|
|
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",
|
package/server/interceptor.js
CHANGED
|
@@ -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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/server/lib/updater.js
CHANGED
|
@@ -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}`);
|
package/server/routes/events.js
CHANGED
|
@@ -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
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
});
|