cc-viewer 1.6.299 → 1.6.301

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.
@@ -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`);
@@ -1,6 +1,7 @@
1
1
  // Miscellaneous small routes (moved verbatim from server.js handleRequest).
2
2
  import { getUserProfile } from '../lib/user-profile.js';
3
3
  import { runWaterfallHook } from '../lib/plugin-loader.js';
4
+ import { normalizeBasePath } from '../lib/base-path.js';
4
5
 
5
6
  async function userProfile(req, res) {
6
7
  const profile = await getUserProfile();
@@ -10,7 +11,10 @@ async function userProfile(req, res) {
10
11
 
11
12
  async function localUrl(req, res, parsedUrl, isLocal, deps) {
12
13
  const localIp = deps.getLocalIp();
13
- const defaultUrl = `${deps.protocol}://${localIp}:${deps.actualPort}?token=${deps.ACCESS_TOKEN}`;
14
+ // 反代子路径部署时分享/二维码 URL 也要带前缀,否则扫码绕过代理直连源站端口(与
15
+ // server.startedNetwork 启动打印保持一致)。未设 CCV_BASE_PATH 时为空串,行为不变。
16
+ const basePath = normalizeBasePath(process.env.CCV_BASE_PATH);
17
+ const defaultUrl = `${deps.protocol}://${localIp}:${deps.actualPort}${basePath}?token=${deps.ACCESS_TOKEN}`;
14
18
  const hookResult = await runWaterfallHook('localUrl', { url: defaultUrl, ip: localIp, port: deps.actualPort, token: deps.ACCESS_TOKEN });
15
19
  res.writeHead(200, { 'Content-Type': 'application/json' });
16
20
  res.end(JSON.stringify({ url: hookResult.url }));
