agent-control-plane-core 0.10.0 → 0.11.1

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/README.md CHANGED
@@ -168,14 +168,32 @@ emit(claudeAdapter.render(verdict, event));
168
168
  `JSON.stringify(render(...))` to stdout exits 0, so the host never blocks.
169
169
 
170
170
  `agent-control-plane-core/runtime` publishes the rest of the plumbing the four
171
- hook entries share. `renderHookResponse` runs parse → judge → render, and returns
172
- the failure response THAT host expects when the payload does not parse. `emit`
171
+ hook entries share. `runHookEntry(adapter, judge)` is a whole entry: each
172
+ `bin/<host>-hook.mjs` is that one call. `renderHookResponse` runs parse → judge
173
+ → `renderEscalated`, so a judge's `ask` on a host with no ask tier blocks a
174
+ vetoable call instead of running it. It returns the failure response THAT host
175
+ expects when the payload does not parse. `emit`
173
176
  drains the whole body before it exits, so a large deny body is never truncated
174
177
  on a non-blocking pipe. The entries ship as `acp-claude-hook`, `acp-codex-hook`,
175
178
  `acp-amp-hook` and `acp-gemini-hook`. Codex's hook stdin carries no version, so
176
179
  set `ACP_CODEX_VERSION` to the Codex version your launcher pins (or call
177
180
  `createCodexAdapter({ version })`); without it, Codex calls parse advisory.
178
181
 
182
+ Each entry fails the way its host expects when the hook itself breaks: claude,
183
+ codex and gemini run the call, and amp asks. Set `ACP_FAIL_CLOSED=1` to block
184
+ instead, on every host. The entry then exits 2 with the reason on stderr when
185
+ the payload does not parse, or when the judge throws or answers no decision.
186
+ Register a fail-closed entry on tool-call events only. On a Stop event, exit 2
187
+ means "keep working".
188
+
189
+ A host that kills a hook on its timeout runs the call. Set
190
+ `ACP_HOOK_TIMEOUT_MS` below the host's hook timeout, and the entry gives its
191
+ failure response at that deadline instead. A judge that blocks the event loop
192
+ past the deadline still loses to the host's timeout. Nothing in the entry can
193
+ answer for a process that never starts (no `node`, a module that fails to
194
+ load). Claude Code covers that case with `"onFailure": "block"` on the hook
195
+ entry. Codex has no such option, so it still runs the call.
196
+
179
197
  The core contract and every adapter (`claude`, `codex`, `amp`, `gemini`) are
180
198
  also on the default entry:
181
199
 
package/bin/amp-hook.mjs CHANGED
@@ -4,18 +4,12 @@
4
4
  * delegate payload on stdin, applies the demo judge, and exits with Amp's
5
5
  * decision code (0 allow / 1 ask / 2 reject).
6
6
  *
7
- * FAILURE POSTURE — fail to ASK (exit 1), NOT allow. Amp's delegate reads exit 1
8
- * as "ask the human," which is the safe degradation when the guardrail itself is
9
- * broken: neither silently allowing (exit 0) nor hard-denying (exit 2) a call it
10
- * never actually judged. This is the asymmetry the per-host split exists for —
11
- * Amp has a safe "ask" failure code that Claude Code's transport does not.
7
+ * FAILURE POSTURE — fail to ASK (exit 1), its `HOST_FAILURE` row in
8
+ * `src/runtime.mjs`. `ACP_FAIL_CLOSED=1` blocks instead (exit 2).
12
9
  *
13
10
  * Usage: `node bin/amp-hook.mjs < payload.json`
14
11
  */
15
12
  import { ampAdapter } from "../src/adapters/amp.mjs";
16
- import { readStdin, renderHookResponse, emit } from "../src/runtime.mjs";
13
+ import { runHookEntry } from "../src/runtime.mjs";
17
14
 
18
- /** @type {import("../src/control-plane.mjs").NativeResponse} */
19
- const FAIL_ASK = { transport: "external_hook", exit_code: 1, enforced: false };
20
-
21
- emit(renderHookResponse(ampAdapter, await readStdin(), FAIL_ASK));
15
+ await runHookEntry(ampAdapter);
@@ -3,18 +3,12 @@
3
3
  * Claude Code EXTERNAL_HOOK entry. Reads a native PreToolUse payload on stdin,
4
4
  * applies the demo judge, and emits Claude Code's native response.
5
5
  *
