agentgui 1.0.845 → 1.0.846

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,11 @@
1
+ ## [Unreleased] - refactor: extract http handler + startup functions
2
+
3
+ - Extract HTTP request handler to lib/http-handler.js (createHttpHandler factory, 134L)
4
+ - Extract startup functions to lib/server-startup.js (createOnServerReady, 117L) and lib/server-startup2.js (createAutoImport, createDbRecovery, createPluginLoader, 84L)
5
+ - server.js reduced from ~575L to 337L; all lib files ≤200L
6
+ - Route registrations populate _routes object for lazy getter pattern in http-handler
7
+
8
+
1
9
  ## [Unreleased]
2
10
 
3
11
  ### Refactor
@@ -0,0 +1,133 @@
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, convRoutes, messagesRoutes, sessionsRoutes, scriptsRoutes, runsRoutes, agentRoutes, oauthRoutes, agentActionsRoutes, authConfigRoutes, speechRoutes, utilRoutes, threadRoutes, 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 = convRoutes._match(req.method, pathOnly);
72
+ if (convHandler) { await convHandler(req, res); return; }
73
+ const messagesHandler = messagesRoutes._match(req.method, pathOnly);
74
+ if (messagesHandler) { await messagesHandler(req, res); return; }
75
+ const sessionsHandler = sessionsRoutes._match(req.method, pathOnly);
76
+ if (sessionsHandler) { await sessionsHandler(req, res); return; }
77
+ const scriptsHandler = scriptsRoutes._match(req.method, pathOnly);
78
+ if (scriptsHandler) { await scriptsHandler(req, res); return; }
79
+ const runsHandler = runsRoutes._match(req.method, pathOnly);
80
+ if (runsHandler) { await runsHandler(req, res); return; }
81
+ const agentHandler = agentRoutes._match(req.method, pathOnly);
82
+ if (agentHandler) { await agentHandler(req, res); return; }
83
+ const oauthHandler = oauthRoutes._match(req.method, pathOnly);
84
+ if (oauthHandler) { await oauthHandler(req, res); return; }
85
+ const agentActionsHandler = agentActionsRoutes._match(req.method, pathOnly);
86
+ if (agentActionsHandler) { await agentActionsHandler(req, res); return; }
87
+ const authConfigHandler = authConfigRoutes._match(req.method, pathOnly);
88
+ if (authConfigHandler) { await authConfigHandler(req, res); return; }
89
+ const speechHandler = speechRoutes._match(req.method, pathOnly);
90
+ if (speechHandler) { await speechHandler(req, res, pathOnly); return; }
91
+ const utilHandler = utilRoutes._match(req.method, pathOnly);
92
+ if (utilHandler) { await utilHandler(req, res); return; }
93
+ const threadHandler = threadRoutes._match(req.method, pathOnly);
94
+ if (threadHandler) { await threadHandler(req, res); return; }
95
+ if (routePath.startsWith('/api/image/')) {
96
+ const imagePath = routePath.slice('/api/image/'.length);
97
+ const decodedPath = decodeURIComponent(imagePath);
98
+ const expandedPath = decodedPath.startsWith('~') ? decodedPath.replace('~', os.homedir()) : decodedPath;
99
+ const normalizedPath = path.normalize(expandedPath);
100
+ const isWindows = os.platform() === 'win32';
101
+ const isAbsolute = isWindows ? /^[A-Za-z]:[\\\/]/.test(normalizedPath) : normalizedPath.startsWith('/');
102
+ if (!isAbsolute || normalizedPath.includes('..')) { res.writeHead(403); res.end('Forbidden'); return; }
103
+ try {
104
+ if (!fs.existsSync(normalizedPath)) { res.writeHead(404); res.end('Not found'); return; }
105
+ const ext = path.extname(normalizedPath).toLowerCase();
106
+ const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml' };
107
+ const contentType = mimeTypes[ext] || 'application/octet-stream';
108
+ res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache' });
109
+ res.end(fs.readFileSync(normalizedPath));
110
+ } catch (err) { sendJSON(req, res, 400, { error: err.message }); }
111
+ return;
112
+ }
113
+
114
+ if (pathOnly.match(/^\/conversations\/[^\/]+$/)) { serveFile(path.join(staticDir, 'index.html'), res, req); return; }
115
+
116
+ let filePath = routePath === '/' ? '/index.html' : routePath;
117
+ filePath = path.join(staticDir, filePath);
118
+ const normalizedPath = path.normalize(filePath);
119
+ if (!normalizedPath.startsWith(staticDir)) { res.writeHead(403); res.end('Forbidden'); return; }
120
+
121
+ fs.stat(filePath, (err, stats) => {
122
+ if (err) { res.writeHead(404); res.end('Not found'); return; }
123
+ if (stats.isDirectory()) {
124
+ filePath = path.join(filePath, 'index.html');
125
+ fs.stat(filePath, (err2) => { if (err2) { res.writeHead(404); res.end('Not found'); return; } serveFile(filePath, res, req); });
126
+ } else { serveFile(filePath, res, req); }
127
+ });
128
+ } catch (e) {
129
+ console.error('Server error:', e.message);
130
+ sendJSON(req, res, 500, { error: e.message });
131
+ }
132
+ };
133
+ }
@@ -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.846",
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';
@@ -33,6 +31,9 @@ import { WsRouter } from './lib/ws-protocol.js';
33
31
  import { encode as wsEncode } from './lib/codec.js';
