agentgui 1.0.866 → 1.0.868

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/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## [Unreleased] - observability + dead-code sweep + root test suite
2
+
3
+ - Expose acp-server-machine snapshots in /api/debug/machines (previously only tool-install + execution machines)
4
+ - Add /api/debug/state endpoint consolidating active executions, queues, wsClients, machine snapshots, ws-stats, recentErrors, uptime, memory (DEBUG=1 gated)
5
+ - Alias /api/debug/ws-stats to /api/ws-stats so docs match runtime
6
+ - Add getMachineActors() export to lib/acp-server-machine.js
7
+ - Delete verified-orphan files: lib/rate-limit-machine.js, lib/model-download-machine.js, lib/agent-registry-configs.js, lib/plugin-interface.js, lib/plugins/git-plugin.js
8
+ - Add root test.js (16 cases): codec roundtrip, DB init+queries, acp-queries helpers, WsRouter dispatch/errors/legacy, XState machine actor getters + lifecycle. Uses better-sqlite3 in-memory — no mocks.
9
+ - CLAUDE.md: remove stale entries for deleted files
10
+
1
11
  ## [Unreleased] - merge: integrate remote UI redesign + cleanup CLAUDE.md
2
12
 
3
13
  - Merge origin/main: resolve UU conflicts in server.js (take remote _jsonlWatcher setter) and static/index.html (take remote UI redesign with overflow menu + SVG icons)
