@1agh/maude 0.45.1 → 0.46.0

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 (91) hide show
  1. package/apps/studio/acp/bridge.ts +628 -74
  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/annotations-snap.ts +50 -10
  6. package/apps/studio/api.ts +344 -14
  7. package/apps/studio/artboard-guides-overlay.tsx +270 -0
  8. package/apps/studio/bin/_import-asset.mjs +35 -5
  9. package/apps/studio/bin/_pdf-playwright.mjs +35 -4
  10. package/apps/studio/bin/_png-playwright.mjs +38 -4
  11. package/apps/studio/bin/_pw-launch.mjs +52 -0
  12. package/apps/studio/bin/_pw-launch.test.mjs +90 -0
  13. package/apps/studio/bin/_smart-frames.mjs +419 -0
  14. package/apps/studio/bin/_smart-frames.test.mjs +140 -0
  15. package/apps/studio/bin/smart-frames.sh +30 -0
  16. package/apps/studio/canvas-edit.ts +302 -3
  17. package/apps/studio/canvas-lib.tsx +203 -11
  18. package/apps/studio/canvas-shell.tsx +167 -28
  19. package/apps/studio/client/app.jsx +678 -176
  20. package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
  21. package/apps/studio/client/panels/ChatPanel.jsx +846 -133
  22. package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
  23. package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
  24. package/apps/studio/client/panels/ReadinessList.jsx +17 -3
  25. package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
  26. package/apps/studio/client/panels/ToolGroup.jsx +79 -0
  27. package/apps/studio/client/panels/acp-capabilities.js +82 -0
  28. package/apps/studio/client/panels/acp-elicitation.js +194 -0
  29. package/apps/studio/client/panels/acp-runtime.js +248 -9
  30. package/apps/studio/client/panels/acp-usage.js +65 -0
  31. package/apps/studio/client/panels/transcript-view.js +36 -0
  32. package/apps/studio/client/styles/6-acp-chat.css +689 -11
  33. package/apps/studio/dist/client.bundle.js +1738 -1736
  34. package/apps/studio/dist/comment-mount.js +2 -2
  35. package/apps/studio/dist/styles.css +1 -1
  36. package/apps/studio/dom-selection.ts +20 -0
  37. package/apps/studio/export-dialog.tsx +138 -18
  38. package/apps/studio/exporters/pdf.ts +332 -18
  39. package/apps/studio/exporters/png.ts +45 -3
  40. package/apps/studio/footage/schema.ts +17 -1
  41. package/apps/studio/generation/gemma-models.ts +224 -0
  42. package/apps/studio/generation/prefs.ts +43 -0
  43. package/apps/studio/generation/whisper-models.test.ts +28 -1
  44. package/apps/studio/generation/whisper-models.ts +22 -7
  45. package/apps/studio/http.ts +246 -9
  46. package/apps/studio/print/marks.ts +113 -0
  47. package/apps/studio/print/units.ts +269 -0
  48. package/apps/studio/print-overlay-content.tsx +132 -0
  49. package/apps/studio/test/acp-bridge.test.ts +11 -6
  50. package/apps/studio/test/acp-capabilities.test.ts +123 -0
  51. package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
  52. package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
  53. package/apps/studio/test/acp-elicitation.test.ts +251 -0
  54. package/apps/studio/test/acp-mode-banner.test.ts +45 -0
  55. package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
  56. package/apps/studio/test/acp-permission.test.ts +262 -0
  57. package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -0
  58. package/apps/studio/test/acp-toolgroup.test.ts +76 -0
  59. package/apps/studio/test/acp-transcript-view.test.ts +71 -0
  60. package/apps/studio/test/acp-transcript.test.ts +75 -2
  61. package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
  62. package/apps/studio/test/acp-usage.test.ts +143 -0
  63. package/apps/studio/test/annotations-snap.test.ts +56 -0
  64. package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
  65. package/apps/studio/test/artboard-kinds.test.tsx +83 -0
  66. package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
  67. package/apps/studio/test/canvas-meta-api.test.ts +167 -0
  68. package/apps/studio/test/element-structural-edit.test.ts +250 -0
  69. package/apps/studio/test/exporters/png.test.ts +49 -1
  70. package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
  71. package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
  72. package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
  73. package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
  74. package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
  75. package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
  76. package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
  77. package/apps/studio/test/import-asset.test.ts +31 -3
  78. package/apps/studio/test/pdf-print-boxes.test.ts +272 -0
  79. package/apps/studio/test/print-marks.test.ts +113 -0
  80. package/apps/studio/test/print-units.test.ts +173 -0
  81. package/apps/studio/test/use-snap-guides.test.ts +81 -0
  82. package/apps/studio/use-chrome-visibility.tsx +19 -0
  83. package/apps/studio/use-element-resize.tsx +21 -2
  84. package/apps/studio/use-snap-guides.tsx +73 -5
  85. package/apps/studio/use-spacing-handles.tsx +9 -5
  86. package/apps/studio/whats-new.json +62 -0
  87. package/cli/commands/design.mjs +116 -3
  88. package/cli/lib/pkg-root.mjs +42 -10
  89. package/cli/lib/pkg-root.test.mjs +33 -1
  90. package/package.json +11 -9
  91. package/plugins/design/dependencies.json +35 -0
