How coding-agent command hooks handle their event payload on stdin, what breaks, and who is at fault. Measured 2026-08-17 while chasing a codex tool hook error.

The failure

Every command-hook agent (claude, codex, agy) writes its event JSON to the hook’s stdin. A hook that exits without reading it, or that stops reading at a head -c cap, closes the read end while the writer is still in write(2).

  • Payload under the 64K pipe buffer: the write lands in the buffer, nobody notices. This is why it can survive for months.
  • Payload over it: the writer blocks, the hook exits, the writer takes EPIPE.
  • It surfaces on PostToolUse first, the event carrying tool output.

Who tolerates it

callerbehaviour
gittolerates. Measured: pre-push with 700 refs writes 125,898 bytes (confirmed by a control hook that counts them); a hook ignoring stdin entirely still leaves git push at exit 0 with clean stderr.
Claude Codetolerates. No EPIPE / broken-pipe / stdin-write error anywhere in the debug corpus, against a control proving hook failures are logged there (Hook Stop (Stop) error:).
codex 0.147reports it: failed to write hook stdin: (string present in the binary next to failed to serialize post tool use hook input:).

Both callers receive the same EPIPE. Rust’s std and Node both ignore SIGPIPE, so the write returns an error rather than killing the process; codex propagates it, git and Claude swallow it.

So this is arguably codex’s defect. Exiting before reading is legitimate hook behaviour, and a hook that decides entirely on argv is the textbook case. Neither the Claude Code hooks docs nor githooks(5) state any obligation to consume stdin, document a size limit, or define EPIPE semantics. The contract is unspecified, and codex is the strictest reading of it.

The fix that generalises

Not a per-hook fix. The hooks that need it most are the ones with nothing to parse (tmux-agent-state takes state on argv; direnv-load reads no input), so they have no natural reason to touch stdin and no author will remember.

Put it in the wrapper. mkBashBin’s hookStdin prologue consumes stdin to EOF and re-serves it to the body, and mkHook turns it on by default. A cap then bounds only what the body is handed, never what is read, which is what stops a memory bound from doubling as an EPIPE.

Two constraints follow:

  • Every caller must reach EOF. Node’s execFile hands the child a stdin pipe it never writes to and never closes, so a blocking drain waits there for the life of the parent. Use spawn(bin, args, {stdio: "ignore"}); execFile silently ignores an stdio option. spawn also needs an error listener or an unhandled error throws.
  • A terminal stdin never reaches EOF, so guard with [ -t 0 ].

Enforcement is a build-time proof, not a lint and not a unit test. See A Build Proof Must Be a Build Input. ShellCheck 0.11 says nothing even with --enable=all, and no plausible rule could: the obligation lives in the caller, and exiting early on a pipe is the same construct as head -1.

Why not types

The property is “must consume”, which is linearity, not shape. Rust is affine (values may be dropped, Drop is implicit) and has no MustMove in std::marker. Austral does enforce it: “a value of a linear type must be used once and only once. Not can: must.” Linear Haskell has linearity on the arrow but exceptions are an acknowledged gap.

None of it reaches this problem anyway. A hook protocol’s whole value is “any executable, any language”, so the peer is untyped by construction. Session types are the right discipline for protocol conformance and have real implementations, but the literature’s own answer for a participant you do not control is runtime monitoring. Types check a program; this needs a contract on a stranger’s.

Discriminator

Feed the hook 1K and 200K over a real pipe and compare the writer’s status (141 = SIGPIPE). The hook exits 0 either way, so asserting on the hook proves nothing.

Landed in the dotfiles as c057425 (per-hook fix) and f80269c (wrapper + proof). See 2026-08-17 A Hook That Never Reads Stdin Breaks the Pipe.