Skip to main content

Module hooks

Module hooks 

Source
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§

EventMask
One bit per Hooks event 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 whose Hooks impl doesn’t register a given event pays exactly one branch on a cached u8 for it instead (§S1 HookRouter diet).
InProcessHooks
Lane 1 — in-process Rust hooks: the compiled-in registry that the shell adapter (lane 2) and any future third-party lane register against.
NotificationDrain
A session-scoped registry of in-flight detached notify task handles (Finding 1 fix, the Critical review caught in the real CLI): notify pushes the JoinHandle it spawns here instead of discarding it, so a caller that’s about to tear down its runtime — the one-shot CLI’s current_thread block_on, which drops the instant its driving future resolves — can drain them first, under a short bounded grace period. Without this, a detached tokio::spawn awaiting a real subprocess (a notification shell hook) is silently killed mid-flight the moment block_on returns: nothing else is left polling it, unlike the long-lived TUI/interactive path (or a #[tokio::test] that keeps polling events.recv() in a loop) where the runtime stays alive on its own. Cloning shares the same underlying registry (an Arc), so notify’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). All fires for every tool (the pre-matcher default); Names fires only for an exact (case-sensitive) name match.
NotificationKind
The Notification hook’s kind (tier-1 gap #7, the hotl watch/desktop seam): the agent blocked on a human, went idle awaiting a prompt, or completed a turn.
PreToolDecision
A PreToolUse decision (wrap-style intercept). A Rewrite re-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).
StopDecision
A Stop hook’s veto decision (tech-debt #10’s node-vs-wrap pin: Stop is node-style — it returns a decision, never wraps the branch itself).

Constants§

ADDITIONAL_CONTEXT_CAP
One additionalContext item per commit point (Innovation #7): concatenate every non-empty hook result into a single string, capped to the Claude schema’s ~10K-char shape, or None if nothing was returned.
CAP_MARK
Appended to a field cap_tool_input clipped, 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 bytes restore_capped recognizes.
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§

Hooks

Functions§

call_post_tool
PostToolUse under the timeout and the cancel token, with the payload cap applied HERE (pin #1) rather than trusted to each impl. Both fallbacks are None: on a timeout or an interrupt the model sees the tool’s real output, never a half-applied proposal.
call_pre_tool
PreToolUse under the timeout and the turn’s cancel token. Both fall back to Continue: 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 killed notify’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_stop under HOOK_CALL_TIMEOUT; a timeout behaves exactly like Allow — a hung hook can never wedge a turn (it’s a no-op, not a block).
call_user_prompt
Await Hooks::on_user_prompt under HOOK_CALL_TIMEOUT; a timeout behaves exactly like None — 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_CAP is 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 the Ordering.
merge_pre_tool
Deterministic most-restrictive merge over pre_tool results collected from every matching hook (Innovation #1): Deny beats Rewrite beats Continue. results is in registration order — the caller collects matching hooks in a plain loop (T3-10 removed the join_all that 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 by InProcessHooks) 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 by ties_among_same_severity_decisions_resolve_by_registration_order and in_process_hooks_run_in_registration_order.
merge_stop
Deterministic most-restrictive merge over Stop results: any Block wins, first-registered among ties — the same discipline as merge_pre_tool, exposed for the shell adapter to share.
notify
Notification (tier-1 gap #7, the hotl watch/desktop seam): spawn on_notification detached, under its own timeout, and return immediately — the caller MUST NOT .await this. 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 in drain (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_input for 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 by capping_a_tool_input_is_reversible_when_the_hook_echoes_it_back, a_deliberate_rewrite_of_a_capped_field_still_wins, and the_hook_sees_a_capped_input_but_the_tool_gets_the_full_one.