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/cli.js +69 -3
- package/dist/assets/{index-CwFfGP5V.js → index-LxuWMLD-.js} +145 -136
- package/dist/index.html +1 -1
- package/i18n.js +3 -3
- package/interceptor.js +140 -17
- package/package.json +1 -1
- package/pty-manager.js +12 -1
- package/server.js +202 -24
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-
|
|
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 -
|
|
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 -
|
|
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 -
|
|
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
|
-
|
|
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
|
-
//
|
|
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
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
|
-
|
|
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
|
-
|
|
1071
|
-
|
|
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
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
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() {
|