Skip to main content

Engineering

Detecting and Recovering Crashed Claude Code Sessions

Detecting stale Claude Code session heartbeats and recovering crashed sessions

Claude Code does not ship a heartbeat. It also does not ship a way to tell, from outside the editor, whether a long-running session is mid-thought, paused on a 429, or genuinely dead. The signals are there in ~/.claude/projects/ if you know where to look. Here is what stale really means, how to tell a crash from a clean exit, and what a productized watchdog does that a bash loop cannot.

What "stale heartbeat" actually means

A heartbeat is a process-of-life signal — a marker that something updates at a known cadence. A reader checks the marker; if it has not advanced in longer than a configured threshold, the writer is presumed dead.

Stock Claude Code does not emit a dedicated heartbeat file. It does something close: the session JSONL transcript at ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl is appended to every time the agent takes a turn, every time a tool call fires, and every time a tool result returns. The file's mtime is a heartbeat of last activity. If the mtime is fresh, work is happening. If it is many minutes old, something is wrong — or the agent is genuinely thinking about a hard problem.

The distinction between "thinking" and "dead" is the entire challenge of building a watchdog around Claude Code. A single complex tool call can run for sixty seconds without producing a new record. An auto-compact pass can take longer. A rate-limit pause can hold the file unchanged for the full Retry-After window — sometimes an hour. None of those are crashes. All of them produce a stale mtime by a naive threshold.

The honest answer is that the threshold is workload-dependent. Five minutes is too aggressive — you will get false positives during normal long tool calls. Sixty minutes is too lax — you will miss real crashes by an hour. A working watchdog uses thirty to forty-five minutes as a default and exposes the threshold as configurable.

Reading the signals in `~/.claude`

The forensic data you need is all in the JSONL transcript. A representative end-of-session inspection:

