Writing
What Claude Code's hooks can't tell you
Six things the hook API never reports, each one found the same way: a row in my status list that confidently said the wrong thing.
I wanted one thing from my terminal: a list of running agents where I can see, without looking at any of them, which one is working, which one is blocked, and which one is done.
The obvious way is to read the screen. Capture the pane, run regexes over it, guess. That works with any CLI tool, which is why it is still the fallback for every tool I support. But it is guessing. A spinner disappears for one frame and the agent looks idle. A tool prints something spinner-shaped and it looks busy.
Claude Code has hooks, so for Claude sessions the guessing should be unnecessary. It mostly is. This is about the part that isn't.
The setup
Each managed session launches with a generated settings file passed as
--settings, and gets one environment variable: a path to write status into.
AGENT_MANAGER_STATUS_FILE=/…/hooks/<session-id>.status
Every hook is a one-line shell command that writes a word to that file.
[ -z "$AGENT_MANAGER_STATUS_FILE" ] || printf working > "$AGENT_MANAGER_STATUS_FILE"
The guard matters more than it looks. If Claude Code ever loads that settings file outside a managed session, the variable is unset, the command exits 0, and nothing happens. A hook that can fail is a hook that breaks someone's agent.
| Event | Writes |
|---|---|
UserPromptSubmit | working |
PreToolUse, PostToolUse | working |
Notification | waiting, conditionally |
Stop | finished |
SessionStart | idle |
SessionEnd | deletes the file |
A poller reads that file every two seconds. That is the whole happy path, and for a straightforward turn it is exactly right, with none of the regex guessing. Then you meet the edges.
Notification means two different things
It fires when the agent is blocked on a permission prompt. It also fires after 60 seconds of an idle input box, as a nudge that Claude is waiting for you. Same event, and only the first one means the agent is stuck on something.
Take it at face value and every session you walk away from eventually claims to be blocked. The fix is to read the payload on stdin and drop the reminder.
[ -z "$AGENT_MANAGER_STATUS_FILE" ] || grep -q "waiting for your input" \
|| printf waiting > "$AGENT_MANAGER_STATUS_FILE"
A plain-text question fires nothing
The agent finishing its turn with "should I also update the tests?" is, to the hook API, a
completed turn. Stop fires, the file says finished, and the list
shows a green checkmark on a session that will sit there forever waiting for a one-word
answer.
There is no event for "the model asked you something in prose". The only place that information exists is on screen.
Esc fires nothing either
Interrupt a turn and no Stop arrives. The last thing written was
working, so the file says working, and it will keep saying
working until you type something. A status that is wrong until the user acts is
worse than no status: it is the exact case where you walk away trusting the list.
Stop is about the main agent, not the work
Stop means the main loop stopped responding. Background agents it spawned can
still be grinding. So the file says finished while real work continues, and if
that work writes files you get a "done" row whose repo is still changing under review.
The inverse shows up too. Background subagents write working through
PreToolUse and PostToolUse, and fire no Stop of their
own when they end. Nothing ever clears it. Without a correction, that file stays pinned at
working forever.
SessionStart fires mid-turn
It fires on startup, on resume, on clear, and also on compact. Compact happens in the middle
of an active turn, so an unmatched SessionStart handler will write
idle over a session that is very much working. The matcher has to be explicit.
"SessionStart": [{ "matcher": "startup|resume|clear", "hooks": [ … ] }]
A crash skips your cleanup
SessionEnd deletes the status file. A crash or a SIGKILL does not
run SessionEnd. The file survives its process and keeps confidently reporting
working for an agent that no longer exists. So the process has to be checked
independently, and a status file whose agent is gone has to be deleted rather than trusted.
Where that leaves the design
Hooks are the first-tier source, and the screen is still read on every poll to correct them.
A hook saying finished is upgraded when the pane shows a question, an error, or
ongoing work. A hook saying working is upgraded when the pane shows the turn
already ended.
switch hookStatus {
case status.Finished:
if matched && (paneStatus == status.Waiting || paneStatus == status.Errored || paneStatus == status.Working) {
return paneStatus
}
case status.Working:
if matched && (paneStatus == status.Waiting || paneStatus == status.Finished || paneStatus == status.Errored) {
return paneStatus
}
}
return hookStatus
The pane only reports a resting state once the newest turn has gone quiet, so this never fires mid-stream.
I expected hooks to replace the screen scraping. What they actually did is make it accurate. Hooks know things the screen cannot show, like a tool call that prints nothing, and the screen knows things hooks never fire for, like a question, an interrupt, or an error. Neither one alone gets a status list you can trust while you are not looking at it, which was the entire point.
internal/ui/poller.go holds the reconciliation. Both are in
agent-manager, a tmux TUI for running
Claude Code, Codex and OpenCode side by side. Go, MIT.