agentgui 1.0.694 → 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,6 +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 { AbortError } from 'p-retry';
7
+ import * as acpMachine from './acp-server-machine.js';
6
8
 
7
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
10
  const projectRoot = path.resolve(__dirname, '..');
@@ -16,11 +18,10 @@ const ACP_TOOLS = [
16
18
 
17
19
  const HEALTH_INTERVAL_MS = 30000;
18
20
  const STARTUP_GRACE_MS = 5000;
19
- const MAX_RESTARTS = 10;
20
- const RESTART_WINDOW_MS = 300000;
21
21
  const IDLE_TIMEOUT_MS = 120000;
22
22
 
23
23
  const processes = new Map();
24
+ const idleTimers = new Map();
24
25
  let healthTimer = null;
25
26
  let shuttingDown = false;
26
27
 
@@ -33,169 +34,174 @@ function resolveCommand(tool) {
33
34
  return { bin: tool.cmd, args: tool.args };
34
35
  }
35
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
+
36
63
  function startProcess(tool) {
37
64
  if (shuttingDown) return null;
38
- const existing = processes.get(tool.id);
39
- if (existing?.process && !existing.process.killed) return existing;
40
-
41
65
  const resolved = resolveCommand(tool);
42
- const entry = {
43
- id: tool.id,
44
- port: tool.port,
45
- startedAt: Date.now(),
46
- lastUsed: Date.now(),
47
- lastHealthCheck: 0,
48
- healthy: false,
49
- process: null,
50
- pid: null,
51
- restarts: [],
52
- idleTimer: null,
53
- providerInfo: null,
54
- _stopping: false
55
- };
56
-
66
+ let proc;
57
67
  try {
58
- entry.process = spawn(resolved.bin, resolved.args, {
68
+ proc = spawn(resolved.bin, resolved.args, {
59
69
  stdio: ['ignore', 'pipe', 'pipe'],
60
- detached: false
70
+ detached: false,
61
71
  });
62
- entry.pid = entry.process.pid;
63
-
64
- entry.process.on('close', (code) => {
65
- entry.healthy = false;
66
- if (shuttingDown || entry._stopping) return;
67
- log(tool.id + ' exited code ' + code);
68
- const window = Date.now() - RESTART_WINDOW_MS;
69
- entry.restarts = entry.restarts.filter(t => t > window);
70
- if (entry.restarts.length < MAX_RESTARTS) {
71
- const delay = Math.min(1000 * Math.pow(2, entry.restarts.length), 30000);
72
- entry.restarts.push(Date.now());
73
- setTimeout(() => startProcess(tool), delay);
74
- } else {
75
- log(tool.id + ' max restarts reached');
76
- }
77
- });
78
-
79
- processes.set(tool.id, entry);
80
- log(tool.id + ' started port ' + tool.port + ' pid ' + entry.pid);
81
- setTimeout(() => checkHealth(tool.id), STARTUP_GRACE_MS);
82
- resetIdleTimer(tool.id);
83
72
  } catch (err) {
84
73
  log(tool.id + ' spawn failed: ' + err.message);
74
+ acpMachine.send(tool.id, { type: 'CRASHED' });
75
+ scheduleRestart(tool);
76
+ return null;
85
77
  }
86
78
 
87
- return entry;
88
- }
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 });
89
82
 
90
- function resetIdleTimer(toolId) {
91
- const entry = processes.get(toolId);
92
- if (!entry) return;
93
- entry.lastUsed = Date.now();
94
- if (entry.idleTimer) clearTimeout(entry.idleTimer);
95
- entry.idleTimer = setTimeout(() => stopTool(toolId), IDLE_TIMEOUT_MS);
96
- }
83
+ log(tool.id + ' started port ' + tool.port + ' pid ' + proc.pid);
97
84
 
98
- function stopTool(toolId) {
99
- const entry = processes.get(toolId);
100
- if (!entry) return;
101
- log(toolId + ' idle, stopping to free RAM');
102
- entry._stopping = true;
103
- if (entry.idleTimer) clearTimeout(entry.idleTimer);
104
- try { entry.process.kill('SIGTERM'); } catch (_) {}
105
- setTimeout(() => { try { entry.process.kill('SIGKILL'); } catch (_) {} }, 5000);
106
- 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;
107
98
  }
108
99
 
109
- async function checkHealth(toolId) {
110
- const entry = processes.get(toolId);
111
- 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
+ }
112
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;
113
114
  try {
114
- const res = await fetch('http://127.0.0.1:' + entry.port + '/provider', {
115
- signal: AbortSignal.timeout(3000)
115
+ const res = await fetch('http://127.0.0.1:' + p + '/provider', {
116
+ signal: AbortSignal.timeout(3000),
116
117
  });
117
- entry.healthy = res.ok;
118
118
  if (res.ok) {
119
- 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' });
120
123
  }
121
124
  } catch (_) {
122
- entry.healthy = false;
125
+ acpMachine.send(toolId, { type: 'UNHEALTHY' });
123
126
  }
124
- entry.lastHealthCheck = Date.now();
125
127
  }
126
128
 
127
129
  export async function ensureRunning(agentId) {
128
130
  const tool = ACP_TOOLS.find(t => t.id === agentId);
129
131
  if (!tool) return null;
130
- let entry = processes.get(agentId);
131
- if (entry?.healthy) { resetIdleTimer(agentId); return entry.port; }
132
- if (!entry || entry._stopping) {
133
- entry = startProcess(tool);
134
- 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);
135
136
  }
136
137
  for (let i = 0; i < 20; i++) {
137
138
  await new Promise(r => setTimeout(r, 500));
138
- await checkHealth(agentId);
139
- if (processes.get(agentId)?.healthy) { resetIdleTimer(agentId); return tool.port; }
139
+ if (shuttingDown) return null;
140
+ if (acpMachine.isHealthy(agentId)) { resetIdleTimer(agentId); return tool.port; }
140
141
  }
141
142
  return null;
142
143
  }
143
144
 
144
- export function touch(agentId) {
145
- const entry = processes.get(agentId);
146
- if (entry) resetIdleTimer(agentId);
147
- }
145
+ export function touch(agentId) { resetIdleTimer(agentId); }
148
146
 
149
147
  export async function startAll() {
150
148
  log('ACP tools available (on-demand start)');
151
149
  healthTimer = setInterval(() => {
152
- 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
+ }
153
156
  }, HEALTH_INTERVAL_MS);
154
157
  }
