agentgui 1.0.150 → 1.0.152

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';
@@ -146,6 +147,36 @@ function parseBody(req) {
146
147
  });
147
148
  }
148
149
 
150
+ function acceptsEncoding(req, encoding) {
151
+ const accept = req.headers['accept-encoding'] || '';
152
+ return accept.includes(encoding);
153
+ }
154
+
155
+ function compressAndSend(req, res, statusCode, contentType, body) {
156
+ const raw = typeof body === 'string' ? Buffer.from(body) : body;
157
+ if (raw.length < 860) {
158
+ res.writeHead(statusCode, { 'Content-Type': contentType, 'Content-Length': raw.length });
159
+ res.end(raw);
160
+ return;
161
+ }
162
+ if (acceptsEncoding(req, 'br')) {
163
+ const compressed = zlib.brotliCompressSync(raw, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } });
164
+ res.writeHead(statusCode, { 'Content-Type': contentType, 'Content-Encoding': 'br', 'Content-Length': compressed.length });
165
+ res.end(compressed);
166
+ } else if (acceptsEncoding(req, 'gzip')) {
167
+ const compressed = zlib.gzipSync(raw, { level: 6 });
168
+ res.writeHead(statusCode, { 'Content-Type': contentType, 'Content-Encoding': 'gzip', 'Content-Length': compressed.length });
169
+ res.end(compressed);
170
+ } else {
171
+ res.writeHead(statusCode, { 'Content-Type': contentType, 'Content-Length': raw.length });
172
+ res.end(raw);
173
+ }
174
+ }
175
+
176
+ function sendJSON(req, res, statusCode, data) {
177
+ compressAndSend(req, res, statusCode, 'application/json', JSON.stringify(data));
178
+ }
179
+
149
180
  const server = http.createServer(async (req, res) => {
150
181
  res.setHeader('Access-Control-Allow-Origin', '*');
151
182
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
@@ -179,8 +210,7 @@ const server = http.createServer(async (req, res) => {
179
210
  const pathOnly = routePath.split('?')[0];
180
211
 
181
212
  if (pathOnly === '/api/conversations' && req.method === 'GET') {
182
- res.writeHead(200, { 'Content-Type': 'application/json' });
183
- res.end(JSON.stringify({ conversations: queries.getConversationsList() }));
213
+ sendJSON(req, res, 200, { conversations: queries.getConversationsList() });
184
214
  return;
185
215
  }
186
216
 
@@ -189,8 +219,7 @@ const server = http.createServer(async (req, res) => {
189
219
  const conversation = queries.createConversation(body.agentId, body.title, body.workingDirectory || null);
190
220
  queries.createEvent('conversation.created', { agentId: body.agentId, workingDirectory: conversation.workingDirectory }, conversation.id);
191
221
  broadcastSync({ type: 'conversation_created', conversation });
192
- res.writeHead(201, { 'Content-Type': 'application/json' });
193
- res.end(JSON.stringify({ conversation }));
222
+ sendJSON(req, res, 201, { conversation });
194
223
  return;
195
224
  }
196
225
 
@@ -198,39 +227,36 @@ const server = http.createServer(async (req, res) => {
198
227
  if (convMatch) {
199
228
  if (req.method === 'GET') {
200
229
  const conv = queries.getConversation(convMatch[1]);
201
- if (!conv) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); return; }
230
+ if (!conv) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
202
231
 
203
232
  // Check both in-memory and database for active streaming status
204
233
  const latestSession = queries.getLatestSession(convMatch[1]);
205
234
  const isActivelyStreaming = activeExecutions.has(convMatch[1]) ||
206
235
  (latestSession && latestSession.status === 'active');
207
236
 
208
- res.writeHead(200, { 'Content-Type': 'application/json' });
209
- res.end(JSON.stringify({
237
+ sendJSON(req, res, 200, {
210
238
  conversation: conv,
211
239
  isActivelyStreaming,
212
240
  latestSession
213
- }));
241
+ });
214
242
  return;
215
243
  }
216
244
 
217
245
  if (req.method === 'POST' || req.method === 'PUT') {
218
246
  const body = await parseBody(req);
219
247
  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; }
248
+ if (!conv) { sendJSON(req, res, 404, { error: 'Conversation not found' }); return; }
221
249
  queries.createEvent('conversation.updated', body, convMatch[1]);
222
250
  broadcastSync({ type: 'conversation_updated', conversation: conv });
223
- res.writeHead(200, { 'Content-Type': 'application/json' });
224
- res.end(JSON.stringify({ conversation: conv }));
251
+ sendJSON(req, res, 200, { conversation: conv });
225
252
  return;
226
253
  }
227
254
 
228
255
  if (req.method === 'DELETE') {
229
256
  const deleted = queries.deleteConversation(convMatch[1]);
230
- if (!deleted) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); return; }
257
+ if (!deleted) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
231
258
  broadcastSync({ type: 'conversation_deleted', conversationId: convMatch[1] });
232
- res.writeHead(200, { 'Content-Type': 'application/json' });
233
- res.end(JSON.stringify({ deleted: true }));
259
+ sendJSON(req, res, 200, { deleted: true });
234
260
  return;
235
261
  }
236
262
  }
@@ -242,15 +268,14 @@ const server = http.createServer(async (req, res) => {
242
268
  const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 100);
243
269
  const offset = Math.max(parseInt(url.searchParams.get('offset') || '0'), 0);
244
270
  const result = queries.getPaginatedMessages(messagesMatch[1], limit, offset);
245
- res.writeHead(200, { 'Content-Type': 'application/json' });
246
- res.end(JSON.stringify(result));
271
+ sendJSON(req, res, 200, result);
247
272
  return;
248
273
  }
249
274
 
250
275
  if (req.method === 'POST') {
251
276
  const conversationId = messagesMatch[1];
252
277
  const conv = queries.getConversation(conversationId);
253
- if (!conv) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Conversation not found' })); return; }
278
+ if (!conv) { sendJSON(req, res, 404, { error: 'Conversation not found' }); return; }
254
279
  const body = await parseBody(req);
255
280
  const idempotencyKey = body.idempotencyKey || null;
256
281
  const message = queries.createMessage(conversationId, 'user', body.content, idempotencyKey);
@@ -258,8 +283,7 @@ const server = http.createServer(async (req, res) => {
258
283
  broadcastSync({ type: 'message_created', conversationId, message, timestamp: Date.now() });
259
284
  const session = queries.createSession(conversationId);
260
285
  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 }));
286
+ sendJSON(req, res, 201, { message, session, idempotencyKey });
263
287
  // Fire-and-forget with proper error handling
264
288
  processMessage(conversationId, message.id, body.content, body.agentId)
265
289
  .catch(err => debugLog(`[processMessage] Uncaught error: ${err.message}`));
@@ -272,7 +296,7 @@ const server = http.createServer(async (req, res) => {
272
296
  const conversationId = streamMatch[1];
273
297
  const body = await parseBody(req);
274
298
  const conv = queries.getConversation(conversationId);
275
- if (!conv) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Conversation not found' })); return; }
299
+ if (!conv) { sendJSON(req, res, 404, { error: 'Conversation not found' }); return; }
276
300
 
277
301
  const prompt = body.content || '';
278
302
  const agentId = body.agentId || 'claude-code';
@@ -290,16 +314,14 @@ const server = http.createServer(async (req, res) => {
290
314
  const queueLength = messageQueues.get(conversationId).length;
291
315
  broadcastSync({ type: 'queue_status', conversationId, queueLength, messageId: userMessage.id, timestamp: Date.now() });
292
316
 
293
- res.writeHead(200, { 'Content-Type': 'application/json' });
294
- res.end(JSON.stringify({ message: userMessage, queued: true, queuePosition: queueLength }));
317
+ sendJSON(req, res, 200, { message: userMessage, queued: true, queuePosition: queueLength });
295
318
  return;
296
319
  }
297
320
 
298
321
  const session = queries.createSession(conversationId);
299
322
  queries.createEvent('session.created', { messageId: userMessage.id, sessionId: session.id }, conversationId, session.id);
300
323
 
301
- res.writeHead(200, { 'Content-Type': 'application/json' });
302
- res.end(JSON.stringify({ message: userMessage, session, streamId: session.id }));
324
+ sendJSON(req, res, 200, { message: userMessage, session, streamId: session.id });
303
325
 
304
326
  broadcastSync({
305
327
  type: 'streaming_start',
@@ -318,19 +340,17 @@ const server = http.createServer(async (req, res) => {
318
340
  const messageMatch = pathOnly.match(/^\/api\/conversations\/([^/]+)\/messages\/([^/]+)$/);
319
341
  if (messageMatch && req.method === 'GET') {
320
342
  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 }));
343
+ if (!msg || msg.conversationId !== messageMatch[1]) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
344
+ sendJSON(req, res, 200, { message: msg });
324
345
  return;
325
346
  }
326
347
 
327
348
  const sessionMatch = pathOnly.match(/^\/api\/sessions\/([^/]+)$/);
328
349
  if (sessionMatch && req.method === 'GET') {
329
350
  const sess = queries.getSession(sessionMatch[1]);
330
- if (!sess) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); return; }
351
+ if (!sess) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
331
352
  const events = queries.getSessionEvents(sessionMatch[1]);
332
- res.writeHead(200, { 'Content-Type': 'application/json' });
333
- res.end(JSON.stringify({ session: sess, events }));
353
+ sendJSON(req, res, 200, { session: sess, events });
334
354
  return;
335
355
  }
336
356
 
@@ -338,20 +358,19 @@ const server = http.createServer(async (req, res) => {
338
358
  if (fullLoadMatch && req.method === 'GET') {
339
359
  const conversationId = fullLoadMatch[1];
340
360
  const conv = queries.getConversation(conversationId);
341
- if (!conv) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); return; }
361
+ if (!conv) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
342
362
  const latestSession = queries.getLatestSession(conversationId);
343
363
  const isActivelyStreaming = activeExecutions.has(conversationId) ||
344
364
  (latestSession && latestSession.status === 'active');
345
365
  const chunks = queries.getConversationChunks(conversationId);
346
366
  const msgResult = queries.getPaginatedMessages(conversationId, 100, 0);
347
- res.writeHead(200, { 'Content-Type': 'application/json' });
348
- res.end(JSON.stringify({
367
+ sendJSON(req, res, 200, {
349
368
  conversation: conv,
350
369
  isActivelyStreaming,
351
370
  latestSession,
352
371
  chunks,
353
372
  messages: msgResult.messages
354
- }));
373
+ });
355
374
  return;
356
375
  }
357
376
 
@@ -359,7 +378,7 @@ const server = http.createServer(async (req, res) => {
359
378
  if (conversationChunksMatch && req.method === 'GET') {
360
379
  const conversationId = conversationChunksMatch[1];
361
380
  const conv = queries.getConversation(conversationId);
362
- if (!conv) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Conversation not found' })); return; }
381
+ if (!conv) { sendJSON(req, res, 404, { error: 'Conversation not found' }); return; }
363
382
 
364
383
  const url = new URL(req.url, 'http://localhost');
365
384
  const since = parseInt(url.searchParams.get('since') || '0');
@@ -367,8 +386,7 @@ const server = http.createServer(async (req, res) => {
367
386
  const allChunks = queries.getConversationChunks(conversationId);
368
387
  debugLog(`[chunks] Conv ${conversationId}: ${allChunks.length} total chunks`);
369
388
  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 }));
389
+ sendJSON(req, res, 200, { ok: true, chunks });
372
390
  return;
373
391
  }
374
392
 
@@ -376,14 +394,13 @@ const server = http.createServer(async (req, res) => {
376
394
  if (sessionChunksMatch && req.method === 'GET') {
377
395
  const sessionId = sessionChunksMatch[1];
378
396
  const sess = queries.getSession(sessionId);
379
- if (!sess) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Session not found' })); return; }
397
+ if (!sess) { sendJSON(req, res, 404, { error: 'Session not found' }); return; }
380
398
 
381
399
  const url = new URL(req.url, 'http://localhost');
382
400
  const since = parseInt(url.searchParams.get('since') || '0');
383
401
 
384
402
  const chunks = queries.getChunksSince(sessionId, since);
385
- res.writeHead(200, { 'Content-Type': 'application/json' });
386
- res.end(JSON.stringify({ ok: true, chunks }));
403
+ sendJSON(req, res, 200, { ok: true, chunks });
387
404
  return;
388
405
  }
389
406
 
@@ -391,13 +408,11 @@ const server = http.createServer(async (req, res) => {
391
408
  const convId = pathOnly.match(/^\/api\/conversations\/([^/]+)\/sessions\/latest$/)[1];
392
409
  const latestSession = queries.getLatestSession(convId);
393
410
  if (!latestSession) {
394
- res.writeHead(200, { 'Content-Type': 'application/json' });
395
- res.end(JSON.stringify({ session: null }));
411
+ sendJSON(req, res, 200, { session: null });
396
412
  return;
397
413
  }
398
414
  const events = queries.getSessionEvents(latestSession.id);
399
- res.writeHead(200, { 'Content-Type': 'application/json' });
400
- res.end(JSON.stringify({ session: latestSession, events }));
415
+ sendJSON(req, res, 200, { session: latestSession, events });
401
416
  return;
402
417
  }
403
418
 
@@ -432,39 +447,33 @@ const server = http.createServer(async (req, res) => {
432
447
  executionData.events = executionData.events.filter(e => e.type === filterType);
433
448
  }
434
449
 
435
- res.writeHead(200, { 'Content-Type': 'application/json' });
436
- res.end(JSON.stringify(executionData));
450
+ sendJSON(req, res, 200, executionData);
437
451
  } catch (err) {
438
- res.writeHead(400, { 'Content-Type': 'application/json' });
439
- res.end(JSON.stringify({ error: err.message }));
452
+ sendJSON(req, res, 400, { error: err.message });
440
453
  }
441
454
  return;
442
455
  }
443
456
 
444
457
  if (routePath === '/api/agents' && req.method === 'GET') {
445
- res.writeHead(200, { 'Content-Type': 'application/json' });
446
- res.end(JSON.stringify({ agents: discoveredAgents }));
458
+ sendJSON(req, res, 200, { agents: discoveredAgents });
447
459
  return;
448
460
  }
449
461
 
450
462
 
451
463
  if (routePath === '/api/import/claude-code' && req.method === 'GET') {
452
464
  const result = queries.importClaudeCodeConversations();
453
- res.writeHead(200, { 'Content-Type': 'application/json' });
454
- res.end(JSON.stringify({ imported: result }));
465
+ sendJSON(req, res, 200, { imported: result });
455
466
  return;
456
467
  }
457
468
 
458
469
  if (routePath === '/api/discover/claude-code' && req.method === 'GET') {
459
470
  const discovered = queries.discoverClaudeCodeConversations();
460
- res.writeHead(200, { 'Content-Type': 'application/json' });
461
- res.end(JSON.stringify({ discovered }));
471
+ sendJSON(req, res, 200, { discovered });
462
472
  return;
463
473
  }
464
474
 
465
475
  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 }));
476
+ sendJSON(req, res, 200, { home: os.homedir(), cwd: STARTUP_CWD });
468
477
  return;
469
478
  }
470
479
 
@@ -474,20 +483,15 @@ const server = http.createServer(async (req, res) => {
474
483
  for await (const chunk of req) chunks.push(chunk);
475
484
  const audioBuffer = Buffer.concat(chunks);
476
485
  if (audioBuffer.length === 0) {
477
- res.writeHead(400, { 'Content-Type': 'application/json' });
478
- res.end(JSON.stringify({ error: 'No audio data' }));
486
+ sendJSON(req, res, 400, { error: 'No audio data' });
479
487
  return;
480
488
  }
481
489
  const { transcribe } = await getSpeech();
482
490
  const text = await transcribe(audioBuffer);
483
- res.writeHead(200, { 'Content-Type': 'application/json' });
484
- res.end(JSON.stringify({ text: (text || '').trim() }));
491
+ sendJSON(req, res, 200, { text: (text || '').trim() });
485
492
  } catch (err) {
486
493
  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' }));
494
+ if (!res.headersSent) sendJSON(req, res, 500, { error: err.message || 'STT failed' });
491
495
  }
492
496
  return;
493
497
  }
@@ -497,8 +501,7 @@ const server = http.createServer(async (req, res) => {
497
501
  const body = await parseBody(req);
498
502
  const text = body.text || '';
499
503
  if (!text) {
500
- res.writeHead(400, { 'Content-Type': 'application/json' });
501
- res.end(JSON.stringify({ error: 'No text provided' }));
504
+ sendJSON(req, res, 400, { error: 'No text provided' });
502
505
  return;
503
506
  }
504
507
  const { synthesize } = await getSpeech();
@@ -507,10 +510,7 @@ const server = http.createServer(async (req, res) => {
507
510
  res.end(wavBuffer);
508
511
  } catch (err) {
509
512
  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' }));
513
+ if (!res.headersSent) sendJSON(req, res, 500, { error: err.message || 'TTS failed' });
514
514
  }
515
515
  return;
516
516
  }
@@ -518,11 +518,9 @@ const server = http.createServer(async (req, res) => {
518
518
  if (routePath === '/api/speech-status' && req.method === 'GET') {
519
519
  try {
520
520
  const { getStatus } = await getSpeech();
521
- res.writeHead(200, { 'Content-Type': 'application/json' });
522
- res.end(JSON.stringify(getStatus()));
521
+ sendJSON(req, res, 200, getStatus());
523
522
  } catch (err) {
524
- res.writeHead(200, { 'Content-Type': 'application/json' });
525
- res.end(JSON.stringify({ sttReady: false, ttsReady: false, sttLoading: false, ttsLoading: false }));
523
+ sendJSON(req, res, 200, { sttReady: false, ttsReady: false, sttLoading: false, ttsLoading: false });
526
524
  }
527
525
  return;
528
526
  }
@@ -538,11 +536,9 @@ const server = http.createServer(async (req, res) => {
538
536
  .filter(e => e.isDirectory() && !e.name.startsWith('.'))
539
537
  .map(e => ({ name: e.name }))
540
538
  .sort((a, b) => a.name.localeCompare(b.name));
541
- res.writeHead(200, { 'Content-Type': 'application/json' });
542
- res.end(JSON.stringify({ folders }));
539
+ sendJSON(req, res, 200, { folders });
543
540
  } catch (err) {
544
- res.writeHead(400, { 'Content-Type': 'application/json' });
545
- res.end(JSON.stringify({ error: err.message }));
541
+ sendJSON(req, res, 400, { error: err.message });
546
542
  }
547
543
  return;
548
544
  }
@@ -565,8 +561,7 @@ const server = http.createServer(async (req, res) => {
565
561
  res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache' });
566
562
  res.end(fileContent);
567
563
  } catch (err) {
568
- res.writeHead(400, { 'Content-Type': 'application/json' });
569
- res.end(JSON.stringify({ error: err.message }));
564
+ sendJSON(req, res, 400, { error: err.message });
570
565
  }
571
566
  return;
572
567
  }
@@ -574,7 +569,7 @@ const server = http.createServer(async (req, res) => {
574
569
  // Handle conversation detail routes - serve index.html for client-side routing
575
570
  if (pathOnly.match(/^\/conversations\/[^\/]+$/)) {
576
571
  const indexPath = path.join(staticDir, 'index.html');
577
- serveFile(indexPath, res);
572
+ serveFile(indexPath, res, req);
578
573
  return;
579
574
  }
580
575
 
@@ -589,34 +584,59 @@ const server = http.createServer(async (req, res) => {
589
584
  filePath = path.join(filePath, 'index.html');
590
585
  fs.stat(filePath, (err2) => {
591
586
  if (err2) { res.writeHead(404); res.end('Not found'); return; }
592
- serveFile(filePath, res);
587
+ serveFile(filePath, res, req);
593
588
  });
594
589
  } else {
595
- serveFile(filePath, res);
590
+ serveFile(filePath, res, req);
596
591
  }
597
592
  });
598
593
  } catch (e) {
599
594
  console.error('Server error:', e.message);
600
- res.writeHead(500, { 'Content-Type': 'application/json' });
601
- res.end(JSON.stringify({ error: e.message }));
595
+ sendJSON(req, res, 500, { error: e.message });
602
596
  }
603
597
  });
604
598
 
605
599
  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
600
 
607
- function serveFile(filePath, res) {
601
+ function generateETag(stats) {
602
+ return `"${stats.mtimeMs.toString(36)}-${stats.size.toString(36)}"`;
603
+ }
604
+
605
+ function serveFile(filePath, res, req) {
608
606
  const ext = path.extname(filePath).toLowerCase();
609
607
  const contentType = MIME_TYPES[ext] || 'application/octet-stream';
610
608
 
611
609
  if (ext !== '.html') {
612
610
  fs.stat(filePath, (err, stats) => {
613
611
  if (err) { res.writeHead(500); res.end('Server error'); return; }
614
- res.writeHead(200, {
612
+ const etag = generateETag(stats);
613
+ if (req && req.headers['if-none-match'] === etag) {
614
+ res.writeHead(304);
615
+ res.end();
616
+ return;
617
+ }
618
+ const headers = {
615
619
  'Content-Type': contentType,
616
620
  'Content-Length': stats.size,
617
- 'Cache-Control': 'no-cache, must-revalidate'
618
- });
619
- fs.createReadStream(filePath).pipe(res);
621
+ 'ETag': etag,
622
+ 'Cache-Control': 'public, max-age=3600, must-revalidate'
623
+ };
624
+ if (acceptsEncoding(req, 'br') && stats.size > 860) {
625
+ const stream = fs.createReadStream(filePath);
626
+ headers['Content-Encoding'] = 'br';
627
+ delete headers['Content-Length'];
628
+ res.writeHead(200, headers);
629
+ stream.pipe(zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } })).pipe(res);
630
+ } else if (acceptsEncoding(req, 'gzip') && stats.size > 860) {
631
+ const stream = fs.createReadStream(filePath);
632
+ headers['Content-Encoding'] = 'gzip';
633
+ delete headers['Content-Length'];
634
+ res.writeHead(200, headers);
635
+ stream.pipe(zlib.createGzip({ level: 6 })).pipe(res);
636
+ } else {
637
+ res.writeHead(200, headers);
638
+ fs.createReadStream(filePath).pipe(res);
639
+ }
620
640
  });
621
641
  return;
622
642
  }
@@ -629,24 +649,52 @@ function serveFile(filePath, res) {
629
649
  if (watch) {
630
650
  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
651
  }
632
- res.writeHead(200, { 'Content-Type': contentType });
633
- res.end(content);
652
+ compressAndSend(req, res, 200, contentType, content);
634
653
  });
635
654
  }
636
655
 
637
- function persistChunkWithRetry(sessionId, conversationId, sequence, blockType, blockData, maxRetries = 3) {
638
- for (let attempt = 0; attempt < maxRetries; attempt++) {
656
+ function createChunkBatcher() {
657
+ const pending = [];
658
+ let timer = null;
659
+ const BATCH_SIZE = 10;
660
+ const BATCH_INTERVAL = 50;
661
+
662
+ function flush() {
663
+ if (pending.length === 0) return;
664
+ const batch = pending.splice(0);
639
665
  try {
640
- return queries.createChunk(sessionId, conversationId, sequence, blockType, blockData);
666
+ const tx = queries._db ? queries._db.transaction(() => {
667
+ for (const c of batch) queries.createChunk(c.sessionId, c.conversationId, c.sequence, c.type, c.data);
668
+ }) : null;
669
+ if (tx) { tx(); } else {
670
+ for (const c of batch) {
671
+ try { queries.createChunk(c.sessionId, c.conversationId, c.sequence, c.type, c.data); } catch (e) { debugLog(`[chunk] ${e.message}`); }
672
+ }
673
+ }
641
674
  } 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;
675
+ debugLog(`[chunk-batch] Batch write failed: ${err.message}`);
676
+ for (const c of batch) {
677
+ try { queries.createChunk(c.sessionId, c.conversationId, c.sequence, c.type, c.data); } catch (_) {}
646
678
  }
647
679
  }
648
680
  }
649
- return null;
681
+
682
+ function add(sessionId, conversationId, sequence, blockType, blockData) {
683
+ pending.push({ sessionId, conversationId, sequence, type: blockType, data: blockData });
684
+ if (pending.length >= BATCH_SIZE) {
685
+ if (timer) { clearTimeout(timer); timer = null; }
686
+ flush();
687
+ } else if (!timer) {
688
+ timer = setTimeout(() => { timer = null; flush(); }, BATCH_INTERVAL);
689
+ }
690
+ }
691
+
692
+ function drain() {
693
+ if (timer) { clearTimeout(timer); timer = null; }
694
+ flush();
695
+ }
696
+
697
+ return { add, drain };
650
698
  }
651
699
 
652
700
  async function processMessageWithStreaming(conversationId, messageId, sessionId, content, agentId) {
@@ -665,6 +713,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
665
713
  let allBlocks = [];
666
714
  let eventCount = 0;
667
715
  let currentSequence = queries.getMaxSequence(sessionId) ?? -1;
716
+ const batcher = createChunkBatcher();
668
717
 
669
718
  const onEvent = (parsed) => {
670
719
  eventCount++;
@@ -683,7 +732,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
683
732
  };
684
733
 
685
734
  currentSequence++;
686
- persistChunkWithRetry(sessionId, conversationId, currentSequence, 'system', systemBlock);
735
+ batcher.add(sessionId, conversationId, currentSequence, 'system', systemBlock);
687
736
 
688
737
  broadcastSync({
689
738
  type: 'streaming_progress',
@@ -698,7 +747,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
698
747
  allBlocks.push(block);
699
748
 
700
749
  currentSequence++;
701
- persistChunkWithRetry(sessionId, conversationId, currentSequence, block.type || 'assistant', block);
750
+ batcher.add(sessionId, conversationId, currentSequence, block.type || 'assistant', block);
702
751
 
703
752
  broadcastSync({
704
753
  type: 'streaming_progress',
@@ -720,7 +769,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
720
769
  };
721
770
 
722
771
  currentSequence++;
723
- persistChunkWithRetry(sessionId, conversationId, currentSequence, 'tool_result', toolResultBlock);
772
+ batcher.add(sessionId, conversationId, currentSequence, 'tool_result', toolResultBlock);
724
773
 
725
774
  broadcastSync({
726
775
  type: 'streaming_progress',
@@ -744,7 +793,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
744
793
  };
745
794
 
746
795
  currentSequence++;
747
- persistChunkWithRetry(sessionId, conversationId, currentSequence, 'result', resultBlock);
796
+ batcher.add(sessionId, conversationId, currentSequence, 'result', resultBlock);
748
797
 
749
798
  broadcastSync({
750
799
  type: 'streaming_progress',
@@ -777,6 +826,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
777
826
  };
778
827
 
779
828
  const { outputs, sessionId: claudeSessionId } = await runClaudeWithStreaming(content, cwd, agentId || 'claude-code', config);
829
+ batcher.drain();
780
830
  debugLog(`[stream] Claude returned ${outputs.length} outputs, sessionId=${claudeSessionId}`);
781
831
 
782
832
  if (claudeSessionId && !conv?.claudeSessionId) {
@@ -828,6 +878,7 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
828
878
  timestamp: Date.now()
829
879
  });
830
880
  } finally {
881
+ batcher.drain();
831
882
  activeExecutions.delete(conversationId);
832
883
  queries.setIsStreaming(conversationId, false);
833
884
  drainMessageQueue(conversationId);
@@ -915,9 +966,16 @@ async function processMessage(conversationId, messageId, content, agentId) {
915
966
  }
916
967
  }
917
968
 
918
- const wss = new WebSocketServer({ server });
969
+ const wss = new WebSocketServer({
970
+ server,
971
+ perMessageDeflate: {
972
+ zlibDeflateOptions: { level: 6 },
973
+ threshold: 256
974
+ }
975
+ });
919
976
  const hotReloadClients = [];
920
977
  const syncClients = new Set();
978
+ const subscriptionIndex = new Map();
921
979
 
922
980
  wss.on('connection', (ws, req) => {
923
981
  // req.url in WebSocket is just the path (e.g., '/gm/sync'), not a full URL
@@ -941,8 +999,17 @@ wss.on('connection', (ws, req) => {
941
999
  try {
942
1000
  const data = JSON.parse(msg);
943
1001
  if (data.type === 'subscribe') {
944
- if (data.sessionId) ws.subscriptions.add(data.sessionId);
945
- if (data.conversationId) ws.subscriptions.add(`conv-${data.conversationId}`);
1002
+ if (data.sessionId) {
1003
+ ws.subscriptions.add(data.sessionId);
1004
+ if (!subscriptionIndex.has(data.sessionId)) subscriptionIndex.set(data.sessionId, new Set());
1005
+ subscriptionIndex.get(data.sessionId).add(ws);
1006
+ }
1007
+ if (data.conversationId) {
1008
+ const key = `conv-${data.conversationId}`;
1009
+ ws.subscriptions.add(key);
1010
+ if (!subscriptionIndex.has(key)) subscriptionIndex.set(key, new Set());
1011
+ subscriptionIndex.get(key).add(ws);
1012
+ }
946
1013
  const subTarget = data.sessionId || data.conversationId;
947
1014
  debugLog(`[WebSocket] Client ${ws.clientId} subscribed to ${subTarget}`);
948
1015
  ws.send(JSON.stringify({
@@ -953,6 +1020,8 @@ wss.on('connection', (ws, req) => {
953
1020
  }));
954
1021
  } else if (data.type === 'unsubscribe') {
955
1022
  ws.subscriptions.delete(data.sessionId);
1023
+ const idx = subscriptionIndex.get(data.sessionId);
1024
+ if (idx) { idx.delete(ws); if (idx.size === 0) subscriptionIndex.delete(data.sessionId); }
956
1025
  debugLog(`[WebSocket] Client ${ws.clientId} unsubscribed from ${data.sessionId}`);
957
1026
  } else if (data.type === 'get_subscriptions') {
958
1027
  ws.send(JSON.stringify({
@@ -979,6 +1048,10 @@ wss.on('connection', (ws, req) => {
979
1048
  ws.on('pong', () => { ws.isAlive = true; });
980
1049
  ws.on('close', () => {
981
1050
  syncClients.delete(ws);
1051
+ for (const sub of ws.subscriptions) {
1052
+ const idx = subscriptionIndex.get(sub);
1053
+ if (idx) { idx.delete(ws); if (idx.size === 0) subscriptionIndex.delete(sub); }
1054
+ }
982
1055
  console.log(`[WebSocket] Client ${ws.clientId} disconnected`);
983
1056
  });
984
1057
  }
@@ -990,19 +1063,52 @@ const BROADCAST_TYPES = new Set([
990
1063
  'streaming_complete', 'streaming_error'
991
1064
  ]);
992
1065
 
1066
+ const wsBatchQueues = new Map();
1067
+ const WS_BATCH_INTERVAL = 16;
1068
+
1069
+ function flushWsBatch(ws) {
1070
+ const queue = wsBatchQueues.get(ws);
1071
+ if (!queue || queue.msgs.length === 0) return;
1072
+ if (ws.readyState !== 1) { wsBatchQueues.delete(ws); return; }
1073
+ if (queue.msgs.length === 1) {
1074
+ ws.send(queue.msgs[0]);
1075
+ } else {
1076
+ ws.send('[' + queue.msgs.join(',') + ']');
1077
+ }
1078
+ queue.msgs.length = 0;
1079
+ queue.timer = null;
1080
+ }
1081
+
1082
+ function sendToClient(ws, data) {
1083
+ if (ws.readyState !== 1) return;
1084
+ let queue = wsBatchQueues.get(ws);
1085
+ if (!queue) { queue = { msgs: [], timer: null }; wsBatchQueues.set(ws, queue); }
1086
+ queue.msgs.push(data);
1087
+ if (!queue.timer) {
1088
+ queue.timer = setTimeout(() => flushWsBatch(ws), WS_BATCH_INTERVAL);
1089
+ }
1090
+ }
1091
+
993
1092
  function broadcastSync(event) {
994
1093
  if (syncClients.size === 0) return;
995
1094
  const data = JSON.stringify(event);
996
1095
  const isBroadcast = BROADCAST_TYPES.has(event.type);
997
1096
 
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
- }
1097
+ if (isBroadcast) {
1098
+ for (const ws of syncClients) sendToClient(ws, data);
1099
+ return;
1100
+ }
1101
+
1102
+ const targets = new Set();
1103
+ if (event.sessionId) {
1104
+ const subs = subscriptionIndex.get(event.sessionId);
1105
+ if (subs) for (const ws of subs) targets.add(ws);
1106
+ }
1107
+ if (event.conversationId) {
1108
+ const subs = subscriptionIndex.get(`conv-${event.conversationId}`);
1109
+ if (subs) for (const ws of subs) targets.add(ws);
1005
1110
  }
1111
+ for (const ws of targets) sendToClient(ws, data);
1006
1112
  }
1007
1113
 
1008
1114
  // Heartbeat interval to detect stale connections
@@ -1166,18 +1272,38 @@ function onServerReady() {
1166
1272
 
1167
1273
  }
1168
1274
 
1275
+ const importMtimeCache = new Map();
1276
+
1277
+ function hasIndexFilesChanged() {
1278
+ const projectsDir = path.join(os.homedir(), '.claude', 'projects');
1279
+ if (!fs.existsSync(projectsDir)) return false;
1280
+ let changed = false;
1281
+ try {
1282
+ const dirs = fs.readdirSync(projectsDir);
1283
+ for (const d of dirs) {
1284
+ const indexPath = path.join(projectsDir, d, 'sessions-index.json');
1285
+ try {
1286
+ const stat = fs.statSync(indexPath);
1287
+ const cached = importMtimeCache.get(indexPath);
1288
+ if (!cached || cached < stat.mtimeMs) {
1289
+ importMtimeCache.set(indexPath, stat.mtimeMs);
1290
+ changed = true;
1291
+ }
1292
+ } catch (_) {}
1293
+ }
1294
+ } catch (_) {}
1295
+ return changed;
1296
+ }
1297
+
1169
1298
  function performAutoImport() {
1170
1299
  try {
1300
+ if (!hasIndexFilesChanged()) return;
1171
1301
  const imported = queries.importClaudeCodeConversations();
1172
1302
  if (imported.length > 0) {
1173
1303
  const importedCount = imported.filter(i => i.status === 'imported').length;
1174
- const skippedCount = imported.filter(i => i.status === 'skipped').length;
1175
1304
  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
1305
+ console.log(`[AUTO-IMPORT] Imported ${importedCount} new Claude Code conversations`);
1178
1306
  broadcastSync({ type: 'conversations_updated', count: importedCount });
1179
- } else if (skippedCount > 0) {
1180
- // All conversations already imported, don't spam logs
1181
1307
  }
1182
1308
  }
1183
1309
  } catch (err) {