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 +28 -1
- package/lib/execution-machine.js +35 -0
- package/lib/ws-handlers-conv.js +51 -34
- package/lib/ws-handlers-util.js +82 -42
- package/package.json +1 -1
- package/server.js +385 -23
- package/static/js/client.js +43 -28
- package/static/js/conv-machine.js +21 -10
- package/static/js/websocket-manager.js +18 -1
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:
|
|
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
|
package/lib/execution-machine.js
CHANGED
|
@@ -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
|
}
|
package/lib/ws-handlers-conv.js
CHANGED
|
@@ -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 && !
|
|
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:
|
|
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:
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
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(
|
|
231
|
-
const queueLength =
|
|
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
|
-
//
|
|
253
|
-
if (!
|
|
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
|
-
//
|
|
287
|
-
if (!
|
|
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:
|
|
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
|
-
|
|
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
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if (
|
|
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:
|
|
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
|
|
331
|
-
|
|
332
|
-
|
|
346
|
+
const machineQueue = execMachine.getQueue(p.id);
|
|
347
|
+
const mapQueue = messageQueues.get(p.id);
|
|
348
|
+
if (!machineQueue.length && !mapQueue) notFound('Queue not found');
|
|
349
|
+
const item = machineQueue.find(q => q.messageId === p.messageId) || mapQueue?.find(q => q.messageId === p.messageId);
|
|
333
350
|
if (!item) notFound('Queued message not found');
|
|
334
351
|
if (p.content !== undefined) item.content = p.content;
|
|
335
352
|
if (p.agentId !== undefined) item.agentId = p.agentId;
|
package/lib/ws-handlers-util.js
CHANGED
|
@@ -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
|
}
|