@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.
- package/NOTICE +1 -1
- package/README.md +1 -1
- package/dist/acp-agent.d.ts +62 -8
- package/dist/acp-agent.js +130 -15
- package/dist/agent-catalog.d.ts +0 -1
- package/dist/ansi-mirror.d.ts +0 -1
- package/dist/besteffort.d.ts +0 -1
- package/dist/billing/entrypoint-guard.d.ts +0 -1
- package/dist/claude-path.d.ts +0 -1
- package/dist/command-catalog.d.ts +84 -0
- package/dist/command-catalog.js +339 -0
- package/dist/diff-enriched-reader.d.ts +0 -1
- package/dist/diff-source.d.ts +0 -1
- package/dist/drift-checks.d.ts +0 -1
- package/dist/end-of-turn.d.ts +0 -1
- package/dist/engine-lifecycle.d.ts +0 -1
- package/dist/engine-pty.d.ts +0 -1
- package/dist/engine-watcher.d.ts +0 -1
- package/dist/engine.d.ts +0 -1
- package/dist/event-switch.d.ts +0 -1
- package/dist/gate/port.d.ts +0 -1
- package/dist/gate/settings-writer.d.ts +0 -1
- package/dist/image-input.d.ts +0 -1
- package/dist/image-vision-smoke.d.ts +0 -1
- package/dist/index.d.ts +0 -1
- package/dist/jsonl.d.ts +0 -1
- package/dist/lib.d.ts +0 -1
- package/dist/linearize.d.ts +1 -2
- package/dist/linearize.js +1 -1
- package/dist/live-diff-env.d.ts +0 -1
- package/dist/live-subagent-env.d.ts +0 -1
- package/dist/mcp-config-writer.d.ts +0 -1
- package/dist/model-catalog.d.ts +39 -1
- package/dist/model-catalog.js +77 -7
- package/dist/permissions/allow-inject.d.ts +0 -1
- package/dist/permissions/deny.d.ts +12 -1
- package/dist/permissions/deny.js +18 -0
- package/dist/permissions/elicitation-bridge.d.ts +71 -0
- package/dist/permissions/elicitation-bridge.js +146 -0
- package/dist/permissions/gate-wiring.d.ts +23 -3
- package/dist/permissions/gate-wiring.js +123 -1
- package/dist/permissions/hook-server.d.ts +11 -3
- package/dist/permissions/hook-server.js +10 -1
- package/dist/permissions/permission-mode.d.ts +0 -1
- package/dist/permissions/request-permission.d.ts +0 -1
- package/dist/settings.d.ts +0 -1
- package/dist/stop-reason-map.d.ts +0 -1
- package/dist/subagent-gate.d.ts +0 -1
- package/dist/subagent-source.d.ts +0 -1
- package/dist/subagent-watcher.d.ts +0 -1
- package/dist/tools.d.ts +0 -1
- package/dist/usage-env.d.ts +0 -1
- package/dist/usage.d.ts +0 -1
- package/dist/utils.d.ts +0 -1
- package/dist/zed-register.d.ts +0 -1
- package/package.json +6 -3
- package/dist/acp-agent.d.ts.map +0 -1
- package/dist/agent-catalog.d.ts.map +0 -1
- package/dist/ansi-mirror.d.ts.map +0 -1
- package/dist/besteffort.d.ts.map +0 -1
- package/dist/billing/entrypoint-guard.d.ts.map +0 -1
- package/dist/claude-path.d.ts.map +0 -1
- package/dist/diff-enriched-reader.d.ts.map +0 -1
- package/dist/diff-source.d.ts.map +0 -1
- package/dist/drift-checks.d.ts.map +0 -1
- package/dist/end-of-turn.d.ts.map +0 -1
- package/dist/engine-lifecycle.d.ts.map +0 -1
- package/dist/engine-pty.d.ts.map +0 -1
- package/dist/engine-watcher.d.ts.map +0 -1
- package/dist/engine.d.ts.map +0 -1
- package/dist/event-switch.d.ts.map +0 -1
- package/dist/gate/port.d.ts.map +0 -1
- package/dist/gate/settings-writer.d.ts.map +0 -1
- package/dist/image-input.d.ts.map +0 -1
- package/dist/image-vision-smoke.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/jsonl.d.ts.map +0 -1
- package/dist/lib.d.ts.map +0 -1
- package/dist/linearize.d.ts.map +0 -1
- package/dist/live-diff-env.d.ts.map +0 -1
- package/dist/live-subagent-env.d.ts.map +0 -1
- package/dist/mcp-config-writer.d.ts.map +0 -1
- package/dist/model-catalog.d.ts.map +0 -1
- package/dist/permissions/allow-inject.d.ts.map +0 -1
- package/dist/permissions/deny.d.ts.map +0 -1
- package/dist/permissions/gate-wiring.d.ts.map +0 -1
- package/dist/permissions/hook-server.d.ts.map +0 -1
- package/dist/permissions/permission-mode.d.ts.map +0 -1
- package/dist/permissions/request-permission.d.ts.map +0 -1
- package/dist/settings.d.ts.map +0 -1
- package/dist/stop-reason-map.d.ts.map +0 -1
- package/dist/subagent-gate.d.ts.map +0 -1
- package/dist/subagent-source.d.ts.map +0 -1
- package/dist/subagent-watcher.d.ts.map +0 -1
- package/dist/tools.d.ts.map +0 -1
- package/dist/usage-env.d.ts.map +0 -1
- package/dist/usage.d.ts.map +0 -1
- package/dist/utils.d.ts.map +0 -1
- package/dist/zed-register.d.ts.map +0 -1
package/dist/model-catalog.js
CHANGED
|
@@ -41,7 +41,8 @@ export const MODEL_CATALOG = [
|
|
|
41
41
|
{
|
|
42
42
|
value: "default",
|
|
43
43
|
displayName: "Default (recommended)",
|
|
44
|
-
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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: "
|
|
78
|
+
description: "Fastest for quick answers",
|
|
77
79
|
},
|
|
78
80
|
{
|
|
79
81
|
value: "opusplan",
|
|
80
|
-
displayName: "Opus Plan",
|
|
81
|
-
description: "Opus
|
|
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
|
package/dist/permissions/deny.js
CHANGED
|
@@ -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
|
|
68
|
-
|
|
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
|
-
/**
|
|
31
|
-
|
|
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
|
|
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.
|