Skip to main content

Module hook_status

Module hook_status 

Source
Expand description

Hook-derived status signals: an OPTIONAL, non-authoritative observability lane for CLI backends that expose lifecycle hooks (ticket .kranz/tickets/agent-hooks-status-signals.md; the consumer backend is crate::backend_cursor).

NOT crate::hooks — that module is the D-F webhook triggers the engine EMITS to the operator — and NOT crate::hook_gates, which projects deterministic gates onto Claude Code’s in-session PreToolUse hooks. This module is the third, deliberately smallest hook lane: the backend CLI’s LIFECYCLE hooks (sessionStart, stop, sessionEnd, …) are projected onto a kranz-managed command that reports a coarse signal — “running”, “needs input”, “interrupted”, “turn finished” — so the dashboard and Slack can say SOMETHING honest about a session whose output stream has gone quiet.

§Why signals are never mission state

The event fold is the only source of mission truth. A hook payload is worker-reachable input (the per-session spec file lives in the session-writable scratch root, so a hostile or confused session can POST anything its token allows), and a lifecycle hook can simply never fire (killed process, pre-hooks CLI, crashed endpoint). Neither property is acceptable for state transitions, so this lane is structurally incapable of touching it: signals land ONLY in an ephemeral derived projection under the gitignored runtime dir (.kranz/hook-status/), keyed by (mission_id, run_id), and NO reducer-driving EventKind is added — the module has no EventKind reference at all. A durable additive observability event for hook receipts is an explicitly separate decision (ticket’s persistence constraint), not this lane.

§The verified cursor hook surface (ground truth)