@@ -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 {
@@ -4,6 +4,7 @@ import { chmodSync, statSync } from 'node:fs';
4
4
  import { platform, arch, homedir } from 'node:os';
5
5
  import { createRequire } from 'node:module';
6
6
  import { prepareEmbeddedShellSpawn } from './lib/terminal-env.js';
7
+ import { killPtyTree } from './lib/term-signals.js';
7
8
 
8
9
  const __filename = fileURLToPath(import.meta.url);
9
10
  const __dirname = dirname(__filename);
@@ -219,7 +220,11 @@ export function killScratch(id) {
219
220
  flushBatch(s);
220
221
  s.batchBuffer = '';
221
222
  s.batchScheduled = false;
222
- try { s.ptyProcess.kill(); } catch { }
223
+ // pty-manager.killPty 同款:win32 taskkill /T 收割 ConPTY 树,
224
+ // 绕开 node-pty kill 的同步挂起问题;非 Windows 走原路径。
225
+ if (!killPtyTree(s.ptyProcess.pid)) {
226
+ try { s.ptyProcess.kill(); } catch { }
227
+ }
223
228
  s.ptyProcess = null;
224
229
  }
225
230
  // 显式 kill 后整条记录清掉,监听器一并丢弃(前端 ws.close 也会清)
package/server/server.js CHANGED
@@ -77,6 +77,9 @@ 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';
81
+ import { normalizeBasePath, validateBasePath, stripBasePath } from './lib/base-path.js';
82
+ import { createHardenedCleanup } from './lib/term-signals.js';
80
83
  import { createBackpressureGate } from './lib/ws-backpressure.js';
81
84
 
82
85
 
@@ -566,12 +569,13 @@ async function handleRequest(req, res) {
566
569
  let url = parsedUrl.pathname;
567
570
 
568
571
  // CCV_BASE_PATH reverse proxy: strip prefix at TOP so API/WS/static/SPA
569
- // all work with original unprefixed paths. basePath normalized.
570
- const bpRaw = process.env.CCV_BASE_PATH || '';
571
- const bp = bpRaw && bpRaw !== '/' ? bpRaw.replace(/\/?$/, '/') : '';
572
- if (bp && url.startsWith(bp)) {
573
- url = url.slice(bp.length) || '/';
574
- }
572
+ // all work with original unprefixed paths. 剥离后必须写回 parsedUrl.pathname ——
573
+ // dispatch()(routes/_dispatch.js)与多个 handler(files-content/ask-perm/im)直读
574
+ // parsedUrl.pathname 做路由匹配和偏移 slice,不写回则前缀下全部 /api/* SSE /events
575
+ // 命不中、落 SPA fallback(PR #108 遗留 P0)。searchParams 不受 pathname 赋值影响。
576
+ const bp = normalizeBasePath(process.env.CCV_BASE_PATH);
577
+ url = stripBasePath(url, bp);
578
+ parsedUrl.pathname = url;
575
579
  const method = req.method;
576
580
 
577
581
  // WebSocket 路径不处理,交给 upgrade 事件
@@ -676,14 +680,9 @@ async function handleRequest(req, res) {
676
680
 
677
681
  // 静态文件服务
678
682
  if (method === 'GET') {
679
- const rawBase = process.env.CCV_BASE_PATH || '';
680
- // Normalize to ensure trailing slash; prevents /proxy/ws from
681
- // incorrectly matching /proxy/ws-other due to startsWith ambiguity.
682
- const basePath = rawBase && rawBase !== '/' ? rawBase.replace(/\/?$/, '/') : '';
683
+ // basePath 已在 handleRequest 顶部统一剥离,这里不可再剥——否则 /proxy/proxy/x
684
+ // 这类路径会被双重剥离。
683
685
  let filePath = url;
684
- if (basePath && url.startsWith(basePath)) {
685
- filePath = url.slice(basePath.length) || '/';
686
- }
687
686
  if (filePath === '/') filePath = '/index.html';
688
687
  // 去掉 query string
689
688
  filePath = filePath.split('?')[0];
@@ -727,12 +726,12 @@ async function handleRequest(req, res) {
727
726
  html = html.replace(/<html([^>]*?)data-theme="[^"]*"/, `<html$1data-theme="${themeColor}"`);
728
727
  // 运行时注入 <base> 标签:当 CCV_BASE_PATH 设置为非空非根路径时,
729
728
  // 使浏览器将所有相对 URL 解析到代理子路径下。配合 Vite base='' 输出相对路径。
730
- const basePath = process.env.CCV_BASE_PATH || '';
731
- if (basePath && basePath !== '/') {
732
- const safeBase = basePath.replace(/\/?$/, '/');
733
- const escapedBase = safeBase.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
734
- const jsSafeBase = safeBase.replace(/"/g, '"').replace(/<\//g, '<\/');
735
- html = html.replace(/<head[^>]*>/i, m => m + `<base href="${escapedBase}"><script>window.__CCV_BASE_PATH__="${jsSafeBase}"</script>`);
729
+ const injectBase = normalizeBasePath(process.env.CCV_BASE_PATH);
730
+ if (injectBase) {
731
+ const escapedBase = injectBase.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
732
+ // JS 双引号字符串转义:\ \\、" \"、</ → <\/(防 </script> 提前闭合)
733
+ const jsSafeBase = injectBase.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/<\//g, '<\\/');
734
+ html = html.replace(/<head[^>]*>/i, m => m + `<base href="${escapedBase}"><script>window.__CCV_BASE_PATH__="${jsSafeBase}"</script>`);
736
735
  }
737
736
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
738
737
  res.end(html);
@@ -804,6 +803,10 @@ export async function startViewer() {
804
803
  // 清理过期解压缓存(fire-and-forget;任何错误吞掉)
805
804
  setImmediate(() => { try { cleanupExtractCache(); } catch { /* ignore */ } });
806
805
 
806
+ // 启动期配置备份:preferences/profile/workspaces → LOG_DIR 外的 cc-viewer-config-backups/
807
+ // (滚动留 10 份)。2026-06-06 事故:配置随 LOG_DIR 整树丢失后无处可恢复。fire-and-forget。
808
+ setImmediate(() => { try { backupConfigs(); } catch { /* ignore */ } });
809
+
807
810
  // 启动时清理磁盘上 ASK_HOOK_TIMEOUT_MS 之前的 ask 条目(兜底防泄漏)。
808
811
  // 内存 Map 不 hydrate:旧 res 已死、新 ask-bridge 重连同 toolUseId 会自动复用槽位
809
812
  // (server.js 已有"旧 res 已断 → 复用"分支),无需在这里主动重建内存态。
@@ -872,17 +875,25 @@ export async function startViewer() {
872
875
  if (_prefs.lang) setLang(_prefs.lang);
873
876
  }
874
877
  } catch { /* 读 prefs 失败就保持默认语言 */ }
878
+ // CCV_BASE_PATH 配置校验:缺前导 '/' 时剥离静默失效(startsWith 永不命中),
879
+ // 启动期告警一次。放在 setLang 之后,告警语言才跟随用户配置。
880
+ {
881
+ const _bpCheck = validateBasePath(process.env.CCV_BASE_PATH);
882
+ if (_bpCheck.warning) console.warn(t(_bpCheck.warning, { value: process.env.CCV_BASE_PATH }));
883
+ }
875
884
  // interceptor.js runs in this same process (via proxy.js → setupInterceptor).
876
885
  // Inject live-port via module-level setter instead of process.env to avoid
877
886
  // polluting env of child_process.spawn descendants (Bash tools / MCP / Electron tabs).
878
887
  setLivePort(port, serverProtocol);
879
- const url = `${serverProtocol}://127.0.0.1:${port}`;
888
+ // 自动打开/serverStarted hook 用的 URL 也要带反代前缀(与启动打印一致)
889
+ const url = `${serverProtocol}://127.0.0.1:${port}${normalizeBasePath(process.env.CCV_BASE_PATH)}`;
880
890
  if (!isCliMode) {
881
891
  console.error(t('server.started'));
882
- console.error(t('server.startedLocal', { protocol: serverProtocol, port }));
892
+ const _bp = normalizeBasePath(process.env.CCV_BASE_PATH);
893
+ console.error(t('server.startedLocal', { protocol: serverProtocol, port, basePath: _bp }));
883
894
  const _ips = getAllLocalIps();
884
895
  for (const _ip of _ips) {
885
- console.error(t('server.startedNetwork', { protocol: serverProtocol, ip: _ip, port, token: ACCESS_TOKEN }));
896
+ console.error(t('server.startedNetwork', { protocol: serverProtocol, ip: _ip, port, basePath: _bp, token: ACCESS_TOKEN }));
886
897
  }
887
898
  if (authConfig.enabled) {
888
899
  if (authConfig.password === '') console.error(t('server.passwordEmptyWarn'));
@@ -1059,12 +1070,8 @@ async function setupTerminalWebSocket(httpServer) {
1059
1070
 
1060
1071
  httpServer.on('upgrade', (req, socket, head) => {
1061
1072
  const wsUrl = new URL(req.url, `${serverProtocol}://${req.headers.host}`);
1062
- let pathname = wsUrl.pathname;
1063
- const bpRaw = process.env.CCV_BASE_PATH || '';
1064
- const wsBp = bpRaw && bpRaw !== '/' ? bpRaw.replace(/\/?$/, '/') : '';
1065
- if (wsBp && pathname.startsWith(wsBp)) {
1066
- pathname = '/' + pathname.slice(wsBp.length);
1067
- }
1073
+ // upgrade 不经 handleRequest,basePath 需独立剥离(与 HTTP 段同走统一函数)
1074
+ let pathname = stripBasePath(wsUrl.pathname, normalizeBasePath(process.env.CCV_BASE_PATH));
1068
1075
  // 与 HTTP 一致的鉴权(此前 WS upgrade 完全不校验 token,远程终端实为无门禁——本次堵洞)。
1069
1076
  // 在此显式计算 isLocal(与 handleRequest 同款三态判断),WS 视作非 HTML 请求。
1070
1077
  const wsRemoteIp = req.socket.remoteAddress;
@@ -1883,6 +1890,10 @@ export function broadcastTurnEnd(sessionId = null, ts = Date.now()) {
1883
1890
  // 流式状态 SSE 推送定时器:检测 streamingState 变化并广播给所有客户端。
1884
1891
  // rising-edge → turn_end flush 由 _observeStreamingTick 统一处理。
1885
1892
  let _streamingStatusTimer = null;
1893
+ // 启动后 30s 的更新检查 timer 句柄。必须可清理:
1894
+ // - .unref() 防止它把事件循环 keep-alive 30s(测试进程靠 --test-force-exit 兜底是时序侥幸);
1895
+ // - _doStop 里 clearTimeout 防止 stop/start 循环(Electron tab / 测试)泄漏多个 pending 检查。
1896
+ let _updateCheckTimer = null;
1886
1897
  function startStreamingStatusTimer() {
1887
1898
  if (_streamingStatusTimer) return;
1888
1899
  _streamingStatusTimer = setInterval(() => {
@@ -1920,8 +1931,20 @@ async function _doStop() {
1920
1931
  _lastCliActive = false;
1921
1932
  // Tear down all IM bridge connections so a stop/start cycle (Electron tab switch, tests) never
1922
1933
  // leaks a second WS to the same app. Idempotent + swallows errors.
1923
- try { await imCore.stopAll(); } catch { }
1924
- try { await Promise.race([runParallelHook('serverStopping'), new Promise(r => setTimeout(r, 3000))]); } catch { }
1934
+ // IM teardown + serverStopping hook 共用一个 3s 总预算(保持串行语义):
1935
+ // Windows IM bridge WS teardown 挂住会卡死整条退出链(原本裸 await
1936
+ // "Ctrl+C 完全无反应"的 B 类成因);两段若各自 3s race 串行最坏 6s,会越过
1937
+ // cleanup watchdog(5s) 截断其后的 temp jsonl rename(用户数据)——合并为单预算
1938
+ // 保证 teardown ≤3s,watchdog 前始终留出 rename 余量。超时后控制流顺序继续。
1939
+ try {
1940
+ await Promise.race([
1941
+ (async () => {
1942
+ try { await imCore.stopAll(); } catch { }
1943
+ await runParallelHook('serverStopping');
1944
+ })(),
1945
+ new Promise(r => setTimeout(r, 3000)),
1946
+ ]);
1947
+ } catch { }
1925
1948
  // 如果用户未做选择,将临时文件转为正式文件
1926
1949
  if (_resumeState && _resumeState.tempFile) {
1927
1950
  try {
@@ -1957,6 +1980,10 @@ async function _doStop() {
1957
1980
  clearInterval(_streamingStatusTimer);
1958
1981
  _streamingStatusTimer = null;
1959
1982
  }
1983
+ if (_updateCheckTimer) {
1984
+ clearTimeout(_updateCheckTimer);
1985
+ _updateCheckTimer = null;
1986
+ }
1960
1987
  resetStreamingState();
1961
1988
  // 清 interceptor 的 live-port,避免 stop/start 循环(Electron tab 切换 / 测试)间隙内
1962
1989
  // 早期请求向已关闭的端口 POST 丢包。新 startViewer 的 listen 回调会再次 setLivePort
@@ -2036,7 +2063,9 @@ if (!isWorkspaceMode) {
2036
2063
  // 为什么是 30s 而非 3s:空闲/忙判断的核心是 `clients.length`(SSE 已连) + PTY + SDK。
2037
2064
  // 3s 时大多数 client 还没连上 → busy 恒 false → 升级照打断用户。30s 给"活跃会话"留出进入窗口。
2038
2065
  // 同大版本直接后台 detached npm install(不阻塞事件循环);跨大版本 / 忙时 → 仅广播 banner,用户下次启动再升。
2039
- setTimeout(async () => {
2066
+ // 句柄必须保存:_doStop 要 clearTimeout(stop/start 循环防泄漏);.unref() keep-alive。
2067
+ // 测试侧三重防护:此处 unref + updater.js 的 L5 铁闸 + npm test 脚本 DISABLE_NONESSENTIAL_TRAFFIC。
2068
+ _updateCheckTimer = setTimeout(async () => {
2040
2069
  let ptyRunning = false;
2041
2070
  try {
2042
2071
  const { getPtyState } = await import('./pty-manager.js');
@@ -2061,6 +2090,7 @@ if (!isWorkspaceMode) {
2061
2090
  }
2062
2091
  } catch { /* update check 失败静默 */ }
2063
2092
  }, 30_000);
2093
+ _updateCheckTimer.unref();
2064
2094
  }).catch(err => {
2065
2095
  console.error('Failed to start CC Viewer:', err);
2066
2096
  });
@@ -2084,6 +2114,9 @@ function handleExit() {
2084
2114
  if (!globalThis._ccvServerSignalsRegistered) {
2085
2115
  globalThis._ccvServerSignalsRegistered = true;
2086
2116
  process.on('exit', handleExit);
2087
- process.on('SIGINT', () => { stopViewer().finally(() => process.exit()); });
2088
- process.on('SIGTERM', () => { stopViewer().finally(() => process.exit()); });
2117
+ // hardened:watchdog 5s 强退 + 重复触发立退(防 Windows 上 stopViewer 内部
2118
+ // await 挂住导致 .finally(exit) 永不执行 = Ctrl+C 完全无反应)。
2119
+ const _hardenedStop = createHardenedCleanup({ doCleanup: () => stopViewer() });
2120
+ process.on('SIGINT', _hardenedStop);
2121
+ process.on('SIGTERM', _hardenedStop);
2089
2122
  }