My Mac crashed with 7 Claude Code sessions open. Never again: a cross-project session picker in one bash script
Yesterday my Mac hard-crashed with seven Claude Code CLI sessions open - different repos, different features, all mid-flight. When it came back up: nothing. Seven blank terminal windows' worth of context, gone.
The official recovery path is claude --resume, but it has two problems at this scale:
- It's per-directory - it only lists sessions for the folder you run it from. To restore 7 sessions across 5 repos, you have to remember which repos you were even in,
cdinto each one, and pick from a list. - It takes over the terminal - each resume eats the shell you ran it from, so you're spawning windows and retyping paths anyway.
The Claude Code desktop app handles this gracefully. The CLI made me do it by hand. That BS needed to stop.
The find: your sessions are just files
Everything Claude Code knows about your sessions lives in plain JSONL files:
~/.claude/projects/<encoded-project-path>/<session-id>.jsonl
One directory per project, one file per session, one JSON object per line. And the early lines of each file contain everything a session browser needs:
{
"type": "user",
"sessionId": "bc077585-ea2d-47c5-85e5-4deace624806",
"cwd": "/Users/dg/GitHub/DLS/qa-dashboard",
"gitBranch": "master",
"message": {
"content": "I want to add a capability to..."
}
}
sessionId- whatclaude --resume <id>wantscwd- the exact directory to resume in (no more guessing which repo)gitBranch- nice for telling worktrees apart- the first user message - a human-readable title for free
There's even more in there: sessions spawned as agent teammates carry an agentName field on every entry, and Task-tool subagent transcripts live in a <session-id>/subagents/ subdirectory. So you can tell your sessions apart from the swarm your sessions spawned.
That's a complete session index, sitting on disk, waiting for fzf.
The tool: claude-sessions
~150 lines of bash with an embedded Python parser. It:
- lists every session across every project, newest first, with age, project path, branch, and the opening message
- shows a live preview of the actual conversation (last user/assistant exchanges) as you scroll
- on Enter, opens the session in a new iTerm tab (or a new tmux window if you're inside tmux), running
cd <project> && claude --resume <session-id>- interactively, so Claude's own "resume from summary or full session?" prompt appears right in the new tab - keeps the picker open, so you restore your sessions one by one, as fast as you can hit Enter
- tags agent-teammate sessions with a magenta
โญ agent-nameand lets you toggle them off with Ctrl-A - when you use multi-agent workflows, 40% of your session files aren't yours - Ctrl-R refreshes, Esc quits
Restoring my seven sessions went from a 15-minute archaeology dig to about ten seconds of Enter-Enter-Enter.
Install
Dependencies: fzf (brew install fzf) and python3 (ships with macOS). The tab-opening uses AppleScript for iTerm2 with a Terminal.app fallback; inside tmux it uses plain tmux new-window, which also makes that path Linux-friendly.
# save the script below, then:
chmod +x ~/bin/claude-sessions # or wherever you keep scripts on PATH
alias cs='claude-sessions' # optional
The script
#!/usr/bin/env bash
# claude-sessions - cross-project Claude Code session picker.
#
# Lists every session under ~/.claude/projects (all directories, newest first)
# in fzf. Enter opens the highlighted session in its own window running
# `claude --resume <id>` interactively (so its summary-vs-full prompt appears
# there), while the picker stays open for the next one:
# - inside tmux -> new tmux window
# - otherwise -> new TAB in the current iTerm window (Terminal.app fallback)
#
# Keys: enter=open ctrl-r=refresh esc=quit
set -euo pipefail
SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
CLAUDE_BIN="${CLAUDE_BIN:-$(command -v claude || echo claude)}"
MAX_SESSIONS="${CLAUDE_SESSIONS_MAX:-300}"
# ---------------------------------------------------------------- list -----
# Emits tab-separated lines: session_id <TAB> cwd <TAB> jsonl_path <TAB> display
# $1 = "all" (default) or "main" (hide teammate/agent sessions)
list_sessions() {
python3 - "$MAX_SESSIONS" "${1:-all}" << 'PY'
import glob, json, os, re, signal, sys, time
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
max_n, mode = int(sys.argv[1]), sys.argv[2]
root = os.path.expanduser("~/.claude/projects")
files = [f for f in glob.glob(os.path.join(root, "*", "*.jsonl"))
if not os.path.basename(f).startswith("agent-")]
files.sort(key=os.path.getmtime, reverse=True)
DIM, CYAN, YELLOW, MAGENTA, RESET = "\033[2m", "\033[36m", "\033[33m", "\033[35m", "\033[0m"
def age(ts):
s = int(time.time() - ts)
if s < 3600: return f"{s // 60}m"
if s < 86400: return f"{s // 3600}h"
return f"{s // 86400}d"
def first_text(content):
if isinstance(content, str): return content
if isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get("type") == "text":
return part.get("text", "")
return ""
shown = 0
home = os.path.expanduser("~")
for f in files:
if shown >= max_n: break
sid = cwd = branch = agent = ""
title = summary = raw = ""
try:
with open(f, errors="replace") as fh:
for i, line in enumerate(fh):
if i > 40 or (title and cwd): break
try:
e = json.loads(line)
except Exception:
continue
if e.get("type") == "summary" and not summary:
summary = e.get("summary", "")
if e.get("type") == "user" and not e.get("isSidechain"):
sid = sid or e.get("sessionId", "")
cwd = cwd or e.get("cwd", "")
branch = branch or (e.get("gitBranch") or "")
agent = agent or (e.get("agentName") or "")
if not title:
t = first_text(e.get("message", {}).get("content", "")).strip()
if t.startswith("Caveat:"):
t = t.split("\n", 1)[-1].strip()
raw = raw or t
if t and not t.startswith("<"):
title = t
except OSError:
continue
if not sid or not cwd: continue
is_agent = bool(agent) and agent != "team-lead"
if mode == "main" and is_agent: continue
if not title and raw:
title = re.sub(r"<[^>]*>", " ", raw)
title = " ".join((title or summary or "(no message)").split())[:120]
proj = cwd.replace(home, "~")
if len(proj) > 34: proj = "โฆ" + proj[-33:]
br = f" {YELLOW}{branch[:14]}{RESET}" if branch and branch not in ("main", "master", "HEAD") else ""
tag = f" {MAGENTA}โญ {agent[:18]}{RESET}" if is_agent else ""
display = (f"{DIM}{age(os.path.getmtime(f)):>3}{RESET} "
f"{CYAN}{proj:<34}{RESET}{tag}{br} {title}")
print(f"{sid}\t{cwd}\t{f}\t{display}")
shown += 1
PY
}
# ------------------------------------------------------------- preview -----
preview_session() {
python3 - "$1" << 'PY'
import json, os, sys
path = sys.argv[1]
size = os.path.getsize(path)
with open(path, "rb") as fh:
if size > 300_000:
fh.seek(-300_000, 2)
fh.readline()
lines = fh.read().decode(errors="replace").splitlines()
BOLD, GREEN, BLUE, RESET = "\033[1m", "\033[32m", "\033[34m", "\033[0m"
out = []
for line in lines:
try:
e = json.loads(line)
except Exception:
continue
role = e.get("type")
if role not in ("user", "assistant") or e.get("isSidechain"): continue
c = e.get("message", {}).get("content", "")
if isinstance(c, list):
c = "\n".join(p.get("text", "") for p in c if isinstance(p, dict) and p.get("type") == "text")
if not isinstance(c, str) or not c.strip() or c.startswith("<"): continue
tag = f"{GREEN}{BOLD}user{RESET}" if role == "user" else f"{BLUE}{BOLD}claude{RESET}"
out.append(f"{tag}: {c.strip()[:600]}")
print("\n\n".join(out[-15:]) or "(no readable messages)")
PY
}
# ---------------------------------------------------------------- open -----
open_one() {
local id="$1" cwd="$2"
[ -d "$cwd" ] || cwd="$HOME"
if [ -n "${TMUX:-}" ]; then
tmux new-window -c "$cwd" -n "$(basename "$cwd")" "$CLAUDE_BIN --resume $id"
elif [ "${TERM_PROGRAM:-}" = "iTerm.app" ] || ps -axo comm | grep -q "MacOS/iTerm2$"; then
osascript - "$cwd" "$id" "$CLAUDE_BIN" << 'EOF' >/dev/null
on run argv
set theCmd to "cd " & quoted form of item 1 of argv & " && " & item 3 of argv & " --resume " & item 2 of argv
tell application "iTerm"
activate
if (count of windows) is 0 then
create window with default profile
else
tell current window to create tab with default profile
end if
tell current session of current window to write text theCmd
end tell
end run
EOF
else
osascript - "$cwd" "$id" "$CLAUDE_BIN" << 'EOF' >/dev/null
on run argv
set theCmd to "cd " & quoted form of item 1 of argv & " && " & item 3 of argv & " --resume " & item 2 of argv
tell application "Terminal"
activate
do script theCmd
end tell
end run
EOF
fi
}
open_line() {
# $1 = file containing the highlighted tab-delimited line
IFS=$'\t' read -r id cwd _rest < "$1"
[ -n "$id" ] && open_one "$id" "$cwd"
}
# ---------------------------------------------------------------- main -----
case "${1:-}" in
--list) list_sessions "${2:-all}" ;;
--preview) preview_session "$2" ;;
--open-line) open_line "$2" ;;
*)
list_sessions | fzf --ansi --delimiter '\t' --with-nth 4.. \
--no-sort --layout=reverse --info=inline \
--header 'enter: open in new tab (picker stays)|ctrl-a: hide/show โญ agents|ctrl-r: refresh|esc: quit' \
--preview "'$SELF' --preview {3}" --preview-window 'right,50%,wrap' \
--bind "enter:execute-silent('$SELF' --open-line {f})" \
--bind "ctrl-r:reload('$SELF' --list all)" \
--bind "ctrl-a:transform:[\"$FZF_PROMPT\" = 'main> '] \
&& echo \"change-prompt(> )+reload('$SELF' --list all)\" \
|| echo \"change-prompt(main> )+reload('$SELF' --list main)\"" \
> /dev/null || true
;;
esac
Caveats
- The
~/.claude/projectsJSONL layout is undocumented internal storage - a Claude Code update could change it. The script only reads these files, so the worst case is the picker breaks, never your sessions. - Window/tab spawning is macOS-specific (AppleScript). On Linux, run it inside tmux and it just works.
- The list is capped at the 300 most recent sessions (
CLAUDE_SESSIONS_MAXto change).
What I'd add next
A Homebrew tap so it's brew install-able, and maybe ctrl-d to archive dead sessions. But honestly, the core lesson is the fun part: when a CLI tool stores its state as plain files, you're one fzf away from the UI you wish it had.
Fittingly, I built this with Claude Code itself - it read its own session format off disk and wrote its own recovery tool in about 20 minutes.
Comments
No comments yet. Start the discussion.