@@ -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(() => {});
@@ -125,6 +202,40 @@ function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT
125
202
  * `LoadSessionRequest` (schema/types.gen.d.ts) — `sessionFor`'s resume path
126
203
  * spreads this same object and adds `sessionId` rather than duplicating it.
127
204
  */
205
+ // The curated tool allow-list every Maude bridge session auto-approves, so the
206
+ // design workflow (edit a canvas, then shell out to the `maude` CLI + its design
207
+ // helpers) runs without a permission prompt on EVERY step — the out-of-box
208
+ // "Manual mode blocks every edit" complaint this closes (DDR-184). Deliberately
209
+ // NARROW, and a complement to (not a reversal of) DDR-179's "mode picker stays
210
+ // honest": the session mode is left untouched at its default, so anything NOT on
211
+ // this list still routes through the real approve/deny gate (requestPermission →
212
+ // PermissionPrompt) — arbitrary `Bash(curl …)`/`rm`, WebFetch, unknown MCP tools.
213
+ //
214
+ // • File tools (Read/Edit/Write/Glob/Grep/NotebookEdit) — the canvas-editing
215
+ // surface. Auto-approving Edit/Write is the accepted residual: edits land in
216
+ // the served project (already the edit target) and are reversible via the
217
+ // `_history/` snapshot stack.
218
+ // • `Bash(maude:*)` — the SINGLE rule that covers the entire design-helper
219
+ // surface, because DDR-062 routes every helper through `maude design <verb>`
220
+ // (screenshot / draw-* / canvas-rects / probe-footage / …) and their own deps
221
+ // (agent-browser, playwright, svgo) run as CHILDREN of that one bash call, so
222
+ // they need no separate entry. Bash NOT starting with `maude` still prompts.
223
+ //
224
+ // SOURCE-OF-TRUTH GUARD: this list is asserted against the `maude design` verb
225
+ // dispatch (`cli/commands/design.mjs`) + `plugins/design/dependencies.json` by
226
+ // acp-session-allowed-tools.test.ts — a future helper that is NOT reached via
227
+ // `maude design` (a brand-new top-level tool, not a new verb) fails that test
228
+ // loudly instead of silently prompting the user mid-workflow.
229
+ export const MAUDE_DEFAULT_ALLOWED_TOOLS: readonly string[] = [
230
+ 'Read',
231
+ 'Edit',
232
+ 'Write',
233
+ 'Glob',
234
+ 'Grep',
235
+ 'NotebookEdit',
236
+ 'Bash(maude:*)',
237
+ ];
238
+
128
239
  export function newSessionParams(
129
240
  repoRoot: string,
130
241
  studioBrief?: string,
@@ -142,7 +253,15 @@ export function newSessionParams(
142
253
  // deputy chain (the DDR-143 guard #6 follow-up). The project's CLAUDE.md is read
143
254
  // via a separate path and is unaffected. Always injected — every Maude bridge
144
255
  // session is auto-approving. `...plugins` (DDR-143) rides the same options object.
145
- const options: Record<string, unknown> = { settingSources: ['user'] };
256
+ const options: Record<string, unknown> = {
257
+ settingSources: ['user'],
258
+ // DDR-184 — auto-approve Maude's own first-party tool surface so the design
259
+ // workflow never stalls on a per-edit / per-`maude` permission prompt. Rides
260
+ // the same `_meta.claudeCode.options` spread as `plugins`/`settings` below
261
+ // (`...userProvidedOptions`, acp-agent.js:2333→2455) into the SDK's
262
+ // `allowedTools` (sdk.d.ts:1331). Everything off this list still prompts.
263
+ allowedTools: [...MAUDE_DEFAULT_ALLOWED_TOOLS],
264
+ };
146
265
  if (plugins && plugins.length > 0) {
147
266
  options.plugins = plugins;
148
267
  // DDR-168 — the bundled `design` plugin is now injected UNCONDITIONALLY
@@ -179,15 +298,58 @@ export function newSessionParams(
179
298
  };
180
299
  }
181
300
 
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
- );
301
+ // Milestone B (DDR-125 F2 retirement) — how long a permission request waits
302
+ // for a human decision before the bridge settles it itself. Generous (a
303
+ // person reading a tool-call card and clicking a button, not a network hop)
304
+ // but bounded so a request can never hang the turn forever. The default on
305
+ // timeout — like on turn-cancel — is DENY (`cancelled`), never allow: this is
306
+ // the security control, so failing open would defeat the point.
307
+ const PERMISSION_TIMEOUT_MS = 120_000;
308
+
309
+ // SECURITY (ethical-hacker finding, retroactive review) — a single agent turn
310
+ // can legitimately issue several tool calls back to back (a burst is normal
311
+ // agent behavior, not a bug — e.g. prompt-injected content directing several
312
+ // actions in one turn), so "one per tool call" is NOT the natural ceiling the
313
+ // original comment above assumed. Mirrors the elicitation channel's
314
+ // MAX_PENDING_ELICITATIONS cap for the same reason: an unbounded queue lets a
315
+ // backlog build silently (no depth indicator existed either — see
316
+ // ChatPanel.jsx's queue-count render) and manufactures the exact "reflexive
317
+ // Enter-mashing" precondition that made the wrong-default bug below
318
+ // exploitable in practice. Denying beyond the cap is always the safe
319
+ // direction — it degrades to "the user will have to re-trigger that action,"
320
+ // never to a silent allow.
321
+ export const MAX_PENDING_PERMISSIONS = 10;
322
+
323
+ // feature-acp-ask-user-question, SECURITY (ethical-hacker finding) — unlike a
324
+ // permission request (one per tool call, rate-limited by how fast a model can
325
+ // call tools), an elicitation can be issued directly by any connected MCP
326
+ // server with no such natural ceiling. Without a cap, a compromised/hostile
327
+ // MCP server can flood `pendingElicitations` (unbounded memory growth) or
328
+ // send an oversized `requestedSchema` (e.g. thousands of `oneOf` options) that
329
+ // the client renders with no clamp — either can freeze the panel or force the
330
+ // user into an endless Submit/Skip/Cancel loop just to get their composer
331
+ // back. Both are enforced BEFORE a request is ever registered or forwarded to
332
+ // the client — a request that trips either cap is declined immediately, the
333
+ // same fail-closed outcome as a timeout.
334
+ export const MAX_PENDING_ELICITATIONS = 5;
335
+ export const MAX_ELICITATION_SCHEMA_PROPERTIES = 20;
336
+ export const MAX_ELICITATION_SCHEMA_BYTES = 16_384;
337
+
338
+ /** Exported for direct unit-testing of the bound math without needing a live
339
+ * bridge/subprocess — see `test/acp-elicitation-bridge.test.ts`. */
340
+ export function elicitationSchemaWithinBounds(schema: unknown): boolean {
341
+ if (!schema || typeof schema !== 'object') return true; // nothing to bound
342
+ const properties = (schema as { properties?: unknown }).properties;
343
+ if (properties && typeof properties === 'object') {
344
+ if (Object.keys(properties).length > MAX_ELICITATION_SCHEMA_PROPERTIES) return false;
345
+ }
346
+ let serialized: string;
347
+ try {
348
+ serialized = JSON.stringify(schema);
349
+ } catch {
350
+ return false; // unserializable (e.g. a cycle) — never trust it
351
+ }
352
+ return serialized.length <= MAX_ELICITATION_SCHEMA_BYTES;
191
353
  }
192
354
 
193
355
  export class AcpBridge {
@@ -212,16 +374,69 @@ export class AcpBridge {
212
374
  /** True while `conn.loadSession()` is replaying a resumed session's history
213
375
  * back through the `sessionUpdate` client callback — see the guard in `start()`. */
214
376
  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.
377
+ // The last-advertised capability set for the live session — the dynamic
378
+ // replacement for the old hardcoded MODELS/EFFORTS arrays (feature-acp-panel-
379
+ // dynamic-claude-code-capabilities). Also doubles as the server-side
380
+ // allowlist a `set-mode`/`set-config` WS frame is validated against
381
+ // (DDR-125 F1 — a loopback frame still can't pin an arbitrary value).
382
+ private lastModes: SessionModeState | null = null;
383
+ private lastConfigOptions: SessionConfigOption[] = [];
384
+ // The user's current model/effort/mode picks — no longer env-at-spawn
385
+ // (Task A3); applied ONCE, live, right after a session is established
386
+ // (create OR resume), never forcing a respawn. `null` = "leave the
387
+ // session's own default alone."
218
388
  private desiredModel: string | null = null;
219
- private desiredEffort: AcpEffort = 'balanced';
220
- private activeModel: string | null = null;
221
- private activeEffort: AcpEffort = 'balanced';
389
+ private desiredEffort: string | null = null;
390
+ private desiredModeId: string | null = null;
391
+ // Milestone B — permission requests awaiting a human decision, keyed by the
392
+ // nonce handed to the client in the `permission-request` frame.
393
+ private pendingPermissions = new Map<
394
+ string,
395
+ {
396
+ resolve: (r: RequestPermissionResponse) => void;
397
+ timer: ReturnType<typeof setTimeout>;
398
+ /** The optionIds actually offered for THIS request — resolvePermission
399
+ * fails closed (denies) on anything else, so a decision can't pin an
400
+ * option that was never on the table (DDR-125 F1 posture). */
401
+ optionIds: Set<string>;
402
+ }
403
+ >();
404
+ // feature-acp-ask-user-question — elicitation-form requests awaiting a human
405
+ // decision, keyed by the nonce handed to the client in the
406
+ // `elicitation-request` frame. Parallel to `pendingPermissions` rather than
407
+ // sharing its Map: the two response shapes (`RequestPermissionResponse`'s
408
+ // `{outcome:{outcome,optionId?}}` vs `CreateElicitationResponse`'s
409
+ // `{action,content?}`) don't unify cleanly under one generic "pending
410
+ // client answer" type without a discriminated wrapper that would make BOTH
411
+ // call sites harder to read for no real gain (open decision #2 in the plan).
412
+ private pendingElicitations = new Map<
413
+ string,
414
+ {
415
+ resolve: (r: CreateElicitationResponse) => void;
416
+ timer: ReturnType<typeof setTimeout>;
417
+ }
418
+ >();
419
+ // Milestone D — the last-seen usage snapshot, cached the same way lastModes/
420
+ // lastConfigOptions are (mirrors the manager's latestCommands replay pattern).
421
+ private lastUsage: BridgeUsage | null = null;
222
422
 
223
423
  constructor(private readonly opts: AcpBridgeOptions) {}
224
424
 
425
+ /** The last-advertised mode roster + current mode (read-only snapshot). */
426
+ get modes(): SessionModeState | null {
427
+ return this.lastModes;
428
+ }
429
+
430
+ /** The last-advertised generic config-option set (read-only snapshot). */
431
+ get configOptions(): SessionConfigOption[] {
432
+ return this.lastConfigOptions;
433
+ }
434
+
435
+ /** The last-seen usage snapshot (read-only), or null before the first `usage_update`. */
436
+ get usage(): BridgeUsage | null {
437
+ return this.lastUsage;
438
+ }
439
+
225
440
  /** The session id of the most recent prompt (for the `connected` frame). */
226
441
  get sessionId(): string | null {
227
442
  return this.currentSession;
@@ -239,15 +454,94 @@ export class AcpBridge {
239
454
  this.sessionStorePath = path;
240
455
  }
241
456
 
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';
457
+ /**
458
+ * The user's current model/effort/mode picks (dynamic option ids/values —
459
+ * sourced from a PRIOR session's advertised `configOptions`/`modes`, never
460
+ * a hardcoded list). Stored, not applied immediately: `establishSession`
461
+ * live-applies them once, best-effort, right after the next session comes
462
+ * up (create OR resume) — see `applyDesiredConfigOnce`. Does NOT touch a
463
+ * session that's already established; use `setMode`/`setConfigOption` for
464
+ * a live mid-chat change.
465
+ */
466
+ setConfig(model: string | null, effort: string | null, modeId: string | null = null): void {
467
+ this.desiredModel = model || null;
468
+ this.desiredEffort = effort || null;
469
+ this.desiredModeId = modeId || null;
470
+ }
471
+
472
+ /**
473
+ * Live-set the session mode for `chatId` (Task A2/A4 — driven by a
474
+ * `set-mode` WS frame). Establishes the session first if none exists yet,
475
+ * so the picker works before the first prompt. The caller (index.ts)
476
+ * validates `modeId` against `this.modes` before invoking this.
477
+ */
478
+ async setMode(chatId: string, modeId: string): Promise<void> {
479
+ await this.ensureStarted();
480
+ const sessionId = await this.sessionFor(chatId);
481
+ if (!this.conn) throw new Error('ACP adapter not ready');
482
+ await this.conn.setSessionMode({ sessionId, modeId });
247
483
  }
248
484
 
249
- private configChanged(): boolean {
250
- return this.desiredModel !== this.activeModel || this.desiredEffort !== this.activeEffort;
485
+ /**
486
+ * Live-set one config option (model/effort/fast/…) for `chatId`. The
487
+ * response echoes the FULL refreshed option set (a model switch can add/
488
+ * remove the effort option, for instance) — fed back through `onCaps` so
489
+ * every listener sees the side effects, not just the option that changed.
490
+ */
491
+ async setConfigOption(chatId: string, configId: string, value: string): Promise<void> {
492
+ await this.ensureStarted();
493
+ const sessionId = await this.sessionFor(chatId);
494
+ if (!this.conn) throw new Error('ACP adapter not ready');
495
+ const response = await this.conn.setSessionConfigOption({ sessionId, configId, value });
496
+ this.lastConfigOptions = response.configOptions;
497
+ this.opts.onCaps?.(this.lastModes, this.lastConfigOptions);
498
+ }
499
+
500
+ /** True when `value` is currently offered for the select-type option `configId`. */
501
+ private optionOffers(configId: string, value: string): boolean {
502
+ const opt = this.lastConfigOptions.find((o) => o.id === configId);
503
+ if (opt?.type !== 'select') return false;
504
+ return flattenSelectOptions(opt.options).some((o) => o.value === value);
505
+ }
506
+
507
+ /**
508
+ * Reflect the user's persisted model/effort/mode picks onto a FRESHLY
509
+ * established session (Task A3) — replaces the old env-at-spawn + respawn
510
+ * dance with live `setSessionConfigOption`/`setSessionMode` calls, none of
511
+ * which tear down the running `claude` subprocess. Best-effort and ordered:
512
+ * model first (switching it can change which OTHER options — e.g. effort —
513
+ * are even offered), then effort, then mode. A pick that isn't advertised
514
+ * (e.g. an effort level unsupported by the model claude resolved to) is
515
+ * silently skipped, leaving the session's own default in place.
516
+ */
517
+ private async applyDesiredConfigOnce(sessionId: string): Promise<void> {
518
+ if (!this.conn) return;
519
+ try {
520
+ if (this.desiredModel && this.optionOffers('model', this.desiredModel)) {
521
+ const res = await this.conn.setSessionConfigOption({
522
+ sessionId,
523
+ configId: 'model',
524
+ value: this.desiredModel,
525
+ });
526
+ this.lastConfigOptions = res.configOptions;
527
+ }
528
+ if (this.desiredEffort && this.optionOffers('effort', this.desiredEffort)) {
529
+ const res = await this.conn.setSessionConfigOption({
530
+ sessionId,
531
+ configId: 'effort',
532
+ value: this.desiredEffort,
533
+ });
534
+ this.lastConfigOptions = res.configOptions;
535
+ }
536
+ if (
537
+ this.desiredModeId &&
538
+ this.lastModes?.availableModes.some((m) => m.id === this.desiredModeId)
539
+ ) {
540
+ await this.conn.setSessionMode({ sessionId, modeId: this.desiredModeId });
541
+ }
542
+ } catch {
543
+ /* best-effort — a stale/unsupported persisted pick just leaves the session default */
544
+ }
251
545
  }
252
546
 
253
547
  /** Spawn + handshake exactly once; concurrent callers share the same promise. */
@@ -297,6 +591,9 @@ export class AcpBridge {
297
591
 
298
592
  const params = newSessionParams(this.opts.repoRoot, this.opts.studioBrief, this.opts.plugins);
299
593
  const persistedId = await this.readPersistedSessionId();
594
+ let sessionId: string | undefined;
595
+ let modes: SessionModeState | null | undefined;
596
+ let configOptions: SessionConfigOption[] | null | undefined;
300
597
  if (persistedId) {
301
598
  try {
302
599
  this.replaying = true;
@@ -308,7 +605,9 @@ export class AcpBridge {
308
605
  throw new Error(`loadSession timed out after ${LOAD_SESSION_TIMEOUT_MS}ms`);
309
606
  }
310
607
  this.sessions.set(chatId, persistedId);
311
- return persistedId;
608
+ sessionId = persistedId;
609
+ modes = result.modes;
610
+ configOptions = result.configOptions;
312
611
  } catch (err) {
313
612
  await this.appendTranscript({
314
613
  role: 'bootstrap',
@@ -320,10 +619,25 @@ export class AcpBridge {
320
619
  }
321
620
  }
322
621
 
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;
622
+ if (sessionId === undefined) {
623
+ const created = await this.conn.newSession(params);
624
+ this.sessions.set(chatId, created.sessionId);
625
+ await this.writePersistedSessionId(created.sessionId);
626
+ sessionId = created.sessionId;
627
+ modes = created.modes;
628
+ configOptions = created.configOptions;
629
+ }
630
+
631
+ // Capture + apply + broadcast caps AFTER the resume-replay window closes
632
+ // (`this.replaying` back to false) — the initial state here is the real,
633
+ // current session state (the RPC response), never replayed history, but
634
+ // we still sequence it after the try/finally so nothing fires while a
635
+ // resume is nominally in flight.
636
+ this.lastModes = modes ?? null;
637
+ this.lastConfigOptions = configOptions ?? [];
638
+ await this.applyDesiredConfigOnce(sessionId);
639
+ this.opts.onCaps?.(this.lastModes, this.lastConfigOptions);
640
+ return sessionId;
327
641
  }
328
642
 
329
643
  /** Read the sessionId persisted for this chat by a prior bridge lifetime.
@@ -390,14 +704,19 @@ export class AcpBridge {
390
704
  delete env.MAUDE_TOKEN_ENDPOINT;
391
705
  // biome-ignore lint/performance/noDelete: security env-scrub — see above; `delete` is the intentional primitive here.
392
706
  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], {
707
+ // Model + effort are no longer env-at-spawn (Task A3) — the adapter starts
708
+ // on its own default and `establishSession`/`applyDesiredConfigOnce` live-
709
+ // applies the user's persisted picks via `setSessionConfigOption` once the
710
+ // session (and its advertised options) exist. No respawn on a config change.
711
+
712
+ // RCA-G1 — resolve a runnable JS runtime. On a node/bun-less machine this
713
+ // falls back to our own compiled self, which must be spawned with
714
+ // BUN_BE_BUN=1 to behave as `bun` (else it re-runs the embedded server and
715
+ // the handshake below never completes → "Working…" forever). Set on `env`
716
+ // AFTER scrubAgentEnv (which doesn't touch BUN_BE_BUN).
717
+ const runtime = resolveAgentRuntime();
718
+ if (runtime.bunBeBun) env.BUN_BE_BUN = '1';
719
+ const proc = Bun.spawn([runtime.bin, adapterEntry], {
401
720
  cwd: this.opts.repoRoot,
402
721
  env,
403
722
  stdin: 'pipe',
@@ -433,10 +752,57 @@ export class AcpBridge {
433
752
 
434
753
  const client: Client = {
435
754
  sessionUpdate: (params: SessionNotification) => {
755
+ const u = params.update;
436
756
  // The command catalogue is chrome, not chat — surface it to the UI but
437
757
  // 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 ?? []);
758
+ if (u.sessionUpdate === 'available_commands_update') {
759
+ this.opts.onCommands?.(u.availableCommands ?? []);
760
+ return;
761
+ }
762
+ // Capability-channel notifications (feature-acp-panel-dynamic-claude-code-
763
+ // capabilities) are chrome too — same treatment as the command catalogue
764
+ // above, deliberately NOT gated by `this.replaying` (mirrors
765
+ // available_commands_update): a resumed session's `loadSession` replay
766
+ // walks prior MESSAGE content only (claude-agent-acp's
767
+ // replaySessionHistory), never re-emits these side-channel notifications,
768
+ // so there is nothing stale to guard against here.
769
+ if (u.sessionUpdate === 'current_mode_update') {
770
+ // Carries only the new currentModeId — merge into the cached roster,
771
+ // never replace it (availableModes doesn't change on a mode switch).
772
+ this.lastModes = this.lastModes
773
+ ? { ...this.lastModes, currentModeId: u.currentModeId }
774
+ : { currentModeId: u.currentModeId, availableModes: [] };
775
+ this.opts.onCaps?.(this.lastModes, this.lastConfigOptions);
776
+ return;
777
+ }
778
+ if (u.sessionUpdate === 'config_option_update') {
779
+ this.lastConfigOptions = u.configOptions ?? [];
780
+ // The adapter mirrors the current mode as a "mode"-id select option
781
+ // inside configOptions (claude-agent-acp's MODE_CONFIG_ID) but doesn't
782
+ // always pair that with a current_mode_update — e.g. `setSessionMode`
783
+ // itself only emits config_option_update. Cross-derive so the
784
+ // dedicated mode picker (driven off `lastModes`) stays correct either way.
785
+ const modeOpt = this.lastConfigOptions.find((o) => o.id === 'mode');
786
+ if (modeOpt && typeof modeOpt.currentValue === 'string') {
787
+ this.lastModes = this.lastModes
788
+ ? { ...this.lastModes, currentModeId: modeOpt.currentValue }
789
+ : { currentModeId: modeOpt.currentValue, availableModes: [] };
790
+ }
791
+ this.opts.onCaps?.(this.lastModes, this.lastConfigOptions);
792
+ return;
793
+ }
794
+ if (u.sessionUpdate === 'session_info_update') {
795
+ this.opts.onSessionInfo?.({ title: u.title, updatedAt: u.updatedAt });
796
+ return;
797
+ }
798
+ if (u.sessionUpdate === 'usage_update') {
799
+ this.lastUsage = {
800
+ used: u.used,
801
+ size: u.size,
802
+ cost: u.cost ?? null,
803
+ rateLimit: u._meta?.['_claude/rateLimit'],
804
+ };
805
+ this.opts.onUsage?.(this.lastUsage);
440
806
  return;
441
807
  }
442
808
  // `loadSession` replays the resumed session's entire prior history back
@@ -445,31 +811,125 @@ export class AcpBridge {
445
811
  // transcript and already rendered client-side, so forwarding/re-appending
446
812
  // it here would duplicate every message in the panel and the jsonl file.
447
813
  if (this.replaying) return;
448
- this.opts.onUpdate(params.update);
449
- void this.appendTranscript({ role: 'agent', update: params.update });
814
+ this.opts.onUpdate(u);
815
+ void this.appendTranscript({ role: 'agent', update: u });
450
816
  },
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.
817
+ requestPermission: (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
818
+ // Milestone B (retires DDR-125 F2's blanket auto-approve) — the
819
+ // permission POLICY is now the selected session mode (sourced from
820
+ // Claude Code itself): `bypassPermissions`/`dontAsk` short-circuit
821
+ // adapter-side and never reach here at all; every OTHER mode routes
822
+ // through this real approve/deny gate. `onPermission` stays as a
823
+ // transparency callback (every request, however it resolves);
824
+ // `onPermissionRequest` is the actual UI hook the client answers.
456
825
  this.opts.onPermission?.(params);
457
- const option = pickAllowOption(params);
458
- if (!option) return { outcome: { outcome: 'cancelled' } };
459
- return { outcome: { outcome: 'selected', optionId: option.optionId } };
826
+ // SECURITY (ethical-hacker finding) — bound queue depth before
827
+ // registering a pending entry, mirroring the elicitation channel's
828
+ // MAX_PENDING_ELICITATIONS cap. Deny immediately past the cap — safe
829
+ // by construction, since deny is this gate's own fail-closed default.
830
+ if (this.pendingPermissions.size >= MAX_PENDING_PERMISSIONS) {
831
+ return Promise.resolve({ outcome: { outcome: 'cancelled' } });
832
+ }
833
+ const id = crypto.randomUUID();
834
+ const optionIds = new Set((params.options ?? []).map((o) => o.optionId));
835
+ return new Promise<RequestPermissionResponse>((resolve) => {
836
+ const timer = setTimeout(
837
+ () => this.resolvePermission(id, 'cancelled'),
838
+ this.opts.permissionTimeoutMs ?? PERMISSION_TIMEOUT_MS
839
+ );
840
+ this.pendingPermissions.set(id, { resolve, timer, optionIds });
841
+ this.opts.onPermissionRequest?.(id, params);
842
+ });
843
+ },
844
+ unstable_createElicitation: (
845
+ params: CreateElicitationRequest
846
+ ): Promise<CreateElicitationResponse> => {
847
+ // feature-acp-ask-user-question — mirrors requestPermission's shape
848
+ // exactly. Fires for BOTH `AskUserQuestion` and any MCP-server
849
+ // elicitation (see the plan's Research section) — the toolCallId/
850
+ // session scope is not special-cased to assume it's always the
851
+ // built-in tool.
852
+ //
853
+ // SECURITY (ethical-hacker finding, post-implementation review) — only
854
+ // `form` mode was ever declared in `clientCapabilities` (never `url`),
855
+ // but capability negotiation is advisory, not enforced: a non-compliant
856
+ // adapter or a malicious/buggy MCP server could send `mode:'url'`
857
+ // anyway. Reject it HERE, structurally, rather than trusting the other
858
+ // side to honor what we advertised — `url`-mode has never had client
859
+ // rendering (no code anywhere reads/shows `params.url`), so forwarding
860
+ // it would have produced a bare "message + Submit" card the user could
861
+ // click through with no idea an out-of-band URL flow was actually being
862
+ // confirmed (a confused-consent primitive — see DDR-180).
863
+ if (params.mode !== 'form') {
864
+ return Promise.resolve({ action: 'decline' });
865
+ }
866
+ // SECURITY (ethical-hacker finding) — bound queue depth + schema size
867
+ // BEFORE registering a pending entry or forwarding anything to the
868
+ // client, so a flood or an oversized schema never reaches the
869
+ // renderer at all rather than being handled gracefully once there.
870
+ if (this.pendingElicitations.size >= MAX_PENDING_ELICITATIONS) {
871
+ return Promise.resolve({ action: 'decline' });
872
+ }
873
+ if (!elicitationSchemaWithinBounds(params.requestedSchema)) {
874
+ return Promise.resolve({ action: 'decline' });
875
+ }
876
+ const id = crypto.randomUUID();
877
+ return new Promise<CreateElicitationResponse>((resolve) => {
878
+ const timer = setTimeout(
879
+ () => this.resolveElicitation(id, { action: 'decline' }),
880
+ this.opts.permissionTimeoutMs ?? PERMISSION_TIMEOUT_MS
881
+ );
882
+ this.pendingElicitations.set(id, { resolve, timer });
883
+ this.opts.onElicitationRequest?.(id, params);
884
+ });
460
885
  },
461
886
  };
462
887
 
463
888
  const conn = new ClientSideConnection(() => client, stream);
464
889
  this.conn = conn;
465
890
 
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
- });
891
+ // RCA-G1 — bound the handshake. If the spawned "adapter" is actually a
892
+ // mis-launched runtime that never speaks ACP (the exact node-less bug: a
893
+ // compiled sidecar re-run as a server instead of `bun`, before the
894
+ // BUN_BE_BUN fix, or any future runtime regression), `initialize()` never
895
+ // resolves and the panel hangs at "Working…" forever with no error. Time it
896
+ // out, tear down the dead child, and surface a real error the UI can show
897
+ // instead of an infinite spinner. Mirrors the `withTimeout` guard already
898
+ // used for `loadSession`.
899
+ const initResult = await withTimeout(
900
+ conn.initialize({
901
+ protocolVersion: PROTOCOL_VERSION,
902
+ // We don't expose the project filesystem to the agent over ACP — the
903
+ // spawned `claude` already has direct disk access to `cwd`, so advertising
904
+ // fs capabilities here would only duplicate (and widen) that surface.
905
+ // feature-acp-ask-user-question — declares `form` only, never `url`
906
+ // (an agent-chosen URL the user is directed to open is a materially
907
+ // bigger trust surface than a schema-driven form rendered entirely
908
+ // client-side — see the plan's Open decisions). This is also the
909
+ // single client-capability gate that unblocks the built-in
910
+ // `AskUserQuestion` tool AND any connected MCP server's elicitation
911
+ // requests (same wire mechanism, no sub-flag to separate them —
912
+ // acp-agent.js's `disallowedTools` check).
913
+ clientCapabilities: {
914
+ fs: { readTextFile: false, writeTextFile: false },
915
+ elicitation: { form: {} },
916
+ },
917
+ }),
918
+ INITIALIZE_TIMEOUT_MS
919
+ );
920
+ if (initResult === TIMED_OUT) {
921
+ this.conn = null;
922
+ try {
923
+ proc.kill();
924
+ } catch {
925
+ /* already gone */
926
+ }
927
+ this.proc = null;
928
+ throw new Error(
929
+ `AI editing couldn't start: the agent runtime didn't respond within ${INITIALIZE_TIMEOUT_MS / 1000}s. ` +
930
+ `Check that Claude Code is installed and signed in (Help ▸ Check AI editing readiness).`
931
+ );
932
+ }
473
933
  // Sessions are created lazily per chat (sessionFor) — not here.
474
934
  }
475
935
 
@@ -478,11 +938,6 @@ export class AcpBridge {
478
938
  text: string,
479
939
  chatId: string
480
940
  ): 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
941
  await this.ensureStarted();
487
942
  const conn = this.conn;
488
943
  if (!conn) throw new Error('ACP adapter not ready');
@@ -527,13 +982,110 @@ export class AcpBridge {
527
982
  * Best-effort: callers swallow errors (autocomplete degrades to the static list).
528
983
  */
529
984
  async warmUp(chatId: string): Promise<void> {
530
- if (this.conn && this.configChanged()) await this.stop();
531
985
  await this.ensureStarted();
532
986
  await this.sessionFor(chatId);
533
987
  }
534
988
 
989
+ /**
990
+ * Settle a pending permission request (Milestone B). `decision` is either a
991
+ * `PermissionOption.optionId` the agent offered, or the literal `'cancelled'`
992
+ * (reject/deny — the timeout default and what a turn-cancel forces). A
993
+ * request that's already been settled or whose id is unknown (stale client,
994
+ * already timed out) is a silent no-op — never throws on a race. A
995
+ * `decision` that ISN'T `'cancelled'` and wasn't actually among the options
996
+ * offered for THIS request fails closed to `'cancelled'` too (DDR-125 F1 —
997
+ * a frame can't pin an option that was never on the table, e.g. a stale
998
+ * optionId replayed from a different, already-settled request).
999
+ */
1000
+ resolvePermission(id: string, decision: string): void {
1001
+ const pending = this.pendingPermissions.get(id);
1002
+ if (!pending) return;
1003
+ this.pendingPermissions.delete(id);
1004
+ clearTimeout(pending.timer);
1005
+ const optionId = decision !== 'cancelled' && pending.optionIds.has(decision) ? decision : null;
1006
+ pending.resolve(
1007
+ optionId
1008
+ ? { outcome: { outcome: 'selected', optionId } }
1009
+ : { outcome: { outcome: 'cancelled' } }
1010
+ );
1011
+ }
1012
+
1013
+ /** Deny every currently-pending permission request — turn-cancel and full
1014
+ * teardown must never leave one hanging on a decision that will now never
1015
+ * arrive (Milestone B: the default on ANY abandonment is deny, not allow). */
1016
+ private denyAllPendingPermissions(): void {
1017
+ for (const id of [...this.pendingPermissions.keys()]) this.resolvePermission(id, 'cancelled');
1018
+ }
1019
+
1020
+ /**
1021
+ * Settle a pending elicitation request (feature-acp-ask-user-question).
1022
+ * `response` is the client's WS-frame payload — already validated shallowly
1023
+ * by index.ts (a well-formed `{action, content?}`); this is still the last
1024
+ * line of defense, so anything that isn't literally `accept` with a real
1025
+ * `content` object, or literally `cancel`, collapses to `decline`. A
1026
+ * request that's already settled or whose id is unknown (stale client,
1027
+ * already timed out) is a silent no-op — never throws on a race. `decline`
1028
+ * is deliberately NOT the same failure mode as a permission `cancelled`:
1029
+ * per `applyAskElicitationResponse`'s documented contract, decline tells
1030
+ * the model the user skipped (the turn continues), while `cancel` aborts
1031
+ * the tool call — so a bridge-initiated fail-safe (timeout, turn-cancel,
1032
+ * teardown) always declines, never cancels, unless the human explicitly
1033
+ * clicked Cancel client-side.
1034
+ */
1035
+ resolveElicitation(id: string, response: { action?: unknown; content?: unknown }): void {
1036
+ const pending = this.pendingElicitations.get(id);
1037
+ if (!pending) return;
1038
+ this.pendingElicitations.delete(id);
1039
+ clearTimeout(pending.timer);
1040
+ this.opts.onElicitationSettled?.(id);
1041
+ if (
1042
+ response.action === 'accept' &&
1043
+ response.content &&
1044
+ typeof response.content === 'object' &&
1045
+ !Array.isArray(response.content)
1046
+ ) {
1047
+ // Validate each value against the wire-allowed ElicitationContentValue
1048
+ // shape (string | number | boolean | string[]) — mirrors
1049
+ // claude-agent-acp's own `acceptedElicitationContent` validation
1050
+ // (confirmed on disk) exactly, so a value the adapter would itself
1051
+ // reject never gets forwarded as though it were a real answer.
1052
+ // `Object.create(null)` — not `{}` — for defense-in-depth parity with
1053
+ // `acp-elicitation.js`'s `buildElicitationContent` (client-side sibling
1054
+ // building the same shape): a hand-crafted `elicitation-response` frame
1055
+ // reaches THIS function directly (index.ts only shallow-validates), so
1056
+ // it's the actual last line of defense against a `__proto__`-keyed
1057
+ // `content`, not the client-side builder the real UI happens to use.
1058
+ const content: Record<string, string | number | boolean | string[]> = Object.create(null);
1059
+ for (const [key, value] of Object.entries(response.content)) {
1060
+ if (
1061
+ typeof value === 'string' ||
1062
+ typeof value === 'number' ||
1063
+ typeof value === 'boolean' ||
1064
+ (Array.isArray(value) && value.every((item) => typeof item === 'string'))
1065
+ ) {
1066
+ content[key] = value;
1067
+ }
1068
+ }
1069
+ pending.resolve({ action: 'accept', content });
1070
+ } else if (response.action === 'cancel') {
1071
+ pending.resolve({ action: 'cancel' });
1072
+ } else {
1073
+ pending.resolve({ action: 'decline' });
1074
+ }
1075
+ }
1076
+
1077
+ /** Decline every currently-pending elicitation request — same fail-closed
1078
+ * discipline as `denyAllPendingPermissions`, called from the same places. */
1079
+ private declineAllPendingElicitations(): void {
1080
+ for (const id of [...this.pendingElicitations.keys()]) {
1081
+ this.resolveElicitation(id, { action: 'decline' });
1082
+ }
1083
+ }
1084
+
535
1085
  /** Cancel the in-flight turn (no-op if nothing is running). */
536
1086
  async cancel(): Promise<void> {
1087
+ this.denyAllPendingPermissions();
1088
+ this.declineAllPendingElicitations();
537
1089
  if (this.conn && this.currentSession) {
538
1090
  try {
539
1091
  await this.conn.cancel({ sessionId: this.currentSession });
@@ -546,6 +1098,8 @@ export class AcpBridge {
546
1098
  /** Tear down: cancel, kill the subprocess, drop all handles + sessions. */
547
1099
  async stop(): Promise<void> {
548
1100
  await this.cancel();
1101
+ this.denyAllPendingPermissions(); // belt-and-suspenders — cancel() already does this
1102
+ this.declineAllPendingElicitations(); // ditto
549
1103
  try {
550
1104
  this.proc?.kill();
551
1105
  } catch {