@sema-agent/core 5.55.0 → 5.56.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 (42) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/agents/send-message-tool.js +48 -2
  3. package/dist/agents/subagent.js +250 -89
  4. package/dist/core/auto-compaction.d.ts +17 -4
  5. package/dist/core/auto-compaction.js +3 -0
  6. package/dist/core/context-edit.d.ts +55 -6
  7. package/dist/core/context-edit.js +12 -1
  8. package/dist/core/hooks.d.ts +293 -11
  9. package/dist/core/hooks.js +158 -11
  10. package/dist/core/human-input-projection.d.ts +20 -2
  11. package/dist/core/human-input-projection.js +9 -0
  12. package/dist/core/permission-rules.d.ts +23 -15
  13. package/dist/core/permission-rules.js +40 -31
  14. package/dist/core/runner/prepare-task.d.ts +8 -0
  15. package/dist/core/runner/prepare-task.js +34 -23
  16. package/dist/core/runner/runtask.js +158 -21
  17. package/dist/core/runner/session-rule-policy.js +5 -5
  18. package/dist/core/session-reconcile.d.ts +32 -0
  19. package/dist/core/session-reconcile.js +15 -0
  20. package/dist/core/task-notification.d.ts +34 -7
  21. package/dist/core/task-notification.js +11 -1
  22. package/dist/core/task-registry-agent.d.ts +20 -3
  23. package/dist/core/task-registry-agent.js +31 -2
  24. package/dist/core/tool-policy.d.ts +14 -9
  25. package/dist/core/tool-policy.js +27 -22
  26. package/dist/core/types.d.ts +37 -11
  27. package/dist/core/untrusted-text.js +8 -0
  28. package/dist/engine/compaction/compaction.d.ts +77 -7
  29. package/dist/engine/compaction/compaction.js +98 -9
  30. package/dist/engine/compaction/utils.d.ts +4 -0
  31. package/dist/engine/compaction/utils.js +6 -0
  32. package/dist/engine/harness/agent-harness.d.ts +84 -0
  33. package/dist/engine/harness/agent-harness.js +88 -12
  34. package/dist/engine/harness/messages.d.ts +4 -2
  35. package/dist/engine/harness/messages.js +7 -2
  36. package/dist/engine/harness/types.d.ts +11 -5
  37. package/dist/engine/loop/types.d.ts +7 -0
  38. package/dist/engine/session/import-validate.js +10 -0
  39. package/dist/engine/session/session.js +2 -2
  40. package/dist/orchestration/run-spec.js +8 -1
  41. package/dist/prompts/default.d.ts +10 -4
  42. package/package.json +1 -1
@@ -14,11 +14,18 @@ export declare const EDIT_FRACTION = 0.7;
14
14
  * effectiveWindow = autocompactWindow − min(maxOutputTokens, 20000) (CC `Nye`, cap `uMd`)
15
15
  * trigger = effectiveWindow − 13000 (CC `dSo`, buffer `rMd`)
16
16
  * i.e. trigger = W − 33000 for every model whose max output is ≥ 20k: a 200k window triggers at
