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.
Files changed (40) hide show
  1. package/cli.js +7 -2
  2. package/dist/assets/App-BeCGow-I.js +2 -0
  3. package/dist/assets/{MdxEditorPanel-B8xrlDZJ.js → MdxEditorPanel-D52b5qxi.js} +1 -1
  4. package/dist/assets/{Mobile-fsi8-Lpb.js → Mobile-8fflztx7.js} +1 -1
  5. package/dist/assets/index-DtpelJc4.js +2 -0
  6. package/dist/assets/seqResourceLoaders-DM-48tr-.js +2 -0
  7. package/dist/index.html +1 -1
  8. package/findcc.js +3 -3
  9. package/package.json +1 -1
  10. package/server/i18n.js +224 -8
  11. package/server/interceptor.js +23 -19
  12. package/server/lib/adapters/dingtalk-adapter.js +62 -0
  13. package/server/lib/adapters/discord-adapter.js +35 -0
  14. package/server/lib/adapters/feishu-adapter.js +37 -0
  15. package/server/lib/ask-store.js +19 -90
  16. package/server/lib/async-file-lock.js +123 -0
  17. package/server/lib/async-write-queue.js +131 -0
  18. package/server/lib/git-diff.js +4 -1
  19. package/server/lib/im-bridge-core.js +119 -14
  20. package/server/lib/im-config.js +11 -6
  21. package/server/lib/im-process-manager.js +1 -1
  22. package/server/lib/jsonl-archive.js +0 -1
  23. package/server/lib/log-management.js +46 -99
  24. package/server/lib/log-stream.js +102 -8
  25. package/server/lib/log-watcher.js +231 -178
  26. package/server/lib/plugin-manager.js +1 -1
  27. package/server/lib/updater.js +4 -2
  28. package/server/pty-manager.js +1 -1
  29. package/server/routes/ask-perm.js +2 -2
  30. package/server/routes/dingtalk.js +2 -0
  31. package/server/routes/events.js +3 -3
  32. package/server/routes/files-fs.js +4 -4
  33. package/server/routes/logs.js +5 -5
  34. package/server/routes/project-meta.js +18 -1
  35. package/server/routes/workspaces.js +10 -13
  36. package/server/server.js +33 -25
  37. package/server/workspace-registry.js +26 -72
  38. package/dist/assets/App-C66LoBEz.js +0 -2
  39. package/dist/assets/index-BTZqk5O5.js +0 -2
  40. package/dist/assets/seqResourceLoaders-6k4uXcNn.js +0 -2
@@ -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
- { detached: true, stdio: 'ignore', shell: process.platform === 'win32' }
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) {
@@ -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 {
@@ -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
  }
@@ -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, unwatchFile } from 'node:fs';
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, getWatchedFiles } from '../lib/log-watcher.js';
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
- for (const logFile of getWatchedFiles().keys()) {
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
- const execFileAsync = promisify(execFile);
44
- const execAsync = promisify(exec);
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, getWatchedFiles, sendEventToClients, sendToClients } from './lib/log-watcher.js';
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
- try { askStoreSetEntry(id, { questions: entry.questions, createdAt: entry.createdAt }); } catch {}
166
+ askStoreSetEntry(id, { questions: entry.questions, createdAt: entry.createdAt }).catch(() => {});
162
167
  });
163
168
  }
164
169
  function _persistAskDelete(id) {
165
170
  setImmediate(() => {
166
- try { askStoreDeleteEntry(id); } catch {}
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
- let html = readFileSync(indexPath, 'utf-8');
685
- let themeColor = 'light';
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 { /* 读 prefs 失败就走默认 light */ }
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(() => { try { askStorePruneStale(ASK_HOOK_TIMEOUT_MS); } catch {} });
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
- try { askStorePruneStale(ASK_HOOK_TIMEOUT_MS); } catch {}
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
- if (raw === undefined || raw === '' || /^\s*$/.test(raw)) return 10_000;
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 10000`); return 10_000; }
1646
- if (n < 100 || n > 60_000) { console.warn(`[turn-end] CCV_TURN_END_DEBOUNCE_MS=${n} out of [100,60000], using 10000`); return 10_000; }
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
- for (const logFile of getWatchedFiles().keys()) {
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, statSync, readdirSync, openSync, closeSync, unlinkSync } from 'node:fs';
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 = withLock(() => {
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 = withLock(() => {
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
- return list
138
- .map(w => {
139
- let logCount = 0;
140
- let totalSize = 0;
141
- const logDir = join(LOG_DIR, w.projectName);
142
- try {
143
- if (existsSync(logDir)) {
144
- const files = readdirSync(logDir);
145
- for (const f of files) {
146
- if (f.endsWith('.jsonl')) {
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
- } catch { }
153
- return { ...w, logCount, totalSize };
154
- })
155
- .sort((a, b) => new Date(b.lastUsed) - new Date(a.lastUsed));
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
  }