agentgui 1.0.695 → 1.0.696

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
@@ -18,6 +18,8 @@ server.js HTTP server + WebSocket + all API routes (raw http.create
18
18
  database.js SQLite setup (WAL mode), schema, query functions
19
19
  lib/claude-runner.js Agent framework - spawns CLI processes, parses stream-json output
20
20
  lib/acp-manager.js ACP tool lifecycle - auto-starts opencode/kilo HTTP servers, restart on crash
21
+ lib/execution-machine.js XState v5 machine per conversation: idle/streaming/draining/rate_limited states
22
+ lib/acp-server-machine.js XState v5 machine per ACP tool: stopped/starting/running/crashed/restarting states
21
23
  lib/ws-protocol.js WebSocket RPC router (WsRouter class)
22
24
  lib/ws-optimizer.js Per-client priority queue for WS event batching
23
25
  lib/ws-handlers-conv.js Conversation/message/queue RPC handlers (~70 methods total)
@@ -41,9 +43,28 @@ static/js/syntax-highlighter.js Code syntax highlighting
41
43
  static/js/voice.js Voice input/output
42
44
  static/js/features.js View toggle, drag-drop upload, model progress indicator
43
45
  static/js/tools-manager.js Tool install/update UI
46
+ static/js/ws-machine.js XState v5 WS connection machine: disconnected/connecting/connected/reconnecting
47
+ static/js/conv-machine.js XState v5 per-conversation UI machine: idle/streaming/queued
48
+ static/lib/xstate.umd.min.js XState v5 browser bundle (UMD, served locally from node_modules)
44
49
  static/templates/ 31 HTML template fragments for event rendering
45
50
  ```
46
51
 
52
+ ## XState State Machines
53
+
54
+ XState v5 machines provide formal state tracking alongside (not replacing) the existing Map-based execution tracking. The machines are authoritative for state validation; the Maps remain as the operational data store.
55
+
56
+ **Server machines** (ESM, `lib/`):
57
+ - `execution-machine.js`: One actor per conversation. States: idle → streaming → draining (queue drain) → streaming → idle. Also rate_limited state. `execMachine.send(convId, event)` API. `conv.get` and `conv.full` WS responses include `executionState` field.
58
+ - `acp-server-machine.js`: One actor per ACP tool (opencode/kilo/codex). States: stopped → starting → running ↔ crashed → restarting. Used by `acp-sdk-manager.js` to track health and drive restart backoff.
59
+
60
+ **Client machines** (browser UMD, `static/js/`):
61
+ - `ws-machine.js`: Wraps WebSocketManager. States: disconnected/connecting/connected/reconnecting. Actor accessible as `wsManager._wsActor`. State readable via `wsManager.connectionState`.
62
+ - `conv-machine.js`: One actor per conversation. States: idle/streaming/queued. API exposed as `window.convMachineAPI`. All actors in `window.__convMachines` Map for debug.
63
+
64
+ **XState browser loading**: UMD bundle at `static/lib/xstate.umd.min.js` (copied from `node_modules/xstate/dist/xstate.umd.min.js` during npm install). Loaded as `defer` script before ws-machine.js and conv-machine.js. Exposes `window.XState` global.
65
+
66
+ **Integration pattern**: Machines augment existing state (Maps, Set, booleans). They receive events at the same points the Maps are mutated. Machine state does NOT replace Map checks in server logic — both run in parallel, machine is the formal model.
67
+
47
68
  ## Key Details
48
69
 
49
70
  - Express is used only for file upload (`/api/upload/:conversationId`) and fsbrowse file browser (`/files/:conversationId`). All other routes use raw `http.createServer` with manual routing.
@@ -3,7 +3,8 @@ import path from 'path';
3
3
  import os from 'os';
4
4
  import fs from 'fs';
5
5
  import { fileURLToPath } from 'url';
6
- import pRetry, { AbortError } from 'p-retry';
6
+ import { AbortError } from 'p-retry';
7
+ import * as acpMachine from './acp-server-machine.js';
7
8
 
8
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
10
  const projectRoot = path.resolve(__dirname, '..');
@@ -17,11 +18,10 @@ const ACP_TOOLS = [
17
18
 
18
19
  const HEALTH_INTERVAL_MS = 30000;
19
20
  const STARTUP_GRACE_MS = 5000;
20
- const MAX_RESTARTS = 10;
21
- const RESTART_WINDOW_MS = 300000;
22
21
  const IDLE_TIMEOUT_MS = 120000;
23
22
 
24
23
  const processes = new Map();
24
+ const idleTimers = new Map();
25
25
  let healthTimer = null;
26
26
  let shuttingDown = false;
27
27
 
@@ -34,172 +34,174 @@ function resolveCommand(tool) {
34
34
  return { bin: tool.cmd, args: tool.args };
35
35
  }
36
36
 
37
+ function resetIdleTimer(toolId) {
38
+ acpMachine.send(toolId, { type: 'TOUCH' });
39
+ const existing = idleTimers.get(toolId);
40
+ if (existing) clearTimeout(existing);
41
+ idleTimers.set(toolId, setTimeout(() => {
42
+ acpMachine.send(toolId, { type: 'IDLE_TIMEOUT' });
43
+ stopTool(toolId);
44
+ }, IDLE_TIMEOUT_MS));
45
+ }
46
+
47
+ function clearIdleTimer(toolId) {
48
+ const t = idleTimers.get(toolId);
49
+ if (t) { clearTimeout(t); idleTimers.delete(toolId); }
50
+ }
51
+
52
+ function stopTool(toolId) {
53
+ const proc = processes.get(toolId);
54
+ if (!proc) return;
55
+ log(toolId + ' stopping');
56
+ clearIdleTimer(toolId);
57
+ try { proc.kill('SIGTERM'); } catch (_) {}
58
+ setTimeout(() => { try { proc.kill('SIGKILL'); } catch (_) {} }, 5000);
59
+ processes.delete(toolId);
60
+ acpMachine.send(toolId, { type: 'STOPPED' });
61
+ }
62
+
37
63
  function startProcess(tool) {
38
64
  if (shuttingDown) return null;
39
- const existing = processes.get(tool.id);
40
- if (existing?.process && !existing.process.killed) return existing;
41
-
42
65
  const resolved = resolveCommand(tool);
43
- const entry = {
44
- id: tool.id,
45
- port: tool.port,
46
- startedAt: Date.now(),
47
- lastUsed: Date.now(),
48
- lastHealthCheck: 0,
49
- healthy: false,
50
- process: null,
51
- pid: null,
52
- restarts: [],
53
- idleTimer: null,
54
- providerInfo: null,
55
- _stopping: false
56
- };
57
-
66
+ let proc;
58
67
  try {
59
- entry.process = spawn(resolved.bin, resolved.args, {
68
+ proc = spawn(resolved.bin, resolved.args, {
60
69
  stdio: ['ignore', 'pipe', 'pipe'],
61
- detached: false
70
+ detached: false,
62
71
  });
63
- entry.pid = entry.process.pid;
64
-
65
- entry.process.on('close', (code) => {
66
- entry.healthy = false;
67
- if (shuttingDown || entry._stopping) return;
68
- log(tool.id + ' exited code ' + code);
69
- const window = Date.now() - RESTART_WINDOW_MS;
70
- entry.restarts = entry.restarts.filter(t => t > window);
71
- if (entry.restarts.length >= MAX_RESTARTS) { log(tool.id + ' max restarts reached'); return; }
72
- const attempt = entry.restarts.length;
73
- entry.restarts.push(Date.now());
74
- const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
75
- setTimeout(() => { if (!shuttingDown) startProcess(tool); }, delay);
76
- });
77
-
78
- processes.set(tool.id, entry);
79
- log(tool.id + ' started port ' + tool.port + ' pid ' + entry.pid);
80
- setTimeout(() => checkHealth(tool.id), STARTUP_GRACE_MS);
81
- resetIdleTimer(tool.id);
82
72
  } catch (err) {
83
73
  log(tool.id + ' spawn failed: ' + err.message);
74
+ acpMachine.send(tool.id, { type: 'CRASHED' });
75
+ scheduleRestart(tool);
76
+ return null;
84
77
  }
85
78
 
86
- return entry;
87
- }
79
+ processes.set(tool.id, proc);
80
+ acpMachine.send(tool.id, { type: 'START' });
81
+ acpMachine.send(tool.id, { type: 'STARTED', process: proc, pid: proc.pid });
88
82
 
89
- function resetIdleTimer(toolId) {
90
- const entry = processes.get(toolId);
91
- if (!entry) return;
92
- entry.lastUsed = Date.now();
93
- if (entry.idleTimer) clearTimeout(entry.idleTimer);
94
- entry.idleTimer = setTimeout(() => stopTool(toolId), IDLE_TIMEOUT_MS);
95
- }
83
+ log(tool.id + ' started port ' + tool.port + ' pid ' + proc.pid);
96
84
 
97
- function stopTool(toolId) {
98
- const entry = processes.get(toolId);
99
- if (!entry) return;
100
- log(toolId + ' idle, stopping to free RAM');
101
- entry._stopping = true;
102
- if (entry.idleTimer) clearTimeout(entry.idleTimer);
103
- try { entry.process.kill('SIGTERM'); } catch (_) {}
104
- setTimeout(() => { try { entry.process.kill('SIGKILL'); } catch (_) {} }, 5000);
105
- processes.delete(toolId);
85
+ proc.on('close', (code) => {
86
+ processes.delete(tool.id);
87
+ if (shuttingDown) return;
88
+ log(tool.id + ' exited code ' + code);
89
+ acpMachine.send(tool.id, { type: 'CRASHED' });
90
+ const snap = acpMachine.snapshot(tool.id);
91
+ if (snap?.value === 'stopped') { log(tool.id + ' max restarts reached'); return; }
92
+ scheduleRestart(tool);
93
+ });
94
+
95
+ setTimeout(() => checkHealth(tool.id, tool.port), STARTUP_GRACE_MS);
96
+ resetIdleTimer(tool.id);
97
+ return proc;
106
98
  }
107
99
 
108
- async function checkHealth(toolId) {
109
- const entry = processes.get(toolId);
110
- if (!entry || shuttingDown) return;
100
+ function scheduleRestart(tool) {
101
+ if (shuttingDown) return;
102
+ const delay = acpMachine.getBackoffDelay(tool.id);
103
+ setTimeout(() => {
104
+ if (!shuttingDown) startProcess(tool);
105
+ }, delay);
106
+ }
111
107
 
108
+ async function checkHealth(toolId, port) {
109
+ if (shuttingDown) return;
110
+ const snap = acpMachine.snapshot(toolId);
111
+ if (!snap || snap.value === 'stopped' || snap.value === 'idle_stopping') return;
112
+ const p = port || ACP_TOOLS.find(t => t.id === toolId)?.port;
113
+ if (!p) return;
112
114
  try {
113
- const res = await fetch('http://127.0.0.1:' + entry.port + '/provider', {
114
- signal: AbortSignal.timeout(3000)
115
+ const res = await fetch('http://127.0.0.1:' + p + '/provider', {
116
+ signal: AbortSignal.timeout(3000),
115
117
  });
116
- entry.healthy = res.ok;
117
118
  if (res.ok) {
118
- entry.providerInfo = await res.json();
119
+ const providerInfo = await res.json();
120
+ acpMachine.send(toolId, { type: 'HEALTHY', providerInfo });
121
+ } else {
122
+ acpMachine.send(toolId, { type: 'UNHEALTHY' });
119
123
  }
120
124
  } catch (_) {
121
- entry.healthy = false;
125
+ acpMachine.send(toolId, { type: 'UNHEALTHY' });
122
126
  }
123
- entry.lastHealthCheck = Date.now();
124
127
  }
125
128
 
126
129
  export async function ensureRunning(agentId) {
127
130
  const tool = ACP_TOOLS.find(t => t.id === agentId);
128
131
  if (!tool) return null;
129
- let entry = processes.get(agentId);
130
- if (entry?.healthy) { resetIdleTimer(agentId); return entry.port; }
131
- if (!entry || entry._stopping) {
132
- entry = startProcess(tool);
133
- if (!entry) return null;
132
+ if (acpMachine.isHealthy(agentId)) { resetIdleTimer(agentId); return tool.port; }
133
+ const snap = acpMachine.snapshot(agentId);
134
+ if (!snap || snap.value === 'stopped' || snap.value === 'crashed') {
135
+ startProcess(tool);
134
136
  }
135
- try {
136
- await pRetry(async () => {
137
- if (shuttingDown) throw new AbortError('shutting down');
138
- await checkHealth(agentId);
139
- if (!processes.get(agentId)?.healthy) throw new Error('not healthy yet');
140
- }, { retries: 20, minTimeout: 500, maxTimeout: 500, factor: 1 });
141
- resetIdleTimer(agentId);
142
- return tool.port;
143
- } catch {
144
- return null;
137
+ for (let i = 0; i < 20; i++) {
138
+ await new Promise(r => setTimeout(r, 500));
139
+ if (shuttingDown) return null;
140
+ if (acpMachine.isHealthy(agentId)) { resetIdleTimer(agentId); return tool.port; }
145
141
  }
142
+ return null;
146
143
  }
147
144
 
148
- export function touch(agentId) {
149
- const entry = processes.get(agentId);
150
- if (entry) resetIdleTimer(agentId);
151
- }
145
+ export function touch(agentId) { resetIdleTimer(agentId); }
152
146
 
153
147
  export async function startAll() {
154
148
  log('ACP tools available (on-demand start)');
155
149
  healthTimer = setInterval(() => {
156
- for (const [id] of processes) checkHealth(id);
150
+ for (const tool of ACP_TOOLS) {
151
+ const snap = acpMachine.snapshot(tool.id);
152
+ if (snap && (snap.value === 'running' || snap.value === 'starting')) {
153
+ checkHealth(tool.id, tool.port);
154
+ }
155
+ }
157
156
  }, HEALTH_INTERVAL_MS);
158
157
  }
159
158
 
160
159
  export async function stopAll() {
161
160
  shuttingDown = true;
162
161
  if (healthTimer) { clearInterval(healthTimer); healthTimer = null; }
162
+ for (const toolId of idleTimers.keys()) clearIdleTimer(toolId);
163
163
  const kills = [];
164
- for (const [id, entry] of processes) {
165
- if (entry.idleTimer) clearTimeout(entry.idleTimer);
166
- log('stopping ' + id + ' pid ' + entry.pid);
164
+ for (const [id, proc] of processes) {
165
+ log('stopping ' + id + ' pid ' + proc.pid);
167
166
  kills.push(new Promise(resolve => {
168
- const t = setTimeout(() => { try { entry.process.kill('SIGKILL'); } catch (_) {} resolve(); }, 5000);
169
- entry.process.on('close', () => { clearTimeout(t); resolve(); });
170
- try { entry.process.kill('SIGTERM'); } catch (_) {}
167
+ const t = setTimeout(() => { try { proc.kill('SIGKILL'); } catch (_) {} resolve(); }, 5000);
168
+ proc.on('close', () => { clearTimeout(t); resolve(); });
169
+ try { proc.kill('SIGTERM'); } catch (_) {}
171
170
  }));
172
171
  }
173
172
  await Promise.all(kills);
174
173
  processes.clear();
174
+ acpMachine.stopAll();
175
175
  log('all stopped');
176
176
  }
177
177
 
178
178
  export function getStatus() {
179
179
  return ACP_TOOLS.map(tool => {
180
- const e = processes.get(tool.id);
180
+ const snap = acpMachine.snapshot(tool.id);
181
+ const ctx = snap?.context || {};
181
182
  return {
182
183
  id: tool.id,
183
184
  port: tool.port,
184
- running: !!e,
185
- healthy: e?.healthy || false,
186
- pid: e?.pid,
187
- uptime: e ? Date.now() - e.startedAt : 0,
188
- restartCount: e?.restarts.length || 0,
189
- idleMs: e ? Date.now() - e.lastUsed : 0,
190
- providerInfo: e?.providerInfo || null,
185
+ running: snap?.value === 'running' || snap?.value === 'starting',
186
+ healthy: ctx.healthy || false,
187
+ pid: ctx.pid,
188
+ uptime: ctx.startedAt ? Date.now() - ctx.startedAt : 0,
189
+ restartCount: ctx.restarts?.length || 0,
190
+ idleMs: ctx.lastUsed ? Date.now() - ctx.lastUsed : 0,
191
+ providerInfo: ctx.providerInfo || null,
191
192
  };
192
193
  });
193
194
  }
194
195
 
195
196
  export function getPort(agentId) {
196
- const e = processes.get(agentId);
197
- return e?.healthy ? e.port : null;
197
+ return acpMachine.isHealthy(agentId) ? (ACP_TOOLS.find(t => t.id === agentId)?.port || null) : null;
198
198
  }
199
199
 
200
200
  export function getRunningPorts() {
201
201
  const ports = {};
202
- for (const [id, e] of processes) if (e.healthy) ports[id] = e.port;
202
+ for (const tool of ACP_TOOLS) {
203
+ if (acpMachine.isHealthy(tool.id)) ports[tool.id] = tool.port;
204
+ }
203
205
  return ports;
204
206
  }
205
207
 
@@ -216,7 +218,7 @@ export async function queryModels(agentId) {
216
218
  if (!port) return [];
217
219
  try {
218
220
  const res = await fetch('http://127.0.0.1:' + port + '/models', {
219
- signal: AbortSignal.timeout(3000)
221
+ signal: AbortSignal.timeout(3000),
220
222
  });
221
223
  if (!res.ok) return [];
222
224
  const data = await res.json();
@@ -225,6 +227,5 @@ export async function queryModels(agentId) {
225
227
  }
226
228
 
227
229
  export function isAvailable(agentId) {
228
- const tool = ACP_TOOLS.find(t => t.id === agentId);
229
- return !!tool;
230
+ return !!ACP_TOOLS.find(t => t.id === agentId);
230
231
  }
@@ -0,0 +1,166 @@
1
+ import { createMachine, createActor, assign } from 'xstate';
2
+
3
+ const MAX_RESTARTS = 10;
4
+ const RESTART_WINDOW_MS = 300000;
5
+
6
+ function calcBackoff(restarts) {
7
+ return Math.min(1000 * Math.pow(2, restarts.length), 30000);
8
+ }
9
+
10
+ function purgeOldRestarts(restarts) {
11
+ const window = Date.now() - RESTART_WINDOW_MS;
12
+ return restarts.filter(t => t > window);
13
+ }
14
+
15
+ const machine = createMachine({
16
+ id: 'acp-server',
17
+ initial: 'stopped',
18
+ context: {
19
+ process: null,
20
+ pid: null,
21
+ healthy: false,
22
+ restarts: [],
23
+ lastHealthCheck: 0,
24
+ providerInfo: null,
25
+ startedAt: null,
26
+ lastUsed: null,
27
+ idleDeadline: null,
28
+ },
29
+ states: {
30
+ stopped: {
31
+ entry: assign({ process: null, pid: null, healthy: false }),
32
+ on: {
33
+ START: 'starting',
34
+ },
35
+ },
36
+ starting: {
37
+ entry: assign(({ event }) => ({
38
+ process: event.process || null,
39
+ pid: event.pid || null,
40
+ startedAt: Date.now(),
41
+ lastUsed: Date.now(),
42
+ healthy: false,
43
+ })),
44
+ on: {
45
+ HEALTHY: {
46
+ target: 'running',
47
+ actions: assign(({ event }) => ({
48
+ healthy: true,
49
+ lastHealthCheck: Date.now(),
50
+ providerInfo: event.providerInfo || null,
51
+ })),
52
+ },
53
+ CRASHED: 'crashed',
54
+ STOP: 'idle_stopping',
55
+ },
56
+ },
57
+ running: {
58
+ entry: assign({ healthy: true }),
59
+ on: {
60
+ UNHEALTHY: {
61
+ actions: assign({ healthy: false, lastHealthCheck: Date.now() }),
62
+ },
63
+ HEALTHY: {
64
+ actions: assign(({ event }) => ({
65
+ healthy: true,
66
+ lastHealthCheck: Date.now(),
67
+ providerInfo: event.providerInfo || null,
68
+ })),
69
+ },
70
+ TOUCH: {
71
+ actions: assign({ lastUsed: Date.now() }),
72
+ },
73
+ CRASHED: 'crashed',
74
+ STOP: 'idle_stopping',
75
+ IDLE_TIMEOUT: 'idle_stopping',
76
+ },
77
+ },
78
+ crashed: {
79
+ entry: assign({ healthy: false }),
80
+ always: [
81
+ {
82
+ guard: ({ context }) => {
83
+ const recent = purgeOldRestarts(context.restarts);
84
+ return recent.length >= MAX_RESTARTS;
85
+ },
86
+ target: 'stopped',
87
+ },
88
+ { target: 'restarting' },
89
+ ],
90
+ },
91
+ restarting: {
92
+ entry: assign(({ context }) => ({
93
+ restarts: [...purgeOldRestarts(context.restarts), Date.now()],
94
+ process: null,
95
+ pid: null,
96
+ healthy: false,
97
+ })),
98
+ on: {
99
+ STARTED: {
100
+ target: 'starting',
101
+ actions: assign(({ event }) => ({
102
+ process: event.process,
103
+ pid: event.pid,
104
+ startedAt: Date.now(),
105
+ })),
106
+ },
107
+ STOP: 'stopped',
108
+ },
109
+ },
110
+ idle_stopping: {
111
+ entry: assign({ healthy: false }),
112
+ on: {
113
+ STOPPED: {
114
+ target: 'stopped',
115
+ actions: assign({ process: null, pid: null }),
116
+ },
117
+ },
118
+ },
119
+ },
120
+ });
121
+
122
+ const actors = new Map();
123
+
124
+ export function getOrCreate(toolId) {
125
+ if (actors.has(toolId)) return actors.get(toolId);
126
+ const actor = createActor(machine);
127
+ actor.start();
128
+ actors.set(toolId, actor);
129
+ return actor;
130
+ }
131
+
132
+ export function get(toolId) {
133
+ return actors.get(toolId) || null;
134
+ }
135
+
136
+ export function send(toolId, event) {
137
+ const actor = getOrCreate(toolId);
138
+ actor.send(event);
139
+ return actor.getSnapshot();
140
+ }
141
+
142
+ export function snapshot(toolId) {
143
+ const actor = actors.get(toolId);
144
+ return actor ? actor.getSnapshot() : null;
145
+ }
146
+
147
+ export function isRunning(toolId) {
148
+ const s = snapshot(toolId);
149
+ return s ? s.value === 'running' : false;
150
+ }
151
+
152
+ export function isHealthy(toolId) {
153
+ const s = snapshot(toolId);
154
+ return s ? (s.value === 'running' && s.context.healthy) : false;
155
+ }
156
+
157
+ export function getBackoffDelay(toolId) {
158
+ const s = snapshot(toolId);
159
+ if (!s) return 1000;
160
+ return calcBackoff(purgeOldRestarts(s.context.restarts));
161
+ }
162
+
163
+ export function stopAll() {
164
+ for (const [, actor] of actors) actor.stop();
165
+ actors.clear();
166
+ }
@@ -0,0 +1,147 @@
1
+ import { createMachine, createActor, assign } from 'xstate';
2
+
3
+ const machine = createMachine({
4
+ id: 'conv-execution',
5
+ initial: 'idle',
6
+ context: {
7
+ pid: null,
8
+ proc: null,
9
+ sessionId: null,
10
+ startTime: null,
11
+ lastActivity: null,
12
+ queue: [],
13
+ nextItem: null,
14
+ },
15
+ states: {
16
+ idle: {
17
+ entry: assign({ pid: null, proc: null, sessionId: null, nextItem: null }),
18
+ on: {
19
+ START: {
20
+ target: 'streaming',
21
+ actions: assign(({ event }) => ({
22
+ sessionId: event.sessionId,
23
+ startTime: Date.now(),
24
+ lastActivity: Date.now(),
25
+ pid: null,
26
+ proc: null,
27
+ })),
28
+ },
29
+ },
30
+ },
31
+ streaming: {
32
+ on: {
33
+ SET_PID: { actions: assign(({ event }) => ({ pid: event.pid, lastActivity: Date.now() })) },
34
+ SET_PROC: { actions: assign(({ event }) => ({ proc: event.proc, lastActivity: Date.now() })) },
35
+ ACTIVITY: { actions: assign({ lastActivity: Date.now() }) },
36
+ ENQUEUE: {
37
+ actions: assign(({ context, event }) => ({
38
+ queue: [...context.queue, event.item],
39
+ })),
40
+ },
41
+ COMPLETE: [
42
+ {
43
+ guard: ({ context }) => context.queue.length > 0,
44
+ target: 'draining',
45
+ },
46
+ { target: 'idle' },
47
+ ],
48
+ CANCEL: {
49
+ target: 'idle',
50
+ actions: assign({ queue: [] }),
51
+ },
52
+ RATE_LIMITED: {
53
+ target: 'rate_limited',
54
+ actions: assign(({ event }) => ({
55
+ rateLimitRetryAt: event.retryAt,
56
+ rateLimitCooldownMs: event.cooldownMs,
57
+ rateLimitRetryCount: event.retryCount,
58
+ rateLimitMessageId: event.messageId,
59
+ rateLimitContent: event.content,
60
+ rateLimitAgentId: event.agentId,
61
+ rateLimitModel: event.model,
62
+ rateLimitSubAgent: event.subAgent,
63
+ })),
64
+ },
65
+ },
66
+ },
67
+ rate_limited: {
68
+ on: {
69
+ RETRY: {
70
+ target: 'streaming',
71
+ actions: assign(({ event }) => ({
72
+ sessionId: event.sessionId,
73
+ startTime: Date.now(),
74
+ lastActivity: Date.now(),
75
+ pid: null,
76
+ proc: null,
77
+ rateLimitRetryAt: null,
78
+ })),
79
+ },
80
+ CANCEL: {
81
+ target: 'idle',
82
+ actions: assign({ queue: [] }),
83
+ },
84
+ },
85
+ },
86
+ draining: {
87
+ always: {
88
+ target: 'streaming',
89
+ actions: assign(({ context }) => {
90
+ const [next, ...rest] = context.queue;
91
+ return {
92
+ pid: null,
93
+ proc: null,
94
+ sessionId: null,
95
+ queue: rest,
96
+ nextItem: next,
97
+ };
98
+ }),
99
+ },
100
+ },
101
+ },
102
+ });
103
+
104
+ const actors = new Map();
105
+
106
+ export function getOrCreate(convId) {
107
+ if (actors.has(convId)) return actors.get(convId);
108
+ const actor = createActor(machine);
109
+ actor.start();
110
+ actors.set(convId, actor);
111
+ return actor;
112
+ }
113
+
114
+ export function get(convId) {
115
+ return actors.get(convId) || null;
116
+ }
117
+
118
+ export function remove(convId) {
119
+ const actor = actors.get(convId);
120
+ if (actor) { actor.stop(); actors.delete(convId); }
121
+ }
122
+
123
+ export function snapshot(convId) {
124
+ const actor = actors.get(convId);
125
+ return actor ? actor.getSnapshot() : null;
126
+ }
127
+
128
+ export function isStreaming(convId) {
129
+ const s = snapshot(convId);
130
+ return s ? s.value === 'streaming' || s.value === 'rate_limited' : false;
131
+ }
132
+
133
+ export function getContext(convId) {
134
+ const s = snapshot(convId);
135
+ return s ? s.context : null;
136
+ }
137
+
138
+ export function send(convId, event) {
139
+ const actor = getOrCreate(convId);
140
+ actor.send(event);
141
+ return actor.getSnapshot();
142
+ }
143
+
144
+ export function stopAll() {
145
+ for (const [, actor] of actors) actor.stop();
146
+ actors.clear();
147
+ }