agent-manager

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.

environment
AGENT_MANAGER_STATUS_FILE=/…/hooks/<session-id>.status

Every hook is a one-line shell command that writes a word to that file.

hook command
[ -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.

EventWrites
UserPromptSubmitworking
PreToolUse, PostToolUseworking
Notificationwaiting, conditionally
Stopfinished
SessionStartidle
SessionEnddeletes 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.

Notification hook
[ -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.

claude-settings.json
"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.

internal/ui/poller.go
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.

The code internal/hooks generates the settings file and reads the status files; 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.