agentgui 1.0.3
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/README.md +213 -0
- package/acp-launcher.js +348 -0
- package/bin/gmgui.cjs +70 -0
- package/database.js +447 -0
- package/install.sh +147 -0
- package/package.json +23 -0
- package/server.js +412 -0
- package/static/app.js +925 -0
- package/static/index.html +201 -0
- package/static/rippleui.css +208 -0
- package/static/styles.css +1432 -0
- package/static/theme.js +72 -0
package/server.js
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { WebSocketServer } from 'ws';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import { execSync } from 'child_process';
|
|
8
|
+
import { queries } from './database.js';
|
|
9
|
+
import ACPConnection from './acp-launcher.js';
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const PORT = process.env.PORT || 3000;
|
|
13
|
+
const BASE_URL = (process.env.BASE_URL || '/gm').replace(/\/+$/, '');
|
|
14
|
+
const watch = process.argv.includes('--watch');
|
|
15
|
+
|
|
16
|
+
const staticDir = path.join(__dirname, 'static');
|
|
17
|
+
if (!fs.existsSync(staticDir)) fs.mkdirSync(staticDir, { recursive: true });
|
|
18
|
+
|
|
19
|
+
// ACP connection pool keyed by agentId
|
|
20
|
+
const acpPool = new Map();
|
|
21
|
+
|
|
22
|
+
async function getACP(agentId, cwd) {
|
|
23
|
+
let conn = acpPool.get(agentId);
|
|
24
|
+
if (conn?.isRunning()) return conn;
|
|
25
|
+
|
|
26
|
+
conn = new ACPConnection();
|
|
27
|
+
const agentType = agentId === 'opencode' ? 'opencode' : 'claude-code';
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
await conn.connect(agentType, cwd);
|
|
31
|
+
await conn.initialize();
|
|
32
|
+
await conn.newSession(cwd);
|
|
33
|
+
await conn.setSessionMode('bypassPermissions');
|
|
34
|
+
await conn.injectSkills(['html_rendering', 'image_display', 'scrot', 'fs_access']);
|
|
35
|
+
acpPool.set(agentId, conn);
|
|
36
|
+
console.log(`ACP connection ready for ${agentId} in ${cwd}`);
|
|
37
|
+
return conn;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error(`Failed to initialize ACP connection for ${agentId}: ${err.message}`);
|
|
40
|
+
acpPool.delete(agentId);
|
|
41
|
+
if (conn) await conn.terminate();
|
|
42
|
+
throw new Error(`ACP initialization failed for ${agentId}: ${err.message}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function discoverAgents() {
|
|
47
|
+
const agents = [];
|
|
48
|
+
const binaries = [
|
|
49
|
+
{ cmd: 'claude', id: 'claude-code', name: 'Claude Code', icon: 'C' },
|
|
50
|
+
{ cmd: 'opencode', id: 'opencode', name: 'OpenCode', icon: 'O' },
|
|
51
|
+
];
|
|
52
|
+
for (const bin of binaries) {
|
|
53
|
+
try {
|
|
54
|
+
const result = execSync(`which ${bin.cmd} 2>/dev/null`, { encoding: 'utf-8' }).trim();
|
|
55
|
+
if (result) agents.push({ id: bin.id, name: bin.name, icon: bin.icon, path: result });
|
|
56
|
+
} catch (_) {}
|
|
57
|
+
}
|
|
58
|
+
return agents;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const discoveredAgents = discoverAgents();
|
|
62
|
+
|
|
63
|
+
function parseBody(req) {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
let body = '';
|
|
66
|
+
req.on('data', chunk => body += chunk);
|
|
67
|
+
req.on('error', reject);
|
|
68
|
+
req.on('end', () => {
|
|
69
|
+
try { resolve(body ? JSON.parse(body) : {}); }
|
|
70
|
+
catch (e) { reject(new Error('Invalid JSON')); }
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const server = http.createServer(async (req, res) => {
|
|
76
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
77
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
78
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
79
|
+
if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
|
|
80
|
+
|
|
81
|
+
if (req.url === '/') { res.writeHead(302, { Location: BASE_URL + '/' }); res.end(); return; }
|
|
82
|
+
|
|
83
|
+
if (!req.url.startsWith(BASE_URL + '/') && req.url !== BASE_URL) {
|
|
84
|
+
res.writeHead(404); res.end('Not found'); return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const routePath = req.url.slice(BASE_URL.length) || '/';
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
if (routePath === '/api/conversations' && req.method === 'GET') {
|
|
91
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
92
|
+
res.end(JSON.stringify({ conversations: queries.getAllConversations() }));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (routePath === '/api/conversations' && req.method === 'POST') {
|
|
97
|
+
const body = await parseBody(req);
|
|
98
|
+
const conversation = queries.createConversation(body.agentId, body.title);
|
|
99
|
+
queries.createEvent('conversation.created', { agentId: body.agentId }, conversation.id);
|
|
100
|
+
broadcastSync({ type: 'conversation_created', conversation });
|
|
101
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
102
|
+
res.end(JSON.stringify({ conversation }));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const convMatch = routePath.match(/^\/api\/conversations\/([^/]+)$/);
|
|
107
|
+
if (convMatch) {
|
|
108
|
+
if (req.method === 'GET') {
|
|
109
|
+
const conv = queries.getConversation(convMatch[1]);
|
|
110
|
+
if (!conv) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); return; }
|
|
111
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
112
|
+
res.end(JSON.stringify({ conversation: conv }));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (req.method === 'POST') {
|
|
117
|
+
const body = await parseBody(req);
|
|
118
|
+
const conv = queries.updateConversation(convMatch[1], body);
|
|
119
|
+
queries.createEvent('conversation.updated', body, convMatch[1]);
|
|
120
|
+
broadcastSync({ type: 'conversation_updated', conversation: conv });
|
|
121
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
122
|
+
res.end(JSON.stringify({ conversation: conv }));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (req.method === 'DELETE') {
|
|
127
|
+
const deleted = queries.deleteConversation(convMatch[1]);
|
|
128
|
+
if (!deleted) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); return; }
|
|
129
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
130
|
+
res.end(JSON.stringify({ deleted: true }));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const messagesMatch = routePath.match(/^\/api\/conversations\/([^/]+)\/messages$/);
|
|
136
|
+
if (messagesMatch) {
|
|
137
|
+
if (req.method === 'GET') {
|
|
138
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
139
|
+
res.end(JSON.stringify({ messages: queries.getConversationMessages(messagesMatch[1]) }));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (req.method === 'POST') {
|
|
144
|
+
const conversationId = messagesMatch[1];
|
|
145
|
+
const body = await parseBody(req);
|
|
146
|
+
const idempotencyKey = body.idempotencyKey || null;
|
|
147
|
+
const message = queries.createMessage(conversationId, 'user', body.content, idempotencyKey);
|
|
148
|
+
queries.createEvent('message.created', { role: 'user', messageId: message.id }, conversationId);
|
|
149
|
+
broadcastSync({ type: 'message_created', conversationId, message });
|
|
150
|
+
const session = queries.createSession(conversationId);
|
|
151
|
+
queries.createEvent('session.created', { messageId: message.id, sessionId: session.id }, conversationId, session.id);
|
|
152
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
153
|
+
res.end(JSON.stringify({ message, session, idempotencyKey }));
|
|
154
|
+
processMessage(conversationId, message.id, session.id, body.content, body.agentId, body.folderContext);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const messageMatch = routePath.match(/^\/api\/conversations\/([^/]+)\/messages\/([^/]+)$/);
|
|
160
|
+
if (messageMatch && req.method === 'GET') {
|
|
161
|
+
const msg = queries.getMessage(messageMatch[2]);
|
|
162
|
+
if (!msg || msg.conversationId !== messageMatch[1]) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); return; }
|
|
163
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
164
|
+
res.end(JSON.stringify({ message: msg }));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const sessionMatch = routePath.match(/^\/api\/sessions\/([^/]+)$/);
|
|
169
|
+
if (sessionMatch && req.method === 'GET') {
|
|
170
|
+
const sess = queries.getSession(sessionMatch[1]);
|
|
171
|
+
if (!sess) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); return; }
|
|
172
|
+
const events = queries.getSessionEvents(sessionMatch[1]);
|
|
173
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
174
|
+
res.end(JSON.stringify({ session: sess, events }));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (routePath.match(/^\/api\/conversations\/([^/]+)\/sessions\/latest$/) && req.method === 'GET') {
|
|
179
|
+
const convId = routePath.match(/^\/api\/conversations\/([^/]+)\/sessions\/latest$/)[1];
|
|
180
|
+
const latestSession = queries.getLatestSession(convId);
|
|
181
|
+
if (!latestSession) {
|
|
182
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
183
|
+
res.end(JSON.stringify({ session: null }));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const events = queries.getSessionEvents(latestSession.id);
|
|
187
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
188
|
+
res.end(JSON.stringify({ session: latestSession, events }));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (routePath === '/api/agents' && req.method === 'GET') {
|
|
193
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
194
|
+
res.end(JSON.stringify({ agents: discoveredAgents }));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (routePath === '/api/home' && req.method === 'GET') {
|
|
199
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
200
|
+
res.end(JSON.stringify({ home: process.env.HOME || '/config' }));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (routePath === '/api/folders' && req.method === 'POST') {
|
|
205
|
+
const body = await parseBody(req);
|
|
206
|
+
const folderPath = body.path || '/config';
|
|
207
|
+
try {
|
|
208
|
+
const expandedPath = folderPath.startsWith('~') ?
|
|
209
|
+
folderPath.replace('~', process.env.HOME || '/config') : folderPath;
|
|
210
|
+
const entries = fs.readdirSync(expandedPath, { withFileTypes: true });
|
|
211
|
+
const folders = entries
|
|
212
|
+
.filter(e => e.isDirectory())
|
|
213
|
+
.map(e => ({ name: e.name }))
|
|
214
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
215
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
216
|
+
res.end(JSON.stringify({ folders }));
|
|
217
|
+
} catch (err) {
|
|
218
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
219
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (routePath.startsWith('/api/image/')) {
|
|
225
|
+
const imagePath = routePath.slice('/api/image/'.length);
|
|
226
|
+
const decodedPath = decodeURIComponent(imagePath);
|
|
227
|
+
const expandedPath = decodedPath.startsWith('~') ?
|
|
228
|
+
decodedPath.replace('~', process.env.HOME || '/config') : decodedPath;
|
|
229
|
+
const normalizedPath = path.normalize(expandedPath);
|
|
230
|
+
if (!normalizedPath.startsWith('/') || normalizedPath.includes('..')) {
|
|
231
|
+
res.writeHead(403); res.end('Forbidden'); return;
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
if (!fs.existsSync(normalizedPath)) { res.writeHead(404); res.end('Not found'); return; }
|
|
235
|
+
const ext = path.extname(normalizedPath).toLowerCase();
|
|
236
|
+
const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml' };
|
|
237
|
+
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
|
238
|
+
const fileContent = fs.readFileSync(normalizedPath);
|
|
239
|
+
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=3600' });
|
|
240
|
+
res.end(fileContent);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
243
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let filePath = routePath === '/' ? '/index.html' : routePath;
|
|
249
|
+
filePath = path.join(staticDir, filePath);
|
|
250
|
+
const normalizedPath = path.normalize(filePath);
|
|
251
|
+
if (!normalizedPath.startsWith(staticDir)) { res.writeHead(403); res.end('Forbidden'); return; }
|
|
252
|
+
|
|
253
|
+
fs.stat(filePath, (err, stats) => {
|
|
254
|
+
if (err) { res.writeHead(404); res.end('Not found'); return; }
|
|
255
|
+
if (stats.isDirectory()) {
|
|
256
|
+
filePath = path.join(filePath, 'index.html');
|
|
257
|
+
fs.stat(filePath, (err2) => {
|
|
258
|
+
if (err2) { res.writeHead(404); res.end('Not found'); return; }
|
|
259
|
+
serveFile(filePath, res);
|
|
260
|
+
});
|
|
261
|
+
} else {
|
|
262
|
+
serveFile(filePath, res);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
} catch (e) {
|
|
266
|
+
console.error('Server error:', e.message);
|
|
267
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
268
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
function serveFile(filePath, res) {
|
|
273
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
274
|
+
const mimeTypes = { '.html': 'text/html; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml' };
|
|
275
|
+
fs.readFile(filePath, (err, data) => {
|
|
276
|
+
if (err) { res.writeHead(500); res.end('Server error'); return; }
|
|
277
|
+
let content = data.toString();
|
|
278
|
+
if (ext === '.html') {
|
|
279
|
+
const baseTag = `<script>window.__BASE_URL='${BASE_URL}'; window.__AUTH_TOKEN=localStorage.getItem('gmgui-token');</script>`;
|
|
280
|
+
content = content.replace('<head>', '<head>\n ' + baseTag);
|
|
281
|
+
if (watch) {
|
|
282
|
+
content += `\n<script>(function(){const ws=new WebSocket('ws://'+location.host+'${BASE_URL}/hot-reload');ws.onmessage=e=>{if(JSON.parse(e.data).type==='reload')location.reload()};})();</script>`;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'application/octet-stream' });
|
|
286
|
+
res.end(content);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function processMessage(conversationId, messageId, sessionId, content, agentId, folderContext) {
|
|
291
|
+
try {
|
|
292
|
+
queries.updateSession(sessionId, { status: 'processing' });
|
|
293
|
+
queries.createEvent('session.processing', { sessionId }, conversationId, sessionId);
|
|
294
|
+
broadcastSync({ type: 'session_updated', sessionId, status: 'processing' });
|
|
295
|
+
|
|
296
|
+
const cwd = folderContext?.path || '/config';
|
|
297
|
+
const conn = await getACP(agentId || 'claude-code', cwd);
|
|
298
|
+
|
|
299
|
+
let fullText = '';
|
|
300
|
+
const blocks = [];
|
|
301
|
+
conn.onUpdate = (params) => {
|
|
302
|
+
const u = params.update;
|
|
303
|
+
if (!u) return;
|
|
304
|
+
const kind = u.sessionUpdate;
|
|
305
|
+
if (kind === 'agent_message_chunk' && u.content?.text) {
|
|
306
|
+
fullText += u.content.text;
|
|
307
|
+
} else if (kind === 'html_content' && u.content?.html) {
|
|
308
|
+
blocks.push({ type: 'html', html: u.content.html, title: u.content.title, id: u.content.id });
|
|
309
|
+
} else if (kind === 'image_content' && u.content?.path) {
|
|
310
|
+
const imageUrl = BASE_URL + '/api/image/' + encodeURIComponent(u.content.path);
|
|
311
|
+
blocks.push({ type: 'image', path: u.content.path, url: imageUrl, title: u.content.title, alt: u.content.alt });
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const result = await conn.sendPrompt(content);
|
|
316
|
+
conn.onUpdate = null;
|
|
317
|
+
|
|
318
|
+
const responseText = fullText || (result?.stopReason ? `Completed: ${result.stopReason}` : 'No response.');
|
|
319
|
+
const messageContent = blocks.length > 0 ? { text: responseText, blocks } : responseText;
|
|
320
|
+
|
|
321
|
+
const assistantMessage = queries.createMessage(conversationId, 'assistant', messageContent);
|
|
322
|
+
queries.updateSession(sessionId, { status: 'completed', response: { text: responseText, messageId: assistantMessage.id }, completed_at: Date.now() });
|
|
323
|
+
queries.createEvent('session.completed', { messageId: assistantMessage.id }, conversationId, sessionId);
|
|
324
|
+
|
|
325
|
+
broadcastSync({ type: 'session_updated', sessionId, status: 'completed', message: assistantMessage });
|
|
326
|
+
} catch (e) {
|
|
327
|
+
console.error('processMessage error:', e.message);
|
|
328
|
+
queries.createMessage(conversationId, 'assistant', `Error: ${e.message}`);
|
|
329
|
+
queries.updateSession(sessionId, { status: 'error', error: e.message, completed_at: Date.now() });
|
|
330
|
+
queries.createEvent('session.error', { error: e.message }, conversationId, sessionId);
|
|
331
|
+
broadcastSync({ type: 'session_updated', sessionId, status: 'error', error: e.message });
|
|
332
|
+
acpPool.delete(agentId || 'claude-code');
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const wss = new WebSocketServer({ server });
|
|
337
|
+
const hotReloadClients = [];
|
|
338
|
+
const syncClients = new Set();
|
|
339
|
+
|
|
340
|
+
wss.on('connection', (ws, req) => {
|
|
341
|
+
const url = new URL(req.url, 'http://localhost');
|
|
342
|
+
const wsPath = url.pathname.startsWith(BASE_URL) ? url.pathname.slice(BASE_URL.length) : url.pathname;
|
|
343
|
+
if (wsPath === '/hot-reload') {
|
|
344
|
+
hotReloadClients.push(ws);
|
|
345
|
+
ws.on('close', () => { const i = hotReloadClients.indexOf(ws); if (i > -1) hotReloadClients.splice(i, 1); });
|
|
346
|
+
} else if (wsPath === '/sync') {
|
|
347
|
+
syncClients.add(ws);
|
|
348
|
+
ws.isAlive = true;
|
|
349
|
+
ws.send(JSON.stringify({ type: 'sync_connected' }));
|
|
350
|
+
ws.on('pong', () => { ws.isAlive = true; });
|
|
351
|
+
ws.on('close', () => { syncClients.delete(ws); });
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
function broadcastSync(event) {
|
|
356
|
+
const data = JSON.stringify(event);
|
|
357
|
+
for (const ws of syncClients) {
|
|
358
|
+
if (ws.readyState === 1) ws.send(data);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Heartbeat interval to detect stale connections
|
|
363
|
+
const heartbeatInterval = setInterval(() => {
|
|
364
|
+
syncClients.forEach(ws => {
|
|
365
|
+
if (!ws.isAlive) {
|
|
366
|
+
syncClients.delete(ws);
|
|
367
|
+
return ws.terminate();
|
|
368
|
+
}
|
|
369
|
+
ws.isAlive = false;
|
|
370
|
+
ws.ping();
|
|
371
|
+
});
|
|
372
|
+
}, 30000);
|
|
373
|
+
|
|
374
|
+
if (watch) {
|
|
375
|
+
const watchedFiles = new Map();
|
|
376
|
+
try {
|
|
377
|
+
fs.readdirSync(staticDir).forEach(file => {
|
|
378
|
+
const fp = path.join(staticDir, file);
|
|
379
|
+
if (watchedFiles.has(fp)) return;
|
|
380
|
+
fs.watchFile(fp, { interval: 100 }, (curr, prev) => {
|
|
381
|
+
if (curr.mtime > prev.mtime) hotReloadClients.forEach(c => { if (c.readyState === 1) c.send(JSON.stringify({ type: 'reload' })); });
|
|
382
|
+
});
|
|
383
|
+
watchedFiles.set(fp, true);
|
|
384
|
+
});
|
|
385
|
+
} catch (e) { console.error('Watch error:', e.message); }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
process.on('SIGTERM', async () => {
|
|
389
|
+
for (const conn of acpPool.values()) await conn.terminate();
|
|
390
|
+
acpPool.clear();
|
|
391
|
+
wss.close(() => server.close(() => process.exit(0)));
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
server.on('error', (err) => {
|
|
395
|
+
if (err.code === 'EADDRINUSE') {
|
|
396
|
+
console.error(`Port ${PORT} already in use. Waiting 3 seconds before retry...`);
|
|
397
|
+
setTimeout(() => {
|
|
398
|
+
server.listen(PORT, onServerReady);
|
|
399
|
+
}, 3000);
|
|
400
|
+
} else {
|
|
401
|
+
console.error('Server error:', err.message);
|
|
402
|
+
process.exit(1);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
function onServerReady() {
|
|
407
|
+
console.log(`GMGUI running on http://localhost:${PORT}${BASE_URL}/`);
|
|
408
|
+
console.log(`Agents: ${discoveredAgents.map(a => a.name).join(', ') || 'none'}`);
|
|
409
|
+
console.log(`Hot reload: ${watch ? 'on' : 'off'}`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
server.listen(PORT, onServerReady);
|