cc-viewer 1.4.16 → 1.4.18

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/dist/index.html CHANGED
@@ -6,7 +6,7 @@
6
6
  <title>Claude Code Viewer</title>
7
7
  <link rel="icon" href="/favicon.ico?v=1">
8
8
  <link rel="shortcut icon" href="/favicon.ico?v=1">
9
- <script type="module" crossorigin src="/assets/index-CwFfGP5V.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-LxuWMLD-.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="/assets/index-k2L1uWUr.css">
11
11
  </head>
12
12
  <body>
package/i18n.js CHANGED
@@ -182,9 +182,9 @@ const i18nData = {
182
182
  "uk": "\nДля видалення виконайте: ccv --uninstall"
183
183
  },
184
184
  "cli.help": {
185
- "zh": "CC Viewer CLI\n\n用法:\n ccv [options]\n ccv run -- <command> [args...]\n\n选项:\n -h, --help 显示帮助\n -v, --version 显示版本\n -c, --c CLI 模式:在 PTY 中运行 Claude,自动打开浏览器\n -d, --d Dangerous 模式:CLI 模式 + --dangerously-skip-permissions\n --uninstall 移除 CC Viewer 集成\n\n说明:\n 直接运行 ccv 将安装/修复 Claude Code 的集成 Hook",
186
- "en": "CC Viewer CLI\n\nUsage:\n ccv [options]\n ccv run -- <command> [args...]\n\nOptions:\n -h, --help Show help\n -v, --version Show version\n -c, --c CLI mode: run Claude in PTY with auto browser\n -d, --d Dangerous mode: CLI mode + --dangerously-skip-permissions\n --uninstall Remove CC Viewer integration\n\nNotes:\n Running ccv without arguments installs/repairs the Claude Code hook.",
187
- "zh-TW": "CC Viewer CLI\n\n用法:\n ccv [options]\n ccv run -- <command> [args...]\n\n選項:\n -h, --help 顯示說明\n -v, --version 顯示版本\n -c, --c CLI 模式:在 PTY 中執行 Claude,自動開啟瀏覽器\n -d, --d Dangerous 模式:CLI 模式 + --dangerously-skip-permissions\n --uninstall 移除 CC Viewer 整合\n\n說明:\n 直接執行 ccv 會安裝/修復 Claude Code 的整合 Hook"
185
+ "zh": "CC Viewer CLI\n\n用法:\n ccv [options]\n ccv run -- <command> [args...]\n ccv -d [path] 启动交互模式 + 危险权限\n ccv -c [path] 启动交互模式\n\n选项:\n -h, --help 显示帮助\n -v, --version 显示版本\n -d [path] 启动交互式 Web Viewer(跳过权限确认)\n 不指定 path 时使用当前目录\n -c [path] 启动交互式 Web Viewer\n 不指定 path 时使用当前目录\n --uninstall 移除 CC Viewer 集成\n\n说明:\n 直接运行 ccv 将安装/修复 Claude Code 的集成 Hook。\n 使用 -d/-c 启动后,可在 Web 界面中切换工作区。",
186
+ "en": "CC Viewer CLI\n\nUsage:\n ccv [options]\n ccv run -- <command> [args...]\n ccv -d [path] Start interactive mode + dangerous permissions\n ccv -c [path] Start interactive mode\n\nOptions:\n -h, --help Show help\n -v, --version Show version\n -d [path] Start interactive Web Viewer (skip permission prompts)\n Uses current directory if path is not specified\n -c [path] Start interactive Web Viewer\n Uses current directory if path is not specified\n --uninstall Remove CC Viewer integration\n\nNotes:\n Running ccv without arguments installs/repairs the Claude Code hook.\n After starting with -d/-c, you can switch workspaces from the Web UI.",
187
+ "zh-TW": "CC Viewer CLI\n\n用法:\n ccv [options]\n ccv run -- <command> [args...]\n ccv -d [path] 啟動互動模式 + 危險權限\n ccv -c [path] 啟動互動模式\n\n選項:\n -h, --help 顯示說明\n -v, --version 顯示版本\n -d [path] 啟動互動式 Web Viewer(跳過權限確認)\n 不指定 path 時使用當前目錄\n -c [path] 啟動互動式 Web Viewer\n 不指定 path 時使用當前目錄\n --uninstall 移除 CC Viewer 整合\n\n說明:\n 直接執行 ccv 會安裝/修復 Claude Code 的整合 Hook。\n 使用 -d/-c 啟動後,可在 Web 介面中切換工作區。"
188
188
  },