155
158
 
156
159
  export async function stopAll() {
157
160
  shuttingDown = true;
158
161
  if (healthTimer) { clearInterval(healthTimer); healthTimer = null; }
162
+ for (const toolId of idleTimers.keys()) clearIdleTimer(toolId);
159
163
  const kills = [];
160
- for (const [id, entry] of processes) {
161
- if (entry.idleTimer) clearTimeout(entry.idleTimer);
162
- log('stopping ' + id + ' pid ' + entry.pid);
164
+ for (const [id, proc] of processes) {
165
+ log('stopping ' + id + ' pid ' + proc.pid);
163
166
  kills.push(new Promise(resolve => {
164
- const t = setTimeout(() => { try { entry.process.kill('SIGKILL'); } catch (_) {} resolve(); }, 5000);
165
- entry.process.on('close', () => { clearTimeout(t); resolve(); });
166
- 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 (_) {}
167
170
  }));
168
171
  }
169
172
  await Promise.all(kills);
170
173
  processes.clear();
174
+ acpMachine.stopAll();
171
175
  log('all stopped');
172
176
  }
173
177
 
174
178
  export function getStatus() {
175
179
  return ACP_TOOLS.map(tool => {
176
- const e = processes.get(tool.id);
180
+ const snap = acpMachine.snapshot(tool.id);
181
+ const ctx = snap?.context || {};
177
182
  return {
178
183
  id: tool.id,
179
184
  port: tool.port,
180
- running: !!e,
181
- healthy: e?.healthy || false,
182
- pid: e?.pid,
183
- uptime: e ? Date.now() - e.startedAt : 0,
184
- restartCount: e?.restarts.length || 0,
185
- idleMs: e ? Date.now() - e.lastUsed : 0,
186
- 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,
187
192
  };
188
193
  });
189
194
  }
190
195
 
191
196
  export function getPort(agentId) {
192
- const e = processes.get(agentId);
193
- return e?.healthy ? e.port : null;
197
+ return acpMachine.isHealthy(agentId) ? (ACP_TOOLS.find(t => t.id === agentId)?.port || null) : null;
194
198
  }
195
199
 
196
200
  export function getRunningPorts() {
197
201
  const ports = {};
198
- 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
+ }
199
205
  return ports;
200
206
  }
201
207
 
@@ -212,7 +218,7 @@ export async function queryModels(agentId) {
212
218
  if (!port) return [];
213
219
  try {
214
220
  const res = await fetch('http://127.0.0.1:' + port + '/models', {
215
- signal: AbortSignal.timeout(3000)
221
+ signal: AbortSignal.timeout(3000),
216
222
  });
217
223
  if (!res.ok) return [];
218
224
  const data = await res.json();
@@ -221,6 +227,5 @@ export async function queryModels(agentId) {
221
227
  }
222
228
 
223
229
  export function isAvailable(agentId) {
224
- const tool = ACP_TOOLS.find(t => t.id === agentId);
225
- return !!tool;
230
+ return !!ACP_TOOLS.find(t => t.id === agentId);
226
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
+ }