agentgui 1.0.151 → 1.0.153

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/server.js CHANGED
@@ -2,6 +2,7 @@ import http from 'http';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
4
  import os from 'os';
5
+ import zlib from 'zlib';
5
6
  import { fileURLToPath } from 'url';
6
7
  import { WebSocketServer } from 'ws';
7
8
  import { execSync } from 'child_process';
@@ -23,9 +24,11 @@ const SYSTEM_PROMPT = `Write all responses as clean semantic HTML. Use tags like
23
24
 
24
25
  const activeExecutions = new Map();
25
26
  const messageQueues = new Map();
27
+ const rateLimitState = new Map();
26
28
  const STUCK_AGENT_THRESHOLD_MS = 600000;
27
29
  const NO_PID_GRACE_PERIOD_MS = 60000;
28
30
  const STALE_SESSION_MIN_AGE_MS = 30000;
31
+ const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 60000;
29
32
 
30
33
  const debugLog = (msg) => {
31
34
  const timestamp = new Date().toISOString();
@@ -146,6 +149,36 @@ function parseBody(req) {
146
149
  });
147
150
  }
148
151
 
152
+ function acceptsEncoding(req, encoding) {
153
+ const accept = req.headers['accept-encoding'] || '';
154
+ return accept.includes(encoding);
155
+ }
156
+
157
+ function compressAndSend(req, res, statusCode, contentType, body) {
158
+ const raw = typeof body === 'string' ? Buffer.from(body) : body;
159
+ if (raw.length < 860) {
160
+ res.writeHead(statusCode, { 'Content-Type': contentType, 'Content-Length': raw.length });
161
+ res.end(raw);
162
+ return;
163
+ }
164
+ if (acceptsEncoding(req, 'br')) {
165
+ const compressed = zlib.brotliCompressSync(raw, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } });
166
+ res.writeHead(statusCode, { 'Content-Type': contentType, 'Content-Encoding': 'br', 'Content-Length': compressed.length });
167
+ res.end(compressed);
168
+ } else if (acceptsEncoding(req, 'gzip')) {
169
+ const compressed = zlib.gzipSync(raw, { level: 6 });
170
+ res.writeHead(statusCode, { 'Content-Type': contentType, 'Content-Encoding': 'gzip', 'Content-Length': compressed.length });
171
+ res.end(compressed);
172
+ } else {
173
+ res.writeHead(statusCode, { 'Content-Type': contentType, 'Content-Length': raw.length });
174
+ res.end(raw);
175
+ }
176
+ }
177
+
178
+ function sendJSON(req, res, statusCode, data) {
179
+ compressAndSend(req, res, statusCode, 'application/json', JSON.stringify(data));
180
+ }
181
+
149
182
  const server = http.createServer(async (req, res) => {
150
183
  res.setHeader('Access-Control-Allow-Origin', '*');
151
184
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
@@ -179,8 +212,7 @@ const server = http.createServer(async (req, res) => {
179
212
  const pathOnly = routePath.split('?')[0];
180
213
 
181
214
  if (pathOnly === '/api/conversations' && req.method === 'GET') {
182
- res.writeHead(200, { 'Content-Type': 'application/json' });
183
- res.end(JSON.stringify({ conversations: queries.getConversationsList() }));
215
+ sendJSON(req, res, 200, { conversations: queries.getConversationsList() });
184
216
  return;
185
217
  }
186
218
 
@@ -189,8 +221,7 @@ const server = http.createServer(async (req, res) => {
189
221
  const conversation = queries.createConversation(body.agentId, body.title, body.workingDirectory || null);
190
222
  queries.createEvent('conversation.created', { agentId: body.agentId, workingDirectory: conversation.workingDirectory }, conversation.id);
191
223
  broadcastSync({ type: 'conversation_created', conversation });
192
- res.writeHead(201, { 'Content-Type': 'application/json' });
193
- res.end(JSON.stringify({ conversation }));
224
+ sendJSON(req, res, 201, { conversation });
194
225
  return;
195
226
  }
196
227
 
@@ -198,39 +229,36 @@ const server = http.createServer(async (req, res) => {
198
229
  if (convMatch) {
199
230
  if (req.method === 'GET') {
200
231
  const conv = queries.getConversation(convMatch[1]);
201
- if (!conv) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); return; }
232
+ if (!conv) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
202
233
 
203
234
  // Check both in-memory and database for active streaming status
204
235
  const latestSession = queries.getLatestSession(convMatch[1]);
205
236
  const isActivelyStreaming = activeExecutions.has(convMatch[1]) ||
206
237
  (latestSession && latestSession.status === 'active');
207
238
 
208
- res.writeHead(200, { 'Content-Type': 'application/json' });
209
- res.end(JSON.stringify({
239
+ sendJSON(req, res, 200, {
210
240
  conversation: conv,
211
241
  isActivelyStreaming,
212
242
  latestSession
213
- }));
243
+ });
214
244
  return;
215
245
  }
216
246
 
217
247
  if (req.method === 'POST' || req.method === 'PUT') {
218
248
  const body = await parseBody(req);
219
249
  const conv = queries.updateConversation(convMatch[1], body);
220
- if (!conv) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Conversation not found' })); return; }
250
+ if (!conv) { sendJSON(req, res, 404, { error: 'Conversation not found' }); return; }
221
251
  queries.createEvent('conversation.updated', body, convMatch[1]);
222
252
  broadcastSync({ type: 'conversation_updated', conversation: conv });
223
- res.writeHead(200, { 'Content-Type': 'application/json' });
224
- res.end(JSON.stringify({ conversation: conv }));
253
+ sendJSON(req, res, 200, { conversation: conv });
225
254
  return;
226
255
  }
227
256
 
228
257
  if (req.method === 'DELETE') {
229
258
  const deleted = queries.deleteConversation(convMatch[1]);
230
- if (!deleted) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); return; }
259
+ if (!deleted) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
231
260
  broadcastSync({ type: 'conversation_deleted', conversationId: convMatch[1] });
232
- res.writeHead(200, { 'Content-Type': 'application/json' });
233
- res.end(JSON.stringify({ deleted: true }));
261
+ sendJSON(req, res, 200, { deleted: true });
234
262
  return;
235
263
  }
236
264
  }
@@ -242,15 +270,14 @@ const server = http.createServer(async (req, res) => {
242
270
  const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 100);
243
271
  const offset = Math.max(parseInt(url.searchParams.get('offset') || '0'), 0);
244
272
  const result = queries.getPaginatedMessages(messagesMatch[1], limit, offset);
245
- res.writeHead(200, { 'Content-Type': 'application/json' });
246
- res.end(JSON.stringify(result));
273
+ sendJSON(req, res, 200, result);
247
274
  return;
248
275
  }
249
276
 
250
277
  if (req.method === 'POST') {
251
278
  const conversationId = messagesMatch[1];
252
279
  const conv = queries.getConversation(conversationId);
253
- if (!conv) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Conversation not found' })); return; }
280
+ if (!conv) { sendJSON(req, res, 404, { error: 'Conversation not found' }); return; }
254
281
  const body = await parseBody(req);
255
282
  const idempotencyKey = body.idempotencyKey || null;
256
283
  const message = queries.createMessage(conversationId, 'user', body.content, idempotencyKey);
@@ -258,8 +285,7 @@ const server = http.createServer(async (req, res) => {
258
285
  broadcastSync({ type: 'message_created', conversationId, message, timestamp: Date.now() });
259
286
  const session = queries.createSession(conversationId);
260
287
  queries.createEvent('session.created', { messageId: message.id, sessionId: session.id }, conversationId, session.id);
261
- res.writeHead(201, { 'Content-Type': 'application/json' });
262
- res.end(JSON.stringify({ message, session, idempotencyKey }));
288
+ sendJSON(req, res, 201, { message, session, idempotencyKey });
263
289
  // Fire-and-forget with proper error handling
264
290
  processMessage(conversationId, message.id, body.content, body.agentId)
265
291
  .catch(err => debugLog(`[processMessage] Uncaught error: ${err.message}`));
@@ -272,10 +298,10 @@ const server = http.createServer(async (req, res) => {
272
298
  const conversationId = streamMatch[1];
273
299
  const body = await parseBody(req);
274
300
  const conv = queries.getConversation(conversationId);
275
- if (!conv) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Conversation not found' })); return; }
301
+ if (!conv) { sendJSON(req, res, 404, { error: 'Conversation not found' }); return; }
276
302
 
277
- const prompt = body.content || '';
278
- const agentId = body.agentId || 'claude-code';
303
+ const prompt = body.content || body.message || '';
304
+ const agentId = body.agentId || conv.agentType || conv.agentId || 'claude-code';
279
305
 
280
306
  const userMessage = queries.createMessage(conversationId, 'user', prompt);
281
307
  queries.createEvent('message.created', { role: 'user', messageId: userMessage.id }, conversationId);
@@ -290,16 +316,14 @@ const server = http.createServer(async (req, res) => {
290
316
  const queueLength = messageQueues.get(conversationId).length;
291
317
  broadcastSync({ type: 'queue_status', conversationId, queueLength, messageId: userMessage.id, timestamp: Date.now() });
292
318
 
293
- res.writeHead(200, { 'Content-Type': 'application/json' });
294
- res.end(JSON.stringify({ message: userMessage, queued: true, queuePosition: queueLength }));
319
+ sendJSON(req, res, 200, { message: userMessage, queued: true, queuePosition: queueLength });
295
320
  return;
296
321
  }
297
322
 
298
323
  const session = queries.createSession(conversationId);
299
324
  queries.createEvent('session.created', { messageId: userMessage.id, sessionId: session.id }, conversationId, session.id);
300
325
 
301
- res.writeHead(200, { 'Content-Type': 'application/json' });
302
- res.end(JSON.stringify({ message: userMessage, session, streamId: session.id }));
326
+ sendJSON(req, res, 200, { message: userMessage, session, streamId: session.id });
303
327
 
304
328
  broadcastSync({
305
329
  type: 'streaming_start',
@@ -318,19 +342,17 @@ const server = http.createServer(async (req, res) => {
318
342
  const messageMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/messages\/([^/]+)$/);
319
343
  if (messageMatch && req.method === 'GET') {
320
344
  const msg = queries.getMessage(messageMatch[2]);
321
- if (!msg || msg.conversationId !== messageMatch[1]) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); return; }
322
- res.writeHead(200, { 'Content-Type': 'application/json' });
323
- res.end(JSON.stringify({ message: msg }));
345
+ if (!msg || msg.conversationId !== messageMatch[1]) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
346
+ sendJSON(req, res, 200, { message: msg });
324
347
  return;
325
348
  }
326
349
 
327
350
  const sessionMatch = pathOnly.match(/^\/api\/sessions\/([^/]+)$/);
328
351
  if (sessionMatch && req.method === 'GET') {
329
352
  const sess = queries.getSession(sessionMatch[1]);
330
- if (!sess) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); return; }
353
+ if (!sess) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
331
354
  const events = queries.getSessionEvents(sessionMatch[1]);
332
- res.writeHead(200, { 'Content-Type': 'application/json' });
333
- res.end(JSON.stringify({ session: sess, events }));
355
+ sendJSON(req, res, 200, { session: sess, events });
334
356
  return;
335
357
  }
336
358
 
@@ -338,20 +360,33 @@ const server = http.createServer(async (req, res) => {
338
360
  if (fullLoadMatch && req.method === 'GET') {
339
361
  const conversationId = fullLoadMatch[1];
340
362
  const conv = queries.getConversation(conversationId);
341
- if (!conv) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); return; }
363
+ if (!conv) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
342
364
  const latestSession = queries.getLatestSession(conversationId);
343
365
  const isActivelyStreaming = activeExecutions.has(conversationId) ||
344
366
  (latestSession && latestSession.status === 'active');
345
- const chunks = queries.getConversationChunks(conversationId);
367
+
368
+ const url = new URL(req.url, 'http://localhost');
369
+ const chunkLimit = Math.min(parseInt(url.searchParams.get('chunkLimit') || '500'), 5000);
370
+ const allChunks = url.searchParams.get('allChunks') === '1';
371
+
372
+ const totalChunks = queries.getConversationChunkCount(conversationId);
373
+ let chunks;
374
+ if (allChunks || totalChunks <= chunkLimit) {
375
+ chunks = queries.getConversationChunks(conversationId);
376
+ } else {
377
+ chunks = queries.getRecentConversationChunks(conversationId, chunkLimit);
378
+ }
346
379
  const msgResult = queries.getPaginatedMessages(conversationId, 100, 0);
347
- res.writeHead(200, { 'Content-Type': 'application/json' });
348
- res.end(JSON.stringify({
380
+ const rateLimitInfo = rateLimitState.get(conversationId) || null;
381
+ sendJSON(req, res, 200, {
349
382
  conversation: conv,
350
383
  isActivelyStreaming,
351
384
  latestSession,
352
385
  chunks,
353
- messages: msgResult.messages
354
- }));
386
+ totalChunks,
387
+ messages: msgResult.messages,
388
+ rateLimitInfo
389
+ });
355
390
  return;
356
391
  }
357
392
 
@@ -359,7 +394,7 @@ const server = http.createServer(async (req, res) => {
359
394
  if (conversationChunksMatch && req.method === 'GET') {
360
395
  const conversationId = conversationChunksMatch[1];
361
396
  const conv = queries.getConversation(conversationId);
362
- if (!conv) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Conversation not found' })); return; }
397
+ if (!conv) { sendJSON(req, res, 404, { error: 'Conversation not found' }); return; }
363
398
 
364
399
  const url = new URL(req.url, 'http://localhost');
365
400
  const since = parseInt(url.searchParams.get('since') || '0');
@@ -367,8 +402,7 @@ const server = http.createServer(async (req, res) => {
367
402
  const allChunks = queries.getConversationChunks(conversationId);
368
403
  debugLog(`[chunks] Conv ${conversationId}: ${allChunks.length} total chunks`);
369
404
  const chunks = since > 0 ? allChunks.filter(c => c.created_at > since) : allChunks;
370
- res.writeHead(200, { 'Content-Type': 'application/json' });
371
- res.end(JSON.stringify({ ok: true, chunks }));
405
+ sendJSON(req, res, 200, { ok: true, chunks });
372
406
  return;
373
407
  }
374
408
 
@@ -376,14 +410,13 @@ const server = http.createServer(async (req, res) => {
376
410
  if (sessionChunksMatch && req.method === 'GET') {
377
411
  const sessionId = sessionChunksMatch[1];
378
412
  const sess = queries.getSession(sessionId);
379
- if (!sess) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Session not found' })); return; }
413
+ if (!sess) { sendJSON(req, res, 404, { error: 'Session not found' }); return; }
380
414
 
381
415
  const url = new URL(req.url, 'http://localhost');
382
416
  const since = parseInt(url.searchParams.get('since') || '0');
383
417
 
384
418
  const chunks = queries.getChunksSince(sessionId, since);
385
- res.writeHead(200, { 'Content-Type': 'application/json' });
386
- res.end(JSON.stringify({ ok: true, chunks }));
419
+ sendJSON(req, res, 200, { ok: true, chunks });
387
420
  return;
388
421
  }
389
422
 
@@ -391,13 +424,11 @@ const server = http.createServer(async (req, res) => {
391
424
  const convId = pathOnly.match(/^\/api\/conversations\/([^/]+)\/sessions\/latest$/)[1];
392
425
  const latestSession = queries.getLatestSession(convId);
393
426
  if (!latestSession) {
394
- res.writeHead(200, { 'Content-Type': 'application/json' });
395
- res.end(JSON.stringify({ session: null }));
427
+ sendJSON(req, res, 200, { session: null });
396
428
  return;
397
429
  }
398
430
  const events = queries.getSessionEvents(latestSession.id);
399
- res.writeHead(200, { 'Content-Type': 'application/json' });
400
- res.end(JSON.stringify({ session: latestSession, events }));
431
+ sendJSON(req, res, 200, { session: latestSession, events });
401
432
  return;
402
433
  }
403
434
 
@@ -432,39 +463,33 @@ const server = http.createServer(async (req, res) => {
432
463
  executionData.events = executionData.events.filter(e => e.type === filterType);
433
464
  }
434
465
 
435
- res.writeHead(200, { 'Content-Type': 'application/json' });
436
- res.end(JSON.stringify(executionData));
466
+ sendJSON(req, res, 200, executionData);
437
467
  } catch (err) {
438
- res.writeHead(400, { 'Content-Type': 'application/json' });
439
- res.end(JSON.stringify({ error: err.message }));
468
+ sendJSON(req, res, 400, { error: err.message });
440
469
  }
441
470
  return;
442
471
  }
443
472
 
444
473
  if (routePath === '/api/agents' && req.method === 'GET') {
445
- res.writeHead(200, { 'Content-Type': 'application/json' });
446
- res.end(JSON.stringify({ agents: discoveredAgents }));
474
+ sendJSON(req, res, 200, { agents: discoveredAgents });
447
475
  return;
448
476
  }
449
477
 
450
478
 
451
479
  if (routePath === '/api/import/claude-code' && req.method === 'GET') {
452
480
  const result = queries.importClaudeCodeConversations();
453
- res.writeHead(200, { 'Content-Type': 'application/json' });
454
- res.end(JSON.stringify({ imported: result }));
481
+ sendJSON(req, res, 200, { imported: result });
455
482
  return;
456
483
  }
457
484
 
458
485
  if (routePath === '/api/discover/claude-code' && req.method === 'GET') {
459
486
  const discovered = queries.discoverClaudeCodeConversations();
460
- res.writeHead(200, { 'Content-Type': 'application/json' });
461
- res.end(JSON.stringify({ discovered }));
487
+ sendJSON(req, res, 200, { discovered });
462
488
  return;
463
489
  }
464
490
 
465
491
  if (routePath === '/api/home' && req.method === 'GET') {
466
- res.writeHead(200, { 'Content-Type': 'application/json' });
467
- res.end(JSON.stringify({ home: os.homedir(), cwd: STARTUP_CWD }));
492
+ sendJSON(req, res, 200, { home: os.homedir(), cwd: STARTUP_CWD });
468
493
  return;
469
494
  }
470
495
 
@@ -474,20 +499,15 @@ const server = http.createServer(async (req, res) => {
474
499
  for await (const chunk of req) chunks.push(chunk);
475
500
  const audioBuffer = Buffer.concat(chunks);
476
501
  if (audioBuffer.length === 0) {
477
- res.writeHead(400, { 'Content-Type': 'application/json' });
478
- res.end(JSON.stringify({ error: 'No audio data' }));
502
+ sendJSON(req, res, 400, { error: 'No audio data' });
479
503
  return;
480
504
  }
481
505
  const { transcribe } = await getSpeech();
482
506
  const text = await transcribe(audioBuffer);
483
- res.writeHead(200, { 'Content-Type': 'application/json' });
484
- res.end(JSON.stringify({ text: (text || '').trim() }));
507
+ sendJSON(req, res, 200, { text: (text || '').trim() });
485
508
  } catch (err) {
486
509
  debugLog('[STT] Error: ' + err.message);
487
- if (!res.headersSent) {
488
- res.writeHead(500, { 'Content-Type': 'application/json' });
489
- }
490
- res.end(JSON.stringify({ error: err.message || 'STT failed' }));
510
+ if (!res.headersSent) sendJSON(req, res, 500, { error: err.message || 'STT failed' });
491
511
  }
492
512
  return;
493
513
  }
@@ -497,8 +517,7 @@ const server = http.createServer(async (req, res) => {
497
517
  const body = await parseBody(req);
498
518
  const text = body.text || '';
499
519
  if (!text) {
500
- res.writeHead(400, { 'Content-Type': 'application/json' });
501
- res.end(JSON.stringify({ error: 'No text provided' }));
520
+ sendJSON(req, res, 400, { error: 'No text provided' });
502
521
  return;
503
522
  }
504
523
  const { synthesize } = await getSpeech();
@@ -507,10 +526,7 @@ const server = http.createServer(async (req, res) => {
507
526
  res.end(wavBuffer);
508
527
  } catch (err) {
509
528
  debugLog('[TTS] Error: ' + err.message);
510
- if (!res.headersSent) {
511
- res.writeHead(500, { 'Content-Type': 'application/json' });
512
- }
513
- res.end(JSON.stringify({ error: err.message || 'TTS failed' }));
529
+ if (!res.headersSent) sendJSON(req, res, 500, { error: err.message || 'TTS failed' });
514
530
  }
515
531
  return;
516
532
  }
@@ -518,11 +534,9 @@ const server = http.createServer(async (req, res) => {
518
534
  if (routePath === '/api/speech-status' && req.method === 'GET') {
519
535
  try {
520
536
  const { getStatus } = await getSpeech();
521
- res.writeHead(200, { 'Content-Type': 'application/json' });
522
- res.end(JSON.stringify(getStatus()));
537
+ sendJSON(req, res, 200, getStatus());
523
538
  } catch (err) {
524
- res.writeHead(200, { 'Content-Type': 'application/json' });
525
- res.end(JSON.stringify({ sttReady: false, ttsReady: false, sttLoading: false, ttsLoading: false }));
539
+ sendJSON(req, res, 200, { sttReady: false, ttsReady: false, sttLoading: false, ttsLoading: false });
526
540
  }
527
541
  return;
528
542
  }
@@ -538,11 +552,9 @@ const server = http.createServer(async (req, res) => {
538
552
  .filter(e => e.isDirectory() && !e.name.startsWith('.'))
539
553
  .map(e => ({ name: e.name }))
540
554
  .sort((a, b) => a.name.localeCompare(b.name));
541
- res.writeHead(200, { 'Content-Type': 'application/json' });
542
- res.end(JSON.stringify({ folders }));
555
+ sendJSON(req, res, 200, { folders });
543
556
  } catch (err) {
544
- res.writeHead(400, { 'Content-Type': 'application/json' });
545
- res.end(JSON.stringify({ error: err.message }));
557
+ sendJSON(req, res, 400, { error: err.message });
546
558
  }
547
559
  return;
548
560
  }
@@ -565,8 +577,7 @@ const server = http.createServer(async (req, res) => {
565
577
  res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache' });
566
578
  res.end(fileContent);
567
579
  } catch (err) {
568
- res.writeHead(400, { 'Content-Type': 'application/json' });
569
- res.end(JSON.stringify({ error: err.message }));
580
+ sendJSON(req, res, 400, { error: err.message });
570
581
  }
571
582
  return;
572
583
  }
@@ -574,7 +585,7 @@ const server = http.createServer(async (req, res) => {
574
585
  // Handle conversation detail routes - serve index.html for client-side routing
575
586
  if (pathOnly.match(/^\/conversations\/[^\/]+$/)) {
576
587
  const indexPath = path.join(staticDir, 'index.html');
577
- serveFile(indexPath, res);
588
+ serveFile(indexPath, res, req);
578
589
  return;
579
590
  }
580
591
 
@@ -589,34 +600,59 @@ const server = http.createServer(async (req, res) => {
589
600
  filePath = path.join(filePath, 'index.html');
590
601
  fs.stat(filePath, (err2) => {
591
602
  if (err2) { res.writeHead(404); res.end('Not found'); return; }
592
- serveFile(filePath, res);
603
+ serveFile(filePath, res, req);
593
604
  });
594
605
  } else {
595
- serveFile(filePath, res);
606
+ serveFile(filePath, res, req);
596
607
  }
597
608
  });
598
609
  } catch (e) {
599
610
  console.error('Server error:', e.message);
600
- res.writeHead(500, { 'Content-Type': 'application/json' });
601
- res.end(JSON.stringify({ error: e.message }));
611
+ sendJSON(req, res, 500, { error: e.message });
602
612
  }
603
613
  });
604
614
 
605
615
  const MIME_TYPES = { '.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' };
606
616
 
607
- function serveFile(filePath, res) {
617
+ function generateETag(stats) {
618
+ return `"${stats.mtimeMs.toString(36)}-${stats.size.toString(36)}"`;
619
+ }
620
+
621
+ function serveFile(filePath, res, req) {
608
622
  const ext = path.extname(filePath).toLowerCase();
609
623
  const contentType = MIME_TYPES[ext] || 'application/octet-stream';
610
624
 
611
625
  if (ext !== '.html') {
612
626
  fs.stat(filePath, (err, stats) => {
613
627
  if (err) { res.writeHead(500); res.end('Server error'); return; }
614
- res.writeHead(200, {
628
+ const etag = generateETag(stats);
629
+ if (req && req.headers['if-none-match'] === etag) {
630
+ res.writeHead(304);
631
+ res.end();
632
+ return;
633
+ }
634
+ const headers = {
615
635
  'Content-Type': contentType,
616
636
  'Content-Length': stats.size,
617
- 'Cache-Control': 'no-cache, must-revalidate'
618
- });
619
- fs.createReadStream(filePath).pipe(res);
637
+ 'ETag': etag,
638
+ 'Cache-Control': 'public, max-age=3600, must-revalidate'
639
+ };
640
+ if (acceptsEncoding(req, 'br') && stats.size > 860) {
641
+ const stream = fs.createReadStream(filePath);
642
+ headers['Content-Encoding'] = 'br';
643
+ delete headers['Content-Length'];
644
+ res.writeHead(200, headers);
645
+ stream.pipe(zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } })).pipe(res);
646
+ } else if (acceptsEncoding(req, 'gzip') && stats.size > 860) {
647
+ const stream = fs.createReadStream(filePath);
648
+ headers['Content-Encoding'] = 'gzip';
649
+ delete headers['Content-Length'];
650
+ res.writeHead(200, headers);
651
+ stream.pipe(zlib.createGzip({ level: 6 })).pipe(res);
652
+ } else {
653
+ res.writeHead(200, headers);
654
+ fs.createReadStream(filePath).pipe(res);
655
+ }
620
656
  });
621
657
  return;
622
658
  }
@@ -629,24 +665,52 @@ function serveFile(filePath, res) {
629
665
  if (watch) {
630
666
  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>`;
