agentgui 1.0.844 → 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 +9 -0
- package/lib/http-handler.js +133 -0
- package/lib/server-startup.js +116 -0
- package/lib/server-startup2.js +83 -0
- package/lib/ws-legacy-handlers.js +154 -0
- package/lib/ws-setup.js +77 -0
- package/package.json +1 -1
- package/server.js +58 -799
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
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
|
|
12
|
+
- Extract WebSocket server setup and legacy message handler from server.js to lib/ws-setup.js (77L, createWsSetup factory) and lib/ws-legacy-handlers.js (154L, registerLegacyHandler); server.js reduced from 1077L to 748L; wss.on('connection'), heartbeat interval, hot-reload watcher, subscribe/unsubscribe/terminal/PM2 legacy handlers all moved; unused WebSocketServer, spawn, createRequire imports removed from server.js
|
|
4
13
|
- Extract express upload + fsbrowse setup from server.js to lib/routes-upload.js (79L) exporting createExpressApp; server.js imports createExpressApp and no longer contains Busboy/fsbrowse/express inline code
|
|
5
14
|
- Extract maskKey, getProviderConfigs, saveProviderConfig, buildSystemPrompt, PROVIDER_CONFIGS from server.js to lib/provider-config.js (151L); extract logError, makeCleanupExecution, makeGetModelsForAgent, errLogPath from server.js to lib/server-utils.js (61L); server.js imports all via named imports; cleanupExecution wired after broadcastSync; _debugRoutes receives errLogPath
|
|
6
15
|
- Extract parseBody, acceptsEncoding, compressAndSend, sendJSON from server.js to lib/http-utils.js (43L); server.js imports from new module; zlib import removed from server.js
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { createRequire } from 'module';
|
|
3
|
+
|
|
4
|
+
const _req = createRequire(import.meta.url);
|
|
5
|
+
|
|
6
|
+
export function registerLegacyHandler(wsRouter, { subscriptionIndex, execMachine, activeExecutions, messageQueues, checkpointManager, queries, pm2Manager, pm2Subscribers, getSeq, sendWs, debugLog }) {
|
|
7
|
+
wsRouter.onLegacy((data, ws) => {
|
|
8
|
+
try {
|
|
9
|
+
if (data.type === 'subscribe') {
|
|
10
|
+
if (data.sessionId) {
|
|
11
|
+
ws.subscriptions.add(data.sessionId);
|
|
12
|
+
if (!subscriptionIndex.has(data.sessionId)) subscriptionIndex.set(data.sessionId, new Set());
|
|
13
|
+
subscriptionIndex.get(data.sessionId).add(ws);
|
|
14
|
+
}
|
|
15
|
+
if (data.conversationId) {
|
|
16
|
+
const key = `conv-${data.conversationId}`;
|
|
17
|
+
ws.subscriptions.add(key);
|
|
18
|
+
if (!subscriptionIndex.has(key)) subscriptionIndex.set(key, new Set());
|
|
19
|
+
subscriptionIndex.get(key).add(ws);
|
|
20
|
+
}
|
|
21
|
+
const subTarget = data.sessionId || data.conversationId;
|
|
22
|
+
debugLog(`[WebSocket] Client ${ws.clientId} subscribed to ${subTarget}`);
|
|
23
|
+
sendWs(ws, { type: 'subscription_confirmed', sessionId: data.sessionId, conversationId: data.conversationId, timestamp: Date.now() });
|
|
24
|
+
if (data.conversationId && execMachine.isActive(data.conversationId)) {
|
|
25
|
+
const ctx = execMachine.getContext(data.conversationId);
|
|
26
|
+
const execution = activeExecutions.get(data.conversationId);
|
|
27
|
+
const sessionId = ctx?.sessionId || execution?.sessionId;
|
|
28
|
+
const conv = queries.getConversation(data.conversationId);
|
|
29
|
+
const queueLength = execMachine.getQueue(data.conversationId).length || messageQueues.get(data.conversationId)?.length || 0;
|
|
30
|
+
sendWs(ws, { type: 'streaming_start', sessionId, conversationId: data.conversationId, agentId: conv?.agentType || conv?.agentId || 'claude-code', queueLength, resumed: true, seq: getSeq(), timestamp: Date.now() });
|
|
31
|
+
}
|
|
32
|
+
if (data.conversationId && checkpointManager.hasPendingCheckpoint(data.conversationId)) {
|
|
33
|
+
const checkpoint = checkpointManager.getPendingCheckpoint(data.conversationId);
|
|
34
|
+
if (checkpoint) {
|
|
35
|
+
debugLog(`[checkpoint] Injecting ${checkpoint.events.length} events to client for ${data.conversationId}`);
|
|
36
|
+
const latestSession = queries.getLatestSession(data.conversationId);
|
|
37
|
+
if (latestSession) {
|
|
38
|
+
sendWs(ws, { type: 'streaming_resumed', sessionId: latestSession.id, conversationId: data.conversationId, resumeFrom: checkpoint.sessionId, eventCount: checkpoint.events.length, chunkCount: checkpoint.chunks.length, timestamp: Date.now() });
|
|
39
|
+
checkpointManager.injectCheckpointEvents(latestSession.id, checkpoint, (evt) => {
|
|
40
|
+
sendWs(ws, { ...evt, sessionId: latestSession.id, conversationId: data.conversationId });
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} else if (data.type === 'unsubscribe') {
|
|
46
|
+
if (data.sessionId) {
|
|
47
|
+
ws.subscriptions.delete(data.sessionId);
|
|
48
|
+
const idx = subscriptionIndex.get(data.sessionId);
|
|
49
|
+
if (idx) { idx.delete(ws); if (idx.size === 0) subscriptionIndex.delete(data.sessionId); }
|
|
50
|
+
}
|
|
51
|
+
if (data.conversationId) {
|
|
52
|
+
const key = `conv-${data.conversationId}`;
|
|
53
|
+
ws.subscriptions.delete(key);
|
|
54
|
+
const idx = subscriptionIndex.get(key);
|
|
55
|
+
if (idx) { idx.delete(ws); if (idx.size === 0) subscriptionIndex.delete(key); }
|
|
56
|
+
}
|
|
57
|
+
debugLog(`[WebSocket] Client ${ws.clientId} unsubscribed from ${data.sessionId || data.conversationId}`);
|
|
58
|
+
} else if (data.type === 'get_subscriptions') {
|
|
59
|
+
sendWs(ws, { type: 'subscriptions', subscriptions: Array.from(ws.subscriptions), timestamp: Date.now() });
|
|
60
|
+
} else if (data.type === 'set_voice') {
|
|
61
|
+
ws.ttsVoiceId = data.voiceId || 'default';
|
|
62
|
+
} else if (data.type === 'latency_report') {
|
|
63
|
+
ws.latencyTier = data.quality || 'good';
|
|
64
|
+
ws.latencyAvg = data.avg || 0;
|
|
65
|
+
ws.latencyTrend = data.trend || 'stable';
|
|
66
|
+
} else if (data.type === 'ping') {
|
|
67
|
+
sendWs(ws, { type: 'pong', requestId: data.requestId, timestamp: Date.now() });
|
|
68
|
+
} else if (data.type === 'terminal_start') {
|
|
69
|
+
if (ws.terminalProc) { try { ws.terminalProc.kill(); } catch (_) {} }
|
|
70
|
+
try {
|
|
71
|
+
const pty = _req('node-pty');
|
|
72
|
+
const shell = process.env.SHELL || '/bin/bash';
|
|
73
|
+
const cwd = data.cwd || process.env.STARTUP_CWD || process.env.HOME || '/';
|
|
74
|
+
const proc = pty.spawn(shell, [], { name: 'xterm-256color', cols: data.cols || 80, rows: data.rows || 24, cwd, env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' } });
|
|
75
|
+
ws.terminalProc = proc;
|
|
76
|
+
ws.terminalPty = true;
|
|
77
|
+
proc.on('data', (chunk) => { if (ws.readyState === 1) sendWs(ws, { type: 'terminal_output', data: Buffer.from(chunk).toString('base64'), encoding: 'base64' }); });
|
|
78
|
+
proc.on('exit', (code) => { if (ws.readyState === 1) sendWs(ws, { type: 'terminal_exit', code }); ws.terminalProc = null; });
|
|
79
|
+
proc.on('error', (err) => { console.error('[TERMINAL] PTY error (contained):', err.message); if (ws.readyState === 1) sendWs(ws, { type: 'terminal_exit', code: 1, error: err.message }); ws.terminalProc = null; });
|
|
80
|
+
sendWs(ws, { type: 'terminal_started', timestamp: Date.now() });
|
|
81
|
+
} catch (_e) {
|
|
82
|
+
console.error('[TERMINAL] Failed to spawn PTY, falling back to pipes:', _e.message);
|
|
83
|
+
const shell = process.env.SHELL || '/bin/bash';
|
|
84
|
+
const cwd = data.cwd || process.env.STARTUP_CWD || process.env.HOME || '/';
|
|
85
|
+
const proc = spawn(shell, ['-i'], { cwd, env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' }, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
86
|
+
ws.terminalProc = proc;
|
|
87
|
+
ws.terminalPty = false;
|
|
88
|
+
proc.stdout.on('data', (chunk) => { if (ws.readyState === 1) sendWs(ws, { type: 'terminal_output', data: chunk.toString('base64'), encoding: 'base64' }); });
|
|
89
|
+
proc.stderr.on('data', (chunk) => { if (ws.readyState === 1) sendWs(ws, { type: 'terminal_output', data: chunk.toString('base64'), encoding: 'base64' }); });
|
|
90
|
+
proc.on('exit', (code) => { if (ws.readyState === 1) sendWs(ws, { type: 'terminal_exit', code }); ws.terminalProc = null; });
|
|
91
|
+
proc.on('error', (err) => { console.error('[TERMINAL] Spawn error (contained):', err.message); if (ws.readyState === 1) sendWs(ws, { type: 'terminal_exit', code: 1, error: err.message }); ws.terminalProc = null; });
|
|
92
|
+
proc.stdin.on('error', () => {});
|
|
93
|
+
proc.stdout.on('error', () => {});
|
|
94
|
+
proc.stderr.on('error', () => {});
|
|
95
|
+
sendWs(ws, { type: 'terminal_started', timestamp: Date.now() });
|
|
96
|
+
}
|
|
97
|
+
} else if (data.type === 'terminal_input') {
|
|
98
|
+
if (ws.terminalProc) {
|
|
99
|
+
try {
|
|
100
|
+
const input = Buffer.from(data.data, 'base64');
|
|
101
|
+
if (ws.terminalPty) { ws.terminalProc.write(input); }
|
|
102
|
+
else if (ws.terminalProc.stdin && ws.terminalProc.stdin.writable) { ws.terminalProc.stdin.write(input); }
|
|
103
|
+
} catch (_) {}
|
|
104
|
+
}
|
|
105
|
+
} else if (data.type === 'terminal_resize') {
|
|
106
|
+
if (ws.terminalProc && ws.terminalPty) {
|
|
107
|
+
try {
|
|
108
|
+
const { cols, rows } = data;
|
|
109
|
+
if (cols && rows && typeof ws.terminalProc.resize === 'function') { ws.terminalProc.resize(cols, rows); }
|
|
110
|
+
} catch (_) {}
|
|
111
|
+
}
|
|
112
|
+
} else if (data.type === 'terminal_stop') {
|
|
113
|
+
if (ws.terminalProc) { try { ws.terminalProc.kill(); } catch (_) {} ws.terminalProc = null; }
|
|
114
|
+
} else if (data.type === 'pm2_list') {
|
|
115
|
+
if (!pm2Manager.connected) {
|
|
116
|
+
if (ws.readyState === 1) sendWs(ws, { type: 'pm2_unavailable', reason: 'PM2 not connected', timestamp: Date.now() });
|
|
117
|
+
} else {
|
|
118
|
+
pm2Manager.listProcesses().then(processes => {
|
|
119
|
+
if (ws.readyState === 1) {
|
|
120
|
+
const hasActive = processes.some(p => ['online', 'launching', 'stopping', 'waiting restart'].includes(p.status));
|
|
121
|
+
sendWs(ws, { type: 'pm2_list_response', processes, hasActive });
|
|
122
|
+
}
|
|
123
|
+
}).catch(() => { if (ws.readyState === 1) sendWs(ws, { type: 'pm2_unavailable', reason: 'list failed', timestamp: Date.now() }); });
|
|
124
|
+
}
|
|
125
|
+
} else if (data.type === 'pm2_start_monitoring') {
|
|
126
|
+
pm2Subscribers.add(ws);
|
|
127
|
+
ws.pm2Subscribed = true;
|
|
128
|
+
if (!pm2Manager.connected) {
|
|
129
|
+
if (ws.readyState === 1) sendWs(ws, { type: 'pm2_unavailable', reason: 'PM2 not connected', timestamp: Date.now() });
|
|
130
|
+
} else {
|
|
131
|
+
sendWs(ws, { type: 'pm2_monitoring_started' });
|
|
132
|
+
}
|
|
133
|
+
} else if (data.type === 'pm2_stop_monitoring') {
|
|
134
|
+
pm2Subscribers.delete(ws);
|
|
135
|
+
ws.pm2Subscribed = false;
|
|
136
|
+
sendWs(ws, { type: 'pm2_monitoring_stopped' });
|
|
137
|
+
} else if (data.type === 'pm2_start') {
|
|
138
|
+
pm2Manager.startProcess(data.name).then(result => { sendWs(ws, { type: 'pm2_start_response', name: data.name, ...result }); });
|
|
139
|
+
} else if (data.type === 'pm2_stop') {
|
|
140
|
+
pm2Manager.stopProcess(data.name).then(result => { sendWs(ws, { type: 'pm2_stop_response', name: data.name, ...result }); });
|
|
141
|
+
} else if (data.type === 'pm2_restart') {
|
|
142
|
+
pm2Manager.restartProcess(data.name).then(result => { sendWs(ws, { type: 'pm2_restart_response', name: data.name, ...result }); });
|
|
143
|
+
} else if (data.type === 'pm2_delete') {
|
|
144
|
+
pm2Manager.deleteProcess(data.name).then(result => { sendWs(ws, { type: 'pm2_delete_response', name: data.name, ...result }); });
|
|
145
|
+
} else if (data.type === 'pm2_logs') {
|
|
146
|
+
pm2Manager.getLogs(data.name, { lines: data.lines || 100 }).then(result => { sendWs(ws, { type: 'pm2_logs_response', name: data.name, ...result }); });
|
|
147
|
+
} else if (data.type === 'pm2_flush_logs') {
|
|
148
|
+
pm2Manager.flushLogs(data.name).then(result => { sendWs(ws, { type: 'pm2_flush_logs_response', name: data.name, ...result }); });
|
|
149
|
+
} else if (data.type === 'pm2_ping') {
|
|
150
|
+
pm2Manager.ping().then(result => { sendWs(ws, { type: 'pm2_ping_response', ...result }); });
|
|
151
|
+
}
|
|
152
|
+
} catch (err) { console.error('[WS-LEGACY] Handler error (contained):', err.message); }
|
|
153
|
+
});
|
|
154
|
+
}
|
package/lib/ws-setup.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { WebSocketServer } from 'ws';
|
|
4
|
+
import { registerLegacyHandler } from './ws-legacy-handlers.js';
|
|
5
|
+
|
|
6
|
+
export function createWsSetup(server, { BASE_URL, watch, staticDir, _assetCache, htmlState, sendWs, wsRouter, debugLog, subscriptionIndex, syncClients, pm2Subscribers, wsOptimizer, legacyDeps }) {
|
|
7
|
+
const hotReloadClients = [];
|
|
8
|
+
|
|
9
|
+
const wss = new WebSocketServer({ server, perMessageDeflate: false });
|
|
10
|
+
wss.on('error', (err) => { console.error('[WSS] WebSocket server error (contained):', err.message); });
|
|
11
|
+
|
|
12
|
+
wss.on('connection', (ws, req) => {
|
|
13
|
+
const _pwd = process.env.PASSWORD;
|
|
14
|
+
if (_pwd) {
|
|
15
|
+
const url = new URL(req.url, 'http://localhost');
|
|
16
|
+
const token = url.searchParams.get('token');
|
|
17
|
+
if (token !== _pwd) { ws.close(4001, 'Unauthorized'); return; }
|
|
18
|
+
}
|
|
19
|
+
const wsPath = req.url.split('?')[0];
|
|
20
|
+
const wsRoute = wsPath.startsWith(BASE_URL) ? wsPath.slice(BASE_URL.length) : wsPath;
|
|
21
|
+
if (wsRoute === '/hot-reload') {
|
|
22
|
+
hotReloadClients.push(ws);
|
|
23
|
+
ws.on('close', () => { const i = hotReloadClients.indexOf(ws); if (i > -1) hotReloadClients.splice(i, 1); });
|
|
24
|
+
} else if (wsRoute === '/sync') {
|
|
25
|
+
syncClients.add(ws);
|
|
26
|
+
ws.isAlive = true;
|
|
27
|
+
ws.subscriptions = new Set();
|
|
28
|
+
ws.clientId = `client-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
29
|
+
sendWs(ws, { type: 'sync_connected', clientId: ws.clientId, timestamp: Date.now() });
|
|
30
|
+
ws.on('error', (err) => { console.error('[WS] Client error (contained):', ws.clientId, err.message); });
|
|
31
|
+
ws.on('message', (msg) => { try { wsRouter.onMessage(ws, msg); } catch (e) { console.error('[WS] Message handler error (contained):', e.message); } });
|
|
32
|
+
ws.on('pong', () => { ws.isAlive = true; });
|
|
33
|
+
ws.on('close', () => {
|
|
34
|
+
if (ws.terminalProc) { try { ws.terminalProc.kill(); } catch (_) {} ws.terminalProc = null; }
|
|
35
|
+
syncClients.delete(ws);
|
|
36
|
+
wsOptimizer.removeClient(ws);
|
|
37
|
+
for (const sub of ws.subscriptions) {
|
|
38
|
+
const idx = subscriptionIndex.get(sub);
|
|
39
|
+
if (idx) { idx.delete(ws); if (idx.size === 0) subscriptionIndex.delete(sub); }
|
|
40
|
+
}
|
|
41
|
+
if (ws.pm2Subscribed) pm2Subscribers.delete(ws);
|
|
42
|
+
debugLog(`[WebSocket] Client ${ws.clientId} disconnected`);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
registerLegacyHandler(wsRouter, legacyDeps);
|
|
48
|
+
|
|
49
|
+
setInterval(() => {
|
|
50
|
+
syncClients.forEach(ws => {
|
|
51
|
+
if (!ws.isAlive) { syncClients.delete(ws); wsOptimizer.removeClient(ws); return ws.terminate(); }
|
|
52
|
+
ws.isAlive = false;
|
|
53
|
+
ws.ping();
|
|
54
|
+
});
|
|
55
|
+
}, 30000);
|
|
56
|
+
|
|
57
|
+
if (watch) {
|
|
58
|
+
const watchedFiles = new Map();
|
|
59
|
+
try {
|
|
60
|
+
fs.readdirSync(staticDir).forEach(file => {
|
|
61
|
+
const fp = path.join(staticDir, file);
|
|
62
|
+
if (watchedFiles.has(fp)) return;
|
|
63
|
+
fs.watchFile(fp, { interval: 100 }, (curr, prev) => {
|
|
64
|
+
if (curr.mtime > prev.mtime) {
|
|
65
|
+
_assetCache.clear();
|
|
66
|
+
htmlState.cache = null;
|
|
67
|
+
htmlState.etag = null;
|
|
68
|
+
hotReloadClients.forEach(c => { if (c.readyState === 1) c.send(JSON.stringify({ type: 'reload' })); });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
watchedFiles.set(fp, true);
|
|
72
|
+
});
|
|
73
|
+
} catch (e) { console.error('Watch error:', e.message); }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { wss, hotReloadClients };
|
|
77
|
+
}
|