2026-08-17 A Hook That Never Reads Stdin Breaks the Pipe

What I set out to do

Chase a hunch: something wrong with the tmux agent-state hook “when passed over buffer pipes”. The only concrete symptom was a half-remembered tool hook error from Codex.

What I actually did

Wrong first guess. “Buffer pipes” pointed me at send-paste-buffer-to-claude.sh and its save-buffer / paste-buffer path, and I built a menu of candidate symptoms around it. The answer came back as the Codex error instead, which re-aimed everything at the hook itself.

tmux-agent-state.sh takes <agent> <state> on argv and deliberately never parses stdin JSON. That argv contract is what lets one binary serve claude, codex, agy and pi with four different event vocabularies. But Codex, like Claude, still writes the event payload to the hook’s stdin. Never reading it means the hook exits, the read end closes, and the writer takes EPIPE. strings on the Codex binary has the matching error verbatim: failed to write hook stdin:.

Reproduced it as a clean discriminator (same hook, same pipe, two payload sizes): 1K writes fine, 200K gets BrokenPipeError. That is the 64K pipe buffer. Under the buffer the write vanishes into the kernel and nobody notices; over it the writer blocks in write(2) and loses the race. PostToolUse is the event carrying tool output, so it is the one that trips first, hence tool hook error.

Fix is one line, [ -t 0 ] || cat >/dev/null, placed first so it precedes even the AGENT_DISABLE_* opt-outs (those exit soonest and lose the race most reliably), plus pkgs.coreutils in runtimeInputs because resholve gates that. Test written first, confirmed red at 141, green after.

Then the drain’s own cost: it makes “stdin reaches EOF” a requirement on every caller. Tested pi’s, and node’s execFile hands the child a stdin pipe it never writes to and never closes. The drain would have hung there for the session. execFile also silently ignores an stdio option, so the fix was switching to spawn(..., {stdio: "ignore"}) with an error listener.

just check green, hm switch applied, and the deployed store path verified against 200K and 2MB payloads.

What was striking

The bug was invisible for the whole life of the script because small payloads fit in the pipe buffer. It needed a size threshold, not a code path, to show up. That is the sort of thing that reads as flaky rather than broken.

Also: fixing it created a new constraint rather than just removing a defect. Draining stdin is only safe if every caller closes stdin, and one of the four didn’t. A fix whose correctness depends on callers you also own is a fix you have to go audit.

Then swept the same defect through claude/hooks/neovim-*.sh, which had it twice over: they read stdin behind their CLAUDE_FROM_NEOVIM / disable gates, and then only up to head -c 100000 (neovim-session-binder.sh at 10000). Read moves above the gates, drain appended, so the cap now bounds what is held in memory rather than what is read.

Writing those tests, my first harness silently didn’t test anything: NVIM=... "$@" bash "$HOOK" with "$@" holding CLAUDE_FROM_NEOVIM=1. Bash only recognises assignments written literally in the command position, so the expanded word was taken as the command name. The hook never ran, and the test failed for the right-looking reason with the wrong cause. env "$@" fixes it. Caught it because the one test that passed was the one that already used env.

Confirmed the suites discriminate by reverting all three hooks and re-running: three reds at 141, green again after restore. Committed as c057425.

Then went looking for what could have caught it. Nothing lintable: ShellCheck 0.11 with --enable=all says nothing, and no rule could, since exiting early on a pipe is the same construct as head -1. So I researched three angles instead.

Prior art reframed the whole thing. Built a throwaway repo with 700 branches and a pre-push hook that ignores stdin: git writes 125,898 bytes (a control hook counted them) and still exits 0 with clean stderr. Claude Code tolerates it too, with a control proving hook failures do reach the debug log. Codex is the only caller that reports it. So the defect is arguably codex’s, and my fix is the strictest caller’s rule applied to everyone.

The Nix leg produced the more reusable finding. passthru.tests is eval-only: pkgs.hello declares two and neither is in the inputDrvs of its own build, so building never runs them. The repo’s existing resholveVerified trick is the strong form, and I confirmed it at the .drv level. So stdinDrained reuses that rather than passthru.tests, which was my first instinct and would have been strictly weaker.

Types were a dead end for good reasons. “Must consume” is linearity, and Rust is affine with no MustMove in std::marker (a search result claiming Rust 1.95 shipped one is fabricated, which is worth remembering). Austral enforces it properly; Linear Haskell has an acknowledged exceptions gap. None of it reaches a protocol whose peer is any executable in any language.

Built the two things that do work. mkBashBin grows hookStdin: a prologue drains stdin and re-serves it, so the caps move from the bodies into the wrapper and bound what the body is handed rather than what is read. And hookStdinCheck binds a 300K probe to the artifact through a derivation attribute. Verified it is load-bearing by breaking the drain: the check fails with writer exited 141 and nix then refuses to build the hook. Nine hooks now carry it, including direnv-load, which had the same defect and which no per-hook sweep would have found. f80269c.

The arXiv MCP server timed out on every call including a control, so the type-theory leg ran on web sources rather than the paper tools.

Continues the agent-state thread from 2026-08-03 Edge-Triggered Agent State Has No Way to Retire a Wait, 2026-07-28 Codex Hook Trust and the Unkeyed Model Cache and 2026-07-27 Cross-Agent Detection and Waiting State in Tmux.

Captured as Agent Hook Stdin Contract and A Build Proof Must Be a Build Input.