package/CLAUDE.md CHANGED
@@ -25,7 +25,6 @@ lib/acp-protocol.js ACP session/update message normalization (shared by all A
25
25
  lib/acp-sdk-manager.js ACP tool lifecycle - on-demand start opencode/kilo/codex, health checks, idle timeout
26
26
  lib/acp-server-machine.js XState v5 machine per ACP tool: stopped/starting/running/crashed/restarting states
27
27
  lib/agent-discovery.js Agent binary detection (findCommand), ACP server query, discoverAgents, CLI wrapper logic
28
- lib/agent-registry-configs.js Agent registration configs (Claude Code, OpenCode, Gemini, 10+ ACP agents)
29
28
  lib/agent-descriptors.js Data-driven ACP agent descriptor builder
30
29
  lib/checkpoint-manager.js Session recovery - load checkpoints, inject into resume flow, idempotency
31
30
  lib/codec.js msgpack encode/decode (pack/unpack wrappers)
@@ -37,7 +36,6 @@ lib/jsonl-watcher.js Watches ~/.claude/projects for JSONL file changes, delega
37
36
  lib/oauth-common.js Shared OAuth helpers (buildBaseUrl, isRemoteRequest, encodeOAuthState, result/relay pages)
38
37
  lib/oauth-gemini.js Gemini OAuth flow (credential discovery, token exchange, callback handling)
39
38
  lib/oauth-codex.js Codex CLI OAuth flow (PKCE S256, token exchange, callback handling)
40
- lib/plugin-interface.js Plugin interface contract definition
41
39
  lib/plugin-loader.js Plugin discovery and loading (EventEmitter-based)
42
40
  lib/pm2-manager.js PM2 process management wrapper
43
41
  lib/speech.js Speech-to-text and text-to-speech via @huggingface/transformers
@@ -160,6 +160,10 @@ export function getBackoffDelay(toolId) {
160
160
  return calcBackoff(purgeOldRestarts(s.context.restarts));
161
161
  }
162
162
 
163
+ export function getMachineActors() {
164
+ return actors;
165
+ }
166
+
163
167
  export function stopAll() {
164
168
  for (const [, actor] of actors) actor.stop();
165
169
  actors.clear();
@@ -3,28 +3,94 @@ import path from 'path';
3
3
  import os from 'os';
4
4
  import * as toolInstallMachine from './tool-install-machine.js';
5
5
  import * as execMachine from './execution-machine.js';
6
+ import * as acpServerMachine from './acp-server-machine.js';
7
+
8
+ function collectMachineSnapshots(activeExecutions) {
9
+ const toolInstall = {};
10
+ for (const [id, actor] of toolInstallMachine.getMachineActors()) {
11
+ const s = actor.getSnapshot();
12
+ toolInstall[id] = { state: s.value, context: s.context };
13
+ }
14
+ const execution = {};
15
+ for (const id of activeExecutions.keys()) {
16
+ const s = execMachine.snapshot(id);
17
+ if (s) execution[id] = { state: s.value, context: { pid: s.context.pid, sessionId: s.context.sessionId, queueLen: s.context.queue?.length } };
18
+ }
19
+ const acpServer = {};
20
+ for (const [id, actor] of acpServerMachine.getMachineActors()) {
21
+ const s = actor.getSnapshot();
22
+ acpServer[id] = { state: s.value, context: { pid: s.context.pid, healthy: s.context.healthy, startedAt: s.context.startedAt, lastUsed: s.context.lastUsed, restarts: s.context.restarts?.length || 0, providerInfo: s.context.providerInfo } };
23
+ }
24
+ return { toolInstall, execution, acpServer };
25
+ }
26
+
27
+ function readRecentErrors(errLogPath, limit = 50) {
28
+ try {
29
+ const raw = fs.readFileSync(errLogPath, 'utf8');
30
+ return raw.trim().split('\n').slice(-limit).map(l => { try { return JSON.parse(l); } catch { return l; } });
31
+ } catch { return []; }
32
+ }
6
33
 
7
34
  export function register(deps) {
8
35
  const { sendJSON, queries, activeExecutions, messageQueues, syncClients, wsOptimizer, _errLogPath } = deps;
9
36
  const routes = {};
10
37
 
38
+ function snapshotExecutions() {
39
+ const o = {};
40
+ for (const [k, v] of activeExecutions) o[k] = { pid: v.pid, startTime: v.startTime, sessionId: v.sessionId, lastActivity: v.lastActivity };
41
+ return o;
42
+ }
43
+ function snapshotQueues() {
44
+ const o = {};
45
+ for (const [k, v] of messageQueues) o[k] = { length: v.length, messageIds: v.map(m => m.messageId) };
46
+ return o;
47
+ }
48
+ function snapshotWsClients() {
49
+ const out = [];
50
+ for (const ws of syncClients) out.push({ clientId: ws.clientId, subs: ws.subscriptions ? [...ws.subscriptions] : [] });
51
+ return out;
52
+ }
53
+ function streamingConvs() {
54
+ try { return queries.getConversationsList().filter(c => c.isStreaming).map(c => ({ id: c.id, isStreaming: c.isStreaming })); }
55
+ catch { return []; }
56
+ }
57
+
11
58
  routes['GET /api/debug'] = async (req, res) => {
12
- const execSnap = {};
13
- for (const [k, v] of activeExecutions) execSnap[k] = { pid: v.pid, startTime: v.startTime, sessionId: v.sessionId, lastActivity: v.lastActivity };
14
- const queueSnap = {};
15
- for (const [k, v] of messageQueues) queueSnap[k] = { length: v.length, messageIds: v.map(m => m.messageId) };
16
- let streamingConvs = [];
17
- try { streamingConvs = queries.getConversationsList().filter(c => c.isStreaming).map(c => ({ id: c.id, isStreaming: c.isStreaming })); } catch (_) {}
18
- const clientSubs = [];
19
- for (const ws of syncClients) clientSubs.push({ clientId: ws.clientId, subs: ws.subscriptions ? [...ws.subscriptions] : [] });
20
- let recentErrors = [];
21
- try {
22
- const raw = fs.readFileSync(_errLogPath, 'utf8');
23
- recentErrors = raw.trim().split('\n').slice(-20).map(l => { try { return JSON.parse(l); } catch { return l; } });
24
- } catch (_) {}
25
- sendJSON(req, res, 200, { activeExecutions: execSnap, messageQueues: queueSnap, streamingConversations: streamingConvs, wsClients: clientSubs, recentErrors });
59
+ sendJSON(req, res, 200, {
60
+ activeExecutions: snapshotExecutions(),
61
+ messageQueues: snapshotQueues(),
62
+ streamingConversations: streamingConvs(),
63
+ wsClients: snapshotWsClients(),
64
+ recentErrors: readRecentErrors(_errLogPath, 20),
65
+ });
66
+ };
67
+
68
+ routes['GET /api/debug/state'] = async (req, res) => {
69
+ if (!process.env.DEBUG) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
70
+ sendJSON(req, res, 200, {
71
+ activeExecutions: snapshotExecutions(),
72
+ messageQueues: snapshotQueues(),
73
+ streamingConversations: streamingConvs(),
74
+ wsClients: snapshotWsClients(),
75
+ machines: collectMachineSnapshots(activeExecutions),
76
+ wsStats: wsOptimizer.getStats(),
77
+ recentErrors: readRecentErrors(_errLogPath, 50),
78
+ uptime: process.uptime(),
79
+ memory: process.memoryUsage(),
80
+ });
81
+ };
82
+
83
+ routes['GET /api/debug/machines'] = async (req, res) => {
84
+ if (!process.env.DEBUG) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
85
+ sendJSON(req, res, 200, collectMachineSnapshots(activeExecutions));
26
86
  };
27
87
 
88
+ routes['GET /api/ws-stats'] = async (req, res) => {
89
+ sendJSON(req, res, 200, wsOptimizer.getStats());
90
+ };
91
+
92
+ routes['GET /api/debug/ws-stats'] = routes['GET /api/ws-stats'];
93
+
28
94
  routes['GET /api/backup'] = async (req, res) => {
29
95
  const dbPath = path.join(os.homedir(), '.gmgui', 'data.db');
30
96
  if (!fs.existsSync(dbPath)) { sendJSON(req, res, 404, { error: 'Database not found' }); return; }
@@ -53,28 +119,7 @@ export function register(deps) {
53
119
  });
54
120
  };
55
121
 
56
- routes['GET /api/debug/machines'] = async (req, res) => {
57
- if (!process.env.DEBUG) { sendJSON(req, res, 404, { error: 'Not found' }); return; }
58
- const toolSnap = {};
59
- for (const [id, actor] of toolInstallMachine.getMachineActors()) {
60
- const s = actor.getSnapshot();
61
- toolSnap[id] = { state: s.value, context: s.context };
62
- }
63
- const execSnap = {};
64
- for (const id of activeExecutions.keys()) {
65
- const s = execMachine.snapshot(id);
66
- if (s) execSnap[id] = { state: s.value, context: { pid: s.context.pid, sessionId: s.context.sessionId, queueLen: s.context.queue?.length } };
67
- }
68
- sendJSON(req, res, 200, { toolInstall: toolSnap, execution: execSnap });
69
- };
70
-
71
- routes['GET /api/ws-stats'] = async (req, res) => {
72
- sendJSON(req, res, 200, wsOptimizer.getStats());
73
- };
74
-
75
- routes['_match'] = (method, pathOnly) => {
76
- return routes[`${method} ${pathOnly}`] || null;
77
- };
122
+ routes['_match'] = (method, pathOnly) => routes[`${method} ${pathOnly}`] || null;
78
123
 
79
124
  return routes;
80
125
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.866",
3
+ "version": "1.0.868",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -167,6 +167,5 @@ Object.assign(AgentGUIClient.prototype, {
167
167
  this.showError('Failed to load conversation: ' + error.message);
168
168
  }
169
169
  }
170
- }
171
170
  }