17
- * 167000 (83.5% of nominal), a 1M window at 967000. (CC additionally ships a sonnet-5-only 1M
18
- * autocompact-window special of 967000 via dynamic config, making ITS 1M trigger 934000; that is a
19
- * per-model config delivery, not geometry an embedder reproduces it EXACTLY via
20
- * `model.autoCompactTokens = 967000`, which lowers only this trigger-side window while the guard
21
- * and physical request budgeting stay on the 1M window CC's dual-window shape.) We take the
17
+ * 167000 (83.5% of nominal), a 1M window at 967000.
18
+ *
19
+ * 1M DIVERGENCE, stated as fact rather than as an equivalence (anchoring re-check 2026-08-23): CC
20
+ * carries a MODEL-DEFAULT autocompact-window table (2.1.223 `j3u` :242918 `claude-sonnet-5`
21
+ * default 967000, and since 223 a surface dimension: `remote_cowork` / `local-agent` 500000), so a
22
+ * 1M sonnet-5 gets 967000 as its window and 934000 as its trigger WITHOUT anyone configuring
23
+ * anything. We ship no such table: a model declaring only `contextWindow: 1e6` triggers at 967000
24
+ * here, 33000 LATER than CC. `model.autoCompactTokens = 967000` reproduces CC's number — but that
25
+ * is an EMBEDDER action against CC's product default, not the same posture, and the choice not to
26
+ * follow 934000 is deliberate (design/146 §1.3 argues against living on that thin a cushion). The
27
+ * knob itself is real and unchanged: `autoCompactTokens` lowers only this trigger-side window while
28
+ * the guard and physical request budgeting stay on the 1M window — CC's dual-window shape. We take the
22
29
  * 20000 cap branch unconditionally (flat 33000), i.e. the `min(maxOutputTokens, 20000)` branch is
23
30
  * deliberately NOT ported: the deduction is exact parity only for models with max output ≥ 20k. A
24
31
  * model with a smaller max output would deduct less in CC (trigger LATER); we deduct the full
@@ -51,11 +58,53 @@ export declare function contextEditFrontier(window: number): number;
51
58
  * PowerShell is kept for CC fidelity even though sema does not mount it. Overridable per call via
52
59
  * {@link ContextEditOptions.compactableTools} for custom-tool-heavy embedders.
53
60
  */
61
+ /**
62
+ * How many most-recent CANDIDATE tool results keep their content when the stale-result pass runs.
63
+ *
64
+ * 3, UNCHANGED — and the fact that CC's corresponding number is 5 (2.1.223 `uAp`, and the same value
65
+ * on CC 88's `timeBasedMCConfig`, so it is stable across both corpus generations) is deliberately NOT
66
+ * adopted here. Two reasons, in order of weight:
67
+ *
68
+ * 1. MEASURED: widening the window removes this pass's only lever in the shape where a terminal
69
+ * parallel batch holds exactly as many large results as the window keeps. Probed on a 200k model
70
+ * with a 140k usage anchor and five 36k-char Bash results: at 3 the pass clears two and the
71
+ * request lands at ~167k, UNDER the 177k request guard; at 5 nothing is clearable, the request
72
+ * stays at 185k, and the guard cannot recover it either — `trimToBudget` must turn-align back to
73
+ * the emitting assistant, so the whole batch is retained. The window is count-based on both
74
+ * sides (CC's `slice(-keepRecent)` too), so this ceiling exists at every value; 5 simply widens
75
+ * the band that reaches it.
76
+ * 2. SEAT: the two numbers do not govern the same machine. CC's keep-recent clear runs ONLY on the
77
+ * `context_hint` rejection leg — gated off by default — and behind a hard "saves ≥ 20000 tokens
78
+ * or don't bother" test, with request REFUSAL as the real backstop. Ours is on by default, is
79
+ * the only reduction between the frontier and the guard, and its backstop drops messages instead
80
+ * of refusing. Copying a constant across that difference is the "same name, different question"
81
+ * mistake, not parity.
82
+ *
83
+ * So the VALUE is an open adjudication (recorded with the probe above), while the two things CC
84
+ * unambiguously answers — the floor and the candidate-scoped window — are followed exactly.
85
+ */
86
+ export declare const DEFAULT_KEEP_RECENT_TOOL_RESULTS = 3;
87
+ /** Minimum kept results — CC `EUs` :397710 `Math.max(1, keepRecent)`. See
88
+ * {@link ContextEditOptions.keepRecentToolResults} for why both degenerate ends are unusable. */
89
+ export declare const MIN_KEEP_RECENT_TOOL_RESULTS = 1;
54
90
  export declare const COMPACTABLE_TOOLS: ReadonlySet<string>;
55
91
  export interface ContextEditOptions {
56
92
  /** Start clearing once estimated context tokens exceed this. */
57
93
  budgetTokens: number;
58
- /** Always keep the content of this many most-recent tool results. Default 3. */
94
+ /**
95
+ * Always keep the content of this many most-recent CANDIDATE tool results (candidates = results
96
+ * from {@link compactableTools}). Default {@link DEFAULT_KEEP_RECENT_TOOL_RESULTS} = 3 — see that
97
+ * constant for why CC's 5 is not adopted here.
98
+ *
99
+ * Floored at 1, which IS CC's rule (`EUs` `Math.max(1, keepRecent)`), whose own source note gives
100
+ * the reason: 0 leaves the model with zero working tool context, and in CC's `slice(-0)` spelling
101
+ * it degenerates the other way into keeping everything — two unusable extremes for one value.
102
+ * Ours degenerated identically (a 0 or negative `keep` cleared EVERY candidate). Values that are
103
+ * not a non-negative safe integer are REFUSED rather than floored: a NaN used to make this whole
104
+ * defense silently inert (`slice(0, NaN)` = clear nothing), which is the one outcome a context
105
+ * defense must never reach quietly, and there is no config door upstream to announce at — this
106
+ * option only ever arrives as a direct argument from an embedder.
107
+ */
59
108
  keepRecentToolResults?: number;
60
109
  /**
61
110
  * roadmap #6② (CC contentReplacementState parity): when set, a result's FULL TEXT is offloaded to
@@ -27,6 +27,17 @@ export const COMPACTION_TRIGGER_BUFFER_TOKENS = 13000;
27
27
  export function contextEditFrontier(window) {
28
28
  return Math.max(window - (CONTEXT_OUTPUT_RESERVE_TOKENS + COMPACTION_TRIGGER_BUFFER_TOKENS), Math.floor(window * EDIT_FRACTION));
29
29
  }
30
+ export const DEFAULT_KEEP_RECENT_TOOL_RESULTS = 3;
31
+ export const MIN_KEEP_RECENT_TOOL_RESULTS = 1;
32
+ function resolveKeepRecentToolResults(value) {
33
+ if (value === undefined)
34
+ return DEFAULT_KEEP_RECENT_TOOL_RESULTS;
35
+ if (!Number.isSafeInteger(value) || value < 0) {
36
+ throw new TypeError(`keepRecentToolResults must be a non-negative safe integer (got ${String(value)}); ` +
37
+ `omit it for the default ${DEFAULT_KEEP_RECENT_TOOL_RESULTS}`);
38
+ }
39
+ return Math.max(MIN_KEEP_RECENT_TOOL_RESULTS, value);
40
+ }
30
41
  export const COMPACTABLE_TOOLS = new Set([
31
42
  "Read",
32
43
  "Bash",
@@ -74,7 +85,7 @@ export function clearStaleToolResults(messages, opts) {
74
85
  if (total <= opts.budgetTokens) {
75
86
  return messages;
76
87
  }
77
- const keep = opts.keepRecentToolResults ?? 3;
88
+ const keep = resolveKeepRecentToolResults(opts.keepRecentToolResults);
78
89
  const compactable = opts.compactableTools ?? COMPACTABLE_TOOLS;
79
90
  const toolResultCandidates = messages.flatMap((m, i) => isToolResult(m) && !isCleared(m) && compactable.has(m.toolName) ? [{ idx: i, target: m }] : []);
80
91
  const clearable = toolResultCandidates.slice(0, Math.max(0, toolResultCandidates.length - keep));
@@ -25,6 +25,23 @@ import type { WiringLegKind } from "./wiring-manifest.js";
25
25
  *
26
26
  * All feedback the model should see (a block reason, injected context) is wrapped as a
27
27
  * `<system-reminder>` via {@link formatHookFeedback} so the model can read and self-correct.
28
+ *
29
+ * **FAILURE POSTURE, and a correction to a widely-quoted anchor (recorded 2026-08-23).** Three repos
30
+ * have reasoned from the sentence "CC fails open on hook failures, exit code 2 excepted". That sentence
31
+ * is TRUE OF ONE LANE and false as a general statement, and the difference lands exactly on this seam.
32
+ * CC runs hooks of seven kinds; the `command` kind is a shell script, and for it a spawn failure, a
33
+ * non-zero exit and a per-hook timeout are all non-blocking — the call proceeds. But CC ALSO has an
34
+ * in-process CALLBACK lane, which is the one structurally equivalent to this interface, and there a
35
+ * PreToolUse callback that throws or times out is FAIL-CLOSED, with its own message stating that the
36
+ * tool call was not executed. (Two further details the short sentence hides: the decision channel is
37
+ * stdout JSON, with the exit code only a fallback — so exit 0 can block and a non-zero non-2 exit with
38
+ * valid JSON does not; and the callback lane's UserPromptSubmit timeout BLOCKS the prompt.)
39
+ *
40
+ * So this seam's fail-closed postures are not a sema invention to be justified against CC — they are
41
+ * the same posture CC takes on the same kind of lane: a `preToolUse` that throws or does not answer
42
+ * refuses the call, a `userPromptSubmit` that throws or does not answer refuses the prompt, and the
43
+ * OBSERVATION seats swallow and disclose because an observer has nothing to fail closed about. Anyone
44
+ * quoting the short sentence to argue this layer "should" fail open is quoting the script lane.
28
45
  */
29
46
  export interface Hooks {
30
47
  preToolUse?(toolName: string, input: unknown, ctx: HookToolContext): PreToolUseResult | undefined | Promise<PreToolUseResult | undefined>;
@@ -159,9 +176,179 @@ export interface Hooks {
159
176
  * in for CC `decision_reason_type`): no `retry` capability (recorded — retry semantics vs our adjudicate
160
177
  * chain need their own design), no additionalContext. A throwing callback is swallowed + traced via
161
178
  * `onError(phase:"hook")` — an observer bug must never alter the deny outcome (design/134 discipline).
179
+ *
180
+ * **HOW WIDE THIS SEAT IS, stated honestly (anchoring re-check 2026-08-23).** The exclusion sentence
181
+ * above is quoted from the SDK's TYPE-LEVEL documentation, and the whole R5 scoping argument was
182
+ * built on it without ever being checked against a shipped bundle. Against CC 2.1.223's runtime the
183
+ * picture is different in a way that matters for how this comment may be USED: CC emits its
184
+ * PermissionDenied event from exactly ONE site, guarded to the auto-mode CLASSIFIER's refusals —
185
+ * a rule deny, a mode deny and a headless auto-deny emit nothing at all. So "a hook's own deny is
186
+ * excluded" is TRIVIALLY true there (almost everything is excluded), and it must not be read as
187
+ * evidence that this seat's TRIGGER SURFACE matches CC's. It does not: sema fires on four engine
188
+ * emissions with seven `source` values, deliberately WIDER. The width is the useful direction for an
189
+ * observation seat — the blind spot R3 named is real and a narrower seat would not close it — and it
190
+ * is a divergence rather than a parity claim. The exclusion itself stays, on its own merit: a hook's
191
+ * deny is the HOOK's decision, and this seat exists to attribute the ENGINE's.
162
192
  */
163
193
  permissionDenied?(payload: PermissionDeniedPayload): void | Promise<void>;
194
+ /**
195
+ * The TIME BOUND every seat on this object runs under, in milliseconds. Absent ⇒
196
+ * {@link DEFAULT_HOOK_TIMEOUT_MS}. It is the analog of the CC hook record's own `timeout` field —
197
+ * declared once per hook record there, once per hook OBJECT here, because this seam's record IS the
198
+ * object. The unit is ms rather than CC's seconds: every other deadline this library takes ends in
199
+ * `Ms`, and one unit per codebase beats one unit per upstream.
200
+ *
201
+ * **Why the seam has a bound at all.** Every callback above is DEPLOYMENT code awaited on the
202
+ * engine's critical path, and one that never returns used to stop the whole run — the tool gate
203
+ * awaited `preToolUse` bare, so a hung screening face left the call unsettled and the task's own
204
+ * timeout/cancel could not end it. The diagnosis was an internal asymmetry rather than a guess: the
205
+ * SAME callback installed as an inherited constraint on a delegated child was already bounded, because
206
+ * that path folds it into a `ToolPolicy` and the policy wait is abort-raced — and the sibling seam in
207
+ * this very file (`reversibilityProbe`) had already been given a default deadline for the identical
208
+ * reason. Policies were bounded, probes were bounded, hooks were not.
209
+ *
210
+ * **What a bound does NOT change.** A seat that answers keeps its answer, byte for byte; a seat that
211
+ * throws keeps its existing crash posture (this member changes nothing about a throw). The bound is
212
+ * consulted only when a seat has not answered yet, and what happens then is per-seat and stated on
213
+ * each member's own contract — decision seats fail CLOSED (`preToolUse` refuses the call the way a
214
+ * crash does; `userPromptSubmit` refuses the prompt), observation seats are ABANDONED and the engine
215
+ * proceeds as if the seat had returned nothing. Either way the deployment is told on its `onError`
216
+ * lane, because a silently-dropped seat is the failure mode this member exists to make visible.
217
+ *
218
+ * **The seat also gets a signal.** An expired wait would otherwise leave the callback running with
219
+ * nobody reading its answer — an orphan of the deployment's own making. So every context carries an
220
+ * optional `signal` that aborts when this bound elapses (or the task aborts), and an implementation
221
+ * that honors it can cancel the read it was waiting on. Honoring it is optional; a seat that ignores
222
+ * it is exactly as bounded as before, the engine simply stops waiting.
223
+ *
224
+ * **WHAT THE BOUND IS ON, stated narrowly because the difference is not academic.** It bounds the
225
+ * engine's WAIT, not the callback's CPU. A hook that blocks the thread SYNCHRONOUSLY — a spin loop, a
226
+ * synchronous exec, a synchronous parse of something enormous — cannot be preempted by this bound or
227
+ * by any other in-process mechanism, because the timer that would fire is queued behind the code that
228
+ * is not yielding; the ordering of the timer's installation makes no difference to that. In-process
229
+ * is the seam's whole premise (it is what makes a hook able to rewrite args and return a verdict),
230
+ * and preemption is what it trades away. A deployment that must survive a hook it does not trust to
231
+ * yield runs that hook somewhere with its own thread — a worker, a process, a service — and awaits
232
+ * the result from here. What this member DOES bound is every asynchronous wait, which is where the
233
+ * failure this was written for lives: a callback that awaits an unreachable host, a lock, a queue.
234
+ *
235
+ * **Value discipline** (bad values are loud, never silently reinterpreted): a non-finite, negative, or
236
+ * above-`setTimeout`-ceiling (2147483647) value is REFUSED to the default and reported on the
237
+ * deployment's error lane — the same treatment, and the same ceiling, the gate's `approvalTimeoutMs`
238
+ * seat gives its own garbage. `0` is honored as written (every seat expires immediately), because a
239
+ * deployment that writes zero has said something, and guessing that it meant "unbounded" would be the
240
+ * one reading this member cannot support.
241
+ */
242
+ timeoutMs?: number;
164
243
  }
244
+ /**
245
+ * The bound a {@link Hooks} seat runs under when the deployment declares none ({@link Hooks.timeoutMs}).
246
+ *
247
+ * The value is CC's own hook default (600s) rather than a number invented here. It is deliberately
248
+ * GENEROUS: the seat bound's job is to make a wedge terminal, not to police how long a legitimate hook
249
+ * may think — the task's own abort signal is what ends a hung DECISION seat promptly on a run that has a
250
+ * deadline, and this ceiling is what ends it on a run that does not. A deployment whose hooks should
251
+ * answer in a second says so with `timeoutMs`.
252
+ */
253
+ export declare const DEFAULT_HOOK_TIMEOUT_MS = 600000;
254
+ /**
255
+ * How long an observation seat still gets when the task is cancelled `elapsed` ms into its wait — the
256
+ * grace, capped by what is LEFT of the declared deadline.
257
+ *
258
+ * The `remaining` argument, not the whole deadline, is the correction: arming a fresh
259
+ * `min(GRACE, timeoutMs)` from the abort moment restarts the clock, so an abort landing near the
260
+ * deadline pushes the seat's absolute end PAST the number the deployment declared — up to double it for
261
+ * a sub-second bound — on precisely the cancelled-task settlement path the grace exists to shorten. The
262
+ * two contract sentences this file already carries ("never an extension", "a deployment that declared a
263
+ * SHORTER `timeoutMs` keeps its own number") were true only in the aborted-on-entry case, where elapsed
264
+ * is zero and the two formulas coincide. Now they are true everywhere.
265
+ *
266
+ * Clamped at zero: a deadline already spent is no grace at all, not a negative timer.
267
+ */
268
+ export declare function postAbortGraceMs(remainingMs: number): number;
269
+ /**
270
+ * Read {@link Hooks.timeoutMs} as a usable deadline: the declared value when it is one, the default
271
+ * when it is absent, and the default LOUDLY when it is garbage (bad-value loudness — a knob's bad value
272
+ * must never resolve silently to the default).
273
+ *
274
+ * `owner` is the record the value was read off; passing it bounds the disclosure to once per record
275
+ * (see {@link badHookTimeoutReported}). Omit it where the caller already bounds its own channel.
276
+ */
277
+ export declare function resolveHookTimeoutMs(supplied: number | undefined, report?: (err: unknown) => void, owner?: object): number;
278
+ /** The ten {@link Hooks} callback seats, named so a bound wait can say WHICH one did not answer. */
279
+ export type HookSeatName = "preToolUse" | "postToolUse" | "postToolUseFailure" | "postToolBatch" | "userPromptSubmit" | "stop" | "stopFailure" | "preCompact" | "postCompact" | "permissionDenied";
280
+ /** What one bounded seat invocation produced: the seat's own value, or the fact that the wait ended
281
+ * first. A seat that THREW is neither — the throw propagates out of {@link runHookSeat} unchanged, so
282
+ * each site's existing crash arm keeps owning it. */
283
+ export type HookSeatOutcome<T> = {
284
+ readonly expired: false;
285
+ readonly value: T;
286
+ } | {
287
+ readonly expired: true;
288
+ readonly cause: "timeout" | "aborted";
289
+ };
290
+ /** How one seat invocation is bounded. */
291
+ export interface HookSeatBound {
292
+ /** The deployment's declared bound ({@link Hooks.timeoutMs}); re-validated here, so a caller that
293
+ * already validated pays nothing and a caller that did not cannot pass garbage through. */
294
+ timeoutMs?: number;
295
+ /** The task abort signal. It is ALWAYS forwarded to the seat (so an implementation can cancel its own
296
+ * underlying wait) and it ALWAYS shortens the wait — the two seat kinds differ only in HOW MUCH.
297
+ * Supply it wherever the caller has one: a seat with no signal is bounded by its deadline alone, and
298
+ * a ten-minute default deadline is not a cancellation story. */
299
+ signal?: AbortSignal;
300
+ /** True for a seat whose answer is MOOT once the task is ending — the DECISION seats. Those settle as
301
+ * `aborted` the instant the task is cancelled. An observation seat leaves this false on purpose:
302
+ * `postToolUseFailure` fires precisely BECAUSE the task aborted (`isInterrupt`), and cancelling it
303
+ * outright would delete the observation the seat exists to deliver — it gets
304
+ * {@link POST_ABORT_GRACE_MS} instead of the full deadline. */
305
+ abortEnds?: boolean;
306
+ /** Where a bad {@link timeoutMs} is disclosed. The EXPIRY itself is not reported here — each site
307
+ * words its own, because the sentence a deployment needs includes what the engine then did. */
308
+ onBadTimeout?: (err: unknown) => void;
309
+ /** The hook record the bound was read off, for once-per-record bad-value disclosure. */
310
+ owner?: object;
311
+ }
312
+ /**
313
+ * Run ONE {@link Hooks} seat invocation under its bound — the single home of "a deployment callback
314
+ * gets a deadline", so the ten seats cannot grow ten dialects of one wait.
315
+ *
316
+ * Shape notes, each one load-bearing:
317
+ * · the seat is invoked SYNCHRONOUSLY, in this turn, exactly where the bare `await` used to be — a
318
+ * callback that reads its own mutable state before its first `await` must see the turn it always saw;
319
+ * · a SYNCHRONOUS throw propagates unchanged (it never reached a deadline, and the site's crash arm is
320
+ * the contract for it), and so does an asynchronous rejection;
321
+ * · the seat's signal fires when — and ONLY when — this function stops waiting, and its promise is then
322
+ * abandoned with a rejection sink attached (an abandoned seat that fails later must not surface as an
323
+ * unhandled rejection somewhere else). The task's own signal is never forwarded raw: for an
324
+ * OBSERVATION seat the task being cancelled is not a reason to stop, it is the reason the seat is
325
+ * firing at all;
326
+ * · the TASK signal ends the wait on both kinds of seat, differently. A DECISION seat
327
+ * ({@link HookSeatBound.abortEnds}) settles as `aborted` the moment the task is cancelled, INCLUDING
328
+ * when it was already cancelled on entry — a verdict about a call that will not run decides nothing,
329
+ * and waiting the full deadline for one holds a cancelled task open for as long as the deadline says.
330
+ * On the entry case the callback is still INVOKED (an audit face keeps seeing the attempt) and its
331
+ * answer is discarded: settling on the cancellation rather than on whichever of the two happened to
332
+ * land first is what makes the arm a rule instead of a race.
333
+ * An OBSERVATION seat is not cancelled by the abort — it fires precisely BECAUSE the task aborted
334
+ * (`isInterrupt`), and racing it against that same abort would delete the observation it exists to
335
+ * deliver — but it does not get the full deadline either: the abort collapses its wait to
336
+ * {@link POST_ABORT_GRACE_MS}. Prompt observers still land; a hung one can no longer hold a cancelled
337
+ * task's settlement for the length of the whole bound.
338
+ */
339
+ export declare function runHookSeat<T>(seat: HookSeatName, bound: HookSeatBound, call: (signal: AbortSignal) => T | Promise<T>): Promise<HookSeatOutcome<T>>;
340
+ /**
341
+ * The ONE wording home for "the engine stopped waiting for a hook seat", so the ten sites cannot drift
342
+ * into ten sentences about one fact. `consequence` is the half only the site knows: what the engine did
343
+ * instead.
344
+ *
345
+ * The CAUSE is named, not glossed. A deadline that elapsed and a task that was cancelled are different
346
+ * facts to whoever reads this — the first says a hook is too slow or stuck and wants fixing, the second
347
+ * says nothing about the hook at all — and one sentence covering both would have to be false about one
348
+ * of them. (It was: the first version of this said "did not answer within its Nms bound" on every
349
+ * expiry, including the ones where the bound was never reached.)
350
+ */
351
+ export declare function hookSeatExpiredError(seat: HookSeatName, timeoutMs: number, cause: "timeout" | "aborted", consequence: string): Error;
165
352
  /**
166
353
  * Where a {@link Hooks.permissionDenied} deny came from — our gate-source enum standing in for CC's
167
354
  * `decision_reason_type` ('classifier'|'asyncAgent'|'mode'|'rule'), named after OUR adjudicate-chain
@@ -170,7 +357,9 @@ export interface Hooks {
170
357
  * (headless auto-deny / approver said no / approval aborted).
171
358
  * - `"hook"` — a PreToolUse hook `ask` (folded allow→ask) resolved to deny, OR the engine's fail-closed
172
359
  * interception of a hook that THREW (RB-442). (A hook's own `deny` short-circuit is EXCLUDED from the
173
- * event, CC-exact — a decision the hook made; the crash interception is a decision the ENGINE made.)
360
+ * event — a decision the hook made; the crash interception is a decision the ENGINE made. The
361
+ * exclusion is ours on that merit; see {@link Hooks.permissionDenied} for why it must NOT be read
362
+ * as a parity claim about this seat's trigger surface.)
174
363
  * - `"safety"` — the gate's deterministic egress/irreversibility tighten (design/70 / design/77 §4)
175
364
  * raised the `ask` that resolved to deny, from an explicit per-tool mark.
176
365
  * - `"shellGate"` — same tighten-deny, but the tool's irreversibility tier was installed by the COARSE
@@ -205,6 +394,10 @@ export interface PermissionDeniedPayload {
205
394
  * screen; a policy's direct deny, a hook deny, and the crash/plan-mode/compliance emissions carry
206
395
  * none. See {@link import("./tool-policy.js").AskDenyResolution}. */
207
396
  resolution?: import("./tool-policy.js").AskDenyResolution;
397
+ /** {@link HookSeatSignal} — this invocation's own abort signal. On an OBSERVATION seat the deny has
398
+ * already happened and nothing this callback does can change it, so the signal says exactly one
399
+ * thing: stop reading, nobody is waiting for your answer any more. */
400
+ signal?: AbortSignal;
208
401
  }
209
402
  /**
210
403
  * 1.256 复审 MED-1 — observe-only payload isolation for {@link Hooks.permissionDenied}: clone the tool
@@ -227,7 +420,28 @@ export interface StopFailureContext {
227
420
  errorKind?: string;
228
421
  /** Turns completed when the failure ended the run. */
229
422
  turns: number;
423
+ /** {@link HookSeatSignal} — this invocation's own abort signal. */
424
+ signal?: AbortSignal;
230
425
  }