Verified 2026-08-06 against the live docs (https://cursor.com/docs/hooks and https://cursor.com/docs/cli/changelog; the local agent --help prints no hook documentation):

  • Hooks are declared in hooks.json files; the USER-level file is ~/.cursor/hooks.json (the project-level <root>/.cursor/hooks.json is a tracked-tree file this lane NEVER writes — see install hygiene below). Shape: { "version": 1, "hooks": { "<event>": [ { "command": "...", "timeout": 10 } ] } }.
  • Command hooks are spawned processes receiving the payload JSON on STDIN (argv delivery was replaced by stdin in the 2026-05-20 CLI changelog entry) and returning JSON on stdout; exit 0 = ok, exit 2 = block the action, any other code = hook failed and the action proceeds (fail-open). There is NO HTTP/URL hook type — delivery to kranz’s endpoint is done by the installed kranz hook-status command.
  • Documented agent lifecycle events include sessionStart, stop ({status, loop_count}), sessionEnd ({reason: completed|aborted| error|window_close|user_close, error_message?}), and postToolUseFailure ({tool_name, failure_type: timeout|error| permission_denied, error_message}). CLI hook support dates from the January 2026 CLI changelog entry; no docs page names a version floor, so a pre-hooks CLI is a degradation (hooks never fire — the lane says nothing), never an error.

§The lane, end to end

  1. Config opt-in (hookStatus in the mission config, off by default) plus a hook-capable backend ([crate::types::BackendKind:: supports_hook_status_signals] — cursor only today) makes the runner mint a per-run capability token, REGISTER it in the projection store (the gitignored .kranz/hook-status/<mission>/<run>.json file), and seed the session spec (crate::backend::SessionSpec::hook_status).
  2. The cursor backend installs the session’s hook config AT SPAWN: a hooks.json in the SESSION-PRIVATE scratch HOME (<home>/.cursor/ hooks.json, the same per-session seeding channel as the account/ config seed) pointing every mapped lifecycle event at kranz hook-status --config <scratch>/hook-status/spec.json, plus that spec file (endpoint, token, mission/run ids).
  3. The cursor CLI fires a lifecycle hook → kranz hook-status reads the payload on stdin (bounded), maps it to a HookSignal (map_cursor_hook), and POSTs {token, missionId, runId, signal, detail} to the configured loopback endpoint. Every failure exits 0: the lane is observational and must never block or fail a session.
  4. The server endpoint (kranz serve, loopback + capability-token gated — NOT the serve mutation token, which never crosses into a worker-readable file) validates the POST against the registration (constant-time token-hash compare, safe ids, staleness TTL, bounded body) and rewrites the run’s projection entry. Untrusted-payload discipline: path traversal, stale ids, oversized bodies are all rejected, and the endpoint’s ONLY write is the projection file.
  5. GET /api/missions/:id/hook-status re-reads the projection from disk per request (the server’s every-read-is-a-reread idiom); the dashboard renders it beside the pending-decision chrome and Slack appends it to /kranz status, always labelled hook-derived and non-authoritative.

§Install hygiene (non-negotiable, per the ticket)

The lane writes hook config ONLY into the session-private scratch HOME — never the primary checkout’s tracked .cursor/hooks.json, and never as a side effect of kranz serve (the server process writes nothing but projection files under the gitignored runtime dir). A repo-local hook install, if ever offered, must be an explicit operator action producing a reviewable diff — no such action exists today.

Structs§

HookStatusSeed
The runner-side seed carried on the session spec: what the backend needs to install the lane. Backend-neutral (the runner mints it without knowing which backend impl will consume it); backends without a lifecycle-hook surface ignore it exactly like settings_json.
HookStatusSpec
Everything kranz hook-status needs to report one signal, written by the backend at spawn time into the session-private scratch root. The hook command line carries ONLY this file’s path (mirroring the hook-gate lane: no new env or credential channel — the session’s already-cleared env is the whole channel).
RunHookStatus
One run’s projection entry: the registration (who may write) plus the latest signal (what was last heard). The token lives here ONLY as a SHA-256 hash — the cleartext token exists in exactly two places (the runner’s memory and the session’s spec file), so a read of the projection dir yields nothing replayable.
RunHookStatusView
The public (tokenless) view of one run’s entry, served by GET /api/missions/:id/hook-status and rendered by dashboard/Slack.
SignalPost
The JSON body kranz hook-status POSTs (and the endpoint consumes). Kept in the engine so the relay and the server share one wire shape.
SignalRecord
One accepted signal occurrence.

Enums§

HookSignal
One coarse lifecycle signal. This is the COMPLETE vocabulary the lane can express — deliberately much smaller than mission status, so no hook payload can ever spell a state transition (FeatureFailed, Blocked, Complete, grant mutations): the mapping is an enum, not a string.
RecordRejection
Why a signal POST was rejected. The endpoint maps these to statuses that oracle nothing about neighboring runs.

Constants§

REGISTRATION_TTL
How long a registration accepts signals. A run outliving this TTL has its late POSTs rejected as stale (stale ids per the ticket): the registration file is runtime cruft from a run the engine has long reaped, and an unbounded acceptance window would let a leaked token rewrite history forever. 24h is generous for any real run.
SIGNAL_BODY_MAX_BYTES
Max bytes the endpoint accepts for one signal POST (the relay’s body is a handful of small fields; the server’s route-level body limit is set to this same bound).
SPEC_VERSION
Schema version of the per-session spec file (HookStatusSpec) and of the projection entries (RunHookStatus) — both bump together.
STDIN_PAYLOAD_MAX_BYTES
Max bytes of hook payload kranz hook-status reads from stdin. Real cursor lifecycle payloads are a few KB; a boundless read would let a hostile or broken CLI exhaust memory in the relay.

Functions§

cursor_hooks_json
The ~/.cursor/hooks.json content wiring the mapped lifecycle events to command. Pure so the exact wire shape is unit-testable without spawning anything. Uses only the long-stable documented subset — version: 1, per-event [{command, timeout}] handler lists (see module docs for the verified surface). The mapped events are exactly the ones map_cursor_hook consumes; installing MORE would only add hook-spawn overhead for payloads the relay ignores.
endpoint_is_loopback_http
The full signal POST path for a configured base endpoint: callers configure the bare endpoint URL (e.g. http://127.0.0.1:4560/api/ hook-status) verbatim — no path munging here; config validation owns its shape.
hook_status_dir
The repo-level projection dir (gitignored runtime — see crate::paths::KRANZ_GITIGNORE_RULES, which carries hook-status/).
install_cursor_hook_status
Install the lane into one cursor session: write the per-session spec file plus <session_home>/.cursor/hooks.json. session_home MUST be the session-private HOME the child will actually receive (the backend resolves it from the same seeding logic as its env channel) — this is the install-hygiene invariant: hook config only ever lands in the throwaway per-session HOME, never the primary checkout’s tracked tree.
map_cursor_hook
Map one cursor lifecycle-hook payload (the JSON the CLI pipes to the hook command’s stdin) to a signal and an optional human-readable detail. None = the event carries no status meaning for this lane and is IGNORED (malformed or unmapped payloads are always ignorable — the relay treats them as success and never retries).
mint_token
Mint a per-run capability token (uuid v4 simple hex, same idiom as the serve tokens).
read_mission_signals
Read one mission’s projection (most recently registered first, capped at [READ_CAP]). Best-effort per entry: an unreadable/corrupt run file is skipped rather than failing the whole read — the projection is runtime state, and a partial honest answer beats none. Returns an empty vec when the lane was never used for this mission.
record_signal
Record one signal against its registration — the endpoint’s ONLY write. Every untrusted-input check lives here so the route handler stays a thin shell: safe ids, registration present and readable, constant-time token-hash compare, staleness TTL. The detail is scrubbed and bounded before it persists. The rewrite is atomic (tmp + rename) so a reader never observes a torn entry.
register
Register one run’s lane: create the projection entry carrying the token hash. Called by the runner at spec time. Ids are validated before any path is joined; a failure here degrades to NO lane (the caller logs and leaves the spec seed unset) — never to a spawn error.
resolved_endpoint
The engine-side unit of the lane’s config gate: everything the runner needs from hookStatus resolved to “install or not, and where to”. Defined here (not in types.rs) so MissionConfig stays POD — see crate::types::HookStatusConfig.
signal_post_for
Build the POST body for one mapped payload (relay side). None when the payload maps to no signal — the relay then has nothing to send.
spec_file
The backend-written spec file the hook command is pointed at.