@1agh/maude 0.45.1 → 0.45.2

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 (47) hide show
  1. package/apps/studio/acp/bridge.ts +585 -73
  2. package/apps/studio/acp/index.ts +172 -23
  3. package/apps/studio/acp/probe.ts +31 -7
  4. package/apps/studio/acp/transcript.ts +91 -5
  5. package/apps/studio/bin/_import-asset.mjs +35 -5
  6. package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
  7. package/apps/studio/client/panels/ChatPanel.jsx +802 -133
  8. package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
  9. package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
  10. package/apps/studio/client/panels/ReadinessList.jsx +17 -3
  11. package/apps/studio/client/panels/ToolGroup.jsx +79 -0
  12. package/apps/studio/client/panels/acp-capabilities.js +71 -0
  13. package/apps/studio/client/panels/acp-elicitation.js +194 -0
  14. package/apps/studio/client/panels/acp-runtime.js +248 -9
  15. package/apps/studio/client/panels/acp-usage.js +65 -0
  16. package/apps/studio/client/panels/transcript-view.js +36 -0
  17. package/apps/studio/client/styles/6-acp-chat.css +648 -11
  18. package/apps/studio/dist/client.bundle.js +1596 -1594
  19. package/apps/studio/dist/styles.css +1 -1
  20. package/apps/studio/generation/whisper-models.test.ts +28 -1
  21. package/apps/studio/generation/whisper-models.ts +22 -7
  22. package/apps/studio/http.ts +33 -2
  23. package/apps/studio/test/acp-bridge.test.ts +11 -6
  24. package/apps/studio/test/acp-capabilities.test.ts +123 -0
  25. package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
  26. package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
  27. package/apps/studio/test/acp-elicitation.test.ts +251 -0
  28. package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
  29. package/apps/studio/test/acp-permission.test.ts +262 -0
  30. package/apps/studio/test/acp-toolgroup.test.ts +76 -0
  31. package/apps/studio/test/acp-transcript-view.test.ts +71 -0
  32. package/apps/studio/test/acp-transcript.test.ts +75 -2
  33. package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
  34. package/apps/studio/test/acp-usage.test.ts +143 -0
  35. package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
  36. package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
  37. package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
  38. package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
  39. package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
  40. package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
  41. package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
  42. package/apps/studio/test/import-asset.test.ts +31 -3
  43. package/apps/studio/whats-new.json +34 -0
  44. package/cli/commands/design.mjs +107 -2
  45. package/cli/lib/pkg-root.mjs +42 -10
  46. package/cli/lib/pkg-root.test.mjs +33 -1
  47. package/package.json +11 -9
@@ -15,11 +15,15 @@ import {
15
15
  type AvailableCommand,
16
16
  type Client,
17
17
  ClientSideConnection,
18
+ type CreateElicitationRequest,
19
+ type CreateElicitationResponse,
18
20
  ndJsonStream,
19
21
  PROTOCOL_VERSION,
20
22
  type PromptResponse,
21
23
  type RequestPermissionRequest,
22
24
  type RequestPermissionResponse,
25
+ type SessionConfigOption,
26
+ type SessionModeState,
23
27
  type SessionNotification,
24
28
  type SessionUpdate,
25
29
  } from '@agentclientprotocol/sdk';
@@ -46,30 +50,99 @@ export interface AcpBridgeOptions {
46
50
  plugins?: SdkPluginConfig[];
47
51
  /** Streamed `session/update` notifications relayed to the browser. */
48
52
  onUpdate: (update: SessionUpdate) => void;
49
- /** Informational: a tool permission was auto-approved (transparency for the UI). */
53
+ /**
54
+ * Informational transparency callback: fires whenever the agent asks for a
55
+ * tool permission, REGARDLESS of how it's ultimately resolved. Kept
56
+ * alongside `onPermissionRequest` below (Milestone B, DDR-125 F2 retirement)
57
+ * so any existing audit/logging consumer keeps seeing every request.
58
+ */
50
59
  onPermission?: (req: RequestPermissionRequest) => void;
60
+ /**
61
+ * The actual approve/deny UI hook (retires DDR-125 F2's blanket auto-
62
+ * approve) — fires once per request with a fresh nonce `id`; the caller
63
+ * (index.ts) forwards it to the browser as a `permission-request` frame.
64
+ * The bridge awaits `resolvePermission(id, …)` before returning to the
65
+ * adapter — nothing is pre-decided here.
66
+ */
67
+ onPermissionRequest?: (id: string, req: RequestPermissionRequest) => void;
68
+ /**
69
+ * The elicitation-form UI hook (feature-acp-ask-user-question) — fires once
70
+ * per `unstable_createElicitation` call with a fresh nonce `id`, mirroring
71
+ * `onPermissionRequest` exactly. Carries BOTH `AskUserQuestion`-sourced forms
72
+ * AND any MCP-server-originated elicitation (same wire mechanism — see the
73
+ * plan's Research section); the bridge does not and cannot distinguish them.
74
+ * The bridge awaits `resolveElicitation(id, …)` before returning to the
75
+ * adapter.
76
+ */
77
+ onElicitationRequest?: (id: string, req: CreateElicitationRequest) => void;
78
+ /**
79
+ * Fires whenever a pending elicitation is settled, REGARDLESS of which path
80
+ * settled it (a client `elicitation-response`, a bridge-side timeout,
81
+ * `cancel()`, or `stop()`). A client-driven response already removes its own
82
+ * pending entry optimistically (see `respondElicitation` in acp-runtime.js),
83
+ * so for that path this is a harmless no-op notification; it exists for the
84
+ * paths the client can't otherwise learn about — a server-side timeout in
85
+ * particular used to leave the card showing a Submit button that was already
86
+ * dead (the bridge had moved on), with no visible feedback and no way for a
87
+ * click to do anything (dogfooding finding — "submit does nothing" after the
88
+ * card sat open long enough to time out).
89
+ */
90
+ onElicitationSettled?: (id: string) => void;
51
91
  /**
52
92
  * The agent's slash-command catalogue (`available_commands_update`) — drives
53
93
  * the composer autocomplete + inline command pill. Fires whenever the agent
54
94
  * (re)publishes the list; the manager caches the latest and pushes it to the UI.
55
95
  */
56
96
  onCommands?: (commands: AvailableCommand[]) => void;
97
+ /**
98
+ * The session's permission-mode roster + generic config-option set (models,
99
+ * effort, fast-mode, agent persona, …) — sourced live from the ACP session,
100
+ * never hardcoded (feature-acp-panel-dynamic-claude-code-capabilities).
101
+ * Fires once right after a session is established (create OR resume, AFTER
102
+ * any resume-replay window closes) and again on every `current_mode_update`
103
+ * / `config_option_update` notification.
104
+ */
105
+ onCaps?: (modes: SessionModeState | null, configOptions: SessionConfigOption[]) => void;
106
+ /** The agent-generated chat title (`session_info_update`) — fires at turn-end. */
107
+ onSessionInfo?: (info: { title?: string | null; updatedAt?: string | null }) => void;
108
+ /**
109
+ * Context-window usage + cost (`usage_update`, Milestone D) — fires after
110
+ * each result and, less often, on a `rate_limit_event` (carried in `_meta`).
111
+ * Chrome, not turn content — the client renders it as an ambient meter, not
112
+ * a message part.
113
+ */
114
+ onUsage?: (usage: BridgeUsage) => void;
115
+ /** Override for `PERMISSION_TIMEOUT_MS` (tests only — production always gets the real default). */
116
+ permissionTimeoutMs?: number;
57
117
  }
