cc-viewer 1.5.13 → 1.5.15
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 +1 -1
- package/dist/assets/{index-DuRhaLLC.css → index-DI-cFJZT.css} +1 -1
- package/dist/assets/{index-Db56U3Ow.js → index-VTa-G52S.js} +82 -79
- package/dist/index.html +2 -2
- package/lib/ccv-editor.js +73 -0
- package/package.json +1 -1
- package/pty-manager.js +75 -5
- package/server.js +106 -10
package/dist/index.html
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
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-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-VTa-G52S.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DI-cFJZT.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<div id="root"></div>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ccv-editor.js — Custom $EDITOR wrapper for cc-viewer.
|
|
5
|
+
*
|
|
6
|
+
* When Claude Code spawns $EDITOR (e.g. /memory), this script is invoked instead.
|
|
7
|
+
* It notifies the cc-viewer server to open the file in the built-in FileContentView,
|
|
8
|
+
* then polls until the user closes the editor in the web UI.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { resolve } from 'node:path';
|
|
13
|
+
|
|
14
|
+
const filePath = resolve(process.argv[2] || '');
|
|
15
|
+
if (!filePath) {
|
|
16
|
+
console.error('Usage: ccv-editor <file>');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const port = process.env.CCV_EDITOR_PORT;
|
|
21
|
+
if (!port) {
|
|
22
|
+
console.error('CCV_EDITOR_PORT not set');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sessionId = randomUUID();
|
|
27
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
28
|
+
const POLL_INTERVAL = 500;
|
|
29
|
+
const TIMEOUT = 30 * 60 * 1000; // 30 minutes
|
|
30
|
+
|
|
31
|
+
async function main() {
|
|
32
|
+
// Notify server to open editor
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(`${baseUrl}/api/editor-open`, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
body: JSON.stringify({ sessionId, filePath }),
|
|
38
|
+
});
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
const err = await res.text();
|
|
41
|
+
console.error('Failed to open editor:', err);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error('Failed to connect to cc-viewer server:', err.message);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Poll until done
|
|
50
|
+
const start = Date.now();
|
|
51
|
+
while (true) {
|
|
52
|
+
if (Date.now() - start > TIMEOUT) {
|
|
53
|
+
console.error('Editor session timed out');
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await new Promise(r => setTimeout(r, POLL_INTERVAL));
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch(`${baseUrl}/api/editor-status?id=${sessionId}`);
|
|
61
|
+
if (!res.ok) continue;
|
|
62
|
+
const data = await res.json();
|
|
63
|
+
if (data.done) {
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// Connection error — server may have restarted, exit
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
main();
|
package/package.json
CHANGED
package/pty-manager.js
CHANGED
|
@@ -4,12 +4,18 @@ import { join, dirname } from 'node:path';
|
|
|
4
4
|
import { chmodSync, statSync } from 'node:fs';
|
|
5
5
|
import { platform, arch } from 'node:os';
|
|
6
6
|
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
|
|
7
10
|
let ptyProcess = null;
|
|
8
11
|
let dataListeners = [];
|
|
9
12
|
let exitListeners = [];
|
|
10
13
|
let lastExitCode = null;
|
|
11
14
|
let outputBuffer = '';
|
|
12
15
|
let currentWorkspacePath = null;
|
|
16
|
+
let lastWorkspacePath = null; // 进程退出后保留,用于 respawn shell
|
|
17
|
+
let lastPtyCols = 120;
|
|
18
|
+
let lastPtyRows = 30;
|
|
13
19
|
const MAX_BUFFER = 200000;
|
|
14
20
|
let batchBuffer = '';
|
|
15
21
|
let batchScheduled = false;
|
|
@@ -65,7 +71,6 @@ function flushBatch() {
|
|
|
65
71
|
|
|
66
72
|
function fixSpawnHelperPermissions() {
|
|
67
73
|
try {
|
|
68
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
69
74
|
const os = platform();
|
|
70
75
|
const cpu = arch();
|
|
71
76
|
const helperPath = join(__dirname, 'node_modules', 'node-pty', 'prebuilds', `${os}-${cpu}`, 'spawn-helper');
|
|
@@ -76,7 +81,7 @@ function fixSpawnHelperPermissions() {
|
|
|
76
81
|
} catch {}
|
|
77
82
|
}
|
|
78
83
|
|
|
79
|
-
export async function spawnClaude(proxyPort, cwd, extraArgs = [], claudePath = null, isNpmVersion = false) {
|
|
84
|
+
export async function spawnClaude(proxyPort, cwd, extraArgs = [], claudePath = null, isNpmVersion = false, serverPort = null) {
|
|
80
85
|
if (ptyProcess) {
|
|
81
86
|
killPty();
|
|
82
87
|
}
|
|
@@ -98,6 +103,14 @@ export async function spawnClaude(proxyPort, cwd, extraArgs = [], claudePath = n
|
|
|
98
103
|
env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${proxyPort}`;
|
|
99
104
|
env.CCV_PROXY_MODE = '1'; // 告诉 interceptor.js 不要再启动 server
|
|
100
105
|
|
|
106
|
+
// Override EDITOR/VISUAL to use built-in FileContentView
|
|
107
|
+
if (serverPort) {
|
|
108
|
+
const editorScript = join(__dirname, 'lib', 'ccv-editor.js');
|
|
109
|
+
env.EDITOR = `${process.execPath} ${editorScript}`;
|
|
110
|
+
env.VISUAL = env.EDITOR;
|
|
111
|
+
env.CCV_EDITOR_PORT = String(serverPort);
|
|
112
|
+
}
|
|
113
|
+
|
|
101
114
|
// 通过 --settings 注入 ANTHROPIC_BASE_URL,确保覆盖 settings.json 中的配置。
|
|
102
115
|
// 仅覆盖 env.ANTHROPIC_BASE_URL,不影响其他 settings 字段。
|
|
103
116
|
const settingsJson = JSON.stringify({
|
|
@@ -118,12 +131,13 @@ export async function spawnClaude(proxyPort, cwd, extraArgs = [], claudePath = n
|
|
|
118
131
|
lastExitCode = null;
|
|
119
132
|
outputBuffer = '';
|
|
120
133
|
currentWorkspacePath = cwd || process.cwd();
|
|
134
|
+
lastWorkspacePath = currentWorkspacePath;
|
|
121
135
|
|
|
122
136
|
ptyProcess = pty.spawn(command, args, {
|
|
123
137
|
name: 'xterm-256color',
|
|
124
|
-
cols:
|
|
125
|
-
rows:
|
|
126
|
-
cwd:
|
|
138
|
+
cols: lastPtyCols,
|
|
139
|
+
rows: lastPtyRows,
|
|
140
|
+
cwd: currentWorkspacePath,
|
|
127
141
|
env,
|
|
128
142
|
});
|
|
129
143
|
|
|
@@ -145,6 +159,7 @@ export async function spawnClaude(proxyPort, cwd, extraArgs = [], claudePath = n
|
|
|
145
159
|
flushBatch();
|
|
146
160
|
lastExitCode = exitCode;
|
|
147
161
|
ptyProcess = null;
|
|
162
|
+
// 保留 lastWorkspacePath,不清除,用于 respawn
|
|
148
163
|
currentWorkspacePath = null;
|
|
149
164
|
for (const cb of exitListeners) {
|
|
150
165
|
try { cb(exitCode); } catch {}
|
|
@@ -160,7 +175,62 @@ export function writeToPty(data) {
|
|
|
160
175
|
}
|
|
161
176
|
}
|
|
162
177
|
|
|
178
|
+
/**
|
|
179
|
+
* 进程退出后,自动 spawn 一个交互式 shell,让终端恢复可用。
|
|
180
|
+
* 返回 true 表示成功 spawn,false 表示无需或失败。
|
|
181
|
+
*/
|
|
182
|
+
export async function spawnShell() {
|
|
183
|
+
if (ptyProcess) return false; // 已有进程在运行
|
|
184
|
+
const cwd = lastWorkspacePath || process.cwd();
|
|
185
|
+
|
|
186
|
+
const ptyMod = await import('node-pty');
|
|
187
|
+
const pty = ptyMod.default || ptyMod;
|
|
188
|
+
|
|
189
|
+
fixSpawnHelperPermissions();
|
|
190
|
+
|
|
191
|
+
const shell = process.env.SHELL || '/bin/sh';
|
|
192
|
+
|
|
193
|
+
lastExitCode = null;
|
|
194
|
+
currentWorkspacePath = cwd;
|
|
195
|
+
|
|
196
|
+
ptyProcess = pty.spawn(shell, [], {
|
|
197
|
+
name: 'xterm-256color',
|
|
198
|
+
cols: lastPtyCols,
|
|
199
|
+
rows: lastPtyRows,
|
|
200
|
+
cwd,
|
|
201
|
+
env: { ...process.env },
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
ptyProcess.onData((data) => {
|
|
205
|
+
outputBuffer += data;
|
|
206
|
+
if (outputBuffer.length > MAX_BUFFER) {
|
|
207
|
+
const rawStart = outputBuffer.length - MAX_BUFFER;
|
|
208
|
+
const safeStart = findSafeSliceStart(outputBuffer, rawStart);
|
|
209
|
+
outputBuffer = outputBuffer.slice(safeStart);
|
|
210
|
+
}
|
|
211
|
+
batchBuffer += data;
|
|
212
|
+
if (!batchScheduled) {
|
|
213
|
+
batchScheduled = true;
|
|
214
|
+
setImmediate(flushBatch);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
219
|
+
flushBatch();
|
|
220
|
+
lastExitCode = exitCode;
|
|
221
|
+
ptyProcess = null;
|
|
222
|
+
currentWorkspacePath = null;
|
|
223
|
+
for (const cb of exitListeners) {
|
|
224
|
+
try { cb(exitCode); } catch {}
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
163
231
|
export function resizePty(cols, rows) {
|
|
232
|
+
lastPtyCols = cols;
|
|
233
|
+
lastPtyRows = rows;
|
|
164
234
|
if (ptyProcess) {
|
|
165
235
|
try { ptyProcess.resize(cols, rows); } catch {}
|
|
166
236
|
}
|
package/server.js
CHANGED
|
@@ -53,6 +53,10 @@ let _workspaceClaudeArgs = [];
|
|
|
53
53
|
let _workspaceClaudePath = null;
|
|
54
54
|
let _workspaceIsNpmVersion = false;
|
|
55
55
|
let _workspaceLaunched = false; // 工作区是否已经启动了会话
|
|
56
|
+
|
|
57
|
+
// Editor session state (for $EDITOR intercept)
|
|
58
|
+
const editorSessions = new Map(); // sessionId → { filePath, done }
|
|
59
|
+
let terminalWss = null; // WebSocketServer reference for broadcasting
|
|
56
60
|
export function setWorkspaceClaudeArgs(args) {
|
|
57
61
|
_workspaceClaudeArgs = args;
|
|
58
62
|
}
|
|
@@ -542,7 +546,7 @@ async function handleRequest(req, res) {
|
|
|
542
546
|
const proxyPort = process.env.CCV_PROXY_PORT;
|
|
543
547
|
if (proxyPort) {
|
|
544
548
|
const { spawnClaude } = await import('./pty-manager.js');
|
|
545
|
-
await spawnClaude(parseInt(proxyPort), wsPath, _workspaceClaudeArgs, _workspaceClaudePath, _workspaceIsNpmVersion);
|
|
549
|
+
await spawnClaude(parseInt(proxyPort), wsPath, _workspaceClaudeArgs, _workspaceClaudePath, _workspaceIsNpmVersion, actualPort);
|
|
546
550
|
}
|
|
547
551
|
|
|
548
552
|
_workspaceLaunched = true;
|
|
@@ -830,16 +834,95 @@ async function handleRequest(req, res) {
|
|
|
830
834
|
return;
|
|
831
835
|
}
|
|
832
836
|
|
|
837
|
+
// === Editor session API (for $EDITOR intercept) ===
|
|
838
|
+
|
|
839
|
+
if (url === '/api/editor-open' && method === 'POST') {
|
|
840
|
+
let body = '';
|
|
841
|
+
req.on('data', chunk => { body += chunk; });
|
|
842
|
+
req.on('end', () => {
|
|
843
|
+
try {
|
|
844
|
+
const { sessionId, filePath } = JSON.parse(body);
|
|
845
|
+
if (!sessionId || !filePath) {
|
|
846
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
847
|
+
res.end(JSON.stringify({ error: 'Missing sessionId or filePath' }));
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
editorSessions.set(sessionId, { filePath, done: false });
|
|
851
|
+
// Broadcast to all terminal WebSocket clients
|
|
852
|
+
if (terminalWss) {
|
|
853
|
+
const msg = JSON.stringify({ type: 'editor-open', sessionId, filePath });
|
|
854
|
+
terminalWss.clients.forEach(client => {
|
|
855
|
+
if (client.readyState === 1) {
|
|
856
|
+
try { client.send(msg); } catch {}
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
861
|
+
res.end(JSON.stringify({ ok: true }));
|
|
862
|
+
} catch {
|
|
863
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
864
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (url.startsWith('/api/editor-status') && method === 'GET') {
|
|
871
|
+
const id = parsedUrl.searchParams.get('id');
|
|
872
|
+
if (!id) {
|
|
873
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
874
|
+
res.end(JSON.stringify({ error: 'Missing id' }));
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
const session = editorSessions.get(id);
|
|
878
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
879
|
+
res.end(JSON.stringify({ done: session ? session.done : true }));
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (url === '/api/editor-done' && method === 'POST') {
|
|
884
|
+
let body = '';
|
|
885
|
+
req.on('data', chunk => { body += chunk; });
|
|
886
|
+
req.on('end', () => {
|
|
887
|
+
try {
|
|
888
|
+
const { sessionId } = JSON.parse(body);
|
|
889
|
+
if (!sessionId) {
|
|
890
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
891
|
+
res.end(JSON.stringify({ error: 'Missing sessionId' }));
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
const session = editorSessions.get(sessionId);
|
|
895
|
+
if (session) {
|
|
896
|
+
session.done = true;
|
|
897
|
+
}
|
|
898
|
+
// Clean up after a short delay to allow the polling to pick it up
|
|
899
|
+
setTimeout(() => editorSessions.delete(sessionId), 5000);
|
|
900
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
901
|
+
res.end(JSON.stringify({ ok: true }));
|
|
902
|
+
} catch {
|
|
903
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
904
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
|
|
833
910
|
// 读取文件内容 API
|
|
834
911
|
if (url === '/api/file-content' && method === 'GET') {
|
|
835
912
|
const reqPath = parsedUrl.searchParams.get('path');
|
|
836
|
-
|
|
913
|
+
const isEditorSession = parsedUrl.searchParams.get('editorSession') === 'true';
|
|
914
|
+
if (!reqPath) {
|
|
837
915
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
838
916
|
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
839
917
|
return;
|
|
840
918
|
}
|
|
841
|
-
|
|
842
|
-
|
|
919
|
+
// Allow absolute paths only for editor sessions
|
|
920
|
+
if (!isEditorSession && (reqPath.startsWith('/') || reqPath.includes('..'))) {
|
|
921
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
922
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
const targetFile = isEditorSession && reqPath.startsWith('/') ? reqPath : join(process.env.CCV_PROJECT_DIR || process.cwd(), reqPath);
|
|
843
926
|
try {
|
|
844
927
|
if (!existsSync(targetFile)) {
|
|
845
928
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
@@ -884,8 +967,14 @@ async function handleRequest(req, res) {
|
|
|
884
967
|
return;
|
|
885
968
|
}
|
|
886
969
|
try {
|
|
887
|
-
const { path: reqPath, content } = JSON.parse(body);
|
|
888
|
-
if (!reqPath
|
|
970
|
+
const { path: reqPath, content, editorSession } = JSON.parse(body);
|
|
971
|
+
if (!reqPath) {
|
|
972
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
973
|
+
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
// Allow absolute paths only for editor sessions
|
|
977
|
+
if (!editorSession && (reqPath.startsWith('/') || reqPath.includes('..'))) {
|
|
889
978
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
890
979
|
res.end(JSON.stringify({ error: 'Invalid path' }));
|
|
891
980
|
return;
|
|
@@ -895,8 +984,7 @@ async function handleRequest(req, res) {
|
|
|
895
984
|
res.end(JSON.stringify({ error: 'Content must be a string' }));
|
|
896
985
|
return;
|
|
897
986
|
}
|
|
898
|
-
const
|
|
899
|
-
const targetFile = join(cwd, reqPath);
|
|
987
|
+
const targetFile = editorSession && reqPath.startsWith('/') ? reqPath : join(process.env.CCV_PROJECT_DIR || process.cwd(), reqPath);
|
|
900
988
|
writeFileSync(targetFile, content, 'utf-8');
|
|
901
989
|
const stat = statSync(targetFile);
|
|
902
990
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -1616,10 +1704,11 @@ export async function startViewer() {
|
|
|
1616
1704
|
async function setupTerminalWebSocket(httpServer) {
|
|
1617
1705
|
try {
|
|
1618
1706
|
const { WebSocketServer } = await import('ws');
|
|
1619
|
-
const { writeToPty, resizePty, onPtyData, onPtyExit, getPtyState, getOutputBuffer, getCurrentWorkspace } = await import('./pty-manager.js');
|
|
1707
|
+
const { writeToPty, resizePty, onPtyData, onPtyExit, getPtyState, getOutputBuffer, getCurrentWorkspace, spawnShell } = await import('./pty-manager.js');
|
|
1620
1708
|
// const { default: chokidar } = await import('chokidar');
|
|
1621
1709
|
|
|
1622
1710
|
const wss = new WebSocketServer({ noServer: true });
|
|
1711
|
+
terminalWss = wss;
|
|
1623
1712
|
|
|
1624
1713
|
// 多客户端共享 PTY 的尺寸冲突解决:
|
|
1625
1714
|
// 移动端优先——只要有移动端在线,PTY 始终使用移动端尺寸,
|
|
@@ -1811,10 +1900,17 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1811
1900
|
});
|
|
1812
1901
|
|
|
1813
1902
|
// WebSocket → PTY
|
|
1814
|
-
ws.on('message', (raw) => {
|
|
1903
|
+
ws.on('message', async (raw) => {
|
|
1815
1904
|
try {
|
|
1816
1905
|
const msg = JSON.parse(raw.toString());
|
|
1817
1906
|
if (msg.type === 'input') {
|
|
1907
|
+
// PTY 已退出时,自动 spawn 交互式 shell
|
|
1908
|
+
const state = getPtyState();
|
|
1909
|
+
if (!state.running) {
|
|
1910
|
+
try {
|
|
1911
|
+
await spawnShell();
|
|
1912
|
+
} catch {}
|
|
1913
|
+
}
|
|
1818
1914
|
// 发送 input 的客户端成为活跃客户端
|
|
1819
1915
|
if (activeWs !== ws) {
|
|
1820
1916
|
activeWs = ws;
|