{"slug": "reading-agent-status-out-of-claude-code-s-hooks", "title": "Reading agent status out of Claude Code's hooks", "summary": "Claude Code 2.1.220's hooks do not cleanly map to agent status, so a developer still relies on screen scraping for three edge cases: Notification events include non-blocking types like idle prompts, a question at the end of a turn triggers Stop but leaves the session waiting, and pressing Esc during a turn leaves the status stuck on 'working'.", "body_md": "Writing\n\n# Reading agent status out of Claude Code's hooks\n\nI wanted a list of running agents that told me which one is working, which is blocked, and which is done, without me looking at any of them.\n\nThe obvious way is to read the screen. Capture the pane, run some regexes, guess. That works for any CLI, which is why I still do it for every tool I support. But it is guessing. A spinner drops for one frame and the agent looks idle. Something prints a line that looks like a spinner and it looks busy.\n\nClaude Code has hooks, so for those sessions the guessing should not be necessary. Mostly it is not. This post is about the places where the event stream does not map cleanly onto a status, and the three gaps I still cover with the pane. Everything below is against Claude Code 2.1.220.\n\n## The setup[#](#setup)\n\nEach managed session starts with a generated settings file (`--settings`\n\n) and one\nenv var pointing at a status file:\n\n```\nAGENT_MANAGER_STATUS_FILE=/…/hooks/<session-id>.status\n```\n\nEvery hook is a one liner that writes a word into that file:\n\n```\n[ -z \"$AGENT_MANAGER_STATUS_FILE\" ] || printf working > \"$AGENT_MANAGER_STATUS_FILE\"\n```\n\nThe guard matters. If Claude 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 will break someone else's agent.\n\n| Event | Writes |\n|---|---|\n`UserPromptSubmit` | working |\n`PreToolUse` , `PostToolUse` | working |\n`Notification` | waiting (see below) |\n`Stop` | finished |\n`SessionStart` | idle |\n`SessionEnd` | deletes the file |\n\nA poller reads the file every two seconds by default. For a normal turn that is enough and you never need the regexes. Then the edges show up.\n\n## Notification is not one thing[#](#notification)\n\nIt fires for a permission prompt. It also fires for the idle nudge on a quiet input box\n(`idle_prompt`\n\n), for auth success, for MCP elicitation, and when an agent finishes.\nOnly some of those mean you are stuck.\n\nThe two I care about are a permission dialog and an MCP elicitation form:\n`permission_prompt`\n\nand `elicitation_dialog`\n\n.\n\nI learned this the hard way. I took `Notification`\n\nat face value, and every session\nI walked away from eventually claimed to be blocked because of the idle nudge. First fix was\ngrepping stdin for English:\n\n```\n[ -z \"$AGENT_MANAGER_STATUS_FILE\" ] || grep -q \"waiting for your input\" \\\n  || printf waiting > \"$AGENT_MANAGER_STATUS_FILE\"\n```\n\nThat works. It is also the wrong layer. `Notification`\n\nhas a matcher, so you can\nsubscribe to the blocking types and never see the rest:\n\n```\n\"Notification\": [{ \"matcher\": \"permission_prompt|elicitation_dialog\", \"hooks\": [ … ] }]\n```\n\nFull list from the docs: `permission_prompt`\n\n, `idle_prompt`\n\n,\n`auth_success`\n\n, `elicitation_dialog`\n\n,\n`elicitation_complete`\n\n, `elicitation_response`\n\n,\n`agent_needs_input`\n\n, `agent_completed`\n\n. Grepping English is matching a\nstring someone will reword. The matcher is the same decision by name. I do not match\n`agent_needs_input`\n\nright now. That is another wait type if you care about the\nagent view.\n\n## A question looks like a finished turn[#](#question)\n\nIf the agent ends with \"should I also update the tests?\", the event stream treats that as a\ncompleted turn. `Stop`\n\nfires. Nothing on the event says it ended on a question. The\nlist shows finished on a session that will sit there forever waiting for a one word answer.\n\nThe text is not gone. `Stop`\n\nhas `last_assistant_message`\n\n, and that is\nthe field you want if you care about the prose. The transcript path is async and can lag, so\nreading the transcript at `Stop`\n\ntime can miss the message you just got.\n`MessageDisplay`\n\nalso fires while assistant text streams.\n\nMy hooks do not parse any of that. They only `printf`\n\na status word. The pane still\ndecides whether the text was a question. Same guess either way, just a cleaner source if you\nwire it up later.\n\n## Esc leaves you stuck on working[#](#interrupt)\n\nInterrupt a turn and `Stop`\n\ndoes not fire. That is in the docs:\n`Stop`\n\ndoes not run when the stoppage is a user interrupt. There is no separate\ninterrupt event either. Last write was `working`\n\n, so the file keeps saying\n`working`\n\nuntil you type something. None of the hooks I wire fire on Esc.\n\nWrong status while you are not looking is worse than no status. That is exactly when you trust the list.\n\n## Stop is the main loop, not the work[#](#stop)\n\n`Stop`\n\nmeans the main agent stopped responding. Work it started can still run: a\nbackground shell, something queued elsewhere. So the file says finished while the repo under\nreview is still changing.\n\nSubagents got me for a while. They write `working`\n\nvia `PreToolUse`\n\n/\n`PostToolUse`\n\nand never fire the main `Stop`\n\n, so one status file stays\non `working`\n\nafter they finish. They do have `SubagentStart`\n\nand\n`SubagentStop`\n\n, with `agent_id`\n\nand `agent_type`\n\n. One file\nper session still cannot say \"three subagents running, one done\". That needs a richer model,\nnot a missing event.\n\n## SessionStart can fire mid turn[#](#sessionstart)\n\nMatchers: `startup`\n\n, `resume`\n\n, `clear`\n\n,\n`compact`\n\n, `fork`\n\n. Fork is a new session, not the trap. Compact is. It\nfires `SessionStart`\n\nin the middle of an active turn, so a bare handler will write\n`idle`\n\nover a session that is still working. I exclude compact:\n\n```\n\"SessionStart\": [{ \"matcher\": \"startup|resume|clear\", \"hooks\": [ … ] }]\n```\n\n## A crash skips cleanup[#](#crash)\n\n`SessionEnd`\n\ndeletes the status file. Its reasons are all orderly:\n`clear`\n\n, `resume`\n\n, `logout`\n\n,\n`prompt_input_exit`\n\n, `bypass_permissions_disabled`\n\n,\n`other`\n\n. Crash or `SIGKILL`\n\nruns none of that. The file outlives the\nprocess and keeps saying `working`\n\nfor a dead agent.\n\nSo the process has to be checked on its own, and a status file with no agent behind it has to go.\n\n## What I actually ship[#](#design)\n\nHooks are the first source. The pane is still read every poll to correct them. If the hook says finished and the pane shows a question, an error, or ongoing work, the pane wins. If the hook says working and the pane shows the turn already ended, the pane wins.\n\n```\nswitch hookStatus {\ncase status.Finished:\n\tif matched && (paneStatus == status.Waiting || paneStatus == status.Errored || paneStatus == status.Working) {\n\t\treturn paneStatus\n\t}\ncase status.Working:\n\tif matched && (paneStatus == status.Waiting || paneStatus == status.Finished || paneStatus == status.Errored) {\n\t\treturn paneStatus\n\t}\n}\nreturn hookStatus\n```\n\nFinished and waiting upgrades wait for turn end signals on the pane. A working pane can still override a finished hook when the screen shows work, on purpose.\n\nI expected hooks to replace screen scraping. They made it accurate instead. The API has grown matchers and fields that cover cases I used to scrape. What is still on the pane side is the interrupt, the crashed process, and deciding whether a paragraph was a question. Neither source alone is enough for a list you trust while you look away.\n\n**The code**\n\n[internal/hooks](https://github.com/YoanWai/agent-manager/tree/main/internal/hooks)builds the settings file and reads the status files.\n\n`internal/ui/poller.go`\n\ndoes the merge. Both live in\n[agent-manager](https://github.com/YoanWai/agent-manager).", "url": "https://wpnews.pro/news/reading-agent-status-out-of-claude-code-s-hooks", "canonical_source": "https://agent-manager.dev/writing/claude-code-hooks/", "published_at": "2026-08-03 14:42:53+00:00", "updated_at": "2026-08-03 14:52:59.597533+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents"], "entities": ["Claude Code"], "alternates": {"html": "https://wpnews.pro/news/reading-agent-status-out-of-claude-code-s-hooks", "markdown": "https://wpnews.pro/news/reading-agent-status-out-of-claude-code-s-hooks.md", "text": "https://wpnews.pro/news/reading-agent-status-out-of-claude-code-s-hooks.txt", "jsonld": "https://wpnews.pro/news/reading-agent-status-out-of-claude-code-s-hooks.jsonld"}}