Tracking Claude Code Session Status with Hooks
Claude Code's hooks are usually pitched for policy — block this tool, lint that edit. But they're also the only public API that tells you what a session is doing: running, finished, or blocked on a question. This guide shows how to turn hook events into reliable session state, and where the naive version breaks. It's the same foundation our app AgentManager is built on.
Hooks in one minute
A hook is a shell
command Claude Code runs at a lifecycle event, with a JSON payload on stdin
(session_id, hook_event_name, cwd, and per-event fields).
Registration lives in ~/.claude/settings.json (global) or a project's
.claude/settings.json:
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "$HOME/.claude/scripts/on-stop.sh" }
]
}
]
}
}
For status tracking, these are the events that matter:
| Event | Fires when |
|---|---|
SessionStart | A session starts or resumes |
UserPromptSubmit | You submit a prompt |
PreToolUse / PostToolUse | Before / after each tool call |
Notification | Claude needs your attention (permission prompt, idle reminder…) |
Stop | The main agent finishes its response |
SubagentStop | A subagent (Task) finishes |
SessionEnd | The session terminates |
From events to state
Four states cover what you actually want to know: waiting (blocked on you), running (working), done (turn finished), idle (open, nothing happening). A first-pass mapping:
| Event | New state | Why |
|---|---|---|
SessionStart | idle | Nothing asked yet |
UserPromptSubmit | running | You could type, so no dialog was blocking; a turn begins |
PreToolUse, PostToolUse | running | Tool activity = work in progress |
PreToolUse for AskUserQuestion / ExitPlanMode | waiting | These tools immediately block on your answer (a choice / a plan approval) |
Notification, type permission_prompt | waiting | A tool-permission dialog is sitting unanswered |
Notification, type idle_prompt | done | Idle reminder after a finished turn — not a new question |
Stop | done | Turn complete |
SessionEnd | (remove) | Session is gone |
A minimal working implementation
One script, registered for a handful of events, writing one small JSON file per
session_id — then anything (a status bar widget, a tmux segment, a dashboard)
can render the directory. In ~/.claude/settings.json:
{
"hooks": {
"UserPromptSubmit": [
{ "hooks": [ { "type": "command", "command": "$HOME/.claude/track-status.sh running" } ] }
],
"Notification": [
{ "hooks": [ { "type": "command", "command": "$HOME/.claude/track-status.sh waiting" } ] }
],
"Stop": [
{ "hooks": [ { "type": "command", "command": "$HOME/.claude/track-status.sh done" } ] }
],
"SessionEnd": [
{ "hooks": [ { "type": "command", "command": "$HOME/.claude/track-status.sh ended" } ] }
]
}
}
And ~/.claude/track-status.sh (needs jq; make it executable with chmod +x):
#!/bin/bash
# Reads the hook payload on stdin, keeps one status file per session.
STATE="$1"
DIR="$HOME/.claude/session-status"
mkdir -p "$DIR"
PAYLOAD=$(cat)
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id')
CWD=$(echo "$PAYLOAD" | jq -r '.cwd')
if [ "$STATE" = "ended" ]; then
rm -f "$DIR/$SESSION_ID.json"
else
# Write atomically (temp file + mv) so readers never see half a file.
jq -n --arg state "$STATE" --arg cwd "$CWD" \
'{state: $state, cwd: $cwd, updated: now | todate}' \
> "$DIR/$SESSION_ID.json.tmp" && mv "$DIR/$SESSION_ID.json.tmp" "$DIR/$SESSION_ID.json"
fi
Now cat ~/.claude/session-status/*.json shows the live state of every session.
Two design notes baked in above: files are keyed by session_id, not working
directory — two sessions can share a cwd — and writes are atomic. This four-event version
skips the finer waiting rows of the mapping (AskUserQuestion,
ExitPlanMode, idle_prompt); add them once the basics work.
The edge cases that break the naive version
These are the ones that cost real debugging time. Knowing them up front is most of the value of this guide.
1. Approving a permission prompt fires no event
Hooks tell you when a permission dialog appears (Notification) but not
when you answer it. The next event arrives only when the approved tool completes
(PostToolUse) or the turn ends (Stop). Approve a long build and your
tracker shows "waiting" for the whole build. Design for it: treat waiting as
"cleared by the next lifecycle event", and accept that clearing lags — the error is at least
on the safe side (a stale "waiting", never a missed one).
2. Stop doesn't mean everything stopped
Background subagents keep running after the main agent's Stop. If you flip to
done on Stop while a subagent is mid-task, your state lies. Track
subagent lifecycles and only settle to done when the main agent has stopped
and no subagents remain. Also reset that bookkeeping on SessionStart —
it fires on resume too, and stale subagent IDs otherwise pin the state at "running" forever.
3. Ordering: don't let Stop trample waiting
A background subagent hits a permission prompt → Notification says waiting →
the main agent finishes → Stop says done. Pure last-event-wins now hides a
session that is genuinely blocked. The fix is to track why you're waiting (main
agent vs. subagent) and let only the matching resolution clear it, rather than letting any
later event overwrite the flag. The same applies to idle_prompt, which
re-fires periodically while a session sits unattended.
4. Escaped and killed sessions leak files
SessionEnd is not guaranteed — crashes and force-kills skip it, and interrupting
a subagent with ESC skips its SubagentStop. Anything long-lived needs garbage
collection (e.g. drop state files whose owning process is gone).
Build vs. install
Everything above is buildable in an afternoon — for one consumer, in one format, plus the edge-case hardening as you hit each case. That's a fine afternoon if you enjoy it (we clearly did). The alternative:
AgentManager is this pipeline, productized for macOS: its hooks write per-session state files, and a small always-on-top window renders every session with a status lamp — waiting (tagged with what kind of answer: approval / plan / choice), running, done, idle. All the edge cases above are handled — including clearing approved permission prompts the moment you answer — plus the parts that don't fit in a hook script: auto-surfacing the window when a session starts waiting, and click-to-jump to the exact terminal pane. Hook registration is one click and cleanly reversible, and it composes with your own hooks.
Session status, already built
AgentManager turns Claude Code's hook events into a live status board for every session — free to use, no account required.