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/Debian2. 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.sh3. 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
/exitNext day, claude again and it picks up where you stopped.
Two extra commands you get:
| Command | What it does |
|---|---|
/wrap | Writes a summary of the session to disk. Type this before you quit. |
/pickup | Re-reads the state files. Use it after a compaction if Claude seems lost. |
What it installs
Four automatic hooks:
| When | What happens | Cost |
|---|---|---|
| Session starts | Your handoff + git status get loaded into context | free |
| After every turn | A "breadcrumb" file is updated with git state + your resume ID | free |
| Before compaction | The transcript is snapshotted to .claude/snapshots/ | free |
| Session ends | If you forgot /wrap, Claude writes the handoff for you | 1 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 skippedIf 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 liveThree 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.shfirst — if it says "unexpected EOF", theSH/MD/EOFterminator 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.jsonafterwards. - If you already have a
.claude/settings.jsonwith hooks in it, this overwrites those hook entries. A backup is saved tosettings.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