@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.
- package/apps/studio/acp/bridge.ts +628 -74
- package/apps/studio/acp/index.ts +172 -23
- package/apps/studio/acp/probe.ts +31 -7
- package/apps/studio/acp/transcript.ts +91 -5
- package/apps/studio/annotations-snap.ts +50 -10
- package/apps/studio/api.ts +344 -14
- package/apps/studio/artboard-guides-overlay.tsx +270 -0
- package/apps/studio/bin/_import-asset.mjs +35 -5
- package/apps/studio/bin/_pdf-playwright.mjs +35 -4
- package/apps/studio/bin/_png-playwright.mjs +38 -4
- package/apps/studio/bin/_pw-launch.mjs +52 -0
- package/apps/studio/bin/_pw-launch.test.mjs +90 -0
- package/apps/studio/bin/_smart-frames.mjs +419 -0
- package/apps/studio/bin/_smart-frames.test.mjs +140 -0
- package/apps/studio/bin/smart-frames.sh +30 -0
- package/apps/studio/canvas-edit.ts +302 -3
- package/apps/studio/canvas-lib.tsx +203 -11
- package/apps/studio/canvas-shell.tsx +167 -28
- package/apps/studio/client/app.jsx +678 -176
- package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
- package/apps/studio/client/panels/ChatPanel.jsx +846 -133
- package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
- package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
- package/apps/studio/client/panels/ReadinessList.jsx +17 -3
- package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
- package/apps/studio/client/panels/ToolGroup.jsx +79 -0
- package/apps/studio/client/panels/acp-capabilities.js +82 -0
- package/apps/studio/client/panels/acp-elicitation.js +194 -0
- package/apps/studio/client/panels/acp-runtime.js +248 -9
- package/apps/studio/client/panels/acp-usage.js +65 -0
- package/apps/studio/client/panels/transcript-view.js +36 -0
- package/apps/studio/client/styles/6-acp-chat.css +689 -11
- package/apps/studio/dist/client.bundle.js +1738 -1736
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +20 -0
- package/apps/studio/export-dialog.tsx +138 -18
- package/apps/studio/exporters/pdf.ts +332 -18
- package/apps/studio/exporters/png.ts +45 -3
- package/apps/studio/footage/schema.ts +17 -1
- package/apps/studio/generation/gemma-models.ts +224 -0
- package/apps/studio/generation/prefs.ts +43 -0
- package/apps/studio/generation/whisper-models.test.ts +28 -1
- package/apps/studio/generation/whisper-models.ts +22 -7
- package/apps/studio/http.ts +246 -9
- package/apps/studio/print/marks.ts +113 -0
- package/apps/studio/print/units.ts +269 -0
- package/apps/studio/print-overlay-content.tsx +132 -0
- package/apps/studio/test/acp-bridge.test.ts +11 -6
- package/apps/studio/test/acp-capabilities.test.ts +123 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
- package/apps/studio/test/acp-elicitation.test.ts +251 -0
- package/apps/studio/test/acp-mode-banner.test.ts +45 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
- package/apps/studio/test/acp-permission.test.ts +262 -0
- package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -0
- package/apps/studio/test/acp-toolgroup.test.ts +76 -0
- package/apps/studio/test/acp-transcript-view.test.ts +71 -0
- package/apps/studio/test/acp-transcript.test.ts +75 -2
- package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
- package/apps/studio/test/acp-usage.test.ts +143 -0
- package/apps/studio/test/annotations-snap.test.ts +56 -0
- package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
- package/apps/studio/test/artboard-kinds.test.tsx +83 -0
- package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
- package/apps/studio/test/canvas-meta-api.test.ts +167 -0
- package/apps/studio/test/element-structural-edit.test.ts +250 -0
- package/apps/studio/test/exporters/png.test.ts +49 -1
- package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
- package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
- package/apps/studio/test/import-asset.test.ts +31 -3
- package/apps/studio/test/pdf-print-boxes.test.ts +272 -0
- package/apps/studio/test/print-marks.test.ts +113 -0
- package/apps/studio/test/print-units.test.ts +173 -0
- package/apps/studio/test/use-snap-guides.test.ts +81 -0
- package/apps/studio/use-chrome-visibility.tsx +19 -0
- package/apps/studio/use-element-resize.tsx +21 -2
- package/apps/studio/use-snap-guides.tsx +73 -5
- package/apps/studio/use-spacing-handles.tsx +9 -5
- package/apps/studio/whats-new.json +62 -0
- package/cli/commands/design.mjs +116 -3
- package/cli/lib/pkg-root.mjs +42 -10
- package/cli/lib/pkg-root.test.mjs +33 -1
- package/package.json +11 -9
- 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
|
-
/**
|
|
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
|
-
|
|
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
|
-
*
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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> = {
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
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:
|
|
220
|
-
private
|
|
221
|
-
|
|
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
|
-
/**
|
|
243
|
-
*
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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 (
|
|
439
|
-
this.opts.onCommands?.(
|
|
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(
|
|
449
|
-
void this.appendTranscript({ role: 'agent', update:
|
|
814
|
+
this.opts.onUpdate(u);
|
|
815
|
+
void this.appendTranscript({ role: 'agent', update: u });
|
|
450
816
|
},
|
|
451
|
-
requestPermission: (params: RequestPermissionRequest): RequestPermissionResponse => {
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
// Claude Code
|
|
455
|
-
//
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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 {
|