426
+ /**
427
+ * The abort signal every hook seat's context carries (`ctx.signal`), documented once here and
428
+ * referenced from each seat.
429
+ *
430
+ * It carries exactly one meaning: **the engine has stopped waiting for this invocation.** It is not the
431
+ * task's abort signal and it never fires early on the task's behalf — a DECISION seat's wait ends when
432
+ * the task is cancelled, so its signal fires then; an OBSERVATION seat's wait ends at its deadline or
433
+ * after the post-abort grace, so its signal fires there and NOT at the cancellation itself. The
434
+ * difference is deliberate: a `postToolUseFailure` fires precisely BECAUSE the task aborted, and a
435
+ * signal that went off at the cancellation would tell a cooperative observer to abandon the very
436
+ * observation it was invoked to deliver.
437
+ *
438
+ * Honoring it is optional and it changes nothing about the engine's own behavior — the engine stops
439
+ * waiting either way. What it is for is the deployment's side of that: a seat that was awaiting a
440
+ * network read, a lock or a subprocess can cancel it instead of leaving an orphan running with nobody
441
+ * left to read its answer. Absent when a host drives a seat outside the engine's own invocation (no
442
+ * bound, nothing to signal).
443
+ */
444
+ export type HookSeatSignal = AbortSignal;
231
445
  /** The failed tool execution a PostToolUseFailure callback inspects (design/134). */
