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 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
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.843",
3
+ "version": "1.0.845",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
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
- wsRouter.onLegacy((data, ws) => {
514
- try {
515
- if (data.type === 'subscribe') {
516
- if (data.sessionId) {
517
- ws.subscriptions.add(data.sessionId);
518
- if (!subscriptionIndex.has(data.sessionId)) subscriptionIndex.set(data.sessionId, new Set());
519
- subscriptionIndex.get(data.sessionId).add(ws);
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,