---
title: "Reading Claude Code's hooks for live session status"
description: "Turning Claude Code hook events into the colour on a session row."
canonical: "https://agent-manager.dev/writing/claude-code-hooks/"
last-updated: "2026-08-31"
section: "Writing"
source: "agent-manager"
---

# 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.

| 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.

`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`

```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`

```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](https://github.com/YoanWai/agent-manager/tree/main/internal/hooks) generates the settings file and reads the status files; `internal/ui/poller.go` holds the reconciliation. Both are in [agent-manager](https://github.com/YoanWai/agent-manager), a tmux TUI for running Claude Code, Codex and OpenCode side by side. Go, Apache-2.0.