58
118
 
59
- type Spawned = ReturnType<typeof Bun.spawn>;
119
+ /** The bridge's normalized shape of a `usage_update` notification. `rateLimit`
120
+ * is the RAW `_meta["_claude/rateLimit"]` payload (an `SDKRateLimitInfo`) —
121
+ * passed through opaque; `client/panels/acp-usage.js`'s `parseUsage` is
122
+ * where it gets mapped to a friendly label, not here. */
123
+ export interface BridgeUsage {
124
+ used: number;
125
+ size: number;
126
+ cost?: { amount: number; currency: string } | null;
127
+ rateLimit?: unknown;
128
+ }
60
129
 
61
- /**
62
- * Effort extended-thinking budget, fed to the adapter as `MAX_THINKING_TOKENS`
63
- * (it maps 0 thinking disabled, a positive int an enabled budget). `balanced`
64
- * leaves it unset so the agent uses Claude Code's own default.
65
- */
66
- const EFFORT_THINKING_TOKENS: Record<string, number | null> = {
67
- fast: 0,
68
- balanced: null,
69
- thorough: 31999,
70
- };
130
+ /** Flatten a `SessionConfigSelect.options` — a flat option array OR grouped
131
+ * (`SessionConfigSelectGroup[]`) into one list of `{value,name}` leaves. */
132
+ function flattenSelectOptions(options: unknown): Array<{ value: string; name?: string | null }> {
133
+ const list = Array.isArray(options) ? options : [];
134
+ const out: Array<{ value: string; name?: string | null }> = [];
135
+ for (const o of list) {
136
+ if (o && typeof o === 'object' && Array.isArray((o as { options?: unknown }).options)) {
137
+ out.push(...(o as { options: Array<{ value: string; name?: string | null }> }).options);
138
+ } else if (o && typeof o === 'object' && 'value' in o) {
139
+ out.push(o as { value: string; name?: string | null });
140
+ }
141
+ }
142
+ return out;
143
+ }
71
144
 
72
- export type AcpEffort = keyof typeof EFFORT_THINKING_TOKENS;
145
+ type Spawned = ReturnType<typeof Bun.spawn>;
73
146
 
74
147
  // Real sessionIds are adapter-generated `randomUUID()`s. A persisted value that
75
148
  // doesn't look like one (corrupt sidecar, or a tracked file a cloned repo
@@ -84,6 +157,10 @@ const VALID_SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
84
157
  // bridge silent forever. Mirrors the `withTimeout`/`TIMED_OUT` pattern already
85
158
  // used for network calls in `apps/studio/git/service.ts`.
86
159
  const LOAD_SESSION_TIMEOUT_MS = 15_000;
160
+ // RCA-G1 — cap the ACP handshake so a mis-launched runtime that never speaks ACP
161
+ // surfaces an error instead of an infinite "Working…". Generous: covers a cold
162
+ // first-spawn of the compiled adapter runtime, but well short of "forever".
163
+ const INITIALIZE_TIMEOUT_MS = 30_000;
87
164
  const TIMED_OUT = Symbol('maude-acp-load-session-timeout');
88
165
  function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT> {
89
166
  p.catch(() => {});
@@ -179,15 +256,58 @@ export function newSessionParams(
179
256
  };
180
257
  }
181
258
 
