claude-opencode-viewer 2.3.0 → 2.4.0
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/.claude/settings.local.json +2 -1
- package/index.html +1122 -133
- package/package.json +2 -1
- package/server.js +238 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-opencode-viewer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "A unified terminal viewer for Claude Code and OpenCode with seamless switching",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"homepage": "https://github.com/ChrisJason121238/claude-opencode-viewer#readme",
|
|
35
35
|
"dependencies": {
|
|
36
|
+
"better-sqlite3": "^11.8.1",
|
|
36
37
|
"node-pty": "^1.1.0",
|
|
37
38
|
"ws": "^8.19.0"
|
|
38
39
|
},
|
package/server.js
CHANGED
|
@@ -3,10 +3,11 @@ import { createServer } from 'node:http';
|
|
|
3
3
|
import { existsSync, createReadStream } from 'node:fs';
|
|
4
4
|
import { join, dirname } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { networkInterfaces, platform, arch } from 'node:os';
|
|
6
|
+
import { networkInterfaces, platform, arch, homedir } from 'node:os';
|
|
7
7
|
import { chmodSync, statSync } from 'node:fs';
|
|
8
8
|
import { execSync } from 'child_process';
|
|
9
9
|
import { WebSocketServer } from 'ws';
|
|
10
|
+
import Database from 'better-sqlite3';
|
|
10
11
|
|
|
11
12
|
// 设置进程名为 claude-opencode-viewer
|
|
12
13
|
process.title = 'claude-opencode-viewer';
|
|
@@ -14,6 +15,9 @@ process.title = 'claude-opencode-viewer';
|
|
|
14
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
const PORT = 7008;
|
|
16
17
|
|
|
18
|
+
// OpenCode 数据库路径
|
|
19
|
+
const OPENCODE_DB_PATH = join(homedir(), '.local/share/opencode/opencode.db');
|
|
20
|
+
|
|
17
21
|
const MAX_BUFFER = 200000;
|
|
18
22
|
|
|
19
23
|
let ptyModule = null;
|
|
@@ -93,7 +97,7 @@ function findCommand(cmd) {
|
|
|
93
97
|
return cmd;
|
|
94
98
|
}
|
|
95
99
|
|
|
96
|
-
async function spawnProcess(mode) {
|
|
100
|
+
async function spawnProcess(mode, sessionId = null) {
|
|
97
101
|
const pty = await getPty();
|
|
98
102
|
fixSpawnHelperPermissions();
|
|
99
103
|
|
|
@@ -108,6 +112,11 @@ async function spawnProcess(mode) {
|
|
|
108
112
|
}
|
|
109
113
|
} else {
|
|
110
114
|
command = findCommand('opencode');
|
|
115
|
+
// 如果提供了 sessionId,添加 --session 参数
|
|
116
|
+
if (sessionId) {
|
|
117
|
+
args = ['--session', sessionId];
|
|
118
|
+
console.log(`[opencode] 恢复会话: ${sessionId}`);
|
|
119
|
+
}
|
|
111
120
|
}
|
|
112
121
|
|
|
113
122
|
const proc = pty.spawn(command, args, {
|
|
@@ -126,11 +135,18 @@ async function spawnProcess(mode) {
|
|
|
126
135
|
dataListeners.forEach(cb => cb(data));
|
|
127
136
|
});
|
|
128
137
|
|
|
129
|
-
proc.onExit(() => {
|
|
138
|
+
proc.onExit(({ exitCode }) => {
|
|
139
|
+
console.log(`[onExit] 进程退出, PID: ${proc.pid}, exitCode: ${exitCode}`);
|
|
130
140
|
if (currentProcess === proc) {
|
|
131
141
|
currentProcess = null;
|
|
132
|
-
exitListeners.forEach(cb => cb(0));
|
|
133
142
|
}
|
|
143
|
+
if (claudeProcess === proc) {
|
|
144
|
+
claudeProcess = null;
|
|
145
|
+
}
|
|
146
|
+
if (opencodeProcess === proc) {
|
|
147
|
+
opencodeProcess = null;
|
|
148
|
+
}
|
|
149
|
+
exitListeners.forEach(cb => cb(exitCode || 0));
|
|
134
150
|
});
|
|
135
151
|
|
|
136
152
|
// 只在初始化时杀死旧进程,switchMode 已经处理了切换时的进程清理
|
|
@@ -214,6 +230,155 @@ function resizePty(cols, rows) {
|
|
|
214
230
|
});
|
|
215
231
|
}
|
|
216
232
|
|
|
233
|
+
// 数据库访问函数
|
|
234
|
+
function getOpenCodeSessions() {
|
|
235
|
+
try {
|
|
236
|
+
if (!existsSync(OPENCODE_DB_PATH)) {
|
|
237
|
+
console.log('[DB] OpenCode 数据库不存在:', OPENCODE_DB_PATH);
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const db = new Database(OPENCODE_DB_PATH, { readonly: true });
|
|
242
|
+
|
|
243
|
+
const sessions = db.prepare(`
|
|
244
|
+
SELECT
|
|
245
|
+
s.id,
|
|
246
|
+
s.title,
|
|
247
|
+
s.directory,
|
|
248
|
+
s.time_created,
|
|
249
|
+
s.time_updated,
|
|
250
|
+
p.name as project_name
|
|
251
|
+
FROM session s
|
|
252
|
+
LEFT JOIN project p ON s.project_id = p.id
|
|
253
|
+
WHERE s.parent_id IS NULL
|
|
254
|
+
AND s.time_archived IS NULL
|
|
255
|
+
ORDER BY s.time_updated DESC
|
|
256
|
+
LIMIT 50
|
|
257
|
+
`).all();
|
|
258
|
+
|
|
259
|
+
// 为每个会话获取第一条用户消息作为预览
|
|
260
|
+
const result = sessions.map(session => {
|
|
261
|
+
// 获取第一条用户消息
|
|
262
|
+
const firstMessage = db.prepare(`
|
|
263
|
+
SELECT id
|
|
264
|
+
FROM message
|
|
265
|
+
WHERE session_id = ?
|
|
266
|
+
AND json_extract(data, '$.role') = 'user'
|
|
267
|
+
ORDER BY time_created ASC
|
|
268
|
+
LIMIT 1
|
|
269
|
+
`).get(session.id);
|
|
270
|
+
|
|
271
|
+
let preview = '';
|
|
272
|
+
if (firstMessage) {
|
|
273
|
+
// 获取该消息的文本 part
|
|
274
|
+
const textPart = db.prepare(`
|
|
275
|
+
SELECT data
|
|
276
|
+
FROM part
|
|
277
|
+
WHERE message_id = ?
|
|
278
|
+
AND json_extract(data, '$.type') = 'text'
|
|
279
|
+
LIMIT 1
|
|
280
|
+
`).get(firstMessage.id);
|
|
281
|
+
|
|
282
|
+
if (textPart) {
|
|
283
|
+
try {
|
|
284
|
+
const partData = JSON.parse(textPart.data);
|
|
285
|
+
if (partData.text) {
|
|
286
|
+
// 截取前80个字符作为预览
|
|
287
|
+
preview = partData.text.length > 80
|
|
288
|
+
? partData.text.substring(0, 80) + '...'
|
|
289
|
+
: partData.text;
|
|
290
|
+
}
|
|
291
|
+
} catch (e) {
|
|
292
|
+
console.error('[DB] 解析 part 失败:', e.message);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
...session,
|
|
299
|
+
preview: preview || session.title
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
db.close();
|
|
304
|
+
return result;
|
|
305
|
+
} catch (err) {
|
|
306
|
+
console.error('[DB] 读取会话失败:', err.message);
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function getSessionMessages(sessionId) {
|
|
312
|
+
try {
|
|
313
|
+
if (!existsSync(OPENCODE_DB_PATH)) {
|
|
314
|
+
return [];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const db = new Database(OPENCODE_DB_PATH, { readonly: true });
|
|
318
|
+
|
|
319
|
+
// 获取消息列表
|
|
320
|
+
const messages = db.prepare(`
|
|
321
|
+
SELECT
|
|
322
|
+
id,
|
|
323
|
+
time_created,
|
|
324
|
+
data
|
|
325
|
+
FROM message
|
|
326
|
+
WHERE session_id = ?
|
|
327
|
+
ORDER BY time_created ASC
|
|
328
|
+
`).all(sessionId);
|
|
329
|
+
|
|
330
|
+
// 为每个消息获取其 parts
|
|
331
|
+
const result = messages.map(msg => {
|
|
332
|
+
const msgData = JSON.parse(msg.data);
|
|
333
|
+
|
|
334
|
+
// 获取该消息的所有 parts
|
|
335
|
+
const parts = db.prepare(`
|
|
336
|
+
SELECT data
|
|
337
|
+
FROM part
|
|
338
|
+
WHERE message_id = ?
|
|
339
|
+
ORDER BY time_created ASC
|
|
340
|
+
`).all(msg.id);
|
|
341
|
+
|
|
342
|
+
const parsedParts = parts.map(p => JSON.parse(p.data));
|
|
343
|
+
|
|
344
|
+
// 提取文本内容
|
|
345
|
+
let text = '';
|
|
346
|
+
let reasoning = '';
|
|
347
|
+
let toolCalls = [];
|
|
348
|
+
let toolResults = [];
|
|
349
|
+
|
|
350
|
+
for (const part of parsedParts) {
|
|
351
|
+
if (part.type === 'text' && part.text) {
|
|
352
|
+
text += part.text;
|
|
353
|
+
} else if (part.type === 'reasoning' && part.text) {
|
|
354
|
+
reasoning += part.text;
|
|
355
|
+
} else if (part.type === 'tool-call' || part.type === 'tool_call') {
|
|
356
|
+
toolCalls.push(part);
|
|
357
|
+
} else if (part.type === 'tool-result' || part.type === 'tool_result') {
|
|
358
|
+
toolResults.push(part);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
id: msg.id,
|
|
364
|
+
time_created: msg.time_created,
|
|
365
|
+
role: msgData.role,
|
|
366
|
+
text: text || undefined,
|
|
367
|
+
reasoning: reasoning || undefined,
|
|
368
|
+
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
369
|
+
toolResults: toolResults.length > 0 ? toolResults : undefined,
|
|
370
|
+
metadata: msgData
|
|
371
|
+
};
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
db.close();
|
|
375
|
+
return result;
|
|
376
|
+
} catch (err) {
|
|
377
|
+
console.error('[DB] 读取消息失败:', err.message);
|
|
378
|
+
return [];
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
217
382
|
const server = createServer((req, res) => {
|
|
218
383
|
if (req.url === '/' || req.url === '/index.html') {
|
|
219
384
|
res.writeHead(200, {
|
|
@@ -223,6 +388,30 @@ const server = createServer((req, res) => {
|
|
|
223
388
|
createReadStream(join(__dirname, 'index.html')).pipe(res);
|
|
224
389
|
return;
|
|
225
390
|
}
|
|
391
|
+
|
|
392
|
+
// API: 获取会话列表
|
|
393
|
+
if (req.url === '/api/sessions') {
|
|
394
|
+
res.writeHead(200, {
|
|
395
|
+
'Content-Type': 'application/json',
|
|
396
|
+
'Access-Control-Allow-Origin': '*',
|
|
397
|
+
});
|
|
398
|
+
const sessions = getOpenCodeSessions();
|
|
399
|
+
res.end(JSON.stringify(sessions));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// API: 获取会话消息
|
|
404
|
+
if (req.url?.startsWith('/api/session/')) {
|
|
405
|
+
const sessionId = req.url.split('/').pop();
|
|
406
|
+
res.writeHead(200, {
|
|
407
|
+
'Content-Type': 'application/json',
|
|
408
|
+
'Access-Control-Allow-Origin': '*',
|
|
409
|
+
});
|
|
410
|
+
const messages = getSessionMessages(sessionId);
|
|
411
|
+
res.end(JSON.stringify(messages));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
226
415
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
227
416
|
res.end('Not Found');
|
|
228
417
|
});
|
|
@@ -260,8 +449,20 @@ wss.on('connection', (ws, req) => {
|
|
|
260
449
|
ws.on('message', async (raw) => {
|
|
261
450
|
try {
|
|
262
451
|
const msg = JSON.parse(raw);
|
|
263
|
-
|
|
452
|
+
console.log(`[WS msg] type=${msg.type}, currentProcess=${!!currentProcess}, currentMode=${currentMode}`);
|
|
453
|
+
|
|
264
454
|
if (msg.type === 'input') {
|
|
455
|
+
// 进程已退出时,自动重新启动(参考 cc-viewer 逻辑)
|
|
456
|
+
if (!currentProcess) {
|
|
457
|
+
try {
|
|
458
|
+
console.log(`[respawn] 进程已退出,自动重新启动 ${currentMode}`);
|
|
459
|
+
outputBuffer = '';
|
|
460
|
+
await spawnProcess(currentMode);
|
|
461
|
+
console.log(`[respawn] 重新启动成功, currentProcess=${!!currentProcess}`);
|
|
462
|
+
} catch (e) {
|
|
463
|
+
console.log(`[respawn] 重新启动失败: ${e.message}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
265
466
|
if (activeWs !== ws) {
|
|
266
467
|
activeWs = ws;
|
|
267
468
|
const mSize = getMobileSize();
|
|
@@ -293,6 +494,38 @@ wss.on('connection', (ws, req) => {
|
|
|
293
494
|
}
|
|
294
495
|
}, 100);
|
|
295
496
|
}
|
|
497
|
+
} else if (msg.type === 'restore') {
|
|
498
|
+
// 恢复会话
|
|
499
|
+
if (msg.sessionId && currentMode === 'opencode') {
|
|
500
|
+
console.log(`[restore] 恢复会话: ${msg.sessionId}`);
|
|
501
|
+
|
|
502
|
+
// 杀死当前 opencode 进程
|
|
503
|
+
if (opencodeProcess) {
|
|
504
|
+
try {
|
|
505
|
+
console.log(`[restore] 杀死当前进程 PID: ${opencodeProcess.pid}`);
|
|
506
|
+
opencodeProcess.kill();
|
|
507
|
+
} catch (e) {
|
|
508
|
+
console.log('[restore] 杀死进程失败:', e.message);
|
|
509
|
+
}
|
|
510
|
+
opencodeProcess = null;
|
|
511
|
+
currentProcess = null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// 清空输出缓冲
|
|
515
|
+
outputBuffer = '';
|
|
516
|
+
|
|
517
|
+
// 等待进程完全退出
|
|
518
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
519
|
+
|
|
520
|
+
// 启动新的 opencode 进程,传入 session ID
|
|
521
|
+
try {
|
|
522
|
+
await spawnProcess('opencode', msg.sessionId);
|
|
523
|
+
ws.send(JSON.stringify({ type: 'restored', sessionId: msg.sessionId }));
|
|
524
|
+
} catch (e) {
|
|
525
|
+
console.error('[restore] 启动进程失败:', e.message);
|
|
526
|
+
ws.send(JSON.stringify({ type: 'restore-error', error: e.message }));
|
|
527
|
+
}
|
|
528
|
+
}
|
|
296
529
|
}
|
|
297
530
|
} catch (err) {
|
|
298
531
|
console.error('[WS] Error:', err.message);
|