34
32
  import { parseBody, acceptsEncoding, compressAndSend, sendJSON } from './lib/http-utils.js';
35
33
  import { createWsSetup } from './lib/ws-setup.js';
34
+ import { createHttpHandler } from './lib/http-handler.js';
35
+ import { createOnServerReady } from './lib/server-startup.js';
36
+ import { createAutoImport, createDbRecovery, createPluginLoader } from './lib/server-startup2.js';
36
37
  const sendWs = (ws, obj) => { if (ws.readyState === 1) ws.send(wsEncode(obj)); };
37
38
  import { register as registerConvHandlers } from './lib/ws-handlers-conv.js';
38
39
  import { register as registerConvHandlers2 } from './lib/ws-handlers-conv2.js';
@@ -52,7 +53,6 @@ import { installGMAgentConfigs } from './lib/gm-agent-configs.js';
52
53
  import * as toolManager from './lib/tool-manager.js';
53
54
  import { pm2Manager } from './lib/pm2-manager.js';
54
55
  import CheckpointManager from './lib/checkpoint-manager.js';
55
- import { JsonlWatcher } from './lib/jsonl-watcher.js';
56
56
  import { createBroadcast } from './lib/broadcast.js';
57
57
  import { createRecovery } from './lib/recovery.js';
58
58
  import { parseRateLimitResetTime } from './lib/process-message-rate-limit.js';
@@ -73,7 +73,6 @@ process.on('unhandledRejection', (reason, promise) => {
73
73
  if (reason instanceof Error) console.error(reason.stack);
74
74
  });
75
75
 
76
- // Signal handlers registered after server initialization (see bottom of file)
77
76
  process.on('SIGHUP', () => { console.log('[SIGNAL] SIGHUP received (ignored - uncrashable)'); });
78
77
  process.on('beforeExit', (code) => { console.log('[PROCESS] beforeExit with code:', code); });
79
78
  process.on('exit', (code) => { console.log('[PROCESS] exit with code:', code); });
@@ -124,204 +123,29 @@ const getModelsForAgent = makeGetModelsForAgent({ modelCache, discoveredAgents,
124
123
  const _rateLimitMap = new LRUCache({ max: 1000, ttl: 60000 });
125
124
  const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX || '300', 10);
126
125
 
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
126
  const _assetDeps = { compressAndSend, acceptsEncoding, watch, BASE_URL, PKG_VERSION };
323
127
  function serveFile(filePath, res, req) { return _serveFile(filePath, res, req, _assetDeps); }
324
128
 
129
+ const _routes = {};
130
+ const server = http.createServer(createHttpHandler({
131
+ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues,
132
+ get wss() { return wss; }, activeExecutions, getACPStatus, discoveredAgents,
133
+ PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap: _rateLimitMap,
134
+ get convRoutes() { return _routes.conv; },
135
+ get messagesRoutes() { return _routes.messages; },
136
+ get sessionsRoutes() { return _routes.sessions; },
137
+ get scriptsRoutes() { return _routes.scripts; },
138
+ get runsRoutes() { return _routes.runs; },
139
+ get agentRoutes() { return _routes.agents; },
140
+ get oauthRoutes() { return _routes.oauth; },
141
+ get agentActionsRoutes() { return _routes.agentActions; },
142
+ get authConfigRoutes() { return _routes.authConfig; },
143
+ get speechRoutes() { return _routes.speech; },
144
+ get utilRoutes() { return _routes.util; },
145
+ get threadRoutes() { return _routes.threads; },
146
+ handleGeminiOAuthCallback, handleCodexOAuthCallback, PORT
147
+ }));
148
+
325
149
  let broadcastSeq = 0;
326
150
  const syncClients = new Set();
327
151
  const subscriptionIndex = new Map();
@@ -356,7 +180,6 @@ const broadcastSync = createBroadcast({
356
180
 
357
181
  const cleanupExecution = makeCleanupExecution({ execMachine, activeExecutions, queries, broadcastSync, debugLog });
358
182
 
359
- // Wire up process-message factories now that broadcastSync and all deps are available
360
183
  const _mqDeps = {
361
184
  queries, messageQueues, activeExecutions, rateLimitState, execMachine,
362
185
  broadcastSync, cleanupExecution, debugLog,
@@ -373,34 +196,33 @@ const { processMessageWithStreaming } = createProcessMessage({
373
196
  scheduleRetry, drainMessageQueue, createEventHandler
374
197
  });
375
198
 
376
- // WebSocket protocol router
377
199
  const wsRouter = new WsRouter();
378
200
 
379
201
  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 });
202
+ _routes.speech = registerSpeechRoutes({ sendJSON, parseBody, broadcastSync, debugLog });
203
+ _routes.oauth = registerOAuthRoutes({ sendJSON, parseBody, PORT, BASE_URL, rootDir });
204
+ _routes.util = registerUtilRoutes({ sendJSON, parseBody, queries, STARTUP_CWD, PKG_VERSION });
383
205
  const _toolRoutes = registerToolRoutes({ sendJSON, parseBody, queries, broadcastSync, logError, toolManager });
384
- const _threadRoutes = registerThreadRoutes({ sendJSON, parseBody, queries });
206
+ _routes.threads = registerThreadRoutes({ sendJSON, parseBody, queries });
385
207
  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 });
