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/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-Db56U3Ow.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-DuRhaLLC.css">
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.5.13",
3
+ "version": "1.5.15",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
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: 120,
125
- rows: 30,
126
- cwd: cwd || process.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
- if (!reqPath || reqPath.startsWith('/') || reqPath.includes('..')) {
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
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
842
- const targetFile = join(cwd, reqPath);
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 || reqPath.startsWith('/') || reqPath.includes('..')) {
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 cwd = process.env.CCV_PROJECT_DIR || process.cwd();
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;