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.
- package/index-pc.html +290 -48
- package/index.html +287 -84
- package/package.json +1 -1
- 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
|
|
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
|
-
//
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1303
|
-
|
|
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) => {
|