@geminilight/mindos 0.6.29 → 0.6.30

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.
Files changed (71) hide show
  1. package/README.md +10 -4
  2. package/app/app/api/acp/config/route.ts +82 -0
  3. package/app/app/api/acp/detect/route.ts +71 -48
  4. package/app/app/api/acp/install/route.ts +51 -0
  5. package/app/app/api/acp/session/route.ts +141 -11
  6. package/app/app/api/ask/route.ts +116 -13
  7. package/app/app/api/workflows/route.ts +156 -0
  8. package/app/app/page.tsx +7 -2
  9. package/app/components/ActivityBar.tsx +12 -4
  10. package/app/components/AskModal.tsx +4 -1
  11. package/app/components/FileTree.tsx +21 -10
  12. package/app/components/HomeContent.tsx +1 -0
  13. package/app/components/Panel.tsx +1 -0
  14. package/app/components/RightAskPanel.tsx +5 -1
  15. package/app/components/SidebarLayout.tsx +6 -0
  16. package/app/components/agents/AgentDetailContent.tsx +263 -47
  17. package/app/components/agents/AgentsContentPage.tsx +11 -0
  18. package/app/components/agents/AgentsPanelA2aTab.tsx +285 -46
  19. package/app/components/agents/AgentsPanelSessionsTab.tsx +166 -0
  20. package/app/components/agents/agents-content-model.ts +2 -2
  21. package/app/components/ask/AgentSelectorCapsule.tsx +218 -0
  22. package/app/components/ask/AskContent.tsx +197 -239
  23. package/app/components/ask/FileChip.tsx +82 -17
  24. package/app/components/ask/MentionPopover.tsx +21 -3
  25. package/app/components/ask/MessageList.tsx +30 -9
  26. package/app/components/ask/SlashCommandPopover.tsx +21 -3
  27. package/app/components/panels/AgentsPanel.tsx +1 -0
  28. package/app/components/panels/AgentsPanelHubNav.tsx +9 -2
  29. package/app/components/panels/WorkflowsPanel.tsx +206 -0
  30. package/app/components/renderers/workflow-yaml/StepEditor.tsx +157 -0
  31. package/app/components/renderers/workflow-yaml/WorkflowEditor.tsx +201 -0
  32. package/app/components/renderers/workflow-yaml/WorkflowRunner.tsx +226 -0
  33. package/app/components/renderers/workflow-yaml/WorkflowYamlRenderer.tsx +126 -0
  34. package/app/components/renderers/workflow-yaml/execution.ts +177 -0
  35. package/app/components/renderers/workflow-yaml/index.ts +6 -0
  36. package/app/components/renderers/workflow-yaml/manifest.ts +21 -0
  37. package/app/components/renderers/workflow-yaml/parser.ts +172 -0
  38. package/app/components/renderers/workflow-yaml/selectors.tsx +522 -0
  39. package/app/components/renderers/workflow-yaml/serializer.ts +56 -0
  40. package/app/components/renderers/workflow-yaml/types.ts +46 -0
  41. package/app/hooks/useAcpConfig.ts +96 -0
  42. package/app/hooks/useAcpDetection.ts +69 -14
  43. package/app/hooks/useAcpRegistry.ts +46 -11
  44. package/app/hooks/useAskModal.ts +12 -5
  45. package/app/hooks/useAskPanel.ts +8 -5
  46. package/app/hooks/useAskSession.ts +19 -2
  47. package/app/hooks/useImageUpload.ts +152 -0
  48. package/app/lib/acp/acp-tools.ts +3 -1
  49. package/app/lib/acp/agent-descriptors.ts +274 -0
  50. package/app/lib/acp/bridge.ts +6 -0
  51. package/app/lib/acp/index.ts +20 -4
  52. package/app/lib/acp/registry.ts +74 -7
  53. package/app/lib/acp/session.ts +481 -28
  54. package/app/lib/acp/subprocess.ts +307 -21
  55. package/app/lib/acp/types.ts +158 -20
  56. package/app/lib/agent/model.ts +18 -3
  57. package/app/lib/agent/to-agent-messages.ts +25 -2
  58. package/app/lib/i18n/modules/knowledge.ts +4 -0
  59. package/app/lib/i18n/modules/navigation.ts +2 -0
  60. package/app/lib/i18n/modules/panels.ts +146 -2
  61. package/app/lib/pi-integration/skills.ts +21 -6
  62. package/app/lib/renderers/index.ts +2 -2
  63. package/app/lib/settings.ts +10 -0
  64. package/app/lib/types.ts +12 -1
  65. package/app/next-env.d.ts +1 -1
  66. package/app/package.json +3 -1
  67. package/package.json +1 -1
  68. package/templates/en/.mindos/workflows/Sprint Release.flow.yaml +130 -0
  69. package/templates/zh/.mindos/workflows//345/221/250/350/277/255/344/273/243/346/243/200/346/237/245.flow.yaml +84 -0
  70. package/app/components/renderers/workflow/WorkflowRenderer.tsx +0 -409
  71. package/app/components/renderers/workflow/manifest.ts +0 -14
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * ACP Session Manager — High-level session lifecycle for ACP agents.
3
3
  * Manages session creation, prompt turns, cancellation, and cleanup.