208
+ _routes.conv = registerConvRoutes({ sendJSON, parseBody, queries, activeExecutions, broadcastSync });
209
+ _routes.agents = registerAgentRoutes({ sendJSON, parseBody, queries, discoveredAgents, getACPStatus, modelCache, getModelsForAgent, debugLog });
210
+ _routes.messages = registerMessagesRoutes({ queries, sendJSON, parseBody, broadcastSync, processMessageWithStreaming, activeExecutions, messageQueues, debugLog, logError });
211
+ _routes.sessions = registerSessionsRoutes({ queries, sendJSON, activeExecutions, rateLimitState, debugLog });
212
+ _routes.runs = registerRunsRoutes({ sendJSON, parseBody, queries, broadcastSync, processMessageWithStreaming, activeExecutions, activeProcessesByRunId, discoveredAgents, STARTUP_CWD });
213
+ _routes.scripts = registerScriptsRoutes({ sendJSON, parseBody, queries, broadcastSync, activeScripts, activeExecutions, processMessageWithStreaming, STARTUP_CWD });
214
+ _routes.agentActions = registerAgentActionsRoutes({ sendJSON, queries, broadcastSync, discoveredAgents, activeScripts, startGeminiOAuth, startCodexOAuth, getGeminiOAuthState, getCodexOAuthState, modelCache, PORT, BASE_URL, rootDir });
215
+ _routes.authConfig = registerAuthConfigRoutes({ sendJSON, parseBody, getProviderConfigs, saveProviderConfig });
394
216
 
395
217
  registerConvHandlers(wsRouter, {
396
218
  queries, activeExecutions, rateLimitState,
397
219
  broadcastSync, processMessageWithStreaming, cleanupExecution,
398
- getJsonlWatcher: () => jsonlWatcher
220
+ getJsonlWatcher
399
221
  });
400
222
  registerConvHandlers2(wsRouter, {
401
223
  queries, activeExecutions, rateLimitState,
402
224
  broadcastSync, processMessageWithStreaming, cleanupExecution,
403
- getJsonlWatcher: () => jsonlWatcher
225
+ getJsonlWatcher
404
226
  });
405
227
 
406
228
  registerMsgHandlers(wsRouter, {
@@ -470,7 +292,7 @@ const { killActiveExecutions, recoverStaleSessions, resumeInterruptedStreams, is
470
292
  process.on('SIGTERM', () => {
471
293
  console.log('[SIGNAL] SIGTERM received - graceful shutdown');
472
294
  killActiveExecutions();
473
- if (jsonlWatcher) try { jsonlWatcher.stop(); } catch (_) {}
295
+ const _jw = getJsonlWatcher(); if (_jw) try { _jw.stop(); } catch (_) {}
474
296
  try { pm2Manager.disconnect(); } catch (_) {}
475
297
  stopACPTools().catch(() => {}).finally(() => {
476
298
  try { wss.close(() => server.close(() => process.exit(0))); } catch (_) { process.exit(0); }
@@ -494,253 +316,19 @@ server.on('error', (err) => {
494
316
  }
495
317
  });
496
318
 
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
- }
319
+ const { performAutoImport } = createAutoImport({ queries, broadcastSync });
320
+ const { performDbRecovery } = createDbRecovery({ queries, debugLog });
321
+ const { loadPluginExtensions } = createPluginLoader({ pluginsDir: path.join(__dirname, 'lib', 'plugins'), expressApp, BASE_URL });
511
322
 
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
- }
323
+ setInterval(performDbRecovery, 300000);
528
324
 
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
- };
645
-
646
- setTimeout(startPM2Monitoring, 2000);
647
-
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
- }
325
+ const { onServerReady, getJsonlWatcher } = createOnServerReady({
326
+ queries, broadcastSync, warmAssetCache, staticDir, toolManager, discoveredAgents,
327
+ PORT, BASE_URL, watch, ownedSessionIds, resumeInterruptedStreams, activeExecutions,
328
+ debugLog, installGMAgentConfigs, startACPTools, getACPStatus, execMachine,
329
+ toolInstallMachine, getSpeech, ensureModelsDownloaded, performAutoImport,
330
+ performAgentHealthCheck, pm2Manager, pm2Subscribers, recoverStaleSessions
331
+ });
744
332
 
745
333
  server.listen(PORT, () => {
746
334
  onServerReady();