@lucascouts/claude-agent-tui 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +1 -1
  3. package/dist/acp-agent.d.ts +62 -8
  4. package/dist/acp-agent.js +130 -15
  5. package/dist/agent-catalog.d.ts +0 -1
  6. package/dist/ansi-mirror.d.ts +0 -1
  7. package/dist/besteffort.d.ts +0 -1
  8. package/dist/billing/entrypoint-guard.d.ts +0 -1
  9. package/dist/claude-path.d.ts +0 -1
  10. package/dist/command-catalog.d.ts +84 -0
  11. package/dist/command-catalog.js +339 -0
  12. package/dist/diff-enriched-reader.d.ts +0 -1
  13. package/dist/diff-source.d.ts +0 -1
  14. package/dist/drift-checks.d.ts +0 -1
  15. package/dist/end-of-turn.d.ts +0 -1
  16. package/dist/engine-lifecycle.d.ts +0 -1
  17. package/dist/engine-pty.d.ts +0 -1
  18. package/dist/engine-watcher.d.ts +0 -1
  19. package/dist/engine.d.ts +0 -1
  20. package/dist/event-switch.d.ts +0 -1
  21. package/dist/gate/port.d.ts +0 -1
  22. package/dist/gate/settings-writer.d.ts +0 -1
  23. package/dist/image-input.d.ts +0 -1
  24. package/dist/image-vision-smoke.d.ts +0 -1
  25. package/dist/index.d.ts +0 -1
  26. package/dist/jsonl.d.ts +0 -1
  27. package/dist/lib.d.ts +0 -1
  28. package/dist/linearize.d.ts +1 -2
  29. package/dist/linearize.js +1 -1
  30. package/dist/live-diff-env.d.ts +0 -1
  31. package/dist/live-subagent-env.d.ts +0 -1
  32. package/dist/mcp-config-writer.d.ts +0 -1
  33. package/dist/model-catalog.d.ts +39 -1
  34. package/dist/model-catalog.js +77 -7
  35. package/dist/permissions/allow-inject.d.ts +0 -1
  36. package/dist/permissions/deny.d.ts +12 -1
  37. package/dist/permissions/deny.js +18 -0
  38. package/dist/permissions/elicitation-bridge.d.ts +71 -0
  39. package/dist/permissions/elicitation-bridge.js +146 -0
  40. package/dist/permissions/gate-wiring.d.ts +23 -3
  41. package/dist/permissions/gate-wiring.js +123 -1
  42. package/dist/permissions/hook-server.d.ts +11 -3
  43. package/dist/permissions/hook-server.js +10 -1
  44. package/dist/permissions/permission-mode.d.ts +0 -1
  45. package/dist/permissions/request-permission.d.ts +0 -1
  46. package/dist/settings.d.ts +0 -1
  47. package/dist/stop-reason-map.d.ts +0 -1
  48. package/dist/subagent-gate.d.ts +0 -1
  49. package/dist/subagent-source.d.ts +0 -1
  50. package/dist/subagent-watcher.d.ts +0 -1
  51. package/dist/tools.d.ts +0 -1
  52. package/dist/usage-env.d.ts +0 -1
  53. package/dist/usage.d.ts +0 -1
  54. package/dist/utils.d.ts +0 -1
  55. package/dist/zed-register.d.ts +0 -1
  56. package/package.json +6 -3
  57. package/dist/acp-agent.d.ts.map +0 -1
  58. package/dist/agent-catalog.d.ts.map +0 -1
  59. package/dist/ansi-mirror.d.ts.map +0 -1
  60. package/dist/besteffort.d.ts.map +0 -1
  61. package/dist/billing/entrypoint-guard.d.ts.map +0 -1
  62. package/dist/claude-path.d.ts.map +0 -1
  63. package/dist/diff-enriched-reader.d.ts.map +0 -1
  64. package/dist/diff-source.d.ts.map +0 -1
  65. package/dist/drift-checks.d.ts.map +0 -1
  66. package/dist/end-of-turn.d.ts.map +0 -1
  67. package/dist/engine-lifecycle.d.ts.map +0 -1
  68. package/dist/engine-pty.d.ts.map +0 -1
  69. package/dist/engine-watcher.d.ts.map +0 -1
  70. package/dist/engine.d.ts.map +0 -1
  71. package/dist/event-switch.d.ts.map +0 -1
  72. package/dist/gate/port.d.ts.map +0 -1
  73. package/dist/gate/settings-writer.d.ts.map +0 -1
  74. package/dist/image-input.d.ts.map +0 -1
  75. package/dist/image-vision-smoke.d.ts.map +0 -1
  76. package/dist/index.d.ts.map +0 -1
  77. package/dist/jsonl.d.ts.map +0 -1
  78. package/dist/lib.d.ts.map +0 -1
  79. package/dist/linearize.d.ts.map +0 -1
  80. package/dist/live-diff-env.d.ts.map +0 -1
  81. package/dist/live-subagent-env.d.ts.map +0 -1
  82. package/dist/mcp-config-writer.d.ts.map +0 -1
  83. package/dist/model-catalog.d.ts.map +0 -1
  84. package/dist/permissions/allow-inject.d.ts.map +0 -1
  85. package/dist/permissions/deny.d.ts.map +0 -1
  86. package/dist/permissions/gate-wiring.d.ts.map +0 -1
  87. package/dist/permissions/hook-server.d.ts.map +0 -1
  88. package/dist/permissions/permission-mode.d.ts.map +0 -1
  89. package/dist/permissions/request-permission.d.ts.map +0 -1
  90. package/dist/settings.d.ts.map +0 -1
  91. package/dist/stop-reason-map.d.ts.map +0 -1
  92. package/dist/subagent-gate.d.ts.map +0 -1
  93. package/dist/subagent-source.d.ts.map +0 -1
  94. package/dist/subagent-watcher.d.ts.map +0 -1
  95. package/dist/tools.d.ts.map +0 -1
  96. package/dist/usage-env.d.ts.map +0 -1
  97. package/dist/usage.d.ts.map +0 -1
  98. package/dist/utils.d.ts.map +0 -1
  99. package/dist/zed-register.d.ts.map +0 -1