4
+ * Implements full ACP spec: initialize → session/new → session/prompt → session/cancel → close.
4
5
  */
5
6
 
6
7
  import type {
@@ -9,6 +10,13 @@ import type {
9
10
  AcpSessionUpdate,
10
11
  AcpPromptResponse,
11
12
  AcpRegistryEntry,
13
+ AcpAgentCapabilities,
14
+ AcpMode,
15
+ AcpConfigOption,
16
+ AcpSessionInfo,
17
+ AcpStopReason,
18
+ AcpAuthMethod,
19
+ AcpContentBlock,
12
20
  } from './types';
13
21
  import {
14
22
  spawnAcpAgent,
@@ -16,6 +24,7 @@ import {
16
24
  sendMessage,
17
25
  onMessage,
18
26
  killAgent,
27
+ installAutoApproval,
19
28
  type AcpProcess,
20
29
  } from './subprocess';
21
30
  import { findAcpAgent } from './registry';
@@ -24,15 +33,17 @@ import { findAcpAgent } from './registry';
24
33
 
25
34
  const sessions = new Map<string, AcpSession>();
26
35
  const sessionProcesses = new Map<string, AcpProcess>();
36
+ const autoApprovalCleanups = new Map<string, () => void>();
27
37
 
28
- /* ── Public API ────────────────────────────────────────────────────────── */
38
+ /* ── Public API Session Lifecycle ───────────────────────────────────── */
29
39
 
30
40
  /**
31
41
  * Create a new ACP session by spawning an agent process.
42
+ * Full ACP lifecycle: spawn → initialize → authenticate (if needed) → session/new.
32
43
  */
33
44
  export async function createSession(
34
45
  agentId: string,
35
- options?: { env?: Record<string, string> },
46
+ options?: { env?: Record<string, string>; cwd?: string },
36
47
  ): Promise<AcpSession> {
37
48
  const entry = await findAcpAgent(agentId);
38
49
  if (!entry) {
@@ -47,37 +58,248 @@ export async function createSession(
47
58
  */
48
59
  export async function createSessionFromEntry(
49
60
  entry: AcpRegistryEntry,
50
- options?: { env?: Record<string, string> },
61
+ options?: { env?: Record<string, string>; cwd?: string },
51
62
  ): Promise<AcpSession> {
52
63
  const proc = spawnAcpAgent(entry, options);
53
64
 
54
- // Send session/new and wait for ack
65
+ // Install auto-approval BEFORE initialize so any early permission requests
66
+ // from the agent don't cause a hang waiting for TTY input.
67
+ const unsubApproval = installAutoApproval(proc);
68
+
69
+ let agentCapabilities: AcpAgentCapabilities | undefined;
70
+ let authMethods: AcpAuthMethod[] | undefined;
71
+
72
+ // Phase 1: Initialize — negotiate protocol and capabilities
55
73
  try {
56
- const response = await sendAndWait(proc, 'session/new', {}, 15_000);
74
+ const response = await sendAndWait(proc, 'initialize', {
75
+ protocolVersion: 1,
76
+ capabilities: {
77
+ fs: { readTextFile: true, writeTextFile: true },
78
+ terminal: true,
79
+ },
80
+ clientInfo: { name: 'mindos', version: '0.6.29' },
81
+ }, 30_000);
57
82
 
58
83
  if (response.error) {
84
+ unsubApproval();
59
85
  killAgent(proc);
60
- throw new Error(`session/new failed: ${response.error.message}`);
86
+ throw new Error(`initialize failed: ${response.error.message}`);
87
+ }
88
+
89
+ // Parse agent capabilities from response
90
+ const initResult = response.result as Record<string, unknown> | undefined;
91
+ if (initResult) {
92
+ agentCapabilities = parseAgentCapabilities(initResult.agentCapabilities);
93
+ authMethods = parseAuthMethods(initResult.authMethods);
61
94
  }
62
95
  } catch (err) {
96
+ unsubApproval();
63
97
  killAgent(proc);
64
98
  throw err;
65
99
  }
66
100
 
101
+ // Phase 2: Authenticate (if agent declares auth methods)
102
+ if (authMethods && authMethods.length > 0) {
103
+ try {
104
+ const authResponse = await sendAndWait(proc, 'authenticate', {
105
+ methodId: authMethods[0].id,
106
+ }, 15_000);
107
+
108
+ if (authResponse.error) {
109
+ // Authentication failed — non-fatal, log and continue
110
+ console.warn(`ACP authenticate warning for ${entry.id}: ${authResponse.error.message}`);
111
+ }
112
+ } catch {
113
+ // Best-effort auth — agent may not require it
114
+ }
115
+ }
116
+
117
+ // Phase 3: session/new — create the conversation session
118
+ let modes: AcpMode[] | undefined;
119
+ let configOptions: AcpConfigOption[] | undefined;
120
+
121
+ try {
122
+ const newResponse = await sendAndWait(proc, 'session/new', {
123
+ cwd: options?.cwd ?? process.cwd(),
124
+ mcpServers: [],
125
+ }, 15_000);
126
+
127
+ if (newResponse.error) {
128
+ // Non-fatal: some agents may not support explicit session/new
129
+ console.warn(`ACP session/new warning for ${entry.id}: ${newResponse.error.message}`);
130
+ } else {
131
+ const newResult = newResponse.result as Record<string, unknown> | undefined;
132
+ if (newResult) {
133
+ modes = parseModes(newResult.modes);
134
+ configOptions = parseConfigOptions(newResult.configOptions);
135
+ }
136
+ }
137
+ } catch {
138
+ // Non-fatal: agent may not support explicit session/new (backwards compat)
139
+ }
140
+
67
141
  const sessionId = `ses-${entry.id}-${Date.now()}`;
68
142
  const session: AcpSession = {
69
143
  id: sessionId,
70
144
  agentId: entry.id,
71
145
  state: 'idle',
146
+ cwd: options?.cwd,
72
147
  createdAt: new Date().toISOString(),
73
148
  lastActivityAt: new Date().toISOString(),
149
+ agentCapabilities,
150
+ modes,
151
+ configOptions,
152
+ authMethods,
74
153
  };
75
154
 
76
155
  sessions.set(sessionId, session);
77
156
  sessionProcesses.set(sessionId, proc);
157
+ autoApprovalCleanups.set(sessionId, unsubApproval);
158
+ return session;
159
+ }
160
+
161
+ /**
162
+ * Load/resume an existing session on an agent.
163
+ * Requires agent to declare `loadSession` capability.
164
+ */
165
+ export async function loadSession(
166
+ agentId: string,
167
+ existingSessionId: string,
168
+ options?: { env?: Record<string, string>; cwd?: string },
169
+ ): Promise<AcpSession> {
170
+ const entry = await findAcpAgent(agentId);
171
+ if (!entry) {
172
+ throw new Error(`ACP agent not found in registry: ${agentId}`);
173
+ }
174
+
175
+ const proc = spawnAcpAgent(entry, options);
176
+ const unsubApproval = installAutoApproval(proc);
177
+
178
+ let agentCapabilities: AcpAgentCapabilities | undefined;
179
+
180
+ // Initialize
181
+ try {
182
+ const initResponse = await sendAndWait(proc, 'initialize', {
183
+ protocolVersion: 1,
184
+ capabilities: {
185
+ fs: { readTextFile: true, writeTextFile: true },
186
+ terminal: true,
187
+ },
188
+ clientInfo: { name: 'mindos', version: '0.6.29' },
189
+ }, 30_000);
190
+
191
+ if (initResponse.error) {
192
+ unsubApproval();
193
+ killAgent(proc);
194
+ throw new Error(`initialize failed: ${initResponse.error.message}`);
195
+ }
196
+
197
+ const initResult = initResponse.result as Record<string, unknown> | undefined;
198
+ if (initResult) {
199
+ agentCapabilities = parseAgentCapabilities(initResult.agentCapabilities);
200
+ }
201
+ } catch (err) {
202
+ unsubApproval();
203
+ killAgent(proc);
204
+ throw err;
205
+ }
206
+
207
+ // Check if agent supports loadSession
208
+ if (!agentCapabilities?.loadSession) {
209
+ unsubApproval();
210
+ killAgent(proc);
211
+ throw new Error(`Agent ${agentId} does not support session/load (loadSession capability not declared)`);
212
+ }
213
+
214
+ // session/load — resume the existing session
215
+ let modes: AcpMode[] | undefined;
216
+ let configOptions: AcpConfigOption[] | undefined;
217
+
218
+ try {
219
+ const loadResponse = await sendAndWait(proc, 'session/load', {
220
+ sessionId: existingSessionId,
221
+ cwd: options?.cwd ?? process.cwd(),
222
+ mcpServers: [],
223
+ }, 15_000);
224
+
225
+ if (loadResponse.error) {
226
+ unsubApproval();
227
+ killAgent(proc);
228
+ throw new Error(`session/load failed: ${loadResponse.error.message}`);
229
+ }
230
+
231
+ const loadResult = loadResponse.result as Record<string, unknown> | undefined;
232
+ if (loadResult) {
233
+ modes = parseModes(loadResult.modes);
234
+ configOptions = parseConfigOptions(loadResult.configOptions);
235
+ }
236
+ } catch (err) {
237
+ unsubApproval();
238
+ killAgent(proc);
239
+ throw err;
240
+ }
241
+
242
+ // Use the original sessionId since we're resuming
243
+ const session: AcpSession = {
244
+ id: existingSessionId,
245
+ agentId: entry.id,
246
+ state: 'idle',
247
+ cwd: options?.cwd,
248
+ createdAt: new Date().toISOString(),
249
+ lastActivityAt: new Date().toISOString(),
250
+ agentCapabilities,
251
+ modes,
252
+ configOptions,
253
+ };
254
+
255
+ sessions.set(existingSessionId, session);
256
+ sessionProcesses.set(existingSessionId, proc);
257
+ autoApprovalCleanups.set(existingSessionId, unsubApproval);
78
258
  return session;
79
259
  }
80
260
 
261
+ /**
262
+ * List resumable sessions from the agent.
263
+ * Requires agent to declare `sessionCapabilities.list`.
264
+ */
265
+ export async function listSessions(
266
+ sessionId: string,
267
+ options?: { cursor?: string; cwd?: string },
268
+ ): Promise<{ sessions: AcpSessionInfo[]; nextCursor?: string }> {
269
+ const { session, proc } = getSessionAndProc(sessionId);
270
+
271
+ if (!session.agentCapabilities?.sessionCapabilities?.list) {
272
+ throw new Error('Agent does not support session/list');
273
+ }
274
+
275
+ const response = await sendAndWait(proc, 'session/list', {
276
+ ...(options?.cursor ? { cursor: options.cursor } : {}),
277
+ ...(options?.cwd ? { cwd: options.cwd } : {}),
278
+ }, 10_000);
279
+
280
+ if (response.error) {
281
+ throw new Error(`session/list failed: ${response.error.message}`);
282
+ }
283
+
284
+ const result = response.result as Record<string, unknown> | undefined;
285
+ const rawSessions = Array.isArray(result?.sessions) ? result.sessions : [];
286
+
287
+ return {
288
+ sessions: rawSessions.map((s: unknown) => {
289
+ const obj = s as Record<string, unknown>;
290
+ return {
291
+ sessionId: String(obj.sessionId ?? ''),
292
+ title: typeof obj.title === 'string' ? obj.title : undefined,
293
+ cwd: typeof obj.cwd === 'string' ? obj.cwd : undefined,
294
+ updatedAt: typeof obj.updatedAt === 'string' ? obj.updatedAt : undefined,
295
+ };
296
+ }),
297
+ nextCursor: typeof result?.nextCursor === 'string' ? result.nextCursor : undefined,
298
+ };
299
+ }
300
+
301
+ /* ── Public API — Prompt ──────────────────────────────────────────────── */
302
+
81
303
  /**
82
304
  * Send a prompt to an active session and collect the full response.
83
305
  * For streaming, use promptStream() instead.
@@ -95,7 +317,11 @@ export async function prompt(
95
317
  updateSessionState(session, 'active');
96
318
 
97
319
  try {
98
- const response = await sendAndWait(proc, 'session/prompt', { text }, 60_000);
320
+ const response = await sendAndWait(proc, 'session/prompt', {
321
+ sessionId,
322
+ prompt: [{ type: 'text', text }] satisfies AcpContentBlock[],
323
+ ...(session.cwd ? { context: { cwd: session.cwd } } : {}),
324
+ }, 60_000);
99
325
 
100
326
  if (response.error) {
101
327
  updateSessionState(session, 'error');
@@ -108,6 +334,7 @@ export async function prompt(
108
334
  sessionId,
109
335
  text: String(result?.text ?? ''),
110
336
  done: true,
337
+ stopReason: parseStopReason(result?.stopReason),
111
338
  toolCalls: result?.toolCalls as AcpPromptResponse['toolCalls'],
112
339
  metadata: result?.metadata as AcpPromptResponse['metadata'],
113
340
  };
@@ -119,6 +346,7 @@ export async function prompt(
119
346
 
120
347
  /**
121
348
  * Send a prompt and receive streaming updates via callback.
349
+ * Handles all 10 ACP session/update types.
122
350
  * Returns the final aggregated response.
123
351
  */
124
352
  export async function promptStream(
@@ -136,47 +364,79 @@ export async function promptStream(
136
364
 
137
365
  return new Promise((resolve, reject) => {
138
366
  let aggregatedText = '';
367
+ let stopReason: AcpStopReason = 'end_turn';
139
368
 
140
369
  const unsub = onMessage(proc, (msg) => {
141
- // Handle streaming notifications (no id field, or notification pattern)
142
370
  if (msg.result && typeof msg.result === 'object') {
143
- const update = msg.result as Record<string, unknown>;
144
- const sessionUpdate: AcpSessionUpdate = {
145
- sessionId,
146
- type: (update.type as AcpSessionUpdate['type']) ?? 'text',
147
- text: update.text as string | undefined,
148
- toolCall: update.toolCall as AcpSessionUpdate['toolCall'],
149
- toolResult: update.toolResult as AcpSessionUpdate['toolResult'],
150
- error: update.error as string | undefined,
151
- };
371
+ const raw = msg.result as Record<string, unknown>;
372
+ const update = parseSessionUpdate(sessionId, raw);
152
373
 
153
- onUpdate(sessionUpdate);
374
+ onUpdate(update);
154
375
 
155
- if (sessionUpdate.type === 'text' && sessionUpdate.text) {
156
- aggregatedText += sessionUpdate.text;
376
+ // Aggregate text from message chunk types
377
+ if ((update.type === 'agent_message_chunk' || update.type === 'text') && update.text) {
378
+ aggregatedText += update.text;
157
379
  }
158
380
 
159
- if (sessionUpdate.type === 'done') {
381
+ // Handle terminal states
382
+ if (update.type === 'done') {
160
383
  unsub();
384
+ if (raw.stopReason) {
385
+ stopReason = parseStopReason(raw.stopReason);
386
+ }
161
387
  updateSessionState(session, 'idle');
162
388
  resolve({
163
389
  sessionId,
164
390
  text: aggregatedText,
165
391
  done: true,
392
+ stopReason,
166
393
  });
167
394
  }
168
395
 
169
- if (sessionUpdate.type === 'error') {
396
+ if (update.type === 'error') {
170
397
  unsub();
171
398
  updateSessionState(session, 'error');
172
- reject(new Error(sessionUpdate.error ?? 'Unknown ACP error'));
399
+ reject(new Error(update.error ?? 'Unknown ACP error'));
400
+ }
401
+
402
+ // Update session metadata from config/mode updates
403
+ if (update.type === 'config_option_update' && update.configOptions) {
404
+ session.configOptions = update.configOptions;
405
+ }
406
+ if (update.type === 'current_mode_update' && update.currentModeId) {
407
+ // Track current mode
408
+ session.lastActivityAt = new Date().toISOString();
173
409
  }
174
410
  }
175
411
  });
176
412
 
177
- // Send the prompt
413
+ // Guard against agent process dying unexpectedly (OOM, SIGKILL, etc.)
414
+ // Without this, the Promise would hang forever if the process exits
415
+ // without sending a done/error notification.
416
+ const onExit = () => {
417
+ unsub();
418
+ updateSessionState(session, 'error');
419
+ reject(new Error(`ACP agent process exited unexpectedly during prompt`));
420
+ };
421
+ proc.proc.once('exit', onExit);
422
+
423
+ // Clean up exit listener when Promise resolves/rejects normally
424
+ const cleanup = () => { proc.proc.removeListener('exit', onExit); };
425
+ // Wrap resolve/reject to include cleanup — but we already unsub in the message handler.
426
+ // The exit listener is a safety net; if done/error fires first, remove the exit listener.
427
+ const origResolve = resolve;
428
+ const origReject = reject;
429
+ resolve = ((val: AcpPromptResponse) => { cleanup(); origResolve(val); }) as typeof resolve;
430
+ reject = ((err: unknown) => { cleanup(); origReject(err); }) as typeof reject;
431
+
432
+ // Send the prompt with ContentBlock format
178
433
  try {
179
- sendMessage(proc, 'session/prompt', { text, stream: true });
434
+ sendMessage(proc, 'session/prompt', {
435
+ sessionId,
436
+ prompt: [{ type: 'text', text }] satisfies AcpContentBlock[],
437
+ stream: true,
438
+ ...(session.cwd ? { context: { cwd: session.cwd } } : {}),
439
+ });
180
440
  } catch (err) {
181
441
  unsub();
182
442
  updateSessionState(session, 'error');
@@ -185,6 +445,8 @@ export async function promptStream(
185
445
  });
186
446
  }
187
447
 
448
+ /* ── Public API — Session Control ─────────────────────────────────────── */
449
+
188
450
  /**
189
451
  * Cancel the current prompt turn on a session.
190
452
  */
@@ -194,7 +456,7 @@ export async function cancelPrompt(sessionId: string): Promise<void> {
194
456
  if (session.state !== 'active') return;
195
457
 
196
458
  try {
197
- await sendAndWait(proc, 'session/cancel', {}, 5_000);
459
+ await sendAndWait(proc, 'session/cancel', { sessionId }, 10_000);
198
460
  } catch {
199
461
  // Best-effort cancel — don't throw if the agent doesn't support it
200
462
  }
@@ -202,6 +464,54 @@ export async function cancelPrompt(sessionId: string): Promise<void> {
202
464
  updateSessionState(session, 'idle');
203
465
  }
204
466
 
467
+ /**
468
+ * Set the operating mode for a session.
469
+ */
470
+ export async function setMode(sessionId: string, modeId: string): Promise<void> {
471
+ const { session, proc } = getSessionAndProc(sessionId);
472
+
473
+ const response = await sendAndWait(proc, 'session/set_mode', {
474
+ sessionId,
475
+ modeId,
476
+ }, 10_000);
477
+
478
+ if (response.error) {
479
+ throw new Error(`session/set_mode failed: ${response.error.message}`);
480
+ }
481
+
482
+ session.lastActivityAt = new Date().toISOString();
483
+ }
484
+
485
+ /**
486
+ * Set a configuration option for a session.
487
+ */
488
+ export async function setConfigOption(
489
+ sessionId: string,
490
+ configId: string,
491
+ value: string,
492
+ ): Promise<AcpConfigOption[]> {
493
+ const { session, proc } = getSessionAndProc(sessionId);
494
+
495
+ const response = await sendAndWait(proc, 'session/set_config_option', {
496
+ sessionId,
497
+ configId,
498
+ value,
499
+ }, 10_000);
500
+
501
+ if (response.error) {
502
+ throw new Error(`session/set_config_option failed: ${response.error.message}`);
503
+ }
504
+
505
+ const result = response.result as Record<string, unknown> | undefined;
506
+ const configOptions = parseConfigOptions(result?.configOptions);
507
+ if (configOptions) {
508
+ session.configOptions = configOptions;
509
+ }
510
+
511
+ session.lastActivityAt = new Date().toISOString();
512
+ return session.configOptions ?? [];
513
+ }
514
+
205
515
  /**
206
516
  * Close a session and terminate the subprocess.
207
517
  */
@@ -210,7 +520,7 @@ export async function closeSession(sessionId: string): Promise<void> {
210
520
 
211
521
  if (proc?.alive) {
212
522
  try {
213
- await sendAndWait(proc, 'session/close', {}, 5_000);
523
+ await sendAndWait(proc, 'session/close', { sessionId }, 5_000);
214
524
  } catch {
215
525
  // Best-effort close
216
526
  }
@@ -219,8 +529,15 @@ export async function closeSession(sessionId: string): Promise<void> {
219
529
 
220
530
  sessions.delete(sessionId);
221
531
  sessionProcesses.delete(sessionId);
532
+ const cleanup = autoApprovalCleanups.get(sessionId);
533
+ if (cleanup) {
534
+ cleanup();
535
+ autoApprovalCleanups.delete(sessionId);
536
+ }
222
537
  }
223
538
 
539
+ /* ── Public API — Queries ─────────────────────────────────────────────── */
540
+
224
541
  /**
225
542
  * Get a session by its ID.
226
543
  */
@@ -243,7 +560,7 @@ export async function closeAllSessions(): Promise<void> {
243
560
  await Promise.allSettled(ids.map(id => closeSession(id)));
244
561
  }
245
562
 
246
- /* ── Internal ──────────────────────────────────────────────────────────── */
563
+ /* ── Internal Session helpers ───────────────────────────────────────── */
247
564
 
248
565
  function getSessionAndProc(sessionId: string): { session: AcpSession; proc: AcpProcess } {
249
566
  const session = sessions.get(sessionId);
@@ -262,3 +579,139 @@ function updateSessionState(session: AcpSession, state: AcpSessionState): void {
262
579
  session.state = state;
263
580
  session.lastActivityAt = new Date().toISOString();
264
581
  }
582
+
583
+ /* ── Internal — Parsers ───────────────────────────────────────────────── */
584
+
585
+ function parseStopReason(raw: unknown): AcpStopReason {
586
+ const valid: AcpStopReason[] = ['end_turn', 'max_tokens', 'max_turn_requests', 'refusal', 'cancelled'];
587
+ return valid.includes(raw as AcpStopReason) ? (raw as AcpStopReason) : 'end_turn';
588
+ }
589
+
590
+ function parseAgentCapabilities(raw: unknown): AcpAgentCapabilities | undefined {
591
+ if (!raw || typeof raw !== 'object') return undefined;
592
+ const obj = raw as Record<string, unknown>;
593
+ return {
594
+ loadSession: obj.loadSession === true,
595
+ mcpCapabilities: typeof obj.mcpCapabilities === 'object' ? obj.mcpCapabilities as AcpAgentCapabilities['mcpCapabilities'] : undefined,
596
+ promptCapabilities: typeof obj.promptCapabilities === 'object' ? obj.promptCapabilities as AcpAgentCapabilities['promptCapabilities'] : undefined,
597
+ sessionCapabilities: typeof obj.sessionCapabilities === 'object' ? obj.sessionCapabilities as AcpAgentCapabilities['sessionCapabilities'] : undefined,
598
+ };
599
+ }
600
+
601
+ function parseAuthMethods(raw: unknown): AcpAuthMethod[] | undefined {
602
+ if (!Array.isArray(raw) || raw.length === 0) return undefined;
603
+ return raw
604
+ .filter((m): m is Record<string, unknown> => !!m && typeof m === 'object')
605
+ .map(m => ({
606
+ id: String(m.id ?? ''),
607
+ name: String(m.name ?? ''),
608
+ description: typeof m.description === 'string' ? m.description : undefined,
609
+ }))
610
+ .filter(m => m.id && m.name);
611
+ }
612
+
613
+ function parseModes(raw: unknown): AcpMode[] | undefined {
614
+ if (!Array.isArray(raw) || raw.length === 0) return undefined;
615
+ return raw
616
+ .filter((m): m is Record<string, unknown> => !!m && typeof m === 'object')
617
+ .map(m => ({
618
+ id: String(m.id ?? ''),
619
+ name: String(m.name ?? ''),
620
+ description: typeof m.description === 'string' ? m.description : undefined,
621
+ }))
622
+ .filter(m => m.id && m.name);
623
+ }
624
+
625
+ function parseConfigOptions(raw: unknown): AcpConfigOption[] | undefined {
626
+ if (!Array.isArray(raw) || raw.length === 0) return undefined;
627
+ return raw
628
+ .filter((o): o is Record<string, unknown> => !!o && typeof o === 'object')
629
+ .map(o => ({
630
+ type: 'select' as const,
631
+ configId: String(o.configId ?? o.id ?? ''),
632
+ category: String(o.category ?? 'other'),
633
+ label: typeof o.label === 'string' ? o.label : undefined,
634
+ currentValue: String(o.currentValue ?? ''),
635
+ options: Array.isArray(o.options) ? o.options.map((opt: unknown) => {
636
+ const optObj = opt as Record<string, unknown>;
637
+ return { id: String(optObj.id ?? ''), label: String(optObj.label ?? '') };
638
+ }) : [],
639
+ }))
640
+ .filter(o => o.configId);
641
+ }
642
+
643
+ /** Parse a raw session/update notification into a typed AcpSessionUpdate. */
644
+ function parseSessionUpdate(sessionId: string, raw: Record<string, unknown>): AcpSessionUpdate {
645
+ const type = raw.type as AcpSessionUpdate['type'] ?? 'text';
646
+
647
+ const base: AcpSessionUpdate = { sessionId, type };
648
+
649
+ switch (type) {
650
+ case 'agent_message_chunk':
651
+ case 'user_message_chunk':
652
+ case 'agent_thought_chunk':
653
+ case 'text':
654
+ base.text = typeof raw.text === 'string' ? raw.text
655
+ : typeof raw.content === 'string' ? raw.content
656
+ : undefined;
657
+ break;
658
+
659
+ case 'tool_call':
660
+ case 'tool_call_update':
661
+ if (raw.toolCall && typeof raw.toolCall === 'object') {
662
+ base.toolCall = raw.toolCall as AcpSessionUpdate['toolCall'];
663
+ } else {
664
+ // Top-level tool call fields
665
+ base.toolCall = {
666
+ toolCallId: String(raw.toolCallId ?? ''),
667
+ title: typeof raw.title === 'string' ? raw.title : undefined,
668
+ kind: raw.kind as AcpSessionUpdate['toolCall'] extends { kind: infer K } ? K : undefined,
669
+ status: (raw.status as 'pending' | 'in_progress' | 'completed' | 'failed') ?? 'pending',
670
+ rawInput: typeof raw.rawInput === 'string' ? raw.rawInput : undefined,
671
+ rawOutput: typeof raw.rawOutput === 'string' ? raw.rawOutput : undefined,
672
+ };
673
+ }
674
+ break;
675
+
676
+ case 'plan':
677
+ if (raw.entries && Array.isArray(raw.entries)) {
678
+ base.plan = { entries: raw.entries as AcpSessionUpdate['plan'] extends { entries: infer E } ? E : never };
679
+ } else if (raw.plan && typeof raw.plan === 'object') {
680
+ base.plan = raw.plan as AcpSessionUpdate['plan'];
681
+ }
682
+ break;
683
+
684
+ case 'available_commands_update':
685
+ base.availableCommands = Array.isArray(raw.availableCommands) ? raw.availableCommands : undefined;
686
+ break;
687
+
688
+ case 'current_mode_update':
689
+ base.currentModeId = typeof raw.currentModeId === 'string' ? raw.currentModeId : undefined;
690
+ break;
691
+
692
+ case 'config_option_update':
693
+ base.configOptions = parseConfigOptions(raw.configOptions);
694
+ break;
695
+
696
+ case 'session_info_update':
697
+ base.sessionInfo = {
698
+ title: typeof raw.title === 'string' ? raw.title : undefined,
699
+ updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : undefined,
700
+ };
701
+ break;
702
+
703
+ case 'error':
704
+ base.error = typeof raw.error === 'string' ? raw.error : String(raw.message ?? 'Unknown error');
705
+ break;
706
+
707
+ case 'done':
708
+ // Terminal state — no extra fields
709
+ break;
710
+
711
+ case 'tool_result':
712
+ base.toolResult = raw.toolResult as AcpSessionUpdate['toolResult'];
713
+ break;
714
+ }
715
+
716
+ return base;
717
+ }