Summary
check-inbox.sh reads its hook payload with an unbounded cat. When the host hands it a stdin that is not a TTY but whose write end is never closed, that cat waits for an EOF that never comes and the hook never exits.
Stop/turn hooks are synchronous — the agent cannot finish its turn until the hook process exits. So a hook that never returns does not just miss a message: it freezes the agent's pane, spinner stuck, keyboard dead, until the user kills it.
The existing [ ! -t 0 ] guard (from #64, for Gemini's PostToolUse inheriting a terminal stdin) only covers the TTY case. A non-TTY pipe that stays open slips straight past it.
https://github.com/fujibee/agmsg/blob/main/scripts/check-inbox.sh#L31-L37
INPUT=""
if [ ! -t 0 ]; then
INPUT=$(cat 2>/dev/null || true) # <-- waits for EOF forever
fi
Evidence (from a real session)
On Windows I found 11 cat.exe and 40 bash.exe processes alive for over 6 hours, all of them check-inbox.sh invocations. Each cat's start time matched its check-inbox.sh parent's start time exactly — they had been blocked on stdin since the moment they were spawned.
The agent panes those hooks belonged to were frozen: TUI stopped repainting, keystrokes ignored, ~0% CPU.
This also compounds a known upstream failure mode. In openai/codex#20862 a hook exited cleanly but a surviving descendant kept the inherited stdout handle open, so Codex waited for an EOF that never arrived. Hung cat/bash descendants of a hook are exactly that shape.
Reproduction
A sleep N | check-inbox.sh pipeline does not work as a repro — bash waits for every pipeline member, so it looks like a hang even when the hook exits immediately. Use a FIFO so only the hook is timed:
fifo=$(mktemp -u); mkfifo "$fifo"
# write the hook JSON, then hold the write end open without closing it
{ printf '%s' '{"stop_hook_active":false,"session_id":"repro"}'; sleep 300; } > "$fifo" &
time timeout 60 bash scripts/check-inbox.sh cursor /path/to/project < "$fifo"
# -> never returns; killed at the 60s cap. `cat` is left running.
Observed: hangs indefinitely.
Expected: reads the payload, delivers (or skips), exits.
Suggested fix
Two layers — bound the read, then bound the whole hook as a backstop. Both fail open: skipping one delivery is recoverable, freezing the user's pane is not.
1. Bound the stdin read
INPUT=""
if [ ! -t 0 ]; then
if command -v timeout >/dev/null 2>&1; then
INPUT=$(timeout "${AGMSG_STDIN_TIMEOUT:-2}" cat 2>/dev/null || true)
else
INPUT=$(cat 2>/dev/null || true)
fi
fi
The payload is already in the command substitution by the time timeout fires, so a runtime that writes the JSON and then simply forgets to close the pipe still delivers correctly — it just costs 2s instead of hanging forever.
2. Hard deadline on the hook itself, so any other unbounded wait (a wedged sqlite3, a lock held by a dead writer) can't wedge a turn either. Re-exec under timeout near the top of the script:
if [ -z "${AGMSG_HOOK_GUARDED:-}" ] && command -v timeout >/dev/null 2>&1; then
export AGMSG_HOOK_GUARDED=1
rc=0
timeout --kill-after=5 "${AGMSG_HOOK_DEADLINE:-30}" "$0" "$@" || rc=$?
if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then
echo "check-inbox: exceeded ${AGMSG_HOOK_DEADLINE:-30}s deadline; skipping delivery this turn" >&2
exit 0
fi
exit "$rc"
fi
With both applied, the FIFO repro above exits in ~9s instead of hanging, and no cat is left behind.
The same unbounded-stdin shape exists in any custom Node/Python Stop-hook driver that does for await (const chunk of process.stdin) / sys.stdin.read(), so it may be worth calling out in the hook-authoring docs as well.
Which runtimes hit this
I hit it with check-inbox.sh invoked from Cursor (via the .cursor/rules/*.mdc "run check-inbox after tool calls" integration, so it inherits the agent's shell command runner's stdin rather than a hooks-contract pipe). Any runtime that hands the hook an open, non-TTY stdin will do it. I could not find an existing issue for the non-TTY case — #64 covers only the TTY one.
Environment
- Windows 11, Git Bash (
C:\Program Files\Git), bash 5.x
- codex-cli 0.144.1, Cursor, Claude Code
- WezTerm 20240203-110809-5046fc22 (ruled out — it faithfully retained the frozen pane's contents; the agent process had stopped writing)
Summary
check-inbox.shreads its hook payload with an unboundedcat. When the host hands it a stdin that is not a TTY but whose write end is never closed, thatcatwaits for an EOF that never comes and the hook never exits.Stop/turn hooks are synchronous — the agent cannot finish its turn until the hook process exits. So a hook that never returns does not just miss a message: it freezes the agent's pane, spinner stuck, keyboard dead, until the user kills it.
The existing
[ ! -t 0 ]guard (from #64, for Gemini's PostToolUse inheriting a terminal stdin) only covers the TTY case. A non-TTY pipe that stays open slips straight past it.https://github.com/fujibee/agmsg/blob/main/scripts/check-inbox.sh#L31-L37
Evidence (from a real session)
On Windows I found 11
cat.exeand 40bash.exeprocesses alive for over 6 hours, all of themcheck-inbox.shinvocations. Eachcat's start time matched itscheck-inbox.shparent's start time exactly — they had been blocked on stdin since the moment they were spawned.The agent panes those hooks belonged to were frozen: TUI stopped repainting, keystrokes ignored, ~0% CPU.
This also compounds a known upstream failure mode. In openai/codex#20862 a hook exited cleanly but a surviving descendant kept the inherited stdout handle open, so Codex waited for an EOF that never arrived. Hung
cat/bashdescendants of a hook are exactly that shape.Reproduction
A
sleep N | check-inbox.shpipeline does not work as a repro — bash waits for every pipeline member, so it looks like a hang even when the hook exits immediately. Use a FIFO so only the hook is timed:Observed: hangs indefinitely.
Expected: reads the payload, delivers (or skips), exits.
Suggested fix
Two layers — bound the read, then bound the whole hook as a backstop. Both fail open: skipping one delivery is recoverable, freezing the user's pane is not.
1. Bound the stdin read
The payload is already in the command substitution by the time
timeoutfires, so a runtime that writes the JSON and then simply forgets to close the pipe still delivers correctly — it just costs 2s instead of hanging forever.2. Hard deadline on the hook itself, so any other unbounded wait (a wedged
sqlite3, a lock held by a dead writer) can't wedge a turn either. Re-exec undertimeoutnear the top of the script:With both applied, the FIFO repro above exits in ~9s instead of hanging, and no
catis left behind.The same unbounded-stdin shape exists in any custom Node/Python Stop-hook driver that does
for await (const chunk of process.stdin)/sys.stdin.read(), so it may be worth calling out in the hook-authoring docs as well.Which runtimes hit this
I hit it with
check-inbox.shinvoked from Cursor (via the.cursor/rules/*.mdc"run check-inbox after tool calls" integration, so it inherits the agent's shell command runner's stdin rather than a hooks-contract pipe). Any runtime that hands the hook an open, non-TTY stdin will do it. I could not find an existing issue for the non-TTY case — #64 covers only the TTY one.Environment
C:\Program Files\Git), bash 5.x