189
189
  "cli.cMode.notFound": {
190
190
  "zh": "错误: 未找到 claude 命令,请确认已安装 Claude Code",
package/interceptor.js CHANGED
@@ -111,13 +111,21 @@ function resolveResumeChoice(choice) {
111
111
  }
112
112
 
113
113
  // 初始化日志文件路径(异步,支持用户交互)
114
- const { filePath: _newLogFile, dir: _logDir, projectName: _projectName } = generateNewLogFilePath();
114
+ // 工作区模式下延迟到选择工作区后再初始化
115
+ let _newLogFile, _logDir, _projectName;
116
+ if (process.env.CCV_WORKSPACE_MODE === '1') {
117
+ _newLogFile = '';
118
+ _logDir = '';
119
+ _projectName = '';
120
+ } else {
121
+ ({ filePath: _newLogFile, dir: _logDir, projectName: _projectName } = generateNewLogFilePath());
122
+ // 启动时清理残留临时文件
123
+ cleanupTempFiles(_logDir, _projectName);
124
+ }
115
125
  let LOG_FILE = _newLogFile;
116
126
 
117
- // 启动时清理残留临时文件
118
- cleanupTempFiles(_logDir, _projectName);
119
-
120
127
  const _initPromise = (async () => {
128
+ if (!_logDir || !_projectName) return; // 工作区模式下跳过
121
129
  try {
122
130
  const recentLog = findRecentLog(_logDir, _projectName);
123
131
  if (recentLog) {
@@ -143,8 +151,116 @@ const _initPromise = (async () => {
143
151
 
144
152
  export { LOG_FILE, _initPromise, _resumeState, _choicePromise, resolveResumeChoice, _projectName, _logDir };
145
153
 
154
+ // 工作区模式:动态初始化指定路径的日志文件
155
+ // 如果有 1 小时内的最近日志,自动复用(与单目录模式行为一致)
156
+ export function initForWorkspace(projectPath) {
157
+ const projectName = basename(projectPath).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
158
+ const dir = join(LOG_DIR, projectName);
159
+ try { mkdirSync(dir, { recursive: true }); } catch {}
160
+
161
+ cleanupTempFiles(dir, projectName);
162
+
163
+ // 检查是否有最近的日志文件可以复用
164
+ const recentLog = findRecentLog(dir, projectName);
165
+ if (recentLog) {
166
+ try {
167
+ const stats = statSync(recentLog);
168
+ const diff = Date.now() - stats.mtime.getTime();
169
+ const oneHour = 60 * 60 * 1000;
170
+ if (diff < oneHour) {
171
+ _projectName = projectName;
172
+ _logDir = dir;
173
+ LOG_FILE = recentLog;
174
+ return { filePath: recentLog, dir, projectName, resumed: true };
175
+ }
176
+ } catch {}
177
+ }
178
+
179
+ // 没有最近日志,创建新文件
180
+ const now = new Date();
181
+ const ts = now.getFullYear().toString()
182
+ + String(now.getMonth() + 1).padStart(2, '0')
183
+ + String(now.getDate()).padStart(2, '0')
184
+ + '_'
185
+ + String(now.getHours()).padStart(2, '0')
186
+ + String(now.getMinutes()).padStart(2, '0')
187
+ + String(now.getSeconds()).padStart(2, '0');
188
+
189
+ const filePath = join(dir, `${projectName}_${ts}.jsonl`);
190
+
191
+ _projectName = projectName;
192
+ _logDir = dir;
193
+ LOG_FILE = filePath;
194
+
195
+ return { filePath, dir, projectName, resumed: false };
196
+ }
197
+
198
+ // 工作区模式:重置日志状态(返回工作区列表时调用)
199
+ export function resetWorkspace() {
200
+ _projectName = '';
201
+ _logDir = '';
202
+ LOG_FILE = '';
203
+ }
204
+
146
205
  const MAX_LOG_SIZE = 300 * 1024 * 1024; // 300MB
147
206
 
207
+ const SUBAGENT_SYSTEM_RE = /command execution specialist|file search specialist|planning specialist|general-purpose agent/i;
208
+
209
+ /**
210
+ * 提取请求体中的 system prompt 文本
211
+ */
212
+ function getSystemText(body) {
213
+ const system = body?.system;
214
+ if (typeof system === 'string') return system;
215
+ if (Array.isArray(system)) {
216
+ return system.map(s => (s && s.text) || '').join('');
217
+ }
218
+ return '';
219
+ }
220
+
221
+ /**
222
+ * 判断请求是否为 MainAgent(拦截器侧标记用)
223
+ * 与 contentFilter.js 保持一致的检测逻辑
224
+ */
225
+ function isMainAgentRequest(body) {
226
+ if (!body?.system || !Array.isArray(body?.tools)) return false;
227
+
228
+ const sysText = getSystemText(body);
229
+
230
+ // 必须包含 MainAgent 身份标识
231
+ if (!sysText.includes('You are Claude Code')) return false;
232
+
233
+ // 排除 SubAgent
234
+ if (SUBAGENT_SYSTEM_RE.test(sysText)) return false;
235
+
236
+ // 新架构检测(v2.1.69+):延迟工具加载机制
237
+ const isSystemArray = Array.isArray(body.system);
238
+ const hasToolSearch = body.tools.some(t => t.name === 'ToolSearch');
239
+
240
+ if (isSystemArray && hasToolSearch) {
241
+ // 检查第一条消息是否包含 <available-deferred-tools>
242
+ const messages = body.messages || [];
243
+ const firstMsgContent = messages.length > 0 ?
244
+ (typeof messages[0].content === 'string' ? messages[0].content :
245
+ Array.isArray(messages[0].content) ? messages[0].content.map(c => c.text || '').join('') : '') : '';
246
+ if (firstMsgContent.includes('<available-deferred-tools>')) {
247
+ return true;
248
+ }
249
+ }
250
+
251
+ // 旧架构检测:工具数量 > 10 且包含核心工具
252
+ if (body.tools.length > 10) {
253
+ const hasEdit = body.tools.some(t => t.name === 'Edit');
254
+ const hasBash = body.tools.some(t => t.name === 'Bash');
255
+ const hasTaskOrAgent = body.tools.some(t => t.name === 'Task' || t.name === 'Agent');
256
+ if (hasEdit && hasBash && hasTaskOrAgent) {
257
+ return true;
258
+ }
259
+ }
260
+
261
+ return false;
262
+ }
263
+
148
264
  function isPreflightEntry(entry) {
149
265
  if (entry.mainAgent || entry.isHeartbeat || entry.isCountTokens) return false;
150
266
  const body = entry.body || {};
@@ -468,18 +584,7 @@ export function setupInterceptor() {
468
584
  isStream: body?.stream === true,
469
585
  isHeartbeat: /\/api\/eval\/sdk-/.test(urlStr),
470
586
  isCountTokens: /\/messages\/count_tokens/.test(urlStr),
471
- mainAgent: (() => {
472
- if (!body?.system || !Array.isArray(body?.tools) || body.tools.length <= 10) return false;
473
- if (!['Edit', 'Bash'].every(n => body.tools.some(t => t.name === n))) return false;
474
- if (!body.tools.some(t => t.name === 'Task' || t.name === 'Agent')) return false;
475
- const sysText = typeof body.system === 'string' ? body.system :
476
- Array.isArray(body.system) ? body.system.map(s => s?.text || '').join('') : '';
477
- // 正向:必须包含 MainAgent 身份标识
478
- if (!sysText.includes('You are Claude Code')) return false;
479
- // 排除 SubAgent(general-purpose 等也携带完整工具集)
480
- if (/command execution specialist|file search specialist|planning specialist|general-purpose agent/i.test(sysText)) return false;
481
- return true;
482
- })()
587
+ mainAgent: isMainAgentRequest(body)
483
588
  };
484
589
  }
485
590
  } catch { }
@@ -497,7 +602,14 @@ export function setupInterceptor() {
497
602
  }
498
603
  }
499
604
 
500
- // 在发起请求前先写入一条无 response 的条目,让前端可以检测在途请求
605
+ // 生成唯一请求 ID,用于关联在途请求和完成请求
606
+ const requestId = `${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
607
+ if (requestEntry) {
608
+ requestEntry.requestId = requestId;
609
+ requestEntry.inProgress = true; // 标记为在途请求
610
+ }
611
+
612
+ // 在发起请求前先写入一条未完成的条目,让前端可以检测在途请求
501
613
  if (requestEntry) {
502
614
  try {
503
615
  appendFileSync(LOG_FILE, JSON.stringify(requestEntry, null, 2) + '\n---\n');
@@ -562,9 +674,14 @@ export function setupInterceptor() {
562
674
  // 直接使用组装后的 message 对象作为 response.body
563
675
  // 如果组装失败(例如非标准 SSE),则使用原始流内容
564
676
  requestEntry.response.body = assembledMessage || streamedContent;
677
+ // 移除在途请求标记,保持原始报文
678
+ delete requestEntry.inProgress;
679
+ delete requestEntry.requestId;
565
680
  appendFileSync(LOG_FILE, JSON.stringify(requestEntry, null, 2) + '\n---\n');
566
681
  } catch (err) {
567
682
  requestEntry.response.body = streamedContent.slice(0, 1000);
683
+ delete requestEntry.inProgress;
684
+ delete requestEntry.requestId;
568
685
  appendFileSync(LOG_FILE, JSON.stringify(requestEntry, null, 2) + '\n---\n');
569
686
  }
570
687
  controller.close();
@@ -593,6 +710,8 @@ export function setupInterceptor() {
593
710
  headers: Object.fromEntries(response.headers.entries()),
594
711
  body: '[Streaming Response - Capture failed]'
595
712
  };
713
+ delete requestEntry.inProgress;
714
+ delete requestEntry.requestId;
596
715
  appendFileSync(LOG_FILE, JSON.stringify(requestEntry, null, 2) + '\n---\n');
597
716
  }
598
717
  } else {
@@ -614,9 +733,13 @@ export function setupInterceptor() {
614
733
  headers: Object.fromEntries(response.headers.entries()),
615
734
  body: responseData
616
735
  };
736
+ delete requestEntry.inProgress;
737
+ delete requestEntry.requestId;
617
738
 
618
739
  appendFileSync(LOG_FILE, JSON.stringify(requestEntry, null, 2) + '\n---\n');
619
740
  } catch (err) {
741
+ delete requestEntry.inProgress;
742
+ delete requestEntry.requestId;
620
743
  appendFileSync(LOG_FILE, JSON.stringify(requestEntry, null, 2) + '\n---\n');
621
744
  }
622
745
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.4.16",
3
+ "version": "1.4.18",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
package/pty-manager.js CHANGED
@@ -9,6 +9,7 @@ let dataListeners = [];
9
9
  let exitListeners = [];
10
10
  let lastExitCode = null;
11
11
  let outputBuffer = '';
12
+ let currentWorkspacePath = null;
12
13
  const MAX_BUFFER = 200000;
13
14
 
14
15
  function fixSpawnHelperPermissions() {
@@ -26,7 +27,7 @@ function fixSpawnHelperPermissions() {
26
27
 
27
28
  export async function spawnClaude(proxyPort, cwd, extraArgs = []) {
28
29
  if (ptyProcess) {
29
- throw new Error('PTY process already running');
30
+ killPty();
30
31
  }
31
32
 
32
33
  const ptyMod = await import('node-pty');
@@ -51,6 +52,7 @@ export async function spawnClaude(proxyPort, cwd, extraArgs = []) {
51
52
 
52
53
  lastExitCode = null;
53
54
  outputBuffer = '';
55
+ currentWorkspacePath = cwd || process.cwd();
54
56
 
55
57
  ptyProcess = pty.spawn(claudePath, args, {
56
58
  name: 'xterm-256color',
@@ -73,6 +75,7 @@ export async function spawnClaude(proxyPort, cwd, extraArgs = []) {
73
75
  ptyProcess.onExit(({ exitCode }) => {
74
76
  lastExitCode = exitCode;
75
77
  ptyProcess = null;
78
+ currentWorkspacePath = null;
76
79
  for (const cb of exitListeners) {
77
80
  try { cb(exitCode); } catch {}
78
81
  }
@@ -121,6 +124,14 @@ export function getPtyState() {
121
124
  };
122
125
  }
123
126
 
127
+ export function getCurrentWorkspace() {
128
+ return {
129
+ running: !!ptyProcess,
130
+ exitCode: lastExitCode,
131
+ cwd: currentWorkspacePath,
132
+ };
133
+ }
134
+
124
135
  export function getOutputBuffer() {
125
136
  return outputBuffer;
126
137
  }
package/server.js CHANGED
@@ -7,7 +7,7 @@ import { dirname, join, extname } from 'node:path';
7
7
  import { homedir, userInfo, platform, networkInterfaces } from 'node:os';
8
8
  import { execSync } from 'node:child_process';
9
9
  import { Worker } from 'node:worker_threads';
10
- import { LOG_FILE, _initPromise, _resumeState, resolveResumeChoice, _projectName, _logDir, _cachedApiKey, _cachedAuthHeader, _cachedHaikuModel } from './interceptor.js';
10
+ import { LOG_FILE, _initPromise, _resumeState, resolveResumeChoice, _projectName, _logDir, _cachedApiKey, _cachedAuthHeader, _cachedHaikuModel, initForWorkspace, resetWorkspace } from './interceptor.js';
11
11
  import { LOG_DIR } from './findcc.js';
12
12
  import { t, detectLanguage } from './i18n.js';
13
13
  import { checkAndUpdate } from './updater.js';
@@ -15,6 +15,14 @@ import { loadPlugins, runWaterfallHook, runParallelHook, getPluginsInfo, PLUGINS
15
15
 
16
16
  const PREFS_FILE = join(LOG_DIR, 'preferences.json');
17
17
  const isCliMode = process.env.CCV_CLI_MODE === '1';
18
+ const isWorkspaceMode = process.env.CCV_WORKSPACE_MODE === '1';
19
+
20
+ // 工作区模式:保存 Claude 额外参数,供 launch API 使用
21
+ let _workspaceClaudeArgs = [];
22
+ let _workspaceLaunched = false; // 工作区是否已经启动了会话
23
+ export function setWorkspaceClaudeArgs(args) {
24
+ _workspaceClaudeArgs = args;
25
+ }
18
26
 
19
27
 
20
28
  // macOS user profile (avatar + display name), cached once
@@ -413,6 +421,170 @@ async function handleRequest(req, res) {
413
421
  return;
414
422
  }
415
423
 
424
+ // === Workspace API ===
425
+
426
+ // 目录浏览器
427
+ if (url.startsWith('/api/browse-dir') && method === 'GET') {
428
+ try {
429
+ const dirPath = parsedUrl.searchParams.get('path') || homedir();
430
+ if (!existsSync(dirPath) || !statSync(dirPath).isDirectory()) {
431
+ res.writeHead(400, { 'Content-Type': 'application/json' });
432
+ res.end(JSON.stringify({ error: 'Invalid directory' }));
433
+ return;
434
+ }
435
+ const entries = readdirSync(dirPath, { withFileTypes: true });
436
+ const dirs = [];
437
+ for (const entry of entries) {
438
+ if (!entry.isDirectory()) continue;
439
+ if (entry.name.startsWith('.') && entry.name !== '.') continue;
440
+ const fullPath = join(dirPath, entry.name);
441
+ let hasGit = false;
442
+ try { hasGit = existsSync(join(fullPath, '.git')); } catch {}
443
+ dirs.push({ name: entry.name, path: fullPath, hasGit });
444
+ }
445
+ dirs.sort((a, b) => {
446
+ if (a.hasGit !== b.hasGit) return a.hasGit ? -1 : 1;
447
+ return a.name.localeCompare(b.name);
448
+ });
449
+ const parent = join(dirPath, '..');
450
+ res.writeHead(200, { 'Content-Type': 'application/json' });
451
+ res.end(JSON.stringify({ current: dirPath, parent: parent !== dirPath ? parent : null, dirs }));
452
+ } catch (err) {
453
+ res.writeHead(500, { 'Content-Type': 'application/json' });
454
+ res.end(JSON.stringify({ error: err.message }));
455
+ }
456
+ return;
457
+ }
458
+
459
+ if (url === '/api/workspaces' && method === 'GET') {
460
+ import('./workspace-registry.js').then(({ getWorkspaces }) => {
461
+ const workspaces = getWorkspaces();
462
+ res.writeHead(200, { 'Content-Type': 'application/json' });
463
+ res.end(JSON.stringify({ workspaces, workspaceMode: isWorkspaceMode && !_workspaceLaunched }));
464
+ }).catch(err => {
465
+ res.writeHead(500, { 'Content-Type': 'application/json' });
466
+ res.end(JSON.stringify({ error: err.message }));
467
+ });
468
+ return;
469
+ }
470
+
471
+ if (url === '/api/workspaces/launch' && method === 'POST') {
472
+ let body = '';
473
+ req.on('data', chunk => { body += chunk; });
474
+ req.on('end', async () => {
475
+ try {
476
+ const { path: wsPath } = JSON.parse(body);
477
+ if (!wsPath || !existsSync(wsPath) || !statSync(wsPath).isDirectory()) {
478
+ res.writeHead(400, { 'Content-Type': 'application/json' });
479
+ res.end(JSON.stringify({ error: 'Invalid directory path' }));
480
+ return;
481
+ }
482
+
483
+ const { registerWorkspace } = await import('./workspace-registry.js');
484
+ registerWorkspace(wsPath);
485
+
486
+ // 初始化 interceptor 的日志文件
487
+ const result = initForWorkspace(wsPath);
488
+ process.env.CCV_PROJECT_DIR = wsPath;
489
+
490
+ // 启动日志监听
491
+ watchLogFile(LOG_FILE);
492
+
493
+ // 启动 stats worker(如果尚未启动)
494
+ if (!statsWorker) startStatsWorker();
495
+
496
+ // 启动 PTY
497
+ const proxyPort = process.env.CCV_PROXY_PORT;
498
+ if (proxyPort) {
499
+ const { spawnClaude } = await import('./pty-manager.js');
500
+ await spawnClaude(parseInt(proxyPort), wsPath, _workspaceClaudeArgs);
501
+ }
502
+
503
+ _workspaceLaunched = true;
504
+
505
+ // 通知所有 SSE 客户端
506
+ clients.forEach(client => {
507
+ try {
508
+ client.write(`event: workspace_started\ndata: ${JSON.stringify({ projectName: result.projectName, path: wsPath })}\n\n`);
509
+ } catch {}
510
+ });
511
+
512
+ res.writeHead(200, { 'Content-Type': 'application/json' });
513
+ res.end(JSON.stringify({ ok: true, projectName: result.projectName }));
514
+ } catch (err) {
515
+ res.writeHead(500, { 'Content-Type': 'application/json' });
516
+ res.end(JSON.stringify({ error: err.message }));
517
+ }
518
+ });
519
+ return;
520
+ }
521
+
522
+ if (url === '/api/workspaces/add' && method === 'POST') {
523
+ let body = '';
524
+ req.on('data', chunk => { body += chunk; });
525
+ req.on('end', async () => {
526
+ try {
527
+ const { path: wsPath } = JSON.parse(body);
528
+ if (!wsPath || !existsSync(wsPath) || !statSync(wsPath).isDirectory()) {
529
+ res.writeHead(400, { 'Content-Type': 'application/json' });
530
+ res.end(JSON.stringify({ error: 'Invalid directory path' }));
531
+ return;
532
+ }
533
+ const { registerWorkspace } = await import('./workspace-registry.js');
534
+ const entry = registerWorkspace(wsPath);
535
+ res.writeHead(200, { 'Content-Type': 'application/json' });
536
+ res.end(JSON.stringify({ ok: true, workspace: entry }));
537
+ } catch (err) {
538
+ res.writeHead(500, { 'Content-Type': 'application/json' });
539
+ res.end(JSON.stringify({ error: err.message }));
540
+ }
541
+ });
542
+ return;
543
+ }
544
+
545
+ if (url.startsWith('/api/workspaces/') && method === 'DELETE') {
546
+ const id = url.split('/').pop();
547
+ import('./workspace-registry.js').then(({ removeWorkspace }) => {
548
+ const removed = removeWorkspace(id);
549
+ res.writeHead(200, { 'Content-Type': 'application/json' });
550
+ res.end(JSON.stringify({ ok: removed }));
551
+ }).catch(err => {
552
+ res.writeHead(500, { 'Content-Type': 'application/json' });
553
+ res.end(JSON.stringify({ error: err.message }));
554
+ });
555
+ return;
556
+ }
557
+
558
+ if (url === '/api/workspaces/stop' && method === 'POST') {
559
+ import('./pty-manager.js').then(({ killPty }) => {
560
+ killPty();
561
+
562
+ // 停止日志监听
563
+ for (const logFile of watchedFiles.keys()) {
564
+ unwatchFile(logFile);
565
+ }
566
+ watchedFiles.clear();
567
+
568
+ // 重置 interceptor 状态
569
+ resetWorkspace();
570
+ _workspaceLaunched = false;
571
+
572
+ // 通知所有 SSE 客户端
573
+ clients.forEach(client => {
574
+ try {
575
+ client.write(`event: workspace_stopped\ndata: {}\n\n`);
576
+ } catch {}
577
+ });
578
+
579
+ res.writeHead(200, { 'Content-Type': 'application/json' });
580
+ res.end(JSON.stringify({ ok: true }));
581
+ }).catch(err => {
582
+ res.writeHead(500, { 'Content-Type': 'application/json' });
583
+ res.end(JSON.stringify({ error: err.message }));
584
+ });
585
+ return;
586
+ }
587
+
416
588
  // SSE endpoint
417
589
  if (url === '/events' && method === 'GET') {
418
590
  res.writeHead(200, {
@@ -595,7 +767,7 @@ async function handleRequest(req, res) {
595
767
  // CLI 模式检测
596
768
  if (url === '/api/cli-mode' && method === 'GET') {
597
769
  res.writeHead(200, { 'Content-Type': 'application/json' });
598
- res.end(JSON.stringify({ cliMode: isCliMode }));
770
+ res.end(JSON.stringify({ cliMode: isCliMode, workspaceMode: isWorkspaceMode && !_workspaceLaunched }));
599
771
  return;
600
772
  }
601
773
 
@@ -1067,8 +1239,11 @@ export async function startViewer() {
1067
1239
  execSync(`${cmd} ${url}`, { stdio: 'ignore', timeout: 5000 });
1068
1240
  }
1069
1241
  } catch { }
1070
- startWatching();
1071
- startStatsWorker();
1242
+ // 工作区模式下延迟到选择工作区后再启动监听
1243
+ if (!isWorkspaceMode) {
1244
+ startWatching();
1245
+ startStatsWorker();
1246
+ }
1072
1247
  // CLI 模式下启动 WebSocket 服务
1073
1248
  if (isCliMode) {
1074
1249
  setupTerminalWebSocket(currentServer);
@@ -1253,27 +1428,30 @@ export function stopViewer() {
1253
1428
  }
1254
1429
 
1255
1430
  // Auto-start the viewer after log file init completes
1256
- _initPromise.then(() => {
1257
- startViewer().then((srv) => {
1258
- if (!srv) return;
1259
- // 延迟 3 秒异步检查更新
1260
- setTimeout(() => {
1261
- checkAndUpdate().then(result => {
1262
- if (result.status === 'updated') {
1263
- clients.forEach(client => {
1264
- try { client.write(`event: update_completed\ndata: ${JSON.stringify({ version: result.remoteVersion })}\n\n`); } catch { }
1265
- });
1266
- } else if (result.status === 'major_available') {
1267
- clients.forEach(client => {
1268
- try { client.write(`event: update_major_available\ndata: ${JSON.stringify({ version: result.remoteVersion })}\n\n`); } catch { }
1269
- });
1270
- }
1271
- }).catch(() => { });
1272
- }, 3000);
1273
- }).catch(err => {
1274
- console.error('Failed to start CC Viewer:', err);
1431
+ // 工作区模式下由 cli.js 直接 import server.js 触发启动,跳过 _initPromise 自动启动
1432
+ if (!isWorkspaceMode) {
1433
+ _initPromise.then(() => {
1434
+ startViewer().then((srv) => {
1435
+ if (!srv) return;
1436
+ // 延迟 3 秒异步检查更新
1437
+ setTimeout(() => {
1438
+ checkAndUpdate().then(result => {
1439
+ if (result.status === 'updated') {
1440
+ clients.forEach(client => {
1441
+ try { client.write(`event: update_completed\ndata: ${JSON.stringify({ version: result.remoteVersion })}\n\n`); } catch { }
1442
+ });
1443
+ } else if (result.status === 'major_available') {
1444
+ clients.forEach(client => {
1445
+ try { client.write(`event: update_major_available\ndata: ${JSON.stringify({ version: result.remoteVersion })}\n\n`); } catch { }
1446
+ });
1447
+ }
1448
+ }).catch(() => { });
1449
+ }, 3000);
1450
+ }).catch(err => {
1451
+ console.error('Failed to start CC Viewer:', err);
1452
+ });
1275
1453
  });
1276
- });
1454
+ }
1277
1455
 
1278
1456
  // 进程退出时,将未决的临时文件转为正式文件
1279
1457
  function handleExit() {