631
667
  }
632
- res.writeHead(200, { 'Content-Type': contentType });
633
- res.end(content);
668
+ compressAndSend(req, res, 200, contentType, content);
634
669
  });
635
670
  }
636
671
 
637
- function persistChunkWithRetry(sessionId, conversationId, sequence, blockType, blockData, maxRetries = 3) {
638
- for (let attempt = 0; attempt < maxRetries; attempt++) {
672
+ function createChunkBatcher() {
673
+ const pending = [];
674
+ let timer = null;
675
+ const BATCH_SIZE = 10;
676
+ const BATCH_INTERVAL = 50;
677
+
678
+ function flush() {
679
+ if (pending.length === 0) return;
680
+ const batch = pending.splice(0);
639
681
  try {
640
- return queries.createChunk(sessionId, conversationId, sequence, blockType, blockData);
682
+ const tx = queries._db ? queries._db.transaction(() => {
683
+ for (const c of batch) queries.createChunk(c.sessionId, c.conversationId, c.sequence, c.type, c.data);
684
+ }) : null;
685
+ if (tx) { tx(); } else {
686
+ for (const c of batch) {
687
+ try { queries.createChunk(c.sessionId, c.conversationId, c.sequence, c.type, c.data); } catch (e) { debugLog(`[chunk] ${e.message}`); }
688
+ }
689
+ }
641
690
  } catch (err) {
642
- debugLog(`[chunk] Persist attempt ${attempt + 1}/${maxRetries} failed: ${err.message}`);
643
- if (attempt >= maxRetries - 1) {
644
- debugLog(`[chunk] Failed to persist after ${maxRetries} retries: ${err.message}`);
645
- return null;
691
+ debugLog(`[chunk-batch] Batch write failed: ${err.message}`);
692
+ for (const c of batch) {
693
+ try { queries.createChunk(c.sessionId, c.conversationId, c.sequence, c.type, c.data); } catch (_) {}
646
694
  }
647
695
  }
648
696
  }
649
- return null;
697
+
698
+ function add(sessionId, conversationId, sequence, blockType, blockData) {
699
+ pending.push({ sessionId, conversationId, sequence, type: blockType, data: blockData });
700
+ if (pending.length >= BATCH_SIZE) {
701
+ if (timer) { clearTimeout(timer); timer = null; }
702
+ flush();
703
+ } else if (!timer) {
704
+ timer = setTimeout(() => { timer = null; flush(); }, BATCH_INTERVAL);
705
+ }
706
+ }
707
+
708
+ function drain() {
709
+ if (timer) { clearTimeout(timer); timer = null; }
710
+ flush();
711
+ }
712
+
713
+ return { add, drain };
650
714
  }
651
715
 
652
716
  async function processMessageWithStreaming(conversationId, messageId, sessionId, content, agentId) {
@@ -654,6 +718,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
654
718
  activeExecutions.set(conversationId, { pid: null, startTime, sessionId, lastActivity: startTime });
655
719
  queries.setIsStreaming(conversationId, true);
656
720
  queries.updateSession(sessionId, { status: 'active' });
721
+ const batcher = createChunkBatcher();
657
722
 
658
723
  try {
659
724
  debugLog(`[stream] Starting: conversationId=${conversationId}, sessionId=${sessionId}`);
@@ -683,7 +748,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
683
748
  };
684
749
 
685
750
  currentSequence++;
686
- persistChunkWithRetry(sessionId, conversationId, currentSequence, 'system', systemBlock);
751
+ batcher.add(sessionId, conversationId, currentSequence, 'system', systemBlock);
687
752
 
688
753
  broadcastSync({
689
754
  type: 'streaming_progress',
@@ -698,7 +763,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
698
763
  allBlocks.push(block);
699
764
 
700
765
  currentSequence++;
701
- persistChunkWithRetry(sessionId, conversationId, currentSequence, block.type || 'assistant', block);
766
+ batcher.add(sessionId, conversationId, currentSequence, block.type || 'assistant', block);
702
767
 
703
768
  broadcastSync({
704
769
  type: 'streaming_progress',
@@ -720,7 +785,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
720
785
  };
721
786
 
722
787
  currentSequence++;
723
- persistChunkWithRetry(sessionId, conversationId, currentSequence, 'tool_result', toolResultBlock);
788
+ batcher.add(sessionId, conversationId, currentSequence, 'tool_result', toolResultBlock);
724
789
 
725
790
  broadcastSync({
726
791
  type: 'streaming_progress',
@@ -744,7 +809,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
744
809
  };
745
810
 
746
811
  currentSequence++;
747
- persistChunkWithRetry(sessionId, conversationId, currentSequence, 'result', resultBlock);
812
+ batcher.add(sessionId, conversationId, currentSequence, 'result', resultBlock);
748
813
 
749
814
  broadcastSync({
750
815
  type: 'streaming_progress',
@@ -777,11 +842,13 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
777
842
  };
778
843
 
779
844
  const { outputs, sessionId: claudeSessionId } = await runClaudeWithStreaming(content, cwd, agentId || 'claude-code', config);
845
+ activeExecutions.delete(conversationId);
846
+ batcher.drain();
780
847
  debugLog(`[stream] Claude returned ${outputs.length} outputs, sessionId=${claudeSessionId}`);
781
848
 
782
- if (claudeSessionId && !conv?.claudeSessionId) {
849
+ if (claudeSessionId) {
783
850
  queries.setClaudeSessionId(conversationId, claudeSessionId);
784
- debugLog(`[stream] Stored claudeSessionId=${claudeSessionId}`);
851
+ debugLog(`[stream] Updated claudeSessionId=${claudeSessionId}`);
785
852
  }
786
853
 
787
854
  // Mark session as complete
@@ -804,13 +871,47 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
804
871
  const elapsed = Date.now() - startTime;
805
872
  debugLog(`[stream] Error after ${elapsed}ms: ${error.message}`);
806
873
 
807
- // Mark session as error
874
+ const isRateLimit = error.rateLimited ||
875
+ /rate.?limit|429|too many requests|overloaded|throttl/i.test(error.message);
876
+
808
877
  queries.updateSession(sessionId, {
809
878
  status: 'error',
810
879
  error: error.message,
811
880
  completed_at: Date.now()
812
881
  });
813
882
 
883
+ if (isRateLimit) {
884
+ const cooldownMs = (error.retryAfterSec || 60) * 1000;
885
+ const retryAt = Date.now() + cooldownMs;
886
+ rateLimitState.set(conversationId, { retryAt, cooldownMs });
887
+ debugLog(`[rate-limit] Conv ${conversationId} hit rate limit, retry in ${cooldownMs}ms`);
888
+
889
+ broadcastSync({
890
+ type: 'rate_limit_hit',
891
+ sessionId,
892
+ conversationId,
893
+ retryAfterMs: cooldownMs,
894
+ retryAt,
895
+ timestamp: Date.now()
896
+ });
897
+
898
+ batcher.drain();
899
+ activeExecutions.delete(conversationId);
900
+ queries.setIsStreaming(conversationId, false);
901
+
902
+ setTimeout(() => {
903
+ rateLimitState.delete(conversationId);
904
+ debugLog(`[rate-limit] Conv ${conversationId} cooldown expired, restarting`);
905
+ broadcastSync({
906
+ type: 'rate_limit_clear',
907
+ conversationId,
908
+ timestamp: Date.now()
909
+ });
910
+ scheduleRetry(conversationId, messageId, content, agentId);
911
+ }, cooldownMs);
912
+ return;
913
+ }
914
+
814
915
  broadcastSync({
815
916
  type: 'streaming_error',
816
917
  sessionId,
@@ -828,12 +929,32 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
828
929
  timestamp: Date.now()
829
930
  });
830
931
  } finally {
932
+ batcher.drain();
831
933
  activeExecutions.delete(conversationId);
832
934
  queries.setIsStreaming(conversationId, false);
833
- drainMessageQueue(conversationId);
935
+ if (!rateLimitState.has(conversationId)) {
936
+ drainMessageQueue(conversationId);
937
+ }
834
938
  }
835
939
  }
836
940
 
941
+ function scheduleRetry(conversationId, messageId, content, agentId) {
942
+ const newSession = queries.createSession(conversationId);
943
+ queries.createEvent('session.created', { messageId, sessionId: newSession.id, retryReason: 'rate_limit' }, conversationId, newSession.id);
944
+
945
+ broadcastSync({
946
+ type: 'streaming_start',
947
+ sessionId: newSession.id,
948
+ conversationId,
949
+ messageId,
950
+ agentId,
951
+ timestamp: Date.now()
952
+ });
953
+
954
+ processMessageWithStreaming(conversationId, messageId, newSession.id, content, agentId)
955
+ .catch(err => debugLog(`[retry] Error: ${err.message}`));
956
+ }
957
+
837
958
  function drainMessageQueue(conversationId) {
838
959
  const queue = messageQueues.get(conversationId);
839
960
  if (!queue || queue.length === 0) return;
@@ -881,7 +1002,7 @@ async function processMessage(conversationId, messageId, content, agentId) {
881
1002
  systemPrompt: SYSTEM_PROMPT
882
1003
  });
883
1004
 
884
- if (claudeSessionId && !conv?.claudeSessionId) {
1005
+ if (claudeSessionId) {
885
1006
  queries.setClaudeSessionId(conversationId, claudeSessionId);
886
1007
  }
887
1008
 
@@ -915,9 +1036,16 @@ async function processMessage(conversationId, messageId, content, agentId) {
915
1036
  }
916
1037
  }
917
1038
 
918
- const wss = new WebSocketServer({ server });
1039
+ const wss = new WebSocketServer({
1040
+ server,
1041
+ perMessageDeflate: {
1042
+ zlibDeflateOptions: { level: 6 },
1043
+ threshold: 256
1044
+ }
1045
+ });
919
1046
  const hotReloadClients = [];
920
1047
  const syncClients = new Set();
1048
+ const subscriptionIndex = new Map();
921
1049
 
922
1050
  wss.on('connection', (ws, req) => {
923
1051
  // req.url in WebSocket is just the path (e.g., '/gm/sync'), not a full URL
@@ -941,8 +1069,17 @@ wss.on('connection', (ws, req) => {
941
1069
  try {
942
1070
  const data = JSON.parse(msg);
943
1071
  if (data.type === 'subscribe') {
944
- if (data.sessionId) ws.subscriptions.add(data.sessionId);
945
- if (data.conversationId) ws.subscriptions.add(`conv-${data.conversationId}`);
1072
+ if (data.sessionId) {
1073
+ ws.subscriptions.add(data.sessionId);
1074
+ if (!subscriptionIndex.has(data.sessionId)) subscriptionIndex.set(data.sessionId, new Set());
1075
+ subscriptionIndex.get(data.sessionId).add(ws);
1076
+ }
1077
+ if (data.conversationId) {
1078
+ const key = `conv-${data.conversationId}`;
1079
+ ws.subscriptions.add(key);
1080
+ if (!subscriptionIndex.has(key)) subscriptionIndex.set(key, new Set());
1081
+ subscriptionIndex.get(key).add(ws);
1082
+ }
946
1083
  const subTarget = data.sessionId || data.conversationId;
947
1084
  debugLog(`[WebSocket] Client ${ws.clientId} subscribed to ${subTarget}`);
948
1085
  ws.send(JSON.stringify({
@@ -953,6 +1090,8 @@ wss.on('connection', (ws, req) => {
953
1090
  }));
954
1091
  } else if (data.type === 'unsubscribe') {
955
1092
  ws.subscriptions.delete(data.sessionId);
1093
+ const idx = subscriptionIndex.get(data.sessionId);
1094
+ if (idx) { idx.delete(ws); if (idx.size === 0) subscriptionIndex.delete(data.sessionId); }
956
1095
  debugLog(`[WebSocket] Client ${ws.clientId} unsubscribed from ${data.sessionId}`);
957
1096
  } else if (data.type === 'get_subscriptions') {
958
1097
  ws.send(JSON.stringify({
@@ -979,6 +1118,10 @@ wss.on('connection', (ws, req) => {
979
1118
  ws.on('pong', () => { ws.isAlive = true; });
980
1119
  ws.on('close', () => {
981
1120
  syncClients.delete(ws);
1121
+ for (const sub of ws.subscriptions) {
1122
+ const idx = subscriptionIndex.get(sub);
1123
+ if (idx) { idx.delete(ws); if (idx.size === 0) subscriptionIndex.delete(sub); }
1124
+ }
982
1125
  console.log(`[WebSocket] Client ${ws.clientId} disconnected`);
983
1126
  });
984
1127
  }
@@ -987,22 +1130,56 @@ wss.on('connection', (ws, req) => {
987
1130
  const BROADCAST_TYPES = new Set([
988
1131
  'message_created', 'conversation_created', 'conversations_updated',
989
1132
  'conversation_deleted', 'queue_status', 'streaming_start',
990
- 'streaming_complete', 'streaming_error'
1133
+ 'streaming_complete', 'streaming_error', 'rate_limit_hit',
1134
+ 'rate_limit_clear'
991
1135
  ]);
992
1136
 
1137
+ const wsBatchQueues = new Map();
1138
+ const WS_BATCH_INTERVAL = 16;
1139
+
1140
+ function flushWsBatch(ws) {
1141
+ const queue = wsBatchQueues.get(ws);
1142
+ if (!queue || queue.msgs.length === 0) return;
1143
+ if (ws.readyState !== 1) { wsBatchQueues.delete(ws); return; }
1144
+ if (queue.msgs.length === 1) {
1145
+ ws.send(queue.msgs[0]);
1146
+ } else {
1147
+ ws.send('[' + queue.msgs.join(',') + ']');
1148
+ }
1149
+ queue.msgs.length = 0;
1150
+ queue.timer = null;
1151
+ }
1152
+
1153
+ function sendToClient(ws, data) {
1154
+ if (ws.readyState !== 1) return;
1155
+ let queue = wsBatchQueues.get(ws);
1156
+ if (!queue) { queue = { msgs: [], timer: null }; wsBatchQueues.set(ws, queue); }
1157
+ queue.msgs.push(data);
1158
+ if (!queue.timer) {
1159
+ queue.timer = setTimeout(() => flushWsBatch(ws), WS_BATCH_INTERVAL);
1160
+ }
1161
+ }
1162
+
993
1163
  function broadcastSync(event) {
994
1164
  if (syncClients.size === 0) return;
995
1165
  const data = JSON.stringify(event);
996
1166
  const isBroadcast = BROADCAST_TYPES.has(event.type);
997
1167
 
998
- for (const ws of syncClients) {
999
- if (ws.readyState !== 1) continue;
1000
- if (isBroadcast ||
1001
- (event.sessionId && ws.subscriptions?.has(event.sessionId)) ||
1002
- (event.conversationId && ws.subscriptions?.has(`conv-${event.conversationId}`))) {
1003
- ws.send(data);
1004
- }
1168
+ if (isBroadcast) {
1169
+ for (const ws of syncClients) sendToClient(ws, data);
1170
+ return;
1005
1171
  }
1172
+
1173
+ const targets = new Set();
1174
+ if (event.sessionId) {
1175
+ const subs = subscriptionIndex.get(event.sessionId);
1176
+ if (subs) for (const ws of subs) targets.add(ws);
1177
+ }
1178
+ if (event.conversationId) {
1179
+ const subs = subscriptionIndex.get(`conv-${event.conversationId}`);
1180
+ if (subs) for (const ws of subs) targets.add(ws);
1181
+ }
1182
+ for (const ws of targets) sendToClient(ws, data);
1006
1183
  }
1007
1184
 
1008
1185
  // Heartbeat interval to detect stale connections
@@ -1166,18 +1343,38 @@ function onServerReady() {
1166
1343
 
1167
1344
  }
1168
1345
 
1346
+ const importMtimeCache = new Map();
1347
+
1348
+ function hasIndexFilesChanged() {
1349
+ const projectsDir = path.join(os.homedir(), '.claude', 'projects');
1350
+ if (!fs.existsSync(projectsDir)) return false;
1351
+ let changed = false;
1352
+ try {
1353
+ const dirs = fs.readdirSync(projectsDir);
1354
+ for (const d of dirs) {
1355
+ const indexPath = path.join(projectsDir, d, 'sessions-index.json');
1356
+ try {
1357
+ const stat = fs.statSync(indexPath);
1358
+ const cached = importMtimeCache.get(indexPath);
1359
+ if (!cached || cached < stat.mtimeMs) {
1360
+ importMtimeCache.set(indexPath, stat.mtimeMs);
1361
+ changed = true;
1362
+ }
1363
+ } catch (_) {}
1364
+ }
1365
+ } catch (_) {}
1366
+ return changed;
1367
+ }
1368
+
1169
1369
  function performAutoImport() {
1170
1370
  try {
1371
+ if (!hasIndexFilesChanged()) return;
1171
1372
  const imported = queries.importClaudeCodeConversations();
1172
1373
  if (imported.length > 0) {
1173
1374
  const importedCount = imported.filter(i => i.status === 'imported').length;
1174
- const skippedCount = imported.filter(i => i.status === 'skipped').length;
1175
1375
  if (importedCount > 0) {
1176
- console.log(`[AUTO-IMPORT] Imported ${importedCount} new Claude Code conversations (${skippedCount} already exist)`);
1177
- // Broadcast to all connected clients that conversations were updated
1376
+ console.log(`[AUTO-IMPORT] Imported ${importedCount} new Claude Code conversations`);
1178
1377
  broadcastSync({ type: 'conversations_updated', count: importedCount });
1179
- } else if (skippedCount > 0) {
1180
- // All conversations already imported, don't spam logs
1181
1378
  }
1182
1379
  }
1183
1380
  } catch (err) {