cc-viewer 1.6.294 → 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/cli.js +7 -2
- package/dist/assets/App-BeCGow-I.js +2 -0
- package/dist/assets/{MdxEditorPanel-B8xrlDZJ.js → MdxEditorPanel-D52b5qxi.js} +1 -1
- package/dist/assets/{Mobile-fsi8-Lpb.js → Mobile-8fflztx7.js} +1 -1
- package/dist/assets/index-DtpelJc4.js +2 -0
- package/dist/assets/seqResourceLoaders-DM-48tr-.js +2 -0
- package/dist/index.html +1 -1
- package/findcc.js +3 -3
- package/package.json +1 -1
- package/server/i18n.js +224 -8
- package/server/interceptor.js +23 -19
- package/server/lib/adapters/dingtalk-adapter.js +62 -0
- package/server/lib/adapters/discord-adapter.js +35 -0
- package/server/lib/adapters/feishu-adapter.js +37 -0
- package/server/lib/ask-store.js +19 -90
- package/server/lib/async-file-lock.js +123 -0
- package/server/lib/async-write-queue.js +131 -0
- package/server/lib/git-diff.js +4 -1
- package/server/lib/im-bridge-core.js +119 -14
- package/server/lib/im-config.js +11 -6
- package/server/lib/im-process-manager.js +1 -1
- package/server/lib/jsonl-archive.js +0 -1
- package/server/lib/log-management.js +46 -99
- package/server/lib/log-stream.js +102 -8
- package/server/lib/log-watcher.js +231 -178
- package/server/lib/plugin-manager.js +1 -1
- package/server/lib/updater.js +4 -2
- package/server/pty-manager.js +1 -1
- package/server/routes/ask-perm.js +2 -2
- package/server/routes/dingtalk.js +2 -0
- package/server/routes/events.js +3 -3
- package/server/routes/files-fs.js +4 -4
- package/server/routes/logs.js +5 -5
- package/server/routes/project-meta.js +18 -1
- package/server/routes/workspaces.js +10 -13
- package/server/server.js +33 -25
- package/server/workspace-registry.js +26 -72
- package/dist/assets/App-C66LoBEz.js +0 -2
- package/dist/assets/index-BTZqk5O5.js +0 -2
- package/dist/assets/seqResourceLoaders-6k4uXcNn.js +0 -2
package/server/lib/updater.js
CHANGED
|
@@ -107,7 +107,7 @@ export function isAnyCcvBusy({ currentPid, busy, portRange, lsofImpl } = {}) {
|
|
|
107
107
|
|
|
108
108
|
const [start, end] = Array.isArray(portRange) && portRange.length === 2 ? portRange : [7008, 7099];
|
|
109
109
|
const pid = typeof currentPid === 'number' ? currentPid : process.pid;
|
|
110
|
-
const runLsof = lsofImpl || ((cmd) => execSync(cmd, { timeout: 2000, encoding: 'utf-8' }));
|
|
110
|
+
const runLsof = lsofImpl || ((cmd) => execSync(cmd, { timeout: 2000, encoding: 'utf-8', windowsHide: true }));
|
|
111
111
|
|
|
112
112
|
try {
|
|
113
113
|
const out = String(runLsof(`lsof -iTCP:${start}-${end} -sTCP:LISTEN -P -n -Fp`));
|
|
@@ -216,7 +216,9 @@ export async function checkAndUpdate(options = {}) {
|
|
|
216
216
|
const child = spawnImpl(
|
|
217
217
|
'npm',
|
|
218
218
|
['install', '-g', `cc-viewer@${remoteVersion}`, '--no-audit', '--no-fund'],
|
|
219
|
-
|
|
219
|
+
// windowsHide:Windows 下 shell 模式经 cmd.exe 跑 npm.cmd(console-subsystem),
|
|
220
|
+
// 不隐藏会在后台更新期间常驻一个可见控制台窗口;POSIX 上为 no-op。
|
|
221
|
+
{ detached: true, stdio: 'ignore', shell: process.platform === 'win32', windowsHide: true }
|
|
220
222
|
);
|
|
221
223
|
if (child && typeof child.unref === 'function') child.unref();
|
|
222
224
|
} catch (err) {
|
package/server/pty-manager.js
CHANGED
|
@@ -178,7 +178,7 @@ export async function spawnClaude(proxyPort, cwd, extraArgs = [], claudePath = n
|
|
|
178
178
|
if (process.versions.electron) {
|
|
179
179
|
const { execSync } = await import('node:child_process');
|
|
180
180
|
try {
|
|
181
|
-
nodePath = execSync(process.platform === 'win32' ? 'where node' : 'which node', { encoding: 'utf-8' }).trim();
|
|
181
|
+
nodePath = execSync(process.platform === 'win32' ? 'where node' : 'which node', { encoding: 'utf-8', windowsHide: true }).trim();
|
|
182
182
|
if (process.platform === 'win32') nodePath = nodePath.split('\n')[0].trim();
|
|
183
183
|
} catch {
|
|
184
184
|
nodePath = process.platform === 'win32' ? 'node' : '/usr/local/bin/node';
|
|
@@ -223,7 +223,7 @@ function askHook(req, res, parsedUrl, isLocal, deps) {
|
|
|
223
223
|
// Phase 3: short-poll handoff endpoint. ask-bridge GET /api/ask-hook/:id/result?wait=30000
|
|
224
224
|
// 在 wait ms 内若答案/cancel 到达 → 立即返;否则返 204 让 client 重发。
|
|
225
225
|
// 内存有 entry → 注册 listener;内存无 → 查 disk consume(server 重启场景)。
|
|
226
|
-
function askHookResult(req, res, parsedUrl, isLocal, deps) {
|
|
226
|
+
async function askHookResult(req, res, parsedUrl, isLocal, deps) {
|
|
227
227
|
const url = parsedUrl.pathname;
|
|
228
228
|
try {
|
|
229
229
|
// URL 形如 /api/ask-hook/<id>/result?wait=30000;id 受白名单约束(与 POST 同源)
|
|
@@ -242,7 +242,7 @@ function askHookResult(req, res, parsedUrl, isLocal, deps) {
|
|
|
242
242
|
// 用 consumeIfFinal 单次 withLock 内判 status 决定是否 delete —— 旧设计的
|
|
243
243
|
// "consume + 若 pending 再 setEntry 写回" 两段是 race window:中间被 markAnswered 命中后,
|
|
244
244
|
// setEntry 走 status guard 已经不会覆盖;但不删的 pending 也无须重写一遍。
|
|
245
|
-
const diskEntry = askStoreConsumeIfFinal(id);
|
|
245
|
+
const diskEntry = await askStoreConsumeIfFinal(id);
|
|
246
246
|
if (diskEntry && diskEntry.status === 'answered') {
|
|
247
247
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
248
248
|
res.end(JSON.stringify({ answers: diskEntry.answers || {} }));
|
|
@@ -80,6 +80,8 @@ function dingtalkConfigPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
80
80
|
allowStaffIds: incoming.allowStaffIds,
|
|
81
81
|
maxChunkChars: incoming.maxChunkChars,
|
|
82
82
|
blockOnSkipPermissions: incoming.blockOnSkipPermissions,
|
|
83
|
+
ackCard: incoming.ackCard,
|
|
84
|
+
cardTemplateId: incoming.cardTemplateId,
|
|
83
85
|
});
|
|
84
86
|
// 驱动进程管理器(替代旧的在进程 reloadBridge):启用→重启 worker,停用→停 worker。
|
|
85
87
|
try {
|
package/server/routes/events.js
CHANGED
|
@@ -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 => {
|
|
@@ -614,7 +614,7 @@ function openFile(req, res, parsedUrl, isLocal, deps) {
|
|
|
614
614
|
if (plat === 'darwin') {
|
|
615
615
|
execFile('open', [fullPath], () => {});
|
|
616
616
|
} else if (plat === 'win32') {
|
|
617
|
-
execFile('cmd.exe', ['/c', 'start', '', fullPath], () => {});
|
|
617
|
+
execFile('cmd.exe', ['/c', 'start', '', fullPath], { windowsHide: true }, () => {});
|
|
618
618
|
} else {
|
|
619
619
|
execFile('xdg-open', [fullPath], () => {});
|
|
620
620
|
}
|
|
@@ -842,7 +842,7 @@ function createDir(req, res, parsedUrl, isLocal, deps) {
|
|
|
842
842
|
function openLogDir(req, res) {
|
|
843
843
|
const dir = LOG_FILE ? dirname(LOG_FILE) : LOG_DIR;
|
|
844
844
|
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
|
|
845
|
-
execFile(cmd, [dir], () => {});
|
|
845
|
+
execFile(cmd, [dir], { windowsHide: true }, () => {});
|
|
846
846
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
847
847
|
res.end(JSON.stringify({ ok: true, dir }));
|
|
848
848
|
}
|
|
@@ -851,7 +851,7 @@ function openProfileDir(req, res) {
|
|
|
851
851
|
const dir = dirname(PROFILE_PATH);
|
|
852
852
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
853
853
|
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
|
|
854
|
-
execFile(cmd, [dir], () => {});
|
|
854
|
+
execFile(cmd, [dir], { windowsHide: true }, () => {});
|
|
855
855
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
856
856
|
res.end(JSON.stringify({ ok: true, dir }));
|
|
857
857
|
}
|
|
@@ -859,7 +859,7 @@ function openProfileDir(req, res) {
|
|
|
859
859
|
function openProjectDir(req, res) {
|
|
860
860
|
const dir = process.env.CCV_PROJECT_DIR || process.cwd();
|
|
861
861
|
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
|
|
862
|
-
execFile(cmd, [dir], () => {});
|
|
862
|
+
execFile(cmd, [dir], { windowsHide: true }, () => {});
|
|
863
863
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
864
864
|
res.end(JSON.stringify({ ok: true, dir }));
|
|
865
865
|
}
|
package/server/routes/logs.js
CHANGED
|
@@ -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) {
|
|
@@ -4,6 +4,23 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { PACKAGE_JSON } from '../_paths.js';
|
|
5
5
|
import { LOG_DIR } from '../../findcc.js';
|
|
6
6
|
import { _projectName } from '../interceptor.js';
|
|
7
|
+
import { detectHomebrewInstall } from '../lib/updater.js';
|
|
8
|
+
|
|
9
|
+
// 判定当前 cc-viewer 的安装渠道,供前端精准匹配升级命令。
|
|
10
|
+
// - electron:桌面版(in-process server),走 GitHub Releases 重新下载安装包。
|
|
11
|
+
// - brew:Homebrew Cellar 布局命中 → `brew upgrade cc-viewer`(npm install -g 会跟 Cellar 打架)。
|
|
12
|
+
// - npm:默认兜底 → `npm install -g cc-viewer --registry=...`(指定官方源避免镜像滞后拿到旧版本)。
|
|
13
|
+
// deps 仅供单测注入;失败安全:detect 抛异常时回落到 npm(不会误导 brew 用户走 npm)。
|
|
14
|
+
export function getInstallMethod({
|
|
15
|
+
electron = process.versions && process.versions.electron,
|
|
16
|
+
detect = detectHomebrewInstall,
|
|
17
|
+
} = {}) {
|
|
18
|
+
if (electron) return 'electron';
|
|
19
|
+
try {
|
|
20
|
+
if (detect()) return 'brew';
|
|
21
|
+
} catch { /* 探测失败 → 回落 npm */ }
|
|
22
|
+
return 'npm';
|
|
23
|
+
}
|
|
7
24
|
|
|
8
25
|
function projectName(req, res) {
|
|
9
26
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -20,7 +37,7 @@ function versionInfo(req, res) {
|
|
|
20
37
|
try {
|
|
21
38
|
const pkg = JSON.parse(readFileSync(PACKAGE_JSON, 'utf-8'));
|
|
22
39
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
23
|
-
res.end(JSON.stringify({ version: pkg.version }));
|
|
40
|
+
res.end(JSON.stringify({ version: pkg.version, installMethod: getInstallMethod() }));
|
|
24
41
|
} catch {
|
|
25
42
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
26
43
|
res.end(JSON.stringify({ error: 'Failed to read version' }));
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// Workspace routes (moved verbatim from server.js handleRequest).
|
|
2
|
-
import { existsSync, statSync
|
|
2
|
+
import { existsSync, statSync } from 'node:fs';
|
|
3
3
|
import { basename } from 'node:path';
|
|
4
4
|
import { LOG_FILE, initForWorkspace, resetWorkspace } from '../interceptor.js';
|
|
5
|
-
import { watchLogFile,
|
|
5
|
+
import { watchLogFile, unwatchAll } from '../lib/log-watcher.js';
|
|
6
6
|
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 => {
|
|
@@ -30,7 +30,7 @@ function workspacesLaunch(req, res, parsedUrl, isLocal, deps) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const { registerWorkspace } = await import('../workspace-registry.js');
|
|
33
|
-
registerWorkspace(wsPath);
|
|
33
|
+
await registerWorkspace(wsPath);
|
|
34
34
|
|
|
35
35
|
// Electron multi-tab 模式:管理 server 只触发 callback,不做日志初始化
|
|
36
36
|
// 所有日志相关操作(initForWorkspace、watchLogFile、spawnClaude)由 tab-worker 子进程负责
|
|
@@ -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
|
});
|
|
@@ -107,7 +107,7 @@ function workspacesAdd(req, res, parsedUrl, isLocal, deps) {
|
|
|
107
107
|
return;
|
|
108
108
|
}
|
|
109
109
|
const { registerWorkspace } = await import('../workspace-registry.js');
|
|
110
|
-
const entry = registerWorkspace(wsPath);
|
|
110
|
+
const entry = await registerWorkspace(wsPath);
|
|
111
111
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
112
112
|
res.end(JSON.stringify({ ok: true, workspace: entry }));
|
|
113
113
|
} catch (err) {
|
|
@@ -120,8 +120,8 @@ function workspacesAdd(req, res, parsedUrl, isLocal, deps) {
|
|
|
120
120
|
function workspacesDelete(req, res, parsedUrl) {
|
|
121
121
|
const url = parsedUrl.pathname;
|
|
122
122
|
const id = url.split('/').pop();
|
|
123
|
-
import('../workspace-registry.js').then(({ removeWorkspace }) => {
|
|
124
|
-
const removed = removeWorkspace(id);
|
|
123
|
+
import('../workspace-registry.js').then(async ({ removeWorkspace }) => {
|
|
124
|
+
const removed = await removeWorkspace(id);
|
|
125
125
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
126
126
|
res.end(JSON.stringify({ ok: removed }));
|
|
127
127
|
}).catch(err => {
|
|
@@ -138,10 +138,7 @@ function workspacesStop(req, res, parsedUrl, isLocal, deps) {
|
|
|
138
138
|
// 接续原有清理流程
|
|
139
139
|
|
|
140
140
|
// 停止日志监听
|
|
141
|
-
|
|
142
|
-
unwatchFile(logFile);
|
|
143
|
-
}
|
|
144
|
-
getWatchedFiles().clear();
|
|
141
|
+
unwatchAll();
|
|
145
142
|
|
|
146
143
|
// 重置 interceptor 状态
|
|
147
144
|
resetWorkspace();
|
package/server/server.js
CHANGED
|
@@ -40,13 +40,18 @@ import './lib/adapters/wecom-adapter.js'; // side-effect: registers the WeCom
|
|
|
40
40
|
import './lib/adapters/discord-adapter.js'; // side-effect: registers the Discord adapter
|
|
41
41
|
import { loadConfig } from './lib/im-config.js';
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// Windows:git.exe / cmd.exe 等 console-subsystem 子进程从无控制台的 worker node.exe 启动时
|
|
44
|
+
// 会各弹一个可见控制台窗口(diff/status 轮询路径高频闪现)。在 promisify 包装层统一默认
|
|
45
|
+
// windowsHide(POSIX 上为 no-op,调用方传入可覆盖)。deps.execFileAsync 注入下游路由同样受益。
|
|
46
|
+
const _execFileAsyncRaw = promisify(execFile);
|
|
47
|
+
const execFileAsync = (cmd, args, opts) => _execFileAsyncRaw(cmd, args, { windowsHide: true, ...opts });
|
|
48
|
+
const _execAsyncRaw = promisify(exec);
|
|
49
|
+
const execAsync = (cmd, opts) => _execAsyncRaw(cmd, { windowsHide: true, ...opts });
|
|
45
50
|
|
|
46
51
|
// execFile with stdin input support (for git check-ignore --stdin)
|
|
47
52
|
function execWithStdin(cmd, args, input, options) {
|
|
48
53
|
return new Promise((resolve, reject) => {
|
|
49
|
-
const child = spawn(cmd, args, { ...options, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
54
|
+
const child = spawn(cmd, args, { ...options, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
50
55
|
let stdout = '';
|
|
51
56
|
let stderr = '';
|
|
52
57
|
child.stdout.on('data', d => { stdout += d; });
|
|
@@ -70,7 +75,7 @@ import { loadAuthConfig, loadAuthState, saveAuthConfig, clearProjectOverride, ge
|
|
|
70
75
|
import { checkAndUpdate } from './lib/updater.js';
|
|
71
76
|
import { loadPlugins, runWaterfallHook, runParallelHook } from './lib/plugin-loader.js';
|
|
72
77
|
import { CONTEXT_WINDOW_FILE, readModelContextSize } from './lib/context-watcher.js';
|
|
73
|
-
import { watchLogFile, startWatching,
|
|
78
|
+
import { watchLogFile, startWatching, unwatchAll, sendEventToClients, sendToClients } from './lib/log-watcher.js';
|
|
74
79
|
import { cleanupExtractCache } from './lib/jsonl-archive.js';
|
|
75
80
|
|
|
76
81
|
|
|
@@ -81,6 +86,7 @@ function getPrefsFile() { return join(LOG_DIR, 'preferences.json'); }
|
|
|
81
86
|
let claudeSettings = {};
|
|
82
87
|
// SSR theme 注入自检状态:模板缺 data-theme 时仅首次 warn(避免高 QPS 刷屏)
|
|
83
88
|
let _ssrThemeAttrWarned = false;
|
|
89
|
+
let _indexHtmlCache = null; // { html: string, mtime: number }
|
|
84
90
|
try {
|
|
85
91
|
const settingsPath = join(getClaudeConfigDir(), 'settings.json');
|
|
86
92
|
if (existsSync(settingsPath)) {
|
|
@@ -156,14 +162,13 @@ const ASK_HOOK_TIMEOUT_MS = ASK_TIMEOUT_MS;
|
|
|
156
162
|
// 任何 pendingAskHooks.set(...) 后必须调 _persistAskEntry;.delete(...) 后必须调 _persistAskDelete。
|
|
157
163
|
function _persistAskEntry(id, entry) {
|
|
158
164
|
if (!entry || !Array.isArray(entry.questions)) return;
|
|
159
|
-
// 异步触发:磁盘 IO 不阻塞 ask 主流程(落盘失败不影响业务)
|
|
160
165
|
setImmediate(() => {
|
|
161
|
-
|
|
166
|
+
askStoreSetEntry(id, { questions: entry.questions, createdAt: entry.createdAt }).catch(() => {});
|
|
162
167
|
});
|
|
163
168
|
}
|
|
164
169
|
function _persistAskDelete(id) {
|
|
165
170
|
setImmediate(() => {
|
|
166
|
-
|
|
171
|
+
askStoreDeleteEntry(id).catch(() => {});
|
|
167
172
|
});
|
|
168
173
|
}
|
|
169
174
|
|
|
@@ -681,16 +686,20 @@ async function handleRequest(req, res) {
|
|
|
681
686
|
const serveIndexHtml = () => {
|
|
682
687
|
try {
|
|
683
688
|
const indexPath = join(DIST_DIR, 'index.html');
|
|
684
|
-
|
|
685
|
-
let
|
|
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';
|
|
686
697
|
try {
|
|
687
698
|
if (existsSync(getPrefsFile())) {
|
|
688
699
|
const prefs = JSON.parse(readFileSync(getPrefsFile(), 'utf-8'));
|
|
689
700
|
if (prefs.themeColor === 'dark' || prefs.themeColor === 'light') themeColor = prefs.themeColor;
|
|
690
701
|
}
|
|
691
|
-
} catch {
|
|
692
|
-
// 自检:模板里没有 <html ... data-theme="..."> 时 replace 静默 no-op,SSR 优化失效但不报错。
|
|
693
|
-
// 仅首次 warn 避免高 QPS 刷屏(_ssrThemeAttrWarned 单进程一次性)。
|
|
702
|
+
} catch {}
|
|
694
703
|
if (!_ssrThemeAttrWarned && !/<html[^>]*data-theme="[^"]*"/.test(html)) {
|
|
695
704
|
_ssrThemeAttrWarned = true;
|
|
696
705
|
console.warn('[serveIndexHtml] dist/index.html 没有 <html data-theme="..."> 属性,SSR theme 注入将不生效。检查 index.html 模板。');
|
|
@@ -770,11 +779,11 @@ export async function startViewer() {
|
|
|
770
779
|
// 内存 Map 不 hydrate:旧 res 已死、新 ask-bridge 重连同 toolUseId 会自动复用槽位
|
|
771
780
|
// (server.js 已有"旧 res 已断 → 复用"分支),无需在这里主动重建内存态。
|
|
772
781
|
// 留下来的 disk 镜像供 /api/pending-asks 端点查询,让浏览器重连后仍能看见 pending 列表。
|
|
773
|
-
setImmediate(() => {
|
|
782
|
+
setImmediate(() => { askStorePruneStale(ASK_HOOK_TIMEOUT_MS).catch(() => {}); });
|
|
774
783
|
// 长跑进程兜底:短轮询路径下 markAnswered 标的终态 entry 若 ask-bridge 已死(GET 不再来 consume),
|
|
775
784
|
// 仅靠启动 prune 永远清不掉。1h 周期触发一次,.unref() 不阻塞进程退出。
|
|
776
785
|
const _pruneAskStoreInterval = setInterval(() => {
|
|
777
|
-
|
|
786
|
+
askStorePruneStale(ASK_HOOK_TIMEOUT_MS).catch(() => {});
|
|
778
787
|
}, 60 * 60 * 1000);
|
|
779
788
|
_pruneAskStoreInterval.unref();
|
|
780
789
|
|
|
@@ -1263,7 +1272,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1263
1272
|
// Phase 3: short-poll 模式不立即删 disk —— 落 answered 让 GET listener / disk consume 拿
|
|
1264
1273
|
if (askEntry.shortPoll) {
|
|
1265
1274
|
let wrote = false;
|
|
1266
|
-
try { wrote = askStoreMarkAnswered(askId, msg.answers); } catch {}
|
|
1275
|
+
try { wrote = await askStoreMarkAnswered(askId, msg.answers); } catch {}
|
|
1267
1276
|
if (wrote) {
|
|
1268
1277
|
_notifyShortPollAnswer(askId, msg.answers);
|
|
1269
1278
|
askAnswered = true;
|
|
@@ -1296,7 +1305,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1296
1305
|
// 不唤醒 listener(让 listener 等到 GET hit disk 拿到真实抢答者答案)—— 自然 idempotent。
|
|
1297
1306
|
// 给当前 ws 发 ack-already-answered 让前端关 modal、不误覆盖灰态。
|
|
1298
1307
|
let wrote = false;
|
|
1299
|
-
try { wrote = askStoreMarkAnswered(askId, msg.answers); } catch {}
|
|
1308
|
+
try { wrote = await askStoreMarkAnswered(askId, msg.answers); } catch {}
|
|
1300
1309
|
if (wrote) {
|
|
1301
1310
|
_notifyShortPollAnswer(askId, msg.answers);
|
|
1302
1311
|
askAnswered = true;
|
|
@@ -1406,7 +1415,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1406
1415
|
pendingAskHooks.delete(cancelId);
|
|
1407
1416
|
// Phase 3: short-poll 同样要让 disk + listener 知道,否则 ask-bridge 永远收不到 cancelled
|
|
1408
1417
|
if (askEntry.shortPoll) {
|
|
1409
|
-
try { askStoreMarkCancelled(cancelId, cancelReason); } catch {}
|
|
1418
|
+
try { await askStoreMarkCancelled(cancelId, cancelReason); } catch {}
|
|
1410
1419
|
_notifyShortPollCancel(cancelId, cancelReason);
|
|
1411
1420
|
} else {
|
|
1412
1421
|
_persistAskDelete(cancelId);
|
|
@@ -1424,7 +1433,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1424
1433
|
// first-wins:disk 已是终态(如另一 client 抢先 answer)时 markCancelled 返 false,
|
|
1425
1434
|
// 不能唤醒 listener 用 cancel 覆盖真实 answer —— 让 listener 下次 GET consumeIfFinal
|
|
1426
1435
|
// 拿到真实 disk 终态自然投递。
|
|
1427
|
-
const wrote = askStoreMarkCancelled(cancelId, cancelReason);
|
|
1436
|
+
const wrote = await askStoreMarkCancelled(cancelId, cancelReason);
|
|
1428
1437
|
if (wrote) {
|
|
1429
1438
|
_notifyShortPollCancel(cancelId, cancelReason);
|
|
1430
1439
|
handled = true;
|
|
@@ -1640,10 +1649,12 @@ const _pendingTurnEndTimers = new Map(); // key: sessionId(string) | null → {
|
|
|
1640
1649
|
// 空串 / 非数 / 范围外都回 default + warn 一次。
|
|
1641
1650
|
const TURN_END_DEBOUNCE_MS = (() => {
|
|
1642
1651
|
const raw = process.env.CCV_TURN_END_DEBOUNCE_MS;
|
|
1643
|
-
|
|
1652
|
+
// IM worker 需要快速 turn_end 以驱动队列——默认 200ms(够 coalesce 重复 POST,不拖延回复)。
|
|
1653
|
+
const imDefault = process.env.CCV_IM_PLATFORM ? 200 : 10_000;
|
|
1654
|
+
if (raw === undefined || raw === '' || /^\s*$/.test(raw)) return imDefault;
|
|
1644
1655
|
const n = Number(raw);
|
|
1645
|
-
if (!Number.isFinite(n)) { console.warn(`[turn-end] CCV_TURN_END_DEBOUNCE_MS=${raw} not finite, using
|
|
1646
|
-
if (n < 100 || n > 60_000) { console.warn(`[turn-end] CCV_TURN_END_DEBOUNCE_MS=${n} out of [100,60000], using
|
|
1656
|
+
if (!Number.isFinite(n)) { console.warn(`[turn-end] CCV_TURN_END_DEBOUNCE_MS=${raw} not finite, using ${imDefault}`); return imDefault; }
|
|
1657
|
+
if (n < 100 || n > 60_000) { console.warn(`[turn-end] CCV_TURN_END_DEBOUNCE_MS=${n} out of [100,60000], using ${imDefault}`); return imDefault; }
|
|
1647
1658
|
return n;
|
|
1648
1659
|
})();
|
|
1649
1660
|
let _isStopping = false;
|
|
@@ -1819,11 +1830,8 @@ async function _doStop() {
|
|
|
1819
1830
|
}
|
|
1820
1831
|
} catch { }
|
|
1821
1832
|
}
|
|
1822
|
-
|
|
1823
|
-
unwatchFile(logFile);
|
|
1824
|
-
}
|
|
1833
|
+
unwatchAll();
|
|
1825
1834
|
unwatchFile(CONTEXT_WINDOW_FILE);
|
|
1826
|
-
getWatchedFiles().clear();
|
|
1827
1835
|
clients.forEach(client => client.end());
|
|
1828
1836
|
// Truncate in place (not `clients = []`) so the array reference stays stable across
|
|
1829
1837
|
// stop/start cycles — deps.clients and _logWatcherOpts() hold this same reference.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Workspace Registry - 工作区持久化管理
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync,
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
3
4
|
import { renameSyncWithRetry } from './lib/file-api.js';
|
|
5
|
+
import { withFileLockAsync } from './lib/async-file-lock.js';
|
|
4
6
|
import { join, basename, resolve } from 'node:path';
|
|
5
7
|
import { randomBytes } from 'node:crypto';
|
|
6
8
|
import { LOG_DIR } from '../findcc.js';
|
|
@@ -9,51 +11,6 @@ import { LOG_DIR } from '../findcc.js';
|
|
|
9
11
|
function getWorkspacesFile() { return join(LOG_DIR, 'workspaces.json'); }
|
|
10
12
|
function getLockFile() { return join(LOG_DIR, 'workspaces.lock'); }
|
|
11
13
|
|
|
12
|
-
function sleep(ms) {
|
|
13
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function withLock(fn) {
|
|
17
|
-
mkdirSync(LOG_DIR, { recursive: true });
|
|
18
|
-
const deadline = Date.now() + 2000;
|
|
19
|
-
// 如果锁文件超过 5 秒未更新,认为它是死锁(前一个进程崩溃)
|
|
20
|
-
const STALE_THRESHOLD = 5000;
|
|
21
|
-
|
|
22
|
-
while (true) {
|
|
23
|
-
try {
|
|
24
|
-
const fd = openSync(getLockFile(), 'wx');
|
|
25
|
-
closeSync(fd);
|
|
26
|
-
break;
|
|
27
|
-
} catch (err) {
|
|
28
|
-
if (err?.code === 'EEXIST') {
|
|
29
|
-
if (Date.now() < deadline) {
|
|
30
|
-
// 检查是否为陈旧锁
|
|
31
|
-
try {
|
|
32
|
-
const stats = statSync(getLockFile());
|
|
33
|
-
if (Date.now() - stats.mtimeMs > STALE_THRESHOLD) {
|
|
34
|
-
// 尝试强制移除锁
|
|
35
|
-
try { unlinkSync(getLockFile()); } catch { }
|
|
36
|
-
// 立即重试获取
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
} catch {
|
|
40
|
-
// stat 失败可能意味着锁刚被释放,继续循环尝试获取
|
|
41
|
-
}
|
|
42
|
-
sleep(25);
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
throw err;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
try {
|
|
51
|
-
return fn();
|
|
52
|
-
} finally {
|
|
53
|
-
try { unlinkSync(getLockFile()); } catch { }
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
14
|
export function loadWorkspaces() {
|
|
58
15
|
try {
|
|
59
16
|
if (!existsSync(getWorkspacesFile())) return [];
|
|
@@ -69,7 +26,7 @@ export function saveWorkspaces(list) {
|
|
|
69
26
|
try {
|
|
70
27
|
mkdirSync(LOG_DIR, { recursive: true });
|
|
71
28
|
writeFileSync(tmpFile, JSON.stringify({ workspaces: list }, null, 2));
|
|
72
|
-
|
|
29
|
+
|
|
73
30
|
// Windows 上 renameSync 可能会因为目标文件存在或被占用而失败。统一走 server/lib/file-api.js
|
|
74
31
|
// renameSyncWithRetry helper(同款重试策略,跟 interceptor / log-management 一致)。
|
|
75
32
|
renameSyncWithRetry(tmpFile, getWorkspacesFile());
|
|
@@ -87,8 +44,8 @@ function _invalidatePolicyCache() {
|
|
|
87
44
|
.catch(() => { /* policy 模块可能在某些 entry 下未加载,无副作用即可 */ });
|
|
88
45
|
}
|
|
89
46
|
|
|
90
|
-
export function registerWorkspace(absolutePath) {
|
|
91
|
-
const result =
|
|
47
|
+
export async function registerWorkspace(absolutePath) {
|
|
48
|
+
const result = await withFileLockAsync(getLockFile(), () => {
|
|
92
49
|
const resolvedPath = resolve(absolutePath);
|
|
93
50
|
const projectName = basename(resolvedPath).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
|
|
94
51
|
const list = loadWorkspaces();
|
|
@@ -113,13 +70,13 @@ export function registerWorkspace(absolutePath) {
|
|
|
113
70
|
list.push(entry);
|
|
114
71
|
saveWorkspaces(list);
|
|
115
72
|
return entry;
|
|
116
|
-
});
|
|
73
|
+
}, { ensureDir: LOG_DIR });
|
|
117
74
|
_invalidatePolicyCache();
|
|
118
75
|
return result;
|
|
119
76
|
}
|
|
120
77
|
|
|
121
|
-
export function removeWorkspace(id) {
|
|
122
|
-
const result =
|
|
78
|
+
export async function removeWorkspace(id) {
|
|
79
|
+
const result = await withFileLockAsync(getLockFile(), () => {
|
|
123
80
|
const list = loadWorkspaces();
|
|
124
81
|
const filtered = list.filter(w => w.id !== id);
|
|
125
82
|
if (filtered.length !== list.length) {
|
|
@@ -127,30 +84,27 @@ export function removeWorkspace(id) {
|
|
|
127
84
|
return true;
|
|
128
85
|
}
|
|
129
86
|
return false;
|
|
130
|
-
});
|
|
87
|
+
}, { ensureDir: LOG_DIR });
|
|
131
88
|
if (result) _invalidatePolicyCache();
|
|
132
89
|
return result;
|
|
133
90
|
}
|
|
134
91
|
|
|
135
|
-
export function getWorkspaces() {
|
|
92
|
+
export async function getWorkspaces() {
|
|
136
93
|
const list = loadWorkspaces();
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
logCount++;
|
|
148
|
-
try { totalSize += statSync(join(logDir, f)).size; } catch { }
|
|
149
|
-
}
|
|
150
|
-
}
|
|
94
|
+
const enriched = await Promise.all(list.map(async (w) => {
|
|
95
|
+
let logCount = 0;
|
|
96
|
+
let totalSize = 0;
|
|
97
|
+
const logDir = join(LOG_DIR, w.projectName);
|
|
98
|
+
try {
|
|
99
|
+
const files = await readdir(logDir);
|
|
100
|
+
for (const f of files) {
|
|
101
|
+
if (f.endsWith('.jsonl')) {
|
|
102
|
+
logCount++;
|
|
103
|
+
try { totalSize += (await stat(join(logDir, f))).size; } catch { }
|
|
151
104
|
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
105
|
+
}
|
|
106
|
+
} catch { }
|
|
107
|
+
return { ...w, logCount, totalSize };
|
|
108
|
+
}));
|
|
109
|
+
return enriched.sort((a, b) => new Date(b.lastUsed) - new Date(a.lastUsed));
|
|
156
110
|
}
|