@@ -41,7 +41,8 @@ export const MODEL_CATALOG = [
41
41
  {
42
42
  value: "default",
43
43
  displayName: "Default (recommended)",
44
- description: "Use the model the claude TUI is configured with (safe fallback); supports reasoning effort.",
44
+ // Story 069 (R3): `default` resolves to the recommended Opus, so it carries the Opus description.
45
+ description: "Best for everyday, complex tasks",
45
46
  supportsEffort: true,
46
47
  supportedEffortLevels: REASONING_EFFORT_LEVELS,
47
48
  supportsAutoMode: true,
@@ -49,7 +50,7 @@ export const MODEL_CATALOG = [
49
50
  {
50
51
  value: "opus",
51
52
  displayName: "Opus",
52
- description: "Claude Opus highest capability; supports reasoning effort.",
53
+ description: "Best for everyday, complex tasks",
53
54
  supportsEffort: true,
54
55
  supportedEffortLevels: REASONING_EFFORT_LEVELS,
55
56
  supportsAutoMode: true,
@@ -57,7 +58,7 @@ export const MODEL_CATALOG = [
57
58
  {
58
59
  value: "sonnet",
59
60
  displayName: "Sonnet",
60
- description: "Claude Sonnet balanced speed and capability; supports reasoning effort.",
61
+ description: "Efficient for routine tasks",
61
62
  supportsEffort: true,
62
63
  supportedEffortLevels: REASONING_EFFORT_LEVELS,
63
64
  supportsAutoMode: true,
@@ -65,7 +66,8 @@ export const MODEL_CATALOG = [
65
66
  {
66
67
  value: "sonnet[1m]",
67
68
  displayName: "Sonnet (1M context)",
68
- description: "Claude Sonnet with a 1M-token context window; draws from usage credits; supports reasoning effort.",
69
+ // Story 069 (R3): fork-only 1M variant the Sonnet description plus its 1M-window note.
70
+ description: "Efficient for routine tasks, with a 1M-token context window",
69
71
  supportsEffort: true,
70
72
  supportedEffortLevels: REASONING_EFFORT_LEVELS,
71
73
  supportsAutoMode: true,
@@ -73,13 +75,81 @@ export const MODEL_CATALOG = [
73
75
  {
74
76
  value: "haiku",
75
77
  displayName: "Haiku",
76
- description: "Claude Haiku fastest and most economical; no reasoning effort levels.",
78
+ description: "Fastest for quick answers",
77
79
  },
78
80
  {
79
81
  value: "opusplan",
80
- displayName: "Opus Plan",
81
- description: "Opus while planning, Sonnet for execution (plan-mode workflow; fork extra).",
82
+ displayName: "Opus Plan Mode",
83
+ description: "Use Opus in plan mode, Sonnet otherwise",
82
84
  },
83
85
  ];
86
+ /**
87
+ * Story 068 (R1, R1.1, R2) — the REAL per-alias context window, keyed by the EXACT {@link MODEL_CATALOG}
88
+ * `value`. These windows are NOT uniform: `opus` is natively 1M, `sonnet`/`haiku` are 200K, and
89
+ * `sonnet[1m]` is the explicit 1M variant. This map is the single source of truth that
90
+ * `inferContextWindowFromModel` (acp-agent.ts) consults BEFORE the `\b1m\b` regex fallback — the bug it
91
+ * fixes is `opus` having wrongly reported 200K (the regex only ever matched the literal `1m` token).
92
+ *
93
+ * Story 069 (R2): `default` and `opusplan` seed to 1M — `default` is the recommended Opus (the claude TUI's
94
+ * `/model default` resolves to `claude-opus-4-8[1m]`, a 1M model) and `opusplan` plans with Opus. This is
95
+ * only the PRE-FIRST-TURN seed: once a turn arrives, `inferContextWindowFromModelId` (story 069)
96
+ * AUTHORITATIVELY refines the window from the transcript's real `model`. Keys MIRROR `MODEL_CATALOG`
97
+ * `value`s; the drift guard lives in the test (068 anti-drift: every catalog value has an explicit entry).
98
+ */
99
+ export const MODEL_CONTEXT_WINDOWS = {
100
+ default: 1_000_000,
101
+ opus: 1_000_000,
102
+ sonnet: 200_000,
103
+ "sonnet[1m]": 1_000_000,
104
+ haiku: 200_000,
105
+ opusplan: 1_000_000,
106
+ };
107
+ /**
108
+ * Story 069 (R1.1) — the REAL context window per concrete model ID, mirroring the claude CLI's
109
+ * `context:{window}` table. Used to AUTHORITATIVELY refine the window from a turn's actual `model`
110
+ * (the JSONL `model` field), correcting the alias seed. Opus is NOT uniform: 4.6 = 200K, 4.7+ = 1M.
111
+ * Dated snapshots / future versions are covered by the family+version heuristic in
112
+ * `inferContextWindowFromModelId`; this table is the exact-ID source of truth for today's gateway IDs.
113
+ */
114
+ export const MODEL_ID_CONTEXT_WINDOWS = {
115
+ "claude-opus-4-8": 1_000_000,
116
+ "claude-opus-4-7": 1_000_000,
117
+ "claude-opus-4-6": 200_000,
118
+ "claude-fable-5": 1_000_000,
119
+ "claude-sonnet-5": 1_000_000,
120
+ "claude-sonnet-4-6": 200_000,
121
+ "claude-sonnet-4-5-20250929": 200_000,
122
+ "claude-sonnet-4-20250514": 200_000,
123
+ "claude-haiku-4-5": 200_000,
124
+ };
125
+ /**
126
+ * Story 072 — the version/context PREFIX the claude `/model` picker now shows before the static tagline
127
+ * (e.g. "Opus 4.8 with 1M context · Best for everyday, complex tasks"). Keyed by catalog `value`.
128
+ *
129
+ * CURATED + DRIFT-PRONE, exactly like MODEL_CATALOG membership: the fork holds only aliases pre-turn and
130
+ * cannot derive the concrete version (the SDK `supportedModels()` was cut), so these MIRROR the LIVE
131
+ * picker and MUST be re-verified on each model launch (source: the user's live `/model` output). The
132
+ * static tagline stays on `ModelInfo.description` (069 R3 untouched); this only prepends "<version> · ".
133
+ * `opusplan` is intentionally absent — its tagline ("Use Opus in plan mode, Sonnet otherwise") already
134
+ * names the models, so it renders bare.
135
+ */
136
+ export const MODEL_VERSION_LABELS = {
137
+ default: "Opus 4.8 with 1M context",
138
+ opus: "Opus 4.8 with 1M context",
139
+ sonnet: "Sonnet 5",
140
+ "sonnet[1m]": "Sonnet 5 with 1M context",
141
+ haiku: "Haiku 4.5",
142
+ };
143
+ /**
144
+ * Story 072 — compose the Zed selector description: "<version label> · <tagline>", or the bare tagline
145
+ * when no label exists (e.g. `opusplan`). PURE + TOTAL: never throws on a missing label or tagline.
146
+ */
147
+ export function modelSelectorDescription(info) {
148
+ const label = MODEL_VERSION_LABELS[info.value];
149
+ const tagline = info.description ?? "";
150
+ if (!label)
151
+ return tagline;
152
+ return tagline ? `${label} · ${tagline}` : label;
153
+ }
84
154
  /** The safe fallback entry, kept as a named export so callers can seed/anchor on it without a lookup. */
85
155
  export const DEFAULT_MODEL_INFO = MODEL_CATALOG[0];
@@ -64,4 +64,3 @@ export interface ClearNativePromptOptions {
64
64
  * @returns an {@link InjectResult}: `'suppressed'` | `'cleared'` | `'stuck'`.
65
65
  */
66
66
  export declare function clearNativePrompt(opts: ClearNativePromptOptions): Promise<InjectResult>;
67
- //# sourceMappingURL=allow-inject.d.ts.map
@@ -44,6 +44,18 @@ export declare function denyDecision(toolCall: DenyToolCall, reason?: string): H
44
44
  * @returns `{ hookSpecificOutput: { hookEventName:'PreToolUse', permissionDecision:'allow', … } }`.
45
45
  */
46
46
  export declare function allowDecision(toolCall: DenyToolCall, reason?: string): HookResponse;
47
+ /** Story 064 — the interactive tool the gate denies fail-closed over the Zed bridge (R1). */
48
+ export declare const ASK_USER_QUESTION_TOOL = "AskUserQuestion";
49
+ /** True iff `toolName` is the AskUserQuestion interactive picker tool (exact, case-sensitive). */
50
+ export declare function isAskUserQuestionTool(toolName: string): boolean;
51
+ /**
52
+ * Story 064 (R1.1) — the deny reason for AskUserQuestion over the Zed bridge. AskUserQuestion renders
53
+ * an interactive multiple-choice picker bound to the hidden PTY's stdin; the Zed user can neither see
54
+ * nor answer it, so allowing it would stall the turn until the watchdog fires. Deny it fail-closed with
55
+ * this reason so the model proceeds without it (interim until story 065 relays it via ACP elicitation).
56
+ * MUST name the tool and cite the bridge limitation (the body matches `/AskUserQuestion/` and `/bridge/i`).
57
+ */
58
+ export declare function askUserQuestionDenyReason(): string;
47
59
  /**
48
60
  * Matcher-scoped deny predicate (R2.3 NUANCE): does this deny matcher target the given tool?
49
61
  *
@@ -57,4 +69,3 @@ export declare function allowDecision(toolCall: DenyToolCall, reason?: string):
57
69
  * @returns true iff the matcher scopes the deny to this tool.
58
70
  */
59
71
  export declare function denyMatchesTool(matcher: string, toolName: string): boolean;
60
- //# sourceMappingURL=deny.d.ts.map
@@ -64,6 +64,24 @@ export function allowDecision(toolCall, reason) {
64
64
  },
65
65
  };
66
66
  }
67
+ /** Story 064 — the interactive tool the gate denies fail-closed over the Zed bridge (R1). */
68
+ export const ASK_USER_QUESTION_TOOL = "AskUserQuestion";
69
+ /** True iff `toolName` is the AskUserQuestion interactive picker tool (exact, case-sensitive). */
70
+ export function isAskUserQuestionTool(toolName) {
71
+ return toolName === ASK_USER_QUESTION_TOOL;
72
+ }
73
+ /**
74
+ * Story 064 (R1.1) — the deny reason for AskUserQuestion over the Zed bridge. AskUserQuestion renders
75
+ * an interactive multiple-choice picker bound to the hidden PTY's stdin; the Zed user can neither see
76
+ * nor answer it, so allowing it would stall the turn until the watchdog fires. Deny it fail-closed with
77
+ * this reason so the model proceeds without it (interim until story 065 relays it via ACP elicitation).
78
+ * MUST name the tool and cite the bridge limitation (the body matches `/AskUserQuestion/` and `/bridge/i`).
79
+ */
80
+ export function askUserQuestionDenyReason() {
81
+ return (`${ASK_USER_QUESTION_TOOL} is not supported over the Zed bridge: its interactive multiple-choice ` +
82
+ `picker renders in a hidden PTY the user cannot see or answer, so the turn would stall. Denying it ` +
83
+ `fail-closed — continue without asking an interactive question.`);
84
+ }
67
85
  /**
68
86
  * Matcher-scoped deny predicate (R2.3 NUANCE): does this deny matcher target the given tool?
69
87
  *
@@ -0,0 +1,71 @@
1
+ import type { CreateElicitationRequest, CreateElicitationResponse } from "@agentclientprotocol/sdk";
2
+ /**
3
+ * The gate decision this bridge yields. Mirrors hook-server's `DenyWithReason`: AskUserQuestion is
4
+ * ALWAYS denied at the wire, and the user's selection (or the dismissal note) is carried in `reason`.
5
+ */
6
+ export interface ElicitationDecision {
7
+ decision: "deny";
8
+ reason: string;
9
+ }
10
+ /** The `AskUserQuestion` tool_input subset the bridge projects into a form elicitation. */
11
+ export interface AskUserQuestionInput {
12
+ questions: Array<{
13
+ /** The human-readable question text — becomes the property `title`. */
14
+ question: string;
15
+ /** The short key — becomes the property name AND a `required` entry. */
16
+ header: string;
17
+ /** Whether multiple options may be selected (currently projected as a single string-enum). */
18
+ multiSelect?: boolean;
19
+ options: Array<{
20
+ label: string;
21
+ description?: string;
22
+ }>;
23
+ }>;
24
+ }
25
+ /**
26
+ * The minimal ACP client surface the bridge needs — structurally satisfied by the kept
27
+ * `AgentSideConnection` (`client.unstable_createElicitation(params)`). Injected so a unit test drives
28
+ * the bridge with a fake client OFFLINE (no AgentSideConnection, no Zed).
29
+ */
30
+ export interface ElicitationClient {
31
+ unstable_createElicitation(params: CreateElicitationRequest): Promise<CreateElicitationResponse>;
32
+ }
33
+ /**
34
+ * Project an `AskUserQuestion` tool_input into an ACP form `CreateElicitationRequest`, scoped to the
35
+ * session and tied to the originating tool call by `tool_use.id` (R1.1, R1.2).
36
+ *
37
+ * Each question becomes one string-enum property keyed by its `header`: `{ type: "string", title:
38
+ * <question>, enum: <option labels, in order> }`, and every header is marked `required` (one entry per
39
+ * question, in order). The returned literal is shaped to satisfy the pinned SDK `zCreateElicitationRequest`.
40
+ */
41
+ export declare function buildElicitationRequest(toolUseId: string, sessionId: string, toolInput: AskUserQuestionInput): CreateElicitationRequest;
42
+ /**
43
+ * Map an elicitation outcome back to a gate decision (R2). EVERY branch returns a `deny`: the gate
44
+ * always denies AskUserQuestion at the wire and surfaces the result through `reason` (the R2.1 seam — a
45
+ * PreToolUse hook cannot synthesize a native tool_result).
46
+ *
47
+ * - `accept`: the reason embeds EVERY selected answer (e.g. `Env=prod, Cache=yes`) so the model can
48
+ * read the choice from the deny reason. `content` null/undefined is handled defensively (still deny).
49
+ * - `decline`/`cancel`/any unrecognized-or-missing action: the reason reads as a user DISMISSAL, not
50
+ * an answer (default-deny defensively for unknown actions).
51
+ */
52
+ export declare function mapOutcomeToDecision(resp: CreateElicitationResponse): ElicitationDecision;
53
+ /** Options for {@link requestElicitation}. */
54
+ export interface RequestElicitationOptions {
55
+ /** The hard upper bound (ms) on the round-trip; a slower client fails closed to a cancel. */
56
+ timeoutMs: number;
57
+ /** Optional sink for the fail-closed diagnostics (defaults to no-op; production wires the logger). */
58
+ onWarn?: (message: string) => void;
59
+ }
60
+ /**
61
+ * Perform the bounded, fail-closed elicitation round-trip (R4). NEVER throws and NEVER hangs past
62
+ * ~`timeoutMs`.
63
+ *
64
+ * - Happy path (a well-formed response, action ∈ {accept,decline,cancel}) → returned UNCHANGED.
65
+ * - Timeout → `onWarn` (naming the timeout) and resolve `{ action: "cancel" }`.
66
+ * - Client throws → `onWarn` (including the underlying error text) and resolve `{ action: "cancel" }`.
67
+ * - Malformed response (action not accept|decline|cancel) → normalized to `{ action: "cancel" }`.
68
+ *
69
+ * The timer is always cleared on settle so no live handle keeps the event loop open.
70
+ */
71
+ export declare function requestElicitation(client: ElicitationClient, req: CreateElicitationRequest, opts: RequestElicitationOptions): Promise<CreateElicitationResponse>;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Project an `AskUserQuestion` tool_input into an ACP form `CreateElicitationRequest`, scoped to the
3
+ * session and tied to the originating tool call by `tool_use.id` (R1.1, R1.2).
4
+ *
5
+ * Each question becomes one string-enum property keyed by its `header`: `{ type: "string", title:
6
+ * <question>, enum: <option labels, in order> }`, and every header is marked `required` (one entry per
7
+ * question, in order). The returned literal is shaped to satisfy the pinned SDK `zCreateElicitationRequest`.
8
+ */
9
+ export function buildElicitationRequest(toolUseId, sessionId, toolInput) {
10
+ const properties = {};
11
+ const required = [];
12
+ for (const q of toolInput.questions) {
13
+ properties[q.header] = {
14
+ type: "string",
15
+ title: q.question,
16
+ enum: q.options.map((o) => o.label),
17
+ };
18
+ required.push(q.header);
19
+ }
20
+ const message = toolInput.questions.length === 1
21
+ ? `Please answer: ${toolInput.questions[0]?.question ?? "the question below"}`
22
+ : `Please answer the following ${toolInput.questions.length} question(s).`;
23
+ // The SDK `CreateElicitationRequest` is an awkward mode/scope intersection; this well-formed form
24
+ // literal parses against `zCreateElicitationRequest` (verified by the C1 test), so a single localized
25
+ // cast is used rather than scattering `any` across the object.
26
+ return {
27
+ mode: "form",
28
+ message,
29
+ sessionId,
30
+ toolCallId: toolUseId,
31
+ requestedSchema: {
32
+ type: "object",
33
+ properties,
34
+ required,
35
+ },
36
+ };
37
+ }
38
+ /** The wire actions the bridge recognizes as a well-formed elicitation response. */
39
+ const KNOWN_ACTIONS = new Set(["accept", "decline", "cancel"]);
40
+ /**
41
+ * True iff `resp` is a structurally well-formed `CreateElicitationResponse` — i.e. an object whose
42
+ * `action` is one of accept|decline|cancel. Used to fail closed on a malformed wire response WITHOUT
43
+ * importing the zod validators (blocked by the package `exports` map).
44
+ */
45
+ function hasKnownAction(resp) {
46
+ return (typeof resp === "object" &&
47
+ resp !== null &&
48
+ "action" in resp &&
49
+ typeof resp.action === "string" &&
50
+ KNOWN_ACTIONS.has(resp.action));
51
+ }
52
+ /**
53
+ * Map an elicitation outcome back to a gate decision (R2). EVERY branch returns a `deny`: the gate
54
+ * always denies AskUserQuestion at the wire and surfaces the result through `reason` (the R2.1 seam — a
55
+ * PreToolUse hook cannot synthesize a native tool_result).
56
+ *
57
+ * - `accept`: the reason embeds EVERY selected answer (e.g. `Env=prod, Cache=yes`) so the model can
58
+ * read the choice from the deny reason. `content` null/undefined is handled defensively (still deny).
59
+ * - `decline`/`cancel`/any unrecognized-or-missing action: the reason reads as a user DISMISSAL, not
60
+ * an answer (default-deny defensively for unknown actions).
61
+ */
62
+ export function mapOutcomeToDecision(resp) {
63
+ // `resp` is a typed union, but we read `action`/`content` defensively (a synthetic fail-closed
64
+ // cancel or a malformed upstream value may reach here) via a single localized view.
65
+ const view = resp;
66
+ if (view?.action === "accept") {
67
+ const content = view.content;
68
+ const answers = content !== undefined && content !== null
69
+ ? Object.entries(content)
70
+ .map(([key, value]) => `${key}=${String(value)}`)
71
+ .join(", ")
72
+ : "";
73
+ const reason = answers.length > 0
74
+ ? `AskUserQuestion answered by the user: ${answers}. (The tool is always denied at the ` +
75
+ `gate; this selection is returned to you via this reason.)`
76
+ : `AskUserQuestion was accepted but returned no selection. (The tool is always denied at ` +
77
+ `the gate; treat this as no usable answer.)`;
78
+ return { decision: "deny", reason };
79
+ }
80
+ // decline, cancel, or any unrecognized/missing action → a dismissal, never an answer.
81
+ return {
82
+ decision: "deny",
83
+ reason: `The user dismissed the AskUserQuestion prompt without providing an answer (no answer was ` +
84
+ `selected). (The tool is always denied at the gate.)`,
85
+ };
86
+ }
87
+ /** A synthetic fail-closed outcome — a cancel is a dismissal, mapped to a deny at the gate. */
88
+ function failClosedCancel() {
89
+ // `{ action: "cancel" }` satisfies the SDK response union; a localized cast avoids widening the API.
90
+ return { action: "cancel" };
91
+ }
92
+ /**
93
+ * Perform the bounded, fail-closed elicitation round-trip (R4). NEVER throws and NEVER hangs past
94
+ * ~`timeoutMs`.
95
+ *
96
+ * - Happy path (a well-formed response, action ∈ {accept,decline,cancel}) → returned UNCHANGED.
97
+ * - Timeout → `onWarn` (naming the timeout) and resolve `{ action: "cancel" }`.
98
+ * - Client throws → `onWarn` (including the underlying error text) and resolve `{ action: "cancel" }`.
99
+ * - Malformed response (action not accept|decline|cancel) → normalized to `{ action: "cancel" }`.
100
+ *
101
+ * The timer is always cleared on settle so no live handle keeps the event loop open.
102
+ */
103
+ export async function requestElicitation(client, req, opts) {
104
+ const { timeoutMs, onWarn } = opts;
105
+ let timer;
106
+ const timeout = new Promise((resolve) => {
107
+ timer = setTimeout(() => resolve({ __timedOut: true }), timeoutMs);
108
+ });
109
+ try {
110
+ const settled = await Promise.race([
111
+ // DEFER the client call through a resolved microtask so a SYNCHRONOUS throw (the method is
112
+ // `undefined` — capability advertised but absent — or it throws before returning a promise)
113
+ // becomes a REJECTION the inline handler below catches, instead of escaping at argument-
114
+ // evaluation time (this `try` has only a `finally`, so an eager throw would re-throw out of
115
+ // requestElicitation, violating "never throws"). Async rejections still take the same path.
116
+ Promise.resolve()
117
+ .then(() => client.unstable_createElicitation(req))
118
+ .then((resp) => ({ ok: true, resp }), (err) => ({ ok: false, err })),
119
+ timeout,
120
+ ]);
121
+ // Timeout won the race — fail closed to a cancel.
122
+ if ("__timedOut" in settled) {
123
+ onWarn?.(`[gate elicitation] FAIL CLOSED: AskUserQuestion elicitation timed out after ${timeoutMs}ms ` +
124
+ `— resolving as a cancel (dismissal), never an accept.`);
125
+ return failClosedCancel();
126
+ }
127
+ // The client threw — fail closed to a cancel, surfacing the underlying error text.
128
+ if (!settled.ok) {
129
+ const err = settled.err;
130
+ onWarn?.(`[gate elicitation] FAIL CLOSED: AskUserQuestion elicitation transport error ` +
131
+ `(${err instanceof Error ? err.message : String(err)}) — resolving as a cancel.`);
132
+ return failClosedCancel();
133
+ }
134
+ // A well-formed response is returned unchanged; a malformed one is normalized to a cancel.
135
+ if (hasKnownAction(settled.resp)) {
136
+ return settled.resp;
137
+ }
138
+ onWarn?.(`[gate elicitation] FAIL CLOSED: malformed elicitation response (action not accept|decline|` +
139
+ `cancel) — resolving as a cancel.`);
140
+ return failClosedCancel();
141
+ }
142
+ finally {
143
+ if (timer !== undefined)
144
+ clearTimeout(timer);
145
+ }
146
+ }
@@ -1,4 +1,5 @@
1
1
  import { ToolUseCorrelator, type PermissionClient } from "./request-permission.js";
2
+ import { type ElicitationClient } from "./elicitation-bridge.js";
2
3
  import { type PtyWriter, type Schedule } from "./allow-inject.js";
3
4
  /**
4
5
  * Substrings that evidence the native TUI permission prompt (the bordered "Do you want to
@@ -62,12 +63,32 @@ export interface GatePty extends PtyWriter {
62
63
  dispose(): void;
63
64
  };
64
65
  }
66
+ /**
67
+ * Story 065 — the default hard upper bound (ms) on the AskUserQuestion elicitation round-trip. Generous
68
+ * (a human may take minutes to answer) but DELIBERATELY BELOW the hook-server
69
+ * {@link import("./hook-server.js").DEFAULT_DECIDER_TIMEOUT_MS} (600_000), so the bridge times out FIRST
70
+ * with a legible fail-closed reason rather than the decider being killed out from under it. Injectable
71
+ * via {@link SessionGateOptions.elicitationTimeoutMs} so offline tests use a small value.
72
+ */
73
+ export declare const DEFAULT_ELICITATION_TIMEOUT_MS = 300000;
65
74
  /** Options for {@link setupSessionGate}. Timing knobs are injectable for offline tests. */
66
75
  export interface SessionGateOptions {
67
- /** The ACP client surface (`AgentSideConnection` satisfies it: `requestPermission(params)`). */
68
- client: PermissionClient;
76
+ /** The ACP client surface. `AgentSideConnection` satisfies BOTH `requestPermission(params)`
77
+ * (the story-033 relay) AND `unstable_createElicitation(params)` (the story-065 elicitation
78
+ * bridge), so the field carries both capabilities. */
79
+ client: PermissionClient & ElicitationClient;
69
80
  /** Diagnostics sink for every fail-closed / stuck-prompt warning (production: logger.error). */
70
81
  onWarn?: (message: string) => void;
82
+ /**
83
+ * Story 065 (R1/R3) — whether the connected ACP client negotiated the elicitation `form` capability
84
+ * (`clientCapabilities.elicitation.form`). When true, AskUserQuestion is driven through a real ACP
85
+ * form elicitation ({@link SessionGateImpl.decideElicitation}); when false/omitted it DEGRADES to the
86
+ * story-064 fail-closed deny-guard. Defaults to false when read, so an existing gate/064 test that
87
+ * omits it keeps the degrade behavior (load-bearing — do NOT make required). */
88
+ clientSupportsElicitationForm?: boolean;
89
+ /** Story 065 — hard upper bound (ms) on the AskUserQuestion elicitation round-trip; default
90
+ * {@link DEFAULT_ELICITATION_TIMEOUT_MS}. Injectable so offline tests use a small value. */
91
+ elicitationTimeoutMs?: number;
71
92
  /** Injectable timer seam (same discipline as allow-inject/end-of-turn). Default: setTimeout. */
72
93
  schedule?: Schedule;
73
94
  /** Directory for the per-session scratch settings file. Default: `os.tmpdir()`. */
@@ -155,4 +176,3 @@ export interface SessionGate {
155
176
  * an ungated claude that LOOKS gated (the blocker-b hazard). `FORK_GATE=off` is the escape hatch.
156
177
  */
157
178
  export declare function setupSessionGate(opts: SessionGateOptions): Promise<SessionGate>;
158
- //# sourceMappingURL=gate-wiring.d.ts.map
@@ -33,8 +33,10 @@ import * as path from "node:path";
33
33
  import { randomBytes, randomUUID } from "node:crypto";
34
34
  import { findFreePort } from "../gate/port.js";
35
35
  import { injectHook, restore } from "../gate/settings-writer.js";
36
- import { startHookServer } from "./hook-server.js";
36
+ import { startHookServer, } from "./hook-server.js";
37
+ import { askUserQuestionDenyReason, isAskUserQuestionTool } from "./deny.js";
37
38
  import { requestPermission, ToolUseCorrelator, } from "./request-permission.js";
39
+ import { buildElicitationRequest, mapOutcomeToDecision, requestElicitation, } from "./elicitation-bridge.js";
38
40
  import { clearNativePrompt } from "./allow-inject.js";
39
41
  /**
40
42
  * Substrings that evidence the native TUI permission prompt (the bordered "Do you want to
@@ -111,9 +113,53 @@ export const SCRATCH_SETTINGS_PREFIX = "fork-acp-gate-settings-";
111
113
  /** Story 046 (R3) — the file-edit tools that `acceptEdits` auto-allows, mirroring claude's native
112
114
  * acceptEdits semantics (edits proceed without prompting; every other tool still asks). */
113
115
  const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
116
+ /**
117
+ * Story 065 — the default hard upper bound (ms) on the AskUserQuestion elicitation round-trip. Generous
118
+ * (a human may take minutes to answer) but DELIBERATELY BELOW the hook-server
119
+ * {@link import("./hook-server.js").DEFAULT_DECIDER_TIMEOUT_MS} (600_000), so the bridge times out FIRST
120
+ * with a legible fail-closed reason rather than the decider being killed out from under it. Injectable
121
+ * via {@link SessionGateOptions.elicitationTimeoutMs} so offline tests use a small value.
122
+ */
123
+ export const DEFAULT_ELICITATION_TIMEOUT_MS = 300_000;
114
124
  const defaultSchedule = (fn, ms) => {
115
125
  setTimeout(fn, ms);
116
126
  };
127
+ /**
128
+ * Story 065 — STRUCTURAL guard that a hook payload's `tool_input` is a usable {@link AskUserQuestionInput}:
129
+ * an object carrying a NON-EMPTY `questions` array, EACH question being an object with a `header` string
130
+ * and a NON-EMPTY `options` array of `{ label: string }`. The runtime zod validators are not importable
131
+ * across the SDK package `exports` map, and the hook payload's `tool_input` is `unknown`, so this narrows
132
+ * before the bridge builder projects it into a form (an empty/garbage form would otherwise reach the
133
+ * client, and a per-question shape the builder walks — `q.options.map(...)`, `q.header` — would otherwise
134
+ * throw).
135
+ *
136
+ * Story 065 / task 4.1 (R4) — the per-question `options`/`header` validation is a BELT-AND-SUSPENDERS
137
+ * addition: {@link SessionGateImpl.decideElicitation} now also wraps the build in a try/catch (the
138
+ * mandatory total-function guarantee), so this guard's job is only to fail closed to the story-064 deny
139
+ * EARLY (with a "malformed tool_input" reason) rather than relying on the catch. Both together mean a
140
+ * malformed per-question input is a legible dismissal, never a crash and never an approve.
141
+ */
142
+ function isAskUserQuestionInput(input) {
143
+ if (typeof input !== "object" ||
144
+ input === null ||
145
+ !("questions" in input) ||
146
+ !Array.isArray(input.questions)) {
147
+ return false;
148
+ }
149
+ const questions = input.questions;
150
+ if (questions.length === 0)
151
+ return false;
152
+ return questions.every((q) => {
153
+ if (typeof q !== "object" || q === null)
154
+ return false;
155
+ const question = q;
156
+ if (typeof question.header !== "string")
157
+ return false;
158
+ if (!Array.isArray(question.options) || question.options.length === 0)
159
+ return false;
160
+ return question.options.every((o) => typeof o === "object" && o !== null && typeof o.label === "string");
161
+ });
162
+ }
117
163
  /** Internal mutable state of one session's gate. */
118
164
  class SessionGateImpl {
119
165
  constructor(opts) {
@@ -239,6 +285,22 @@ class SessionGateImpl {
239
285
  async decide(call) {
240
286
  if (this.torndown)
241
287
  return "deny"; // a hook racing teardown is never approved
288
+ // === Story 064/065 — AskUserQuestion handling, BEFORE any mode auto-allow or ACP relay. =========
289
+ // AskUserQuestion renders an interactive multiple-choice picker bound to the hidden PTY's stdin.
290
+ // Over the bridge the Zed user can't see or answer it, so an allow (including the bypass/acceptEdits
291
+ // auto-allow below) would stall the turn until the watchdog fires. This branch stays FIRST (after the
292
+ // torndown check) so AskUserQuestion is always intercepted regardless of permission mode.
293
+ //
294
+ // Story 065 (R1): when the client negotiated the elicitation `form` capability, drive a REAL ACP form
295
+ // elicitation and carry the answer back in a deny reason (a PreToolUse hook cannot synthesize a native
296
+ // tool_result — the tool is ALWAYS denied at the wire). Story 065 (R3): otherwise DEGRADE to the
297
+ // story-064 fail-closed deny-guard so the model proceeds without stalling.
298
+ if (isAskUserQuestionTool(call.toolName)) {
299
+ if (this.opts.clientSupportsElicitationForm) {
300
+ return await this.decideElicitation(call);
301
+ }
302
+ return { decision: "deny", reason: askUserQuestionDenyReason() }; // R3 degrade
303
+ }
242
304
  // === Story 046 (R3) — honor the live permission mode BEFORE relaying to Zed. =================
243
305
  // The hook payload carries the current mode (probe-d confirmed `permission_mode` matches the
244
306
  // selected mode in acceptEdits/bypassPermissions). Without this branch the gate raises
@@ -339,6 +401,66 @@ class SessionGateImpl {
339
401
  }
340
402
  });
341
403
  }
404
+ /**
405
+ * Story 065 (R1, R2.1, R2.2) — drive an AskUserQuestion via a REAL ACP form elicitation and map the
406
+ * user's outcome back to a gate decision. ALWAYS returns a `deny`+reason (a PreToolUse hook cannot
407
+ * synthesize a native tool_result): on accept the reason CARRIES the answer (R2.1); on
408
+ * decline/cancel/timeout/transport-error the reason reads as a dismissal (R2.2). Reached ONLY when the
409
+ * client negotiated the elicitation `form` capability (guarded in {@link decide}).
410
+ *
411
+ * FAIL CLOSED (mirrors the whole gate's posture): an elicitation is SESSION-SCOPED, so with no bound
412
+ * ACP session id (nor a payload fallback) we CANNOT raise one → fall back to the story-064 deny. A
413
+ * structurally malformed `tool_input` (not an object with a non-empty `questions` array) likewise falls
414
+ * back — the bridge builder would otherwise project an empty/garbage form. Both are dismissals, never
415
+ * an accept.
416
+ *
417
+ * The turn is BLOCKED awaiting the user exactly like the requestPermission relay, so the end-of-turn
418
+ * watchdog is re-armed via {@link startPermissionHeartbeat} for as long as the elicitation is open
419
+ * (always cleared in `finally`). The round-trip itself is bounded + fail-closed inside
420
+ * {@link requestElicitation} (it never throws and never hangs past the timeout).
421
+ */
422
+ async decideElicitation(call) {
423
+ const sessionId = this.sessionId ?? call.sessionId;
424
+ if (!sessionId) {
425
+ this.warn(`[gate elicitation] FAIL CLOSED: AskUserQuestion for tool_use ${call.toolUseId} arrived before ` +
426
+ `the gate was bound to an ACP session — cannot raise a session-scoped elicitation; denying ` +
427
+ `(story-064 fallback).`);
428
+ return { decision: "deny", reason: askUserQuestionDenyReason() };
429
+ }
430
+ if (!isAskUserQuestionInput(call.toolInput)) {
431
+ this.warn(`[gate elicitation] FAIL CLOSED: AskUserQuestion tool_use ${call.toolUseId} carried a malformed ` +
432
+ `tool_input (expected an object with a non-empty "questions" array) — cannot build a form ` +
433
+ `elicitation; denying (story-064 fallback).`);
434
+ return { decision: "deny", reason: askUserQuestionDenyReason() };
435
+ }
436
+ // The claude is blocked on this response with the JSONL silent, so re-arm the end-of-turn watchdog
437
+ // for as long as the elicitation is open (a slow human decision is NOT a dead turn). Mirror the
438
+ // requestPermission relay's heartbeat; always cleared in `finally`, on success or throw.
439
+ const stopHeartbeat = this.startPermissionHeartbeat();
440
+ try {
441
+ // TOTAL by construction (R4): build + round-trip are wrapped so ANY throw degrades to the
442
+ // story-064 deny with a legible diagnostic, never escaping decideElicitation. buildElicitationRequest
443
+ // itself can throw on a per-question input the structural guard did not catch (e.g. a question with
444
+ // no `options` array → `q.options.map(...)` throws); requestElicitation is fail-closed internally,
445
+ // but this catch is the mandatory total-function guarantee (the hook-server's decideWithTimeout is
446
+ // only the generic defense-in-depth net). A throw here is a dismissal, never an accept.
447
+ const req = buildElicitationRequest(call.toolUseId, sessionId, call.toolInput);
448
+ const resp = await requestElicitation(this.opts.client, req, {
449
+ timeoutMs: this.opts.elicitationTimeoutMs ?? DEFAULT_ELICITATION_TIMEOUT_MS,
450
+ onWarn: (m) => this.warn(m),
451
+ });
452
+ return mapOutcomeToDecision(resp);
453
+ }
454
+ catch (err) {
455
+ this.warn(`[gate elicitation] FAIL CLOSED: AskUserQuestion tool_use ${call.toolUseId} could not be ` +
456
+ `elicited (${err instanceof Error ? err.message : String(err)}) — denying (story-064 ` +
457
+ `fallback); the tool is intercepted, never approved.`);
458
+ return { decision: "deny", reason: askUserQuestionDenyReason() };
459
+ }
460
+ finally {
461
+ stopHeartbeat();
462
+ }
463
+ }
342
464
  /** Bounded poll until the pump has registered `toolUseId` as a clean single JSONL match. On
343
465
  * expiry, resolve anyway — `requestPermission` then fails closed on the missing correlation.
344
466
  *
@@ -27,8 +27,17 @@ export interface ForwardedToolCall {
27
27
  * for a main-chain tool. There is NO parent tool_use.id in the payload (grouping is best-effort). */
28
28
  agentType?: string;
29
29
  }
30
- /** The decider the server forwards each tool call to; returns the enforced `'allow'`/`'deny'`. */
31
- export type ToolCallDecider = (call: ForwardedToolCall) => Promise<"allow" | "deny"> | "allow" | "deny";
30
+ /** Story 064 a deny that carries a custom `permissionDecisionReason` (e.g. the AskUserQuestion
31
+ * anti-stall reason) instead of the {@link defaultDenyReason}. */
32
+ export interface DenyWithReason {
33
+ decision: "deny";
34
+ reason: string;
35
+ }
36
+ /** The enforced outcome a decider returns: a plain allow/deny, or a deny carrying a custom reason.
37
+ * Plain `"deny"` and {@link DenyWithReason} both intercept the tool; the latter just names a reason. */
38
+ export type ToolDecision = "allow" | "deny" | DenyWithReason;
39
+ /** The decider the server forwards each tool call to; returns the enforced {@link ToolDecision}. */
40
+ export type ToolCallDecider = (call: ForwardedToolCall) => Promise<ToolDecision> | ToolDecision;
32
41
  /** The running hook server handle. */
33
42
  export interface HookServer {
34
43
  /** The loopback port the server bound (feeds the story-032 hook URL). */
@@ -84,4 +93,3 @@ export declare function parsePayload(raw: string): ForwardedToolCall | null;
84
93
  * fails fast rather than spawning claude with an ungated hook URL).
85
94
  */
86
95
  export declare function startHookServer(opts: StartHookServerOptions): Promise<HookServer>;
87
- //# sourceMappingURL=hook-server.d.ts.map
@@ -42,6 +42,15 @@ function writeDecision(res, decision) {
42
42
  res.writeHead(200, { "content-type": "application/json" });
43
43
  res.end(json);
44
44
  }
45
+ /** Map a decider's {@link ToolDecision} to the §9 hook body, honoring a {@link DenyWithReason}'s custom
46
+ * reason (story 064). A plain allow/deny uses the default allow/deny body. */
47
+ function toHookResponse(decision, call) {
48
+ if (decision === "allow")
49
+ return allowDecision(call);
50
+ if (decision === "deny")
51
+ return denyDecision(call);
52
+ return denyDecision(call, decision.reason);
53
+ }
45
54
  /**
46
55
  * Parse a PreToolUse payload from raw JSON, normalizing to {@link ForwardedToolCall}. Returns null when
47
56
  * the body is unparseable OR lacks a `tool_use_id`/`tool_name` — the caller fails closed on null (a
@@ -161,7 +170,7 @@ export function startHookServer(opts) {
161
170
  return;
162
171
  }
163
172
  const decision = await decideWithTimeout(decider, call, deciderTimeoutMs, onWarn);
164
- writeDecision(res, decision === "allow" ? allowDecision(call) : denyDecision(call));
173
+ writeDecision(res, toHookResponse(decision, call));
165
174
  }
166
175
  catch (err) {
167
176
  // Any unexpected handler error (body read failure, write failure) → fail closed.