6
- * FAILURE POSTURE — fail OPEN. Claude Code treats a hook that crashes, times out,
7
- * or emits a non-conforming body as "no objection" and runs the tool anyway, so
8
- * on any internal failure this exits 0 with no body rather than pretending to a
9
- * decision it couldn't compute. A broken guardrail must not wedge the user; the
10
- * sandbox is the real backstop, not this hook.
6
+ * FAILURE POSTURE — fail OPEN (exit 0), its `HOST_FAILURE` row in
7
+ * `src/runtime.mjs`. `ACP_FAIL_CLOSED=1` blocks instead (exit 2).
11
8
  *
12
9
  * Usage: `node bin/claude-hook.mjs < payload.json`
13
10
  */
14
11
  import { claudeAdapter } from "../src/adapters/claude.mjs";
15
- import { readStdin, renderHookResponse, emit } from "../src/runtime.mjs";
12
+ import { runHookEntry } from "../src/runtime.mjs";
16
13
 
17
- /** @type {import("../src/control-plane.mjs").NativeResponse} */
18
- const FAIL_OPEN = { transport: "external_hook", exit_code: 0, enforced: false };
19
-
20
- emit(renderHookResponse(claudeAdapter, await readStdin(), FAIL_OPEN));
14
+ await runHookEntry(claudeAdapter);
@@ -10,16 +10,14 @@
10
10
  * deny renders advisory (exit 0). Below v0.135 the render is advisory too,
11
11
  * because Codex has no hook veto there.
12
12
  *
13
- * FAILURE POSTURE — fail OPEN (exit 0), like Claude Code: Codex treats a
14
- * non-conforming hook as non-blocking. The sandbox is the backstop.
13
+ * FAILURE POSTURE — fail OPEN (exit 0), its `HOST_FAILURE` row in
14
+ * `src/runtime.mjs`. `ACP_FAIL_CLOSED=1` blocks instead (exit 2).
15
15
  *
16
16
  * Usage: `ACP_CODEX_VERSION=0.151.0 node bin/codex-hook.mjs < payload.json`
17
17
  */
18
18
  import { createCodexAdapter } from "../src/adapters/codex.mjs";
19
- import { readStdin, renderHookResponse, emit } from "../src/runtime.mjs";
19
+ import { runHookEntry } from "../src/runtime.mjs";
20
20
 
21
- /** @type {import("../src/control-plane.mjs").NativeResponse} */
22
- const FAIL_OPEN = { transport: "external_hook", exit_code: 0, enforced: false };
23
-
24
- const adapter = createCodexAdapter({ version: process.env.ACP_CODEX_VERSION });
25
- emit(renderHookResponse(adapter, await readStdin(), FAIL_OPEN));
21
+ await runHookEntry(
22
+ createCodexAdapter({ version: process.env.ACP_CODEX_VERSION }),
23
+ );
@@ -3,17 +3,12 @@
3
3
  * Gemini CLI EXTERNAL_HOOK entry. Reads a native BeforeTool payload on stdin,
4
4
  * applies the demo judge, and emits Gemini's native response.
5
5
  *
6
- * FAILURE POSTURE — fail OPEN (exit 0). Gemini CLI treats a non-2 / crashed hook
7
- * exit as a non-fatal warning and continues, so on any internal failure this
8
- * exits 0 rather than emitting a spurious System Block (only a clean deny exits
9
- * 2). A broken guardrail must not wedge the user; the sandbox is the backstop.
6
+ * FAILURE POSTURE — fail OPEN (exit 0), its `HOST_FAILURE` row in
7
+ * `src/runtime.mjs`. `ACP_FAIL_CLOSED=1` blocks instead (exit 2).
10
8
  *
11
9
  * Usage: `node bin/gemini-hook.mjs < payload.json`
12
10
  */
13
11
  import { geminiAdapter } from "../src/adapters/gemini.mjs";
14
- import { readStdin, renderHookResponse, emit } from "../src/runtime.mjs";
12
+ import { runHookEntry } from "../src/runtime.mjs";
15
13
 
