claude-opencode-viewer 2.6.37 → 2.6.39

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.
Files changed (4) hide show
  1. package/index-pc.html +290 -48
  2. package/index.html +287 -84
  3. package/package.json +1 -1
  4. package/server.js +128 -86
package/server.js CHANGED
@@ -404,7 +404,7 @@ async function switchMode(newMode) {
404
404
  } catch (e) {
405
405
  LOG('[switchMode] 启动新进程失败:', e.message);
406
406
  }
407
- isSwitching = false;
407
+ // 注意:isSwitching 由调用方在回放 buffer 后设为 false
408
408
  }
409
409
 
410
410
  function writeToPty(data) {
@@ -782,6 +782,87 @@ const requestHandler = async (req, res) => {
782
782
  return;
783
783
  }
784
784
 
785
+ // API: 获取最近的 OpenCode 和 Claude 会话(用于启动对话框)
786
+ if (req.url === '/api/last-sessions') {
787
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
788
+ let opencode = null, claude = null;
789
+ // OpenCode: 从 SQLite 查最近会话
790
+ try {
791
+ const db = new Database(OPENCODE_DB_PATH, { readonly: true });
792
+ const row = db.prepare(
793
+ `SELECT id, directory, time_updated FROM session WHERE parent_id IS NULL AND time_archived IS NULL ORDER BY time_updated DESC LIMIT 1`
794
+ ).get();
795
+ db.close();
796
+ if (row) {
797
+ opencode = { id: row.id, mtime: new Date(row.time_updated).getTime(), directory: row.directory || '', preview: '' };
798
+ // 尝试读最近用户消息作为预览(文本在 part 表中)
799
+ try {
800
+ const db2 = new Database(OPENCODE_DB_PATH, { readonly: true });
801
+ const partRow = db2.prepare(
802
+ `SELECT p.data FROM part p JOIN message m ON p.message_id = m.id
803
+ WHERE m.session_id = ? AND json_extract(m.data, '$.role') = 'user'
804
+ ORDER BY m.time_created DESC LIMIT 1`
805
+ ).get(row.id);
806
+ db2.close();
807
+ if (partRow) {
808
+ try {
809
+ const pd = JSON.parse(partRow.data);
810
+ if (pd.text) opencode.preview = pd.text.slice(0, 200);
811
+ } catch {}
812
+ }
813
+ } catch {}
814
+ }
815
+ } catch {}
816
+ // Claude: 扫描 JSONL 找最近文件
817
+ try {
818
+ const projectsDir = join(CLAUDE_HOME, 'projects');
819
+ if (existsSync(projectsDir)) {
820
+ let newest = 0, newestFile = null, newestProj = null;
821
+ for (const projDir of readdirSync(projectsDir, { withFileTypes: true })) {
822
+ if (!projDir.isDirectory()) continue;
823
+ const projPath = join(projectsDir, projDir.name);
824
+ for (const f of readdirSync(projPath)) {
825
+ if (!f.endsWith('.jsonl')) continue;
826
+ try {
827
+ const st = statSync(join(projPath, f));
828
+ if (st.mtimeMs > newest) {
829
+ newest = st.mtimeMs;
830
+ newestFile = f.replace('.jsonl', '');
831
+ newestProj = projDir.name;
832
+ }
833
+ } catch {}
834
+ }
835
+ }
836
+ if (newestFile && newestProj) {
837
+ claude = { id: newestFile, mtime: newest, project: newestProj, directory: '', preview: '' };
838
+ try {
839
+ const content = readFileSync(join(projectsDir, newestProj, newestFile + '.jsonl'), 'utf-8');
840
+ const lines = content.split('\n').slice(0, 30);
841
+ for (const line of lines) {
842
+ if (!line.trim()) continue;
843
+ try {
844
+ const d = JSON.parse(line);
845
+ if (d.cwd && !claude.directory) claude.directory = d.cwd;
846
+ if (d.type === 'user' && d.message && !claude.preview) {
847
+ const c = d.message.content;
848
+ if (typeof c === 'string') claude.preview = c.slice(0, 200);
849
+ else if (Array.isArray(c)) {
850
+ for (const item of c) {
851
+ if (item.type === 'text' && item.text) { claude.preview = item.text.slice(0, 200); break; }
852
+ }
853
+ }
854
+ break;
855
+ }
856
+ } catch {}
857
+ }
858
+ } catch {}
859
+ }
860
+ }
861
+ } catch {}
862
+ res.end(JSON.stringify({ opencode, claude }));
863
+ return;
864
+ }
865
+
785
866
  // API: Claude Code 会话列表(扫描所有项目)
786
867
  if (req.url === '/api/claude-sessions') {
787
868
  res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
@@ -961,28 +1042,7 @@ const requestHandler = async (req, res) => {
961
1042
  const httpsOpts = USE_HTTPS ? await getOrCreateCert() : null;
962
1043
  let server, wss;
963
1044
 
964
- // 无客户端连接后自动退出,防止进程堆积
965
- // 首次启动等 3 分钟(给用户时间打开浏览器),连接过之后断开等 30 秒
966
- let noClientTimer = null;
967
1045
  let hasEverConnected = false;
968
- function startNoClientTimer() {
969
- if (noClientTimer) return;
970
- if (wss && wss.clients.size > 0) return;
971
- const timeout = hasEverConnected ? 10000 : 180000;
972
- noClientTimer = setTimeout(() => {
973
- if (!wss || wss.clients.size === 0) {
974
- LOG(`[auto-exit] ${timeout / 1000}秒无客户端连接,自动退出`);
975
- cleanupAndExit();
976
- }
977
- noClientTimer = null;
978
- }, timeout);
979
- }
980
- function cancelNoClientTimer() {
981
- if (noClientTimer) {
982
- clearTimeout(noClientTimer);
983
- noClientTimer = null;
984
- }
985
- }
986
1046
 
987
1047
  function createServerAndWss() {
988
1048
  server = USE_HTTPS
@@ -1006,7 +1066,6 @@ function setupWss(wssInst) {
1006
1066
  wssInst.on('connection', (ws, req) => {
1007
1067
  LOG('[WS] 客户端连接 from', req.socket.remoteAddress);
1008
1068
  hasEverConnected = true;
1009
- cancelNoClientTimer();
1010
1069
  ws.isAlive = true;
1011
1070
  ws.on('pong', () => { ws.isAlive = true; });
1012
1071
 
@@ -1097,16 +1156,14 @@ wssInst.on('connection', (ws, req) => {
1097
1156
  }
1098
1157
  } else if (msg.type === 'switch') {
1099
1158
  if (msg.mode !== currentMode) {
1159
+ isSwitching = true;
1100
1160
  ws.send(JSON.stringify({ type: 'switching', mode: msg.mode }));
1101
1161
  await switchMode(msg.mode);
1102
- // 切换完成后:先发 reset 清除残留,再发新进程的 buffer
1103
- ws.send(JSON.stringify({ type: 'data', data: '\x1bc' }));
1104
- ws.send(JSON.stringify({ type: 'mode', mode: currentMode }));
1105
- setTimeout(() => {
1106
- if (outputBuffer) {
1107
- ws.send(JSON.stringify({ type: 'data', data: outputBuffer }));
1108
- }
1109
- }, 100);
1162
+ // 切换完成后统一回放(switchMode 期间 isSwitching=true,listener 不推数据)
1163
+ const buf = outputBuffer;
1164
+ outputBuffer = '';
1165
+ ws.send(JSON.stringify({ type: 'mode', mode: currentMode, buffer: buf || undefined }));
1166
+ isSwitching = false;
1110
1167
  }
1111
1168
  } else if (msg.type === 'restore') {
1112
1169
  // 恢复会话(支持 opencode 和 claude)
@@ -1142,13 +1199,29 @@ wssInst.on('connection', (ws, req) => {
1142
1199
  // 启动进程,传入 session ID
1143
1200
  try {
1144
1201
  await spawnProcess(currentMode, msg.sessionId);
1145
- ws.send(JSON.stringify({ type: 'restored', sessionId: msg.sessionId }));
1202
+ const buf = outputBuffer;
1203
+ outputBuffer = '';
1204
+ ws.send(JSON.stringify({ type: 'restored', sessionId: msg.sessionId, buffer: buf || undefined }));
1146
1205
  } catch (e) {
1147
1206
  LOG('[restore] 启动进程失败:', e.message);
1148
1207
  ws.send(JSON.stringify({ type: 'restore-error', error: e.message }));
1149
1208
  }
1150
1209
  isSwitching = false;
1151
1210
  }
1211
+ } else if (msg.type === 'init') {
1212
+ // 首次启动:客户端选择模式和会话后发送
1213
+ const mode = msg.mode || 'claude';
1214
+ currentMode = mode;
1215
+ LOG(`[init] 启动 ${mode}, sessionId=${msg.sessionId || '(新会话)'}`);
1216
+ outputBuffer = '';
1217
+ try {
1218
+ await spawnProcess(mode, msg.sessionId || null);
1219
+ ws.send(JSON.stringify({ type: 'mode', mode: currentMode }));
1220
+ ws.send(JSON.stringify({ type: 'started', sessionId: msg.sessionId || null }));
1221
+ } catch (e) {
1222
+ LOG('[init] 启动失败:', e.message);
1223
+ ws.send(JSON.stringify({ type: 'start-error', error: e.message }));
1224
+ }
1152
1225
  } else if (msg.type === 'start') {
1153
1226
  // 前端启动指令:可选带 sessionId 恢复会话
1154
1227
  const mode = currentMode;
@@ -1185,10 +1258,11 @@ wssInst.on('connection', (ws, req) => {
1185
1258
  await new Promise(resolve => setTimeout(resolve, 500));
1186
1259
  cleanupOrphanProcesses();
1187
1260
 
1188
- // 先通知前端准备好,再启动新进程
1189
- ws.send(JSON.stringify({ type: 'new-session-ok', mode }));
1190
1261
  try {
1191
1262
  await spawnProcess(mode);
1263
+ const buf = outputBuffer;
1264
+ outputBuffer = '';
1265
+ ws.send(JSON.stringify({ type: 'new-session-ok', mode, buffer: buf || undefined }));
1192
1266
  } catch (e) {
1193
1267
  LOG('[new-session] 启动失败:', e.message);
1194
1268
  ws.send(JSON.stringify({ type: 'new-session-error', error: e.message }));
@@ -1220,6 +1294,17 @@ wssInst.on('connection', (ws, req) => {
1220
1294
  setTimeout(() => checkNewSession(attempt + 1), 2000);
1221
1295
  };
1222
1296
  setTimeout(() => checkNewSession(0), 3000);
1297
+ } else if (msg.type === 'quit') {
1298
+ // PC 端关闭浏览器时发送,延迟 5 秒退出(防止刷新页面误杀)
1299
+ LOG('[quit] 收到退出请求,5秒后检查是否仍无连接...');
1300
+ setTimeout(() => {
1301
+ if (!wss || wss.clients.size === 0) {
1302
+ LOG('[quit] 无活跃连接,退出进程');
1303
+ cleanupAndExit();
1304
+ } else {
1305
+ LOG('[quit] 仍有活跃连接,取消退出');
1306
+ }
1307
+ }, 5000);
1223
1308
  }
1224
1309
  } catch (err) {
1225
1310
  LOG('[WS] Error:', err.message);
@@ -1248,15 +1333,19 @@ wssInst.on('connection', (ws, req) => {
1248
1333
  }
1249
1334
  }
1250
1335
  }
1251
- // 无客户端连接时,30秒后自动退出
1252
- startNoClientTimer();
1253
1336
  });
1254
1337
  });
1255
1338
 
1256
1339
  // WebSocket 心跳保活,防止中间网络设备断开空闲连接
1340
+ // 移动端跳过心跳检测:锁屏时 JS 暂停无法回 pong,但不需要断开(进程常驻)
1257
1341
  const HEARTBEAT_INTERVAL = 5000;
1258
1342
  const heartbeat = setInterval(() => {
1259
1343
  wssInst.clients.forEach((ws) => {
1344
+ if (mobileClients.has(ws)) {
1345
+ // 移动端不检测心跳,但清理已断开的僵尸连接
1346
+ if (ws.readyState !== 1) mobileClients.delete(ws);
1347
+ return;
1348
+ }
1260
1349
  if (ws.isAlive === false) return ws.terminate();
1261
1350
  ws.isAlive = false;
1262
1351
  ws.ping();
@@ -1298,57 +1387,10 @@ function startServer() {
1298
1387
  cleanupOrphanProcesses();
1299
1388
 
1300
1389
 
1301
- // 尝试恢复最近的会话,如果没有则新建
1302
- if (currentMode === 'opencode') {
1303
- let lastSessionId = null;
1304
- try {
1305
- const db = new Database(OPENCODE_DB_PATH, { readonly: true });
1306
- const row = db.prepare(
1307
- `SELECT id FROM session WHERE parent_id IS NULL AND time_archived IS NULL ORDER BY time_updated DESC LIMIT 1`
1308
- ).get();
1309
- db.close();
1310
- if (row) lastSessionId = row.id;
1311
- } catch (e) {}
1312
-
1313
- if (lastSessionId) {
1314
- LOG(`[startup] 恢复最近会话: ${lastSessionId}`);
1315
- await spawnProcess('opencode', lastSessionId);
1316
- } else {
1317
- await spawnProcess('opencode');
1318
- }
1319
- } else {
1320
- // Claude 模式:找最近的会话恢复
1321
- let lastClaudeSession = null;
1322
- try {
1323
- const projectsDir = join(CLAUDE_HOME, 'projects');
1324
- if (existsSync(projectsDir)) {
1325
- let newest = 0;
1326
- for (const projDir of readdirSync(projectsDir, { withFileTypes: true })) {
1327
- if (!projDir.isDirectory()) continue;
1328
- const projPath = join(projectsDir, projDir.name);
1329
- for (const f of readdirSync(projPath)) {
1330
- if (!f.endsWith('.jsonl')) continue;
1331
- try {
1332
- const st = statSync(join(projPath, f));
1333
- if (st.mtimeMs > newest) {
1334
- newest = st.mtimeMs;
1335
- lastClaudeSession = f.replace('.jsonl', '');
1336
- }
1337
- } catch {}
1338
- }
1339
- }
1340
- }
1341
- } catch {}
1342
- if (lastClaudeSession) {
1343
- LOG(`[startup] 恢复最近Claude会话: ${lastClaudeSession}`);
1344
- await spawnProcess('claude', lastClaudeSession);
1345
- } else {
1346
- await spawnProcess('claude');
1347
- }
1348
- }
1390
+ // 延迟启动:等待 PC 客户端发送 init 消息后再 spawn 进程
1391
+ // 移动端客户端连接时自动启动 claude(见 WS 连接处理)
1392
+ LOG('[startup] 等待客户端连接并选择会话...');
1349
1393
 
1350
- // 启动首次连接超时检测(3分钟无人连接则退出)
1351
- startNoClientTimer();
1352
1394
  });
1353
1395
 
1354
1396
  server.on('error', (err) => {