agentgui 1.0.93 → 1.0.95

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
@@ -4,13 +4,20 @@ import path from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { WebSocketServer } from 'ws';
6
6
  import { execSync } from 'child_process';
7
+ import { createRequire } from 'module';
7
8
  import { queries } from './database.js';
8
9
  import { runClaudeWithStreaming } from './lib/claude-runner.js';
9
10
 
10
- // System prompt for Claude to format responses as HTML
11
+ const require = createRequire(import.meta.url);
12
+ const express = require('express');
13
+ const Busboy = require('busboy');
14
+ const fsbrowse = require('fsbrowse');
15
+
11
16
  const SYSTEM_PROMPT = `Always write your responses in ripple-ui enhanced HTML. Avoid overriding light/dark mode CSS variables. Use all the benefits of HTML to express technical details with proper semantic markup, tables, code blocks, headings, and lists. Write clean, well-structured HTML that respects the existing design system.`;
12
17
 
13
- // Debug logging to file
18
+ const activeExecutions = new Map();
19
+ const messageQueues = new Map();
20
+
14
21
  const debugLog = (msg) => {
15
22
  const timestamp = new Date().toISOString();
16
23
  console.error(`[${timestamp}] ${msg}`);
@@ -24,6 +31,70 @@ const watch = process.argv.includes('--no-watch') ? false : (process.argv.includ
24
31
  const staticDir = path.join(__dirname, 'static');
25
32
  if (!fs.existsSync(staticDir)) fs.mkdirSync(staticDir, { recursive: true });
26
33
 
34
+ // Express sub-app for fsbrowse file browser and file upload
35
+ const expressApp = express();
36
+
37
+ // File upload endpoint - copies dropped files to conversation workingDirectory
38
+ expressApp.post(BASE_URL + '/api/upload/:conversationId', (req, res) => {
39
+ try {
40
+ const conv = queries.getConversation(req.params.conversationId);
41
+ if (!conv) return res.status(404).json({ error: 'Conversation not found' });
42
+ if (!conv.workingDirectory) return res.status(400).json({ error: 'No working directory set for this conversation' });
43
+
44
+ const uploadDir = conv.workingDirectory;
45
+ if (!fs.existsSync(uploadDir)) {
46
+ fs.mkdirSync(uploadDir, { recursive: true });
47
+ }
48
+
49
+ const bb = Busboy({ headers: req.headers });
50
+ const fileNames = [];
51
+ const writePromises = [];
52
+
53
+ bb.on('file', (fieldname, file, info) => {
54
+ const safeName = path.basename(info.filename);
55
+ const filePath = path.join(uploadDir, safeName);
56
+ fileNames.push(safeName);
57
+ const p = new Promise((resolve) => {
58
+ const writeStream = fs.createWriteStream(filePath);
59
+ file.pipe(writeStream);
60
+ writeStream.on('finish', resolve);
61
+ writeStream.on('error', () => { file.resume(); resolve(); });
62
+ });
63
+ writePromises.push(p);
64
+ });
65
+
66
+ bb.on('finish', () => {
67
+ Promise.all(writePromises).then(() => {
68
+ res.json({ ok: true, files: fileNames, count: fileNames.length });
69
+ }).catch(() => {
70
+ res.json({ ok: true, files: fileNames, count: fileNames.length });
71
+ });
72
+ });
73
+
74
+ bb.on('error', (err) => {
75
+ res.status(500).json({ error: 'Upload failed: ' + err.message });
76
+ });
77
+
78
+ req.pipe(bb);
79
+ } catch (err) {
80
+ res.status(500).json({ error: err.message });
81
+ }
82
+ });
83
+
84
+ // fsbrowse file browser - mounted per conversation workingDirectory
85
+ // Route: /gm/files/:conversationId/*
86
+ expressApp.use(BASE_URL + '/files/:conversationId', (req, res, next) => {
87
+ const conv = queries.getConversation(req.params.conversationId);
88
+ if (!conv || !conv.workingDirectory) {
89
+ return res.status(404).json({ error: 'Conversation not found or no working directory' });
90
+ }
91
+ // Create a fresh fsbrowse router for this conversation's directory
92
+ const router = fsbrowse({ baseDir: conv.workingDirectory });
93
+ // Strip the conversationId param from the path before passing to fsbrowse
94
+ req.baseUrl = BASE_URL + '/files/' + req.params.conversationId;
95
+ router(req, res, next);
96
+ });
97
+
27
98
  function discoverAgents() {
28
99
  const agents = [];
29
100
  const binaries = [
@@ -59,6 +130,12 @@ const server = http.createServer(async (req, res) => {
59
130
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
60
131
  if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
61
132
 
133
+ // Route file upload and fsbrowse requests through Express sub-app
134
+ const pathOnly = req.url.split('?')[0];
135
+ if (pathOnly.startsWith(BASE_URL + '/api/upload/') || pathOnly.startsWith(BASE_URL + '/files/')) {
136
+ return expressApp(req, res);
137
+ }
138
+
62
139
  if (req.url === '/') { res.writeHead(302, { Location: BASE_URL + '/' }); res.end(); return; }
63
140
 
64
141
  if (!req.url.startsWith(BASE_URL + '/') && req.url !== BASE_URL) {
@@ -158,19 +235,30 @@ const server = http.createServer(async (req, res) => {
158
235
  const agentId = body.agentId || 'claude-code';
159
236
  const skipPermissions = body.skipPermissions || false;
160
237
 
161
- debugLog(`[stream] Starting stream: conversationId=${conversationId}, agentId=${agentId}, skipPermissions=${skipPermissions}`);
162
-
163
- // Create user message and session immediately
164
238
  const userMessage = queries.createMessage(conversationId, 'user', prompt);
165
- const session = queries.createSession(conversationId);
166
239
  queries.createEvent('message.created', { role: 'user', messageId: userMessage.id }, conversationId);
240
+
241
+ broadcastSync({ type: 'message_created', conversationId, message: userMessage, timestamp: Date.now() });
242
+
243
+ if (activeExecutions.has(conversationId)) {
244
+ debugLog(`[stream] Conversation ${conversationId} is busy, queuing message`);
245
+ if (!messageQueues.has(conversationId)) messageQueues.set(conversationId, []);
246
+ messageQueues.get(conversationId).push({ content: prompt, agentId, skipPermissions, messageId: userMessage.id });
247
+
248
+ const queueLength = messageQueues.get(conversationId).length;
249
+ broadcastSync({ type: 'queue_status', conversationId, queueLength, messageId: userMessage.id, timestamp: Date.now() });
250
+
251
+ res.writeHead(200, { 'Content-Type': 'application/json' });
252
+ res.end(JSON.stringify({ message: userMessage, queued: true, queuePosition: queueLength }));
253
+ return;
254
+ }
255
+
256
+ const session = queries.createSession(conversationId);
167
257
  queries.createEvent('session.created', { messageId: userMessage.id, sessionId: session.id }, conversationId, session.id);
168
258
 
169
- // Send immediate response with session info
170
259
  res.writeHead(200, { 'Content-Type': 'application/json' });
171
260
  res.end(JSON.stringify({ message: userMessage, session, streamId: session.id }));
172
261
 
173
- // Emit streaming start event
174
262
  broadcastSync({
175
263
  type: 'streaming_start',
176
264
  sessionId: session.id,
@@ -180,7 +268,6 @@ const server = http.createServer(async (req, res) => {
180
268
  timestamp: Date.now()
181
269
  });
182
270
 
183
- // Fire-and-forget streaming with error handling
184
271
  processMessageWithStreaming(conversationId, userMessage.id, session.id, prompt, agentId, skipPermissions)
185
272
  .catch(err => debugLog(`[stream] Uncaught error: ${err.message}`));
186
273
  return;
@@ -374,58 +461,63 @@ function serveFile(filePath, res) {
374
461
 
375
462
  async function processMessageWithStreaming(conversationId, messageId, sessionId, content, agentId, skipPermissions = false) {
376
463
  const startTime = Date.now();
464
+ activeExecutions.set(conversationId, true);
465
+ queries.setIsStreaming(conversationId, true);
466
+
377
467
  try {
378
- debugLog(`[stream] Starting: conversationId=${conversationId}, sessionId=${sessionId}, agentId=${agentId}, skipPermissions=${skipPermissions}`);
468
+ debugLog(`[stream] Starting: conversationId=${conversationId}, sessionId=${sessionId}`);
379
469
 
380
470
  const conv = queries.getConversation(conversationId);
381
471
  const cwd = conv?.workingDirectory || '/config';
382
- const actualAgentId = agentId || 'claude-code';
383
-
384
- debugLog(`[stream] Calling runClaudeWithStreaming with config: skipPermissions=${skipPermissions}`);
385
- const config = {
386
- skipPermissions,
387
- verbose: true,
388
- outputFormat: 'stream-json',
389
- timeout: 1800000, // 30 minutes
390
- print: true
391
- };
392
-
393
- // Prepend system prompt to user content
394
- const promptWithSystem = `${SYSTEM_PROMPT}\n\n${content}`;
472
+ const resumeSessionId = conv?.claudeSessionId || null;
395
473
 
396
- const outputs = await runClaudeWithStreaming(promptWithSystem, cwd, actualAgentId, config);
397
- debugLog(`[stream] Claude returned ${outputs.length} streaming outputs`);
398
-
399
- // Process streaming outputs similar to processMessage
400
- // But emit WebSocket events for each block
401
474
  let allBlocks = [];
402
- let lastAssistantMessage = null;
403
475
  let eventCount = 0;
404
476
 
405
- for (const output of outputs) {
406
- if (output.type === 'assistant' && output.message?.content) {
407
- debugLog(`[stream] Found assistant message with ${output.message.content.length} content blocks`);
408
- lastAssistantMessage = output.message;
409
- allBlocks.push(...(output.message.content || []));
410
-
411
- // Emit progress event for each block
477
+ const onEvent = (parsed) => {
478
+ if (parsed.type === 'assistant' && parsed.message?.content) {
479
+ for (const block of parsed.message.content) {
480
+ allBlocks.push(block);
481
+ eventCount++;
482
+ broadcastSync({
483
+ type: 'streaming_progress',
484
+ sessionId,
485
+ conversationId,
486
+ block,
487
+ blockIndex: allBlocks.length - 1,
488
+ timestamp: Date.now()
489
+ });
490
+ }
491
+ } else if (parsed.type === 'result' && parsed.result && allBlocks.length === 0) {
412
492
  broadcastSync({
413
493
  type: 'streaming_progress',
414
494
  sessionId,
415
495
  conversationId,
416
- blockCount: allBlocks.length,
496
+ block: { type: 'text', text: parsed.result },
497
+ blockIndex: 0,
498
+ isResult: true,
417
499
  timestamp: Date.now()
418
500
  });
419
- eventCount++;
420
- } else if (output.type === 'tool_result' && output.result) {
421
- debugLog(`[stream] Found tool result`);
422
- allBlocks.push({
423
- type: 'tool_result',
424
- result: output.result,
425
- tool_use_id: output.tool_use_id
426
- });
427
- eventCount++;
428
501
  }
502
+ };
503
+
504
+ const config = {
505
+ skipPermissions,
506
+ verbose: true,
507
+ outputFormat: 'stream-json',
508
+ timeout: 1800000,
509
+ print: true,
510
+ resumeSessionId,
511
+ systemPrompt: SYSTEM_PROMPT,
512
+ onEvent
513
+ };
514
+
515
+ const { outputs, sessionId: claudeSessionId } = await runClaudeWithStreaming(content, cwd, agentId || 'claude-code', config);
516
+ debugLog(`[stream] Claude returned ${outputs.length} outputs, sessionId=${claudeSessionId}`);
517
+
518
+ if (claudeSessionId && !conv?.claudeSessionId) {
519
+ queries.setClaudeSessionId(conversationId, claudeSessionId);
520
+ debugLog(`[stream] Stored claudeSessionId=${claudeSessionId}`);
429
521
  }
430
522
 
431
523
  let messageContent = null;
@@ -435,28 +527,20 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
435
527
  blocks: allBlocks,
436
528
  timestamp: Date.now()
437
529
  });
438
- debugLog(`[stream] Storing full execution with ${allBlocks.length} blocks`);
439
530
  } else {
440
531
  let textParts = [];
441
532
  for (const output of outputs) {
442
- if (typeof output === 'string') {
443
- textParts.push(output);
444
- } else if (output.text) {
445
- textParts.push(output.text);
446
- } else if (output.content?.text) {
447
- textParts.push(output.content.text);
448
- } else if (output.result) {
533
+ if (output.type === 'result' && output.result) {
449
534
  textParts.push(String(output.result));
535
+ } else if (typeof output === 'string') {
536
+ textParts.push(output);
450
537
  }
451
538
  }
452
539
  messageContent = textParts.join('\n').trim();
453
- debugLog(`[stream] Storing text response: "${messageContent.substring(0, 100)}..."`);
454
540
  }
455
541
 
456
542
  if (messageContent) {
457
- debugLog(`[stream] Creating assistant message`);
458
543
  const assistantMessage = queries.createMessage(conversationId, 'assistant', messageContent);
459
- debugLog(`[stream] Created message with id: ${assistantMessage.id}`);
460
544
  broadcastSync({
461
545
  type: 'streaming_complete',
462
546
  sessionId,
@@ -465,30 +549,25 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
465
549
  eventCount,
466
550
  timestamp: Date.now()
467
551
  });
468
- } else {
469
- debugLog(`[stream] No response content extracted!`);
552
+ broadcastSync({
553
+ type: 'message_created',
554
+ conversationId,
555
+ message: assistantMessage,
556
+ timestamp: Date.now()
557
+ });
470
558
  }
471
559
 
472
- debugLog(`[stream] Completed: ${outputs.length} outputs received, ${eventCount} events emitted`);
560
+ debugLog(`[stream] Completed: ${outputs.length} outputs, ${eventCount} events`);
473
561
  } catch (error) {
474
562
  const elapsed = Date.now() - startTime;
475
563
  debugLog(`[stream] Error after ${elapsed}ms: ${error.message}`);
476
564
 
477
- // Mark session as incomplete for recovery
478
- try {
479
- const sessionStatus = error.message.includes('timeout') ? 'timeout' : 'error';
480
- queries.markSessionIncomplete(sessionId, error.message);
481
- debugLog(`[stream] Session ${sessionId} marked as incomplete (${sessionStatus})`);
482
- } catch (err) {
483
- debugLog(`[stream] Failed to mark session: ${err.message}`);
484
- }
485
-
486
565
  broadcastSync({
487
566
  type: 'streaming_error',
488
567
  sessionId,
489
568
  conversationId,
490
569
  error: error.message,
491
- recoverable: elapsed < 60000, // Retryable if failed within 1 minute
570
+ recoverable: elapsed < 60000,
492
571
  timestamp: Date.now()
493
572
  });
494
573
 
@@ -499,102 +578,91 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
499
578
  message: errorMessage,
500
579
  timestamp: Date.now()
501
580
  });
581
+ } finally {
582
+ activeExecutions.delete(conversationId);
583
+ queries.setIsStreaming(conversationId, false);
584
+ drainMessageQueue(conversationId);
502
585
  }
503
586
  }
504
587
 
588
+ function drainMessageQueue(conversationId) {
589
+ const queue = messageQueues.get(conversationId);
590
+ if (!queue || queue.length === 0) return;
591
+
592
+ const next = queue.shift();
593
+ if (queue.length === 0) messageQueues.delete(conversationId);
594
+
595
+ debugLog(`[queue] Draining next message for ${conversationId}`);
596
+
597
+ const session = queries.createSession(conversationId);
598
+ queries.createEvent('session.created', { messageId: next.messageId, sessionId: session.id }, conversationId, session.id);
599
+
600
+ broadcastSync({
601
+ type: 'streaming_start',
602
+ sessionId: session.id,
603
+ conversationId,
604
+ messageId: next.messageId,
605
+ agentId: next.agentId,
606
+ timestamp: Date.now()
607
+ });
608
+
609
+ broadcastSync({
610
+ type: 'queue_status',
611
+ conversationId,
612
+ queueLength: queue?.length || 0,
613
+ timestamp: Date.now()
614
+ });
615
+
616
+ processMessageWithStreaming(conversationId, next.messageId, session.id, next.content, next.agentId, next.skipPermissions)
617
+ .catch(err => debugLog(`[queue] Error processing queued message: ${err.message}`));
618
+ }
619
+
505
620
  async function processMessage(conversationId, messageId, content, agentId) {
506
621
  try {
507
622
  debugLog(`[processMessage] Starting: conversationId=${conversationId}, agentId=${agentId}`);
508
623
 
509
624
  const conv = queries.getConversation(conversationId);
510
625
  const cwd = conv?.workingDirectory || '/config';
511
- const actualAgentId = agentId || 'claude-code';
626
+ const resumeSessionId = conv?.claudeSessionId || null;
512
627
 
513
- // Handle both string content and object content (for structured messages)
514
- let contentStr = content;
515
- if (typeof content === 'object') {
516
- contentStr = JSON.stringify(content);
517
- }
628
+ let contentStr = typeof content === 'object' ? JSON.stringify(content) : content;
518
629
 
519
- debugLog(`[processMessage] Calling runClaudeWithStreaming with prompt: "${contentStr.substring(0, 50)}..."`);
520
- // Prepend system prompt to user content
521
- const promptWithSystem = `${SYSTEM_PROMPT}\n\n${contentStr}`;
522
- const outputs = await runClaudeWithStreaming(promptWithSystem, cwd, actualAgentId);
523
- debugLog(`[processMessage] Claude returned ${outputs.length} outputs`);
630
+ const { outputs, sessionId: claudeSessionId } = await runClaudeWithStreaming(contentStr, cwd, agentId || 'claude-code', {
631
+ resumeSessionId,
632
+ systemPrompt: SYSTEM_PROMPT
633
+ });
524
634
 
525
- // Collect all message blocks to preserve full execution details
526
- let allBlocks = [];
527
- let lastAssistantMessage = null;
635
+ if (claudeSessionId && !conv?.claudeSessionId) {
636
+ queries.setClaudeSessionId(conversationId, claudeSessionId);
637
+ }
528
638
 
639
+ let allBlocks = [];
529
640
  for (const output of outputs) {
530
641
  if (output.type === 'assistant' && output.message?.content) {
531
- debugLog(`[processMessage] Found assistant message with ${output.message.content.length} content blocks`);
532
- lastAssistantMessage = output.message;
533
642
  allBlocks.push(...(output.message.content || []));
534
- } else if (output.type === 'tool_result' && output.result) {
535
- debugLog(`[processMessage] Found tool result: ${typeof output.result}`);
536
- allBlocks.push({
537
- type: 'tool_result',
538
- result: output.result,
539
- tool_use_id: output.tool_use_id
540
- });
541
643
  }
542
644
  }
543
645
 
544
- // Store full message structure if we have execution data, otherwise fallback to text
545
646
  let messageContent = null;
546
-
547
647
  if (allBlocks.length > 0) {
548
- // Store full message structure as JSON for proper rendering
549
- messageContent = JSON.stringify({
550
- type: 'claude_execution',
551
- blocks: allBlocks,
552
- timestamp: Date.now()
553
- });
554
- debugLog(`[processMessage] Storing full execution with ${allBlocks.length} blocks`);
648
+ messageContent = JSON.stringify({ type: 'claude_execution', blocks: allBlocks, timestamp: Date.now() });
555
649
  } else {
556
- // Fallback: extract text for simple responses
557
650
  let textParts = [];
558
651
  for (const output of outputs) {
559
- if (typeof output === 'string') {
560
- textParts.push(output);
561
- } else if (output.text) {
562
- textParts.push(output.text);
563
- } else if (output.content?.text) {
564
- textParts.push(output.content.text);
565
- } else if (output.result) {
566
- textParts.push(String(output.result));
567
- }
652
+ if (output.type === 'result' && output.result) textParts.push(String(output.result));
653
+ else if (typeof output === 'string') textParts.push(output);
568
654
  }
569
655
  messageContent = textParts.join('\n').trim();
570
- debugLog(`[processMessage] Storing text response: "${messageContent.substring(0, 100)}..."`);
571
656
  }
572
657
 
573
658
  if (messageContent) {
574
- debugLog(`[processMessage] Creating assistant message`);
575
659
  const assistantMessage = queries.createMessage(conversationId, 'assistant', messageContent);
576
- debugLog(`[processMessage] Created message with id: ${assistantMessage.id}`);
577
- broadcastSync({
578
- type: 'message_created',
579
- conversationId,
580
- message: assistantMessage,
581
- timestamp: Date.now()
582
- });
583
- } else {
584
- debugLog(`[processMessage] No response content extracted!`);
660
+ broadcastSync({ type: 'message_created', conversationId, message: assistantMessage, timestamp: Date.now() });
585
661
  }
586
-
587
- debugLog(`[processMessage] ✅ Completed: ${outputs.length} outputs received`);
588
662
  } catch (error) {
589
663
  debugLog(`[processMessage] Error: ${error.message}`);
590
- debugLog(`[processMessage] Stack: ${error.stack}`);
591
664
  const errorMessage = queries.createMessage(conversationId, 'assistant', `Error: ${error.message}`);
592
- broadcastSync({
593
- type: 'message_created',
594
- conversationId,
595
- message: errorMessage,
596
- timestamp: Date.now()
597
- });
665
+ broadcastSync({ type: 'message_created', conversationId, message: errorMessage, timestamp: Date.now() });
598
666
  }
599
667
  }
600
668
 
@@ -624,11 +692,14 @@ wss.on('connection', (ws, req) => {
624
692
  try {
625
693
  const data = JSON.parse(msg);
626
694
  if (data.type === 'subscribe') {
627
- ws.subscriptions.add(data.sessionId);
628
- debugLog(`[WebSocket] Client ${ws.clientId} subscribed to ${data.sessionId}`);
695
+ if (data.sessionId) ws.subscriptions.add(data.sessionId);
696
+ if (data.conversationId) ws.subscriptions.add(`conv-${data.conversationId}`);
697
+ const subTarget = data.sessionId || data.conversationId;
698
+ debugLog(`[WebSocket] Client ${ws.clientId} subscribed to ${subTarget}`);
629
699
  ws.send(JSON.stringify({
630
700
  type: 'subscription_confirmed',
631
701
  sessionId: data.sessionId,
702
+ conversationId: data.conversationId,
632
703
  timestamp: Date.now()
633
704
  }));
634
705
  } else if (data.type === 'unsubscribe') {
@@ -666,25 +737,17 @@ wss.on('connection', (ws, req) => {
666
737
 
667
738
  function broadcastSync(event) {
668
739
  const data = JSON.stringify(event);
669
- const isStreamingEvent = event.type && event.type.startsWith('streaming_');
670
- const targetSessionId = event.sessionId || (event.conversationId && `conv-${event.conversationId}`);
671
740
 
672
741
  for (const ws of syncClients) {
673
742
  if (ws.readyState !== 1) continue;
674
743
 
675
744
  let shouldSend = false;
676
745
 
677
- if (isStreamingEvent && targetSessionId) {
678
- // Streaming events require sessionId subscription
679
- shouldSend = ws.subscriptions && ws.subscriptions.has(targetSessionId);
680
- } else if (event.sessionId) {
681
- // Regular session events require sessionId subscription
682
- shouldSend = ws.subscriptions && ws.subscriptions.has(event.sessionId);
683
- } else if (event.type === 'message_created' || event.type === 'conversation_created') {
684
- // Global events sent to all clients
746
+ if (event.sessionId && ws.subscriptions?.has(event.sessionId)) {
685
747
  shouldSend = true;
686
- } else {
687
- // Default: send to all connected clients
748
+ } else if (event.conversationId && ws.subscriptions?.has(`conv-${event.conversationId}`)) {
749
+ shouldSend = true;
750
+ } else if (event.type === 'message_created' || event.type === 'conversation_created' || event.type === 'conversations_updated' || event.type === 'conversation_deleted' || event.type === 'queue_status') {
688
751
  shouldSend = true;
689
752
  }
690
753