16
- /** @type {import("../src/control-plane.mjs").NativeResponse} */
17
- const FAIL_OPEN = { transport: "external_hook", exit_code: 0, enforced: false };
18
-
19
- emit(renderHookResponse(geminiAdapter, await readStdin(), FAIL_OPEN));
14
+ await runHookEntry(geminiAdapter);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane-core",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Vendor-neutral control-plane contract for coding agents: a normalized ToolCallEvent/Verdict schema with per-agent adapters (Claude Code, Codex, Amp, Gemini CLI) and a conformance suite.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/runtime.mjs CHANGED
@@ -1,13 +1,11 @@
1
1
  /**
2
2
  * The EXTERNAL_HOOK runtime, published at `agent-control-plane-core/runtime` —
3
3
  * NOT a runtime host multiplexer. Each host has its OWN entry
4
- * (`bin/<host>-hook.mjs`) that hardcodes its adapter and declares its OWN
5
- * failure posture; this module only holds the transport mechanics they
6
- * genuinely share (stdin read, the demo judge, the parse→judge→render pipe with
7
- * a host-supplied fail-safe, and stdout+exit emission). The thing item ②
8
- * deleted was the `--agent` switch that let one binary impersonate every host
9
- * and so had to pick a single shared failure behavior; sharing pure plumbing
10
- * across distinct entries is not that.
4
+ * (`bin/<host>-hook.mjs`) that hardcodes its adapter; {@link runHookEntry}
5
+ * runs it with that host's OWN failure posture from {@link HOST_FAILURE}. The
6
+ * thing item ② deleted was the `--agent` switch that let one binary
7
+ * impersonate every host and so had to pick a single shared failure behavior;
8
+ * a per-host table keyed by the adapter's own AGENT is not that.
11
9
  *
12
10
  * It is exported because every consumer writing its own hook entry had to
13
11
  * re-implement the same two things, and both fail silently when they are wrong:
@@ -15,7 +13,12 @@
15
13
  * `process.exit` (see {@link writeAllSync}).
16
14
  */
17
15
  import { writeSync } from "node:fs";
18
- import { Decision, sanitizeVerdict } from "./control-plane.mjs";
16
+ import {
17
+ Decision,
18
+ gatedResponse,
19
+ renderEscalated,
20
+ sanitizeVerdict,
21
+ } from "./control-plane.mjs";
19
22
 