232
446
  export interface HookToolFailure {
233
447
  /** Model-facing error text of the failed call (the error tool result's text content, joined). */
@@ -273,6 +487,8 @@ export interface PreCompactContext {
273
487
  trigger: "auto" | "manual" | "forced";
274
488
  /** The summarization instructions in effect (spec/deployment-level), when set. */
275
489
  customInstructions?: string;
490
+ /** {@link HookSeatSignal} — this invocation's own abort signal. */
491
+ signal?: AbortSignal;
276
492
  }
277
493
  /** A PreCompact result: skip this compaction (auto/manual only) and/or extend the summary instructions. */
278
494
  export interface PreCompactResult {
@@ -293,6 +509,8 @@ export interface PostCompactContext {
293
509
  summary: string;
294
510
  tokensBefore?: number;
295
511
  tokensAfter?: number;
512
+ /** {@link HookSeatSignal} — this invocation's own abort signal. */
513
+ signal?: AbortSignal;
296
514
  }
297
515
  /** Context for the {@link Hooks.stop} hook (CC `stop_hook_active` parity). */
298
516
  export interface StopHookContext {
@@ -326,6 +544,8 @@ export interface StopHookContext {
326
544
  * Absent when the runner cannot supply one (a stop gate wired without a session).
327
545
  */
328
546
  getBranch?: () => Promise<readonly SessionTreeEntry[]>;
547
+ /** {@link HookSeatSignal} — this invocation's own abort signal. */
548
+ signal?: AbortSignal;
329
549
  }
330
550
  /** A Stop hook result: block the run from ending, with a model-readable reason. */
331
551
  export interface StopHookResult {
@@ -333,11 +553,20 @@ export interface StopHookResult {
333
553
  * model gets another turn to address it. */
334
554
  block?: string;
335
555
  /**
336
- * CC 2.1.201 parity (CC :472050-472077): extra model-readable context injected at the stop point —
337
- * DECOUPLED from `block`. When present it is injected (as a `<system-reminder>`, neutral framing:
338
- * "Stop hook additional context: …") whether or not the hook blocked; `additionalContext` WITHOUT
339
- * `block` still continues the run one more turn, but does NOT count toward the consecutive-block
340
- * cap (the cap is driven by `block` alone).
556
+ * Extra model-readable context injected at the stop point — DECOUPLED from `block`. When present it
557
+ * is injected (as a `<system-reminder>`, neutral framing: "Stop hook additional context: …") whether
558
+ * or not the hook blocked, and an `additionalContext` WITHOUT `block` still continues the run one
559
+ * more turn.
560
+ *
561
+ * **It DOES count toward the consecutive-block cap, and it never resets it.** This sentence used to
562
+ * say the opposite ("the cap is driven by `block` alone", cited to CC 2.1.201), and it was wrong on
563
+ * both halves. Re-read against CC: the `additionalContext` branch pushes into the SAME array as the
564
+ * blocking-error branch, the loop counts that array's length against the cap, and CC never resets
565
+ * mid-run. The engine was fixed to match; the sentence here was not, so the module that DEFINES this
566
+ * seat and the module that IMPLEMENTS it stated opposite behaviors for years. What the cap bounds is
567
+ * "the run did not end when it otherwise would have", and a context-only push-back is exactly that —
568
+ * a hook whose most common failure mode is "no parseable verdict, here is some text" would otherwise
569
+ * run to `maxTurns` looking busy rather than stuck.
341
570
  */
342
571
  additionalContext?: string;
343
572
  }
@@ -524,6 +753,28 @@ export interface HookToolContext {
524
753
  * environment to report). Present ⇒ inspect the individual members: each one is itself optional.
525
754
  */
526
755
  env?: HookEnvCapabilities;
756
+ /**
757
+ * The TASK's tracked working directory at this call — the base the hand tools will resolve a RELATIVE
758
+ * path against, read live off the same ref the tool policy reads (so a hook and a policy judging the
759
+ * same call judge it against the same base).
760
+ *
761
+ * It is NOT {@link HookEnvCapabilities.cwd}, and the difference is the reason this member exists: that
762
+ * one is the ENVIRONMENT's own working directory, which never moves when the shell `cd`s or the run
763
+ * enters a worktree. A hook is documented — on the capability face itself — as the seat that judges
764
+ * "is this write inside the workspace", and it was the only seat in the gate that could not resolve a
765
+ * relative target the way the tool would. CC ships its own `cwd` to every hook event for the same
766
+ * reason; this is that field, named for the value it actually carries.
767
+ *
768
+ * ABSENT when the run has no tracked cwd to report — no real write hands (read-only or no shell), a
769
+ * host driving {@link runToolGate} without one, or the delegation-fold twin (which runs inside a
770
+ * DESCENDANT's policy fold and would otherwise report the install site's directory, i.e. the wrong
771
+ * machine's answer). Absence means "this engine cannot tell you", never "the root".
772
+ */
773
+ cwd?: string;
774
+ /** {@link HookSeatSignal} — this invocation's own abort signal. A FRESH one per screening pass, like
775
+ * the context object it rides: phase 1 and the approval-edit re-screen are two bounded waits, and a
776
+ * shared signal would let the first pass's expiry cancel the second. */
777
+ signal?: AbortSignal;
527
778
  }
528
779
  /** The executed tool result a PostToolUse hook inspects. */
529
780
  export interface HookToolOutput {
@@ -572,12 +823,16 @@ export interface PostToolUseResult {
572
823
  export interface UserPromptSubmitContext {
573
824
  /** The run/leg identity envelope — see {@link HookInvocationIdentity} for the presence law. */
574
825
  identity?: HookInvocationIdentity;
826
+ /** {@link HookSeatSignal} — this invocation's own abort signal. */
827
+ signal?: AbortSignal;
575
828
  }
576
829
  /** #281 件A — the context of a {@link Hooks.postToolBatch} invocation (third parameter, additive —
577
830
  * deliberately NOT folded into `meta`, whose presence already means "the engine injected here"). */
578
831
  export interface PostToolBatchContext {
579
832
  /** The run/leg identity envelope — see {@link HookInvocationIdentity} for the presence law. */
580
833
  identity?: HookInvocationIdentity;
834
+ /** {@link HookSeatSignal} — this invocation's own abort signal. */
835
+ signal?: AbortSignal;
581
836
  }
582
837
  /** A UserPromptSubmit hook result: block the submission, or inject context ahead of the prompt. */
583
838
  export interface UserPromptSubmitResult {
@@ -822,6 +1077,26 @@ export interface ToolGateInput {
822
1077
  * system-prompt declaration names. Absent (a host driving the gate directly) ⇒ bare tags. */
823
1078
  reminderMark?: string;
824
1079
  preToolUse?: Hooks["preToolUse"];
1080
+ /**
1081
+ * The bound the {@link preToolUse} screenings run under — {@link Hooks.timeoutMs}, carried in from
1082
+ * the hook record the caller resolved it off (the gate receives the callback, not the record). Absent
1083
+ * ⇒ {@link DEFAULT_HOOK_TIMEOUT_MS}. A garbage value is refused loudly to that default via
1084
+ * {@link onHookError}, exactly as {@link approvalTimeoutMs} is.
1085
+ *
1086
+ * On elapse the screening FAILS CLOSED — the call does not execute — and the block reason names the
1087
+ * hook and the bound. That direction is not a new posture for this seat: a hook that THROWS is
1088
+ * already the fail-closed deny here, and a hook that never answers has told the gate strictly less
1089
+ * than one that crashed. A face the deployment declared observational is not exempt, for the same
1090
+ * reason its throw is not: the declaration says its VERDICTS do not count, and a wait that never
1091
+ * ends is not a verdict.
1092
+ */
1093
+ hookTimeoutMs?: number;
1094
+ /** The task's live tracked cwd reader, put on every {@link HookToolContext} this gate call builds
1095
+ * (see {@link HookToolContext.cwd}). A FUNCTION rather than a string for the reason the policy's own
1096
+ * threading gives: capturing the value when the gate's wiring is built would freeze the task's
1097
+ * starting directory and answer for a run that has since `cd`ed. Absent ⇒ the contexts carry no
1098
+ * `cwd`, which is the honest answer for a run with no tracked one. */
1099
+ trackedCwd?: () => string | undefined;
825
1100
  /** The read-only env capability face put on every {@link HookToolContext} this gate call
826
1101
  * builds ({@link HookEnvCapabilities}). Built ONCE per task by the runner (after the env is minted) and
827
1102
  * passed through unchanged; omitted when the deployment wired no execution environment. */
@@ -941,12 +1216,13 @@ export interface ToolGateInput {
941
1216
  /**
942
1217
  * design/134 R5: observer for the gate's DENY short-circuit ({@link Hooks.permissionDenied}), pre-wrapped
943
1218
  * by the caller (swallow + onError) so it never throws. Fired ONCE, at the single deny exit of the
944
- * adjudicate chain — a policy deny or a resolved-ask deny. NOT fired for a PreToolUse hook deny
945
- * (CC-exact exclusion) or a durable suspend (the ask path). runToolGate still try/catches defensively:
946
- * an observer must never alter the deny outcome.
1219
+ * adjudicate chain — a policy deny or a resolved-ask deny. NOT fired for a PreToolUse hook deny (the
1220
+ * exclusion on {@link Hooks.permissionDenied}, which also records why that exclusion is not a parity
1221
+ * claim) or a durable suspend (the ask path). runToolGate still try/catches defensively: an observer
1222
+ * must never alter the deny outcome.
947
1223
  *
948
1224
  * RB-442: ALSO fired for the engine's fail-closed interception of a THROWING PreToolUse hook, with
949
- * `source:"hook"` — the CC-exact exclusion is the hook's own deny DECISION, not an engine decision.
1225
+ * `source:"hook"` — the exclusion is the hook's own deny DECISION, not an engine decision.
950
1226
  */
951
1227
  permissionDenied?: (payload: PermissionDeniedPayload) => void | Promise<void>;
952
1228
  /**
@@ -1198,7 +1474,13 @@ export interface ToolGateInput {
1198
1474
  * so a broken screening face is distinguishable from a deliberate refusal, and the folded form has to
1199
1475
  * reach the same lane from inside a descendant's fold.
1200
1476
  */
1201
- export declare function createPreToolUseConstraintPolicy(preToolUse: NonNullable<Hooks["preToolUse"]>, env?: HookEnvCapabilities, onCrash?: (err: unknown) => void): ToolPolicy;
1477
+ export declare function createPreToolUseConstraintPolicy(preToolUse: NonNullable<Hooks["preToolUse"]>, env?: HookEnvCapabilities, onCrash?: (err: unknown) => void,
1478
+ /** The install site's {@link Hooks.timeoutMs}. The folded form was ALREADY bounded by the descendant's
1479
+ * abort race — that asymmetry (bounded as an inherited constraint, unbounded in its own gate) is what
1480
+ * named the hole in the first place — but an abort bound is not a deadline: on a descendant with no
1481
+ * walltime and no cancel, the race never fires. So the same seat bound rides here too, and the two
1482
+ * installations of one callback are now bounded the same way for the same reason. */
1483
+ timeoutMs?: number): ToolPolicy;
1202
1484
  /**
1203
1485
  * The mandate provenance of one call, judged from the SAME mark inputs the gate is driven with —
1204
1486
  * the single source for "could a persisted allow rule clear this ask?". Allow rules silence the