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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-opencode-viewer",
3
- "version": "2.3.0",
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);