172
171
  });
@@ -8,7 +8,7 @@ Object.assign(AgentGUIClient.prototype, {
8
8
  }
9
9
 
10
10
  this.updateBusyPromptArea(conversationId);
11
- }
11
+ },
12
12
 
13
13
 
14
14
  updateBusyPromptArea(conversationId) {
@@ -27,7 +27,7 @@ Object.assign(AgentGUIClient.prototype, {
27
27
  });
28
28
 
29
29
  if (this.ui.sendButton) this.ui.sendButton.style.display = isStreaming ? 'none' : '';
30
- }
30
+ },
31
31
 
32
32
 
33
33
  removeScrollUpDetection() {
@@ -36,7 +36,7 @@ Object.assign(AgentGUIClient.prototype, {
36
36
  scrollContainer.removeEventListener('scroll', this._scrollUpHandler);
37
37
  this._scrollUpHandler = null;
38
38
  }
39
- }
39
+ },
40
40
 
41
41
 
42
42
  setupScrollUpDetection(conversationId) {
@@ -154,7 +154,7 @@ Object.assign(AgentGUIClient.prototype, {
154
154
  scrollContainer.removeEventListener('scroll', this._scrollUpHandler);
155
155
  this._scrollUpHandler = handleScroll;
156
156
  scrollContainer.addEventListener('scroll', this._scrollUpHandler, { passive: true });
157
- }
157
+ },
158
158
 
159
159
 
160
160
  renderMessagesFragment(messages) {
@@ -4,12 +4,12 @@ Object.assign(AgentGUIClient.prototype, {
4
4
  return '<p class="text-secondary">No messages in this conversation yet</p>';
5
5
  }
6
6
  return messages.map(msg => `<div class="message message-${msg.role}"><div class="message-role">${msg.role.charAt(0).toUpperCase() + msg.role.slice(1)}</div>${this.renderMessageContent(msg.content)}<div class="message-timestamp">${new Date(msg.created_at).toLocaleString()}</div></div>`).join('');
7
- }
7
+ },
8
8
 
9
9
 
10
10
  escapeHtml(text) {
11
11
  return window._escHtml(text);
12
- }
12
+ },
13
13
 
14
14
 
15
15
  showError(message) {
@@ -17,7 +17,7 @@ Object.assign(AgentGUIClient.prototype, {
17
17
  if (window.UIDialog) {
18
18
  window.UIDialog.alert(message, 'Error');
19
19
  }
20
- }
20
+ },
21
21
 
22
22
 
23
23
  on(event, callback) {
@@ -25,7 +25,7 @@ Object.assign(AgentGUIClient.prototype, {
25
25
  this.eventHandlers[event] = [];
26
26
  }
27
27
  this.eventHandlers[event].push(callback);
28
- }
28
+ },
29
29
 
30
30
 
31
31
  emit(event, data) {
@@ -38,12 +38,12 @@ Object.assign(AgentGUIClient.prototype, {
38
38
  }
39
39
  });
40
40
  }
41
- }
41
+ },
42
42
 
43
43
 
44
44
  getEffectiveAgentId() {
45
45
  return this.ui.cliSelector?.value || null;
46
- }
46
+ },
47
47
 
48
48
 
49
49
  getEffectiveSubAgent() {
@@ -51,12 +51,12 @@ Object.assign(AgentGUIClient.prototype, {
51
51
  return this.ui.agentSelector.value;
52
52
  }
53
53
  return null;
54
- }
54
+ },
55
55
 
56
56
 
57
57
  getCurrentAgent() {
58
58
  return this.getEffectiveAgentId();
59
- }
59
+ },
60
60
 
61
61
 
62
62
  saveAgentAndModelToConversation() {
@@ -66,12 +66,12 @@ Object.assign(AgentGUIClient.prototype, {
66
66
  const subAgent = this.getEffectiveSubAgent();
67
67
  const model = this.getCurrentModel();
68
68
  window.wsClient.rpc('conv.upd', { id: convId, agentType: agentId, subAgent: subAgent || undefined, model: model || undefined }).catch(() => {});
69
- }
69
+ },
70
70
 
71
71
 
72
72
  getCurrentModel() {
73
73
  return this.ui.modelSelector?.value || null;
74
- }
74
+ },
75
75
 
76
76
 
77
77
  getMetrics() {
@@ -81,7 +81,7 @@ Object.assign(AgentGUIClient.prototype, {
81
81
  eventProcessor: this.eventProcessor.getStats(),
82
82
  state: this.state
83
83
  };
84
- }
84
+ },
85
85
 
86
86
 
87
87
  saveDraftPrompt() {
@@ -93,7 +93,7 @@ Object.assign(AgentGUIClient.prototype, {
93
93
  localStorage.setItem(`draft-${convId}`, draft);
94
94
  }
95
95
  }
96
- }
96
+ },
97
97
 
98
98
 
