agentgui 1.0.698 → 1.0.700

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/CLAUDE.md CHANGED
@@ -73,7 +73,7 @@ XState v5 machines provide formal state tracking alongside (not replacing) the e
73
73
  - WebSocket endpoint is at `BASE_URL + /sync`. Supports subscribe/unsubscribe by sessionId or conversationId, and ping.
74
74
  - All WS RPC uses msgpack binary encoding (lib/codec.js). Wire format: `{ r, m, p }` request, `{ r, d }` reply, `{ type, seq }` broadcast push.
75
75
  - `perMessageDeflate` is disabled on the WS server — msgpack binary doesn't compress well and brotli/gzip was blocking the event loop. HTTP-layer gzip handles static assets.
76
- - Static assets use `Cache-Control: max-age=31536000, immutable` + ETag. Compressed once on first request, served from RAM (`_assetCache` Map keyed by etag).
76
+ - Static assets use `Cache-Control: public, no-cache` + ETag. Browser always revalidates (sends If-None-Match), server returns 304 if unchanged. Compressed once on first request, served from RAM (`_assetCache` Map keyed by etag).
77
77
  - Deployment: runs behind Traefik/Caddy which handles TLS and can support WebTransport/QUIC.
78
78
 
79
79
  ## Environment Variables
@@ -38,6 +38,9 @@ const machine = createMachine({
38
38
  queue: [...context.queue, event.item],
39
39
  })),
40
40
  },