20
23
  /**
21
24
  * Read all of stdin to a string.
@@ -57,15 +60,17 @@ export function demoJudge(event) {
57
60
  /**
58
61
  * Run one host's parse→judge→render pipe over a raw stdin payload. If the payload
59
62
  * can't be parsed or the render throws, return `onFailure` — the host's OWN
60
- * fail-safe response — so a crashed hook degrades the way THAT host expects
61
- * (claude/codex fail OPEN = exit 0; amp fails to ASK = exit 1) instead of a
62
- * single shared default. This is the deliberate, necessary recovery the whole
63
- * per-host split exists for, not a blanket swallow.
63
+ * fail-safe response from {@link failurePosture} — so a crashed hook degrades
64
+ * the way THAT host expects instead of a single shared default. This is the
65
+ * deliberate, necessary recovery the whole per-host split exists for, not a
66
+ * blanket swallow.
64
67
  *
65
68
  * A judge that THROWS is not a malformed payload: the call parsed, and the judge
66
69
  * failed to answer. That takes the same path as a judge that answers garbage —
67
70
  * the clamp's `ask` (see below) — so a crashing guardrail puts the call in front
68
- * of a human instead of taking an exit-0 fail-safe that runs it.
71
+ * of a human instead of taking an exit-0 fail-safe that runs it. The verdict
72
+ * renders through `renderEscalated`, so on a host with no ask tier that ask
73
+ * blocks a vetoable call rather than running it.
69
74
  *
70
75
  * Every error is written to STDERR (never stdout, which belongs to the host
71
76
  * transport) so a regression that silently disables enforcement leaves a
@@ -108,6 +113,11 @@ export function renderHookResponse(
108
113
  answer = { reason: `hook judge threw: ${describe(err).split("\n")[0]}` };
109
114
  report("hook judge error", err);
110
115
  }
116
+ // A host-supplied `onFailure` that BLOCKS (see {@link failurePosture}) wins
117
+ // over the clamp's ask. `renderEscalated` keeps an ask on a host that
118
+ // honours one (Claude, Amp), so without this a fail-closed entry would ask
119
+ // about a call no judge approved instead of blocking it.
120
+ if (onFailure.enforced && !isDecision(answer)) return onFailure;
111
121
  // A judge's answer is clamped, not trusted. `normalizeVerdict` (inside
112
122
  // every adapter's render) THROWS on a decision outside allow/deny/ask, and
113
123
  // that throw would land in the catch below — the host's fail-safe, exit 0
@@ -116,13 +126,158 @@ export function renderHookResponse(
116
126
  // in front of a human. Prose is passed through: scrubbing monitor-authored
117
127
  // text needs a scrubber this transport module has no business choosing.
118
128
  const verdict = sanitizeVerdict(answer, (text) => text);
119
- return adapter.render(verdict, event);
129
+ return renderEscalated(adapter, verdict, event);
120
130
  } catch (err) {
121
131
  report("hook pipeline error", err);
122
132
  return onFailure;
123
133
  }
124
134
  }
125
135
 
136
+ /**
137
+ * Whether `answer` carries one of the three decisions a judge may return.
138
+ * @param {unknown} answer
139
+ * @returns {boolean}
140
+ */
141
+ function isDecision(answer) {
142
+ const decision =
143
+ answer !== null && typeof answer === "object"
144
+ ? /** @type {{ decision?: unknown }} */ (answer).decision
145
+ : undefined;
146
+ return Object.values(Decision).includes(/** @type {any} */ (decision));
147
+ }
148
+
149
+ /**
150
+ * The response that BLOCKS the call on `agent`'s host: its
151
+ * {@link BLOCK_TRANSPORT} row's exit code, with `reason` on the row's reason
152
+ * stream. Every row blocks, so this is the one fail-closed response for all hosts.
153
+ * @param {string} agent
154
+ * @param {string} reason
155
+ * @returns {import("./control-plane.mjs").NativeResponse}
156
+ */
157
+ export function failClosedResponse(agent, reason) {
158
+ return gatedResponse(agent, {
159
+ transport: "external_hook",
160
+ enforced: true,
161
+ unblocked_exit_code: 0,
162
+ reason,
163
+ });
164
+ }
165
+
166
+ /** @type {import("./control-plane.mjs").NativeResponse} */
167
+ const RUN = Object.freeze({
168
+ transport: "external_hook",
169
+ exit_code: 0,
170
+ enforced: false,
171
+ });
172
+
173
+ /**
174
+ * Each host's DEFAULT response when its hook breaks, which is how that host
175
+ * degrades a hook it cannot read:
176
+ * - claude, codex, gemini: exit 0, so the call runs. Each host treats a crashed
177
+ * or non-conforming hook as no objection, and a spurious block would wedge the
178
+ * user. The sandbox is the backstop.
179
+ * - amp: exit 1, which Amp's delegate reads as "ask the human". That neither
180
+ * runs nor rejects a call no judge saw.
181
+ * @type {Readonly<Record<string, import("./control-plane.mjs").NativeResponse>>}
182
+ */
183
+ export const HOST_FAILURE = Object.freeze({
184
+ claude: RUN,
185
+ codex: RUN,
186
+ gemini: RUN,
187
+ amp: Object.freeze({
188
+ transport: "external_hook",
189
+ exit_code: 1,
190
+ enforced: false,
191
+ }),
192
+ });
193
+
194
+ /**
195
+ * The failure response for `agent`: its {@link HOST_FAILURE} row, or
196
+ * {@link failClosedResponse} when `ACP_FAIL_CLOSED=1`. A blocking failure
197
+ * response also blocks a call whose judge threw or answered no decision.
198
+ * Register a fail-closed entry on tool-call events only: on a Stop event,
199
+ * exit 2 means "keep working". {@link runHookEntry} also answers a hang
200
+ * before the host's timeout. It cannot answer for a process that never starts
201
+ * or that the host kills for another reason; only the host can, as Claude
202
+ * Code's `"onFailure": "block"` does.
203
+ * @param {string} agent
204
+ * @param {NodeJS.ProcessEnv} [env]
205
+ * @returns {import("./control-plane.mjs").NativeResponse}
206
+ */
207
+ export function failurePosture(agent, env = process.env) {
208
+ if (!Object.hasOwn(HOST_FAILURE, agent))
209
+ throw new Error(
210
+ `runtime: no HOST_FAILURE row for agent ${JSON.stringify(agent)}`,
211
+ );
212
+ const flag = env.ACP_FAIL_CLOSED;
213
+ if (flag === undefined || flag === "" || flag === "0")
214
+ return HOST_FAILURE[agent];
215
+ if (flag !== "1")
216
+ throw new Error(
217
+ `ACP_FAIL_CLOSED must be "1" or "0", got ${JSON.stringify(flag)}`,
218
+ );
219
+ return failClosedResponse(
220
+ agent,
221
+ "hook failed; ACP_FAIL_CLOSED blocks the call",
222
+ );
223
+ }
224
+
225
+ /**
226
+ * The whole hook entry for `adapter`'s host: read stdin, run
227
+ * {@link renderHookResponse} with that host's {@link failurePosture}, and
228
+ * {@link emit} the result. Every `bin/<host>-hook.mjs` is this one call. The
229
+ * failure response also answers a stdin read error and, when
230
+ * {@link hookDeadlineMs} is set, a hook still running at that deadline.
231
+ * @param {import("./control-plane.mjs").Adapter} adapter
232
+ * @param {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict} [judge]
233
+ * @returns {Promise<never>}
234
+ */
235
+ export async function runHookEntry(adapter, judge = demoJudge) {
236
+ const onFailure = failurePosture(adapter.AGENT);
237
+ const deadline = hookDeadlineMs();
238
+ /** @param {string} what @param {unknown} err @returns {never} */
239
+ const fail = (what, err) => {
240
+ report(what, err);
241
+ return emit(onFailure);
242
+ };
243
+ // A host that kills a hung hook runs the call, so the hook answers first with
244
+ // its own failure response. A judge that blocks the event loop outlasts this.
245
+ if (deadline !== undefined)
246
+ setTimeout(
247
+ () =>
248
+ fail(
249
+ "hook deadline",
250
+ new Error(`no response within ACP_HOOK_TIMEOUT_MS=${deadline}`),
251
+ ),
252
+ deadline,
253
+ );
254
+ /** @type {string} */
255
+ let raw;
256
+ try {
257
+ raw = await readStdin();
258
+ } catch (err) {
259
+ return fail("hook stdin error", err);
260
+ }
261
+ return emit(renderHookResponse(adapter, raw, onFailure, judge));
262
+ }
263
+
264
+ /**
265
+ * `ACP_HOOK_TIMEOUT_MS` as a positive integer, or `undefined` when unset. Set it
266
+ * below the host's own hook timeout, so the hook's failure response lands
267
+ * before the host kills it.
268
+ * @param {NodeJS.ProcessEnv} [env]
269
+ * @returns {number|undefined}
270
+ */
271
+ export function hookDeadlineMs(env = process.env) {
272
+ const value = env.ACP_HOOK_TIMEOUT_MS;
273
+ if (value === undefined || value === "") return undefined;
274
+ if (!/^[1-9]\d*$/u.test(value))
275
+ throw new Error(
276
+ `ACP_HOOK_TIMEOUT_MS must be a positive integer of milliseconds, got ${JSON.stringify(value)}`,
277
+ );
278
+ return Number(value);
279
+ }
280
+
126
281
  /**
127
282
  * A thrown value as readable text: an Error's stack (or message when it has no
128
283
  * stack), or the value itself for a bare throw.
@@ -14,15 +14,17 @@ export function demoJudge(event: import("./control-plane.mjs").ToolCallEvent): i
14
14
  /**
15
15
  * Run one host's parse→judge→render pipe over a raw stdin payload. If the payload
16
16
  * can't be parsed or the render throws, return `onFailure` — the host's OWN
17
- * fail-safe response — so a crashed hook degrades the way THAT host expects
18
- * (claude/codex fail OPEN = exit 0; amp fails to ASK = exit 1) instead of a
19
- * single shared default. This is the deliberate, necessary recovery the whole
20
- * per-host split exists for, not a blanket swallow.
17
+ * fail-safe response from {@link failurePosture} — so a crashed hook degrades
18
+ * the way THAT host expects instead of a single shared default. This is the
19
+ * deliberate, necessary recovery the whole per-host split exists for, not a
20
+ * blanket swallow.
21
21
  *
22
22
  * A judge that THROWS is not a malformed payload: the call parsed, and the judge
23
23
  * failed to answer. That takes the same path as a judge that answers garbage —
24
24
  * the clamp's `ask` (see below) — so a crashing guardrail puts the call in front
25
- * of a human instead of taking an exit-0 fail-safe that runs it.
25
+ * of a human instead of taking an exit-0 fail-safe that runs it. The verdict
26
+ * renders through `renderEscalated`, so on a host with no ask tier that ask
27
+ * blocks a vetoable call rather than running it.
26
28
  *
27
29
  * Every error is written to STDERR (never stdout, which belongs to the host
28
30
  * transport) so a regression that silently disables enforcement leaves a
@@ -39,6 +41,48 @@ export function demoJudge(event: import("./control-plane.mjs").ToolCallEvent): i
39
41
  * @returns {import("./control-plane.mjs").NativeResponse}
40
42
  */
