agentgui 1.0.699 → 1.0.701

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
@@ -82,6 +82,7 @@ XState v5 machines provide formal state tracking alongside (not replacing) the e
82
82
  - `BASE_URL` - URL prefix (default: /gm)
83
83
  - `STARTUP_CWD` - Working directory passed to agents
84
84
  - `HOT_RELOAD` - Set to "false" to disable watch mode
85
+ - `CODEX_HOME` - Override Codex CLI home directory (default: `~/.codex`)
85
86
 
86
87
  ## ACP Tool Lifecycle
87
88
 
@@ -123,6 +124,11 @@ All routes are prefixed with `BASE_URL` (default `/gm`).
123
124
  - `GET /api/tools/:id/history` - Get tool install/update history (query: limit, offset)
124
125
  - `POST /api/tools/update` - Batch update all tools with available updates
125
126
  - `POST /api/tools/refresh-all` - Refresh all tool statuses from package manager
127
+ - `POST /api/codex-oauth/start` - Start Codex CLI OAuth flow (returns `{ authUrl, mode }`)
128
+ - `GET /api/codex-oauth/status` - Get current Codex OAuth state `{ status, email, error }`
129
+ - `POST /api/codex-oauth/relay` - Relay OAuth code+state from remote browser (body: `{ code, state }`)
130
+ - `POST /api/codex-oauth/complete` - Complete OAuth by pasting redirect URL (body: `{ url }`)
131
+ - `GET /codex-oauth2callback` - OAuth callback endpoint (redirect_uri for local flows)
126
132
 
127
133
  ## Tool Update System
128
134
 
@@ -286,6 +292,27 @@ Speech models (~470MB total) are downloaded automatically on server startup. No
286
292
  - **Client init:** `loadAgents()`, `loadConversations()`, `checkSpeechStatus()` run in parallel via `Promise.all()`.
287
293
  - **`perMessageDeflate: false`** on WebSocket server — msgpack binary doesn't compress well, and zlib was blocking the event loop on every streaming_progress send.
288
294
 
295
+ ## Codex CLI OAuth
296
+
297
+ OpenAI Codex CLI uses PKCE authorization code flow against `https://auth.openai.com`.
298
+
299
+ **Flow:**
300
+ 1. `POST /api/codex-oauth/start` generates PKCE (SHA-256 S256 challenge), CSRF state, returns `authUrl`
301
+ 2. User opens `authUrl` in browser and authenticates via OpenAI/ChatGPT
302
+ 3. **Local**: Browser redirects to `http://localhost:1455/auth/callback` — but since agentgui's server is on a different port, the redirect goes to `GET /codex-oauth2callback` (agentgui intercepts via matching route). Token exchange happens server-side.
303
+ 4. **Remote**: Redirect goes to `/codex-oauth2callback` which serves a relay page. Relay POSTs `{ code, state }` to `/api/codex-oauth/relay`. Token exchange happens on the server.
304
+ 5. Tokens saved to `$CODEX_HOME/auth.json` (default: `~/.codex/auth.json`) as `{ auth_mode: "chatgpt", tokens: { id_token, access_token, refresh_token }, last_refresh }`
305
+
306
+ **Constants (in server.js):**
307
+ - Issuer: `https://auth.openai.com`
308
+ - Client ID: `app_EMoamEEZ73f0CkXaXp7hrann`
309
+ - Scopes: `openid profile email offline_access api.connectors.read api.connectors.invoke`
310
+ - Redirect URI (local): `http://localhost:1455/auth/callback` (actual callback goes to agentgui's `/codex-oauth2callback`)
311
+
312
+ **WebSocket handlers** (in `lib/ws-handlers-util.js`): `codex.start`, `codex.status`, `codex.relay`, `codex.complete`
313
+
314
+ **Agent auth**: `POST /api/agents/codex/auth` starts OAuth flow same as Gemini — broadcasts `script_started`/`script_output`/`script_stopped` events as OAuth progresses.
315
+
289
316
  ## ACP SDK Integration
290
317
 
291
318
  - **@agentclientprotocol/sdk** (`^0.4.1`) added to dependencies
@@ -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;
@@ -9,6 +9,7 @@ export function register(router, deps) {
9
9
  const { queries, wsOptimizer, modelDownloadState, ensureModelsDownloaded,
10
10
  broadcastSync, getSpeech, getProviderConfigs, saveProviderConfig,
11
11
  startGeminiOAuth, exchangeGeminiOAuthCode, geminiOAuthState,
12
+ startCodexOAuth, exchangeCodexOAuthCode, codexOAuthState,
12
13
  STARTUP_CWD, activeScripts, voiceCacheManager, toolManager, discoveredAgents } = deps;
13
14
 
14
15
  router.handle('home', () => ({ home: os.homedir(), cwd: STARTUP_CWD }));
@@ -175,6 +176,45 @@ export function register(router, deps) {
175
176
  } catch (e) { err(400, e.message); }
176
177
  });
177
178
 
