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 +8 -0
- package/lib/http-handler.js +133 -0
- package/lib/server-startup.js +116 -0
- package/lib/server-startup2.js +83 -0
- package/package.json +1 -1
- package/server.js +49 -461
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
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
206
|
+
_routes.threads = registerThreadRoutes({ sendJSON, parseBody, queries });
|
|
385
207
|
const _debugRoutes = registerDebugRoutes({ sendJSON, queries, activeExecutions, messageQueues, syncClients, wsOptimizer, _errLogPath: errLogPath });
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
|
220
|
+
getJsonlWatcher
|
|
399
221
|
});
|
|
400
222
|
registerConvHandlers2(wsRouter, {
|
|
401
223
|
queries, activeExecutions, rateLimitState,
|
|
402
224
|
broadcastSync, processMessageWithStreaming, cleanupExecution,
|
|
403
|
-
getJsonlWatcher
|
|
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 (
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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();
|