41
+ SET_QUEUE: {
42
+ actions: assign(({ event }) => ({ queue: event.queue })),
43
+ },
41
44
  COMPLETE: [
42
45
  {
43
46
  guard: ({ context }) => context.queue.length > 0,
@@ -102,10 +105,21 @@ const machine = createMachine({
102
105
  });
103
106
 
104
107
  const actors = new Map();
108
+ // Per-convId listeners: Map<convId, Set<(snapshot) => void>>
109
+ const listeners = new Map();
110
+
111
+ function notifyListeners(convId, snapshot) {
112
+ const set = listeners.get(convId);
113
+ if (!set) return;
114
+ for (const fn of set) {
115
+ try { fn(snapshot); } catch (_) {}
116
+ }
117
+ }
105
118
 
106
119
  export function getOrCreate(convId) {
107
120
  if (actors.has(convId)) return actors.get(convId);
108
121
  const actor = createActor(machine);
122
+ actor.subscribe(snapshot => notifyListeners(convId, snapshot));
109
123
  actor.start();
110
124
  actors.set(convId, actor);
111
125
  return actor;
@@ -118,6 +132,7 @@ export function get(convId) {
118
132
  export function remove(convId) {
119
133
  const actor = actors.get(convId);
120
134
  if (actor) { actor.stop(); actors.delete(convId); }
135
+ listeners.delete(convId);
121
136
  }
122
137
 
123
138
  export function snapshot(convId) {
@@ -130,18 +145,38 @@ export function isStreaming(convId) {
130
145
  return s ? s.value === 'streaming' || s.value === 'rate_limited' : false;
131
146
  }
132
147
 
148
+ export function isActive(convId) {
149
+ const s = snapshot(convId);
150
+ if (!s) return false;
151
+ return s.value === 'streaming' || s.value === 'rate_limited' || s.value === 'draining';
152
+ }
153
+
133
154
  export function getContext(convId) {
134
155
  const s = snapshot(convId);
135
156
  return s ? s.context : null;
136
157
  }
137
158
 
159
+ export function getQueue(convId) {
160
+ const ctx = getContext(convId);
161
+ return ctx ? ctx.queue : [];
162
+ }
163
+
138
164
  export function send(convId, event) {
139
165
  const actor = getOrCreate(convId);
140
166
  actor.send(event);
141
167
  return actor.getSnapshot();
142
168
  }
143
169
 
170
+ // Subscribe to state transitions for a conversation.
171
+ // Returns an unsubscribe function.
172
+ export function subscribe(convId, fn) {
173
+ if (!listeners.has(convId)) listeners.set(convId, new Set());
174
+ listeners.get(convId).add(fn);
175
+ return () => listeners.get(convId)?.delete(fn);
176
+ }
177
+
144
178
  export function stopAll() {
145
179
  for (const [, actor] of actors) actor.stop();
146
180
  actors.clear();
181
+ listeners.clear();
147
182
  }
@@ -55,7 +55,7 @@ export function register(router, deps) {
55
55
 
56
56
  router.handle('conv.ls', () => {
57
57
  const conversations = queries.getConversationsList();
58
- for (const c of conversations) { if (c.isStreaming && !activeExecutions.has(c.id)) c.isStreaming = 0; }
58
+ for (const c of conversations) { if (c.isStreaming && !execMachine.isActive(c.id)) c.isStreaming = 0; }
59
59
  return { conversations };
60
60
  });
61
61
 
@@ -72,7 +72,7 @@ export function register(router, deps) {
72
72
  const conv = queries.getConversation(p.id);
73
73
  if (!conv) notFound();
74
74
  const machineSnap = execMachine.snapshot(p.id);
75
- return { conversation: conv, isActivelyStreaming: activeExecutions.has(p.id), latestSession: queries.getLatestSession(p.id), executionState: machineSnap?.value || 'idle' };
75
+ return { conversation: conv, isActivelyStreaming: execMachine.isActive(p.id), latestSession: queries.getLatestSession(p.id), executionState: machineSnap?.value || 'idle' };
76
76
  });
77
77
 
78
78
  router.handle('conv.upd', (p) => {
@@ -110,7 +110,7 @@ export function register(router, deps) {
110
110
  const machineSnap = execMachine.snapshot(p.id);
111
111
  return {
112
112
  conversation: conv,
113
- isActivelyStreaming: activeExecutions.has(p.id),
113
+ isActivelyStreaming: execMachine.isActive(p.id),
114
114
  executionState: machineSnap?.value || 'idle',
115
115
  latestSession: queries.getLatestSession(p.id),
116
116
  chunks,
@@ -141,15 +141,13 @@ export function register(router, deps) {
141
141
  });
142
142
 
143
143
  router.handle('conv.cancel', (p) => {
144
- const entry = activeExecutions.get(p.id);
145
- if (!entry) notFound('No active execution to cancel');
146
- const { pid, sessionId } = entry;
144
+ if (!execMachine.isActive(p.id)) notFound('No active execution to cancel');
145
+ const ctx = execMachine.getContext(p.id);
146
+ const pid = ctx?.pid || activeExecutions.get(p.id)?.pid;
147
+ const sessionId = ctx?.sessionId || activeExecutions.get(p.id)?.sessionId;
147
148
  if (pid) { try { process.kill(-pid, 'SIGKILL'); } catch { try { process.kill(pid, 'SIGKILL'); } catch {} } }
148
149
  if (sessionId) queries.updateSession(sessionId, { status: 'interrupted', completed_at: Date.now() });
149
-
150
- // Use atomic cleanup function to ensure state consistency
151
150
  cleanupExecution(p.id, false);
152
-
153
151
  broadcastSync({ type: 'streaming_complete', sessionId, conversationId: p.id, interrupted: true, timestamp: Date.now() });
154
152
  return { ok: true, cancelled: true, conversationId: p.id, sessionId };
155
153
  });
@@ -158,9 +156,8 @@ export function register(router, deps) {
158
156
  const conv = queries.getConversation(p.id);
159
157
  if (!conv) notFound('Conversation not found');
160
158
  if (!p.content) fail(400, 'Missing content');
161
- const entry = activeExecutions.get(p.id);
162
159
  const message = queries.createMessage(p.id, 'user', '[INJECTED] ' + p.content);
163
- if (!entry) {
160
+ if (!execMachine.isActive(p.id)) {
164
161
  const agentId = conv.agentId || 'claude-code';
165
162
  const session = queries.createSession(p.id, agentId, 'pending');
166
163
  processMessageWithStreaming(p.id, message.id, session.id, message.content, agentId, conv.model || null, conv.subAgent || null);
@@ -173,10 +170,11 @@ export function register(router, deps) {
173
170
  const conv = queries.getConversation(p.id);
174
171
  if (!conv) notFound('Conversation not found');
175
172
 
176
- const entry = activeExecutions.get(p.id);
177
- if (!entry) fail(409, 'No active execution to steer');
173
+ if (!execMachine.isActive(p.id)) fail(409, 'No active execution to steer');
178
174
 
179
- const { pid, sessionId } = entry;
175
+ const ctx = execMachine.getContext(p.id);
176
+ const pid = ctx?.pid || activeExecutions.get(p.id)?.pid;
177
+ const sessionId = ctx?.sessionId || activeExecutions.get(p.id)?.sessionId;
180
178
 
181
179
  if (pid) {
182
180
  try { process.kill(-pid, 'SIGKILL'); } catch { try { process.kill(pid, 'SIGKILL'); } catch (e) {} }
@@ -184,6 +182,7 @@ export function register(router, deps) {
184
182
 
185
183
  if (sessionId) queries.updateSession(sessionId, { status: 'interrupted', completed_at: Date.now() });
186
184
  queries.setIsStreaming(p.id, false);
185
+ execMachine.send(p.id, { type: 'CANCEL' });
187
186
  activeExecutions.delete(p.id);
188
187
 
189
188
  // Clear claudeSessionId so new execution starts fresh without --resume on a killed session
@@ -218,6 +217,8 @@ export function register(router, deps) {
218
217
  function startExecution(convId, message, agentId, model, content, subAgent) {
219
218
  const session = queries.createSession(convId);
220
219
  queries.createEvent('session.created', { messageId: message.id, sessionId: session.id }, convId, session.id);
220
+ // Machine is authoritative — START event sets sessionId in context
221
+ execMachine.send(convId, { type: 'START', sessionId: session.id });
221
222
  activeExecutions.set(convId, { pid: null, startTime: Date.now(), sessionId: session.id, lastActivity: Date.now() });
222
223
  queries.setIsStreaming(convId, true);
223
224
  broadcastSync({ type: 'streaming_start', sessionId: session.id, conversationId: convId, messageId: message.id, agentId, timestamp: Date.now() });
@@ -226,9 +227,13 @@ export function register(router, deps) {
226
227
  }
227
228
 
228
229
  function enqueue(convId, content, agentId, model, messageId, subAgent) {
230
+ const item = { content, agentId, model, messageId, subAgent };
231
+ // Machine is authoritative for queue — ENQUEUE event adds to machine context.queue
232
+ execMachine.send(convId, { type: 'ENQUEUE', item });
233
+ // Keep messageQueues in sync for legacy REST endpoints
229
234
  if (!messageQueues.has(convId)) messageQueues.set(convId, []);
230
- messageQueues.get(convId).push({ content, agentId, model, messageId, subAgent });
231
- const queueLength = messageQueues.get(convId).length;
235
+ messageQueues.get(convId).push(item);
236
+ const queueLength = execMachine.getQueue(convId).length;
232
237
  broadcastSync({ type: 'queue_status', conversationId: convId, queueLength, messageId, timestamp: Date.now() });
233
238
  return queueLength;
234
239
  }
@@ -249,14 +254,13 @@ export function register(router, deps) {
249
254
  const message = queries.createMessage(p.id, 'user', p.content, idempotencyKey);
250
255
  queries.createEvent('message.created', { role: 'user', messageId: message.id }, p.id);
251
256
 
252
- // Only broadcast message_created if NOT queuing - queued messages show in queue indicator instead
253
- if (!activeExecutions.has(p.id)) {
257
+ // Machine is authoritative: gate on machine state, not Map
258
+ if (!execMachine.isActive(p.id)) {
254
259
  broadcastSync({ type: 'message_created', conversationId: p.id, message, timestamp: Date.now() });
255
260
  const session = startExecution(p.id, message, agentId, model, p.content, subAgent);
256
261
  return { message, session, idempotencyKey };
257
262
  }
258
263
 
259
- // Message is queued - don't broadcast as message_created, let queue_status handle the UI update
260
264
  const qp = enqueue(p.id, p.content, agentId, model, message.id, subAgent);
261
265
  return { message, queued: true, queuePosition: qp, idempotencyKey };
262
266
  });
@@ -283,20 +287,19 @@ export function register(router, deps) {
283
287
  const userMessage = queries.createMessage(p.id, 'user', prompt);
284
288
  queries.createEvent('message.created', { role: 'user', messageId: userMessage.id }, p.id);
285
289
 
286
- // Only broadcast message_created if NOT queuing - queued messages show in queue indicator instead
287
- if (!activeExecutions.has(p.id)) {
290
+ // Machine is authoritative: gate on machine state, not Map
291
+ if (!execMachine.isActive(p.id)) {
288
292
  broadcastSync({ type: 'message_created', conversationId: p.id, message: userMessage, timestamp: Date.now() });
289
293
  const session = startExecution(p.id, userMessage, agentId, model, prompt, subAgent);
290
294
  return { message: userMessage, session, streamId: session.id };
291
295
  }
292
296
 
293
- // Message is queued - don't broadcast as message_created, let queue_status handle the UI update
294
297
  const qp = enqueue(p.id, prompt, agentId, model, userMessage.id, subAgent);
295
298
  const seq = getNextQueueSeq(p.id);
296
299
  broadcastSync({
297
300
  type: 'queue_status',
298
301
  conversationId: p.id,
299
- queueLength: messageQueues.get(p.id)?.length || 1,
302
+ queueLength: execMachine.getQueue(p.id).length,
300
303
  seq,
301
304
  timestamp: Date.now()
302
305
  });
@@ -305,21 +308,34 @@ export function register(router, deps) {
305
308
 
306
309
  router.handle('q.ls', (p) => {
307
310
  if (!queries.getConversation(p.id)) notFound('Conversation not found');
308
- return { queue: messageQueues.get(p.id) || [] };
311
+ // Read queue from machine context (authoritative), fall back to Map for compatibility
312
+ const machineQueue = execMachine.getQueue(p.id);
313
+ return { queue: machineQueue.length > 0 ? machineQueue : (messageQueues.get(p.id) || []) };
309
314
  });
310
315
 
311
316
  router.handle('q.del', (p) => {
312
- const queue = messageQueues.get(p.id);
313
- if (!queue) notFound('Queue not found');
314
- const idx = queue.findIndex(q => q.messageId === p.messageId);
315
- if (idx === -1) notFound('Queued message not found');
316
- queue.splice(idx, 1);
317
- if (queue.length === 0) messageQueues.delete(p.id);
317
+ const machineQueue = execMachine.getQueue(p.id);
318
+ const mapQueue = messageQueues.get(p.id);
319
+ if (!machineQueue.length && !mapQueue) notFound('Queue not found');
320
+ // Remove from both machine and Map
321
+ const idx = machineQueue.findIndex(q => q.messageId === p.messageId);
322
+ if (idx === -1 && (!mapQueue || mapQueue.findIndex(q => q.messageId === p.messageId) === -1)) notFound('Queued message not found');
323
+ // Update machine queue via direct send
324
+ if (idx !== -1) {
325
+ const newQueue = [...machineQueue];
326
+ newQueue.splice(idx, 1);
327
+ execMachine.send(p.id, { type: 'SET_QUEUE', queue: newQueue });
328
+ }
329
+ if (mapQueue) {
330
+ const mi = mapQueue.findIndex(q => q.messageId === p.messageId);
331
+ if (mi !== -1) mapQueue.splice(mi, 1);
332
+ if (mapQueue.length === 0) messageQueues.delete(p.id);
333
+ }
318
334
  const seq = getNextQueueSeq(p.id);
319
335
  broadcastSync({
320
336
  type: 'queue_status',
321
337
  conversationId: p.id,
322
- queueLength: queue?.length || 0,
338
+ queueLength: execMachine.getQueue(p.id).length,
323
339
  seq,
324
340
  timestamp: Date.now()
325
341
  });
@@ -327,9 +343,10 @@ export function register(router, deps) {
327
343
  });
328
344
 
329
345
  router.handle('q.upd', (p) => {
330
- const queue = messageQueues.get(p.id);
331
- if (!queue) notFound('Queue not found');
332
- const item = queue.find(q => q.messageId === p.messageId);
346
+ const machineQueue = execMachine.getQueue(p.id);
347
+ const mapQueue = messageQueues.get(p.id);
348
+ if (!machineQueue.length && !mapQueue) notFound('Queue not found');
349
+ const item = machineQueue.find(q => q.messageId === p.messageId) || mapQueue?.find(q => q.messageId === p.messageId);
333
350
  if (!item) notFound('Queued message not found');
334
351
  if (p.content !== undefined) item.content = p.content;
335
352
  if (p.agentId !== undefined) item.agentId = p.agentId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.698",
3
+ "version": "1.0.700",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -319,18 +319,19 @@ function logError(op, err, ctx = {}) {
319
319
  } catch (_) {}
320
320
  }
321
321
 
322
- // Atomic cleanup function - ensures consistent state across all data structures
322
+ // Atomic cleanup function - machine is authoritative, Map stays in sync
323
323
  function cleanupExecution(conversationId, broadcastCompletion = false) {
324
324
  debugLog(`[cleanup] Starting cleanup for ${conversationId}`);
325
325
 
326
- activeExecutions.delete(conversationId);
327
-
328
- // Sync execution machine to idle state
326
+ // Machine drives cleanup: send CANCEL (clears queue, transitions to idle)
329
327
  const machineSnap = execMachine.snapshot(conversationId);
330
328
  if (machineSnap && machineSnap.value !== 'idle') {
331
329
  execMachine.send(conversationId, { type: 'CANCEL' });
332
330
  }
333
331
 
332
+ // Keep Map in sync with machine
333
+ activeExecutions.delete(conversationId);
334
+
334
335
  // Clean database state
335
336
  queries.setIsStreaming(conversationId, false);
336
337
 
@@ -3304,11 +3305,9 @@ function serveFile(filePath, res, req) {
3304
3305
  res.end();
3305
3306
  return;
3306
3307
  }
3307
- const isJsCss = ['.js', '.css'].includes(ext);
3308
- // Use long cache + ETag for immutable assets; browser only re-requests on server restart
3309
- const cacheControl = isJsCss
3310
- ? 'public, max-age=31536000, immutable'
3311
- : 'public, max-age=3600, must-revalidate';
3308
+ // Use ETag-based revalidation: browser always checks with server, serves from cache on 304
3309
+ // Avoids stale immutable assets when server files change during development
3310
+ const cacheControl = 'public, no-cache';
3312
3311
 
3313
3312
  const sendCached = (cached) => {
3314
3313
  if (acceptsEncoding(req, 'gzip') && cached.gz) {
@@ -3774,10 +3773,10 @@ async function processMessageWithStreaming(conversationId, messageId, sessionId,
3774
3773
  batcher.drain();
3775
3774
  debugLog(`[stream] Claude returned ${outputs.length} outputs, sessionId=${claudeSessionId}`);
3776
3775
 
3777
- if (claudeSessionId) {
3778
- queries.setClaudeSessionId(conversationId, claudeSessionId);
3779
- debugLog(`[stream] Updated claudeSessionId=${claudeSessionId}`);
3780
- }
3776
+ // Clear claudeSessionId after successful completion so next message starts fresh
3777
+ // --resume on a completed session causes "No response requested." from the gm plugin
3778
+ queries.setClaudeSessionId(conversationId, null);
3779
+ debugLog(`[stream] Cleared claudeSessionId after successful completion`);
3781
3780
 
3782
3781
  // Mark session as complete
3783
3782
  queries.updateSession(sessionId, {
@@ -3983,20 +3982,36 @@ function scheduleRetry(conversationId, messageId, content, agentId, model, subAg
3983
3982
  }
3984
3983
 
3985
3984
  function drainMessageQueue(conversationId) {
3986
- const queue = messageQueues.get(conversationId);
3987
- if (!queue || queue.length === 0) return;
3985
+ // Machine context queue is authoritative; fall back to legacy Map
3986
+ const machineQueue = execMachine.getQueue(conversationId);
3987
+ const mapQueue = messageQueues.get(conversationId);
3988
+ if (machineQueue.length === 0 && (!mapQueue || mapQueue.length === 0)) return;
3989
+
3990
+ let next;
3991
+ if (machineQueue.length > 0) {
3992
+ // Consume from machine via COMPLETE transition (draining state pops nextItem)
3993
+ execMachine.send(conversationId, { type: 'COMPLETE' });
3994
+ const ctx = execMachine.getContext(conversationId);
3995
+ next = ctx?.nextItem;
3996
+ // Also keep Map in sync
3997
+ if (mapQueue && mapQueue.length > 0) mapQueue.shift();
3998
+ if (mapQueue && mapQueue.length === 0) messageQueues.delete(conversationId);
3999
+ } else {
4000
+ next = mapQueue.shift();
4001
+ if (mapQueue.length === 0) messageQueues.delete(conversationId);
4002
+ }
3988
4003
 
3989
- const next = queue.shift();
3990
- if (queue.length === 0) messageQueues.delete(conversationId);
4004
+ if (!next) return;
3991
4005
 
3992
4006
  debugLog(`[queue] Draining next message for ${conversationId}, messageId=${next.messageId}`);
3993
4007
 
3994
- // Broadcast queue_item_dequeued so client can update UI immediately
4008
+ const remainingQueueLength = execMachine.getQueue(conversationId).length || messageQueues.get(conversationId)?.length || 0;
4009
+
3995
4010
  broadcastSync({
3996
4011
  type: 'queue_item_dequeued',
3997
4012
  conversationId,
3998
4013
  messageId: next.messageId,
3999
- queueLength: queue?.length || 0,
4014
+ queueLength: remainingQueueLength,
4000
4015
  timestamp: Date.now()
4001
4016
  });
4002
4017
 
@@ -4009,24 +4024,25 @@ function drainMessageQueue(conversationId) {
4009
4024
  conversationId,
4010
4025
  messageId: next.messageId,
4011
4026
  agentId: next.agentId,
4012
- queueLength: queue?.length || 0,
4027
+ queueLength: remainingQueueLength,
4013
4028
  timestamp: Date.now()
4014
4029
  });
4015
4030
 
4016
4031
  broadcastSync({
4017
4032
  type: 'queue_status',
4018
4033
  conversationId,
4019
- queueLength: queue?.length || 0,
4034
+ queueLength: remainingQueueLength,
4020
4035
  timestamp: Date.now()
4021
4036
  });
4022
4037
 
4023
4038
  const startTime = Date.now();
4039
+ // Machine START event makes machine authoritative for this execution
4040
+ execMachine.send(conversationId, { type: 'START', sessionId: session.id });
4024
4041
  activeExecutions.set(conversationId, { pid: null, startTime, sessionId: session.id, lastActivity: startTime });
4025
4042
 
4026
4043
  processMessageWithStreaming(conversationId, next.messageId, session.id, next.content, next.agentId, next.model, next.subAgent)
4027
4044
  .catch(err => {
4028
4045
  debugLog(`[queue] Error processing queued message: ${err.message}`);
4029
- // CRITICAL: Clean up state on error so next message can be retried
4030
4046
  cleanupExecution(conversationId);
4031
4047
  broadcastSync({
4032
4048
  type: 'streaming_error',
@@ -4036,7 +4052,6 @@ function drainMessageQueue(conversationId) {
4036
4052
  recoverable: true,
4037
4053
  timestamp: Date.now()
4038
4054
  });
4039
- // Try to drain next message in queue
4040
4055
  setTimeout(() => drainMessageQueue(conversationId), 100);
4041
4056
  });
4042
4057
  }
@@ -4202,16 +4217,19 @@ wsRouter.onLegacy((data, ws) => {
4202
4217
  }));
4203
4218
 
4204
4219
  // Notify client if this conversation has an active streaming execution
4205
- if (data.conversationId && activeExecutions.has(data.conversationId)) {
4220
+ // Machine is authoritative for streaming state check on subscribe
4221
+ if (data.conversationId && execMachine.isActive(data.conversationId)) {
4222
+ const ctx = execMachine.getContext(data.conversationId);
4206
4223
  const execution = activeExecutions.get(data.conversationId);
4224
+ const sessionId = ctx?.sessionId || execution?.sessionId;
4207
4225
  const conv = queries.getConversation(data.conversationId);
4208
- const queue = messageQueues.get(data.conversationId);
4226
+ const queueLength = execMachine.getQueue(data.conversationId).length || messageQueues.get(data.conversationId)?.length || 0;
4209
4227
  sendWs(ws, ({
4210
4228
  type: 'streaming_start',
4211
- sessionId: execution.sessionId,
4229
+ sessionId,
4212
4230
  conversationId: data.conversationId,
4213
4231
  agentId: conv?.agentType || conv?.agentId || 'claude-code',
4214
- queueLength: queue?.length || 0,
4232
+ queueLength,
4215
4233
  resumed: true,
4216
4234
  seq: ++broadcastSeq,
4217
4235
  timestamp: Date.now()
@@ -228,7 +228,7 @@ class AgentGUIClient {
228
228
  // Save draft from previous conversation before switching
229
229
  this.saveDraftPrompt();
230
230
 
231
- const isStreaming = convId && this.state.streamingConversations.has(convId);
231
+ const isStreaming = this._convIsStreaming(convId);
232
232
  if (!isStreaming && window.switchView) {
233
233
  window.switchView('chat');
234
234
  }
@@ -242,7 +242,7 @@ class AgentGUIClient {
242
242
  const view = e.detail.view;
243
243
  if (view === 'chat') {
244
244
  const convId = this.state.currentConversation?.id;
245
- const isStreaming = convId && this.state.streamingConversations.has(convId);
245
+ const isStreaming = this._convIsStreaming(convId);
246
246
  if (isStreaming) {
247
247
  this.disableControls();
248
248
  } else {
@@ -252,6 +252,25 @@ class AgentGUIClient {
252
252
  });
253
253
  }
254
254
 
255
+ // Authoritative streaming check: conv machine is source of truth, Map is fallback cache
256
+ _convIsStreaming(convId) {
257
+ if (!convId) return false;
258
+ if (typeof convMachineAPI !== 'undefined') return convMachineAPI.isStreaming(convId);
259
+ return this.state.streamingConversations.has(convId);
260
+ }
261
+
262
+ // Mark conversation as streaming in both machine and cache Map
263
+ _setConvStreaming(convId, streaming, sessionId, agentId) {
264
+ if (!convId) return;
265
+ if (streaming) {
266
+ this.state.streamingConversations.set(convId, true);
267
+ if (typeof convMachineAPI !== 'undefined') convMachineAPI.send(convId, { type: 'STREAM_START', sessionId, agentId });
268
+ } else {
269
+ this.state.streamingConversations.delete(convId);
270
+ if (typeof convMachineAPI !== 'undefined') convMachineAPI.send(convId, { type: 'COMPLETE' });
271
+ }
272
+ }
273
+
255
274
  /**
256
275
  * Setup renderer event listeners
257
276
  */
@@ -511,7 +530,7 @@ class AgentGUIClient {
511
530
  if (this.ui.injectButton) {
512
531
  this.ui.injectButton.addEventListener('click', async () => {
513
532
  if (!this.state.currentConversation) return;
514
- const isStreaming = this.state.streamingConversations.has(this.state.currentConversation.id);
533
+ const isStreaming = this._convIsStreaming(this.state.currentConversation.id);
515
534
 
516
535
  if (isStreaming) {
517
536
  const message = this.ui.messageInput?.value || '';
@@ -810,8 +829,7 @@ class AgentGUIClient {
810
829
  // just track the state but do not modify the DOM or start polling
811
830
  if (this.state.currentConversation?.id !== data.conversationId) {
812
831
  console.log('Streaming started for non-active conversation:', data.conversationId);
813
- this.state.streamingConversations.set(data.conversationId, true);
814
- if (typeof convMachineAPI !== 'undefined') convMachineAPI.send(data.conversationId, { type: 'STREAM_START', sessionId: data.sessionId, agentId: data.agentId });
832
+ this._setConvStreaming(data.conversationId, true, data.sessionId, data.agentId);
815
833
  console.log('[SYNC] streaming_start - non-active conv:', { convId: data.conversationId, sessionId: data.sessionId, streamingCount: this.state.streamingConversations.size });
816
834
  this.updateBusyPromptArea(data.conversationId);
817
835
  this.emit('streaming:start', data);
@@ -826,8 +844,7 @@ class AgentGUIClient {
826
844
  return;
827
845
  }
828
846
 
829
- this.state.streamingConversations.set(data.conversationId, true);
830
- if (typeof convMachineAPI !== 'undefined') convMachineAPI.send(data.conversationId, { type: 'STREAM_START', sessionId: data.sessionId, agentId: data.agentId });
847
+ this._setConvStreaming(data.conversationId, true, data.sessionId, data.agentId);
831
848
  this.updateBusyPromptArea(data.conversationId);
832
849
  this.state.currentSession = {
833
850
  id: data.sessionId,
@@ -1125,13 +1142,13 @@ class AgentGUIClient {
1125
1142
  // If this event is for a conversation we are NOT currently viewing, just track state
1126
1143
  if (conversationId && this.state.currentConversation?.id !== conversationId) {
1127
1144
  console.log('Streaming error for non-active conversation:', conversationId);
1128
- this.state.streamingConversations.delete(conversationId);
1145
+ this._setConvStreaming(conversationId, false);
1129
1146
  this.updateBusyPromptArea(conversationId);
1130
1147
  this.emit('streaming:error', data);
1131
1148
  return;
1132
1149
  }
1133
1150
 
1134
- this.state.streamingConversations.delete(conversationId);
1151
+ this._setConvStreaming(conversationId, false);
1135
1152
  this.updateBusyPromptArea(conversationId);
1136
1153
 
1137
1154
  // Clear queue indicator on error
@@ -1197,16 +1214,14 @@ class AgentGUIClient {
1197
1214
 
1198
1215
  if (conversationId && this.state.currentConversation?.id !== conversationId) {
1199
1216
  console.log('Streaming completed for non-active conversation:', conversationId);
1200
- this.state.streamingConversations.delete(conversationId);
1201
- if (typeof convMachineAPI !== 'undefined') convMachineAPI.send(conversationId, { type: 'COMPLETE' });
1217
+ this._setConvStreaming(conversationId, false);
1202
1218
  console.log('[SYNC] streaming_complete - non-active conv:', { convId: conversationId, streamingCount: this.state.streamingConversations.size });
1203
1219
  this.updateBusyPromptArea(conversationId);
1204
1220
  this.emit('streaming:complete', data);
1205
1221
  return;
1206
1222
  }
1207
1223
 
1208
- this.state.streamingConversations.delete(conversationId);
1209
- if (typeof convMachineAPI !== 'undefined') convMachineAPI.send(conversationId, { type: 'COMPLETE' });
1224
+ this._setConvStreaming(conversationId, false);
1210
1225
  console.log('[SYNC] streaming_complete - active conv:', { convId: conversationId, streamingCount: this.state.streamingConversations.size, interrupted: data.interrupted });
1211
1226
  this.updateBusyPromptArea(conversationId);
1212
1227
 
@@ -1302,7 +1317,7 @@ class AgentGUIClient {
1302
1317
  this.state.currentConversation.messageCount = (this.state.currentConversation.messageCount || 0) + 1;
1303
1318
  }
1304
1319
 
1305
- if (data.message.role === 'assistant' && this.state.streamingConversations.has(data.conversationId)) {
1320
+ if (data.message.role === 'assistant' && this._convIsStreaming(data.conversationId)) {
1306
1321
  this.emit('message:created', data);
1307
1322
  return;
1308
1323
  }
@@ -1442,7 +1457,7 @@ class AgentGUIClient {
1442
1457
 
1443
1458
  handleRateLimitHit(data) {
1444
1459
  if (data.conversationId !== this.state.currentConversation?.id) return;
1445
- this.state.streamingConversations.delete(data.conversationId);
1460
+ this._setConvStreaming(data.conversationId, false);
1446
1461
 
1447
1462
  this.enableControls();
1448
1463
 
@@ -1683,8 +1698,8 @@ class AgentGUIClient {
1683
1698
 
1684
1699
  const pendingId = 'pending-' + Date.now() + '-' + Math.random().toString(36).substr(2, 6);
1685
1700
 
1686
- // Check if streaming - only show optimistic message if NOT queuing
1687
- const isStreaming = this.state.currentConversation && this.state.streamingConversations.has(this.state.currentConversation.id);
1701
+ // Conv machine is authoritative: check machine state for optimistic message gating
1702
+ const isStreaming = this._convIsStreaming(this.state.currentConversation?.id);
1688
1703
  if (!isStreaming) {
1689
1704
  this._showOptimisticMessage(pendingId, savedPrompt);
1690
1705
  }
@@ -1705,7 +1720,7 @@ class AgentGUIClient {
1705
1720
  }
1706
1721
 
1707
1722
  if (conv?.id) {
1708
- const isNewConversation = !conv.messageCount && !this.state.streamingConversations.has(conv.id);
1723
+ const isNewConversation = !conv.messageCount && !this._convIsStreaming(conv.id);
1709
1724
  const agentId = (isNewConversation ? this.getCurrentAgent() : null) || conv?.agentType || this.getCurrentAgent();
1710
1725
  const subAgent = this.getEffectiveSubAgent() || conv?.subAgent || null;
1711
1726
  const model = this.ui.modelSelector?.value || null;
@@ -1941,7 +1956,7 @@ class AgentGUIClient {
1941
1956
  // Sync-to-display debugging
1942
1957
  getSyncState: () => ({
1943
1958
  currentConversation: self.state.currentConversation,
1944
- isStreaming: self.state.currentConversation && self.state.streamingConversations.has(self.state.currentConversation.id),
1959
+ isStreaming: self._convIsStreaming(self.state.currentConversation?.id),
1945
1960
  streamingConversations: Array.from(self.state.streamingConversations),
1946
1961
  convMachineStates: typeof convMachineAPI !== 'undefined' ? Object.fromEntries([...window.__convMachines].map(([k, a]) => [k, a.getSnapshot().value])) : {},
1947
1962
  wsConnectionState: self.wsManager?._wsActor?.getSnapshot().value || 'unknown',
@@ -2561,7 +2576,7 @@ class AgentGUIClient {
2561
2576
  if (!convId) return;
2562
2577
  const outputEl = document.getElementById('output');
2563
2578
  if (!outputEl || !outputEl.firstChild) return;
2564
- if (this.state.streamingConversations.has(convId)) return;
2579
+ if (this._convIsStreaming(convId)) return;
2565
2580
 
2566
2581
  this.saveScrollPosition(convId);
2567
2582
  const clone = outputEl.cloneNode(true);
@@ -2661,7 +2676,7 @@ class AgentGUIClient {
2661
2676
 
2662
2677
  var prevId = this.state.currentConversation?.id;
2663
2678
  if (prevId && prevId !== conversationId) {
2664
- if (this.wsManager.isConnected && !this.state.streamingConversations.has(prevId)) {
2679
+ if (this.wsManager.isConnected && !this._convIsStreaming(prevId)) {
2665
2680
  this.wsManager.sendMessage({ type: 'unsubscribe', conversationId: prevId });
2666
2681
  }
2667
2682
  this.state.currentSession = null;
@@ -2689,7 +2704,7 @@ class AgentGUIClient {
2689
2704
  window.ConversationState?.selectConversation(conversationId, 'dom_cache_load', 1);
2690
2705
  this.state.currentConversation = cached.conversation;
2691
2706
  window.dispatchEvent(new CustomEvent('conversation-changed', { detail: { conversationId, conversation: cached.conversation } }));
2692
- const cachedHasActivity = cached.conversation.messageCount > 0 || this.state.streamingConversations.has(conversationId);
2707
+ const cachedHasActivity = cached.conversation.messageCount > 0 || this._convIsStreaming(conversationId);
2693
2708
  this.applyAgentAndModelSelection(cached.conversation, cachedHasActivity);
2694
2709
  this.conversationCache.delete(conversationId);
2695
2710
  this.syncPromptState(conversationId);
@@ -2733,7 +2748,7 @@ class AgentGUIClient {
2733
2748
  window.ConversationState?.selectConversation(conversationId, 'server_load', 1);
2734
2749
  this.state.currentConversation = conversation;
2735
2750
  window.dispatchEvent(new CustomEvent('conversation-changed', { detail: { conversationId, conversation } }));
2736
- const hasActivity = (allMessages && allMessages.length > 0) || isActivelyStreaming || latestSession || this.state.streamingConversations.has(conversationId);
2751
+ const hasActivity = (allMessages && allMessages.length > 0) || isActivelyStreaming || latestSession || this._convIsStreaming(conversationId);
2737
2752
  this.applyAgentAndModelSelection(conversation, hasActivity);
2738
2753
 
2739
2754
  const chunks = (rawChunks || []).map(chunk => ({
@@ -2756,15 +2771,15 @@ class AgentGUIClient {
2756
2771
  const userMessages = (allMessages || []).filter(m => m.role === 'user' && !queuedMessageIds.has(m.id));
2757
2772
  const hasMoreChunks = totalChunks && chunks.length < totalChunks;
2758
2773
 
2759
- const clientKnowsStreaming = this.state.streamingConversations.has(conversationId);
2774
+ const clientKnowsStreaming = this._convIsStreaming(conversationId);
2760
2775
  const shouldResumeStreaming = latestSession &&
2761
2776
  (latestSession.status === 'active' || latestSession.status === 'pending');
2762
2777
 
2763
2778
  if (shouldResumeStreaming) {
2764
- this.state.streamingConversations.set(conversationId, true);
2779
+ this._setConvStreaming(conversationId, true, latestSession?.id, null);
2765
2780
  window.dispatchEvent(new CustomEvent('ws-message', { detail: { type: 'streaming_start', conversationId, sessionId: latestSession?.id } }));
2766
2781
  } else {
2767
- this.state.streamingConversations.delete(conversationId);
2782
+ this._setConvStreaming(conversationId, false);
2768
2783
  window.dispatchEvent(new CustomEvent('ws-message', { detail: { type: 'streaming_complete', conversationId } }));
2769
2784
  }
2770
2785
 
@@ -2940,7 +2955,7 @@ class AgentGUIClient {
2940
2955
  }
2941
2956
 
2942
2957
  if (shouldResumeStreaming && latestSession) {
2943
- this.state.streamingConversations.set(conversationId, true);
2958
+ this._setConvStreaming(conversationId, true, latestSession.id, null);
2944
2959
  this.state.currentSession = {
2945
2960
  id: latestSession.id,
2946
2961
  conversationId: conversationId,
@@ -3009,7 +3024,7 @@ class AgentGUIClient {
3009
3024
 
3010
3025
  updateBusyPromptArea(conversationId) {
3011
3026
  if (this.state.currentConversation?.id !== conversationId) return;
3012
- const isStreaming = this.state.streamingConversations.has(conversationId);
3027
+ const isStreaming = this._convIsStreaming(conversationId);
3013
3028
  const isConnected = this.wsManager?.isConnected;
3014
3029
 
3015
3030
  const injectBtn = document.getElementById('injectBtn');
@@ -1,9 +1,9 @@
1
1
  // Per-conversation UI state machine using XState v5
2
2
  // Tracks IDLE -> STREAMING -> QUEUED states per conversation
3
3
 
4
- const { createMachine, createActor, assign } = XState;
4
+ const { createMachine: cmCreateMachine, createActor: cmCreateActor, assign: cmAssign } = XState;
5
5
 
6
- const convMachine = createMachine({
6
+ const convMachine = cmCreateMachine({
7
7
  id: 'conv-ui',
8
8
  initial: 'idle',
9
9
  context: {
@@ -13,11 +13,11 @@ const convMachine = createMachine({
13
13
  },
14
14
  states: {
15
15
  idle: {
16
- entry: assign({ sessionId: null, agentId: null, queueLength: 0 }),
16
+ entry: cmAssign({ sessionId: null, agentId: null, queueLength: 0 }),
17
17
  on: {
18
18
  STREAM_START: {
19
19
  target: 'streaming',
20
- actions: assign(({ event }) => ({
20
+ actions: cmAssign(({ event }) => ({
21
21
  sessionId: event.sessionId || null,
22
22
  agentId: event.agentId || null,
23
23
  })),
@@ -35,10 +35,10 @@ const convMachine = createMachine({
35
35
  ],
36
36
  ERROR: 'idle',
37
37
  QUEUE_UPDATE: {
38
- actions: assign(({ event }) => ({ queueLength: event.queueLength || 0 })),
38
+ actions: cmAssign(({ event }) => ({ queueLength: event.queueLength || 0 })),
39
39
  },
40
40
  STREAM_START: {
41
- actions: assign(({ event }) => ({
41
+ actions: cmAssign(({ event }) => ({
42
42
  sessionId: event.sessionId || null,
43
43
  agentId: event.agentId || null,
44
44
  })),
@@ -49,13 +49,13 @@ const convMachine = createMachine({
49
49
  on: {
50
50
  STREAM_START: {
51
51
  target: 'streaming',
52
- actions: assign(({ event }) => ({
52
+ actions: cmAssign(({ event }) => ({
53
53
  sessionId: event.sessionId || null,
54
54
  agentId: event.agentId || null,
55
55
  })),
56
56
  },
57
57
  QUEUE_UPDATE: {
58
- actions: assign(({ event }) => ({ queueLength: event.queueLength || 0 })),
58
+ actions: cmAssign(({ event }) => ({ queueLength: event.queueLength || 0 })),
59
59
  guard: ({ event }) => event.queueLength > 0,
60
60
  },
61
61
  QUEUE_EMPTY: 'idle',
@@ -70,7 +70,7 @@ const convMachines = new Map();
70
70
 
71
71
  function getOrCreate(convId) {
72
72
  if (convMachines.has(convId)) return convMachines.get(convId);
73
- const actor = createActor(convMachine);
73
+ const actor = cmCreateActor(convMachine);
74
74
  actor.start();
75
75
  convMachines.set(convId, actor);
76
76
  return actor;
@@ -91,6 +91,17 @@ function isStreaming(convId) {
91
91
  return getState(convId) === 'streaming';
92
92
  }
93
93
 
94
+ // isActive: streaming OR queued — use this to gate optimistic messages
95
+ function isActive(convId) {
96
+ const s = getState(convId);
97
+ return s === 'streaming' || s === 'queued';
98
+ }
99
+
100
+ function getQueueLength(convId) {
101
+ const actor = convMachines.get(convId);
102
+ return actor ? actor.getSnapshot().context.queueLength : 0;
103
+ }
104
+
94
105
  function remove(convId) {
95
106
  const actor = convMachines.get(convId);
96
107
  if (actor) { actor.stop(); convMachines.delete(convId); }
@@ -98,5 +109,5 @@ function remove(convId) {
98
109
 
99
110
  if (typeof window !== 'undefined') {
100
111
  window.__convMachines = convMachines;
101
- window.convMachineAPI = { getOrCreate, send, getState, isStreaming, remove };
112
+ window.convMachineAPI = { getOrCreate, send, getState, isStreaming, isActive, getQueueLength, remove };
102
113
  }
@@ -18,6 +18,8 @@ class WebSocketManager {
18
18
  this.ws = null;
19
19
  this._isConnected = false;
20
20
  this._isConnecting = false;
21
+ this._isManuallyDisconnected = false;
22
+ this._reconnectCount = 0;
21
23
  this.isManuallyDisconnected = false;
22
24
  this.reconnectCount = 0;
23
25
  this.reconnectTimer = null;
@@ -92,6 +94,22 @@ class WebSocketManager {
92
94
  }
93
95
  set connectionState(v) { this._connectionState = v; }
94
96
 
97
+ // Machine context is authoritative for these flags
98
+ get isManuallyDisconnected() {
99
+ if (this._wsActor) return !!this._wsActor.getSnapshot().context.manualDisconnect;
100
+ return this._isManuallyDisconnected;
101
+ }
102
+ set isManuallyDisconnected(v) {
103
+ this._isManuallyDisconnected = v;
104
+ if (this._wsActor && v) this._wsActor.send({ type: 'MANUAL_DISCONNECT' });
105
+ }
106
+
107
+ get reconnectCount() {
108
+ if (this._wsActor) return this._wsActor.getSnapshot().context.reconnectCount;
109
+ return this._reconnectCount;
110
+ }
111
+ set reconnectCount(v) { this._reconnectCount = v; }
112
+
95
113
  getWebSocketURL() {
96
114
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
97
115
  const baseURL = window.__BASE_URL || '/gm';
@@ -555,7 +573,6 @@ class WebSocketManager {
555
573
 
556
574
  disconnect() {
557
575
  this.isManuallyDisconnected = true;
558
- if (this._wsActor) this._wsActor.send({ type: 'MANUAL_DISCONNECT' });
559
576
  this.reconnectCount = 0;
560
577
  this.stopHeartbeat();
561
578
  if (this.reconnectTimer) {