179
+ router.handle('codex.start', async () => {
180
+ try {
181
+ const result = await startCodexOAuth();
182
+ return { authUrl: result.authUrl, mode: result.mode };
183
+ } catch (e) { err(500, e.message); }
184
+ });
185
+
186
+ router.handle('codex.status', () => {
187
+ const st = typeof codexOAuthState === 'function' ? codexOAuthState() : codexOAuthState;
188
+ return st;
189
+ });
190
+
191
+ router.handle('codex.relay', async (p) => {
192
+ const { code, state } = p;
193
+ if (!code || !state) err(400, 'Missing code or state');
194
+ try {
195
+ const email = await exchangeCodexOAuthCode(code, state);
196
+ return { success: true, email };
197
+ } catch (e) { err(400, e.message); }
198
+ });
199
+
200
+ router.handle('codex.complete', async (p) => {
201
+ const pastedUrl = (p.url || '').trim();
202
+ if (!pastedUrl) err(400, 'No URL provided');
203
+ let parsed;
204
+ try { parsed = new URL(pastedUrl); } catch { err(400, 'Invalid URL. Paste the full URL from the browser address bar.'); }
205
+ const urlError = parsed.searchParams.get('error');
206
+ if (urlError) {
207
+ const desc = parsed.searchParams.get('error_description') || urlError;
208
+ return { error: desc };
209
+ }
210
+ const code = parsed.searchParams.get('code');
211
+ const state = parsed.searchParams.get('state');
212
+ try {
213
+ const email = await exchangeCodexOAuthCode(code, state);
214
+ return { success: true, email };
215
+ } catch (e) { err(400, e.message); }
216
+ });
217
+
178
218
  router.handle('ws.stats', () => wsOptimizer.getStats());
179
219
 
180
220
  router.handle('conv.scripts', (p) => {
@@ -268,47 +308,47 @@ export function register(router, deps) {
268
308
  }
269
309
  });
270
310
 
271
- router.handle('agent.subagents', async (p) => {
272
- const { id } = p;
273
- if (!id) err(400, 'Missing agent id');
274
-
275
- // Claude Code: run 'claude agents list' and parse output
276
- if (id === 'claude-code' || id === 'cli-claude') {
277
- const spawnEnv = { ...process.env };
278
- delete spawnEnv.CLAUDECODE;
279
- const result = spawnSync('claude', ['agents', 'list'], {
280
- encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'],
281
- env: spawnEnv
282
- });
283
- if (result.status !== 0 || !result.stdout) return { subAgents: [] };
284
- const output = result.stdout.trim();
285
- // Output format: ' agentId · model' lines under section headers
286
- const agents = [];
287
- for (const line of output.split('\n').filter(l => l.trim())) {
288
- const match = line.match(/^ (\S+)\s+·/);
289
- if (match) {
290
- const id = match[1];
291
- agents.push({ id, name: id });
292
- }
293
- }
294
- console.log('[agent.subagents] claude agents list found:', agents.map(a => a.id).join(', '));
295
- return { subAgents: agents };
296
- }
297
-
298
- // ACP agents: hardcoded map filtered by installed tools
299
- const subAgentMap = {
300
- 'opencode': [{ id: 'gm-oc', name: 'GM OpenCode' }],
301
- 'cli-opencode': [{ id: 'gm-oc', name: 'GM OpenCode' }],
302
- 'gemini': [{ id: 'gm-gc', name: 'GM Gemini' }],
303
- 'cli-gemini': [{ id: 'gm-gc', name: 'GM Gemini' }],
304
- 'kilo': [{ id: 'gm-kilo', name: 'GM Kilo' }],
305
- 'cli-kilo': [{ id: 'gm-kilo', name: 'GM Kilo' }],
306
- 'codex': [],
307
- 'cli-codex': []
308
- };
309
- const subAgents = subAgentMap[id] || [];
310
- const tools = await toolManager.getAllToolsAsync();
311
- const installed = new Set(tools.filter(t => t.category === 'plugin' && t.installed).map(t => t.id));
312
- return { subAgents: subAgents.filter(sa => installed.has(sa.id)) };
311
+ router.handle('agent.subagents', async (p) => {
312
+ const { id } = p;
313
+ if (!id) err(400, 'Missing agent id');
314
+
315
+ // Claude Code: run 'claude agents list' and parse output
316
+ if (id === 'claude-code' || id === 'cli-claude') {
317
+ const spawnEnv = { ...process.env };
318
+ delete spawnEnv.CLAUDECODE;
319
+ const result = spawnSync('claude', ['agents', 'list'], {
320
+ encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'],
321
+ env: spawnEnv
322
+ });
323
+ if (result.status !== 0 || !result.stdout) return { subAgents: [] };
324
+ const output = result.stdout.trim();
325
+ // Output format: ' agentId · model' lines under section headers
326
+ const agents = [];
327
+ for (const line of output.split('\n').filter(l => l.trim())) {
328
+ const match = line.match(/^ (\S+)\s+·/);
329
+ if (match) {
330
+ const id = match[1];
331
+ agents.push({ id, name: id });
332
+ }
333
+ }
334
+ console.log('[agent.subagents] claude agents list found:', agents.map(a => a.id).join(', '));
335
+ return { subAgents: agents };
336
+ }
337
+
338
+ // ACP agents: hardcoded map filtered by installed tools
339
+ const subAgentMap = {
340
+ 'opencode': [{ id: 'gm-oc', name: 'GM OpenCode' }],
341
+ 'cli-opencode': [{ id: 'gm-oc', name: 'GM OpenCode' }],
342
+ 'gemini': [{ id: 'gm-gc', name: 'GM Gemini' }],
343
+ 'cli-gemini': [{ id: 'gm-gc', name: 'GM Gemini' }],
344
+ 'kilo': [{ id: 'gm-kilo', name: 'GM Kilo' }],
345
+ 'cli-kilo': [{ id: 'gm-kilo', name: 'GM Kilo' }],
346
+ 'codex': [],
347
+ 'cli-codex': []
348
+ };
349
+ const subAgents = subAgentMap[id] || [];
350
+ const tools = await toolManager.getAllToolsAsync();
351
+ const installed = new Set(tools.filter(t => t.category === 'plugin' && t.installed).map(t => t.id));
352
+ return { subAgents: subAgents.filter(sa => installed.has(sa.id)) };
313
353
  });
314
354
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.699",
3
+ "version": "1.0.701",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",