# Locate the active session file
SESSION_DIR=~/.claude/projects/-home-dev-projects-myproject
ls -lt "$SESSION_DIR"/*.jsonl | head -1

# Read the last 5 records to see where the session is
tail -5 "$SESSION_DIR"/<session-id>.jsonl | jq -c '{type, role, ts}'

# Check the mtime relative to wall clock
stat -c '%Y' "$SESSION_DIR"/<session-id>.jsonl
date +%s

Each line of the JSONL is one record. Records have a type field. The values relevant for crash detection:

  • user — a user-typed prompt or a tool-result envelope returning data to the agent
  • assistant — a model turn, may contain text blocks and tool_use blocks
  • tool_use — the agent is invoking a tool
  • tool_result — the tool has returned, and the result is being fed back to the agent

For crash detection, the diagnostic question is: was the last record a tool_use with no matching tool_result? If yes, the tool call was in flight when the session died. If no — if the last record is a tool_result or an assistant text block — the session was at a natural pause point when it stopped.

A single line of jq answers it:

# Is the last tool_use orphaned (no matching tool_result)?
jq -c 'select(.type == "tool_use" or .type == "tool_result") | .id' 
    "$SESSION_DIR"/<session-id>.jsonl | tail -2

If the two IDs match, the call completed. If only one ID appears, the call is orphaned.

Clean exit vs crash — how to tell

A clean Claude Code exit leaves a specific signature:

  • The last record is an assistant block with a text field (the model said something) followed by no further records
  • The mtime of the file is exactly the time of that last record
  • The Claude Code process is no longer in the process table

A crash leaves at least one of three different signatures:

  • An orphaned tool_use with no matching tool_result (the call was in flight)
  • A truncated last line that fails to parse as JSON (the writer was mid-flush)
  • A complete last record but the Claude Code process is still listed in the process table (the agent is hung, not exited)

The third case is the most informative one. A "session ended" UI with a still-running Claude Code process is the signature of a deadlock — the agent is alive but no longer making progress. A watchdog process can detect this by combining mtime staleness with a check of the process table. Stale + process exists = hang. Stale + process gone = crash.

The fourth case, the one nobody covers in docs, is the partial flush. Claude Code writes to the JSONL by appending. If the OS kills the process during a write, the last line can be a half-complete JSON object. The next session that tries to read the transcript will fail to parse it. A watchdog has to be tolerant of this — it should detect truncation, log it, and offer the previous record as the recovery point.

Building a watchdog: the minimum viable shell

A minimum watchdog in bash, suitable for one developer's local box, is short enough to read in one screen:

#!/usr/bin/env bash
# claude-watchdog.sh — alert on stale Claude Code sessions
set -euo pipefail

STALE_THRESHOLD=2700   # 45 minutes
PROJECTS_DIR="$HOME/.claude/projects"

while true; do
    now=$(date +%s)
    find "$PROJECTS_DIR" -name '*.jsonl' -print0 | while IFS= read -r -d '' f; do
        mtime=$(stat -c '%Y' "$f")
        age=$((now - mtime))
        if [ "$age" -gt "$STALE_THRESHOLD" ]; then
            # Distinguish hang from crash via process table
            session_id="$(basename "$f" .jsonl)"
            if pgrep -f "claude.*$session_id" >/dev/null; then
                echo "HANG: $f (age ${age}s, process alive)"
            else
                # Check last record
                last_type=$(tail -1 "$f" | jq -r '.type // "malformed"' 2>/dev/null)
                if [ "$last_type" = "tool_use" ]; then
                    echo "CRASH-ORPHAN: $f (age ${age}s, orphaned tool_use)"
                elif [ "$last_type" = "malformed" ]; then
                    echo "CRASH-TRUNCATED: $f (age ${age}s, last line malformed)"
                else
                    echo "ENDED: $f (age ${age}s, clean exit)"
                fi
            fi
        fi
    done
    sleep 60
done

What this gets you: a loop that polls the projects directory once a minute and emits a structured line per stale session, distinguishing between hang, crash-orphan, crash-truncated, and clean-exit. It is a useful starting point. It is not a productized watchdog.

The gotchas the bash version does not handle:

  • The encoded-cwd path scheme is path-encoding sensitive. A directory with non-ASCII characters or unusual separators may produce an encoded form the watchdog cannot map back to a real working directory.
  • Multiple concurrent sessions per workspace. Some users run two VS Code windows pointing at the same project root. The watchdog has to disambiguate sessions by their session-id, not by their project directory.
  • Log rotation. Claude Code does not currently rotate JSONL files, but if it ever does, the watchdog needs to track sessions across renames or the heartbeat detection silently breaks.
  • TodoWrite preservation. The bash watchdog only detects staleness — it does not capture the TodoWrite snapshot that the next session needs to resume. That capture requires hooking into the PostToolUse event from inside Claude Code, not observing the JSONL from outside.

What a productized watchdog adds

A watchdog that actually solves the recovery problem ends up looking different from the bash sketch. The architectural shift is from "external observer of the transcript" to "co-process with hook-level visibility into Claude Code's lifecycle."

The features that distinguish the productized version:

  • PostToolUse hook for TodoWrite. Every time the TodoWrite tool fires, capture the new list. This produces a structured, restart-able view of what the agent was about to do.
  • Pre-compact snapshot. Subscribe to the PreCompact event Claude Code fires before auto-compact runs. Snapshot the pre-compact context. Restart-from-compact is offered as one of the recovery options.
  • Stop hook for clean-exit detection. When Claude Code exits cleanly, fire a marker. Absence of the marker plus mtime staleness = crash.
  • In-editor restart UI. A status-bar item or sidebar entry that, when clicked, opens a new Claude Code session pre-primed with the captured state from the dead session.
  • Cross-platform path resolution. The encoded-cwd convention is observed correctly on POSIX, Windows, and WSL.
  • Watchdog process isolation. If the watchdog itself crashes, Claude Code continues unaffected. The watchdog is observation, never in-band.

That last point matters more than it sounds. A common failure mode of self-rolled watchdogs is the watchdog becoming a single point of failure for the editor experience. The productized version runs in its own VS Code extension host process, communicates with Claude Code only via the documented hook surface, and degrades to "do nothing" if it crashes rather than taking the session down.

The watchdog ships in Power Claude, which is free to download and try with no credit card, so you can point it at your own sessions before deciding whether it earns a place in your setup.

The "or: install one" punchline

Everything above can be built in a weekend by a senior developer who reads the Claude Code hook documentation carefully. Doing it right — handling all four crash signatures, surviving Claude Code updates, presenting the recovery UX inside the editor — is several weeks of work. We did the several weeks of work and shipped it as a VS Code extension.

If skipping those weeks is worth a look, the 14-day Premium Trial is $0 today and you can cancel anytime before day 15 — it bundles the watchdog with rotation and usage analytics. Rather not enter a card? Install Power Claude Free instead; the 7-day free trial gives you full Pro access, no credit card.

Frequently asked questions

Where exactly does Claude Code store the session JSONL files on macOS, Linux, and Windows?

On POSIX systems (Linux and macOS), the path is ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl. On Windows, the path is %USERPROFILE%.claudeprojects<encoded-cwd><session-id>.jsonl. The encoded-cwd is your working directory with path separators replaced by dashes and prefixed with a dash. The convention has been stable across recent Claude Code versions.

Is there an event in Claude Code's hook system that fires when auto-compact is about to run?

Yes. Claude Code emits a PreCompact event before the auto-compact pass executes. A hook subscribed to that event can snapshot the pre-compact context, which is essential for restart-after-compact. The corresponding PostCompact event fires after the rewrite. Both are documented in the Claude Code hook reference.

Can I detect a crash from a Stop hook alone, or do I need an external process?

A Stop hook only fires on clean Claude Code lifecycle events. A process crash kills Claude Code before the Stop hook executes, so you cannot detect crashes from inside the hook surface. Full crash detection requires an external observer of the JSONL files plus the process table. The Stop hook handles the clean-exit half; the external watchdog handles the crash half.

How long should `stale_threshold_seconds` be — 30 seconds, 5 minutes, an hour?

Thirty to forty-five minutes is the working default. Five minutes produces too many false positives during normal long tool calls and auto-compact passes. An hour misses real crashes for too long. Sessions that need a different cadence — autonomous loops with long-running tool calls — should override the threshold rather than trusting a one-size default.

Will killing the watchdog itself cause Claude Code to lose state?

No, if the watchdog is implemented correctly. The watchdog should be a pure observer — it reads JSONL files and process tables, it does not write to Claude Code's state. Killing the watchdog removes the recovery UX but does not affect the underlying session. This isolation is part of what distinguishes a productized watchdog from the bash sketch, where conflating capture and recovery can produce dependencies that fail badly.

Closing

A session that died at three in the morning during an autonomous fix loop is not a session you want to recover by grepping JSONL at nine the next morning. The watchdog is the difference between "twenty minutes of forensic archaeology" and "click the resume button in the status bar". The watchdog we ship hooks into the lifecycle events Claude Code already emits and turns the dead-session experience into a one-click recovery.

To try that recovery flow on a session of your own, the Premium Trial runs 14 days at $0 today and you can cancel anytime before day 15 — it layers the watchdog, Balanced Mode, and usage analytics on top of rotation. Want to start without a card? Download Free: the 7-day free trial includes full Pro access, no credit card.