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.jsonfiles; the USER-level file is~/.cursor/hooks.json(the project-level<root>/.cursor/hooks.jsonis 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-statuscommand. - Documented agent lifecycle events include
sessionStart,stop({status, loop_count}),sessionEnd({reason: completed|aborted| error|window_close|user_close, error_message?}), andpostToolUseFailure({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
- Config opt-in (
hookStatusin 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>.jsonfile), and seed the session spec (crate::backend::SessionSpec::hook_status). - The cursor backend installs the session’s hook config AT SPAWN: a
hooks.jsonin 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 atkranz hook-status --config <scratch>/hook-status/spec.json, plus that spec file (endpoint, token, mission/run ids). - The cursor CLI fires a lifecycle hook →
kranz hook-statusreads the payload on stdin (bounded), maps it to aHookSignal(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. - 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. GET /api/missions/:id/hook-statusre-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§
- Hook
Status Seed - 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. - Hook
Status Spec - Everything
kranz hook-statusneeds 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). - RunHook
Status - 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.
- RunHook
Status View - The public (tokenless) view of one run’s entry, served by
GET /api/missions/:id/hook-statusand rendered by dashboard/Slack. - Signal
Post - The JSON body
kranz hook-statusPOSTs (and the endpoint consumes). Kept in the engine so the relay and the server share one wire shape. - Signal
Record - One accepted signal occurrence.
Enums§
- Hook
Signal - 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. - Record
Rejection - 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 idsper 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-statusreads 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.jsoncontent wiring the mapped lifecycle events tocommand. 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 onesmap_cursor_hookconsumes; 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 carrieshook-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_homeMUST 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
hookStatusresolved to “install or not, and where to”. Defined here (not intypes.rs) soMissionConfigstays POD — seecrate::types::HookStatusConfig. - signal_
post_ for - Build the POST body for one mapped payload (relay side).
Nonewhen 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.