agentgui 1.0.843 → 1.0.845
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/CHANGELOG.md +1 -0
- package/CLAUDE.md +1 -0
- package/lib/ws-legacy-handlers.js +154 -0
- package/lib/ws-setup.js +77 -0
- package/package.json +1 -1
- package/server.js +9 -338
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
3
|
### Refactor
|
|
4
|
+
- Extract WebSocket server setup and legacy message handler from server.js to lib/ws-setup.js (77L, createWsSetup factory) and lib/ws-legacy-handlers.js (154L, registerLegacyHandler); server.js reduced from 1077L to 748L; wss.on('connection'), heartbeat interval, hot-reload watcher, subscribe/unsubscribe/terminal/PM2 legacy handlers all moved; unused WebSocketServer, spawn, createRequire imports removed from server.js
|
|
4
5
|
- Extract express upload + fsbrowse setup from server.js to lib/routes-upload.js (79L) exporting createExpressApp; server.js imports createExpressApp and no longer contains Busboy/fsbrowse/express inline code
|
|
5
6
|
- Extract maskKey, getProviderConfigs, saveProviderConfig, buildSystemPrompt, PROVIDER_CONFIGS from server.js to lib/provider-config.js (151L); extract logError, makeCleanupExecution, makeGetModelsForAgent, errLogPath from server.js to lib/server-utils.js (61L); server.js imports all via named imports; cleanupExecution wired after broadcastSync; _debugRoutes receives errLogPath
|
|
6
7
|
- Extract parseBody, acceptsEncoding, compressAndSend, sendJSON from server.js to lib/http-utils.js (43L); server.js imports from new module; zlib import removed from server.js
|
package/CLAUDE.md
CHANGED
|
@@ -60,6 +60,7 @@ lib/routes-runs.js Runs HTTP route handlers (POST /api/runs, runs search, ru
|
|
|
60
60
|
lib/routes-scripts.js Scripts/cancel/resume/inject HTTP route handlers (conversation scripts, run-script, stop-script, cancel, resume, inject)
|
|
61
61
|
lib/routes-agent-actions.js Agent auth and update HTTP route handlers (POST /api/agents/:id/auth, POST /api/agents/:id/update)
|
|
62
62
|
lib/routes-auth-config.js Auth config HTTP route handlers (GET /api/auth/configs, POST /api/auth/save-config)
|
|
63
|
+
lib/routes-upload.js Express sub-app: POST /api/upload/:conversationId (Busboy file upload) + GET /files/:conversationId fsbrowse router; createExpressApp(deps) factory
|
|
63
64
|
lib/http-utils.js HTTP utility functions (parseBody, acceptsEncoding, compressAndSend, sendJSON)
|
|
64
65
|
lib/provider-config.js Provider config helpers (buildSystemPrompt, maskKey, getProviderConfigs, saveProviderConfig, PROVIDER_CONFIGS)
|
|
65
66
|
lib/server-utils.js Server utility functions (logError, errLogPath, makeCleanupExecution, makeGetModelsForAgent)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { createRequire } from 'module';
|
|
3
|
+
|
|
4
|
+
const _req = createRequire(import.meta.url);
|
|
5
|
+
|
|
6
|
+
export function registerLegacyHandler(wsRouter, { subscriptionIndex, execMachine, activeExecutions, messageQueues, checkpointManager, queries, pm2Manager, pm2Subscribers, getSeq, sendWs, debugLog }) {
|
|
7
|
+
wsRouter.onLegacy((data, ws) => {
|
|
8
|
+
try {
|
|
9
|
+
if (data.type === 'subscribe') {
|
|
10
|
+
if (data.sessionId) {
|
|
11
|
+
ws.subscriptions.add(data.sessionId);
|
|
12
|
+
if (!subscriptionIndex.has(data.sessionId)) subscriptionIndex.set(data.sessionId, new Set());
|
|
13
|
+
subscriptionIndex.get(data.sessionId).add(ws);
|
|
14
|
+
}
|
|
15
|
+
if (data.conversationId) {
|
|
16
|
+
const key = `conv-${data.conversationId}`;
|
|
17
|
+
ws.subscriptions.add(key);
|
|
18
|
+
if (!subscriptionIndex.has(key)) subscriptionIndex.set(key, new Set());
|
|
19
|
+
subscriptionIndex.get(key).add(ws);
|
|
20
|
+
}
|
|
21
|
+
const subTarget = data.sessionId || data.conversationId;
|
|
22
|
+
debugLog(`[WebSocket] Client ${ws.clientId} subscribed to ${subTarget}`);
|
|
23
|
+
sendWs(ws, { type: 'subscription_confirmed', sessionId: data.sessionId, conversationId: data.conversationId, timestamp: Date.now() });
|
|
24
|
+
if (data.conversationId && execMachine.isActive(data.conversationId)) {
|
|
25
|
+
const ctx = execMachine.getContext(data.conversationId);
|
|
26
|
+
const execution = activeExecutions.get(data.conversationId);
|
|
27
|
+
const sessionId = ctx?.sessionId || execution?.sessionId;
|
|
28
|
+
const conv = queries.getConversation(data.conversationId);
|
|
29
|
+
const queueLength = execMachine.getQueue(data.conversationId).length || messageQueues.get(data.conversationId)?.length || 0;
|
|
30
|
+
sendWs(ws, { type: 'streaming_start', sessionId, conversationId: data.conversationId, agentId: conv?.agentType || conv?.agentId || 'claude-code', queueLength, resumed: true, seq: getSeq(), timestamp: Date.now() });
|
|
31
|
+
}
|
|
32
|
+
if (data.conversationId && checkpointManager.hasPendingCheckpoint(data.conversationId)) {
|
|
33
|
+
const checkpoint = checkpointManager.getPendingCheckpoint(data.conversationId);
|
|
34
|
+
if (checkpoint) {
|
|
35
|
+
debugLog(`[checkpoint] Injecting ${checkpoint.events.length} events to client for ${data.conversationId}`);
|
|
36
|
+
const latestSession = queries.getLatestSession(data.conversationId);
|
|
37
|
+
if (latestSession) {
|
|
38
|
+
sendWs(ws, { type: 'streaming_resumed', sessionId: latestSession.id, conversationId: data.conversationId, resumeFrom: checkpoint.sessionId, eventCount: checkpoint.events.length, chunkCount: checkpoint.chunks.length, timestamp: Date.now() });
|
|
39
|
+
checkpointManager.injectCheckpointEvents(latestSession.id, checkpoint, (evt) => {
|
|
40
|
+
sendWs(ws, { ...evt, sessionId: latestSession.id, conversationId: data.conversationId });
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} else if (data.type === 'unsubscribe') {
|
|
46
|
+
if (data.sessionId) {
|
|
47
|
+
ws.subscriptions.delete(data.sessionId);
|
|
48
|
+
const idx = subscriptionIndex.get(data.sessionId);
|
|
49
|
+
if (idx) { idx.delete(ws); if (idx.size === 0) subscriptionIndex.delete(data.sessionId); }
|
|
50
|
+
}
|
|
51
|
+
if (data.conversationId) {
|
|
52
|
+
const key = `conv-${data.conversationId}`;
|
|
53
|
+
ws.subscriptions.delete(key);
|
|
54
|
+
const idx = subscriptionIndex.get(key);
|
|
55
|
+
if (idx) { idx.delete(ws); if (idx.size === 0) subscriptionIndex.delete(key); }
|
|
56
|
+
}
|
|
57
|
+
debugLog(`[WebSocket] Client ${ws.clientId} unsubscribed from ${data.sessionId || data.conversationId}`);
|
|
58
|
+
} else if (data.type === 'get_subscriptions') {
|
|
59
|
+
sendWs(ws, { type: 'subscriptions', subscriptions: Array.from(ws.subscriptions), timestamp: Date.now() });
|
|
60
|
+
} else if (data.type === 'set_voice') {
|
|
61
|
+
ws.ttsVoiceId = data.voiceId || 'default';
|
|
62
|
+
} else if (data.type === 'latency_report') {
|
|
63
|
+
ws.latencyTier = data.quality || 'good';
|
|
64
|
+
ws.latencyAvg = data.avg || 0;
|
|
65
|
+
ws.latencyTrend = data.trend || 'stable';
|
|
66
|
+
} else if (data.type === 'ping') {
|
|
67
|
+
sendWs(ws, { type: 'pong', requestId: data.requestId, timestamp: Date.now() });
|
|
68
|
+
} else if (data.type === 'terminal_start') {
|
|
69
|
+
if (ws.terminalProc) { try { ws.terminalProc.kill(); } catch (_) {} }
|
|
70
|
+
try {
|
|
71
|
+
const pty = _req('node-pty');
|
|
72
|
+
const shell = process.env.SHELL || '/bin/bash';
|
|
73
|
+
const cwd = data.cwd || process.env.STARTUP_CWD || process.env.HOME || '/';
|
|
74
|
+
const proc = pty.spawn(shell, [], { name: 'xterm-256color', cols: data.cols || 80, rows: data.rows || 24, cwd, env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' } });
|
|
75
|
+
ws.terminalProc = proc;
|
|
76
|
+
ws.terminalPty = true;
|
|
77
|
+
proc.on('data', (chunk) => { if (ws.readyState === 1) sendWs(ws, { type: 'terminal_output', data: Buffer.from(chunk).toString('base64'), encoding: 'base64' }); });
|
|
78
|
+
proc.on('exit', (code) => { if (ws.readyState === 1) sendWs(ws, { type: 'terminal_exit', code }); ws.terminalProc = null; });
|
|
79
|
+
proc.on('error', (err) => { console.error('[TERMINAL] PTY error (contained):', err.message); if (ws.readyState === 1) sendWs(ws, { type: 'terminal_exit', code: 1, error: err.message }); ws.terminalProc = null; });
|
|
80
|
+
sendWs(ws, { type: 'terminal_started', timestamp: Date.now() });
|
|
81
|
+
} catch (_e) {
|
|
82
|
+
console.error('[TERMINAL] Failed to spawn PTY, falling back to pipes:', _e.message);
|
|
83
|
+
const shell = process.env.SHELL || '/bin/bash';
|
|
84
|
+
const cwd = data.cwd || process.env.STARTUP_CWD || process.env.HOME || '/';
|
|
85
|
+
const proc = spawn(shell, ['-i'], { cwd, env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
86
|
+
ws.terminalProc = proc;
|
|
87
|
+
ws.terminalPty = false;
|
|
88
|
+
proc.stdout.on('data', (chunk) => { if (ws.readyState === 1) sendWs(ws, { type: 'terminal_output', data: chunk.toString('base64'), encoding: 'base64' }); });
|
|
89
|
+
proc.stderr.on('data', (chunk) => { if (ws.readyState === 1) sendWs(ws, { type: 'terminal_output', data: chunk.toString('base64'), encoding: 'base64' }); });
|
|
90
|
+
proc.on('exit', (code) => { if (ws.readyState === 1) sendWs(ws, { type: 'terminal_exit', code }); ws.terminalProc = null; });
|
|
91
|
+
proc.on('error', (err) => { console.error('[TERMINAL] Spawn error (contained):', err.message); if (ws.readyState === 1) sendWs(ws, { type: 'terminal_exit', code: 1, error: err.message }); ws.terminalProc = null; });
|
|
92
|
+
proc.stdin.on('error', () => {});
|
|
93
|
+
proc.stdout.on('error', () => {});
|
|
94
|
+
proc.stderr.on('error', () => {});
|
|
95
|
+
sendWs(ws, { type: 'terminal_started', timestamp: Date.now() });
|
|
96
|
+
}
|
|
97
|
+
} else if (data.type === 'terminal_input') {
|
|
98
|
+
if (ws.terminalProc) {
|
|
99
|
+
try {
|
|
100
|
+
const input = Buffer.from(data.data, 'base64');
|
|
101
|
+
if (ws.terminalPty) { ws.terminalProc.write(input); }
|
|
102
|
+
else if (ws.terminalProc.stdin && ws.terminalProc.stdin.writable) { ws.terminalProc.stdin.write(input); }
|
|
103
|
+
} catch (_) {}
|
|
104
|
+
}
|
|
105
|
+
} else if (data.type === 'terminal_resize') {
|
|
106
|
+
if (ws.terminalProc && ws.terminalPty) {
|
|
107
|
+
try {
|
|
108
|
+
const { cols, rows } = data;
|
|
109
|
+
if (cols && rows && typeof ws.terminalProc.resize === 'function') { ws.terminalProc.resize(cols, rows); }
|
|
110
|
+
} catch (_) {}
|
|
111
|
+
}
|
|
112
|
+
} else if (data.type === 'terminal_stop') {
|
|
113
|
+
if (ws.terminalProc) { try { ws.terminalProc.kill(); } catch (_) {} ws.terminalProc = null; }
|
|
114
|
+
} else if (data.type === 'pm2_list') {
|
|
115
|
+
if (!pm2Manager.connected) {
|
|
116
|
+
if (ws.readyState === 1) sendWs(ws, { type: 'pm2_unavailable', reason: 'PM2 not connected', timestamp: Date.now() });
|
|
117
|
+
} else {
|
|
118
|
+
pm2Manager.listProcesses().then(processes => {
|
|
119
|
+
if (ws.readyState === 1) {
|
|
120
|
+
const hasActive = processes.some(p => ['online', 'launching', 'stopping', 'waiting restart'].includes(p.status));
|
|
121
|
+
sendWs(ws, { type: 'pm2_list_response', processes, hasActive });
|
|
122
|
+
}
|
|
123
|
+
}).catch(() => { if (ws.readyState === 1) sendWs(ws, { type: 'pm2_unavailable', reason: 'list failed', timestamp: Date.now() }); });
|
|
124
|
+
}
|
|
125
|
+
} else if (data.type === 'pm2_start_monitoring') {
|
|
126
|
+
pm2Subscribers.add(ws);
|
|
127
|
+
ws.pm2Subscribed = true;
|
|
128
|
+
if (!pm2Manager.connected) {
|
|
129
|
+
if (ws.readyState === 1) sendWs(ws, { type: 'pm2_unavailable', reason: 'PM2 not connected', timestamp: Date.now() });
|
|
130
|
+
} else {
|
|
131
|
+
sendWs(ws, { type: 'pm2_monitoring_started' });
|
|
132
|
+
}
|
|
133
|
+
} else if (data.type === 'pm2_stop_monitoring') {
|
|
134
|
+
pm2Subscribers.delete(ws);
|
|
135
|
+
ws.pm2Subscribed = false;
|
|
136
|
+
sendWs(ws, { type: 'pm2_monitoring_stopped' });
|
|
137
|
+
} else if (data.type === 'pm2_start') {
|
|
138
|
+
pm2Manager.startProcess(data.name).then(result => { sendWs(ws, { type: 'pm2_start_response', name: data.name, ...result }); });
|
|
139
|
+
} else if (data.type === 'pm2_stop') {
|
|
140
|
+
pm2Manager.stopProcess(data.name).then(result => { sendWs(ws, { type: 'pm2_stop_response', name: data.name, ...result }); });
|
|
141
|
+
} else if (data.type === 'pm2_restart') {
|
|
142
|
+
pm2Manager.restartProcess(data.name).then(result => { sendWs(ws, { type: 'pm2_restart_response', name: data.name, ...result }); });
|
|
143
|
+
} else if (data.type === 'pm2_delete') {
|
|
144
|
+
pm2Manager.deleteProcess(data.name).then(result => { sendWs(ws, { type: 'pm2_delete_response', name: data.name, ...result }); });
|
|
145
|
+
} else if (data.type === 'pm2_logs') {
|
|
146
|
+
pm2Manager.getLogs(data.name, { lines: data.lines || 100 }).then(result => { sendWs(ws, { type: 'pm2_logs_response', name: data.name, ...result }); });
|
|
147
|
+
} else if (data.type === 'pm2_flush_logs') {
|
|
148
|
+
pm2Manager.flushLogs(data.name).then(result => { sendWs(ws, { type: 'pm2_flush_logs_response', name: data.name, ...result }); });
|
|
149
|
+
} else if (data.type === 'pm2_ping') {
|
|
150
|
+
pm2Manager.ping().then(result => { sendWs(ws, { type: 'pm2_ping_response', ...result }); });
|
|
151
|
+
}
|
|
152
|
+
} catch (err) { console.error('[WS-LEGACY] Handler error (contained):', err.message); }
|
|
153
|
+
});
|
|
154
|
+
}
|
package/lib/ws-setup.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { WebSocketServer } from 'ws';
|
|
4
|
+
import { registerLegacyHandler } from './ws-legacy-handlers.js';
|
|
5
|
+
|
|
6
|
+
export function createWsSetup(server, { BASE_URL, watch, staticDir, _assetCache, htmlState, sendWs, wsRouter, debugLog, subscriptionIndex, syncClients, pm2Subscribers, wsOptimizer, legacyDeps }) {
|
|
7
|
+
const hotReloadClients = [];
|
|
8
|
+
|
|
9
|
+
const wss = new WebSocketServer({ server, perMessageDeflate: false });
|
|
10
|
+
wss.on('error', (err) => { console.error('[WSS] WebSocket server error (contained):', err.message); });
|
|
11
|
+
|
|
12
|
+
wss.on('connection', (ws, req) => {
|
|
13
|
+
const _pwd = process.env.PASSWORD;
|
|
14
|
+
if (_pwd) {
|
|
15
|
+
const url = new URL(req.url, 'http://localhost');
|
|
16
|
+
const token = url.searchParams.get('token');
|
|
17
|
+
if (token !== _pwd) { ws.close(4001, 'Unauthorized'); return; }
|
|
18
|
+
}
|
|
19
|
+
const wsPath = req.url.split('?')[0];
|
|
20
|
+
const wsRoute = wsPath.startsWith(BASE_URL) ? wsPath.slice(BASE_URL.length) : wsPath;
|
|
21
|
+
if (wsRoute === '/hot-reload') {
|
|
22
|
+
hotReloadClients.push(ws);
|
|
23
|
+
ws.on('close', () => { const i = hotReloadClients.indexOf(ws); if (i > -1) hotReloadClients.splice(i, 1); });
|
|
24
|
+
} else if (wsRoute === '/sync') {
|
|
25
|
+
syncClients.add(ws);
|
|
26
|
+
ws.isAlive = true;
|
|
27
|
+
ws.subscriptions = new Set();
|
|
28
|
+
ws.clientId = `client-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
29
|
+
sendWs(ws, { type: 'sync_connected', clientId: ws.clientId, timestamp: Date.now() });
|
|
30
|
+
ws.on('error', (err) => { console.error('[WS] Client error (contained):', ws.clientId, err.message); });
|
|
31
|
+
ws.on('message', (msg) => { try { wsRouter.onMessage(ws, msg); } catch (e) { console.error('[WS] Message handler error (contained):', e.message); } });
|
|
32
|
+
ws.on('pong', () => { ws.isAlive = true; });
|
|
33
|
+
ws.on('close', () => {
|
|
34
|
+
if (ws.terminalProc) { try { ws.terminalProc.kill(); } catch (_) {} ws.terminalProc = null; }
|
|
35
|
+
syncClients.delete(ws);
|
|
36
|
+
wsOptimizer.removeClient(ws);
|
|
37
|
+
for (const sub of ws.subscriptions) {
|
|
38
|
+
const idx = subscriptionIndex.get(sub);
|
|
39
|
+
if (idx) { idx.delete(ws); if (idx.size === 0) subscriptionIndex.delete(sub); }
|
|
40
|
+
}
|
|
41
|
+
if (ws.pm2Subscribed) pm2Subscribers.delete(ws);
|
|
42
|
+
debugLog(`[WebSocket] Client ${ws.clientId} disconnected`);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
registerLegacyHandler(wsRouter, legacyDeps);
|
|
48
|
+
|
|
49
|
+
setInterval(() => {
|
|
50
|
+
syncClients.forEach(ws => {
|
|
51
|
+
if (!ws.isAlive) { syncClients.delete(ws); wsOptimizer.removeClient(ws); return ws.terminate(); }
|
|
52
|
+
ws.isAlive = false;
|
|
53
|
+
ws.ping();
|
|
54
|
+
});
|
|
55
|
+
}, 30000);
|
|
56
|
+
|
|
57
|
+
if (watch) {
|
|
58
|
+
const watchedFiles = new Map();
|
|
59
|
+
try {
|
|
60
|
+
fs.readdirSync(staticDir).forEach(file => {
|
|
61
|
+
const fp = path.join(staticDir, file);
|
|
62
|
+
if (watchedFiles.has(fp)) return;
|
|
63
|
+
fs.watchFile(fp, { interval: 100 }, (curr, prev) => {
|
|
64
|
+
if (curr.mtime > prev.mtime) {
|
|
65
|
+
_assetCache.clear();
|
|
66
|
+
htmlState.cache = null;
|
|
67
|
+
htmlState.etag = null;
|
|
68
|
+
hotReloadClients.forEach(c => { if (c.readyState === 1) c.send(JSON.stringify({ type: 'reload' })); });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
watchedFiles.set(fp, true);
|
|
72
|
+
});
|
|
73
|
+
} catch (e) { console.error('Watch error:', e.message); }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { wss, hotReloadClients };
|
|
77
|
+
}
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -3,10 +3,7 @@ import fs from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
|
-
import { WebSocketServer } from 'ws';
|
|
7
|
-
import { execSync, spawn } from 'child_process';
|
|
8
6
|
import { LRUCache } from 'lru-cache';
|
|
9
|
-
import { createRequire } from 'module';
|
|
10
7
|
import crypto from 'crypto';
|
|
11
8
|
const PKG_VERSION = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
|
|
12
9
|
import { createExpressApp } from './lib/routes-upload.js';
|
|
@@ -35,6 +32,7 @@ import { WSOptimizer } from './lib/ws-optimizer.js';
|
|
|
35
32
|
import { WsRouter } from './lib/ws-protocol.js';
|
|
36
33
|
import { encode as wsEncode } from './lib/codec.js';
|
|
37
34
|
import { parseBody, acceptsEncoding, compressAndSend, sendJSON } from './lib/http-utils.js';
|
|
35
|
+
import { createWsSetup } from './lib/ws-setup.js';
|
|
38
36
|
const sendWs = (ws, obj) => { if (ws.readyState === 1) ws.send(wsEncode(obj)); };
|
|
39
37
|
import { register as registerConvHandlers } from './lib/ws-handlers-conv.js';
|
|
40
38
|
import { register as registerConvHandlers2 } from './lib/ws-handlers-conv2.js';
|
|
@@ -325,72 +323,10 @@ const _assetDeps = { compressAndSend, acceptsEncoding, watch, BASE_URL, PKG_VERS
|
|
|
325
323
|
function serveFile(filePath, res, req) { return _serveFile(filePath, res, req, _assetDeps); }
|
|
326
324
|
|
|
327
325
|
let broadcastSeq = 0;
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const wss = new WebSocketServer({
|
|
332
|
-
server,
|
|
333
|
-
perMessageDeflate: false // Disabled: msgpack binary doesn't compress well, and
|
|
334
|
-
// synchronous zlib on every frame blocks the event loop.
|
|
335
|
-
// HTTP-layer gzip already handles static assets; WS
|
|
336
|
-
// streaming events are small and latency-sensitive.
|
|
337
|
-
});
|
|
338
|
-
wss.on('error', (err) => {
|
|
339
|
-
console.error('[WSS] WebSocket server error (contained):', err.message);
|
|
340
|
-
});
|
|
341
|
-
const hotReloadClients = [];
|
|
342
326
|
const syncClients = new Set();
|
|
343
327
|
const subscriptionIndex = new Map();
|
|
344
328
|
const pm2Subscribers = new Set();
|
|
345
329
|
|
|
346
|
-
wss.on('connection', (ws, req) => {
|
|
347
|
-
const _pwd = process.env.PASSWORD;
|
|
348
|
-
if (_pwd) {
|
|
349
|
-
const url = new URL(req.url, 'http://localhost');
|
|
350
|
-
const token = url.searchParams.get('token');
|
|
351
|
-
if (token !== _pwd) { ws.close(4001, 'Unauthorized'); return; }
|
|
352
|
-
}
|
|
353
|
-
const wsPath = req.url.split('?')[0];
|
|
354
|
-
const wsRoute = wsPath.startsWith(BASE_URL) ? wsPath.slice(BASE_URL.length) : wsPath;
|
|
355
|
-
if (wsRoute === '/hot-reload') {
|
|
356
|
-
hotReloadClients.push(ws);
|
|
357
|
-
ws.on('close', () => { const i = hotReloadClients.indexOf(ws); if (i > -1) hotReloadClients.splice(i, 1); });
|
|
358
|
-
} else if (wsRoute === '/sync') {
|
|
359
|
-
syncClients.add(ws);
|
|
360
|
-
ws.isAlive = true;
|
|
361
|
-
ws.subscriptions = new Set();
|
|
362
|
-
ws.clientId = `client-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
363
|
-
|
|
364
|
-
sendWs(ws, ({
|
|
365
|
-
type: 'sync_connected',
|
|
366
|
-
clientId: ws.clientId,
|
|
367
|
-
timestamp: Date.now()
|
|
368
|
-
}));
|
|
369
|
-
|
|
370
|
-
ws.on('error', (err) => {
|
|
371
|
-
console.error('[WS] Client error (contained):', ws.clientId, err.message);
|
|
372
|
-
});
|
|
373
|
-
ws.on('message', (msg) => {
|
|
374
|
-
try { wsRouter.onMessage(ws, msg); } catch (e) { console.error('[WS] Message handler error (contained):', e.message); }
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
ws.on('pong', () => { ws.isAlive = true; });
|
|
378
|
-
ws.on('close', () => {
|
|
379
|
-
if (ws.terminalProc) { try { ws.terminalProc.kill(); } catch(e) {} ws.terminalProc = null; }
|
|
380
|
-
syncClients.delete(ws);
|
|
381
|
-
wsOptimizer.removeClient(ws);
|
|
382
|
-
for (const sub of ws.subscriptions) {
|
|
383
|
-
const idx = subscriptionIndex.get(sub);
|
|
384
|
-
if (idx) { idx.delete(ws); if (idx.size === 0) subscriptionIndex.delete(sub); }
|
|
385
|
-
}
|
|
386
|
-
if (ws.pm2Subscribed) {
|
|
387
|
-
pm2Subscribers.delete(ws);
|
|
388
|
-
}
|
|
389
|
-
debugLog(`[WebSocket] Client ${ws.clientId} disconnected`);
|
|
390
|
-
});
|
|
391
|
-
}
|
|
392
|
-
});
|
|
393
|
-
|
|
394
330
|
const BROADCAST_TYPES = new Set([
|
|
395
331
|
'message_created', 'conversation_created', 'conversation_updated',
|
|
396
332
|
'conversations_updated', 'conversation_deleted', 'all_conversations_deleted', 'queue_status', 'queue_updated', 'queue_item_dequeued',
|
|
@@ -510,281 +446,16 @@ registerOAuthHandlers(wsRouter, {
|
|
|
510
446
|
codexOAuthState: getCodexOAuthState,
|
|
511
447
|
});
|
|
512
448
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
if (data.conversationId) {
|
|
522
|
-
const key = `conv-${data.conversationId}`;
|
|
523
|
-
ws.subscriptions.add(key);
|
|
524
|
-
if (!subscriptionIndex.has(key)) subscriptionIndex.set(key, new Set());
|
|
525
|
-
subscriptionIndex.get(key).add(ws);
|
|
526
|
-
}
|
|
527
|
-
const subTarget = data.sessionId || data.conversationId;
|
|
528
|
-
debugLog(`[WebSocket] Client ${ws.clientId} subscribed to ${subTarget}`);
|
|
529
|
-
sendWs(ws, ({
|
|
530
|
-
type: 'subscription_confirmed',
|
|
531
|
-
sessionId: data.sessionId,
|
|
532
|
-
conversationId: data.conversationId,
|
|
533
|
-
timestamp: Date.now()
|
|
534
|
-
}));
|
|
535
|
-
|
|
536
|
-
// Notify client if this conversation has an active streaming execution
|
|
537
|
-
// Machine is authoritative for streaming state check on subscribe
|
|
538
|
-
if (data.conversationId && execMachine.isActive(data.conversationId)) {
|
|
539
|
-
const ctx = execMachine.getContext(data.conversationId);
|
|
540
|
-
const execution = activeExecutions.get(data.conversationId);
|
|
541
|
-
const sessionId = ctx?.sessionId || execution?.sessionId;
|
|
542
|
-
const conv = queries.getConversation(data.conversationId);
|
|
543
|
-
const queueLength = execMachine.getQueue(data.conversationId).length || messageQueues.get(data.conversationId)?.length || 0;
|
|
544
|
-
sendWs(ws, ({
|
|
545
|
-
type: 'streaming_start',
|
|
546
|
-
sessionId,
|
|
547
|
-
conversationId: data.conversationId,
|
|
548
|
-
agentId: conv?.agentType || conv?.agentId || 'claude-code',
|
|
549
|
-
queueLength,
|
|
550
|
-
resumed: true,
|
|
551
|
-
seq: ++broadcastSeq,
|
|
552
|
-
timestamp: Date.now()
|
|
553
|
-
}));
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// Inject pending checkpoint events if this is a conversation subscription
|
|
557
|
-
if (data.conversationId && checkpointManager.hasPendingCheckpoint(data.conversationId)) {
|
|
558
|
-
const checkpoint = checkpointManager.getPendingCheckpoint(data.conversationId);
|
|
559
|
-
if (checkpoint) {
|
|
560
|
-
debugLog(`[checkpoint] Injecting ${checkpoint.events.length} events to client for ${data.conversationId}`);
|
|
561
|
-
|
|
562
|
-
const latestSession = queries.getLatestSession(data.conversationId);
|
|
563
|
-
if (latestSession) {
|
|
564
|
-
sendWs(ws, ({
|
|
565
|
-
type: 'streaming_resumed',
|
|
566
|
-
sessionId: latestSession.id,
|
|
567
|
-
conversationId: data.conversationId,
|
|
568
|
-
resumeFrom: checkpoint.sessionId,
|
|
569
|
-
eventCount: checkpoint.events.length,
|
|
570
|
-
chunkCount: checkpoint.chunks.length,
|
|
571
|
-
timestamp: Date.now()
|
|
572
|
-
}));
|
|
573
|
-
|
|
574
|
-
checkpointManager.injectCheckpointEvents(latestSession.id, checkpoint, (evt) => {
|
|
575
|
-
sendWs(ws, ({
|
|
576
|
-
...evt,
|
|
577
|
-
sessionId: latestSession.id,
|
|
578
|
-
conversationId: data.conversationId
|
|
579
|
-
}));
|
|
580
|
-
});
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
} else if (data.type === 'unsubscribe') {
|
|
585
|
-
if (data.sessionId) {
|
|
586
|
-
ws.subscriptions.delete(data.sessionId);
|
|
587
|
-
const idx = subscriptionIndex.get(data.sessionId);
|
|
588
|
-
if (idx) { idx.delete(ws); if (idx.size === 0) subscriptionIndex.delete(data.sessionId); }
|
|
589
|
-
}
|
|
590
|
-
if (data.conversationId) {
|
|
591
|
-
const key = `conv-${data.conversationId}`;
|
|
592
|
-
ws.subscriptions.delete(key);
|
|
593
|
-
const idx = subscriptionIndex.get(key);
|
|
594
|
-
if (idx) { idx.delete(ws); if (idx.size === 0) subscriptionIndex.delete(key); }
|
|
595
|
-
}
|
|
596
|
-
debugLog(`[WebSocket] Client ${ws.clientId} unsubscribed from ${data.sessionId || data.conversationId}`);
|
|
597
|
-
} else if (data.type === 'get_subscriptions') {
|
|
598
|
-
sendWs(ws, ({
|
|
599
|
-
type: 'subscriptions',
|
|
600
|
-
subscriptions: Array.from(ws.subscriptions),
|
|
601
|
-
timestamp: Date.now()
|
|
602
|
-
}));
|
|
603
|
-
} else if (data.type === 'set_voice') {
|
|
604
|
-
ws.ttsVoiceId = data.voiceId || 'default';
|
|
605
|
-
} else if (data.type === 'latency_report') {
|
|
606
|
-
ws.latencyTier = data.quality || 'good';
|
|
607
|
-
ws.latencyAvg = data.avg || 0;
|
|
608
|
-
ws.latencyTrend = data.trend || 'stable';
|
|
609
|
-
} else if (data.type === 'ping') {
|
|
610
|
-
sendWs(ws, ({
|
|
611
|
-
type: 'pong',
|
|
612
|
-
requestId: data.requestId,
|
|
613
|
-
timestamp: Date.now()
|
|
614
|
-
}));
|
|
615
|
-
} else if (data.type === 'terminal_start') {
|
|
616
|
-
if (ws.terminalProc) {
|
|
617
|
-
try { ws.terminalProc.kill(); } catch(e) {}
|
|
618
|
-
}
|
|
619
|
-
try {
|
|
620
|
-
const _req = createRequire(import.meta.url);
|
|
621
|
-
const pty = _req('node-pty');
|
|
622
|
-
const shell = process.env.SHELL || '/bin/bash';
|
|
623
|
-
const cwd = data.cwd || process.env.STARTUP_CWD || process.env.HOME || '/';
|
|
624
|
-
const proc = pty.spawn(shell, [], {
|
|
625
|
-
name: 'xterm-256color',
|
|
626
|
-
cols: data.cols || 80,
|
|
627
|
-
rows: data.rows || 24,
|
|
628
|
-
cwd: cwd,
|
|
629
|
-
env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }
|
|
630
|
-
});
|
|
631
|
-
ws.terminalProc = proc;
|
|
632
|
-
ws.terminalPty = true;
|
|
633
|
-
proc.on('data', (chunk) => {
|
|
634
|
-
if (ws.readyState === 1) sendWs(ws, ({ type: 'terminal_output', data: Buffer.from(chunk).toString('base64'), encoding: 'base64' }));
|
|
635
|
-
});
|
|
636
|
-
proc.on('exit', (code) => {
|
|
637
|
-
if (ws.readyState === 1) sendWs(ws, { type: 'terminal_exit', code });
|
|
638
|
-
ws.terminalProc = null;
|
|
639
|
-
});
|
|
640
|
-
proc.on('error', (err) => {
|
|
641
|
-
console.error('[TERMINAL] PTY error (contained):', err.message);
|
|
642
|
-
if (ws.readyState === 1) sendWs(ws, { type: 'terminal_exit', code: 1, error: err.message });
|
|
643
|
-
ws.terminalProc = null;
|
|
644
|
-
});
|
|
645
|
-
sendWs(ws, ({ type: 'terminal_started', timestamp: Date.now() }));
|
|
646
|
-
} catch (e) {
|
|
647
|
-
console.error('[TERMINAL] Failed to spawn PTY, falling back to pipes:', e.message);
|
|
648
|
-
const shell = process.env.SHELL || '/bin/bash';
|
|
649
|
-
const cwd = data.cwd || process.env.STARTUP_CWD || process.env.HOME || '/';
|
|
650
|
-
const proc = spawn(shell, ['-i'], { cwd, env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
651
|
-
ws.terminalProc = proc;
|
|
652
|
-
ws.terminalPty = false;
|
|
653
|
-
proc.stdout.on('data', (chunk) => {
|
|
654
|
-
if (ws.readyState === 1) sendWs(ws, ({ type: 'terminal_output', data: chunk.toString('base64'), encoding: 'base64' }));
|
|
655
|
-
});
|
|
656
|
-
proc.stderr.on('data', (chunk) => {
|
|
657
|
-
if (ws.readyState === 1) sendWs(ws, ({ type: 'terminal_output', data: chunk.toString('base64'), encoding: 'base64' }));
|
|
658
|
-
});
|
|
659
|
-
proc.on('exit', (code) => {
|
|
660
|
-
if (ws.readyState === 1) sendWs(ws, { type: 'terminal_exit', code });
|
|
661
|
-
ws.terminalProc = null;
|
|
662
|
-
});
|
|
663
|
-
proc.on('error', (err) => {
|
|
664
|
-
console.error('[TERMINAL] Spawn error (contained):', err.message);
|
|
665
|
-
if (ws.readyState === 1) sendWs(ws, { type: 'terminal_exit', code: 1, error: err.message });
|
|
666
|
-
ws.terminalProc = null;
|
|
667
|
-
});
|
|
668
|
-
proc.stdin.on('error', () => {});
|
|
669
|
-
proc.stdout.on('error', () => {});
|
|
670
|
-
proc.stderr.on('error', () => {});
|
|
671
|
-
sendWs(ws, ({ type: 'terminal_started', timestamp: Date.now() }));
|
|
672
|
-
}
|
|
673
|
-
} else if (data.type === 'terminal_input') {
|
|
674
|
-
if (ws.terminalProc) {
|
|
675
|
-
try {
|
|
676
|
-
const input = Buffer.from(data.data, 'base64');
|
|
677
|
-
if (ws.terminalPty) {
|
|
678
|
-
ws.terminalProc.write(input);
|
|
679
|
-
} else if (ws.terminalProc.stdin && ws.terminalProc.stdin.writable) {
|
|
680
|
-
ws.terminalProc.stdin.write(input);
|
|
681
|
-
}
|
|
682
|
-
} catch (e) {}
|
|
683
|
-
}
|
|
684
|
-
} else if (data.type === 'terminal_resize') {
|
|
685
|
-
if (ws.terminalProc && ws.terminalPty) {
|
|
686
|
-
try {
|
|
687
|
-
const { cols, rows } = data;
|
|
688
|
-
if (cols && rows && typeof ws.terminalProc.resize === 'function') {
|
|
689
|
-
ws.terminalProc.resize(cols, rows);
|
|
690
|
-
}
|
|
691
|
-
} catch (e) {}
|
|
692
|
-
}
|
|
693
|
-
} else if (data.type === 'terminal_stop') {
|
|
694
|
-
if (ws.terminalProc) {
|
|
695
|
-
try { ws.terminalProc.kill(); } catch(e) {}
|
|
696
|
-
ws.terminalProc = null;
|
|
697
|
-
}
|
|
698
|
-
} else if (data.type === 'pm2_list') {
|
|
699
|
-
if (!pm2Manager.connected) {
|
|
700
|
-
if (ws.readyState === 1) sendWs(ws, ({ type: 'pm2_unavailable', reason: 'PM2 not connected', timestamp: Date.now() }));
|
|
701
|
-
} else {
|
|
702
|
-
pm2Manager.listProcesses().then(processes => {
|
|
703
|
-
if (ws.readyState === 1) {
|
|
704
|
-
const hasActive = processes.some(p => ['online','launching','stopping','waiting restart'].includes(p.status));
|
|
705
|
-
sendWs(ws, { type: 'pm2_list_response', processes, hasActive });
|
|
706
|
-
}
|
|
707
|
-
}).catch(() => {
|
|
708
|
-
if (ws.readyState === 1) sendWs(ws, ({ type: 'pm2_unavailable', reason: 'list failed', timestamp: Date.now() }));
|
|
709
|
-
});
|
|
710
|
-
}
|
|
711
|
-
} else if (data.type === 'pm2_start_monitoring') {
|
|
712
|
-
pm2Subscribers.add(ws);
|
|
713
|
-
ws.pm2Subscribed = true;
|
|
714
|
-
if (!pm2Manager.connected) {
|
|
715
|
-
if (ws.readyState === 1) sendWs(ws, ({ type: 'pm2_unavailable', reason: 'PM2 not connected', timestamp: Date.now() }));
|
|
716
|
-
} else {
|
|
717
|
-
sendWs(ws, { type: 'pm2_monitoring_started' });
|
|
718
|
-
}
|
|
719
|
-
} else if (data.type === 'pm2_stop_monitoring') {
|
|
720
|
-
pm2Subscribers.delete(ws);
|
|
721
|
-
ws.pm2Subscribed = false;
|
|
722
|
-
sendWs(ws, { type: 'pm2_monitoring_stopped' });
|
|
723
|
-
} else if (data.type === 'pm2_start') {
|
|
724
|
-
pm2Manager.startProcess(data.name).then(result => {
|
|
725
|
-
sendWs(ws, { type: 'pm2_start_response', name: data.name, ...result });
|
|
726
|
-
});
|
|
727
|
-
} else if (data.type === 'pm2_stop') {
|
|
728
|
-
pm2Manager.stopProcess(data.name).then(result => {
|
|
729
|
-
sendWs(ws, { type: 'pm2_stop_response', name: data.name, ...result });
|
|
730
|
-
});
|
|
731
|
-
} else if (data.type === 'pm2_restart') {
|
|
732
|
-
pm2Manager.restartProcess(data.name).then(result => {
|
|
733
|
-
sendWs(ws, { type: 'pm2_restart_response', name: data.name, ...result });
|
|
734
|
-
});
|
|
735
|
-
} else if (data.type === 'pm2_delete') {
|
|
736
|
-
pm2Manager.deleteProcess(data.name).then(result => {
|
|
737
|
-
sendWs(ws, { type: 'pm2_delete_response', name: data.name, ...result });
|
|
738
|
-
});
|
|
739
|
-
} else if (data.type === 'pm2_logs') {
|
|
740
|
-
pm2Manager.getLogs(data.name, { lines: data.lines || 100 }).then(result => {
|
|
741
|
-
sendWs(ws, { type: 'pm2_logs_response', name: data.name, ...result });
|
|
742
|
-
});
|
|
743
|
-
} else if (data.type === 'pm2_flush_logs') {
|
|
744
|
-
pm2Manager.flushLogs(data.name).then(result => {
|
|
745
|
-
sendWs(ws, { type: 'pm2_flush_logs_response', name: data.name, ...result });
|
|
746
|
-
});
|
|
747
|
-
} else if (data.type === 'pm2_ping') {
|
|
748
|
-
pm2Manager.ping().then(result => {
|
|
749
|
-
sendWs(ws, { type: 'pm2_ping_response', ...result });
|
|
750
|
-
});
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
} catch (err) { console.error('[WS-LEGACY] Handler error (contained):', err.message); }
|
|
449
|
+
const { wss, hotReloadClients } = createWsSetup(server, {
|
|
450
|
+
BASE_URL, watch, staticDir, _assetCache, htmlState, sendWs, wsRouter, debugLog,
|
|
451
|
+
subscriptionIndex, syncClients, pm2Subscribers, wsOptimizer,
|
|
452
|
+
legacyDeps: {
|
|
453
|
+
subscriptionIndex, execMachine, activeExecutions, messageQueues,
|
|
454
|
+
checkpointManager, queries, pm2Manager, pm2Subscribers,
|
|
455
|
+
getSeq: () => ++broadcastSeq, sendWs, debugLog
|
|
456
|
+
}
|
|
754
457
|
});
|
|
755
458
|
|
|
756
|
-
// Heartbeat interval to detect stale connections
|
|
757
|
-
const heartbeatInterval = setInterval(() => {
|
|
758
|
-
syncClients.forEach(ws => {
|
|
759
|
-
if (!ws.isAlive) {
|
|
760
|
-
syncClients.delete(ws);
|
|
761
|
-
wsOptimizer.removeClient(ws);
|
|
762
|
-
return ws.terminate();
|
|
763
|
-
}
|
|
764
|
-
ws.isAlive = false;
|
|
765
|
-
ws.ping();
|
|
766
|
-
});
|
|
767
|
-
}, 30000);
|
|
768
|
-
|
|
769
|
-
if (watch) {
|
|
770
|
-
const watchedFiles = new Map();
|
|
771
|
-
try {
|
|
772
|
-
fs.readdirSync(staticDir).forEach(file => {
|
|
773
|
-
const fp = path.join(staticDir, file);
|
|
774
|
-
if (watchedFiles.has(fp)) return;
|
|
775
|
-
fs.watchFile(fp, { interval: 100 }, (curr, prev) => {
|
|
776
|
-
if (curr.mtime > prev.mtime) {
|
|
777
|
-
_assetCache.clear();
|
|
778
|
-
htmlState.cache = null;
|
|
779
|
-
htmlState.etag = null;
|
|
780
|
-
hotReloadClients.forEach(c => { if (c.readyState === 1) c.send(JSON.stringify({ type: 'reload' })); });
|
|
781
|
-
}
|
|
782
|
-
});
|
|
783
|
-
watchedFiles.set(fp, true);
|
|
784
|
-
});
|
|
785
|
-
} catch (e) { console.error('Watch error:', e.message); }
|
|
786
|
-
}
|
|
787
|
-
|
|
788
459
|
const { killActiveExecutions, recoverStaleSessions, resumeInterruptedStreams, isProcessAlive, markAgentDead, resumeConversation, performAgentHealthCheck } = createRecovery({
|
|
789
460
|
activeExecutions,
|
|
790
461
|
processMessageWithStreaming,
|