agentgui 1.0.845 → 1.0.847

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,3 +1,12 @@
1
+ ## [Unreleased] - refactor: extract routes registry + wire tool/debug routes
2
+
3
+ - Extract all route and WS handler registrations from server.js L201-270 to lib/routes-registry.js (63L, createRegistry factory)
4
+ - Move BROADCAST_TYPES set to lib/broadcast.js as exported const; server.js imports it
5
+ - Refactor http-handler.js to accept routes object (routes.conv, routes.tools, routes.debug etc.) instead of individual named parameters; wire previously-unwired tool and debug routes
6
+ - Inline _mqDeps object into createMessageQueue call; compact process.on error handlers
7
+ - server.js reduced from 337L to 200L; all lib files ≤200L
8
+
9
+
1
10
  ## [Unreleased]
2
11
 
3
12
  ### Refactor
package/lib/broadcast.js CHANGED
@@ -1,3 +1,20 @@
1
+ export const BROADCAST_TYPES = new Set([
2
+ 'message_created', 'conversation_created', 'conversation_updated',
3
+ 'conversations_updated', 'conversation_deleted', 'all_conversations_deleted', 'queue_status', 'queue_updated', 'queue_item_dequeued',
4
+ 'rate_limit_hit', 'rate_limit_clear',
5
+ 'script_started', 'script_stopped', 'script_output',
6
+ 'model_download_progress', 'stt_progress', 'tts_setup_progress', 'voice_list',
7
+ 'streaming_start', 'streaming_progress', 'streaming_complete', 'streaming_error',
8
+ 'tool_install_started', 'tool_install_progress', 'tool_install_complete', 'tool_install_failed',
9
+ 'tool_update_progress', 'tool_update_complete', 'tool_update_failed',
10
+ 'tool_status_update', 'tool_update_available',
11
+ 'tools_update_started', 'tools_update_complete', 'tools_refresh_complete',
12
+ 'pm2_monit_update', 'pm2_monitoring_started', 'pm2_monitoring_stopped',
13
+ 'pm2_list_response', 'pm2_start_response', 'pm2_stop_response',
14
+ 'pm2_restart_response', 'pm2_delete_response', 'pm2_logs_response',
15
+ 'pm2_flush_logs_response', 'pm2_ping_response', 'pm2_unavailable'
16
+ ]);
17
+
1
18
  export function createBroadcast({ syncClients, subscriptionIndex, wsOptimizer, broadcastTypes, getSeq }) {
2
19
  return function broadcastSync(event) {
3
20
  try {
@@ -0,0 +1,137 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import crypto from 'crypto';
5
+
6
+ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, wss, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap, routes, handleGeminiOAuthCallback, handleCodexOAuthCallback, PORT }) {
7
+ return async function httpHandler(req, res) {
8
+ res.setHeader('Access-Control-Allow-Origin', '*');
9
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
10
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
11
+ if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
12
+ if (req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket') return;
13
+
14
+ const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress;
15
+ const hits = (rateLimitMap.get(clientIp) || 0) + 1;
16
+ rateLimitMap.set(clientIp, hits);
17
+ res.setHeader('X-RateLimit-Limit', RATE_LIMIT_MAX);
18
+ res.setHeader('X-RateLimit-Remaining', Math.max(0, RATE_LIMIT_MAX - hits));
19
+ if (hits > RATE_LIMIT_MAX) { res.writeHead(429, { 'Retry-After': '60' }); res.end('Too Many Requests'); return; }
20
+
21
+ const _pwd = process.env.PASSWORD;
22
+ if (_pwd) {
23
+ const _auth = req.headers['authorization'] || '';
24
+ let _ok = false;
25
+ if (_auth.startsWith('Basic ')) {
26
+ try {
27
+ const _decoded = Buffer.from(_auth.slice(6), 'base64').toString('utf8');
28
+ const _ci = _decoded.indexOf(':');
29
+ if (_ci !== -1) { const _p = _decoded.slice(_ci + 1); try { _ok = _p.length === _pwd.length && crypto.timingSafeEqual(Buffer.from(_p), Buffer.from(_pwd)); } catch { _ok = false; } }
30
+ } catch (_) {}
31
+ }
32
+ if (!_ok) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="agentgui"' }); res.end('Unauthorized'); return; }
33
+ }
34
+
35
+ const pathOnly = req.url.split('?')[0];
36
+ if (pathOnly.startsWith(BASE_URL + '/api/upload/') || pathOnly.startsWith(BASE_URL + '/files/')) return expressApp(req, res);
37
+
38
+ if (req.url === '/favicon.ico' || req.url === BASE_URL + '/favicon.ico') {
39
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#3b82f6"/><text x="50" y="68" font-size="50" font-family="sans-serif" font-weight="bold" fill="white" text-anchor="middle">G</text></svg>';
40
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=86400' });
41
+ res.end(svg); return;
42
+ }
43
+
44
+ if (req.url === '/') { res.writeHead(302, { Location: BASE_URL + '/' }); res.end(); return; }
45
+
46
+ let routePath = req.url;
47
+ if (req.url.startsWith(BASE_URL + '/')) { routePath = req.url.slice(BASE_URL.length); }
48
+ else if (req.url === BASE_URL) { routePath = '/'; }
49
+ else if (req.url.startsWith('/api/') || req.url.startsWith('/js/') || req.url.startsWith('/css/') ||
50
+ req.url.startsWith('/vendor/') || req.url.startsWith('/sync') || req.url === '/' ||
51
+ req.url.startsWith('/conversations/')) { routePath = req.url; }
52
+ else { res.writeHead(404); res.end('Not found'); return; }
53
+
54
+ routePath = routePath || '/';
55
+
56
+ try {
57
+ const pathOnly = routePath.split('?')[0];
58
+
59
+ if (pathOnly === '/oauth2callback' && req.method === 'GET') { await handleGeminiOAuthCallback(req, res, PORT); return; }
60
+ if (pathOnly === '/codex-oauth2callback' && req.method === 'GET') { await handleCodexOAuthCallback(req, res, PORT); return; }
61
+
62
+ if (pathOnly === '/api/health' && req.method === 'GET') {
63
+ let dbStatus = { ok: true };
64
+ try { queries._db.prepare('SELECT 1').get(); } catch (e) { dbStatus = { ok: false, error: e.message }; }
65
+ const queueSizes = {};
66
+ for (const [k, v] of messageQueues) queueSizes[k] = v.length;
67
+ sendJSON(req, res, 200, { status: 'ok', version: PKG_VERSION, uptime: process.uptime(), agents: discoveredAgents.length, activeExecutions: activeExecutions.size, wsClients: wss.clients.size, memory: process.memoryUsage(), acp: getACPStatus(), db: dbStatus, queueSizes });
68
+ return;
69
+ }
70
+
71
+ const convHandler = routes.conv._match(req.method, pathOnly);
72
+ if (convHandler) { await convHandler(req, res); return; }
73
+ const messagesHandler = routes.messages._match(req.method, pathOnly);
74
+ if (messagesHandler) { await messagesHandler(req, res); return; }
75
+ const sessionsHandler = routes.sessions._match(req.method, pathOnly);
76
+ if (sessionsHandler) { await sessionsHandler(req, res); return; }
77
+ const scriptsHandler = routes.scripts._match(req.method, pathOnly);
78
+ if (scriptsHandler) { await scriptsHandler(req, res); return; }
79
+ const runsHandler = routes.runs._match(req.method, pathOnly);
80
+ if (runsHandler) { await runsHandler(req, res); return; }
81
+ const agentHandler = routes.agents._match(req.method, pathOnly);
82
+ if (agentHandler) { await agentHandler(req, res); return; }
83
+ const oauthHandler = routes.oauth._match(req.method, pathOnly);
84
+ if (oauthHandler) { await oauthHandler(req, res); return; }
85
+ const agentActionsHandler = routes.agentActions._match(req.method, pathOnly);
86
+ if (agentActionsHandler) { await agentActionsHandler(req, res); return; }
87
+ const authConfigHandler = routes.authConfig._match(req.method, pathOnly);
88
+ if (authConfigHandler) { await authConfigHandler(req, res); return; }
89
+ const speechHandler = routes.speech._match(req.method, pathOnly);
90
+ if (speechHandler) { await speechHandler(req, res, pathOnly); return; }
91
+ const utilHandler = routes.util._match(req.method, pathOnly);
92
+ if (utilHandler) { await utilHandler(req, res); return; }
93
+ const threadHandler = routes.threads._match(req.method, pathOnly);
94
+ if (threadHandler) { await threadHandler(req, res); return; }
95
+ const toolHandler = routes.tools._match(req.method, pathOnly);
96
+ if (toolHandler) { await toolHandler(req, res); return; }
97
+ const debugHandler = routes.debug._match(req.method, pathOnly);
98
+ if (debugHandler) { await debugHandler(req, res); return; }
99
+ if (routePath.startsWith('/api/image/')) {
100
+ const imagePath = routePath.slice('/api/image/'.length);
101
+ const decodedPath = decodeURIComponent(imagePath);
102
+ const expandedPath = decodedPath.startsWith('~') ? decodedPath.replace('~', os.homedir()) : decodedPath;
103
+ const normalizedPath = path.normalize(expandedPath);
104
+ const isWindows = os.platform() === 'win32';
105
+ const isAbsolute = isWindows ? /^[A-Za-z]:[\\\/]/.test(normalizedPath) : normalizedPath.startsWith('/');
106
+ if (!isAbsolute || normalizedPath.includes('..')) { res.writeHead(403); res.end('Forbidden'); return; }
107
+ try {
108
+ if (!fs.existsSync(normalizedPath)) { res.writeHead(404); res.end('Not found'); return; }
109
+ const ext = path.extname(normalizedPath).toLowerCase();
110
+ const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml' };
111
+ const contentType = mimeTypes[ext] || 'application/octet-stream';
112
+ res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache' });
113
+ res.end(fs.readFileSync(normalizedPath));
114
+ } catch (err) { sendJSON(req, res, 400, { error: err.message }); }
115
+ return;
116
+ }
117
+
118
+ if (pathOnly.match(/^\/conversations\/[^\/]+$/)) { serveFile(path.join(staticDir, 'index.html'), res, req); return; }
119
+
120
+ let filePath = routePath === '/' ? '/index.html' : routePath;
121
+ filePath = path.join(staticDir, filePath);
122
+ const normalizedPath = path.normalize(filePath);
123
+ if (!normalizedPath.startsWith(staticDir)) { res.writeHead(403); res.end('Forbidden'); return; }
124
+
125
+ fs.stat(filePath, (err, stats) => {
126
+ if (err) { res.writeHead(404); res.end('Not found'); return; }
127
+ if (stats.isDirectory()) {
128
+ filePath = path.join(filePath, 'index.html');
129
+ fs.stat(filePath, (err2) => { if (err2) { res.writeHead(404); res.end('Not found'); return; } serveFile(filePath, res, req); });
130
+ } else { serveFile(filePath, res, req); }
131
+ });
132
+ } catch (e) {
133
+ console.error('Server error:', e.message);
134
+ sendJSON(req, res, 500, { error: e.message });
135
+ }
136
+ };
137
+ }
@@ -0,0 +1,62 @@
1
+ import { register as registerSpeechRoutes } from './routes-speech.js';
2
+ import { register as registerOAuthRoutes } from './routes-oauth.js';
3
+ import { register as registerUtilRoutes } from './routes-util.js';
4
+ import { register as registerToolRoutes } from './routes-tools.js';
5
+ import { register as registerThreadRoutes } from './routes-threads.js';
6
+ import { register as registerDebugRoutes } from './routes-debug.js';
7
+ import { register as registerConvRoutes } from './routes-conversations.js';
8
+ import { register as registerAgentRoutes } from './routes-agents.js';
9
+ import { register as registerMessagesRoutes } from './routes-messages.js';
10
+ import { register as registerSessionsRoutes } from './routes-sessions.js';
11
+ import { register as registerRunsRoutes } from './routes-runs.js';
12
+ import { register as registerScriptsRoutes } from './routes-scripts.js';
13
+ import { register as registerAgentActionsRoutes } from './routes-agent-actions.js';
14
+ import { register as registerAuthConfigRoutes } from './routes-auth-config.js';
15
+ import { register as registerConvHandlers } from './ws-handlers-conv.js';
16
+ import { register as registerConvHandlers2 } from './ws-handlers-conv2.js';
17
+ import { register as registerSessionHandlers } from './ws-handlers-session.js';
18
+ import { register as registerSessionHandlers2 } from './ws-handlers-session2.js';
19
+ import { register as registerRunHandlers } from './ws-handlers-run.js';
20
+ import { register as registerUtilHandlers } from './ws-handlers-util.js';
21
+ import { register as registerOAuthHandlers } from './ws-handlers-oauth.js';
22
+ import { register as registerScriptHandlers } from './ws-handlers-scripts.js';
23
+ import { register as registerQueueHandlers } from './ws-handlers-queue.js';
24
+ import { register as registerMsgHandlers } from './ws-handlers-msg.js';
25
+ import { initSpeechManager, getSpeech, voiceCacheManager, modelDownloadState, ensureModelsDownloaded } from './speech-manager.js';
26
+ import { getAgentDescriptor } from './agent-descriptors.js';
27
+ import { startGeminiOAuth, exchangeGeminiOAuthCode, getGeminiOAuthState } from './oauth-gemini.js';
28
+ import { startCodexOAuth, exchangeCodexOAuthCode, getCodexOAuthState } from './oauth-codex.js';
29
+ import { getProviderConfigs, saveProviderConfig } from './provider-config.js';
30
+
31
+ export function createRegistry(wsRouter, deps) {
32
+ const { queries, sendJSON, parseBody, broadcastSync, debugLog, PORT, BASE_URL, rootDir, STARTUP_CWD, PKG_VERSION, processMessageWithStreaming, activeExecutions, activeProcessesByRunId, activeScripts, messageQueues, rateLimitState, cleanupExecution, discoveredAgents, getACPStatus, modelCache, getModelsForAgent, logError, toolManager, syncClients, wsOptimizer, errLogPath, getJsonlWatcher, routes } = deps;
33
+
34
+ initSpeechManager({ broadcastSync, syncClients, queries });
35
+ routes.speech = registerSpeechRoutes({ sendJSON, parseBody, broadcastSync, debugLog });
36
+ routes.oauth = registerOAuthRoutes({ sendJSON, parseBody, PORT, BASE_URL, rootDir });
37
+ routes.util = registerUtilRoutes({ sendJSON, parseBody, queries, STARTUP_CWD, PKG_VERSION });
38
+ routes.tools = registerToolRoutes({ sendJSON, parseBody, queries, broadcastSync, logError, toolManager });
39
+ routes.threads = registerThreadRoutes({ sendJSON, parseBody, queries });
40
+ routes.debug = registerDebugRoutes({ sendJSON, queries, activeExecutions, messageQueues, syncClients, wsOptimizer, _errLogPath: errLogPath });
41
+ routes.conv = registerConvRoutes({ sendJSON, parseBody, queries, activeExecutions, broadcastSync });
42
+ routes.agents = registerAgentRoutes({ sendJSON, parseBody, queries, discoveredAgents, getACPStatus, modelCache, getModelsForAgent, debugLog });
43
+ routes.messages = registerMessagesRoutes({ queries, sendJSON, parseBody, broadcastSync, processMessageWithStreaming, activeExecutions, messageQueues, debugLog, logError });
44
+ routes.sessions = registerSessionsRoutes({ queries, sendJSON, activeExecutions, rateLimitState, debugLog });
45
+ routes.runs = registerRunsRoutes({ sendJSON, parseBody, queries, broadcastSync, processMessageWithStreaming, activeExecutions, activeProcessesByRunId, discoveredAgents, STARTUP_CWD });
46
+ routes.scripts = registerScriptsRoutes({ sendJSON, parseBody, queries, broadcastSync, activeScripts, activeExecutions, processMessageWithStreaming, STARTUP_CWD });
47
+ routes.agentActions = registerAgentActionsRoutes({ sendJSON, queries, broadcastSync, discoveredAgents, activeScripts, startGeminiOAuth, startCodexOAuth, getGeminiOAuthState, getCodexOAuthState, modelCache, PORT, BASE_URL, rootDir });
48
+ routes.authConfig = registerAuthConfigRoutes({ sendJSON, parseBody, getProviderConfigs, saveProviderConfig });
49
+
50
+ registerConvHandlers(wsRouter, { queries, activeExecutions, rateLimitState, broadcastSync, processMessageWithStreaming, cleanupExecution, getJsonlWatcher });
51
+ registerConvHandlers2(wsRouter, { queries, activeExecutions, rateLimitState, broadcastSync, processMessageWithStreaming, cleanupExecution, getJsonlWatcher });
52
+ registerMsgHandlers(wsRouter, { queries, activeExecutions, messageQueues, broadcastSync, processMessageWithStreaming, logError });
53
+ registerQueueHandlers(wsRouter, { queries, messageQueues, broadcastSync });
54
+ debugLog('[INIT] registerSessionHandlers, agents: ' + discoveredAgents.length);
55
+ registerSessionHandlers(wsRouter, { db: queries, discoveredAgents, modelCache, getAgentDescriptor, activeScripts, broadcastSync, startGeminiOAuth: (req) => startGeminiOAuth(req, { PORT, BASE_URL, rootDir }), geminiOAuthState: getGeminiOAuthState });
56
+ registerSessionHandlers2(wsRouter, { discoveredAgents, modelCache, activeScripts, broadcastSync, startGeminiOAuth: (req) => startGeminiOAuth(req, { PORT, BASE_URL, rootDir }), geminiOAuthState: getGeminiOAuthState });
57
+ debugLog('[INIT] registerSessionHandlers completed');
58
+ registerRunHandlers(wsRouter, { queries, discoveredAgents, activeExecutions, activeProcessesByRunId, broadcastSync, processMessageWithStreaming, cleanupExecution });
59
+ registerUtilHandlers(wsRouter, { queries, wsOptimizer, modelDownloadState, ensureModelsDownloaded, broadcastSync, getSpeech, getProviderConfigs, saveProviderConfig, STARTUP_CWD, voiceCacheManager, toolManager, discoveredAgents });
60
+ registerScriptHandlers(wsRouter, { queries, broadcastSync, STARTUP_CWD, activeScripts });
61
+ registerOAuthHandlers(wsRouter, { startGeminiOAuth: (req) => startGeminiOAuth(req, { PORT, BASE_URL, rootDir }), exchangeGeminiOAuthCode, geminiOAuthState: getGeminiOAuthState, startCodexOAuth: (req) => startCodexOAuth(req, { PORT, BASE_URL }), exchangeCodexOAuthCode, codexOAuthState: getCodexOAuthState });
62
+ }
@@ -0,0 +1,116 @@
1
+ import { JsonlWatcher } from './jsonl-watcher.js';
2
+
3
+ export function createOnServerReady({ queries, broadcastSync, warmAssetCache, staticDir, toolManager, discoveredAgents, PORT, BASE_URL, watch, ownedSessionIds, resumeInterruptedStreams, activeExecutions, debugLog, installGMAgentConfigs, startACPTools, getACPStatus, execMachine, toolInstallMachine, getSpeech, ensureModelsDownloaded, performAutoImport, performAgentHealthCheck, pm2Manager, pm2Subscribers, recoverStaleSessions }) {
4
+ let jsonlWatcher = null;
5
+
6
+ function getJsonlWatcher() { return jsonlWatcher; }
7
+
8
+ function onServerReady() {
9
+ toolManager.clearStatusCache();
10
+ console.log(`GMGUI running on http://localhost:${PORT}${BASE_URL}/`);
11
+ console.log(`Agents: ${discoveredAgents.map(a => a.name).join(', ') || 'none'}`);
12
+ console.log(`Hot reload: ${watch ? 'on' : 'off'}`);
13
+
14
+ const deletedCount = queries.cleanupEmptyConversations();
15
+ if (deletedCount > 0) console.log(`Cleaned up ${deletedCount} empty conversation(s) on startup`);
16
+
17
+ recoverStaleSessions();
18
+ warmAssetCache(staticDir);
19
+
20
+ try { queries.cleanup(); console.log('[cleanup] Initial DB cleanup complete'); } catch (e) { console.error('[cleanup] Error:', e.message); }
21
+ setInterval(() => {
22
+ try { queries.cleanup(); console.log('[cleanup] Scheduled DB cleanup complete'); } catch (e) { console.error('[cleanup] Error:', e.message); }
23
+ }, 6 * 60 * 60 * 1000);
24
+
25
+ try {
26
+ jsonlWatcher = new JsonlWatcher({ broadcastSync, queries, ownedSessionIds });
27
+ jsonlWatcher.start();
28
+ console.log('[JSONL] Watcher started');
29
+ } catch (err) { console.error('[JSONL] Watcher failed to start:', err.message); }
30
+
31
+ resumeInterruptedStreams().catch(err => console.error('[RESUME] Startup error:', err.message));
32
+
33
+ setInterval(() => {
34
+ try {
35
+ const streaming = queries.getStreamingConversations();
36
+ let cleared = 0;
37
+ for (const c of streaming) { if (!activeExecutions.has(c.id)) { queries.setIsStreaming(c.id, false); cleared++; } }
38
+ if (cleared > 0) debugLog(`[HEALTH] Cleared ${cleared} stale streaming flag(s)`);
39
+ } catch (e) { debugLog(`[HEALTH] Error: ${e.message}`); }
40
+ }, 5 * 60 * 1000);
41
+
42
+ installGMAgentConfigs().catch(err => console.error('[GM-CONFIG] Startup error:', err.message));
43
+
44
+ startACPTools().then(() => {
45
+ console.log('[ACP] On-demand startup enabled (ACP tools start when first used)');
46
+ setTimeout(() => {
47
+ const acpStatus = getACPStatus();
48
+ for (const s of acpStatus) { if (s.healthy) { const agent = discoveredAgents.find(a => a.id === s.id); if (agent) agent.acpPort = s.port; } }
49
+ if (acpStatus.length > 0) console.log(`[ACP] Tools ready: ${acpStatus.filter(s => s.healthy).map(s => s.id + ':' + s.port).join(', ') || 'none healthy yet'}`);
50
+ }, 6000);
51
+ }).catch(err => console.error('[ACP] Startup error:', err.message));
52
+
53
+ const toolIds = ['cli-claude', 'cli-opencode', 'cli-gemini', 'cli-kilo', 'cli-codex', 'cli-agent-browser', 'gm-cc', 'gm-oc', 'gm-gc', 'gm-kilo'];
54
+ queries.initializeToolInstallations(toolIds.map(id => ({ id })));
55
+ console.log('[TOOLS] Starting background provisioning...');
56
+
57
+ const toolBroadcaster = (evt) => {
58
+ broadcastSync(evt);
59
+ if (evt.type === 'tool_install_complete' || evt.type === 'tool_update_complete') {
60
+ const d = evt.data || {};
61
+ queries.updateToolStatus(evt.toolId, { status: 'installed', version: d.version || null, installed_at: Date.now() });
62
+ queries.addToolInstallHistory(evt.toolId, evt.type.includes('update') ? 'update' : 'install', 'success', null);
63
+ } else if (evt.type === 'tool_install_failed' || evt.type === 'tool_update_failed') {
64
+ queries.updateToolStatus(evt.toolId, { status: 'failed', error_message: evt.data?.error });
65
+ queries.addToolInstallHistory(evt.toolId, evt.type.includes('update') ? 'update' : 'install', 'failed', evt.data?.error);
66
+ } else if (evt.type === 'tool_status_update') {
67
+ const d = evt.data || {};
68
+ if (d.installed) queries.updateToolStatus(evt.toolId, { status: 'installed', version: d.installedVersion || null, installed_at: Date.now() });
69
+ }
70
+ };
71
+
72
+ toolManager.autoProvision(toolBroadcaster)
73
+ .catch(err => console.error('[TOOLS] Auto-provision error:', err.message))
74
+ .then(() => {
75
+ const acpActors = ['opencode', 'kilo', 'codex'];
76
+ console.log(`[MACHINES] tool-install: ${toolInstallMachine.getMachineActors().size} actors, acp-server: ${acpActors.length} configured`);
77
+ console.log('[TOOLS] Starting periodic update checker...');
78
+ toolManager.startPeriodicUpdateCheck(toolBroadcaster);
79
+ });
80
+
81
+ ensureModelsDownloaded().then(async ok => {
82
+ if (ok) console.log('[MODELS] Speech models ready');
83
+ else console.log('[MODELS] Speech model download failed');
84
+ try { const { getVoices } = await getSpeech(); broadcastSync({ type: 'voice_list', voices: getVoices() }); }
85
+ catch (err) { debugLog('[VOICE] Failed to broadcast voices: ' + err.message); broadcastSync({ type: 'voice_list', voices: [] }); }
86
+ }).catch(async err => {
87
+ console.error('[MODELS] Download error:', err.message);
88
+ try { const { getVoices } = await getSpeech(); broadcastSync({ type: 'voice_list', voices: getVoices() }); }
89
+ catch (err2) { debugLog('[VOICE] Failed to broadcast voices: ' + err2.message); broadcastSync({ type: 'voice_list', voices: [] }); }
90
+ });
91
+
92
+ getSpeech().then(s => s.preloadTTS()).catch(e => debugLog('[TTS] Preload failed: ' + e.message));
93
+ performAutoImport();
94
+ setInterval(performAutoImport, 30000);
95
+ setInterval(performAgentHealthCheck, 30000);
96
+
97
+ const broadcastPM2 = (update) => {
98
+ const msg = JSON.stringify(update);
99
+ for (const client of pm2Subscribers) { if (client.readyState === 1) { try { client.send(msg); } catch (_) {} } }
100
+ };
101
+
102
+ const startPM2Monitoring = async () => {
103
+ try { await pm2Manager.connect(); await pm2Manager.startMonitoring(broadcastPM2); console.log('[PM2] Monitoring started'); }
104
+ catch (err) { console.log('[PM2] Not available:', err.message); broadcastPM2({ type: 'pm2_unavailable', reason: err.message, timestamp: Date.now() }); }
105
+ };
106
+
107
+ setTimeout(startPM2Monitoring, 2000);
108
+ setInterval(async () => {
109
+ if (!pm2Manager.connected && !pm2Manager.monitoring) {
110
+ try { const healed = await pm2Manager.heal(); if (healed.success) await pm2Manager.startMonitoring(broadcastPM2); } catch (_) {}
111
+ }
112
+ }, 30000);
113
+ }
114
+
115
+ return { onServerReady, getJsonlWatcher };
116
+ }
@@ -0,0 +1,83 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import PluginLoader from './plugin-loader.js';
5
+
6
+ export function createAutoImport({ queries, broadcastSync }) {
7
+ const importMtimeCache = new Map();
8
+
9
+ function hasIndexFilesChanged() {
10
+ const projectsDir = path.join(os.homedir(), '.claude', 'projects');
11
+ if (!fs.existsSync(projectsDir)) return false;
12
+ let changed = false;
13
+ try {
14
+ const dirs = fs.readdirSync(projectsDir);
15
+ for (const d of dirs) {
16
+ const indexPath = path.join(projectsDir, d, 'sessions-index.json');
17
+ try {
18
+ const stat = fs.statSync(indexPath);
19
+ const cached = importMtimeCache.get(indexPath);
20
+ if (!cached || cached < stat.mtimeMs) { importMtimeCache.set(indexPath, stat.mtimeMs); changed = true; }
21
+ } catch (_) {}
22
+ }
23
+ } catch (_) {}
24
+ return changed;
25
+ }
26
+
27
+ function performAutoImport() {
28
+ try {
29
+ if (!hasIndexFilesChanged()) return;
30
+ const imported = queries.importClaudeCodeConversations();
31
+ if (imported.length > 0) {
32
+ const importedCount = imported.filter(i => i.status === 'imported').length;
33
+ if (importedCount > 0) {
34
+ console.log(`[AUTO-IMPORT] Imported ${importedCount} new Claude Code conversations`);
35
+ broadcastSync({ type: 'conversations_updated', count: importedCount });
36
+ }
37
+ }
38
+ } catch (err) { console.error('[AUTO-IMPORT] Error:', err.message); }
39
+ }
40
+
41
+ return { performAutoImport };
42
+ }
43
+
44
+ export function createDbRecovery({ queries, debugLog }) {
45
+ function performDbRecovery() {
46
+ try {
47
+ const cleanedUp = queries.cleanupOrphanedSessions(7);
48
+ if (cleanedUp > 0) debugLog(`[RECOVERY] Cleaned up ${cleanedUp} orphaned sessions`);
49
+ const longRunning = queries.getSessionsProcessingLongerThan(120);
50
+ if (longRunning.length > 0) {
51
+ for (const session of longRunning) queries.markSessionIncomplete(session.id, 'Timeout: processing exceeded 2 hours');
52
+ debugLog(`[RECOVERY] Marked ${longRunning.length} long-running sessions as incomplete`);
53
+ }
54
+ } catch (err) { console.error('[RECOVERY] Error:', err.message); }
55
+ }
56
+
57
+ return { performDbRecovery };
58
+ }
59
+
60
+ export function createPluginLoader({ pluginsDir, expressApp, BASE_URL }) {
61
+ const pluginLoader = new PluginLoader(pluginsDir);
62
+
63
+ async function loadPluginExtensions() {
64
+ try {
65
+ await pluginLoader.loadAllPlugins({ router: expressApp, baseUrl: BASE_URL, logger: console, env: process.env });
66
+ const names = Array.from(pluginLoader.registry.keys());
67
+ if (names.length > 0) {
68
+ for (const name of names) {
69
+ const state = pluginLoader.get(name);
70
+ if (!state || !state.routes) continue;
71
+ for (const route of state.routes) {
72
+ const fullPath = BASE_URL + route.path;
73
+ const method = (route.method || 'GET').toLowerCase();
74
+ if (expressApp[method]) expressApp[method](fullPath, route.handler);
75
+ }
76
+ }
77
+ console.log(`[PLUGINS] Loaded extensions: ${names.join(', ')}`);
78
+ }
79
+ } catch (err) { console.error('[PLUGINS] Extension loading failed (non-fatal):', err.message); }
80
+ }
81
+
82
+ return { loadPluginExtensions };
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.845",
3
+ "version": "1.0.847",
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
@@ -1,10 +1,8 @@
1
1
  import http from 'http';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
- import os from 'os';
5
4
  import { fileURLToPath } from 'url';
6
5
  import { LRUCache } from 'lru-cache';
7
- import crypto from 'crypto';
8
6
  const PKG_VERSION = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
9
7
  import { createExpressApp } from './lib/routes-upload.js';
10
8
  import { queries } from './database.js';
@@ -13,37 +11,18 @@ import { initializeDescriptors, getAgentDescriptor } from './lib/agent-descripto
13
11
  import { discoverExternalACPServers, initializeAgentDiscovery } from './lib/agent-discovery.js';
14
12
  import { startGeminiOAuth, exchangeGeminiOAuthCode, handleGeminiOAuthCallback, getGeminiOAuthStatus, getGeminiOAuthState } from './lib/oauth-gemini.js';
15
13
  import { initSpeechManager, getSpeech, voiceCacheManager, modelDownloadState, ensureModelsDownloaded, eagerTTS } from './lib/speech-manager.js';
16
- import { register as registerSpeechRoutes } from './lib/routes-speech.js';
17
- import { register as registerOAuthRoutes } from './lib/routes-oauth.js';
18
- import { register as registerUtilRoutes } from './lib/routes-util.js';
19
- import { register as registerToolRoutes } from './lib/routes-tools.js';
20
- import { register as registerThreadRoutes } from './lib/routes-threads.js';
21
- import { register as registerDebugRoutes } from './lib/routes-debug.js';
22
- import { register as registerConvRoutes } from './lib/routes-conversations.js';
23
- import { register as registerAgentRoutes } from './lib/routes-agents.js';
24
- import { register as registerMessagesRoutes } from './lib/routes-messages.js';
25
- import { register as registerSessionsRoutes } from './lib/routes-sessions.js';
26
- import { register as registerRunsRoutes } from './lib/routes-runs.js';
27
- import { register as registerScriptsRoutes } from './lib/routes-scripts.js';
28
- import { register as registerAgentActionsRoutes } from './lib/routes-agent-actions.js';
29
- import { register as registerAuthConfigRoutes } from './lib/routes-auth-config.js';
14
+ import { createRegistry } from './lib/routes-registry.js';
15
+ import { BROADCAST_TYPES } from './lib/broadcast.js';
30
16
  import { startCodexOAuth, exchangeCodexOAuthCode, handleCodexOAuthCallback, getCodexOAuthStatus, getCodexOAuthState, CODEX_HOME, CODEX_AUTH_FILE } from './lib/oauth-codex.js';
31
17
  import { WSOptimizer } from './lib/ws-optimizer.js';
32
18
  import { WsRouter } from './lib/ws-protocol.js';
33
19
  import { encode as wsEncode } from './lib/codec.js';
34
20
  import { parseBody, acceptsEncoding, compressAndSend, sendJSON } from './lib/http-utils.js';
35
21
  import { createWsSetup } from './lib/ws-setup.js';
22
+ import { createHttpHandler } from './lib/http-handler.js';
23
+ import { createOnServerReady } from './lib/server-startup.js';
24
+ import { createAutoImport, createDbRecovery, createPluginLoader } from './lib/server-startup2.js';
36
25
  const sendWs = (ws, obj) => { if (ws.readyState === 1) ws.send(wsEncode(obj)); };
37
- import { register as registerConvHandlers } from './lib/ws-handlers-conv.js';
38
- import { register as registerConvHandlers2 } from './lib/ws-handlers-conv2.js';
39
- import { register as registerSessionHandlers } from './lib/ws-handlers-session.js';
40
- import { register as registerSessionHandlers2 } from './lib/ws-handlers-session2.js';
41
- import { register as registerRunHandlers } from './lib/ws-handlers-run.js';
42
- import { register as registerUtilHandlers } from './lib/ws-handlers-util.js';
43
- import { register as registerOAuthHandlers } from './lib/ws-handlers-oauth.js';
44
- import { register as registerScriptHandlers } from './lib/ws-handlers-scripts.js';
45
- import { register as registerQueueHandlers } from './lib/ws-handlers-queue.js';
46
- import { register as registerMsgHandlers } from './lib/ws-handlers-msg.js';
47
26
  import { startAll as startACPTools, stopAll as stopACPTools, getStatus as getACPStatus, getPort as getACPPort, ensureRunning, queryModels as queryACPModels, touch as touchACP } from './lib/acp-sdk-manager.js';
48
27
  import * as execMachine from './lib/execution-machine.js';
49
28
  import * as toolInstallMachine from './lib/tool-install-machine.js';
@@ -52,7 +31,6 @@ import { installGMAgentConfigs } from './lib/gm-agent-configs.js';
52
31
  import * as toolManager from './lib/tool-manager.js';
53
32
  import { pm2Manager } from './lib/pm2-manager.js';
54
33
  import CheckpointManager from './lib/checkpoint-manager.js';
55
- import { JsonlWatcher } from './lib/jsonl-watcher.js';
56
34
  import { createBroadcast } from './lib/broadcast.js';
57
35
  import { createRecovery } from './lib/recovery.js';
58
36
  import { parseRateLimitResetTime } from './lib/process-message-rate-limit.js';
@@ -63,17 +41,10 @@ import { buildSystemPrompt, getProviderConfigs, saveProviderConfig } from './lib
63
41
  import { logError, errLogPath, makeCleanupExecution, makeGetModelsForAgent } from './lib/server-utils.js';
64
42
 
65
43
 
66
- process.on('uncaughtException', (err, origin) => {
67
- console.error('[FATAL] Uncaught exception (contained):', err.message, '| origin:', origin);
68
- console.error(err.stack);
69
- });
44
+ process.on('uncaughtException', (err, origin) => { console.error('[FATAL] Uncaught exception:', err.message, '| origin:', origin); console.error(err.stack); });
70
45
 
71
- process.on('unhandledRejection', (reason, promise) => {
72
- console.error('[FATAL] Unhandled rejection (contained):', reason instanceof Error ? reason.message : reason);
73
- if (reason instanceof Error) console.error(reason.stack);
74
- });
46
+ process.on('unhandledRejection', (reason) => { console.error('[FATAL] Unhandled rejection:', reason instanceof Error ? reason.message : reason); if (reason instanceof Error) console.error(reason.stack); });
75
47
 
76
- // Signal handlers registered after server initialization (see bottom of file)
77
48
  process.on('SIGHUP', () => { console.log('[SIGNAL] SIGHUP received (ignored - uncrashable)'); });
78
49
  process.on('beforeExit', (code) => { console.log('[PROCESS] beforeExit with code:', code); });
79
50
  process.on('exit', (code) => { console.log('[PROCESS] exit with code:', code); });
@@ -87,7 +58,6 @@ const activeProcessesByRunId = new Map();
87
58
  const checkpointManager = new CheckpointManager(queries);
88
59
  const STUCK_AGENT_THRESHOLD_MS = 1800000;
89
60
  const NO_PID_GRACE_PERIOD_MS = 60000;
90
- const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 60000;
91
61
 
92
62
  const debugLog = (msg) => {
93
63
  const timestamp = new Date().toISOString();
@@ -124,226 +94,17 @@ const getModelsForAgent = makeGetModelsForAgent({ modelCache, discoveredAgents,
124
94
  const _rateLimitMap = new LRUCache({ max: 1000, ttl: 60000 });
125
95
  const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX || '300', 10);
126
96
 
127
- const server = http.createServer(async (req, res) => {
128
- res.setHeader('Access-Control-Allow-Origin', '*');
129
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
130
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
131
- if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
132
- if (req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket') return;
133
-
134
- const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress;
135
- const hits = (_rateLimitMap.get(clientIp) || 0) + 1;
136
- _rateLimitMap.set(clientIp, hits);
137
- res.setHeader('X-RateLimit-Limit', RATE_LIMIT_MAX);
138
- res.setHeader('X-RateLimit-Remaining', Math.max(0, RATE_LIMIT_MAX - hits));
139
- if (hits > RATE_LIMIT_MAX) {
140
- res.writeHead(429, { 'Retry-After': '60' });
141
- res.end('Too Many Requests');
142
- return;
143
- }
144
-
145
- const _pwd = process.env.PASSWORD;
146
- if (_pwd) {
147
- const _auth = req.headers['authorization'] || '';
148
- let _ok = false;
149
- if (_auth.startsWith('Basic ')) {
150
- try {
151
- const _decoded = Buffer.from(_auth.slice(6), 'base64').toString('utf8');
152
- const _ci = _decoded.indexOf(':');
153
- if (_ci !== -1) { const _p = _decoded.slice(_ci + 1); try { _ok = _p.length === _pwd.length && crypto.timingSafeEqual(Buffer.from(_p), Buffer.from(_pwd)); } catch { _ok = false; } }
154
- } catch (_) {}
155
- }
156
- if (!_ok) {
157
- res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="agentgui"' });
158
- res.end('Unauthorized');
159
- return;
160
- }
161
- }
162
-
163
- const pathOnly = req.url.split('?')[0];
164
-
165
- // Route file upload and fsbrowse requests through Express sub-app
166
- if (pathOnly.startsWith(BASE_URL + '/api/upload/') || pathOnly.startsWith(BASE_URL + '/files/')) {
167
- return expressApp(req, res);
168
- }
169
-
170
- if (req.url === '/favicon.ico' || req.url === BASE_URL + '/favicon.ico') {
171
- const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#3b82f6"/><text x="50" y="68" font-size="50" font-family="sans-serif" font-weight="bold" fill="white" text-anchor="middle">G</text></svg>';
172
- res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=86400' });
173
- res.end(svg);
174
- return;
175
- }
176
-
177
- if (req.url === '/') { res.writeHead(302, { Location: BASE_URL + '/' }); res.end(); return; }
178
-
179
- // Handle requests with or without BASE_URL prefix (for reverse proxy compatibility)
180
- let routePath = req.url;
181
- if (req.url.startsWith(BASE_URL + '/')) {
182
- routePath = req.url.slice(BASE_URL.length);
183
- } else if (req.url === BASE_URL) {
184
- routePath = '/';
185
- } else if (req.url.startsWith('/api/') || req.url.startsWith('/js/') || req.url.startsWith('/css/') ||
186
- req.url.startsWith('/vendor/') || req.url.startsWith('/sync') || req.url === '/' ||
187
- req.url.startsWith('/conversations/')) {
188
- // Allow requests without BASE_URL prefix for static files and known routes
189
- // This supports reverse proxies that strip the BASE_URL prefix
190
- routePath = req.url;
191
- } else {
192
- res.writeHead(404); res.end('Not found'); return;
193
- }
194
-
195
- routePath = routePath || '/';
196
-
197
- try {
198
- // Remove query parameters from routePath for matching
199
- const pathOnly = routePath.split('?')[0];
200
-
201
- if (pathOnly === '/oauth2callback' && req.method === 'GET') {
202
- await handleGeminiOAuthCallback(req, res, PORT);
203
- return;
204
- }
205
-
206
- if (pathOnly === '/codex-oauth2callback' && req.method === 'GET') {
207
- await handleCodexOAuthCallback(req, res, PORT);
208
- return;
209
- }
210
-
211
- if (pathOnly === '/api/health' && req.method === 'GET') {
212
- let dbStatus = { ok: true };
213
- try { queries._db.prepare('SELECT 1').get(); } catch (e) { dbStatus = { ok: false, error: e.message }; }
214
- const queueSizes = {};
215
- for (const [k, v] of messageQueues) queueSizes[k] = v.length;
216
- sendJSON(req, res, 200, {
217
- status: 'ok',
218
- version: PKG_VERSION,
219
- uptime: process.uptime(),
220
- agents: discoveredAgents.length,
221
- activeExecutions: activeExecutions.size,
222
- wsClients: wss.clients.size,
223
- memory: process.memoryUsage(),
224
- acp: getACPStatus(),
225
- db: dbStatus,
226
- queueSizes
227
- });
228
- return;
229
- }
230
-
231
- const convHandler = _convRoutes._match(req.method, pathOnly);
232
- if (convHandler) { await convHandler(req, res); return; }
233
-
234
- const messagesHandler = _messagesRoutes._match(req.method, pathOnly);
235
- if (messagesHandler) { await messagesHandler(req, res); return; }
236
-
237
- const sessionsHandler = _sessionsRoutes._match(req.method, pathOnly);
238
- if (sessionsHandler) { await sessionsHandler(req, res); return; }
239
-
240
- const scriptsHandler = _scriptsRoutes._match(req.method, pathOnly);
241
- if (scriptsHandler) { await scriptsHandler(req, res); return; }
242
-
243
- const runsHandlerA = _runsRoutes._match(req.method, pathOnly);
244
- if (runsHandlerA) { await runsHandlerA(req, res); return; }
245
-
246
- const agentHandler = _agentRoutes._match(req.method, pathOnly);
247
- if (agentHandler) { await agentHandler(req, res); return; }
248
-
249
- const oauthHandler = _oauthRoutes._match(req.method, pathOnly);
250
- if (oauthHandler) { await oauthHandler(req, res); return; }
251
-
252
- const agentActionsHandler = _agentActionsRoutes._match(req.method, pathOnly);
253
- if (agentActionsHandler) { await agentActionsHandler(req, res); return; }
254
-
255
- const authConfigHandler = _authConfigRoutes._match(req.method, pathOnly);
256
- if (authConfigHandler) { await authConfigHandler(req, res); return; }
257
-
258
- const speechHandler = _speechRoutes._match(req.method, pathOnly);
259
- if (speechHandler) { await speechHandler(req, res, pathOnly); return; }
260
-
261
- const utilHandler = _utilRoutes._match(req.method, pathOnly);
262
- if (utilHandler) { await utilHandler(req, res); return; }
263
-
264
- const threadHandler = _threadRoutes._match(req.method, pathOnly);
265
- if (threadHandler) { await threadHandler(req, res); return; }
266
-
267
- if (routePath.startsWith('/api/image/')) {
268
- const imagePath = routePath.slice('/api/image/'.length);
269
- const decodedPath = decodeURIComponent(imagePath);
270
- const expandedPath = decodedPath.startsWith('~') ?
271
- decodedPath.replace('~', os.homedir()) : decodedPath;
272
- const normalizedPath = path.normalize(expandedPath);
273
- const isWindows = os.platform() === 'win32';
274
- const isAbsolute = isWindows ? /^[A-Za-z]:[\\\/]/.test(normalizedPath) : normalizedPath.startsWith('/');
275
- if (!isAbsolute || normalizedPath.includes('..')) {
276
- res.writeHead(403); res.end('Forbidden'); return;
277
- }
278
- try {
279
- if (!fs.existsSync(normalizedPath)) { res.writeHead(404); res.end('Not found'); return; }
280
- const ext = path.extname(normalizedPath).toLowerCase();
281
- const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml' };
282
- const contentType = mimeTypes[ext] || 'application/octet-stream';
283
- const fileContent = fs.readFileSync(normalizedPath);
284
- res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache' });
285
- res.end(fileContent);
286
- } catch (err) {
287
- sendJSON(req, res, 400, { error: err.message });
288
- }
289
- return;
290
- }
291
-
292
- // Handle conversation detail routes - serve index.html for client-side routing
293
- if (pathOnly.match(/^\/conversations\/[^\/]+$/)) {
294
- const indexPath = path.join(staticDir, 'index.html');
295
- serveFile(indexPath, res, req);
296
- return;
297
- }
298
-
299
- let filePath = routePath === '/' ? '/index.html' : routePath;
300
- filePath = path.join(staticDir, filePath);
301
- const normalizedPath = path.normalize(filePath);
302
- if (!normalizedPath.startsWith(staticDir)) { res.writeHead(403); res.end('Forbidden'); return; }
303
-
304
- fs.stat(filePath, (err, stats) => {
305
- if (err) { res.writeHead(404); res.end('Not found'); return; }
306
- if (stats.isDirectory()) {
307
- filePath = path.join(filePath, 'index.html');
308
- fs.stat(filePath, (err2) => {
309
- if (err2) { res.writeHead(404); res.end('Not found'); return; }
310
- serveFile(filePath, res, req);
311
- });
312
- } else {
313
- serveFile(filePath, res, req);
314
- }
315
- });
316
- } catch (e) {
317
- console.error('Server error:', e.message);
318
- sendJSON(req, res, 500, { error: e.message });
319
- }
320
- });
321
-
322
97
  const _assetDeps = { compressAndSend, acceptsEncoding, watch, BASE_URL, PKG_VERSION };
323
98
  function serveFile(filePath, res, req) { return _serveFile(filePath, res, req, _assetDeps); }
324
99
 
100
+ const _routes = {};
101
+ const server = http.createServer(createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, get wss() { return wss; }, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap: _rateLimitMap, routes: _routes, handleGeminiOAuthCallback, handleCodexOAuthCallback, PORT }));
102
+
325
103
  let broadcastSeq = 0;
326
104
  const syncClients = new Set();
327
105
  const subscriptionIndex = new Map();
328
106
  const pm2Subscribers = new Set();
329
107
 
330
- const BROADCAST_TYPES = new Set([
331
- 'message_created', 'conversation_created', 'conversation_updated',
332
- 'conversations_updated', 'conversation_deleted', 'all_conversations_deleted', 'queue_status', 'queue_updated', 'queue_item_dequeued',
333
- 'rate_limit_hit', 'rate_limit_clear',
334
- 'script_started', 'script_stopped', 'script_output',
335
- 'model_download_progress', 'stt_progress', 'tts_setup_progress', 'voice_list',
336
- 'streaming_start', 'streaming_progress', 'streaming_complete', 'streaming_error',
337
- 'tool_install_started', 'tool_install_progress', 'tool_install_complete', 'tool_install_failed',
338
- 'tool_update_progress', 'tool_update_complete', 'tool_update_failed',
339
- 'tool_status_update', 'tool_update_available',
340
- 'tools_update_started', 'tools_update_complete', 'tools_refresh_complete',
341
- 'pm2_monit_update', 'pm2_monitoring_started', 'pm2_monitoring_stopped',
342
- 'pm2_list_response', 'pm2_start_response', 'pm2_stop_response',
343
- 'pm2_restart_response', 'pm2_delete_response', 'pm2_logs_response',
344
- 'pm2_flush_logs_response', 'pm2_ping_response', 'pm2_unavailable'
345
- ]);
346
-
347
108
  const wsOptimizer = new WSOptimizer();
348
109
 
349
110
  const broadcastSync = createBroadcast({
@@ -355,14 +116,7 @@ const broadcastSync = createBroadcast({
355
116
  });
356
117
 
357
118
  const cleanupExecution = makeCleanupExecution({ execMachine, activeExecutions, queries, broadcastSync, debugLog });
358
-
359
- // Wire up process-message factories now that broadcastSync and all deps are available
360
- const _mqDeps = {
361
- queries, messageQueues, activeExecutions, rateLimitState, execMachine,
362
- broadcastSync, cleanupExecution, debugLog,
363
- getProcessMessageWithStreaming: () => processMessageWithStreaming
364
- };
365
- const { scheduleRetry, drainMessageQueue } = createMessageQueue(_mqDeps);
119
+ const { scheduleRetry, drainMessageQueue } = createMessageQueue({ queries, messageQueues, activeExecutions, rateLimitState, execMachine, broadcastSync, cleanupExecution, debugLog, getProcessMessageWithStreaming: () => processMessageWithStreaming });
366
120
 
367
121
  const { processMessageWithStreaming } = createProcessMessage({
368
122
  queries, activeExecutions, rateLimitState, execMachine,
@@ -373,78 +127,9 @@ const { processMessageWithStreaming } = createProcessMessage({
373
127
  scheduleRetry, drainMessageQueue, createEventHandler
374
128
  });
375
129
 
376
- // WebSocket protocol router
377
130
  const wsRouter = new WsRouter();
131
+ createRegistry(wsRouter, { queries, sendJSON, parseBody, broadcastSync, debugLog, PORT, BASE_URL, rootDir, STARTUP_CWD, PKG_VERSION, processMessageWithStreaming, activeExecutions, activeProcessesByRunId, activeScripts, messageQueues, rateLimitState, cleanupExecution, discoveredAgents, getACPStatus, modelCache, getModelsForAgent, logError, toolManager, syncClients, wsOptimizer, errLogPath, getJsonlWatcher: () => getJsonlWatcher(), routes: _routes });
378
132
 
379
- initSpeechManager({ broadcastSync, syncClients, queries });
380
- const _speechRoutes = registerSpeechRoutes({ sendJSON, parseBody, broadcastSync, debugLog });
381
- const _oauthRoutes = registerOAuthRoutes({ sendJSON, parseBody, PORT, BASE_URL, rootDir });
382
- const _utilRoutes = registerUtilRoutes({ sendJSON, parseBody, queries, STARTUP_CWD, PKG_VERSION });
383
- const _toolRoutes = registerToolRoutes({ sendJSON, parseBody, queries, broadcastSync, logError, toolManager });
384
- const _threadRoutes = registerThreadRoutes({ sendJSON, parseBody, queries });
385
- const _debugRoutes = registerDebugRoutes({ sendJSON, queries, activeExecutions, messageQueues, syncClients, wsOptimizer, _errLogPath: errLogPath });
386
- const _convRoutes = registerConvRoutes({ sendJSON, parseBody, queries, activeExecutions, broadcastSync });
387
- const _agentRoutes = registerAgentRoutes({ sendJSON, parseBody, queries, discoveredAgents, getACPStatus, modelCache, getModelsForAgent, debugLog });
388
- const _messagesRoutes = registerMessagesRoutes({ queries, sendJSON, parseBody, broadcastSync, processMessageWithStreaming, activeExecutions, messageQueues, debugLog, logError });
389
- const _sessionsRoutes = registerSessionsRoutes({ queries, sendJSON, activeExecutions, rateLimitState, debugLog });
390
- const _runsRoutes = registerRunsRoutes({ sendJSON, parseBody, queries, broadcastSync, processMessageWithStreaming, activeExecutions, activeProcessesByRunId, discoveredAgents, STARTUP_CWD });
391
- const _scriptsRoutes = registerScriptsRoutes({ sendJSON, parseBody, queries, broadcastSync, activeScripts, activeExecutions, processMessageWithStreaming, STARTUP_CWD });
392
- const _agentActionsRoutes = registerAgentActionsRoutes({ sendJSON, queries, broadcastSync, discoveredAgents, activeScripts, startGeminiOAuth, startCodexOAuth, getGeminiOAuthState, getCodexOAuthState, modelCache, PORT, BASE_URL, rootDir });
393
- const _authConfigRoutes = registerAuthConfigRoutes({ sendJSON, parseBody, getProviderConfigs, saveProviderConfig });
394
-
395
- registerConvHandlers(wsRouter, {
396
- queries, activeExecutions, rateLimitState,
397
- broadcastSync, processMessageWithStreaming, cleanupExecution,
398
- getJsonlWatcher: () => jsonlWatcher
399
- });
400
- registerConvHandlers2(wsRouter, {
401
- queries, activeExecutions, rateLimitState,
402
- broadcastSync, processMessageWithStreaming, cleanupExecution,
403
- getJsonlWatcher: () => jsonlWatcher
404
- });
405
-
406
- registerMsgHandlers(wsRouter, {
407
- queries, activeExecutions, messageQueues,
408
- broadcastSync, processMessageWithStreaming, logError
409
- });
410
-
411
- registerQueueHandlers(wsRouter, { queries, messageQueues, broadcastSync });
412
-
413
- debugLog('[INIT] registerSessionHandlers, agents: ' + discoveredAgents.length);
414
- registerSessionHandlers(wsRouter, {
415
- db: queries, discoveredAgents, modelCache,
416
- getAgentDescriptor, activeScripts, broadcastSync,
417
- startGeminiOAuth: (req) => startGeminiOAuth(req, { PORT, BASE_URL, rootDir }), geminiOAuthState: getGeminiOAuthState
418
- });
419
- registerSessionHandlers2(wsRouter, {
420
- discoveredAgents, modelCache, activeScripts, broadcastSync,
421
- startGeminiOAuth: (req) => startGeminiOAuth(req, { PORT, BASE_URL, rootDir }), geminiOAuthState: getGeminiOAuthState
422
- });
423
- debugLog('[INIT] registerSessionHandlers completed');
424
-
425
- registerRunHandlers(wsRouter, {
426
- queries, discoveredAgents, activeExecutions, activeProcessesByRunId,
427
- broadcastSync, processMessageWithStreaming, cleanupExecution
428
- });
429
-
430
- registerUtilHandlers(wsRouter, {
431
- queries, wsOptimizer, modelDownloadState, ensureModelsDownloaded,
432
- broadcastSync, getSpeech, getProviderConfigs, saveProviderConfig,
433
- STARTUP_CWD, voiceCacheManager, toolManager, discoveredAgents
434
- });
435
-
436
- registerScriptHandlers(wsRouter, {
437
- queries, broadcastSync, STARTUP_CWD, activeScripts
438
- });
439
-
440
- registerOAuthHandlers(wsRouter, {
441
- startGeminiOAuth: (req) => startGeminiOAuth(req, { PORT, BASE_URL, rootDir }),
442
- exchangeGeminiOAuthCode,
443
- geminiOAuthState: getGeminiOAuthState,
444
- startCodexOAuth: (req) => startCodexOAuth(req, { PORT, BASE_URL }),
445
- exchangeCodexOAuthCode,
446
- codexOAuthState: getCodexOAuthState,
447
- });
448
133
 
449
134
  const { wss, hotReloadClients } = createWsSetup(server, {
450
135
  BASE_URL, watch, staticDir, _assetCache, htmlState, sendWs, wsRouter, debugLog,
@@ -470,7 +155,7 @@ const { killActiveExecutions, recoverStaleSessions, resumeInterruptedStreams, is
470
155
  process.on('SIGTERM', () => {
471
156
  console.log('[SIGNAL] SIGTERM received - graceful shutdown');
472
157
  killActiveExecutions();
473
- if (jsonlWatcher) try { jsonlWatcher.stop(); } catch (_) {}
158
+ const _jw = getJsonlWatcher(); if (_jw) try { _jw.stop(); } catch (_) {}
474
159
  try { pm2Manager.disconnect(); } catch (_) {}
475
160
  stopACPTools().catch(() => {}).finally(() => {
476
161
  try { wss.close(() => server.close(() => process.exit(0))); } catch (_) { process.exit(0); }
@@ -494,253 +179,19 @@ server.on('error', (err) => {
494
179
  }
495
180
  });
496
181
 
497
- let jsonlWatcher = null;
498
-
499
- function onServerReady() {
500
- // Clear tool status cache on startup to ensure fresh detection
501
- toolManager.clearStatusCache();
502
-
503
- console.log(`GMGUI running on http://localhost:${PORT}${BASE_URL}/`);
504
- console.log(`Agents: ${discoveredAgents.map(a => a.name).join(', ') || 'none'}`);
505
- console.log(`Hot reload: ${watch ? 'on' : 'off'}`);
506
-
507
- const deletedCount = queries.cleanupEmptyConversations();
508
- if (deletedCount > 0) {
509
- console.log(`Cleaned up ${deletedCount} empty conversation(s) on startup`);
510
- }
511
-
512
- recoverStaleSessions();
513
- warmAssetCache(staticDir);
514
-
515
- // Run DB cleanup on startup and every 6 hours
516
- try { queries.cleanup(); console.log('[cleanup] Initial DB cleanup complete'); } catch (e) { console.error('[cleanup] Error:', e.message); }
517
- setInterval(() => {
518
- try { queries.cleanup(); console.log('[cleanup] Scheduled DB cleanup complete'); } catch (e) { console.error('[cleanup] Error:', e.message); }
519
- }, 6 * 60 * 60 * 1000);
520
-
521
- try {
522
- jsonlWatcher = new JsonlWatcher({ broadcastSync, queries, ownedSessionIds });
523
- jsonlWatcher.start();
524
- console.log('[JSONL] Watcher started');
525
- } catch (err) {
526
- console.error('[JSONL] Watcher failed to start:', err.message);
527
- }
528
-
529
- resumeInterruptedStreams().catch(err => console.error('[RESUME] Startup error:', err.message));
530
-
531
- setInterval(() => {
532
- try {
533
- const streaming = queries.getStreamingConversations();
534
- let cleared = 0;
535
- for (const c of streaming) {
536
- if (!activeExecutions.has(c.id)) {
537
- queries.setIsStreaming(c.id, false);
538
- cleared++;
539
- }
540
- }
541
- if (cleared > 0) debugLog(`[HEALTH] Cleared ${cleared} stale streaming flag(s)`);
542
- } catch (e) { debugLog(`[HEALTH] Error: ${e.message}`); }
543
- }, 5 * 60 * 1000);
544
-
545
- installGMAgentConfigs().catch(err => console.error('[GM-CONFIG] Startup error:', err.message));
546
-
547
- startACPTools().then(() => {
548
- console.log('[ACP] On-demand startup enabled (ACP tools start when first used)');
549
- setTimeout(() => {
550
- const acpStatus = getACPStatus();
551
- for (const s of acpStatus) {
552
- if (s.healthy) {
553
- const agent = discoveredAgents.find(a => a.id === s.id);
554
- if (agent) { agent.acpPort = s.port; }
555
- }
556
- }
557
- if (acpStatus.length > 0) {
558
- console.log(`[ACP] Tools ready: ${acpStatus.filter(s => s.healthy).map(s => s.id + ':' + s.port).join(', ') || 'none healthy yet'}`);
559
- }
560
- }, 6000);
561
- }).catch(err => console.error('[ACP] Startup error:', err.message));
562
-
563
- const toolIds = ['cli-claude', 'cli-opencode', 'cli-gemini', 'cli-kilo', 'cli-codex', 'cli-agent-browser', 'gm-cc', 'gm-oc', 'gm-gc', 'gm-kilo'];
564
- queries.initializeToolInstallations(toolIds.map(id => ({ id })));
565
- console.log('[TOOLS] Starting background provisioning...');
566
-
567
- // Create broadcast handler for tool events
568
- const toolBroadcaster = (evt) => {
569
- broadcastSync(evt);
570
- if (evt.type === 'tool_install_complete' || evt.type === 'tool_update_complete') {
571
- const d = evt.data || {};
572
- queries.updateToolStatus(evt.toolId, { status: 'installed', version: d.version || null, installed_at: Date.now() });
573
- queries.addToolInstallHistory(evt.toolId, evt.type.includes('update') ? 'update' : 'install', 'success', null);
574
- } else if (evt.type === 'tool_install_failed' || evt.type === 'tool_update_failed') {
575
- queries.updateToolStatus(evt.toolId, { status: 'failed', error_message: evt.data?.error });
576
- queries.addToolInstallHistory(evt.toolId, evt.type.includes('update') ? 'update' : 'install', 'failed', evt.data?.error);
577
- } else if (evt.type === 'tool_status_update') {
578
- const d = evt.data || {};
579
- if (d.installed) {
580
- queries.updateToolStatus(evt.toolId, { status: 'installed', version: d.installedVersion || null, installed_at: Date.now() });
581
- }
582
- }
583
- };
584
-
585
- // Initial provisioning (blocks until complete)
586
- toolManager.autoProvision(toolBroadcaster)
587
- .catch(err => console.error('[TOOLS] Auto-provision error:', err.message))
588
- .then(() => {
589
- const acpActors = ['opencode', 'kilo', 'codex'];
590
- const execActorCount = execMachine.stopAll ? 0 : 0;
591
- console.log(`[MACHINES] tool-install: ${toolInstallMachine.getMachineActors().size} actors, acp-server: ${acpActors.length} configured`);
592
- console.log('[TOOLS] Starting periodic update checker...');
593
- toolManager.startPeriodicUpdateCheck(toolBroadcaster);
594
- });
595
-
596
- ensureModelsDownloaded().then(async ok => {
597
- if (ok) console.log('[MODELS] Speech models ready');
598
- else console.log('[MODELS] Speech model download failed');
599
- try {
600
- const { getVoices } = await getSpeech();
601
- const voices = getVoices();
602
- broadcastSync({ type: 'voice_list', voices });
603
- } catch (err) {
604
- debugLog('[VOICE] Failed to broadcast voices: ' + err.message);
605
- broadcastSync({ type: 'voice_list', voices: [] });
606
- }
607
- }).catch(async err => {
608
- console.error('[MODELS] Download error:', err.message);
609
- try {
610
- const { getVoices } = await getSpeech();
611
- const voices = getVoices();
612
- broadcastSync({ type: 'voice_list', voices });
613
- } catch (err2) {
614
- debugLog('[VOICE] Failed to broadcast voices: ' + err2.message);
615
- broadcastSync({ type: 'voice_list', voices: [] });
616
- }
617
- });
618
-
619
- getSpeech().then(s => s.preloadTTS()).catch(e => debugLog('[TTS] Preload failed: ' + e.message));
620
-
621
- performAutoImport();
622
-
623
- setInterval(performAutoImport, 30000);
624
-
625
- setInterval(performAgentHealthCheck, 30000);
626
-
627
- // Initialize PM2 monitoring - only when PM2 daemon is available
628
- const broadcastPM2 = (update) => {
629
- const msg = JSON.stringify(update);
630
- for (const client of pm2Subscribers) {
631
- if (client.readyState === 1) { try { client.send(msg); } catch (_) {} }
632
- }
633
- };
634
-
635
- const startPM2Monitoring = async () => {
636
- try {
637
- await pm2Manager.connect();
638
- await pm2Manager.startMonitoring(broadcastPM2);
639
- console.log('[PM2] Monitoring started');
640
- } catch (err) {
641
- console.log('[PM2] Not available:', err.message);
642
- broadcastPM2({ type: 'pm2_unavailable', reason: err.message, timestamp: Date.now() });
643
- }
644
- };
182
+ const { performAutoImport } = createAutoImport({ queries, broadcastSync });
183
+ const { performDbRecovery } = createDbRecovery({ queries, debugLog });
184
+ const { loadPluginExtensions } = createPluginLoader({ pluginsDir: path.join(__dirname, 'lib', 'plugins'), expressApp, BASE_URL });
645
185
 
646
- setTimeout(startPM2Monitoring, 2000);
186
+ setInterval(performDbRecovery, 300000);
647
187
 
648
- setInterval(async () => {
649
- if (!pm2Manager.connected && !pm2Manager.monitoring) {
650
- try {
651
- const healed = await pm2Manager.heal();
652
- if (healed.success) await pm2Manager.startMonitoring(broadcastPM2);
653
- } catch (_) {}
654
- }
655
- }, 30000);
656
- }
657
-
658
- const importMtimeCache = new Map();
659
-
660
- function hasIndexFilesChanged() {
661
- const projectsDir = path.join(os.homedir(), '.claude', 'projects');
662
- if (!fs.existsSync(projectsDir)) return false;
663
- let changed = false;
664
- try {
665
- const dirs = fs.readdirSync(projectsDir);
666
- for (const d of dirs) {
667
- const indexPath = path.join(projectsDir, d, 'sessions-index.json');
668
- try {
669
- const stat = fs.statSync(indexPath);
670
- const cached = importMtimeCache.get(indexPath);
671
- if (!cached || cached < stat.mtimeMs) {
672
- importMtimeCache.set(indexPath, stat.mtimeMs);
673
- changed = true;
674
- }
675
- } catch (_) {}
676
- }
677
- } catch (_) {}
678
- return changed;
679
- }
680
-
681
- function performAutoImport() {
682
- try {
683
- if (!hasIndexFilesChanged()) return;
684
- const imported = queries.importClaudeCodeConversations();
685
- if (imported.length > 0) {
686
- const importedCount = imported.filter(i => i.status === 'imported').length;
687
- if (importedCount > 0) {
688
- console.log(`[AUTO-IMPORT] Imported ${importedCount} new Claude Code conversations`);
689
- broadcastSync({ type: 'conversations_updated', count: importedCount });
690
- }
691
- }
692
- } catch (err) {
693
- console.error('[AUTO-IMPORT] Error:', err.message);
694
- }
695
- }
696
-
697
- function performRecovery() {
698
- try {
699
- // Cleanup orphaned sessions (older than 7 days)
700
- const cleanedUp = queries.cleanupOrphanedSessions(7);
701
- if (cleanedUp > 0) {
702
- debugLog(`[RECOVERY] Cleaned up ${cleanedUp} orphaned sessions`);
703
- }
704
-
705
- // Mark sessions incomplete if they've been processing too long (>2 hours)
706
- const longRunning = queries.getSessionsProcessingLongerThan(120);
707
- if (longRunning.length > 0) {
708
- for (const session of longRunning) {
709
- queries.markSessionIncomplete(session.id, 'Timeout: processing exceeded 2 hours');
710
- }
711
- debugLog(`[RECOVERY] Marked ${longRunning.length} long-running sessions as incomplete`);
712
- }
713
- } catch (err) {
714
- console.error('[RECOVERY] Error:', err.message);
715
- }
716
- }
717
-
718
- // Run recovery every 5 minutes
719
- setInterval(performRecovery, 300000);
720
-
721
- // Load plugins as extensions (additive, not replacing core routes)
722
- import PluginLoader from './lib/plugin-loader.js';
723
- const pluginLoader = new PluginLoader(path.join(__dirname, 'lib', 'plugins'));
724
- async function loadPluginExtensions() {
725
- try {
726
- await pluginLoader.loadAllPlugins({ router: expressApp, baseUrl: BASE_URL, logger: console, env: process.env });
727
- const names = Array.from(pluginLoader.registry.keys());
728
- if (names.length > 0) {
729
- for (const name of names) {
730
- const state = pluginLoader.get(name);
731
- if (!state || !state.routes) continue;
732
- for (const route of state.routes) {
733
- const fullPath = BASE_URL + route.path;
734
- const method = (route.method || 'GET').toLowerCase();
735
- if (expressApp[method]) expressApp[method](fullPath, route.handler);
736
- }
737
- }
738
- console.log(`[PLUGINS] Loaded extensions: ${names.join(', ')}`);
739
- }
740
- } catch (err) {
741
- console.error('[PLUGINS] Extension loading failed (non-fatal):', err.message);
742
- }
743
- }
188
+ const { onServerReady, getJsonlWatcher } = createOnServerReady({
189
+ queries, broadcastSync, warmAssetCache, staticDir, toolManager, discoveredAgents,
190
+ PORT, BASE_URL, watch, ownedSessionIds, resumeInterruptedStreams, activeExecutions,
191
+ debugLog, installGMAgentConfigs, startACPTools, getACPStatus, execMachine,
192
+ toolInstallMachine, getSpeech, ensureModelsDownloaded, performAutoImport,
193
+ performAgentHealthCheck, pm2Manager, pm2Subscribers, recoverStaleSessions
194
+ });
744
195
 
745
196
  server.listen(PORT, () => {
746
197
  onServerReady();