182
- /** Pick the most-permissive allow option, or null if the agent offered none. */
183
- function pickAllowOption(params: RequestPermissionRequest) {
184
- const options = params.options ?? [];
185
- return (
186
- options.find((o) => o.kind === 'allow_always') ??
187
- options.find((o) => o.kind === 'allow_once') ??
188
- options.find((o) => typeof o.kind === 'string' && o.kind.startsWith('allow')) ??
189
- null
190
- );
259
+ // Milestone B (DDR-125 F2 retirement) how long a permission request waits
260
+ // for a human decision before the bridge settles it itself. Generous (a
261
+ // person reading a tool-call card and clicking a button, not a network hop)
262
+ // but bounded so a request can never hang the turn forever. The default on
263
+ // timeout — like on turn-cancel — is DENY (`cancelled`), never allow: this is
264
+ // the security control, so failing open would defeat the point.
265
+ const PERMISSION_TIMEOUT_MS = 120_000;
266
+
267
+ // SECURITY (ethical-hacker finding, retroactive review) — a single agent turn
268
+ // can legitimately issue several tool calls back to back (a burst is normal
269
+ // agent behavior, not a bug — e.g. prompt-injected content directing several
270
+ // actions in one turn), so "one per tool call" is NOT the natural ceiling the
271
+ // original comment above assumed. Mirrors the elicitation channel's
272
+ // MAX_PENDING_ELICITATIONS cap for the same reason: an unbounded queue lets a
273
+ // backlog build silently (no depth indicator existed either — see
274
+ // ChatPanel.jsx's queue-count render) and manufactures the exact "reflexive
275
+ // Enter-mashing" precondition that made the wrong-default bug below
276
+ // exploitable in practice. Denying beyond the cap is always the safe
277
+ // direction — it degrades to "the user will have to re-trigger that action,"
278
+ // never to a silent allow.
279
+ export const MAX_PENDING_PERMISSIONS = 10;
280
+
281
+ // feature-acp-ask-user-question, SECURITY (ethical-hacker finding) — unlike a
282
+ // permission request (one per tool call, rate-limited by how fast a model can
283
+ // call tools), an elicitation can be issued directly by any connected MCP
284
+ // server with no such natural ceiling. Without a cap, a compromised/hostile
285
+ // MCP server can flood `pendingElicitations` (unbounded memory growth) or
286
+ // send an oversized `requestedSchema` (e.g. thousands of `oneOf` options) that
287
+ // the client renders with no clamp — either can freeze the panel or force the
288
+ // user into an endless Submit/Skip/Cancel loop just to get their composer
289
+ // back. Both are enforced BEFORE a request is ever registered or forwarded to
290
+ // the client — a request that trips either cap is declined immediately, the
291
+ // same fail-closed outcome as a timeout.
292
+ export const MAX_PENDING_ELICITATIONS = 5;
293
+ export const MAX_ELICITATION_SCHEMA_PROPERTIES = 20;
294
+ export const MAX_ELICITATION_SCHEMA_BYTES = 16_384;
295
+
296
+ /** Exported for direct unit-testing of the bound math without needing a live
297
+ * bridge/subprocess — see `test/acp-elicitation-bridge.test.ts`. */
298
+ export function elicitationSchemaWithinBounds(schema: unknown): boolean {
299
+ if (!schema || typeof schema !== 'object') return true; // nothing to bound
300
+ const properties = (schema as { properties?: unknown }).properties;
301
+ if (properties && typeof properties === 'object') {
302
+ if (Object.keys(properties).length > MAX_ELICITATION_SCHEMA_PROPERTIES) return false;
303
+ }
304
+ let serialized: string;
305
+ try {
306
+ serialized = JSON.stringify(schema);
307
+ } catch {
308
+ return false; // unserializable (e.g. a cycle) — never trust it
309
+ }
310
+ return serialized.length <= MAX_ELICITATION_SCHEMA_BYTES;
191
311
  }
192
312
 
193
313
  export class AcpBridge {
@@ -212,16 +332,69 @@ export class AcpBridge {
212
332
  /** True while `conn.loadSession()` is replaying a resumed session's history
213
333
  * back through the `sessionUpdate` client callback — see the guard in `start()`. */
214
334
  private replaying = false;
215
- // Model + effort are env-at-spawn (ANTHROPIC_MODEL / MAX_THINKING_TOKENS), so a
216
- // change re-spawns the adapter. `desired*` is what the UI asked for; `active*`
217
- // is what the running session was spawned with.
335
+ // The last-advertised capability set for the live session the dynamic
336
+ // replacement for the old hardcoded MODELS/EFFORTS arrays (feature-acp-panel-
337
+ // dynamic-claude-code-capabilities). Also doubles as the server-side
338
+ // allowlist a `set-mode`/`set-config` WS frame is validated against
339
+ // (DDR-125 F1 — a loopback frame still can't pin an arbitrary value).
340
+ private lastModes: SessionModeState | null = null;
341
+ private lastConfigOptions: SessionConfigOption[] = [];
342
+ // The user's current model/effort/mode picks — no longer env-at-spawn
343
+ // (Task A3); applied ONCE, live, right after a session is established
344
+ // (create OR resume), never forcing a respawn. `null` = "leave the
345
+ // session's own default alone."
218
346
  private desiredModel: string | null = null;
219
- private desiredEffort: AcpEffort = 'balanced';
220
- private activeModel: string | null = null;
221
- private activeEffort: AcpEffort = 'balanced';
347
+ private desiredEffort: string | null = null;
348
+ private desiredModeId: string | null = null;
349
+ // Milestone B permission requests awaiting a human decision, keyed by the
350
+ // nonce handed to the client in the `permission-request` frame.
351
+ private pendingPermissions = new Map<
352
+ string,
353
+ {
354
+ resolve: (r: RequestPermissionResponse) => void;
355
+ timer: ReturnType<typeof setTimeout>;
356
+ /** The optionIds actually offered for THIS request — resolvePermission
357
+ * fails closed (denies) on anything else, so a decision can't pin an
358
+ * option that was never on the table (DDR-125 F1 posture). */
359
+ optionIds: Set<string>;
360
+ }
361
+ >();
362
+ // feature-acp-ask-user-question — elicitation-form requests awaiting a human
363
+ // decision, keyed by the nonce handed to the client in the
364
+ // `elicitation-request` frame. Parallel to `pendingPermissions` rather than
365
+ // sharing its Map: the two response shapes (`RequestPermissionResponse`'s
366
+ // `{outcome:{outcome,optionId?}}` vs `CreateElicitationResponse`'s
367
+ // `{action,content?}`) don't unify cleanly under one generic "pending
368
+ // client answer" type without a discriminated wrapper that would make BOTH
369
+ // call sites harder to read for no real gain (open decision #2 in the plan).
370
+ private pendingElicitations = new Map<
371
+ string,
372
+ {
373
+ resolve: (r: CreateElicitationResponse) => void;
374
+ timer: ReturnType<typeof setTimeout>;
375
+ }
376
+ >();
377
+ // Milestone D — the last-seen usage snapshot, cached the same way lastModes/
378
+ // lastConfigOptions are (mirrors the manager's latestCommands replay pattern).
379
+ private lastUsage: BridgeUsage | null = null;
222
380
 
223
381
  constructor(private readonly opts: AcpBridgeOptions) {}
224
382
 
383
+ /** The last-advertised mode roster + current mode (read-only snapshot). */
384
+ get modes(): SessionModeState | null {
385
+ return this.lastModes;
386
+ }
387
+
388
+ /** The last-advertised generic config-option set (read-only snapshot). */
389
+ get configOptions(): SessionConfigOption[] {
390
+ return this.lastConfigOptions;
391
+ }
392
+
393
+ /** The last-seen usage snapshot (read-only), or null before the first `usage_update`. */
394
+ get usage(): BridgeUsage | null {
395
+ return this.lastUsage;
396
+ }
397
+
225
398
  /** The session id of the most recent prompt (for the `connected` frame). */
226
399
  get sessionId(): string | null {
227
400
  return this.currentSession;
@@ -239,15 +412,94 @@ export class AcpBridge {
239
412
  this.sessionStorePath = path;
240
413
  }
241
414
 
242
- /** Desired model (alias/id, or null for the user's default) + effort. Applied
243
- * on the next prompt re-spawning the adapter only if it actually changed. */
244
- setConfig(model: string | null, effort: AcpEffort): void {
245
- this.desiredModel = model;
246
- this.desiredEffort = effort in EFFORT_THINKING_TOKENS ? effort : 'balanced';
415
+ /**
416
+ * The user's current model/effort/mode picks (dynamic option ids/values
417
+ * sourced from a PRIOR session's advertised `configOptions`/`modes`, never
418
+ * a hardcoded list). Stored, not applied immediately: `establishSession`
419
+ * live-applies them once, best-effort, right after the next session comes
420
+ * up (create OR resume) — see `applyDesiredConfigOnce`. Does NOT touch a
421
+ * session that's already established; use `setMode`/`setConfigOption` for
422
+ * a live mid-chat change.
423
+ */
424
+ setConfig(model: string | null, effort: string | null, modeId: string | null = null): void {
425
+ this.desiredModel = model || null;
426
+ this.desiredEffort = effort || null;
427
+ this.desiredModeId = modeId || null;
247
428
  }
248
429
 
249
- private configChanged(): boolean {
250
- return this.desiredModel !== this.activeModel || this.desiredEffort !== this.activeEffort;
430
+ /**
431
+ * Live-set the session mode for `chatId` (Task A2/A4 — driven by a
432
+ * `set-mode` WS frame). Establishes the session first if none exists yet,
433
+ * so the picker works before the first prompt. The caller (index.ts)
434
+ * validates `modeId` against `this.modes` before invoking this.
435
+ */
436
+ async setMode(chatId: string, modeId: string): Promise<void> {
437
+ await this.ensureStarted();
438
+ const sessionId = await this.sessionFor(chatId);
439
+ if (!this.conn) throw new Error('ACP adapter not ready');
440
+ await this.conn.setSessionMode({ sessionId, modeId });
441
+ }
442
+
443
+ /**
444
+ * Live-set one config option (model/effort/fast/…) for `chatId`. The
445
+ * response echoes the FULL refreshed option set (a model switch can add/
446
+ * remove the effort option, for instance) — fed back through `onCaps` so
447
+ * every listener sees the side effects, not just the option that changed.
448
+ */
449
+ async setConfigOption(chatId: string, configId: string, value: string): Promise<void> {
450
+ await this.ensureStarted();
451
+ const sessionId = await this.sessionFor(chatId);
452
+ if (!this.conn) throw new Error('ACP adapter not ready');
453
+ const response = await this.conn.setSessionConfigOption({ sessionId, configId, value });
454
+ this.lastConfigOptions = response.configOptions;
455
+ this.opts.onCaps?.(this.lastModes, this.lastConfigOptions);
456
+ }
457
+
458
+ /** True when `value` is currently offered for the select-type option `configId`. */
459
+ private optionOffers(configId: string, value: string): boolean {
460
+ const opt = this.lastConfigOptions.find((o) => o.id === configId);
461
+ if (opt?.type !== 'select') return false;
462
+ return flattenSelectOptions(opt.options).some((o) => o.value === value);
463
+ }
464
+
465
+ /**
466
+ * Reflect the user's persisted model/effort/mode picks onto a FRESHLY
467
+ * established session (Task A3) — replaces the old env-at-spawn + respawn
468
+ * dance with live `setSessionConfigOption`/`setSessionMode` calls, none of
469
+ * which tear down the running `claude` subprocess. Best-effort and ordered:
470
+ * model first (switching it can change which OTHER options — e.g. effort —
471
+ * are even offered), then effort, then mode. A pick that isn't advertised
472
+ * (e.g. an effort level unsupported by the model claude resolved to) is
473
+ * silently skipped, leaving the session's own default in place.
474
+ */
475
+ private async applyDesiredConfigOnce(sessionId: string): Promise<void> {
476
+ if (!this.conn) return;
477
+ try {
478
+ if (this.desiredModel && this.optionOffers('model', this.desiredModel)) {
479
+ const res = await this.conn.setSessionConfigOption({
480
+ sessionId,
481
+ configId: 'model',
482
+ value: this.desiredModel,
483
+ });
484
+ this.lastConfigOptions = res.configOptions;
485
+ }
486
+ if (this.desiredEffort && this.optionOffers('effort', this.desiredEffort)) {
487
+ const res = await this.conn.setSessionConfigOption({
488
+ sessionId,
489
+ configId: 'effort',
490
+ value: this.desiredEffort,
491
+ });
492
+ this.lastConfigOptions = res.configOptions;
493
+ }
494
+ if (
495
+ this.desiredModeId &&
496
+ this.lastModes?.availableModes.some((m) => m.id === this.desiredModeId)
497
+ ) {
498
+ await this.conn.setSessionMode({ sessionId, modeId: this.desiredModeId });
499
+ }
500
+ } catch {
501
+ /* best-effort — a stale/unsupported persisted pick just leaves the session default */
502
+ }
251
503
  }
252
504
 
253
505
  /** Spawn + handshake exactly once; concurrent callers share the same promise. */
@@ -297,6 +549,9 @@ export class AcpBridge {
297
549
 
298
550
  const params = newSessionParams(this.opts.repoRoot, this.opts.studioBrief, this.opts.plugins);
299
551
  const persistedId = await this.readPersistedSessionId();
552
+ let sessionId: string | undefined;
553
+ let modes: SessionModeState | null | undefined;
554
+ let configOptions: SessionConfigOption[] | null | undefined;
300
555
  if (persistedId) {
301
556
  try {
302
557
  this.replaying = true;
@@ -308,7 +563,9 @@ export class AcpBridge {
308
563
  throw new Error(`loadSession timed out after ${LOAD_SESSION_TIMEOUT_MS}ms`);
309
564
  }
310
565
  this.sessions.set(chatId, persistedId);
311
- return persistedId;
566
+ sessionId = persistedId;
567
+ modes = result.modes;
568
+ configOptions = result.configOptions;
312
569
  } catch (err) {
313
570
  await this.appendTranscript({
314
571
  role: 'bootstrap',
@@ -320,10 +577,25 @@ export class AcpBridge {
320
577
  }
321
578
  }
322
579
 
323
- const created = await this.conn.newSession(params);
324
- this.sessions.set(chatId, created.sessionId);
325
- await this.writePersistedSessionId(created.sessionId);
326
- return created.sessionId;
580
+ if (sessionId === undefined) {
581
+ const created = await this.conn.newSession(params);
582
+ this.sessions.set(chatId, created.sessionId);
583
+ await this.writePersistedSessionId(created.sessionId);
584
+ sessionId = created.sessionId;
585
+ modes = created.modes;
586
+ configOptions = created.configOptions;
587
+ }
588
+
589
+ // Capture + apply + broadcast caps AFTER the resume-replay window closes
590
+ // (`this.replaying` back to false) — the initial state here is the real,
591
+ // current session state (the RPC response), never replayed history, but
592
+ // we still sequence it after the try/finally so nothing fires while a
593
+ // resume is nominally in flight.
594
+ this.lastModes = modes ?? null;
595
+ this.lastConfigOptions = configOptions ?? [];
596
+ await this.applyDesiredConfigOnce(sessionId);
597
+ this.opts.onCaps?.(this.lastModes, this.lastConfigOptions);
598
+ return sessionId;
327
599
  }
328
600
 
329
601
  /** Read the sessionId persisted for this chat by a prior bridge lifetime.
@@ -390,14 +662,19 @@ export class AcpBridge {
390
662
  delete env.MAUDE_TOKEN_ENDPOINT;
391
663
  // biome-ignore lint/performance/noDelete: security env-scrub — see above; `delete` is the intentional primitive here.
392
664
  delete env.MAUDE_TOKEN_KEY;
393
- // Model + effort selection config, NOT credentials, so they're added back.
394
- this.activeModel = this.desiredModel;
395
- this.activeEffort = this.desiredEffort;
396
- if (this.activeModel) env.ANTHROPIC_MODEL = this.activeModel;
397
- const thinking = EFFORT_THINKING_TOKENS[this.activeEffort];
398
- if (thinking !== null && thinking !== undefined) env.MAX_THINKING_TOKENS = String(thinking);
399
-
400
- const proc = Bun.spawn([resolveAgentRuntime(), adapterEntry], {
665
+ // Model + effort are no longer env-at-spawn (Task A3) the adapter starts
666
+ // on its own default and `establishSession`/`applyDesiredConfigOnce` live-
667
+ // applies the user's persisted picks via `setSessionConfigOption` once the
668
+ // session (and its advertised options) exist. No respawn on a config change.
669
+
670
+ // RCA-G1 resolve a runnable JS runtime. On a node/bun-less machine this
671
+ // falls back to our own compiled self, which must be spawned with
672
+ // BUN_BE_BUN=1 to behave as `bun` (else it re-runs the embedded server and
673
+ // the handshake below never completes → "Working…" forever). Set on `env`
674
+ // AFTER scrubAgentEnv (which doesn't touch BUN_BE_BUN).
675
+ const runtime = resolveAgentRuntime();
676
+ if (runtime.bunBeBun) env.BUN_BE_BUN = '1';
677
+ const proc = Bun.spawn([runtime.bin, adapterEntry], {
401
678
  cwd: this.opts.repoRoot,
402
679
  env,
403
680
  stdin: 'pipe',
@@ -433,10 +710,57 @@ export class AcpBridge {
433
710
 
434
711
  const client: Client = {
435
712
  sessionUpdate: (params: SessionNotification) => {
713
+ const u = params.update;
436
714
  // The command catalogue is chrome, not chat — surface it to the UI but
437
715
  // keep it out of the rendered turn + the persisted transcript.
438
- if (params.update.sessionUpdate === 'available_commands_update') {
439
- this.opts.onCommands?.(params.update.availableCommands ?? []);
716
+ if (u.sessionUpdate === 'available_commands_update') {
717
+ this.opts.onCommands?.(u.availableCommands ?? []);
718
+ return;
719
+ }
720
+ // Capability-channel notifications (feature-acp-panel-dynamic-claude-code-
721
+ // capabilities) are chrome too — same treatment as the command catalogue
722
+ // above, deliberately NOT gated by `this.replaying` (mirrors
723
+ // available_commands_update): a resumed session's `loadSession` replay
724
+ // walks prior MESSAGE content only (claude-agent-acp's
725
+ // replaySessionHistory), never re-emits these side-channel notifications,
726
+ // so there is nothing stale to guard against here.
727
+ if (u.sessionUpdate === 'current_mode_update') {
728
+ // Carries only the new currentModeId — merge into the cached roster,
729
+ // never replace it (availableModes doesn't change on a mode switch).
730
+ this.lastModes = this.lastModes
731
+ ? { ...this.lastModes, currentModeId: u.currentModeId }
732
+ : { currentModeId: u.currentModeId, availableModes: [] };
733
+ this.opts.onCaps?.(this.lastModes, this.lastConfigOptions);
734
+ return;
735
+ }
736
+ if (u.sessionUpdate === 'config_option_update') {
737
+ this.lastConfigOptions = u.configOptions ?? [];
738
+ // The adapter mirrors the current mode as a "mode"-id select option
739
+ // inside configOptions (claude-agent-acp's MODE_CONFIG_ID) but doesn't
740
+ // always pair that with a current_mode_update — e.g. `setSessionMode`
741
+ // itself only emits config_option_update. Cross-derive so the
742
+ // dedicated mode picker (driven off `lastModes`) stays correct either way.
743
+ const modeOpt = this.lastConfigOptions.find((o) => o.id === 'mode');
744
+ if (modeOpt && typeof modeOpt.currentValue === 'string') {
745
+ this.lastModes = this.lastModes
746
+ ? { ...this.lastModes, currentModeId: modeOpt.currentValue }
747
+ : { currentModeId: modeOpt.currentValue, availableModes: [] };
748
+ }
749
+ this.opts.onCaps?.(this.lastModes, this.lastConfigOptions);
750
+ return;
751
+ }
752
+ if (u.sessionUpdate === 'session_info_update') {
753
+ this.opts.onSessionInfo?.({ title: u.title, updatedAt: u.updatedAt });
754
+ return;
755
+ }
756
+ if (u.sessionUpdate === 'usage_update') {
757
+ this.lastUsage = {
758
+ used: u.used,
759
+ size: u.size,
760
+ cost: u.cost ?? null,
761
+ rateLimit: u._meta?.['_claude/rateLimit'],
762
+ };
763
+ this.opts.onUsage?.(this.lastUsage);
440
764
  return;
441
765
  }
442
766
  // `loadSession` replays the resumed session's entire prior history back
@@ -445,31 +769,125 @@ export class AcpBridge {
445
769
  // transcript and already rendered client-side, so forwarding/re-appending
446
770
  // it here would duplicate every message in the panel and the jsonl file.
447
771
  if (this.replaying) return;
448
- this.opts.onUpdate(params.update);
449
- void this.appendTranscript({ role: 'agent', update: params.update });
772
+ this.opts.onUpdate(u);
773
+ void this.appendTranscript({ role: 'agent', update: u });
450
774
  },
451
- requestPermission: (params: RequestPermissionRequest): RequestPermissionResponse => {
452
- // Auto-approve: the agent is the user's OWN local Claude editing their
453
- // OWN project over loopback granting it is the feature, mirroring
454
- // Claude Code's trusted-session default. A manual approve/deny UI is a
455
- // Task-3 follow-up; we surface the request so the panel can show it.
775
+ requestPermission: (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
776
+ // Milestone B (retires DDR-125 F2's blanket auto-approve) the
777
+ // permission POLICY is now the selected session mode (sourced from
778
+ // Claude Code itself): `bypassPermissions`/`dontAsk` short-circuit
779
+ // adapter-side and never reach here at all; every OTHER mode routes
780
+ // through this real approve/deny gate. `onPermission` stays as a
781
+ // transparency callback (every request, however it resolves);
782
+ // `onPermissionRequest` is the actual UI hook the client answers.
456
783
  this.opts.onPermission?.(params);
457
- const option = pickAllowOption(params);
458
- if (!option) return { outcome: { outcome: 'cancelled' } };
459
- return { outcome: { outcome: 'selected', optionId: option.optionId } };
784
+ // SECURITY (ethical-hacker finding) — bound queue depth before
785
+ // registering a pending entry, mirroring the elicitation channel's
786
+ // MAX_PENDING_ELICITATIONS cap. Deny immediately past the cap safe
787
+ // by construction, since deny is this gate's own fail-closed default.
788
+ if (this.pendingPermissions.size >= MAX_PENDING_PERMISSIONS) {
789
+ return Promise.resolve({ outcome: { outcome: 'cancelled' } });
790
+ }
791
+ const id = crypto.randomUUID();
792
+ const optionIds = new Set((params.options ?? []).map((o) => o.optionId));
793
+ return new Promise<RequestPermissionResponse>((resolve) => {
794
+ const timer = setTimeout(
795
+ () => this.resolvePermission(id, 'cancelled'),
796
+ this.opts.permissionTimeoutMs ?? PERMISSION_TIMEOUT_MS
797
+ );
798
+ this.pendingPermissions.set(id, { resolve, timer, optionIds });
799
+ this.opts.onPermissionRequest?.(id, params);
800
+ });
801
+ },
802
+ unstable_createElicitation: (
803
+ params: CreateElicitationRequest
804
+ ): Promise<CreateElicitationResponse> => {
805
+ // feature-acp-ask-user-question — mirrors requestPermission's shape
806
+ // exactly. Fires for BOTH `AskUserQuestion` and any MCP-server
807
+ // elicitation (see the plan's Research section) — the toolCallId/
808
+ // session scope is not special-cased to assume it's always the
809
+ // built-in tool.
810
+ //
811
+ // SECURITY (ethical-hacker finding, post-implementation review) — only
812
+ // `form` mode was ever declared in `clientCapabilities` (never `url`),
813
+ // but capability negotiation is advisory, not enforced: a non-compliant
814
+ // adapter or a malicious/buggy MCP server could send `mode:'url'`
815
+ // anyway. Reject it HERE, structurally, rather than trusting the other
816
+ // side to honor what we advertised — `url`-mode has never had client
817
+ // rendering (no code anywhere reads/shows `params.url`), so forwarding
818
+ // it would have produced a bare "message + Submit" card the user could
819
+ // click through with no idea an out-of-band URL flow was actually being
820
+ // confirmed (a confused-consent primitive — see DDR-180).
821
+ if (params.mode !== 'form') {
822
+ return Promise.resolve({ action: 'decline' });
823
+ }
824
+ // SECURITY (ethical-hacker finding) — bound queue depth + schema size
825
+ // BEFORE registering a pending entry or forwarding anything to the
826
+ // client, so a flood or an oversized schema never reaches the
827
+ // renderer at all rather than being handled gracefully once there.
828
+ if (this.pendingElicitations.size >= MAX_PENDING_ELICITATIONS) {
829
+ return Promise.resolve({ action: 'decline' });
830
+ }
831
+ if (!elicitationSchemaWithinBounds(params.requestedSchema)) {
832
+ return Promise.resolve({ action: 'decline' });
833
+ }
834
+ const id = crypto.randomUUID();
835
+ return new Promise<CreateElicitationResponse>((resolve) => {
836
+ const timer = setTimeout(
837
+ () => this.resolveElicitation(id, { action: 'decline' }),
838
+ this.opts.permissionTimeoutMs ?? PERMISSION_TIMEOUT_MS
839
+ );
840
+ this.pendingElicitations.set(id, { resolve, timer });
841
+ this.opts.onElicitationRequest?.(id, params);
842
+ });
460
843
  },
461
844
  };
462
845
 
463
846
  const conn = new ClientSideConnection(() => client, stream);
464
847
  this.conn = conn;
465
848
 
466
- await conn.initialize({
467
- protocolVersion: PROTOCOL_VERSION,
468
- // We don't expose the project filesystem to the agent over ACP — the
469
- // spawned `claude` already has direct disk access to `cwd`, so advertising
470
- // fs capabilities here would only duplicate (and widen) that surface.
471
- clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
472
- });
849
+ // RCA-G1 — bound the handshake. If the spawned "adapter" is actually a
850
+ // mis-launched runtime that never speaks ACP (the exact node-less bug: a
851
+ // compiled sidecar re-run as a server instead of `bun`, before the
852
+ // BUN_BE_BUN fix, or any future runtime regression), `initialize()` never
853
+ // resolves and the panel hangs at "Working…" forever with no error. Time it
854
+ // out, tear down the dead child, and surface a real error the UI can show
855
+ // instead of an infinite spinner. Mirrors the `withTimeout` guard already
856
+ // used for `loadSession`.
857
+ const initResult = await withTimeout(
858
+ conn.initialize({
859
+ protocolVersion: PROTOCOL_VERSION,
860
+ // We don't expose the project filesystem to the agent over ACP — the
861
+ // spawned `claude` already has direct disk access to `cwd`, so advertising
862
+ // fs capabilities here would only duplicate (and widen) that surface.
863
+ // feature-acp-ask-user-question — declares `form` only, never `url`
864
+ // (an agent-chosen URL the user is directed to open is a materially
865
+ // bigger trust surface than a schema-driven form rendered entirely
866
+ // client-side — see the plan's Open decisions). This is also the
867
+ // single client-capability gate that unblocks the built-in
868
+ // `AskUserQuestion` tool AND any connected MCP server's elicitation
869
+ // requests (same wire mechanism, no sub-flag to separate them —
870
+ // acp-agent.js's `disallowedTools` check).
871
+ clientCapabilities: {
872
+ fs: { readTextFile: false, writeTextFile: false },
873
+ elicitation: { form: {} },
874
+ },
875
+ }),
876
+ INITIALIZE_TIMEOUT_MS
877
+ );
878
+ if (initResult === TIMED_OUT) {
879
+ this.conn = null;
880
+ try {
881
+ proc.kill();
882
+ } catch {
883
+ /* already gone */
884
+ }
885
+ this.proc = null;
886
+ throw new Error(
887
+ `AI editing couldn't start: the agent runtime didn't respond within ${INITIALIZE_TIMEOUT_MS / 1000}s. ` +
888
+ `Check that Claude Code is installed and signed in (Help ▸ Check AI editing readiness).`
889
+ );
890
+ }
473
891
  // Sessions are created lazily per chat (sessionFor) — not here.
474
892
  }
475
893
 
@@ -478,11 +896,6 @@ export class AcpBridge {
478
896
  text: string,
479
897
  chatId: string
480
898
  ): Promise<{ stopReason: PromptResponse['stopReason'] }> {
481
- // Model/effort are env-at-spawn — if the user changed them, tear the adapter
482
- // down so ensureStarted re-spawns with the new env (sessions re-create lazily).
483
- if (this.conn && this.configChanged()) {
484
- await this.stop();
485
- }
486
899
  await this.ensureStarted();
487
900
  const conn = this.conn;
488
901
  if (!conn) throw new Error('ACP adapter not ready');
@@ -527,13 +940,110 @@ export class AcpBridge {
527
940
  * Best-effort: callers swallow errors (autocomplete degrades to the static list).
528
941
  */
529
942
  async warmUp(chatId: string): Promise<void> {
530
- if (this.conn && this.configChanged()) await this.stop();
531
943
  await this.ensureStarted();
532
944
  await this.sessionFor(chatId);
533
945
  }
534
946
 
947
+ /**
948
+ * Settle a pending permission request (Milestone B). `decision` is either a
949
+ * `PermissionOption.optionId` the agent offered, or the literal `'cancelled'`
950
+ * (reject/deny — the timeout default and what a turn-cancel forces). A
951
+ * request that's already been settled or whose id is unknown (stale client,
952
+ * already timed out) is a silent no-op — never throws on a race. A
953
+ * `decision` that ISN'T `'cancelled'` and wasn't actually among the options
954
+ * offered for THIS request fails closed to `'cancelled'` too (DDR-125 F1 —
955
+ * a frame can't pin an option that was never on the table, e.g. a stale
956
+ * optionId replayed from a different, already-settled request).
957
+ */
958
+ resolvePermission(id: string, decision: string): void {
959
+ const pending = this.pendingPermissions.get(id);
960
+ if (!pending) return;
961
+ this.pendingPermissions.delete(id);
962
+ clearTimeout(pending.timer);
963
+ const optionId = decision !== 'cancelled' && pending.optionIds.has(decision) ? decision : null;
964
+ pending.resolve(
965
+ optionId
966
+ ? { outcome: { outcome: 'selected', optionId } }
967
+ : { outcome: { outcome: 'cancelled' } }
968
+ );
969
+ }
970
+
971
+ /** Deny every currently-pending permission request — turn-cancel and full
972
+ * teardown must never leave one hanging on a decision that will now never
973
+ * arrive (Milestone B: the default on ANY abandonment is deny, not allow). */
974
+ private denyAllPendingPermissions(): void {
975
+ for (const id of [...this.pendingPermissions.keys()]) this.resolvePermission(id, 'cancelled');
976
+ }
977
+
978
+ /**
979
+ * Settle a pending elicitation request (feature-acp-ask-user-question).
980
+ * `response` is the client's WS-frame payload — already validated shallowly
981
+ * by index.ts (a well-formed `{action, content?}`); this is still the last
982
+ * line of defense, so anything that isn't literally `accept` with a real
983
+ * `content` object, or literally `cancel`, collapses to `decline`. A
984
+ * request that's already settled or whose id is unknown (stale client,
985
+ * already timed out) is a silent no-op — never throws on a race. `decline`
986
+ * is deliberately NOT the same failure mode as a permission `cancelled`:
987
+ * per `applyAskElicitationResponse`'s documented contract, decline tells
988
+ * the model the user skipped (the turn continues), while `cancel` aborts
989
+ * the tool call — so a bridge-initiated fail-safe (timeout, turn-cancel,
990
+ * teardown) always declines, never cancels, unless the human explicitly
991
+ * clicked Cancel client-side.
992
+ */
993
+ resolveElicitation(id: string, response: { action?: unknown; content?: unknown }): void {
994
+ const pending = this.pendingElicitations.get(id);
995
+ if (!pending) return;
996
+ this.pendingElicitations.delete(id);
997
+ clearTimeout(pending.timer);
998
+ this.opts.onElicitationSettled?.(id);
999
+ if (
1000
+ response.action === 'accept' &&
1001
+ response.content &&
1002
+ typeof response.content === 'object' &&
1003
+ !Array.isArray(response.content)
1004
+ ) {
1005
+ // Validate each value against the wire-allowed ElicitationContentValue
1006
+ // shape (string | number | boolean | string[]) — mirrors
1007
+ // claude-agent-acp's own `acceptedElicitationContent` validation
1008
+ // (confirmed on disk) exactly, so a value the adapter would itself
1009
+ // reject never gets forwarded as though it were a real answer.
1010
+ // `Object.create(null)` — not `{}` — for defense-in-depth parity with
1011
+ // `acp-elicitation.js`'s `buildElicitationContent` (client-side sibling
1012
+ // building the same shape): a hand-crafted `elicitation-response` frame
1013
+ // reaches THIS function directly (index.ts only shallow-validates), so
1014
+ // it's the actual last line of defense against a `__proto__`-keyed
1015
+ // `content`, not the client-side builder the real UI happens to use.
1016
+ const content: Record<string, string | number | boolean | string[]> = Object.create(null);
1017
+ for (const [key, value] of Object.entries(response.content)) {
1018
+ if (
1019
+ typeof value === 'string' ||
1020
+ typeof value === 'number' ||
1021
+ typeof value === 'boolean' ||
1022
+ (Array.isArray(value) && value.every((item) => typeof item === 'string'))
1023
+ ) {
1024
+ content[key] = value;
1025
+ }
1026
+ }
1027
+ pending.resolve({ action: 'accept', content });
1028
+ } else if (response.action === 'cancel') {
1029
+ pending.resolve({ action: 'cancel' });
1030
+ } else {
1031
+ pending.resolve({ action: 'decline' });
1032
+ }
1033
+ }
1034
+
1035
+ /** Decline every currently-pending elicitation request — same fail-closed
1036
+ * discipline as `denyAllPendingPermissions`, called from the same places. */
1037
+ private declineAllPendingElicitations(): void {
1038
+ for (const id of [...this.pendingElicitations.keys()]) {
1039
+ this.resolveElicitation(id, { action: 'decline' });
1040
+ }
1041
+ }
1042
+
535
1043
  /** Cancel the in-flight turn (no-op if nothing is running). */
536
1044
  async cancel(): Promise<void> {
1045
+ this.denyAllPendingPermissions();
1046
+ this.declineAllPendingElicitations();
537
1047
  if (this.conn && this.currentSession) {
538
1048
  try {
539
1049
  await this.conn.cancel({ sessionId: this.currentSession });
@@ -546,6 +1056,8 @@ export class AcpBridge {
546
1056
  /** Tear down: cancel, kill the subprocess, drop all handles + sessions. */
547
1057
  async stop(): Promise<void> {
548
1058
  await this.cancel();
1059
+ this.denyAllPendingPermissions(); // belt-and-suspenders — cancel() already does this
1060
+ this.declineAllPendingElicitations(); // ditto
549
1061
  try {
550
1062
  this.proc?.kill();
551
1063
  } catch {