Expand description
Extension hooks (M5). The engine consults a Hooks impl at two
points in the tool phase — before a call runs and after it returns —
scoped to the events actually used, not a 35-event schema (Delivery #5).
Vocabulary follows 0006’s M5 pin #2: a wrap-style PreToolUse hook
intercepts a call (allow / block / transiently rewrite its input); a
node-style PostToolUse hook returns a proposal to replace the
result. Hook-visible payloads are byte-capped (pin #1) so a hook can’t be
used to amplify a huge tool result into the process.
Structs§
- Event
Mask - One bit per
Hooksevent kind — the union gate every dispatch call site (hook_gate!) checks before doing ANY per-event work: payload construction, the ≤2KB cap copy, deadline registration, cancel-race scaffolding. A session whoseHooksimpl doesn’t register a given event pays exactly one branch on a cachedu8for it instead (§S1 HookRouter diet). - InProcess
Hooks - Lane 1 — in-process Rust hooks: the compiled-in registry that the shell adapter (lane 2) and any future third-party lane register against.
- Notification
Drain - A session-scoped registry of in-flight detached
notifytask handles (Finding 1 fix, the Critical review caught in the real CLI):notifypushes theJoinHandleit spawns here instead of discarding it, so a caller that’s about to tear down its runtime — the one-shot CLI’scurrent_threadblock_on, which drops the instant its driving future resolves — candrainthem first, under a short bounded grace period. Without this, a detachedtokio::spawnawaiting a real subprocess (anotificationshell hook) is silently killed mid-flight the momentblock_onreturns: nothing else is left polling it, unlike the long-lived TUI/interactive path (or a#[tokio::test]that keeps pollingevents.recv()in a loop) where the runtime stays alive on its own. Cloning shares the same underlying registry (anArc), sonotify’s caller (the actor/turn) and the CLI’s exit-time drain call see the same set of handles.
Enums§
- Matcher
- Per-tool matcher (12 §“tool events match tool name; regex when
non-alphanumeric” — hotl deliberately adopts only the exact-name half:
full regex is YAGNI for a personal harness).
Allfires for every tool (the pre-matcher default);Namesfires only for an exact (case-sensitive) name match. - Notification
Kind - The
Notificationhook’s kind (tier-1 gap #7, thehotl watch/desktop seam): the agent blocked on a human, went idle awaiting a prompt, or completed a turn. - PreTool
Decision - A
PreToolUsedecision (wrap-style intercept). ARewritere-enters the normal permission gate with the new input — a hook cannot launder a call past the y/N ask (SECURITY.md §M5 routing rows). - Stop
Decision - A
Stophook’s veto decision (tech-debt #10’s node-vs-wrap pin:Stopis node-style — it returns a decision, never wraps the branch itself).
Constants§
- ADDITIONAL_
CONTEXT_ CAP - One
additionalContextitem per commit point (Innovation #7): concatenate every non-empty hook result into a single string, capped to the Claude schema’s ~10K-char shape, orNoneif nothing was returned. - CAP_
MARK - Appended to a field
cap_tool_inputclipped, so a hook can tell a capped view from a genuinely short value. Fixed text, never interpolated: a hook that echoes the view back must produce bytesrestore_cappedrecognizes. - HOOK_
CALL_ TIMEOUT - Wall-clock budget for a blocking hook call — the engine awaits these
because they return context or decisions it needs. A hook that exceeds it is
treated as a no-op (never a grant, never a block), so a crashed or hung hook
can’t stall a turn indefinitely. Shell hooks bound each subprocess more
tightly (
shell_hooks::HOOK_TIMEOUT_SECS); this is the outer net. - HOOK_
PAYLOAD_ CAP - Default cap on the bytes of a tool result a hook is shown.
- NOTIFICATION_
TIMEOUT - Budget for a background (
on_notification/on_session_end) hook call — generous (it never blocks anything), but bounded, so a “phone home” hook can’t leak a task forever.
Traits§
Functions§
- call_
post_ tool PostToolUseunder the timeout and the cancel token, with the payload cap applied HERE (pin #1) rather than trusted to each impl. Both fallbacks areNone: on a timeout or an interrupt the model sees the tool’s real output, never a half-applied proposal.- call_
pre_ tool PreToolUseunder the timeout and the turn’s cancel token. Both fall back toContinue: a no-op is not a grant — the call still faces the full permission gate (deny rules, protected paths, the human).- call_
session_ end SessionEnd: run AWAITED, under its own bounded timeout, at actor shutdown — NOT detached (Finding 1 fix). By definition nothing needs the actor responsive once it’s already shutting down for good, so blocking here is correct, and it’s the only way to guarantee the hook fires: a detached task here would race the same runtime-drop that silently killednotify’s old detached shape in the one-shot CLI path. The caller (actor::run) is itself a spawned task the CLI now awaits to completion before returning (SessionHandle::finish), so awaiting here — rather than spawning — is what makes that wait actually cover the hook call.- call_
stop - Await
Hooks::on_stopunderHOOK_CALL_TIMEOUT; a timeout behaves exactly likeAllow— a hung hook can never wedge a turn (it’s a no-op, not a block). - call_
user_ prompt - Await
Hooks::on_user_promptunderHOOK_CALL_TIMEOUT; a timeout behaves exactly likeNone— no context, never a crash. - cap_
payload - Clip a payload to the hook cap on a char boundary (never mid-UTF-8).
- cap_
tool_ input - The capped hook view of a tool input: every top-level string field over
HOOK_PAYLOAD_CAPis clipped and marked. Nested values are left alone — tool inputs are flat by convention, and a hook that needs more should read the file itself. - join_
additional_ context - mask_of
- Read the live §S1 mask through a handle — the one read discipline every
holder of a mask atomic (
SharedDeps::hook_mask,question_sink’s local copy,ShellHooks::event_mask) shares, so there is exactly one place that picks theOrdering. - merge_
pre_ tool - Deterministic most-restrictive merge over
pre_toolresults collected from every matching hook (Innovation #1):DenybeatsRewritebeatsContinue.resultsis in registration order — the caller collects matching hooks in a plain loop (T3-10 removed thejoin_allthat implied a concurrency these synchronous handlers never had), so a tie among same-severity decisions always resolves to the first-registered hook, never a race. Exposed (not just used internally byInProcessHooks) so lane 2 (the shell adapter) shares the exact same merge discipline instead of re-implementing it. INVARIANT: ties resolve by registration order, never by completion order. Enforced byties_among_same_severity_decisions_resolve_by_registration_orderandin_process_hooks_run_in_registration_order. - merge_
stop - Deterministic most-restrictive merge over
Stopresults: anyBlockwins, first-registered among ties — the same discipline asmerge_pre_tool, exposed for the shell adapter to share. - notify
Notification(tier-1 gap #7, thehotl watch/desktop seam): spawnon_notificationdetached, under its own timeout, and return immediately — the caller MUST NOT.awaitthis. A 2s (or hung) notifier must never stall the turn or the actor loop that calls it (Concurrency & parallelism §“Background (fire-and-forget) hooks”). The spawned handle is tracked indrain(Finding 1) so a caller about to tear down its runtime can wait for it first instead of silently killing it mid-flight.- restore_
capped - Undo
cap_tool_inputfor every field the hook handed back byte-identical to the capped form, so capping can never truncate what actually executes. A field the hook genuinely rewrote does not match the capped form, so it is left exactly as the hook wrote it. INVARIANT: the executed input differs from the model’s only where a hook deliberately rewrote it. Enforced bycapping_a_tool_input_is_reversible_when_the_hook_echoes_it_back,a_deliberate_rewrite_of_a_capped_field_still_wins, andthe_hook_sees_a_capped_input_but_the_tool_gets_the_full_one.