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