Skip to content
withyoussef.devby Youssef Mansouri

Session State for Claude Code

Claude forgets everything when you close the terminal or when the context gets compacted. This script fixes that: it saves your working state to files on disk, and loads them back automatically next time.

Youssef Mansouri5 min read

Install (3 steps)

1. Make sure jq is installed:

brew install jq          # macOS
sudo apt install jq      # Ubuntu/Debian

2. Copy the code below into a file called claude-state-setup.sh in your project root, then run it:

cd /path/to/your/project
bash claude-state-setup.sh

3. Restart Claude Code. (Hooks only load at startup.)

That's it. Nothing else to configure.

How you use it every day

claude              # state loads by itself — no --resume needed

... work ...

/wrap               # tell Claude to write the handoff
/exit

Next day, claude again and it picks up where you stopped.

Two extra commands you get:

CommandWhat it does
/wrapWrites a summary of the session to disk. Type this before you quit.
/pickupRe-reads the state files. Use it after a compaction if Claude seems lost.

What it installs

Four automatic hooks:

WhenWhat happensCost
Session startsYour handoff + git status get loaded into contextfree
After every turnA "breadcrumb" file is updated with git state + your resume IDfree
Before compactionThe transcript is snapshotted to .claude/snapshots/free
Session endsIf you forgot /wrap, Claude writes the handoff for you1 API call

And three files you can read yourself:

.claude/state/handoff.md      where we left off
.claude/state/decisions.md    append-only log of choices made
.claude/state/hooks.log       why each hook ran or skipped

If something doesn't work

bash claude-state-setup.sh --doctor    # checks everything and tells you what's broken
tail -f .claude/state/hooks.log        # watch the hooks live

Three things that cause 90% of problems:

  • You didn't restart Claude Code. Hooks load at startup only.
  • `jq` isn't installed. Every hook parses JSON with it.
  • Editor broke the heredocs when you pasted. Run bash -n claude-state-setup.sh first — if it says "unexpected EOF", the SH / MD / EOF terminator lines got indented. They must start at column 0.

