agentgui 1.0.94 → 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/database.js CHANGED
@@ -198,7 +198,9 @@ try {
198
198
  gitBranch: 'TEXT',
199
199
  sourcePath: 'TEXT',
200
200
  lastSyncedAt: 'INTEGER',
201
- workingDirectory: 'TEXT'
201
+ workingDirectory: 'TEXT',
202
+ claudeSessionId: 'TEXT',
203
+ isStreaming: 'INTEGER DEFAULT 0'
202
204
  };
203
205
 
204
206
  let addedColumns = false;
@@ -286,6 +288,46 @@ export const queries = {
286
288
  };
287
289
  },
288
290
 
291
+ setClaudeSessionId(conversationId, claudeSessionId) {
292
+ const stmt = db.prepare('UPDATE conversations SET claudeSessionId = ?, updated_at = ? WHERE id = ?');
293
+ stmt.run(claudeSessionId, Date.now(), conversationId);
294
+ },
295
+
296
+ getClaudeSessionId(conversationId) {
297
+ const stmt = db.prepare('SELECT claudeSessionId FROM conversations WHERE id = ?');
298
+ const row = stmt.get(conversationId);
299
+ return row?.claudeSessionId || null;
300
+ },
301
+
302
+ setIsStreaming(conversationId, isStreaming) {
303
+ const stmt = db.prepare('UPDATE conversations SET isStreaming = ?, updated_at = ? WHERE id = ?');
304
+ stmt.run(isStreaming ? 1 : 0, Date.now(), conversationId);
305
+ },
306
+
307
+ getIsStreaming(conversationId) {
308
+ const stmt = db.prepare('SELECT isStreaming FROM conversations WHERE id = ?');
309
+ const row = stmt.get(conversationId);
310
+ return row?.isStreaming === 1;
311
+ },
312
+
313
+ markSessionIncomplete(sessionId, errorMsg) {
314
+ const stmt = db.prepare('UPDATE sessions SET status = ?, error = ?, completed_at = ? WHERE id = ?');
315
+ stmt.run('incomplete', errorMsg || 'unknown', Date.now(), sessionId);
316
+ },
317
+
318
+ getSessionsProcessingLongerThan(minutes) {
319
+ const cutoff = Date.now() - (minutes * 60 * 1000);
320
+ const stmt = db.prepare('SELECT * FROM sessions WHERE status = ? AND started_at < ?');
321
+ return stmt.all('pending', cutoff);
322
+ },
323
+
324
+ cleanupOrphanedSessions(days) {
325
+ const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
326
+ const stmt = db.prepare('DELETE FROM sessions WHERE status = ? AND started_at < ?');
327
+ const result = stmt.run('pending', cutoff);
328
+ return result.changes || 0;
329
+ },
330
+
289
331
  createMessage(conversationId, role, content, idempotencyKey = null) {
290
332
  if (idempotencyKey) {
291
333
  const cached = this.getIdempotencyKey(idempotencyKey);
@@ -1,23 +1,5 @@
1
1
  import { spawn } from 'child_process';
2
2
 
3
- /**
4
- * Configuration for Claude runner
5
- * @typedef {Object} ClaudeRunnerConfig
6
- * @property {boolean} [skipPermissions=false] - Use --dangerously-skip-permissions flag
7
- * @property {boolean} [verbose=true] - Use --verbose flag
8
- * @property {string} [outputFormat='stream-json'] - Output format (stream-json, json, text)
9
- * @property {number} [timeout=300000] - Timeout in milliseconds (default 5 minutes)
10
- * @property {boolean} [print=true] - Use --print flag
11
- */
12
-
13
- /**
14
- * Run Claude with streaming JSON output
15
- * @param {string} prompt - The prompt to send to Claude
16
- * @param {string} cwd - Working directory
17
- * @param {string} agentId - Agent identifier (for logging)
18
- * @param {ClaudeRunnerConfig} [config={}] - Configuration options
19
- * @returns {Promise<Array>} Array of parsed JSON objects from Claude output
20
- */
21
3
  export async function runClaudeWithStreaming(prompt, cwd, agentId = 'claude-code', config = {}) {
22
4
  return new Promise((resolve, reject) => {
23
5
  const {
@@ -25,20 +7,25 @@ export async function runClaudeWithStreaming(prompt, cwd, agentId = 'claude-code
25
7
  verbose = true,
26
8
  outputFormat = 'stream-json',
27
9
  timeout = 300000,
28
- print = true
10
+ print = true,
11
+ resumeSessionId = null,
12
+ systemPrompt = null,
13
+ onEvent = null
29
14
  } = config;
30
15
 
31
- // Build flags array
32
16
  const flags = [];
33
17
  if (print) flags.push('--print');
34
18
  if (verbose) flags.push('--verbose');
35
19
  flags.push(`--output-format=${outputFormat}`);
36
20
  if (skipPermissions) flags.push('--dangerously-skip-permissions');
21
+ if (resumeSessionId) flags.push('--resume', resumeSessionId);
22
+ if (systemPrompt) flags.push('--append-system-prompt', systemPrompt);
37
23
 
38
24
  const proc = spawn('claude', flags, { cwd });
39
25
  let jsonBuffer = '';
40
26
  const outputs = [];
41
27
  let timedOut = false;
28
+ let sessionId = null;
42
29
 
43
30
  const timeoutHandle = setTimeout(() => {
44
31
  timedOut = true;
@@ -61,6 +48,16 @@ export async function runClaudeWithStreaming(prompt, cwd, agentId = 'claude-code
61
48
  try {
62
49
  const parsed = JSON.parse(line);
63
50
  outputs.push(parsed);
51
+
52
+ if (parsed.session_id) {
53
+ sessionId = parsed.session_id;
54
+ }
55
+
56
+ if (onEvent) {
57
+ try { onEvent(parsed); } catch (e) {
58
+ console.error(`[claude-runner] onEvent error: ${e.message}`);
59
+ }
60
+ }
64
61
  } catch (e) {
65
62
  console.error(`[claude-runner] JSON parse error on line: ${line.substring(0, 100)}`);
66
63
  }
@@ -79,12 +76,17 @@ export async function runClaudeWithStreaming(prompt, cwd, agentId = 'claude-code
79
76
  if (code === 0) {
80
77
  if (jsonBuffer.trim()) {
81
78
  try {
82
- outputs.push(JSON.parse(jsonBuffer));
79
+ const parsed = JSON.parse(jsonBuffer);
80
+ outputs.push(parsed);
81
+ if (parsed.session_id) sessionId = parsed.session_id;
82
+ if (onEvent) {
83
+ try { onEvent(parsed); } catch (e) {}
84
+ }
83
85
  } catch (e) {
84
86
  console.error(`[claude-runner] Final JSON parse error: ${jsonBuffer.substring(0, 100)}`);
85
87
  }
86
88
  }
87
- resolve(outputs);
89
+ resolve({ outputs, sessionId });
88
90
  } else {
89
91
  reject(new Error(`Claude CLI exited with code ${code} for agent ${agentId}`));
90
92
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.94",
3
+ "version": "1.0.95",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -13,10 +13,11 @@ const express = require('express');
13
13
  const Busboy = require('busboy');
14
14
  const fsbrowse = require('fsbrowse');
15
15
 
16
- // System prompt for Claude to format responses as HTML
17
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.`;
18
17
 
19
- // Debug logging to file
18
+ const activeExecutions = new Map();
19
+ const messageQueues = new Map();
20
+
20
21
  const debugLog = (msg) => {
21
22
  const timestamp = new Date().toISOString();
22
23
  console.error(`[${timestamp}] ${msg}`);
@@ -234,19 +235,30 @@ const server = http.createServer(async (req, res) => {
234
235
  const agentId = body.agentId || 'claude-code';
235
236
  const skipPermissions = body.skipPermissions || false;
236
237
 
237
- debugLog(`[stream] Starting stream: conversationId=${conversationId}, agentId=${agentId}, skipPermissions=${skipPermissions}`);
238
-
239
- // Create user message and session immediately
240
238
  const userMessage = queries.createMessage(conversationId, 'user', prompt);
241
- const session = queries.createSession(conversationId);
242
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);
243
257
  queries.createEvent('session.created', { messageId: userMessage.id, sessionId: session.id }, conversationId, session.id);
244
258
 
245
- // Send immediate response with session info
246
259
  res.writeHead(200, { 'Content-Type': 'application/json' });
247
260
  res.end(JSON.stringify({ message: userMessage, session, streamId: session.id }));
248
261
 
249
- // Emit streaming start event
250
262
  broadcastSync({
251
263
  type: 'streaming_start',
252
264
  sessionId: session.id,
@@ -256,7 +268,6 @@ const server = http.createServer(async (req, res) => {
256
268
  timestamp: Date.now()
257
269
  });
258
270
 
259
- // Fire-and-forget streaming with error handling
260
271
  processMessageWithStreaming(conversationId, userMessage.id, session.id, prompt, agentId, skipPermissions)
261
272
  .catch(err => debugLog(`[stream] Uncaught error: ${err.message}`));
262
273
  return;
@@ -450,58 +461,63 @@ function serveFile(filePath, res) {
450
461
 
451
462
  async function processMessageWithStreaming(conversationId, messageId, sessionId, content, agentId, skipPermissions = false) {
452
463
  const startTime = Date.now();
464
+ activeExecutions.set(conversationId, true);
465
+ queries.setIsStreaming(conversationId, true);
466
+
453
467
  try {
454
- debugLog(`[stream] Starting: conversationId=${conversationId}, sessionId=${sessionId}, agentId=${agentId}, skipPermissions=${skipPermissions}`);
468
+ debugLog(`[stream] Starting: conversationId=${conversationId}, sessionId=${sessionId}`);
455
469
 
456
470
  const conv = queries.getConversation(conversationId);
457
471
  const cwd = conv?.workingDirectory || '/config';
458
- const actualAgentId = agentId || 'claude-code';
472
+ const resumeSessionId = conv?.claudeSessionId || null;
459
473
 
460
- debugLog(`[stream] Calling runClaudeWithStreaming with config: skipPermissions=${skipPermissions}`);
461
- const config = {
462
- skipPermissions,
463
- verbose: true,
464
- outputFormat: 'stream-json',
465
- timeout: 1800000, // 30 minutes
466
- print: true
467
- };
468
-
469
- // Prepend system prompt to user content
470
- const promptWithSystem = `${SYSTEM_PROMPT}\n\n${content}`;
471
-
472
- const outputs = await runClaudeWithStreaming(promptWithSystem, cwd, actualAgentId, config);
473
- debugLog(`[stream] Claude returned ${outputs.length} streaming outputs`);
474
-
475
- // Process streaming outputs similar to processMessage
476
- // But emit WebSocket events for each block
477
474
  let allBlocks = [];
478
- let lastAssistantMessage = null;
479
475
  let eventCount = 0;
480
476
 
481
- for (const output of outputs) {
482
- if (output.type === 'assistant' && output.message?.content) {
483
- debugLog(`[stream] Found assistant message with ${output.message.content.length} content blocks`);
484
- lastAssistantMessage = output.message;
485
- allBlocks.push(...(output.message.content || []));
486
-
487
- // 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) {
488
492
  broadcastSync({
489
493
  type: 'streaming_progress',
490
494
  sessionId,
491
495
  conversationId,
492
- blockCount: allBlocks.length,
496
+ block: { type: 'text', text: parsed.result },
497
+ blockIndex: 0,
498
+ isResult: true,
493
499
  timestamp: Date.now()
494
500
  });
495
- eventCount++;
496
- } else if (output.type === 'tool_result' && output.result) {
497
- debugLog(`[stream] Found tool result`);
498
- allBlocks.push({
499
- type: 'tool_result',
500
- result: output.result,
501
- tool_use_id: output.tool_use_id
502
- });
503
- eventCount++;
504
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}`);
505
521
  }
506
522
 
507
523
  let messageContent = null;
@@ -511,28 +527,20 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
511
527
  blocks: allBlocks,
512
528
  timestamp: Date.now()
513
529
  });
514
- debugLog(`[stream] Storing full execution with ${allBlocks.length} blocks`);
515
530
  } else {
516
531
  let textParts = [];
517
532
  for (const output of outputs) {
518
- if (typeof output === 'string') {
519
- textParts.push(output);
520
- } else if (output.text) {
521
- textParts.push(output.text);
522
- } else if (output.content?.text) {
523
- textParts.push(output.content.text);
524
- } else if (output.result) {
533
+ if (output.type === 'result' && output.result) {
525
534
  textParts.push(String(output.result));
535
+ } else if (typeof output === 'string') {
536
+ textParts.push(output);
526
537
  }
527
538
  }
528
539
  messageContent = textParts.join('\n').trim();
529
- debugLog(`[stream] Storing text response: "${messageContent.substring(0, 100)}..."`);
530
540
  }
531
541
 
532
542
  if (messageContent) {
533
- debugLog(`[stream] Creating assistant message`);
534
543
  const assistantMessage = queries.createMessage(conversationId, 'assistant', messageContent);
535
- debugLog(`[stream] Created message with id: ${assistantMessage.id}`);
536
544
  broadcastSync({
537
545
  type: 'streaming_complete',
538
546
  sessionId,
@@ -541,30 +549,25 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
541
549
  eventCount,
542
550
  timestamp: Date.now()
543
551
  });
544
- } else {
545
- debugLog(`[stream] No response content extracted!`);
552
+ broadcastSync({
553
+ type: 'message_created',
554
+ conversationId,
555
+ message: assistantMessage,
556
+ timestamp: Date.now()
557
+ });
546
558
  }
547
559
 
548
- debugLog(`[stream] Completed: ${outputs.length} outputs received, ${eventCount} events emitted`);
560
+ debugLog(`[stream] Completed: ${outputs.length} outputs, ${eventCount} events`);
549
561
  } catch (error) {
550
562
  const elapsed = Date.now() - startTime;
551
563
  debugLog(`[stream] Error after ${elapsed}ms: ${error.message}`);
552
564
 
553
- // Mark session as incomplete for recovery
554
- try {
555
- const sessionStatus = error.message.includes('timeout') ? 'timeout' : 'error';
556
- queries.markSessionIncomplete(sessionId, error.message);
557
- debugLog(`[stream] Session ${sessionId} marked as incomplete (${sessionStatus})`);
558
- } catch (err) {
559
- debugLog(`[stream] Failed to mark session: ${err.message}`);
560
- }
561
-
562
565
  broadcastSync({
563
566
  type: 'streaming_error',
564
567
  sessionId,
565
568
  conversationId,
566
569
  error: error.message,
567
- recoverable: elapsed < 60000, // Retryable if failed within 1 minute
570
+ recoverable: elapsed < 60000,
568
571
  timestamp: Date.now()
569
572
  });
570
573
 
@@ -575,102 +578,91 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
575
578
  message: errorMessage,
576
579
  timestamp: Date.now()
577
580
  });
581
+ } finally {
582
+ activeExecutions.delete(conversationId);
583
+ queries.setIsStreaming(conversationId, false);
584
+ drainMessageQueue(conversationId);
578
585
  }
579
586
  }
580
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
+
581
620
  async function processMessage(conversationId, messageId, content, agentId) {
582
621
  try {
583
622
  debugLog(`[processMessage] Starting: conversationId=${conversationId}, agentId=${agentId}`);
584
623
 
585
624
  const conv = queries.getConversation(conversationId);
586
625
  const cwd = conv?.workingDirectory || '/config';
587
- const actualAgentId = agentId || 'claude-code';
626
+ const resumeSessionId = conv?.claudeSessionId || null;
588
627
 
589
- // Handle both string content and object content (for structured messages)
590
- let contentStr = content;
591
- if (typeof content === 'object') {
592
- contentStr = JSON.stringify(content);
593
- }
628
+ let contentStr = typeof content === 'object' ? JSON.stringify(content) : content;
594
629
 
595
- debugLog(`[processMessage] Calling runClaudeWithStreaming with prompt: "${contentStr.substring(0, 50)}..."`);
596
- // Prepend system prompt to user content
597
- const promptWithSystem = `${SYSTEM_PROMPT}\n\n${contentStr}`;
598
- const outputs = await runClaudeWithStreaming(promptWithSystem, cwd, actualAgentId);
599
- 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
+ });
600
634
 
601
- // Collect all message blocks to preserve full execution details
602
- let allBlocks = [];
603
- let lastAssistantMessage = null;
635
+ if (claudeSessionId && !conv?.claudeSessionId) {
636
+ queries.setClaudeSessionId(conversationId, claudeSessionId);
637
+ }
604
638
 
639
+ let allBlocks = [];
605
640
  for (const output of outputs) {
606
641
  if (output.type === 'assistant' && output.message?.content) {
607
- debugLog(`[processMessage] Found assistant message with ${output.message.content.length} content blocks`);
608
- lastAssistantMessage = output.message;
609
642
  allBlocks.push(...(output.message.content || []));
610
- } else if (output.type === 'tool_result' && output.result) {
611
- debugLog(`[processMessage] Found tool result: ${typeof output.result}`);
612
- allBlocks.push({
613
- type: 'tool_result',
614
- result: output.result,
615
- tool_use_id: output.tool_use_id
616
- });
617
643
  }
618
644
  }
619
645
 
620
- // Store full message structure if we have execution data, otherwise fallback to text
621
646
  let messageContent = null;
622
-
623
647
  if (allBlocks.length > 0) {
624
- // Store full message structure as JSON for proper rendering
625
- messageContent = JSON.stringify({
626
- type: 'claude_execution',
627
- blocks: allBlocks,
628
- timestamp: Date.now()
629
- });
630
- debugLog(`[processMessage] Storing full execution with ${allBlocks.length} blocks`);
648
+ messageContent = JSON.stringify({ type: 'claude_execution', blocks: allBlocks, timestamp: Date.now() });
631
649
  } else {
632
- // Fallback: extract text for simple responses
633
650
  let textParts = [];
634
651
  for (const output of outputs) {
635
- if (typeof output === 'string') {
636
- textParts.push(output);
637
- } else if (output.text) {
638
- textParts.push(output.text);
639
- } else if (output.content?.text) {
640
- textParts.push(output.content.text);
641
- } else if (output.result) {
642
- textParts.push(String(output.result));
643
- }
652
+ if (output.type === 'result' && output.result) textParts.push(String(output.result));
653
+ else if (typeof output === 'string') textParts.push(output);
644
654
  }
645
655
  messageContent = textParts.join('\n').trim();
646
- debugLog(`[processMessage] Storing text response: "${messageContent.substring(0, 100)}..."`);
647
656
  }
648
657
 
649
658
  if (messageContent) {
650
- debugLog(`[processMessage] Creating assistant message`);
651
659
  const assistantMessage = queries.createMessage(conversationId, 'assistant', messageContent);
652
- debugLog(`[processMessage] Created message with id: ${assistantMessage.id}`);
653
- broadcastSync({
654
- type: 'message_created',
655
- conversationId,
656
- message: assistantMessage,
657
- timestamp: Date.now()
658
- });
659
- } else {
660
- debugLog(`[processMessage] No response content extracted!`);
660
+ broadcastSync({ type: 'message_created', conversationId, message: assistantMessage, timestamp: Date.now() });
661
661
  }
662
-
663
- debugLog(`[processMessage] ✅ Completed: ${outputs.length} outputs received`);
664
662
  } catch (error) {
665
663
  debugLog(`[processMessage] Error: ${error.message}`);
666
- debugLog(`[processMessage] Stack: ${error.stack}`);
667
664
  const errorMessage = queries.createMessage(conversationId, 'assistant', `Error: ${error.message}`);
668
- broadcastSync({
669
- type: 'message_created',
670
- conversationId,
671
- message: errorMessage,
672
- timestamp: Date.now()
673
- });
665
+ broadcastSync({ type: 'message_created', conversationId, message: errorMessage, timestamp: Date.now() });
674
666
  }
675
667
  }
676
668
 
@@ -700,11 +692,14 @@ wss.on('connection', (ws, req) => {
700
692
  try {
701
693
  const data = JSON.parse(msg);
702
694
  if (data.type === 'subscribe') {
703
- ws.subscriptions.add(data.sessionId);
704
- 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}`);
705
699
  ws.send(JSON.stringify({
706
700
  type: 'subscription_confirmed',
707
701
  sessionId: data.sessionId,
702
+ conversationId: data.conversationId,
708
703
  timestamp: Date.now()
709
704
  }));
710
705
  } else if (data.type === 'unsubscribe') {
@@ -742,25 +737,17 @@ wss.on('connection', (ws, req) => {
742
737
 
743
738
  function broadcastSync(event) {
744
739
  const data = JSON.stringify(event);
745
- const isStreamingEvent = event.type && event.type.startsWith('streaming_');
746
- const targetSessionId = event.sessionId || (event.conversationId && `conv-${event.conversationId}`);
747
740
 
748
741
  for (const ws of syncClients) {
749
742
  if (ws.readyState !== 1) continue;
750
743
 
751
744
  let shouldSend = false;
752
745
 
753
- if (isStreamingEvent && targetSessionId) {
754
- // Streaming events require sessionId subscription
755
- shouldSend = ws.subscriptions && ws.subscriptions.has(targetSessionId);
756
- } else if (event.sessionId) {
757
- // Regular session events require sessionId subscription
758
- shouldSend = ws.subscriptions && ws.subscriptions.has(event.sessionId);
759
- } else if (event.type === 'message_created' || event.type === 'conversation_created') {
760
- // Global events sent to all clients
746
+ if (event.sessionId && ws.subscriptions?.has(event.sessionId)) {
761
747
  shouldSend = true;
762
- } else {
763
- // 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') {
764
751
  shouldSend = true;
765
752
  }
766
753
 
@@ -193,66 +193,42 @@ class AgentGUIClient {
193
193
  }
194
194
  }
195
195
 
196
- /**
197
- * Handle incoming WebSocket message
198
- */
199
196
  handleWebSocketMessage(data) {
200
197
  try {
201
- // Route by message type
202
198
  switch (data.type) {
203
199
  case 'streaming_start':
204
200
  this.handleStreamingStart(data);
205
201
  break;
206
-
207
202
  case 'streaming_progress':
208
- this.queueEvent(data);
203
+ this.handleStreamingProgress(data);
209
204
  break;
210
-
211
205
  case 'streaming_complete':
212
206
  this.handleStreamingComplete(data);
213
207
  break;
214
-
215
- case 'file_read':
216
- case 'file_write':
217
- case 'command_execute':
218
- case 'git_status':
219
- case 'error':
220
- case 'text_block':
221
- case 'code_block':
222
- case 'thinking_block':
223
- case 'tool_use':
224
- this.queueEvent(data);
208
+ case 'streaming_error':
209
+ this.handleStreamingError(data);
225
210
  break;
226
-
227
211
  case 'conversation_created':
228
212
  this.handleConversationCreated(data);
229
213
  break;
230
-
231
214
  case 'message_created':
232
215
  this.handleMessageCreated(data);
233
216
  break;
234
-
217
+ case 'queue_status':
218
+ this.handleQueueStatus(data);
219
+ break;
235
220
  default:
236
- console.log('Unhandled message type:', data.type);
221
+ break;
237
222
  }
238
223
  } catch (error) {
239
224
  console.error('Message handling error:', error);
240
225
  }
241
226
  }
242
227
 
243
- /**
244
- * Queue event for rendering
245
- */
246
228
  queueEvent(data) {
247
229
  try {
248
- // Process event
249
230
  const processed = this.eventProcessor.processEvent(data);
250
231
  if (!processed) return;
251
-
252
- // Queue for rendering
253
- this.renderer.queueEvent(processed);
254
-
255
- // Track session events
256
232
  if (data.sessionId && this.state.currentSession?.id === data.sessionId) {
257
233
  this.state.sessionEvents.push(processed);
258
234
  }
@@ -261,9 +237,6 @@ class AgentGUIClient {
261
237
  }
262
238
  }
263
239
 
264
- /**
265
- * Handle streaming start
266
- */
267
240
  handleStreamingStart(data) {
268
241
  console.log('Streaming started:', data);
269
242
  this.state.isStreaming = true;
@@ -273,60 +246,146 @@ class AgentGUIClient {
273
246
  agentId: data.agentId,
274
247
  startTime: Date.now()
275
248
  };
276
- this.state.currentConversation = { id: data.conversationId };
277
249
  this.state.sessionEvents = [];
250
+ this.state.streamingBlocks = [];
278
251
 
279
- // Auto-select the streaming conversation in the sidebar
280
- if (window.conversationManager) {
281
- window.conversationManager.select(data.conversationId);
252
+ if (this.wsManager.isConnected) {
253
+ this.wsManager.subscribeToSession(data.sessionId);
282
254
  }
283
255
 
284
- // Load the conversation to display it in real-time
285
- this.loadConversationMessages(data.conversationId).then(() => {
286
- // Clear output and prepare for streaming
287
- const outputEl = document.getElementById('output');
288
- if (outputEl) {
289
- outputEl.innerHTML = '';
256
+ const outputEl = document.getElementById('output');
257
+ if (outputEl) {
258
+ let messagesEl = outputEl.querySelector('.conversation-messages');
259
+ if (!messagesEl) {
260
+ outputEl.innerHTML = '<div class="conversation-messages"></div>';
261
+ messagesEl = outputEl.querySelector('.conversation-messages');
290
262
  }
291
- }).catch(err => {
292
- console.error('Failed to load conversation during streaming:', err);
293
- this.renderer.clear();
294
- });
295
-
296
- this.renderer.queueEvent({
297
- type: 'streaming_start',
298
- sessionId: data.sessionId,
299
- conversationId: data.conversationId,
300
- agentId: data.agentId,
301
- timestamp: data.timestamp || Date.now()
302
- });
263
+ const streamingDiv = document.createElement('div');
264
+ streamingDiv.className = 'message message-assistant streaming-message';
265
+ streamingDiv.id = `streaming-${data.sessionId}`;
266
+ streamingDiv.innerHTML = `
267
+ <div class="message-role">Assistant</div>
268
+ <div class="message-blocks streaming-blocks"></div>
269
+ <div class="streaming-indicator" style="display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0;color:var(--color-text-secondary);font-size:0.875rem;">
270
+ <span class="animate-spin" style="display:inline-block;width:1rem;height:1rem;border:2px solid var(--color-border);border-top-color:var(--color-primary);border-radius:50%;"></span>
271
+ Thinking...
272
+ </div>
273
+ `;
274
+ messagesEl.appendChild(streamingDiv);
275
+ this.scrollToBottom();
276
+ }
303
277
 
304
278
  this.disableControls();
305
279
  this.emit('streaming:start', data);
306
280
  }
307
281
 
308
- /**
309
- * Handle streaming complete
310
- */
282
+ handleStreamingProgress(data) {
283
+ if (!data.block) return;
284
+
285
+ const block = data.block;
286
+ if (!this.state.streamingBlocks) this.state.streamingBlocks = [];
287
+ this.state.streamingBlocks.push(block);
288
+
289
+ const sessionId = data.sessionId || this.state.currentSession?.id;
290
+ const streamingEl = document.getElementById(`streaming-${sessionId}`);
291
+ if (!streamingEl) return;
292
+
293
+ const blocksEl = streamingEl.querySelector('.streaming-blocks');
294
+ if (!blocksEl) return;
295
+
296
+ const indicator = streamingEl.querySelector('.streaming-indicator');
297
+
298
+ if (block.type === 'text' && block.text) {
299
+ const existingTextEl = blocksEl.querySelector('.streaming-text-current');
300
+ if (existingTextEl && !data.isResult) {
301
+ existingTextEl.innerHTML = this.renderBlockContent(block);
302
+ } else {
303
+ const div = document.createElement('div');
304
+ div.className = 'message-text streaming-text-current';
305
+ div.innerHTML = this.renderBlockContent(block);
306
+ blocksEl.appendChild(div);
307
+ }
308
+ } else if (block.type === 'tool_use') {
309
+ const prevTextEl = blocksEl.querySelector('.streaming-text-current');
310
+ if (prevTextEl) prevTextEl.classList.remove('streaming-text-current');
311
+
312
+ const div = document.createElement('div');
313
+ div.className = 'message-tool';
314
+ div.textContent = `[Tool: ${block.name || 'unknown'}]`;
315
+ blocksEl.appendChild(div);
316
+ } else if (block.type === 'tool_result') {
317
+ const div = document.createElement('div');
318
+ div.className = 'message-text';
319
+ div.innerHTML = `<em style="color:var(--color-text-secondary)">${this.escapeHtml(String(block.result || '').substring(0, 500))}</em>`;
320
+ blocksEl.appendChild(div);
321
+ }
322
+
323
+ if (indicator) indicator.querySelector('span:last-child')?.remove();
324
+ if (indicator) {
325
+ const label = document.createElement('span');
326
+ label.textContent = block.type === 'tool_use' ? `Using ${block.name}...` : 'Responding...';
327
+ indicator.appendChild(label);
328
+ }
329
+
330
+ this.scrollToBottom();
331
+ }
332
+
333
+ renderBlockContent(block) {
334
+ if (block.type === 'text' && block.text) {
335
+ const text = block.text;
336
+ if (text.includes('<') && (text.includes('</') || text.includes('/>'))) {
337
+ return text;
338
+ }
339
+ return this.escapeHtml(text);
340
+ }
341
+ return this.escapeHtml(JSON.stringify(block));
342
+ }
343
+
344
+ scrollToBottom() {
345
+ const scrollContainer = document.getElementById('output-scroll');
346
+ if (scrollContainer) {
347
+ scrollContainer.scrollTop = scrollContainer.scrollHeight;
348
+ }
349
+ }
350
+
351
+ handleStreamingError(data) {
352
+ console.error('Streaming error:', data);
353
+ this.state.isStreaming = false;
354
+
355
+ const sessionId = data.sessionId || this.state.currentSession?.id;
356
+ const streamingEl = document.getElementById(`streaming-${sessionId}`);
357
+ if (streamingEl) {
358
+ const indicator = streamingEl.querySelector('.streaming-indicator');
359
+ if (indicator) {
360
+ indicator.innerHTML = `<span style="color:var(--color-error);">Error: ${this.escapeHtml(data.error || 'Unknown error')}</span>`;
361
+ }
362
+ }
363
+
364
+ this.enableControls();
365
+ this.emit('streaming:error', data);
366
+ }
367
+
311
368
  handleStreamingComplete(data) {
312
369
  console.log('Streaming completed:', data);
313
370
  this.state.isStreaming = false;
314
371
 
315
- const duration = data.duration || (Date.now() - (this.state.currentSession?.startTime || Date.now()));
316
-
317
- this.renderer.queueEvent({
318
- type: 'streaming_complete',
319
- sessionId: data.sessionId,
320
- duration,
321
- timestamp: data.timestamp || Date.now()
322
- });
372
+ const sessionId = data.sessionId || this.state.currentSession?.id;
373
+ const streamingEl = document.getElementById(`streaming-${sessionId}`);
374
+ if (streamingEl) {
375
+ const indicator = streamingEl.querySelector('.streaming-indicator');
376
+ if (indicator) indicator.remove();
377
+ streamingEl.classList.remove('streaming-message');
378
+ const prevTextEl = streamingEl.querySelector('.streaming-text-current');
379
+ if (prevTextEl) prevTextEl.classList.remove('streaming-text-current');
380
+
381
+ const ts = document.createElement('div');
382
+ ts.className = 'message-timestamp';
383
+ ts.textContent = new Date().toLocaleString();
384
+ streamingEl.appendChild(ts);
385
+ }
323
386
 
324
387
  this.enableControls();
325
- this.emit('streaming:complete', {
326
- ...data,
327
- duration,
328
- eventCount: this.state.sessionEvents.length
329
- });
388
+ this.emit('streaming:complete', data);
330
389
  }
331
390
 
332
391
  /**
@@ -339,32 +398,55 @@ class AgentGUIClient {
339
398
  }
340
399
  }
341
400
 
342
- /**
343
- * Handle message created
344
- */
345
401
  handleMessageCreated(data) {
346
- // If the message is for the currently displayed conversation, append it to the output
347
- if (data.conversationId === this.state.currentConversation?.id && data.message) {
348
- const outputEl = document.querySelector('.conversation-messages');
349
- if (outputEl) {
350
- const messageHtml = `
351
- <div class="message message-${data.message.role}">
352
- <div class="message-role">${data.message.role.charAt(0).toUpperCase() + data.message.role.slice(1)}</div>
353
- ${this.renderMessageContent(data.message.content)}
354
- <div class="message-timestamp">${new Date(data.message.created_at).toLocaleString()}</div>
355
- </div>
356
- `;
357
- outputEl.insertAdjacentHTML('beforeend', messageHtml);
358
- // Scroll to bottom
359
- const scrollContainer = document.getElementById('output-scroll');
360
- if (scrollContainer) {
361
- scrollContainer.scrollTop = scrollContainer.scrollHeight;
362
- }
363
- }
402
+ if (data.conversationId !== this.state.currentConversation?.id || !data.message) {
403
+ this.emit('message:created', data);
404
+ return;
364
405
  }
406
+
407
+ if (data.message.role === 'assistant' && this.state.isStreaming) {
408
+ this.emit('message:created', data);
409
+ return;
410
+ }
411
+
412
+ const outputEl = document.querySelector('.conversation-messages');
413
+ if (!outputEl) {
414
+ this.emit('message:created', data);
415
+ return;
416
+ }
417
+
418
+ const messageHtml = `
419
+ <div class="message message-${data.message.role}" data-msg-id="${data.message.id}">
420
+ <div class="message-role">${data.message.role.charAt(0).toUpperCase() + data.message.role.slice(1)}</div>
421
+ ${this.renderMessageContent(data.message.content)}
422
+ <div class="message-timestamp">${new Date(data.message.created_at).toLocaleString()}</div>
423
+ </div>
424
+ `;
425
+ outputEl.insertAdjacentHTML('beforeend', messageHtml);
426
+ this.scrollToBottom();
365
427
  this.emit('message:created', data);
366
428
  }
367
429
 
430
+ handleQueueStatus(data) {
431
+ if (data.conversationId !== this.state.currentConversation?.id) return;
432
+
433
+ const outputEl = document.querySelector('.conversation-messages');
434
+ if (!outputEl) return;
435
+
436
+ let queueEl = outputEl.querySelector('.queue-indicator');
437
+ if (data.queueLength > 0) {
438
+ if (!queueEl) {
439
+ queueEl = document.createElement('div');
440
+ queueEl.className = 'queue-indicator';
441
+ queueEl.style.cssText = 'padding:0.5rem 1rem;margin:0.5rem 0;border-radius:0.375rem;background:var(--color-warning);color:#000;font-size:0.875rem;text-align:center;';
442
+ outputEl.appendChild(queueEl);
443
+ }
444
+ queueEl.textContent = `${data.queueLength} message${data.queueLength > 1 ? 's' : ''} queued`;
445
+ } else if (queueEl) {
446
+ queueEl.remove();
447
+ }
448
+ }
449
+
368
450
  /**
369
451
  * Parse markdown code blocks from text
370
452
  * Returns array of parts with type ('text' or 'code') and content/language/code
@@ -476,15 +558,7 @@ class AgentGUIClient {
476
558
  }
477
559
  }
478
560
 
479
- /**
480
- * Start execution
481
- */
482
561
  async startExecution() {
483
- if (this.state.isStreaming) {
484
- this.showError('Streaming already in progress');
485
- return;
486
- }
487
-
488
562
  const prompt = this.ui.messageInput?.value || '';
489
563
  const agentId = this.ui.agentSelector?.value || 'claude-code';
490
564
 
@@ -493,23 +567,28 @@ class AgentGUIClient {
493
567
  return;
494
568
  }
495
569
 
496
- try {
497
- this.disableControls();
570
+ if (this.ui.messageInput) this.ui.messageInput.value = '';
498
571
 
499
- const response = await fetch(window.__BASE_URL + '/api/conversations', {
500
- method: 'POST',
501
- headers: { 'Content-Type': 'application/json' },
502
- body: JSON.stringify({
503
- agentId,
504
- title: prompt.substring(0, 50)
505
- })
506
- });
572
+ try {
573
+ if (this.state.currentConversation?.id) {
574
+ await this.streamToConversation(this.state.currentConversation.id, prompt, agentId);
575
+ } else {
576
+ this.disableControls();
577
+ const response = await fetch(window.__BASE_URL + '/api/conversations', {
578
+ method: 'POST',
579
+ headers: { 'Content-Type': 'application/json' },
580
+ body: JSON.stringify({ agentId, title: prompt.substring(0, 50) })
581
+ });
582
+ const { conversation } = await response.json();
583
+ this.state.currentConversation = conversation;
507
584
 
508
- const { conversation } = await response.json();
509
- this.state.currentConversation = conversation;
585
+ if (window.conversationManager) {
586
+ window.conversationManager.loadConversations();
587
+ window.conversationManager.select(conversation.id);
588
+ }
510
589
 
511
- // Start streaming
512
- await this.streamToConversation(conversation.id, prompt, agentId);
590
+ await this.streamToConversation(conversation.id, prompt, agentId);
591
+ }
513
592
  } catch (error) {
514
593
  console.error('Execution error:', error);
515
594
  this.showError('Failed to start execution: ' + error.message);
@@ -517,33 +596,32 @@ class AgentGUIClient {
517
596
  }
518
597
  }
519
598
 
520
- /**
521
- * Stream execution to conversation
522
- */
523
599
  async streamToConversation(conversationId, prompt, agentId) {
524
600
  try {
601
+ if (this.wsManager.isConnected) {
602
+ this.wsManager.sendMessage({ type: 'subscribe', conversationId });
603
+ }
604
+
525
605
  const response = await fetch(`${window.__BASE_URL}/api/conversations/${conversationId}/stream`, {
526
606
  method: 'POST',
527
607
  headers: { 'Content-Type': 'application/json' },
528
- body: JSON.stringify({
529
- content: prompt,
530
- agentId,
531
- skipPermissions: false
532
- })
608
+ body: JSON.stringify({ content: prompt, agentId, skipPermissions: false })
533
609
  });
534
610
 
535
- if (!response.ok) {
536
- throw new Error(`HTTP ${response.status}`);
537
- }
611
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
538
612
 
539
- const { session, streamId } = await response.json();
613
+ const result = await response.json();
540
614
 
541
- // Subscribe to session events via WebSocket
542
- if (this.wsManager.isConnected) {
543
- this.wsManager.subscribeToSession(session.id);
615
+ if (result.queued) {
616
+ console.log('Message queued, position:', result.queuePosition);
617
+ return;
544
618
  }
545
619
 
546
- this.emit('execution:started', { session, streamId });
620
+ if (result.session && this.wsManager.isConnected) {
621
+ this.wsManager.subscribeToSession(result.session.id);
622
+ }
623
+
624
+ this.emit('execution:started', result);
547
625
  } catch (error) {
548
626
  console.error('Stream execution error:', error);
549
627
  this.showError('Failed to stream execution: ' + error.message);
@@ -675,37 +753,33 @@ class AgentGUIClient {
675
753
  }
676
754
  }
677
755
 
678
- /**
679
- * Load and display conversation messages
680
- */
681
756
  async loadConversationMessages(conversationId) {
682
757
  try {
683
- this.state.currentConversation = { id: conversationId };
684
-
685
- // Fetch conversation details
686
758
  const convResponse = await fetch(window.__BASE_URL + `/api/conversations/${conversationId}`);
687
759
  const { conversation } = await convResponse.json();
760
+ this.state.currentConversation = conversation;
688
761
 
689
- // Fetch messages
690
- const messagesResponse = await fetch(window.__BASE_URL + `/api/conversations/${conversationId}/messages`);
691
- if (!messagesResponse.ok) {
692
- throw new Error(`Failed to fetch messages: ${messagesResponse.status}`);
762
+ if (this.wsManager.isConnected) {
763
+ this.wsManager.sendMessage({ type: 'subscribe', conversationId });
693
764
  }
765
+
766
+ const messagesResponse = await fetch(window.__BASE_URL + `/api/conversations/${conversationId}/messages`);
767
+ if (!messagesResponse.ok) throw new Error(`Failed to fetch messages: ${messagesResponse.status}`);
694
768
  const messagesData = await messagesResponse.json();
695
769
 
696
- // Clear output and display conversation header
697
770
  const outputEl = document.getElementById('output');
698
771
  if (outputEl) {
699
- const wdInfo = conversation.workingDirectory ? ` ${this.escapeHtml(conversation.workingDirectory)}` : '';
772
+ const wdInfo = conversation.workingDirectory ? ` - ${this.escapeHtml(conversation.workingDirectory)}` : '';
700
773
  outputEl.innerHTML = `
701
774
  <div class="conversation-header">
702
775
  <h2>${this.escapeHtml(conversation.title || 'Conversation')}</h2>
703
- <p class="text-secondary">${conversation.agentType || 'unknown'} ${new Date(conversation.created_at).toLocaleDateString()}${wdInfo}</p>
776
+ <p class="text-secondary">${conversation.agentType || 'unknown'} - ${new Date(conversation.created_at).toLocaleDateString()}${wdInfo}</p>
704
777
  </div>
705
778
  <div class="conversation-messages">
706
779
  ${this.renderMessages(messagesData.messages || [])}
707
780
  </div>
708
781
  `;
782
+ this.scrollToBottom();
709
783
  }
710
784
  } catch (error) {
711
785
  console.error('Failed to load conversation messages:', error);
@@ -50,7 +50,7 @@ class WebSocketManager {
50
50
  getWebSocketURL() {
51
51
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
52
52
  const baseURL = window.__BASE_URL || '/gm';
53
- return `${protocol}//${window.location.host}${baseURL}/ws`;
53
+ return `${protocol}//${window.location.host}${baseURL}/sync`;
54
54
  }
55
55
 
56
56
  /**