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.
- package/cli.js +26 -87
- package/dist/assets/{App-CCSG-Uk4.js → App-CsXuZvCc.js} +1 -1
- package/dist/assets/{MdxEditorPanel-CEwcZJSb.js → MdxEditorPanel-B-y66Flk.js} +1 -1
- package/dist/assets/{Mobile-EHy38ALw.js → Mobile-O0bfec7C.js} +1 -1
- package/dist/assets/index-DpIkVZv8.js +2 -0
- package/dist/assets/seqResourceLoaders-DpUrNd29.js +2 -0
- package/dist/index.html +1 -1
- package/findcc.js +19 -0
- package/package.json +4 -4
- package/server/i18n.js +56 -36
- package/server/interceptor.js +24 -8
- package/server/lib/async-write-queue.js +30 -4
- package/server/lib/base-path.js +34 -0
- 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/log-watcher.js +7 -1
- package/server/lib/sdk-manager.js +4 -0
- package/server/lib/term-signals.js +80 -0
- package/server/lib/updater.js +18 -1
- package/server/pty-manager.js +9 -1
- package/server/routes/events.js +37 -15
- package/server/routes/misc.js +5 -1
- package/server/routes/preferences.js +5 -0
- package/server/scratch-pty-manager.js +6 -1
- package/server/server.js +66 -33
- package/dist/assets/index-DWYtIFvu.js +0 -2
- package/dist/assets/seqResourceLoaders-DYyvUcvx.js +0 -2
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`);
|
package/server/routes/misc.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
680
|
-
//
|
|
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
|
|
731
|
-
if (
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
729
|
+
const injectBase = normalizeBasePath(process.env.CCV_BASE_PATH);
|
|
730
|
+
if (injectBase) {
|
|
731
|
+
const escapedBase = injectBase.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1063
|
-
|
|
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
|
-
|
|
1924
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2088
|
-
|
|
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
|
}
|