@lucascouts/claude-agent-acp-plus 0.1.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.
@@ -0,0 +1,3 @@
1
+ export declare function agentNameFromPackageName(name: string): string;
2
+ export declare const agentName: string;
3
+ //# sourceMappingURL=agent-name.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-name.d.ts","sourceRoot":"","sources":["../src/agent-name.ts"],"names":[],"mappings":"AASA,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7D;AAKD,eAAO,MAAM,SAAS,QAA6C,CAAC"}
@@ -0,0 +1,15 @@
1
+ // Single import point for the adapter's announced agent identity (R2.1, R2.2).
2
+ // This file must stay top-level in src/: it compiles to dist/agent-name.js,
3
+ // where the `../package.json` relative import still resolves. A subdirectory
4
+ // placement would compile to dist/<subdir>/ and break that path.
5
+ import packageJson from "../package.json" with { type: "json" };
6
+ // Strips a leading npm scope (`@scope/`) from a package name; unscoped names
7
+ // pass through unchanged. Anchored replace (not split("/")) so an unscoped
8
+ // name is never mangled.
9
+ export function agentNameFromPackageName(name) {
10
+ return name.replace(/^@[^/]+\//, "");
11
+ }
12
+ // The agent name this adapter announces over ACP, derived from its own
13
+ // package.json `name` — e.g. "@lucascouts/claude-agent-acp-fork" →
14
+ // "claude-agent-acp-fork". Consumed by the ACP initialize wiring.
15
+ export const agentName = agentNameFromPackageName(packageJson.name);
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Fallback for the built-in AskUserQuestion tool when the connected client does
3
+ * NOT advertise `elicitation.form`. Upstream disables the tool for such clients;
4
+ * this module lets the agent instead route each question through ACP's
5
+ * `session/request_permission` dialog. The path is gated by the
6
+ * `ACP_ASKUSERQUESTION_FALLBACK` env var so it can be turned off to restore
7
+ * byte-for-byte upstream behavior.
8
+ *
9
+ * Alongside the env gate this module provides the pure mapping/orchestration
10
+ * helpers (`questionPermissionOptions`, `handleAskUserQuestionViaPermission`);
11
+ * the ACP client is injected by the adapter that wires them in.
12
+ */
13
+ import type { PermissionOption } from "@agentclientprotocol/sdk";
14
+ import { type AskUserQuestion } from "./elicitation.js";
15
+ /**
16
+ * Whether the AskUserQuestion permission fallback is enabled for clients lacking
17
+ * `elicitation.form`. Defaults ON: only an explicit `0` or `false` (trimmed,
18
+ * case-insensitive) turns it off; any other value — unset, `1`, `true`, … —
19
+ * leaves it on.
20
+ *
21
+ * @param env Environment map to read `ACP_ASKUSERQUESTION_FALLBACK` from (pass
22
+ * `process.env`).
23
+ */
24
+ export declare function askUserQuestionFallbackEnabled(env: Record<string, string | undefined>): boolean;
25
+ /**
26
+ * Map one AskUserQuestion question to the options shown in ACP's
27
+ * `session/request_permission` dialog: one `allow_once` option per choice (in
28
+ * order) followed by a trailing `reject_once` "Skip this question" option.
29
+ *
30
+ * Each choice's `optionId` is its bare `label` — the value the tool records as
31
+ * the answer — so the description is never folded into the id; a non-empty
32
+ * `description` is instead appended to the human-readable `name` as
33
+ * `"<label> — <description>"`. The skip option is given a random `optionId`
34
+ * that cannot collide with any label, so the orchestrator can recognize a skip
35
+ * by id alone.
36
+ *
37
+ * @param question The single question whose choices become permission options.
38
+ */
39
+ export declare function questionPermissionOptions(question: AskUserQuestion): PermissionOption[];
40
+ /**
41
+ * Route an AskUserQuestion tool call through ACP permission requests rather than
42
+ * a form elicitation, for clients that lack `elicitation.form`. Each question is
43
+ * asked sequentially via the injected `requestPermission`; the selected label is
44
+ * accumulated into `answers` (keyed by the question text), a skipped question is
45
+ * omitted, and a multi-select question naturally degrades to a single label
46
+ * since each option is single-select here.
47
+ *
48
+ * Mirrors the upstream form handler's contract: input with no parseable
49
+ * questions denies with the identical message, and an aborted signal or a
50
+ * cancelled request throws `"Tool use aborted"`, discarding every accumulated
51
+ * answer. On success it returns the original `toolInput` augmented with
52
+ * `answers` as `updatedInput`.
53
+ *
54
+ * Pure and injectable — the real ACP client is wired in by the adapter — so the
55
+ * orchestration can be exercised without a transport.
56
+ *
57
+ * @param toolInput Raw AskUserQuestion tool input (expected to carry `questions`).
58
+ * @param requestPermission Injected permission-request function, invoked once per
59
+ * question, resolving to the user's selection or a cancellation.
60
+ * @param signal Abort signal for the tool call; checked on entry and around each
61
+ * request.
62
+ */
63
+ export declare function handleAskUserQuestionViaPermission(toolInput: Record<string, unknown>, requestPermission: (req: {
64
+ question: AskUserQuestion;
65
+ options: PermissionOption[];
66
+ }) => Promise<{
67
+ outcome: "selected";
68
+ optionId: string;
69
+ } | {
70
+ outcome: "cancelled";
71
+ }>, signal: AbortSignal): Promise<{
72
+ behavior: "allow";
73
+ updatedInput: Record<string, unknown>;
74
+ } | {
75
+ behavior: "deny";
76
+ message: string;
77
+ }>;
78
+ //# sourceMappingURL=ask-user-question-fallback.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ask-user-question-fallback.d.ts","sourceRoot":"","sources":["../src/ask-user-question-fallback.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AACjE,OAAO,EAA2B,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAEjF;;;;;;;;GAQG;AACH,wBAAgB,8BAA8B,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAAG,OAAO,CAO/F;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,eAAe,GAAG,gBAAgB,EAAE,CAYvF;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAsB,kCAAkC,CACtD,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClC,iBAAiB,EAAE,CAAC,GAAG,EAAE;IACvB,QAAQ,EAAE,eAAe,CAAC;IAC1B,OAAO,EAAE,gBAAgB,EAAE,CAAC;CAC7B,KAAK,OAAO,CAAC;IAAE,OAAO,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,WAAW,CAAA;CAAE,CAAC,EACnF,MAAM,EAAE,WAAW,GAClB,OAAO,CACN;IAAE,QAAQ,EAAE,OAAO,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GAC5D;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CACxC,CAyBA"}
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Fallback for the built-in AskUserQuestion tool when the connected client does
3
+ * NOT advertise `elicitation.form`. Upstream disables the tool for such clients;
4
+ * this module lets the agent instead route each question through ACP's
5
+ * `session/request_permission` dialog. The path is gated by the
6
+ * `ACP_ASKUSERQUESTION_FALLBACK` env var so it can be turned off to restore
7
+ * byte-for-byte upstream behavior.
8
+ *
9
+ * Alongside the env gate this module provides the pure mapping/orchestration
10
+ * helpers (`questionPermissionOptions`, `handleAskUserQuestionViaPermission`);
11
+ * the ACP client is injected by the adapter that wires them in.
12
+ */
13
+ import { randomUUID } from "node:crypto";
14
+ import { extractAskUserQuestions } from "./elicitation.js";
15
+ /**
16
+ * Whether the AskUserQuestion permission fallback is enabled for clients lacking
17
+ * `elicitation.form`. Defaults ON: only an explicit `0` or `false` (trimmed,
18
+ * case-insensitive) turns it off; any other value — unset, `1`, `true`, … —
19
+ * leaves it on.
20
+ *
21
+ * @param env Environment map to read `ACP_ASKUSERQUESTION_FALLBACK` from (pass
22
+ * `process.env`).
23
+ */
24
+ export function askUserQuestionFallbackEnabled(env) {
25
+ const raw = env.ACP_ASKUSERQUESTION_FALLBACK;
26
+ if (raw === undefined) {
27
+ return true;
28
+ }
29
+ const normalized = raw.trim().toLowerCase();
30
+ return normalized !== "0" && normalized !== "false";
31
+ }
32
+ /**
33
+ * Map one AskUserQuestion question to the options shown in ACP's
34
+ * `session/request_permission` dialog: one `allow_once` option per choice (in
35
+ * order) followed by a trailing `reject_once` "Skip this question" option.
36
+ *
37
+ * Each choice's `optionId` is its bare `label` — the value the tool records as
38
+ * the answer — so the description is never folded into the id; a non-empty
39
+ * `description` is instead appended to the human-readable `name` as
40
+ * `"<label> — <description>"`. The skip option is given a random `optionId`
41
+ * that cannot collide with any label, so the orchestrator can recognize a skip
42
+ * by id alone.
43
+ *
44
+ * @param question The single question whose choices become permission options.
45
+ */
46
+ export function questionPermissionOptions(question) {
47
+ const options = question.options.map((option) => ({
48
+ kind: "allow_once",
49
+ optionId: option.label,
50
+ name: option.description ? `${option.label} — ${option.description}` : option.label,
51
+ }));
52
+ options.push({
53
+ kind: "reject_once",
54
+ name: "Skip this question",
55
+ optionId: randomUUID(),
56
+ });
57
+ return options;
58
+ }
59
+ /**
60
+ * Route an AskUserQuestion tool call through ACP permission requests rather than
61
+ * a form elicitation, for clients that lack `elicitation.form`. Each question is
62
+ * asked sequentially via the injected `requestPermission`; the selected label is
63
+ * accumulated into `answers` (keyed by the question text), a skipped question is
64
+ * omitted, and a multi-select question naturally degrades to a single label
65
+ * since each option is single-select here.
66
+ *
67
+ * Mirrors the upstream form handler's contract: input with no parseable
68
+ * questions denies with the identical message, and an aborted signal or a
69
+ * cancelled request throws `"Tool use aborted"`, discarding every accumulated
70
+ * answer. On success it returns the original `toolInput` augmented with
71
+ * `answers` as `updatedInput`.
72
+ *
73
+ * Pure and injectable — the real ACP client is wired in by the adapter — so the
74
+ * orchestration can be exercised without a transport.
75
+ *
76
+ * @param toolInput Raw AskUserQuestion tool input (expected to carry `questions`).
77
+ * @param requestPermission Injected permission-request function, invoked once per
78
+ * question, resolving to the user's selection or a cancellation.
79
+ * @param signal Abort signal for the tool call; checked on entry and around each
80
+ * request.
81
+ */
82
+ export async function handleAskUserQuestionViaPermission(toolInput, requestPermission, signal) {
83
+ if (signal.aborted) {
84
+ throw new Error("Tool use aborted");
85
+ }
86
+ const questions = extractAskUserQuestions(toolInput);
87
+ if (!questions) {
88
+ return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
89
+ }
90
+ const answers = {};
91
+ for (const question of questions) {
92
+ const options = questionPermissionOptions(question);
93
+ const skipOptionId = options.find((option) => option.kind === "reject_once")?.optionId;
94
+ const outcome = await requestPermission({ question, options });
95
+ if (outcome.outcome === "cancelled" || signal.aborted) {
96
+ throw new Error("Tool use aborted");
97
+ }
98
+ if (outcome.optionId === skipOptionId) {
99
+ continue;
100
+ }
101
+ answers[question.question] = outcome.optionId;
102
+ }
103
+ return { behavior: "allow", updatedInput: { ...toolInput, answers } };
104
+ }
@@ -0,0 +1,129 @@
1
+ import type { CreateElicitationRequest, CreateElicitationResponse } from "@agentclientprotocol/sdk";
2
+ import type { ElicitationRequest, ElicitationResult } from "@anthropic-ai/claude-agent-sdk";
3
+ import type { AskUserQuestionInput } from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
4
+ /**
5
+ * Bridges between the Claude Agent SDK's elicitation/dialog callbacks and ACP's
6
+ * (unstable) elicitation protocol.
7
+ *
8
+ * Two distinct SDK surfaces flow through here:
9
+ *
10
+ * 1. `onElicitation` — fired when an MCP server requests user input. These map
11
+ * directly onto ACP `session/create_elicitation` (form or url mode).
12
+ * 2. The built-in AskUserQuestion tool — when a `canUseTool` callback is
13
+ * registered the SDK routes its permission check through `canUseTool`
14
+ * (not the interactive `permission_ask_user_question` dialog). We render
15
+ * its questions as an ACP form elicitation and feed the user's selections
16
+ * back as the tool's `updatedInput`, which the tool's own `call()` reads.
17
+ */
18
+ /** Modes the connected client advertised support for. */
19
+ export type ElicitationSupport = {
20
+ form: boolean;
21
+ url: boolean;
22
+ };
23
+ /**
24
+ * Convert an MCP elicitation request (from the SDK's `onElicitation` callback)
25
+ * into an ACP `CreateElicitationRequest`. Returns `null` when the request can't
26
+ * be represented (e.g. a url-mode request with no url).
27
+ */
28
+ export declare function mcpElicitationToCreateRequest(request: ElicitationRequest, sessionId: string): CreateElicitationRequest | null;
29
+ /**
30
+ * Map an ACP elicitation response back to the MCP `ElicitResult` the SDK expects
31
+ * to hand back to the requesting server.
32
+ */
33
+ export declare function createElicitationResponseToElicitResult(response: CreateElicitationResponse): ElicitationResult;
34
+ /**
35
+ * A single question as supplied by the AskUserQuestion tool. Derived from the
36
+ * SDK's input type so the shape stays in sync; the SDK validates the model's
37
+ * tool call against this schema before it reaches us.
38
+ */
39
+ export type AskUserQuestion = AskUserQuestionInput["questions"][number];
40
+ /**
41
+ * Pull the well-formed questions out of an AskUserQuestion tool input. Returns
42
+ * `null` when there are no usable questions — including the case where every
43
+ * entry is malformed and filtering leaves an empty list — so callers can treat
44
+ * "nothing to ask" uniformly.
45
+ */
46
+ export declare function extractAskUserQuestions(input: Record<string, unknown>): AskUserQuestion[] | null;
47
+ /**
48
+ * Render the AskUserQuestion tool's questions as an ACP form elicitation.
49
+ *
50
+ * Fields are keyed by a short stable id (`question_<n>`) rather than the full
51
+ * question text, so the question text appears in exactly one place per field.
52
+ * Single-select questions use a titled `oneOf` enum; multi-select questions use
53
+ * an array with a titled `anyOf` item enum. The enum `const` is always the
54
+ * option label, since that is what the tool records as the answer; an option's
55
+ * secondary text travels in the enum option's own `description` field.
56
+ *
57
+ * Each question is followed by its own optional free-text "custom answer" field
58
+ * (`question_<n>_custom`), mirroring the CLI's per-question "Other" box: the
59
+ * user can type their own answer instead of picking an option, scoped to that
60
+ * specific question. Nothing is marked required, so the user can also just skip
61
+ * — matching the built-in tool, which always offers Skip + a free-text box.
62
+ */
63
+ export declare function askUserQuestionsToCreateRequest(questions: AskUserQuestion[], sessionId: string, toolCallId: string | undefined): CreateElicitationRequest;
64
+ /** Outcome of an AskUserQuestion elicitation, decoupled from any transport. */
65
+ export type AskUserQuestionOutcome = {
66
+ action: "answered";
67
+ updatedInput: Record<string, unknown>;
68
+ } | {
69
+ action: "cancel";
70
+ };
71
+ /**
72
+ * Fold an ACP elicitation response into the AskUserQuestion tool's input.
73
+ *
74
+ * Selected labels are read back from the indexed form fields and written into
75
+ * `answers` as a `{ [questionText]: label }` map (comma-joining multi-selects)
76
+ * — the key shape the tool's own `call()` reads. A non-empty per-question
77
+ * custom-answer field (`question_<n>_custom`) takes precedence over that
78
+ * question's selection, since the user typed their own answer instead of
79
+ * picking one. Decline yields empty answers (the model is told the user skipped
80
+ * rather than the turn aborting); cancel — and any custom/future action we
81
+ * don't understand — aborts the tool call.
82
+ */
83
+ export declare function applyAskElicitationResponse(response: CreateElicitationResponse, toolInput: Record<string, unknown>, questions: AskUserQuestion[]): AskUserQuestionOutcome;
84
+ /**
85
+ * The `request_user_dialog` kind the CLI emits when a model refusal has a
86
+ * fallback available but needs user consent before retrying (e.g. Claude Fable
87
+ * declining a request with Opus available as the fallback). Declaring this
88
+ * kind in `supportedDialogKinds` is the opt-in: the CLI fails closed and never
89
+ * emits an undeclared kind — the flow degrades to the classic refusal error
90
+ * ending the turn.
91
+ */
92
+ export declare const REFUSAL_FALLBACK_DIALOG_KIND = "refusal_fallback_prompt";
93
+ /**
94
+ * Payload of the `refusal_fallback_prompt` dialog. The dialog protocol
95
+ * transports payloads opaquely, so this shape is recovered from the CLI's own
96
+ * schema (v2.1.177): `originalModel`/`fallbackModel` are required strings;
97
+ * `apiRefusalCategory` (nullable), `guidanceText`, and
98
+ * `retractedMessageUuids` are optional. We ignore `retractedMessageUuids` —
99
+ * ACP has no way to retract already-streamed chunks.
100
+ */
101
+ export type RefusalFallbackPrompt = {
102
+ originalModel: string;
103
+ fallbackModel: string;
104
+ apiRefusalCategory: string | null;
105
+ guidanceText?: string;
106
+ };
107
+ /**
108
+ * Validate the opaque dialog payload into a {@link RefusalFallbackPrompt}.
109
+ * Returns `null` when the required fields are missing or mistyped (a newer CLI
110
+ * may reshape the payload), so the caller can cancel the dialog and let the
111
+ * CLI apply its default behavior instead of rendering something misleading.
112
+ */
113
+ export declare function extractRefusalFallbackPrompt(payload: Record<string, unknown>): RefusalFallbackPrompt | null;
114
+ /**
115
+ * Render the refusal-fallback consent prompt as an ACP form elicitation: a
116
+ * single-select between retrying on the fallback model and keeping the
117
+ * refusal. The enum `const`s are the dialog's wire result values, so the
118
+ * response maps back without a translation table.
119
+ */
120
+ export declare function refusalFallbackToCreateRequest(prompt: RefusalFallbackPrompt, sessionId: string): CreateElicitationRequest;
121
+ /**
122
+ * Map the elicitation response back to the dialog's result enum. Only an
123
+ * explicit accept-with-retry resolves to `retry_fallback`; decline, cancel, a
124
+ * skipped field, or an unrecognized value all keep the refusal — the dialog's
125
+ * own default — so a dismissed or half-filled form can never trigger a model
126
+ * switch the user didn't ask for.
127
+ */
128
+ export declare function refusalFallbackResultFromResponse(response: CreateElicitationResponse): string;
129
+ //# sourceMappingURL=elicitation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"elicitation.d.ts","sourceRoot":"","sources":["../src/elicitation.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,wBAAwB,EACxB,yBAAyB,EAM1B,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AAC5F,OAAO,KAAK,EACV,oBAAoB,EAErB,MAAM,6CAA6C,CAAC;AAErD;;;;;;;;;;;;;GAaG;AAEH,yDAAyD;AACzD,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,OAAO,CAAC;IACd,GAAG,EAAE,OAAO,CAAC;CACd,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,kBAAkB,EAC3B,SAAS,EAAE,MAAM,GAChB,wBAAwB,GAAG,IAAI,CAyBjC;AAyCD;;;GAGG;AACH,wBAAgB,uCAAuC,CACrD,QAAQ,EAAE,yBAAyB,GAClC,iBAAiB,CAUnB;AAED;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG,oBAAoB,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;AAExE;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,eAAe,EAAE,GAAG,IAAI,CAUhG;AAwBD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,+BAA+B,CAC7C,SAAS,EAAE,eAAe,EAAE,EAC5B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GAAG,SAAS,GAC7B,wBAAwB,CAqD1B;AAED,+EAA+E;AAC/E,MAAM,MAAM,sBAAsB,GAChC;IAAE,MAAM,EAAE,UAAU,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AAEvF;;;;;;;;;;;GAWG;AACH,wBAAgB,2BAA2B,CACzC,QAAQ,EAAE,yBAAyB,EACnC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClC,SAAS,EAAE,eAAe,EAAE,GAC3B,sBAAsB,CAkCxB;AAgBD;;;;;;;GAOG;AACH,eAAO,MAAM,4BAA4B,4BAA4B,CAAC;AAEtE;;;;;;;GAOG;AACH,MAAM,MAAM,qBAAqB,GAAG;IAClC,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC/B,qBAAqB,GAAG,IAAI,CAW9B;AAYD;;;;;GAKG;AACH,wBAAgB,8BAA8B,CAC5C,MAAM,EAAE,qBAAqB,EAC7B,SAAS,EAAE,MAAM,GAChB,wBAAwB,CA+B1B;AAED;;;;;;GAMG;AACH,wBAAgB,iCAAiC,CAAC,QAAQ,EAAE,yBAAyB,GAAG,MAAM,CAG7F"}
@@ -0,0 +1,312 @@
1
+ import { randomUUID } from "node:crypto";
2
+ /**
3
+ * Convert an MCP elicitation request (from the SDK's `onElicitation` callback)
4
+ * into an ACP `CreateElicitationRequest`. Returns `null` when the request can't
5
+ * be represented (e.g. a url-mode request with no url).
6
+ */
7
+ export function mcpElicitationToCreateRequest(request, sessionId) {
8
+ if (request.mode === "url") {
9
+ if (!request.url) {
10
+ return null;
11
+ }
12
+ return {
13
+ mode: "url",
14
+ sessionId,
15
+ message: request.message,
16
+ url: request.url,
17
+ // URL elicitations need a stable id so the client can correlate the
18
+ // later `session/complete_elicitation` notification. MCP servers usually
19
+ // provide one; fall back to a generated id if not.
20
+ elicitationId: request.elicitationId ?? randomUUID(),
21
+ };
22
+ }
23
+ // Form mode (the default). The MCP `requestedSchema` is already a JSON Schema
24
+ // with primitive-typed properties, which is structurally what ACP expects.
25
+ return {
26
+ mode: "form",
27
+ sessionId,
28
+ message: request.message,
29
+ requestedSchema: normalizeElicitationSchema(request.requestedSchema),
30
+ };
31
+ }
32
+ /** A wire value allowed in elicitation form content. */
33
+ function isElicitationContentValue(value) {
34
+ return (typeof value === "string" ||
35
+ typeof value === "number" ||
36
+ typeof value === "boolean" ||
37
+ (Array.isArray(value) && value.every((item) => typeof item === "string")));
38
+ }
39
+ /**
40
+ * Content of an accepted elicitation response.
41
+ *
42
+ * The response union includes a custom/future-action variant whose `action` is
43
+ * plain `string`, so a positive `=== "accept"` check can never narrow it away
44
+ * and `content` reads as `unknown`. Rather than asserting which union member
45
+ * this is, validate the values: the SDK's own wire validation has the same
46
+ * blind spot (an accept response with malformed content parses via the
47
+ * catch-all variant), so ill-typed values are dropped here either way.
48
+ */
49
+ function acceptedElicitationContent(response) {
50
+ if (response.action !== "accept") {
51
+ return {};
52
+ }
53
+ const { content } = response;
54
+ if (!content || typeof content !== "object") {
55
+ return {};
56
+ }
57
+ const validated = {};
58
+ for (const [key, value] of Object.entries(content)) {
59
+ if (isElicitationContentValue(value)) {
60
+ validated[key] = value;
61
+ }
62
+ }
63
+ return validated;
64
+ }
65
+ /**
66
+ * Map an ACP elicitation response back to the MCP `ElicitResult` the SDK expects
67
+ * to hand back to the requesting server.
68
+ */
69
+ export function createElicitationResponseToElicitResult(response) {
70
+ switch (response.action) {
71
+ case "accept":
72
+ return { action: "accept", content: acceptedElicitationContent(response) };
73
+ case "decline":
74
+ return { action: "decline" };
75
+ case "cancel":
76
+ default:
77
+ return { action: "cancel" };
78
+ }
79
+ }
80
+ /**
81
+ * Pull the well-formed questions out of an AskUserQuestion tool input. Returns
82
+ * `null` when there are no usable questions — including the case where every
83
+ * entry is malformed and filtering leaves an empty list — so callers can treat
84
+ * "nothing to ask" uniformly.
85
+ */
86
+ export function extractAskUserQuestions(input) {
87
+ const questions = input.questions;
88
+ if (!Array.isArray(questions)) {
89
+ return null;
90
+ }
91
+ const valid = questions.filter((q) => !!q && typeof q.question === "string" && Array.isArray(q.options) && q.options.length > 0);
92
+ return valid.length > 0 ? valid : null;
93
+ }
94
+ /** Stable form-field key for the question at the given index. */
95
+ function questionFieldKey(index) {
96
+ return `question_${index}`;
97
+ }
98
+ /**
99
+ * Form-field key for the per-question free-text "custom answer" field that sits
100
+ * alongside `question_<n>`. Mirrors the first-party clients, where every
101
+ * question carries its own "Other" box rather than one form-level field.
102
+ */
103
+ function questionCustomFieldKey(index) {
104
+ return `question_${index}_custom`;
105
+ }
106
+ /**
107
+ * `_meta` key under which a bridged enum option carries its `preview`, the one
108
+ * option field ACP's `EnumOption` still has no slot for (descriptions are
109
+ * first-class as of schema 1.19). Namespaced like the agent's other `_meta`
110
+ * extensions (`_claude/...`).
111
+ */
112
+ const OPTION_META_KEY = "_claude/askUserQuestionOption";
113
+ /**
114
+ * Render the AskUserQuestion tool's questions as an ACP form elicitation.
115
+ *
116
+ * Fields are keyed by a short stable id (`question_<n>`) rather than the full
117
+ * question text, so the question text appears in exactly one place per field.
118
+ * Single-select questions use a titled `oneOf` enum; multi-select questions use
119
+ * an array with a titled `anyOf` item enum. The enum `const` is always the
120
+ * option label, since that is what the tool records as the answer; an option's
121
+ * secondary text travels in the enum option's own `description` field.
122
+ *
123
+ * Each question is followed by its own optional free-text "custom answer" field
124
+ * (`question_<n>_custom`), mirroring the CLI's per-question "Other" box: the
125
+ * user can type their own answer instead of picking an option, scoped to that
126
+ * specific question. Nothing is marked required, so the user can also just skip
127
+ * — matching the built-in tool, which always offers Skip + a free-text box.
128
+ */
129
+ export function askUserQuestionsToCreateRequest(questions, sessionId, toolCallId) {
130
+ const single = questions.length === 1;
131
+ const properties = {};
132
+ questions.forEach((question, index) => {
133
+ const options = question.options.map((option) => {
134
+ const enumOption = {
135
+ const: option.label,
136
+ title: option.label,
137
+ };
138
+ if (option.description) {
139
+ enumOption.description = option.description;
140
+ }
141
+ // The SDK option's `preview` (mockups, code snippets, comparisons shown
142
+ // on focus) still has no structural slot in `EnumOption`, so forward it
143
+ // under ACP's reserved `_meta` extension point for clients that render it.
144
+ if (option.preview) {
145
+ enumOption._meta = { [OPTION_META_KEY]: { preview: option.preview } };
146
+ }
147
+ return enumOption;
148
+ });
149
+ // For a single question the prompt is carried by `message`, so we don't
150
+ // repeat it in the field description. With multiple questions each field
151
+ // needs its own question text.
152
+ const description = single ? undefined : question.question;
153
+ const title = question.header || undefined;
154
+ properties[questionFieldKey(index)] = question.multiSelect
155
+ ? { type: "array", title, description, items: { anyOf: options } }
156
+ : { type: "string", title, description, oneOf: options };
157
+ properties[questionCustomFieldKey(index)] = {
158
+ type: "string",
159
+ title: "Other",
160
+ description: "Type your own answer instead of choosing an option above (optional).",
161
+ };
162
+ });
163
+ const requestedSchema = {
164
+ type: "object",
165
+ properties,
166
+ };
167
+ const message = single ? questions[0].question : "Please answer the following questions.";
168
+ return {
169
+ mode: "form",
170
+ sessionId,
171
+ ...(toolCallId ? { toolCallId } : {}),
172
+ message,
173
+ requestedSchema,
174
+ };
175
+ }
176
+ /**
177
+ * Fold an ACP elicitation response into the AskUserQuestion tool's input.
178
+ *
179
+ * Selected labels are read back from the indexed form fields and written into
180
+ * `answers` as a `{ [questionText]: label }` map (comma-joining multi-selects)
181
+ * — the key shape the tool's own `call()` reads. A non-empty per-question
182
+ * custom-answer field (`question_<n>_custom`) takes precedence over that
183
+ * question's selection, since the user typed their own answer instead of
184
+ * picking one. Decline yields empty answers (the model is told the user skipped
185
+ * rather than the turn aborting); cancel — and any custom/future action we
186
+ * don't understand — aborts the tool call.
187
+ */
188
+ export function applyAskElicitationResponse(response, toolInput, questions) {
189
+ if (response.action === "decline") {
190
+ return { action: "answered", updatedInput: { ...toolInput, answers: {} } };
191
+ }
192
+ if (response.action !== "accept") {
193
+ return { action: "cancel" };
194
+ }
195
+ const content = acceptedElicitationContent(response);
196
+ // Typed against the tool's own output schema so the answer/response shapes
197
+ // stay in sync with what the built-in tool's call() expects to read back.
198
+ const answers = {};
199
+ questions.forEach((question, index) => {
200
+ // A typed custom answer wins over the selection: the user chose to write
201
+ // their own answer for this question instead of picking an option.
202
+ const custom = content[questionCustomFieldKey(index)];
203
+ if (typeof custom === "string" && custom.trim() !== "") {
204
+ answers[question.question] = custom.trim();
205
+ return;
206
+ }
207
+ const value = content[questionFieldKey(index)];
208
+ if (value === undefined || value === null) {
209
+ return;
210
+ }
211
+ const text = Array.isArray(value) ? value.join(", ") : String(value);
212
+ if (text === "") {
213
+ return;
214
+ }
215
+ answers[question.question] = text;
216
+ });
217
+ return { action: "answered", updatedInput: { ...toolInput, answers } };
218
+ }
219
+ /**
220
+ * Coerce an arbitrary MCP `requestedSchema` into an ACP `ElicitationSchema`.
221
+ * The two are structurally compatible JSON Schemas; we just guarantee the
222
+ * `type: "object"` discriminator is present.
223
+ */
224
+ function normalizeElicitationSchema(schema) {
225
+ if (!schema || typeof schema !== "object") {
226
+ return { type: "object", properties: {} };
227
+ }
228
+ return { ...schema, type: "object" };
229
+ }
230
+ /**
231
+ * The `request_user_dialog` kind the CLI emits when a model refusal has a
232
+ * fallback available but needs user consent before retrying (e.g. Claude Fable
233
+ * declining a request with Opus available as the fallback). Declaring this
234
+ * kind in `supportedDialogKinds` is the opt-in: the CLI fails closed and never
235
+ * emits an undeclared kind — the flow degrades to the classic refusal error
236
+ * ending the turn.
237
+ */
238
+ export const REFUSAL_FALLBACK_DIALOG_KIND = "refusal_fallback_prompt";
239
+ /**
240
+ * Validate the opaque dialog payload into a {@link RefusalFallbackPrompt}.
241
+ * Returns `null` when the required fields are missing or mistyped (a newer CLI
242
+ * may reshape the payload), so the caller can cancel the dialog and let the
243
+ * CLI apply its default behavior instead of rendering something misleading.
244
+ */
245
+ export function extractRefusalFallbackPrompt(payload) {
246
+ const { originalModel, fallbackModel, apiRefusalCategory, guidanceText } = payload;
247
+ if (typeof originalModel !== "string" || typeof fallbackModel !== "string") {
248
+ return null;
249
+ }
250
+ return {
251
+ originalModel,
252
+ fallbackModel,
253
+ apiRefusalCategory: typeof apiRefusalCategory === "string" ? apiRefusalCategory : null,
254
+ ...(typeof guidanceText === "string" && guidanceText ? { guidanceText } : {}),
255
+ };
256
+ }
257
+ /** Form-field key carrying the user's choice in the refusal-fallback form. */
258
+ const REFUSAL_FALLBACK_CHOICE_KEY = "choice";
259
+ /** Wire values of the dialog's result enum (CLI schema). `edit_prompt` is
260
+ * deliberately not offered: in the CLI it prefills the composer with the
261
+ * refused prompt for edit-and-retry, and ACP has no composer-prefill surface
262
+ * — the user can simply edit and resend on their own. */
263
+ const RETRY_FALLBACK_RESULT = "retry_fallback";
264
+ const KEEP_REFUSAL_RESULT = "cancelled";
265
+ /**
266
+ * Render the refusal-fallback consent prompt as an ACP form elicitation: a
267
+ * single-select between retrying on the fallback model and keeping the
268
+ * refusal. The enum `const`s are the dialog's wire result values, so the
269
+ * response maps back without a translation table.
270
+ */
271
+ export function refusalFallbackToCreateRequest(prompt, sessionId) {
272
+ const category = prompt.apiRefusalCategory ? ` (${prompt.apiRefusalCategory})` : "";
273
+ const guidance = prompt.guidanceText ? `\n\n${prompt.guidanceText}` : "";
274
+ return {
275
+ mode: "form",
276
+ sessionId,
277
+ message: `${prompt.originalModel} declined this request${category}. ` +
278
+ `Retry with ${prompt.fallbackModel}?` +
279
+ guidance,
280
+ requestedSchema: {
281
+ type: "object",
282
+ properties: {
283
+ [REFUSAL_FALLBACK_CHOICE_KEY]: {
284
+ type: "string",
285
+ oneOf: [
286
+ {
287
+ const: RETRY_FALLBACK_RESULT,
288
+ title: `Retry with ${prompt.fallbackModel}`,
289
+ description: `The session continues on ${prompt.fallbackModel}.`,
290
+ },
291
+ {
292
+ const: KEEP_REFUSAL_RESULT,
293
+ title: "Keep the refusal",
294
+ description: "You can send a new message.",
295
+ },
296
+ ],
297
+ },
298
+ },
299
+ },
300
+ };
301
+ }
302
+ /**
303
+ * Map the elicitation response back to the dialog's result enum. Only an
304
+ * explicit accept-with-retry resolves to `retry_fallback`; decline, cancel, a
305
+ * skipped field, or an unrecognized value all keep the refusal — the dialog's
306
+ * own default — so a dismissed or half-filled form can never trigger a model
307
+ * switch the user didn't ask for.
308
+ */
309
+ export function refusalFallbackResultFromResponse(response) {
310
+ const choice = acceptedElicitationContent(response)[REFUSAL_FALLBACK_CHOICE_KEY];
311
+ return choice === RETRY_FALLBACK_RESULT ? RETRY_FALLBACK_RESULT : KEEP_REFUSAL_RESULT;
312
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}