EN JA
Free download

Tracking Claude Code Session Status with Hooks

Updated August 26, 2026

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:

EventFires when
SessionStartA session starts or resumes
UserPromptSubmitYou submit a prompt
PreToolUse / PostToolUseBefore / after each tool call
NotificationClaude needs your attention (permission prompt, idle reminder…)
StopThe main agent finishes its response
SubagentStopA subagent (Task) finishes
SessionEndThe 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:

EventNew stateWhy
SessionStartidleNothing asked yet
UserPromptSubmitrunningYou could type, so no dialog was blocking; a turn begins
PreToolUse, PostToolUserunningTool activity = work in progress
PreToolUse for AskUserQuestion / ExitPlanModewaitingThese tools immediately block on your answer (a choice / a plan approval)
Notification, type permission_promptwaitingA tool-permission dialog is sitting unanswered
Notification, type idle_promptdoneIdle reminder after a finished turn — not a new question
StopdoneTurn 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).

Why so much care? The failure directions aren't symmetric. A stale "waiting" wastes a glance; a missed "waiting" wastes the whole stall — the exact thing you built the tracker to prevent. When in doubt, err toward showing waiting.

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.

macOS 13+ — free plan shows up to 2 sessions at once