99
99
  restoreDraftPrompt(conversationId) {
@@ -106,13 +106,13 @@ Object.assign(AgentGUIClient.prototype, {
106
106
  }
107
107
 
108
108
  this.ui.messageInput.value = draft;
109
- }
109
+ },
110
110
 
111
111
 
112
112
  clearDraft(conversationId) {
113
113
  this.draftPrompts.delete(conversationId);
114
114
  localStorage.removeItem(`draft-${conversationId}`);
115
- }
115
+ },
116
116
 
117
117
 
118
118
  updateSendButtonState() {
@@ -125,11 +125,11 @@ Object.assign(AgentGUIClient.prototype, {
125
125
  if (this.ui.queueButton && this.ui.queueButton.classList.contains('visible')) {
126
126
  this.ui.queueButton.disabled = !this.wsManager.isConnected;
127
127
  }
128
- }
128
+ },
129
129
 
130
130
 
131
131
  disablePromptArea() {
132
- }
132
+ },
133
133
 
134
134
 
135
135
  enablePromptArea() {
@@ -138,7 +138,7 @@ Object.assign(AgentGUIClient.prototype, {
138
138
  }
139
139
  const injectBtn = document.getElementById('injectBtn');
140
140
  if (injectBtn) injectBtn.disabled = false;
141
- }
141
+ },
142
142
 
143
143
 
144
144
  showStreamingPromptButtons() {
@@ -150,14 +150,14 @@ Object.assign(AgentGUIClient.prototype, {
150
150
  this.ui.queueButton.classList.add('visible');
151
151
  this.ui.queueButton.disabled = !this.wsManager.isConnected;
152
152
  }
153
- }
153
+ },
154
154
 
155
155
 
156
156
  ensurePromptAreaAlwaysEnabled() {
157
157
  if (this.ui.messageInput) {
158
158
  this.ui.messageInput.disabled = false;
159
159
  }
160
- }
160
+ },
161
161
 
162
162
 
163
163
  destroy() {
@@ -166,10 +166,9 @@ Object.assign(AgentGUIClient.prototype, {
166
166
  this.wsManager.destroy();
167
167
  this.eventHandlers = {};
168
168
  }
169
- }
169
+ });
170
170
 
171
171
  window.__convPerfMetrics = () => {
172
172
  const entries = performance.getEntriesByType('measure').filter(e => e.name.startsWith('conv-'));
173
173
  return entries.map(e => ({ name: e.name, ms: Math.round(e.duration) }));
174
174
  };