41
43
  export function renderHookResponse(adapter: import("./control-plane.mjs").Adapter, rawInput: string, onFailure: import("./control-plane.mjs").NativeResponse, judge?: (event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict): import("./control-plane.mjs").NativeResponse;
44
+ /**
45
+ * The response that BLOCKS the call on `agent`'s host: its
46
+ * {@link BLOCK_TRANSPORT} row's exit code, with `reason` on the row's reason
47
+ * stream. Every row blocks, so this is the one fail-closed response for all hosts.
48
+ * @param {string} agent
49
+ * @param {string} reason
50
+ * @returns {import("./control-plane.mjs").NativeResponse}
51
+ */
52
+ export function failClosedResponse(agent: string, reason: string): import("./control-plane.mjs").NativeResponse;
53
+ /**
54
+ * The failure response for `agent`: its {@link HOST_FAILURE} row, or
55
+ * {@link failClosedResponse} when `ACP_FAIL_CLOSED=1`. A blocking failure
56
+ * response also blocks a call whose judge threw or answered no decision.
57
+ * Register a fail-closed entry on tool-call events only: on a Stop event,
58
+ * exit 2 means "keep working". {@link runHookEntry} also answers a hang
59
+ * before the host's timeout. It cannot answer for a process that never starts
60
+ * or that the host kills for another reason; only the host can, as Claude
61
+ * Code's `"onFailure": "block"` does.
62
+ * @param {string} agent
63
+ * @param {NodeJS.ProcessEnv} [env]
64
+ * @returns {import("./control-plane.mjs").NativeResponse}
65
+ */
66
+ export function failurePosture(agent: string, env?: NodeJS.ProcessEnv): import("./control-plane.mjs").NativeResponse;
67
+ /**
68
+ * The whole hook entry for `adapter`'s host: read stdin, run
69
+ * {@link renderHookResponse} with that host's {@link failurePosture}, and
70
+ * {@link emit} the result. Every `bin/<host>-hook.mjs` is this one call. The
71
+ * failure response also answers a stdin read error and, when
72
+ * {@link hookDeadlineMs} is set, a hook still running at that deadline.
73
+ * @param {import("./control-plane.mjs").Adapter} adapter
74
+ * @param {(event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict} [judge]
75
+ * @returns {Promise<never>}
76
+ */
77
+ export function runHookEntry(adapter: import("./control-plane.mjs").Adapter, judge?: (event: import("./control-plane.mjs").ToolCallEvent) => import("./control-plane.mjs").Verdict): Promise<never>;
78
+ /**
79
+ * `ACP_HOOK_TIMEOUT_MS` as a positive integer, or `undefined` when unset. Set it
80
+ * below the host's own hook timeout, so the hook's failure response lands
81
+ * before the host kills it.
82
+ * @param {NodeJS.ProcessEnv} [env]
83
+ * @returns {number|undefined}
84
+ */
85
+ export function hookDeadlineMs(env?: NodeJS.ProcessEnv): number | undefined;
42
86
  /**
43
87
  * Whether a failed `writeSync` is the one recoverable case: EAGAIN, meaning the
44
88
  * non-blocking pipe is momentarily full because the host has not drained it
@@ -80,3 +124,14 @@ export function writeAllSync(fd: number, text: string): void;
80
124
  * @returns {never}
81
125
  */
82
126
  export function emit(response: import("./control-plane.mjs").NativeResponse): never;
127
+ /**
128
+ * Each host's DEFAULT response when its hook breaks, which is how that host
129
+ * degrades a hook it cannot read:
130
+ * - claude, codex, gemini: exit 0, so the call runs. Each host treats a crashed
131
+ * or non-conforming hook as no objection, and a spurious block would wedge the
132
+ * user. The sandbox is the backstop.
133
+ * - amp: exit 1, which Amp's delegate reads as "ask the human". That neither
134
+ * runs nor rejects a call no judge saw.
135
+ * @type {Readonly<Record<string, import("./control-plane.mjs").NativeResponse>>}
136
+ */
137
+ export const HOST_FAILURE: Readonly<Record<string, import("./control-plane.mjs").NativeResponse>>;