Two things to know before running it

  • It spends tokens on session end (one headless Claude call to write your handoff, only if you didn't /wrap). To make it completely free, delete the "SessionEnd" block from .claude/settings.json afterwards.
  • If you already have a .claude/settings.json with hooks in it, this overwrites those hook entries. A backup is saved to settings.json.bak — check it: diff .claude/settings.json.bak .claude/settings.json

The code

Everything is explained in the comments. Copy all of it into claude-state-setup.sh.

#!/usr/bin/env bash
# ===============================================================
# claude-state-setup.sh
#
# Session continuity for Claude Code. Supersedes:
#   setup-claude-state.sh, crash-safety-addon.sh,
#   fix-source-aware-load.sh, doctor.sh, fix-headless-permissions.sh
#
#   bash claude-state-setup.sh          install / upgrade
#   bash claude-state-setup.sh --doctor diagnose only
#
# Fixed vs v1:
#   - every hook path uses $CLAUDE_PROJECT_DIR (monorepo safe)
#   - every script resolves the project root itself, never trusts cwd
#   - claude binary resolved to an absolute path (hooks lack your PATH)
#   - BSD/macOS safe: no `xargs -r`, stat handled both ways
#   - git gate only applies when actually inside a work tree
#   - source-aware SessionStart: no double-inject on --resume
#   - every hook logs why it did nothing
#   - headless run gets acceptEdits + scoped allowedTools (else Write
#     is auto-denied and the handoff silently never writes)
#   - recursion guard: the headless run cannot spawn another one
#
# Run from the REPO ROOT. Requires jq.
# ===============================================================
set -euo pipefail

# ---------------------------------------------------------------
# resolve the project root: git top-level, else cwd
# ---------------------------------------------------------------
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$ROOT"
echo "Project root: $ROOT"

command -v jq >/dev/null 2>&1 || {
  echo "ERROR: jq required.  brew install jq  |  sudo apt install jq" >&2; exit 1; }

# ---------------------------------------------------------------
# find claude, checking the places version managers hide it
# ---------------------------------------------------------------
find_claude() {
  local c
  c="$(command -v claude 2>/dev/null || true)"
  [ -n "$c" ] && { echo "$c"; return; }
  for p in "$HOME"/.nvm/versions/node/*/bin/claude \
           "$HOME"/.local/bin/claude \
           "$HOME"/.claude/local/claude \
           "$HOME"/Library/pnpm/claude \
           /opt/homebrew/bin/claude /usr/local/bin/claude; do
    [ -x "$p" ] && { echo "$p"; return; }
  done
  echo ""
}

CLAUDE_BIN="$(find_claude)"
if [ -z "$CLAUDE_BIN" ]; then
  echo "WARN: could not locate the claude binary."
  echo "      The SessionEnd fallback will be disabled until you set it."
  CLAUDE_BIN="claude"
else
  echo "Claude binary: $CLAUDE_BIN"
fi

# ===============================================================
# DOCTOR MODE
# ===============================================================
if [ "${1:-}" = "--doctor" ]; then
  ok(){ printf '  OK    %s\n' "$1"; }; bad(){ printf '  FAIL  %s\n' "$1"; }
  wrn(){ printf '  WARN  %s\n' "$1"; }

  echo; echo "=== claude reachable from a bare (hook-like) env? ==="
  if env -i HOME="$HOME" PATH=/usr/local/bin:/usr/bin:/bin sh -c 'command -v claude' >/dev/null 2>&1
    then ok "yes"; else wrn "no — that is why hooks need the absolute path"; fi
  [ -x "$CLAUDE_BIN" ] && ok "absolute path works: $CLAUDE_BIN" || bad "no usable claude binary"

  echo; echo "=== settings ==="
  if [ -f .claude/settings.json ]; then
    jq -e . .claude/settings.json >/dev/null 2>&1 && ok "valid JSON" || bad "malformed JSON"
    for e in SessionStart SessionEnd PreCompact Stop; do
      jq -e ".hooks.$e" .claude/settings.json >/dev/null 2>&1 && ok "$e registered" || bad "$e missing"
    done
    grep -q 'CLAUDE_PROJECT_DIR' .claude/settings.json \
      && ok "uses \$CLAUDE_PROJECT_DIR" || bad "relative paths — breaks outside repo root"
  else bad ".claude/settings.json missing"; fi

  echo; echo "=== state ==="
  for f in handoff breadcrumb decisions; do
    [ -f ".claude/state/$f.md" ] && ok "$f.md ($(wc -c < ".claude/state/$f.md" | tr -d ' ') bytes)" \
      || wrn "$f.md absent"
  done

  echo; echo "=== hook log (last 20) ==="
  tail -n 20 .claude/state/hooks.log 2>/dev/null | sed 's/^/  /' || echo "  (no log yet)"

  echo; echo "=== live fire: SessionEnd ==="
  printf '{"hook_event_name":"SessionEnd","session_id":"DOCTOR","cwd":"%s","reason":"prompt_input_exit"}' "$ROOT" \
    | bash .claude/hooks/save-state.sh
  tail -n 5 .claude/state/hooks.log 2>/dev/null | sed 's/^/  /'

  echo; echo "=== live fire: SessionStart source=resume ==="
  printf '{"hook_event_name":"SessionStart","source":"resume","cwd":"%s"}' "$ROOT" \
    | bash .claude/hooks/load-state.sh | head -5 | sed 's/^/  /'
  exit 0
fi

# ===============================================================
# INSTALL
# ===============================================================
mkdir -p .claude/state .claude/hooks .claude/commands .claude/snapshots

# ---------------------------------------------------------------
# shared preamble sourced by every hook
# ---------------------------------------------------------------
cat > .claude/hooks/_common.sh <<'SH'
# Sourced by every hook. Never trusts cwd.
set -uo pipefail

# CLAUDE_PROJECT_DIR is set by Claude Code. Fall back to git, then cwd.
PROJ="${CLAUDE_PROJECT_DIR:-}"
[ -z "$PROJ" ] && PROJ="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$PROJ" 2>/dev/null || exit 0

STATE="$PROJ/.claude/state"
SNAPS="$PROJ/.claude/snapshots"
LOG="$STATE/hooks.log"
mkdir -p "$STATE" "$SNAPS" 2>/dev/null

log() { printf '[%s] %s: %s\n' "$(date '+%F %T')" "${HOOK_NAME:-hook}" "$*" >> "$LOG"; }

# mtime, GNU and BSD
mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0; }
age()   { echo $(( $(date +%s) - $(mtime "$1") )); }

in_git() { git rev-parse --is-inside-work-tree >/dev/null 2>&1; }
jqf() { printf '%s' "$HOOK_INPUT" | jq -r "$1" 2>/dev/null || echo ""; }
SH

# ---------------------------------------------------------------
# SessionStart — source aware
# ---------------------------------------------------------------
cat > .claude/hooks/load-state.sh <<'SH'
#!/usr/bin/env bash
HOOK_NAME=SessionStart
HOOK_INPUT="$(cat 2>/dev/null || true)"
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$DIR/_common.sh"

SRC="$(jqf '.source // "startup"')"; [ -z "$SRC" ] && SRC=startup
H="$STATE/handoff.md"; B="$STATE/breadcrumb.md"; D="$STATE/decisions.md"

newer() { [ -f "$1" ] && { [ ! -f "$2" ] || [ "$1" -nt "$2" ]; }; }

full_state() {
  echo "## Session handoff"
  [ -f "$H" ] && head -c 4000 "$H" || echo "(none)"
  if newer "$B" "$H"; then
    echo; echo "## WARNING: previous session ended without a handoff"
    echo "Breadcrumb is newer than the handoff, so the handoff is stale."
    echo "Trust git state over it."; echo
    head -c 2500 "$B"
  fi
  echo; echo "## Recent decisions"; tail -n 15 "$D" 2>/dev/null
  echo; echo "## Git"
  git status --short 2>/dev/null | head -n 20
  git log --oneline -5 2>/dev/null
}

pointer() {
  echo "## State files (on disk, not loaded)"
  echo "- .claude/state/handoff.md   (older than this conversation — prefer the thread above)"
  echo "- .claude/state/decisions.md (append-only, still authoritative)"
}

case "$SRC" in
  startup|fork|clear) full_state ;;
  resume)             pointer ;;
  compact)
    echo "## Post-compaction anchor"
    echo "Detail was summarised away. Durable state on disk:"
    tail -n 15 "$D" 2>/dev/null
    git status --short 2>/dev/null | head -n 20 ;;
  *) pointer ;;
esac

log "source=$SRC"
exit 0
SH

# ---------------------------------------------------------------
# Stop — zero-token breadcrumb
# ---------------------------------------------------------------
cat > .claude/hooks/breadcrumb.sh <<'SH'
#!/usr/bin/env bash
HOOK_NAME=Stop
HOOK_INPUT="$(cat 2>/dev/null || true)"
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$DIR/_common.sh"

SID="$(jqf '.session_id // empty')"
F="$STATE/breadcrumb.md"; T="$F.$$.tmp"

{
  echo "# Breadcrumb — $(date '+%F %T')   session ${SID:0:8}"
  echo "Auto-written every turn. No model call."
  echo; echo "## Uncommitted"
  git diff --stat 2>/dev/null | tail -n 25
  git status --short 2>/dev/null | head -n 25
  echo; echo "## Recent commits"; git log --oneline -5 2>/dev/null
  echo; echo "## Touched in last 60 min"
  find . -type f -mmin -60 \
    -not -path './.git/*' -not -path './node_modules/*' \
    -not -path './.claude/*' -not -path './dist/*' \
    -not -path './.turbo/*' -not -path './.next/*' 2>/dev/null | head -n 25
  echo; echo "## Exact-thread handle (only if git state is not enough)"
  echo "claude --resume $SID"
} > "$T" 2>/dev/null && mv -f "$T" "$F"

rm -f "$T" 2>/dev/null
exit 0
SH

# ---------------------------------------------------------------
# SessionEnd — gated, logged, absolute binary
# ---------------------------------------------------------------
cat > .claude/hooks/save-state.sh <<'SH'
#!/usr/bin/env bash
HOOK_NAME=SessionEnd
HOOK_INPUT="$(cat 2>/dev/null || true)"
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$DIR/_common.sh"

CLAUDE_BIN="__CLAUDE_BIN__"

# --- recursion guard -------------------------------------------
# The headless run below is itself a Claude Code session, so it fires
# SessionEnd too and would spawn another one. Stop that here.
if [ "${CC_HANDOFF_CHILD:-0}" = "1" ]; then
  log "SKIP recursion guard (this IS the handoff run)"; exit 0
fi

SID="$(jqf '.session_id // empty')"
REASON="$(jqf '.reason // empty')"
log "fired reason=$REASON sid=${SID:0:8} root=$PROJ"

[ -x "$CLAUDE_BIN" ]      || { log "ABORT no claude binary at $CLAUDE_BIN"; exit 0; }
[ -n "$SID" ]             || { log "ABORT no session_id (jq missing?)";      exit 0; }
[ "$REASON" = "clear" ]   && { log "SKIP /clear is not end of work";         exit 0; }

if [ -f "$STATE/handoff.md" ]; then
  A="$(age "$STATE/handoff.md")"
  [ "$A" -lt 600 ] && { log "SKIP /wrap ran ${A}s ago"; exit 0; }
fi

# only gate on git when this really is a work tree
if in_git && [ -z "$(git status --porcelain 2>/dev/null)" ]; then
  log "SKIP clean tree, nothing to summarise"; exit 0
fi

PROMPT='Overwrite .claude/state/handoff.md with sections STATE, DECISIONS, FILES TOUCHED, NEXT 3 STEPS, OPEN QUESTIONS. Explicit file paths. Terse. Append any architectural decision as one line to .claude/state/decisions.md. Do not modify source files.'

# -p is headless: it CANNOT show a permission prompt, so without these
# two flags every Write is auto-denied and the file never changes.
# Writes are scoped to .claude/state so this can never touch source.
log "LAUNCH headless handoff (acceptEdits, writes scoped to .claude/state)"
CC_HANDOFF_CHILD=1 nohup "$CLAUDE_BIN" -p --resume "$SID" \
  --output-format json \
  --permission-mode acceptEdits \
  --allowedTools "Read" "Write(.claude/state/**)" "Edit(.claude/state/**)" \
  "$PROMPT" >> "$LOG" 2>&1 &
exit 0
SH

# ---------------------------------------------------------------
# PreCompact — snapshot, BSD-safe cleanup
# ---------------------------------------------------------------
cat > .claude/hooks/snapshot.sh <<'SH'
#!/usr/bin/env bash
HOOK_NAME=PreCompact
HOOK_INPUT="$(cat 2>/dev/null || true)"
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$DIR/_common.sh"

TP="$(jqf '.transcript_path // empty')"
TS="$(date +%Y%m%d-%H%M%S)"

if [ -n "$TP" ] && [ -f "$TP" ]; then
  cp "$TP" "$SNAPS/pre-compact-$TS.jsonl" 2>/dev/null && log "saved transcript $TS"
else
  log "transcript_path empty or missing — git state only"
fi

{ git status --short; git log --oneline -10; } > "$SNAPS/pre-compact-$TS.git" 2>/dev/null

# keep 10 most recent (no xargs -r: not portable to BSD)
ls -1t "$SNAPS"/pre-compact-*.jsonl 2>/dev/null | tail -n +11 | while read -r f; do
  [ -n "$f" ] && rm -f "$f"
done
exit 0
SH

sed -i.bak "s|__CLAUDE_BIN__|$CLAUDE_BIN|" .claude/hooks/save-state.sh
rm -f .claude/hooks/save-state.sh.bak
chmod +x .claude/hooks/*.sh

# ---------------------------------------------------------------
# state files
# ---------------------------------------------------------------
[ -f .claude/state/handoff.md ] || printf '# Handoff\n\n## STATE\n(nothing yet)\n\n## NEXT 3 STEPS\n1.\n2.\n3.\n\n## OPEN QUESTIONS\n-\n' > .claude/state/handoff.md
[ -f .claude/state/decisions.md ] || printf '# Decisions (append-only)\n\nFormat: `YYYY-MM-DD | what | why | rejected alternative`\n' > .claude/state/decisions.md

# ---------------------------------------------------------------
# slash commands
# ---------------------------------------------------------------
cat > .claude/commands/wrap.md <<'MD'
---
description: Write the session handoff before exiting
---
Overwrite `.claude/state/handoff.md` with exactly these sections:

## STATE
What works now, what is half-done. Two or three lines.

## DECISIONS
Choices made this session and why. Omit if none.

## FILES TOUCHED
Explicit paths, one per line, few words each.

## NEXT 3 STEPS
Concrete. Not "continue work on X".

## OPEN QUESTIONS
Genuinely unresolved only. Omit section if empty.

Then append any architectural decision as one line to
`.claude/state/decisions.md` as `YYYY-MM-DD | what | why | rejected`.

Terse. No preamble. Do not touch source files. Then stop.
MD

cat > .claude/commands/pickup.md <<'MD'
---
description: Re-read state files after a compaction
---
Read `.claude/state/handoff.md` and the last 20 lines of
`.claude/state/decisions.md`. State in three lines where we are and what
is next. Do not start working until I confirm.
MD

# ---------------------------------------------------------------
# settings.json — $CLAUDE_PROJECT_DIR everywhere
# ---------------------------------------------------------------
NEW_HOOKS='{
  "hooks": {
    "SessionStart": [{ "hooks": [{ "type": "command",
      "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/load-state.sh\"" }] }],
    "SessionEnd":   [{ "hooks": [{ "type": "command",
      "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/save-state.sh\"" }] }],
    "Stop":         [{ "hooks": [{ "type": "command",
      "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/breadcrumb.sh\"" }] }],
    "PreCompact":   [{ "matcher": "auto", "hooks": [{ "type": "command",
      "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/snapshot.sh\"" }] }]
  },
  "permissions": {
    "deny": ["Bash(rm -rf *)", "Bash(git push *)", "Bash(sudo *)"]
  }
}'

if [ -f .claude/settings.json ]; then
  cp .claude/settings.json .claude/settings.json.bak
  printf '%s' "$NEW_HOOKS" | jq -s '.[0] * .[1]' .claude/settings.json - \
    > .claude/settings.json.tmp && mv .claude/settings.json.tmp .claude/settings.json
  echo "Merged hooks (backup: .claude/settings.json.bak)"
else
  printf '%s\n' "$NEW_HOOKS" | jq . > .claude/settings.json
  echo "Created .claude/settings.json"
fi

# ---------------------------------------------------------------
# CLAUDE.md pointer (survives compaction)
# ---------------------------------------------------------------
POINTER='## Session state
Durable state lives on disk, not in this conversation:
- `.claude/state/handoff.md`   where we left off
- `.claude/state/decisions.md` append-only decision log

After a compaction, or whenever prior context is unclear, re-read both
files instead of asking me to re-explain. After any task that modifies
files, append one line to `.claude/state/decisions.md`.'

if [ -f CLAUDE.md ]; then
  grep -q "Durable state lives on disk" CLAUDE.md || printf '\n%s\n' "$POINTER" >> CLAUDE.md
else
  printf '%s\n' "$POINTER" > CLAUDE.md
fi

# ---------------------------------------------------------------
# tmux wrapper — closing the window detaches instead of killing
# ---------------------------------------------------------------
cat > .claude/cc <<'SH'
#!/usr/bin/env bash
# ./.claude/cc <session-name> [subdir]
# Runs Claude in tmux from the REPO ROOT so hooks always resolve.
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
NAME="${1:-cc-$(basename "$ROOT")}"
cd "$ROOT" || exit 1
if command -v tmux >/dev/null 2>&1; then
  exec tmux new-session -A -s "$NAME" "cd '$ROOT' && claude -n '$NAME'"
else
  exec claude -n "$NAME"
fi
SH
chmod +x .claude/cc

touch .gitignore
grep -q '.claude/snapshots' .gitignore  || printf '\n.claude/snapshots/\n'   >> .gitignore
grep -q '.claude/state/hooks.log' .gitignore || printf '.claude/state/hooks.log\n' >> .gitignore
grep -q '.claude/state/\*.tmp' .gitignore    || printf '.claude/state/*.tmp\n'     >> .gitignore

cat <<EOF

Installed at $ROOT

  Every hook path uses \$CLAUDE_PROJECT_DIR, so launching from
  apps/api or packages/web works the same as from the root.

  Verify:   bash $(basename "$0") --doctor
  Daily:    ./.claude/cc worker-queue    ... work ...    /wrap    /exit
  Log:      .claude/state/hooks.log

  Restart Claude Code — hooks registered mid-session do not apply
  to that session, and you may be asked to review the change.
EOF

Enjoyed this? There's more coming.

One email per new guide. Join free and never miss one.