175
- });
@@ -0,0 +1,4 @@
1
+ // Client-side msgpack codec — loaded via dynamic import() in ws-client.js
2
+ // msgpackr is loaded globally from static/lib/msgpackr.min.js
3
+ export const encode = (obj) => msgpackr.pack(obj);
4
+ export const decode = (buf) => msgpackr.unpack(new Uint8Array(buf));
package/test.js ADDED
@@ -0,0 +1,165 @@
1
+ import assert from 'assert';
2
+ import Database from 'better-sqlite3';
3
+ import { initSchema } from './database-schema.js';
4
+ import { migrateConversationColumns } from './database-migrations.js';
5
+ import { migrateACPSchema } from './database-migrations-acp.js';
6
+ import { createQueries } from './lib/db-queries.js';
7
+ import { encode, decode } from './lib/codec.js';
8
+ import { WsRouter } from './lib/ws-protocol.js';
9
+ import * as toolInstallMachine from './lib/tool-install-machine.js';
10
+ import * as execMachine from './lib/execution-machine.js';
11
+ import * as acpServerMachine from './lib/acp-server-machine.js';
12
+
13
+ let passed = 0, failed = 0;
14
+ const section = (name, fn) => {
15
+ try { fn(); console.log(`ok — ${name}`); passed++; }
16
+ catch (err) { console.error(`FAIL — ${name}: ${err.message}`); failed++; }
17
+ };
18
+
19
+ function inMemDb() {
20
+ const db = new Database(':memory:');
21
+ db.pragma('foreign_keys = ON');
22
+ const origLog = console.log, origWarn = console.warn;
23
+ console.log = () => {}; console.warn = () => {};
24
+ try {
25
+ initSchema(db);
26
+ migrateConversationColumns(db);
27
+ migrateACPSchema(db);
28
+ } finally { console.log = origLog; console.warn = origWarn; }
29
+ const prep = (sql) => db.prepare(sql);
30
+ const gid = (p) => `${p}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
31
+ return { db, prep, gid };
32
+ }
33
+
34
+ section('codec: roundtrip primitives', () => {
35
+ const obj = { a: 1, b: 'str', c: [1, 2, 3], d: { nested: true } };
36
+ assert.deepEqual(decode(encode(obj)), obj);
37
+ });
38
+
39
+ section('codec: binary payload', () => {
40
+ const buf = Buffer.from([1, 2, 3, 4]);
41
+ const round = decode(encode({ bin: buf }));
42
+ assert.deepEqual(Array.from(round.bin), [1, 2, 3, 4]);
43
+ });
44
+
45
+ section('db: init schema creates conversations table', () => {
46
+ const { db } = inMemDb();
47
+ const t = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='conversations'").get();
48
+ assert.ok(t, 'conversations table exists');
49
+ });
50
+
51
+ section('db-queries: createConversation round-trip', () => {
52
+ const { db, prep, gid } = inMemDb();
53
+ const q = createQueries(db, prep, gid);
54
+ const c = q.createConversation('claude-code', 'Test', '/tmp', 'sonnet', null);
55
+ assert.equal(c.title, 'Test');
56
+ const fetched = q.getConversation(c.id);
57
+ assert.equal(fetched.title, 'Test');
58
+ assert.equal(fetched.status, 'active');
59
+ });
60
+
61
+ section('db-queries: archive + restore', () => {
62
+ const { db, prep, gid } = inMemDb();
63
+ const q = createQueries(db, prep, gid);
64
+ const c = q.createConversation('claude-code', 'A');
65
+ q.archiveConversation(c.id);
66
+ assert.equal(q.getConversation(c.id).status, 'archived');
67
+ q.restoreConversation(c.id);
68
+ assert.equal(q.getConversation(c.id).status, 'active');
69
+ });
70
+
71
+ section('db-queries: streaming flag', () => {
72
+ const { db, prep, gid } = inMemDb();
73
+ const q = createQueries(db, prep, gid);
74
+ const c = q.createConversation('claude-code', 'S');
75
+ q.setIsStreaming(c.id, true);
76
+ assert.equal(q.getIsStreaming(c.id), true);
77
+ q.setIsStreaming(c.id, false);
78
+ assert.equal(q.getIsStreaming(c.id), false);
79
+ });
80
+
81
+ section('acp-queries: createThread + getThread + patchThread', () => {
82
+ const { db, prep, gid } = inMemDb();
83
+ const q = createQueries(db, prep, gid);
84
+ const t = q.createThread({ foo: 'bar' });
85
+ assert.ok(t.thread_id);
86
+ const got = q.getThread(t.thread_id);
87
+ assert.deepEqual(got.metadata, { foo: 'bar' });
88
+ const patched = q.patchThread(t.thread_id, { metadata: { foo: 'baz' }, status: 'active' });
89
+ assert.equal(patched.status, 'active');
90
+ assert.deepEqual(q.getThread(t.thread_id).metadata, { foo: 'baz' });
91
+ });
92
+
93
+ section('acp-queries: searchThreads parameterized', () => {
94
+ const { db, prep, gid } = inMemDb();
95
+ const q = createQueries(db, prep, gid);
96
+ q.createThread({ kind: 'a' });
97
+ q.createThread({ kind: 'b' });
98
+ const r = q.searchThreads({});
99
+ assert.equal(r.total, 2);
100
+ assert.equal(r.threads.length, 2);
101
+ });
102
+
103
+ section('WsRouter: dispatch registered method', async () => {
104
+ const router = new WsRouter();
105
+ router.handle('ping', async (p) => ({ pong: p.n }));
106
+ const replies = [];
107
+ const ws = { readyState: 1, send: (buf) => replies.push(decode(buf)), clientId: 'c1' };
108
+ await router.onMessage(ws, encode({ r: 1, m: 'ping', p: { n: 7 } }));
109
+ assert.deepEqual(replies[0], { r: 1, d: { pong: 7 } });
110
+ });
111
+
112
+ section('WsRouter: unknown method replies 404', async () => {
113
+ const router = new WsRouter();
114
+ const replies = [];
115
+ const ws = { readyState: 1, send: (buf) => replies.push(decode(buf)), clientId: 'c' };
116
+ await router.onMessage(ws, encode({ r: 2, m: 'nope', p: {} }));
117
+ assert.equal(replies[0].r, 2);
118
+ assert.equal(replies[0].e.c, 404);
119
+ });
120
+
121
+ section('WsRouter: handler exception becomes error reply', async () => {
122
+ const router = new WsRouter();
123
+ router.handle('boom', async () => { throw Object.assign(new Error('kaboom'), { code: 422 }); });
124
+ const replies = [];
125
+ const ws = { readyState: 1, send: (buf) => replies.push(decode(buf)), clientId: 'c' };
126
+ await router.onMessage(ws, encode({ r: 3, m: 'boom', p: {} }));
127
+ assert.equal(replies[0].e.c, 422);
128
+ assert.equal(replies[0].e.m, 'kaboom');
129
+ });
130
+
131
+ section('WsRouter: legacy handler called for non-RPC messages', async () => {
132
+ const router = new WsRouter();
133
+ let seen = null;
134
+ router.onLegacy((msg) => { seen = msg; });
135
+ const ws = { readyState: 1, send: () => {}, clientId: 'c' };
136
+ await router.onMessage(ws, encode({ type: 'subscribe', id: 'x' }));
137
+ assert.equal(seen.type, 'subscribe');
138
+ });
139
+
140
+ section('tool-install-machine: actors map exposed', () => {
141
+ const actors = toolInstallMachine.getMachineActors();
142
+ assert.ok(actors instanceof Map);
143
+ });
144
+
145
+ section('execution-machine: snapshot returns null for unknown id', () => {
146
+ assert.equal(execMachine.snapshot('nonexistent-conv-id'), null);
147
+ });
148
+
149
+ section('acp-server-machine: lifecycle transitions', () => {
150
+ const actor = acpServerMachine.getOrCreate('test-tool');
151
+ assert.equal(actor.getSnapshot().value, 'stopped');
152
+ acpServerMachine.send('test-tool', { type: 'START', pid: 123 });
153
+ assert.equal(acpServerMachine.get('test-tool').getSnapshot().value, 'starting');
154
+ acpServerMachine.send('test-tool', { type: 'HEALTHY', providerInfo: { ok: true } });
155
+ assert.equal(acpServerMachine.isHealthy('test-tool'), true);
156
+ acpServerMachine.stopAll();
157
+ });
158
+
159
+ section('acp-server-machine: getMachineActors returns Map', () => {
160
+ const actors = acpServerMachine.getMachineActors();
161
+ assert.ok(actors instanceof Map);
162
+ });
163
+
164
+ console.log(`\n${passed} passed, ${failed} failed`);
165
+ process.exit(failed === 0 ? 0 : 1);
@@ -1,125 +0,0 @@
1
- import { acpProtocolHandler } from './acp-protocol.js';
2
-
3
- const SIMPLE_ACP_AGENTS = [
4
- { id: 'goose', name: 'Goose', command: 'goose' },
5
- { id: 'openhands', name: 'OpenHands', command: 'openhands' },
6
- { id: 'augment', name: 'Augment Code', command: 'augment' },
7
- { id: 'cline', name: 'Cline', command: 'cline' },
8
- { id: 'kimi', name: 'Kimi CLI', command: 'kimi' },
9
- { id: 'qwen', name: 'Qwen Code', command: 'qwen-code' },
10
- { id: 'codex', name: 'Codex CLI', command: 'codex' },
11
- { id: 'mistral', name: 'Mistral Vibe', command: 'mistral-vibe' },
12
- { id: 'kiro', name: 'Kiro CLI', command: 'kiro' },
13
- { id: 'fast-agent', name: 'fast-agent', command: 'fast-agent' },
14
- ];
15
-
16
- function parseClaudeOutput(line) {
17
- try {
18
- const entry = JSON.parse(line);
19
- if (!entry || typeof entry !== 'object') return null;
20
- if (entry.type === 'user' && entry.isMeta === true) return null;
21
- if (entry.isApiErrorMessage === true && entry.error === 'rate_limit') {
22
- entry._rateLimitDetected = true;
23
- }
24
- if (entry.type === 'assistant' && entry.message) {
25
- entry._isFragment = entry.message.stop_reason === null || entry.message.stop_reason === undefined;
26
- }
27
- if (entry.type === 'system' && entry.subtype === 'turn_duration' && entry.durationMs) {
28
- entry._turnDurationMs = entry.durationMs;
29
- }
30
- if (entry.type === 'system' && entry.subtype === 'compact_boundary' && entry.compactMetadata) {
31
- entry._preTokens = entry.compactMetadata.preTokens;
32
- }
33
- if (entry.message?.usage) {
34
- const u = entry.message.usage;
35
- entry._cacheUsage = {
36
- cache_creation: u.cache_creation_input_tokens || u['cache_creation.ephemeral_1h_input_tokens'] || u['cache_creation.ephemeral_5m_input_tokens'] || 0,
37
- cache_read: u.cache_read_input_tokens || 0
38
- };
39
- }
40
- return entry;
41
- } catch {
42
- return null;
43
- }
44
- }
45
-
46
- export function registerAllAgents(registry) {
47
- registry.register({
48
- id: 'claude-code',
49
- name: 'Claude Code',
50
- command: 'claude',
51
- protocol: 'direct',
52
- supportsStdin: false,
53
- closeStdin: true,
54
- useJsonRpcStdin: false,
55
- supportedFeatures: ['streaming', 'resume', 'system-prompt', 'permissions-skip', 'steering'],
56
- spawnEnv: { MAX_THINKING_TOKENS: '0', AGENTGUI_SUBPROCESS: '1' },
57
- buildArgs(prompt, config) {
58
- const { verbose = true, outputFormat = 'stream-json', print = true, resumeSessionId = null, systemPrompt = null, model = null } = config;
59
- const flags = [];
60
- if (print) flags.push('--print');
61
- if (verbose) flags.push('--verbose');
62
- flags.push(`--output-format=${outputFormat}`);
63
- if (model) flags.push('--model', model);
64
- if (resumeSessionId) flags.push('--resume', resumeSessionId);
65
- if (systemPrompt) flags.push('--append-system-prompt', systemPrompt);
66
- flags.push('--dangerously-skip-permissions');
67
- flags.push(typeof prompt === 'string' ? prompt : String(prompt));
68
- return flags;
69
- },
70
- parseOutput: parseClaudeOutput,
71
- });
72
-
73
- registry.register({
74
- id: 'opencode',
75
- name: 'OpenCode',
76
- command: 'opencode',
77
- protocol: 'acp',
78
- supportsStdin: false,
79
- npxPackage: 'opencode-ai',
80
- supportedFeatures: ['streaming', 'resume', 'acp-protocol'],
81
- buildArgs: () => ['acp'],
82
- protocolHandler: acpProtocolHandler,
83
- });
84
-
85
- registry.register({
86
- id: 'gemini',
87
- name: 'Gemini CLI',
88
- command: 'gemini',
89
- protocol: 'acp',
90
- supportsStdin: false,
91
- npxPackage: '@google/gemini-cli',
92
- supportedFeatures: ['streaming', 'resume', 'acp-protocol'],
93
- buildArgs(prompt, config) {
94
- const args = ['--experimental-acp', '--yolo'];
95
- if (config?.model) args.push('--model', config.model);
96
- return args;
97
- },
98
- protocolHandler: acpProtocolHandler,
99
- });
100
-
101
- registry.register({
102
- id: 'kilo',
103
- name: 'Kilo CLI',
104
- command: 'kilo',
105
- protocol: 'acp',
106
- supportsStdin: false,
107
- npxPackage: '@kilocode/cli',
108
- supportedFeatures: ['streaming', 'resume', 'acp-protocol', 'models'],
109
- buildArgs: () => ['acp'],
110
- protocolHandler: acpProtocolHandler,
111
- });
112
-
113
- for (const agent of SIMPLE_ACP_AGENTS) {
114
- registry.register({
115
- id: agent.id,
116
- name: agent.name,
117
- command: agent.command,
118
- protocol: 'acp',
119
- supportsStdin: false,
120
- supportedFeatures: ['streaming', 'resume', 'acp-protocol'],
121
- buildArgs: () => ['acp'],
122
- protocolHandler: acpProtocolHandler,
123
- });
124
- }
125
- }
@@ -1,107 +0,0 @@
1
- import { createMachine, createActor, assign } from 'xstate';
2
-
3
- const machine = createMachine({
4
- id: 'model-download',
5
- initial: 'idle',
6
- context: {
7
- progress: null,
8
- error: null,
9
- startTime: null,
10
- waiters: [],
11
- },
12
- states: {
13
- idle: {
14
- on: {
15
- START: {
16
- target: 'downloading',
17
- actions: assign({ startTime: () => Date.now(), error: null }),
18
- },
19
- WAIT: {
20
- actions: assign(({ context, event }) => ({
21
- waiters: [...context.waiters, event.resolve],
22
- })),
23
- },
24
- },
25
- },
26
- downloading: {
27
- on: {
28
- PROGRESS: {
29
- actions: assign(({ event }) => ({ progress: event.progress })),
30
- },
31
- COMPLETE: { target: 'complete' },
32
- ERROR: {
33
- target: 'error',
34
- actions: assign(({ event }) => ({ error: event.error })),
35
- },
36
- WAIT: {
37
- actions: assign(({ context, event }) => ({
38
- waiters: [...context.waiters, event.resolve],
39
- })),
40
- },
41
- },
42
- },
43
- complete: {
44
- entry: assign(({ context }) => {
45
- for (const resolve of context.waiters) resolve(true);
46
- return { waiters: [] };
47
- }),
48
- on: {
49
- RESET: { target: 'idle' },
50
- },
51
- },
52
- error: {
53
- entry: assign(({ context }) => {
54
- for (const resolve of context.waiters) resolve(false);
55
- return { waiters: [] };
56
- }),
57
- on: {
58
- RESET: { target: 'idle' },
59
- START: {
60
- target: 'downloading',
61
- actions: assign({ startTime: () => Date.now(), error: null }),
62
- },
63
- },
64
- },
65
- },
66
- });
67
-
68
- let actor = null;
69
-
70
- export function getActor() {
71
- if (!actor) {
72
- actor = createActor(machine);
73
- actor.start();
74
- }
75
- return actor;
76
- }
77
-
78
- export function send(event) {
79
- return getActor().send(event);
80
- }
81
-
82
- export function snapshot() {
83
- return getActor().getSnapshot();
84
- }
85
-
86
- export function isDownloading() {
87
- const s = snapshot();
88
- return s.value === 'downloading';
89
- }
90
-
91
- export function isComplete() {
92
- const s = snapshot();
93
- return s.value === 'complete';
94
- }
95
-
96
- export function isError() {
97
- const s = snapshot();
98
- return s.value === 'error';
99
- }
100
-
101
- export function getState() {
102
- return snapshot().value;
103
- }
104
-
105
- export function getContext() {
106
- return snapshot().context;
107
- }
@@ -1,36 +0,0 @@
1
- // Plugin interface contract - every plugin must implement this
2
-
3
- export default {
4
- // Plugin metadata
5
- name: 'plugin-name', // unique identifier
6
- version: '1.0.0',
7
- dependencies: [], // list of other plugin names this depends on
8
-
9
- // Lifecycle methods (all required)
10
- async init(config, plugins) {
11
- // config = { router, wsManager, db, logger, env }
12
- // plugins = Map<name, plugin> of all loaded plugins
13
- // MUST return: { routes[], wsHandlers{}, api{}, stop() }
14
- return {
15
- routes: [], // [ { method, path, handler } ]
16
- wsHandlers: {}, // { eventType: handler(data, clients) }
17
- api: {}, // exported functions for other plugins
18
- };
19
- },
20
-
21
- async reload(state) {
22
- // Called on hot reload. Preserve state from previous instance.
23
- // Return new state (or updated state from previous)
24
- return state;
25
- },
26
-
27
- async stop() {
28
- // Graceful shutdown. Clean up resources.
29
- // No need to return anything.
30
- },
31
-
32
- // Optional: Called when another plugin throws error
33
- async handleError(error, context) {
34
- // context = { pluginName, phase, ... }
35
- },
36
- };
@@ -1,117 +0,0 @@
1
- // Git plugin - version control, workflow detection, push events
2
-
3
- import { execSync } from 'child_process';
4
- import path from 'path';
5
- import fs from 'fs';
6
-
7
- export default {
8
- name: 'git',
9
- version: '1.0.0',
10
- dependencies: [],
11
-
12
- async init(config, plugins) {
13
- let lastPushSha = null;
14
- let workflowsList = [];
15
- let pushInProgress = false;
16
-
17
- const getRepoRoot = () => {
18
- try {
19
- return execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
20
- } catch {
21
- return process.cwd();
22
- }
23
- };
24
-
25
- const getStatus = async () => {
26
- try {
27
- const status = execSync('git status --short', { encoding: 'utf8' });
28
- const unpushed = execSync('git rev-list --count @{u}..HEAD', { encoding: 'utf8' }).trim();
29
- return { dirty: status.length > 0, unpushedCount: parseInt(unpushed) || 0 };
30
- } catch (e) {
31
- return { dirty: false, unpushedCount: 0 };
32
- }
33
- };
34
-
35
- const listWorkflows = () => {
36
- const root = getRepoRoot();
37
- const workflowDir = path.join(root, '.github', 'workflows');
38
- if (!fs.existsSync(workflowDir)) return [];
39
- return fs.readdirSync(workflowDir).filter(f => f.endsWith('.yml') || f.endsWith('.yaml'));
40
- };
41
-
42
- const push = async (message) => {
43
- if (pushInProgress) throw new Error('Push already in progress');
44
- pushInProgress = true;
45
- try {
46
- execSync('git add -A');
47
- execSync(`git commit -m "${message}"`);
48
- const result = execSync('git push', { encoding: 'utf8' });
49
- lastPushSha = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();
50
- return { success: true, sha: lastPushSha };
51
- } catch (error) {
52
- return { success: false, error: error.message };
53
- } finally {
54
- pushInProgress = false;
55
- }
56
- };
57
-
58
- workflowsList = listWorkflows();
59
-
60
- return {
61
- routes: [
62
- {
63
- method: 'GET',
64
- path: '/api/git/status',
65
- handler: async (req, res) => {
66
- const status = await getStatus();
67
- res.json({ ...status, workflows: workflowsList });
68
- },
69
- },
70
- {
71
- method: 'POST',
72
- path: '/api/git/push',
73
- handler: async (req, res) => {
74
- const { message } = req.body;
75
- try {
76
- const result = await push(message);
77
- res.json(result);
78
- } catch (e) {
79
- res.status(400).json({ error: e.message });
80
- }
81
- },
82
- },
83
- {
84
- method: 'GET',
85
- path: '/api/git/workflows',
86
- handler: (req, res) => {
87
- res.json({ workflows: workflowsList });
88
- },
89
- },
90
- {
91
- method: 'POST',
92
- path: '/api/git/workflow/:name/run',
93
- handler: (req, res) => {
94
- res.json({ status: 'not-implemented', message: 'Use GitHub Actions API' });
95
- },
96
- },
97
- ],
98
- wsHandlers: {
99
- git_status_changed: (data, clients) => {
100
- // Broadcast git status to all clients
101
- },
102
- },
103
- api: {
104
- getStatus,
105
- push,
106
- listWorkflows,
107
- },
108
- stop: async () => {},
109
- };
110
- },
111
-
112
- async reload(state) {
113
- return state;
114
- },
115
-
116
- async stop() {},
117
- };
@@ -1,99 +0,0 @@
1
- import { createMachine, createActor, assign } from 'xstate';
2
-
3
- const machine = createMachine({
4
- id: 'rate-limit',
5
- initial: 'normal',
6
- context: {
7
- retryCount: 0,
8
- retryAt: null,
9
- cooldownMs: null,
10
- isStreamDetected: false,
11
- },
12
- states: {
13
- normal: {
14
- entry: assign({ retryCount: 0, retryAt: null, cooldownMs: null, isStreamDetected: false }),
15
- on: {
16
- HIT: {
17
- target: 'limited',
18
- actions: assign(({ context, event }) => ({
19
- retryCount: context.retryCount + 1,
20
- retryAt: event.retryAt,
21
- cooldownMs: event.cooldownMs,
22
- isStreamDetected: event.isStreamDetected || false,
23
- })),
24
- },
25
- },
26
- },
27
- limited: {
28
- on: {
29
- CLEAR: [
30
- { guard: ({ context }) => context.retryCount >= 3, target: 'exceeded' },
31
- { target: 'normal' },
32
- ],
33
- HIT: {
34
- actions: assign(({ context, event }) => ({
35
- retryCount: context.retryCount + 1,
36
- retryAt: event.retryAt,
37
- cooldownMs: event.cooldownMs,
38
- isStreamDetected: event.isStreamDetected || false,
39
- })),
40
- },
41
- },
42
- },
43
- exceeded: {
44
- on: {
45
- RESET: { target: 'normal' },
46
- },
47
- },
48
- },
49
- });
50
-
51
- const actors = new Map();
52
-
53
- export function getOrCreate(convId) {
54
- if (actors.has(convId)) return actors.get(convId);
55
- const actor = createActor(machine);
56
- actor.start();
57
- actors.set(convId, actor);
58
- return actor;
59
- }
60
-
61
- export function get(convId) {
62
- return actors.get(convId) || null;
63
- }
64
-
65
- export function remove(convId) {
66
- const actor = actors.get(convId);
67
- if (actor) { actor.stop(); actors.delete(convId); }
68
- }
69
-
70
- export function snapshot(convId) {
71
- const actor = actors.get(convId);
72
- return actor ? actor.getSnapshot() : null;
73
- }
74
-
75
- export function send(convId, event) {
76
- const actor = getOrCreate(convId);
77
- actor.send(event);
78
- return actor.getSnapshot();
79
- }
80
-
81
- export function isLimited(convId) {
82
- const s = snapshot(convId);
83
- return s ? s.value === 'limited' : false;
84
- }
85
-
86
- export function isExceeded(convId) {
87
- const s = snapshot(convId);
88
- return s ? s.value === 'exceeded' : false;
89
- }
90
-
91
- export function getContext(convId) {
92
- const s = snapshot(convId);
93
- return s ? s.context : null;
94
- }
95
-
96
- export function has(convId) {
97
- const s = snapshot(convId);
98
- return s ? s.value !== 'normal' : false;
99
- }