Skip to content

Instantly share code, notes, and snippets.

@aparente
Last active May 12, 2026 05:42
Show Gist options
  • Select an option

  • Save aparente/3ee714027f510a657d75a518e67e713a to your computer and use it in GitHub Desktop.

Select an option

Save aparente/3ee714027f510a657d75a518e67e713a to your computer and use it in GitHub Desktop.
Claude Code Session Management — auto-naming wrapper + checkpoint/restore for surviving terminal crashes
#!/bin/bash
# Snapshot all live Claude Code sessions + cmux workspace layout.
# Dead PIDs are pruned. Run via launchd every 5 minutes.
CHECKPOINT="$HOME/.claude/session-checkpoint.json"
SESSIONS_DIR="$HOME/.claude/sessions"
[ -d "$SESSIONS_DIR" ] || exit 0
live=()
for f in "$SESSIONS_DIR"/*.json; do
[ -f "$f" ] || continue
pid=$(basename "$f" .json)
kill -0 "$pid" 2>/dev/null || continue
entry=$(python3 -c "
import json
d = json.load(open('$f'))
print(json.dumps({
'pid': d.get('pid'),
'sessionId': d.get('sessionId',''),
'name': d.get('name',''),
'cwd': d.get('cwd',''),
'startedAt': d.get('startedAt',0)
}))
" 2>/dev/null)
[ -n "$entry" ] && live+=("$entry")
done
# Capture cmux layout if available
cmux_layout=""
if command -v cmux &>/dev/null; then
cmux_layout=$(python3 -c "
import subprocess, json
workspaces = subprocess.run(['cmux', 'list-workspaces'], capture_output=True, text=True).stdout.strip().split('\n')
layout = []
for ws_line in workspaces:
parts = ws_line.strip().lstrip('* ').split()
ws_ref = parts[0]
ws_name = parts[1] if len(parts) > 1 else ws_ref
surfaces = subprocess.run(['cmux', 'list-pane-surfaces', '--workspace', ws_ref], capture_output=True, text=True).stdout.strip().split('\n')
tabs = []
for s in surfaces:
s = s.strip().lstrip('* ')
sparts = s.split()
if len(sparts) >= 2:
sref = sparts[0]
sname = ' '.join(sparts[1:]).replace('[selected]','').strip()
is_claude = sname.startswith('✳')
clean_name = sname.lstrip('✳ ').strip()
tabs.append({'ref': sref, 'name': clean_name, 'claude': is_claude})
layout.append({'ref': ws_ref, 'name': ws_name, 'tabs': tabs})
print(json.dumps(layout))
" 2>/dev/null)
fi
tmp="$CHECKPOINT.tmp"
python3 -c "
import json, sys, time
entries = [json.loads(e) for e in sys.argv[1:]]
named = [e for e in entries if e.get('name')]
unnamed_count = len(entries) - len(named)
cmux_raw = '''$cmux_layout'''
cmux = json.loads(cmux_raw) if cmux_raw else None
# Match sessions to cmux workspaces by name
# Try exact match first, then case-insensitive substring
if cmux:
# Build list of (tab_name, workspace_name) pairs
all_tabs = []
for ws in cmux:
for tab in ws['tabs']:
all_tabs.append((tab['name'], ws['name']))
def find_workspace(session_name):
sn = session_name.lower().replace('_', ' ').replace('-', ' ')
# Exact match
for tab_name, ws_name in all_tabs:
if tab_name == session_name:
return ws_name
# Tab name is substring of session name or vice versa (case-insensitive)
for tab_name, ws_name in all_tabs:
tn = tab_name.lower().replace('_', ' ').replace('-', ' ')
if tn in sn or sn in tn:
return ws_name
return ''
for s in named:
s['workspace'] = find_workspace(s['name'])
out = {
'timestamp': int(time.time()),
'named': sorted(named, key=lambda x: x.get('startedAt', 0), reverse=True),
'unnamed_count': unnamed_count,
'total': len(entries)
}
if cmux:
out['cmux_layout'] = cmux
json.dump(out, open('$tmp', 'w'), indent=2)
" "${live[@]}"
# Keep prior checkpoint as backup so one bad cycle (e.g., restart-then-tick)
# doesn't wipe the only record of named sessions.
[ -f "$CHECKPOINT" ] && cp "$CHECKPOINT" "$CHECKPOINT.prev"
mv "$tmp" "$CHECKPOINT"
# Commit to ~/.claude (private repo aparente/claude-config) only when the
# named-session set actually changes, so we get ~1 commit per session
# start/stop instead of 288/day from every tick.
CLAUDE_DIR="$HOME/.claude"
if [ -d "$CLAUDE_DIR/.git" ]; then
extract_names() {
python3 -c "
import json, sys
try:
d = json.load(open(sys.argv[1]))
print(' '.join(sorted(f\"{s.get('name','')}@{s.get('cwd','')}\" for s in d.get('named', []))))
except Exception:
print('')
" "$1" 2>/dev/null
}
new_names=$(extract_names "$CHECKPOINT")
prev_path=$(mktemp)
(cd "$CLAUDE_DIR" && git show HEAD:session-checkpoint.json 2>/dev/null > "$prev_path")
old_names=$(extract_names "$prev_path")
rm -f "$prev_path"
if [ "$new_names" != "$old_names" ]; then
count=$(python3 -c "import json; print(len(json.load(open('$CHECKPOINT')).get('named',[])))" 2>/dev/null || echo "?")
summary=$(python3 -c "
import json
d = json.load(open('$CHECKPOINT'))
names = [s.get('name','') for s in d.get('named',[])][:5]
print(', '.join(names) if names else '(none)')
" 2>/dev/null || echo "")
(
cd "$CLAUDE_DIR" || exit 0
git add session-checkpoint.json session-checkpoint.json.prev 2>/dev/null
git commit -m "Checkpoint: $count named sessions ($summary)" >/dev/null 2>&1 \
&& git push origin >/dev/null 2>&1 &
)
fi
fi
#!/bin/bash
# claude-restore: Reopen all named Claude sessions from the last checkpoint.
# Restores cmux workspace layout if available.
#
# Usage:
# claude-restore # Interactive — shows list, asks to confirm
# claude-restore --yes # Skip confirmation
# claude-restore --list # Just show what's checkpointed
# claude-restore --dry-run # Show what would happen without opening anything
# claude-restore --prev # Use the .prev backup (last good tick)
# claude-restore --from-commit <sha> # Use a historical checkpoint from
# # the ~/.claude git repo. Find the
# # sha with:
# # cd ~/.claude && git log --oneline session-checkpoint.json
#
# Supports: cmux (default if installed), Terminal.app fallback (macOS)
# Requires: claude-checkpoint to have run at least once.
# Sessions are resumed by UUID (the checkpoint records sessionId per entry),
# so each tab opens directly rather than landing on the resume picker.
CHECKPOINT="$HOME/.claude/session-checkpoint.json"
# Pre-parse special checkpoint-source flags.
# --prev read the prior tick's backup (the live tick may have wiped it)
# --from-commit <sha> read a historical checkpoint from the ~/.claude git repo
# (useful when a tick already overwrote the .prev backup)
# --from-file <path> read an arbitrary checkpoint JSON (for tests, manual
# recovery from copies, or restoring someone else's state)
args=()
next_is_commit=0
next_is_file=0
COMMIT_SHA=""
FILE_PATH=""
for a in "$@"; do
if [[ "$next_is_commit" == "1" ]]; then
COMMIT_SHA="$a"
next_is_commit=0
continue
fi
if [[ "$next_is_file" == "1" ]]; then
FILE_PATH="$a"
next_is_file=0
continue
fi
if [[ "$a" == "--prev" ]]; then
CHECKPOINT="$CHECKPOINT.prev"
elif [[ "$a" == "--from-commit" ]]; then
next_is_commit=1
elif [[ "$a" == "--from-file" ]]; then
next_is_file=1
else
args+=("$a")
fi
done
set -- "${args[@]}"
if [ -n "$FILE_PATH" ]; then
CHECKPOINT="$FILE_PATH"
fi
if [ -n "$COMMIT_SHA" ]; then
TMP_CHECKPOINT=$(mktemp -t claude-restore-XXXXXX.json)
trap 'rm -f "$TMP_CHECKPOINT"' EXIT
if ! (cd "$HOME/.claude" && git show "$COMMIT_SHA:session-checkpoint.json") > "$TMP_CHECKPOINT" 2>/dev/null; then
echo "Could not read session-checkpoint.json from $COMMIT_SHA" >&2
echo "Try: cd ~/.claude && git log --oneline session-checkpoint.json" >&2
exit 1
fi
CHECKPOINT="$TMP_CHECKPOINT"
fi
if [ ! -f "$CHECKPOINT" ]; then
echo "No checkpoint found at $CHECKPOINT"
echo "Run claude-checkpoint first."
exit 1
fi
show_sessions() {
python3 -c "
import json, datetime, os
d = json.load(open('$CHECKPOINT'))
ts = datetime.datetime.fromtimestamp(d['timestamp']).strftime('%Y-%m-%d %H:%M')
named = d.get('named', [])
print(f'Checkpoint from {ts}: {len(named)} named, {d.get(\"unnamed_count\",0)} unnamed')
print()
by_ws = {}
for s in named:
ws = s.get('workspace', '')
by_ws.setdefault(ws or '(no workspace)', []).append(s)
for ws, sessions in sorted(by_ws.items()):
print(f' [{ws}]')
for s in sessions:
started = datetime.datetime.fromtimestamp(s['startedAt']/1000).strftime('%m/%d %H:%M')
cwd = s['cwd'].replace(os.path.expanduser('~'), '~')
print(f' {s[\"name\"]:33s} {started} {cwd}')
print()
"
}
if [[ "$1" == "--list" ]]; then
show_sessions
exit 0
fi
show_sessions
if [[ "$1" != "--yes" && "$1" != "--dry-run" ]]; then
read -r -p "Restore all named sessions? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || exit 0
fi
# Detect terminal multiplexer
if [[ "$1" == "--dry-run" ]]; then
MODE="dry-run"
elif command -v cmux &>/dev/null; then
MODE="cmux"
elif [[ "$OSTYPE" == darwin* ]]; then
MODE="terminal"
else
MODE="echo"
fi
python3 -c "
import json, subprocess, shlex, time, sys, os
d = json.load(open('$CHECKPOINT'))
mode = '$MODE'
# Drop sessions whose UUID is already in the LIVE checkpoint — they're
# either still running or about to be reopened twice.
live_path = os.path.expanduser('~/.claude/session-checkpoint.json')
live_uuids = set()
try:
live = json.load(open(live_path))
live_uuids = {s.get('sessionId') for s in live.get('named', []) if s.get('sessionId')}
except Exception:
pass
original_named = d.get('named', [])
skipped = []
filtered = []
for s in original_named:
if s.get('sessionId') in live_uuids:
skipped.append(s['name'])
else:
filtered.append(s)
d['named'] = filtered
if skipped and mode != 'dry-run':
print(f' skipped {len(skipped)} already-running: {\", \".join(skipped)}', file=sys.stderr)
def build_cmd(s):
# Prefer the full session UUID (resumes directly). Falls back to the
# name as a picker search term, but that requires manual selection —
# so warn so the user knows why their tabs are sitting on a picker.
cwd = s.get('cwd', '')
sid = s.get('sessionId', '')
name = s.get('name', '')
if sid:
target = sid
else:
target = name
print(f' WARN: no sessionId for {name!r} — tab will open picker, not resume', file=sys.stderr)
return f'cd {shlex.quote(cwd)} && claude --resume {shlex.quote(target)}'
def run_cmux(args, label=''):
# Wrap subprocess.run so cmux failures are visible instead of silent.
r = subprocess.run(['cmux'] + args, capture_output=True, text=True)
if r.returncode != 0:
print(f' ERR: cmux {args[0]} failed (exit {r.returncode}): {r.stderr.strip()}', file=sys.stderr)
return r
if mode == 'dry-run':
# Check existing cmux workspaces
existing_ws = set()
try:
ws_out = subprocess.run(['cmux', 'list-workspaces'], capture_output=True, text=True).stdout.strip().split('\n')
for line in ws_out:
parts = line.strip().lstrip('* ').split()
if len(parts) >= 2:
existing_ws.add(parts[1])
except: pass
by_ws = {}
for s in d.get('named', []):
ws = s.get('workspace', '')
by_ws.setdefault(ws or '(new workspace)', []).append(s)
for ws_name, sessions in sorted(by_ws.items()):
exists = ws_name in existing_ws
label = ' (exists, would add tabs)' if exists else ' (would create)'
print(f' [{ws_name}]{label}')
for s in sessions:
name = s['name']
cmd = build_cmd(s)
print(f' -> new tab: {name}')
print(f' {cmd}')
print()
elif mode == 'cmux':
# Get existing workspaces
ws_out = run_cmux(['list-workspaces']).stdout.strip().split('\n')
existing_ws = {}
for line in ws_out:
parts = line.strip().lstrip('* ').split()
if len(parts) >= 2:
existing_ws[parts[1]] = parts[0]
# Group sessions by workspace
by_ws = {}
for s in d.get('named', []):
ws = s.get('workspace', '')
by_ws.setdefault(ws, []).append(s)
failures = []
for ws_name, sessions in by_ws.items():
ws_ref = existing_ws.get(ws_name)
# Create workspace if needed
if ws_name and not ws_ref:
run_cmux(['new-workspace'])
run_cmux(['rename-workspace', ws_name])
time.sleep(0.3)
# Re-read to get the ref
ws_out2 = run_cmux(['list-workspaces']).stdout.strip().split('\n')
for line in ws_out2:
parts = line.strip().lstrip('* ').split()
if len(parts) >= 2 and parts[1] == ws_name:
ws_ref = parts[0]
existing_ws[ws_name] = ws_ref
break
for s in sessions:
name = s['name']
cmd = build_cmd(s)
# Create new surface (tab) in the target workspace
if ws_ref:
result = run_cmux(['new-surface', '--workspace', ws_ref])
# Parse 'OK surface:62 pane:3 workspace:3' to get surface ref
surface_ref = None
for token in result.stdout.strip().split():
if token.startswith('surface:'):
surface_ref = token
break
if not surface_ref and result.returncode != 0:
failures.append(name)
continue
time.sleep(0.2)
send_args = ['send', '--workspace', ws_ref]
rename_args = ['rename-tab', '--workspace', ws_ref]
if surface_ref:
send_args += ['--surface', surface_ref]
rename_args += ['--surface', surface_ref]
run_cmux(send_args + [cmd + chr(10)])
run_cmux(rename_args + [name])
else:
run_cmux(['new-workspace', '--command', cmd])
ws_label = f' [{ws_name}]' if ws_name else ''
print(f' opened: {name}{ws_label}')
if failures:
print(f'\\nFAILED to open {len(failures)} session(s): {\", \".join(failures)}', file=sys.stderr)
elif mode == 'terminal':
for s in d.get('named', []):
name = s['name']
cmd = build_cmd(s)
script = f'tell application \"Terminal\" to do script \"{cmd}\"'
subprocess.run(['osascript', '-e', script])
print(f' opened: {name}')
else:
for s in d.get('named', []):
name = s['name']
cwd = s['cwd']
print(f' cd {shlex.quote(cwd)} && claude --resume {shlex.quote(name)}')
"

Claude Code session management

I run 15+ Claude Code sessions at a time. When my terminal crashes I lose track of all of them, and the built-in claude --resume picker only surfaces a fraction of my history. These three scripts fix both problems.

What's here

claude-session-namer.sh — name your sessions on launch

Shell wrapper. Every time you type claude, it asks for a name. Hit enter to skip and let Claude auto-name.

# Add to ~/.zshrc or ~/.bashrc
source claude-session-namer.sh
$ claude
Session name (enter to auto-name): protein-analysis

Doesn't interfere with --resume, -c, -p, --help, or -n.

claude-checkpoint + claude-restore — survive terminal crashes

claude-checkpoint writes all live, named sessions to ~/.claude/session-checkpoint.json every 5 minutes (via launchd). Dead PIDs get pruned. Each tick also copies the prior checkpoint to .prev as a one-cycle backup, and (if ~/.claude is a git repo) commits the file when the named-session set changes.

claude-restore reads the checkpoint and reopens everything. Uses cmux workspaces if you have it installed (preserving sidebar grouping), otherwise falls back to Terminal.app tabs.

claude-restore --list                # see what's saved
claude-restore                       # reopen everything (asks first)
claude-restore --yes                 # skip confirmation
claude-restore --prev                # read .prev backup instead of live checkpoint
                                     # (use when a checkpoint tick wiped real state)
claude-restore --from-commit <sha>   # read a historical checkpoint from the
                                     # ~/.claude git repo. Find one with:
                                     #   cd ~/.claude && git log --oneline session-checkpoint.json

Sessions are resumed by their UUID (the checkpoint records sessionId per entry), so each tab resumes the real conversation directly — not the picker pre-filtered by name, which was a real failure mode of an earlier version of this script. The --from-commit flag is the recovery hatch for when both the live checkpoint and .prev have been overwritten with stale state: if ~/.claude is git-tracked, the checkpoint is committed on every named-session-set change, so any prior state is recoverable from history. Already-running sessions (matched by UUID against the current live checkpoint) are skipped on restore to avoid opening duplicates.

claude-sessions — the all-time catalog

Separate from claude-restore. Reads ~/.claude/projects/*/*.jsonl and prints every Claude Code session that has ever existed — name, cwd, last-touched timestamp, message count, first-prompt preview, session UUID. Useful when the built-in picker can't find the session you remember having three weeks ago.

claude-sessions                          # all sessions, newest last
claude-sessions --named                  # only sessions you named
claude-sessions --cwd ~/projects/foo     # filter by cwd
claude-sessions --since 2026-04-01       # recent only
claude-sessions --resume aperture        # print copy-pasteable resume command
claude-sessions --search "specific text" # deep-grep transcript bodies (uses ripgrep)
claude-sessions --json | jq ...          # machine-readable output

The --resume flag emits (cd <cwd> && claude --resume <uuid>) so you sidestep the picker entirely. --search uses ripgrep to find sessions by content when you don't remember the name.

Setup (macOS)

cp claude-checkpoint.sh ~/.local/bin/claude-checkpoint
cp claude-restore.sh ~/.local/bin/claude-restore
cp claude-sessions.py ~/.local/bin/claude-sessions
chmod +x ~/.local/bin/claude-checkpoint ~/.local/bin/claude-restore ~/.local/bin/claude-sessions

# Checkpoint every 5 minutes
cat > ~/Library/LaunchAgents/com.claude.session-checkpoint.plist << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.claude.session-checkpoint</string>
  <key>ProgramArguments</key>
  <array>
    <string>/Users/YOUR_USER/.local/bin/claude-checkpoint</string>
  </array>
  <key>StartInterval</key>
  <integer>300</integer>
</dict>
</plist>
EOF

launchctl load ~/Library/LaunchAgents/com.claude.session-checkpoint.plist

What the checkpoint looks like

{
  "timestamp": 1775882363,
  "named": [
    { "name": "protein-analysis", "cwd": "/Users/me/projects/biolab", "workspace": "Biolab" },
    { "name": "api-refactor", "cwd": "/Users/me/projects/backend", "workspace": "" }
  ],
  "unnamed_count": 2,
  "total": 6
}

If cmux is running, each named session is matched to its workspace by tab name — exact match first, then case-insensitive substring fallback.

How it works

checkpoint — Claude Code keeps live session metadata in ~/.claude/sessions/{PID}.json. The checkpoint script iterates those files, checks PID liveness with kill -0, captures cmux workspace layout if available, and writes the named ones to a single JSON file. Atomic write (tmp + mv). The prior checkpoint is kept as .prev so one bad cycle (e.g., a tick right after a restart) doesn't wipe the only record.

restore — Reads the checkpoint. With cmux: creates each workspace if missing, opens a new surface (vertical tab) per session, sends the resume command, renames the tab. Without cmux: uses osascript to open Terminal.app tabs.

sessions — Iterates every transcript JSONL once, extracting customTitle, cwd, timestamps, and the first user prompt. Total scan time is ~1s for 1,000+ sessions on local SSD. The catalog is built fresh each run; no cache.

Requirements

  • macOS for launchd. Linux works too — substitute cron and pick a multiplexer.
  • Python 3 (stdlib only)
  • Claude Code CLI
  • cmux (optional, for workspace-aware restore)
  • ripgrep (optional, only for claude-sessions --search)
# Claude Code Session Namer
# Add to ~/.zshrc or ~/.bashrc
# Prompts for a session name every time you launch `claude`.
# Hit enter to skip and use Claude's default auto-naming.
claude() {
# Pass through for resume, continue, one-shot, pipe, help, or already named
if [[ " $* " =~ " -r " ]] || [[ " $* " =~ " --resume" ]] || \
[[ " $* " =~ " -c " ]] || [[ " $* " =~ " --help" ]] || \
[[ " $* " =~ " -n " ]] || [[ " $* " =~ " -p " ]]; then
command claude "$@"
return
fi
printf "Session name (enter to auto-name): "
read -r name
if [[ -n "$name" ]]; then
command claude -n "$name" "$@"
else
command claude "$@"
fi
}
#!/usr/bin/env python3
"""
claude-sessions: list every Claude Code session that has ever existed.
Distinct from claude-restore (which only sees currently-running sessions).
Reads transcript JSONLs in ~/.claude/projects/ and prints a sortable catalog.
Usage:
claude-sessions # all sessions, newest last
claude-sessions --cwd ~/foo # filter by cwd substring
claude-sessions --name pattern # filter by session name substring (regex)
claude-sessions --since 2026-04-01 # only sessions touched after this
claude-sessions --named # only sessions with a custom title
claude-sessions --json # machine-readable JSON
claude-sessions --resume <substr> # print `claude --resume <id>` command for matches
claude-sessions --search "phrase" # deep-search transcript bodies (uses ripgrep)
The output is designed to pipe into grep, fzf, or awk.
The all-time catalog beats `claude --resume`'s picker because it includes
every session ever — not just whatever the picker decides to surface.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
PROJECTS = Path.home() / ".claude" / "projects"
@dataclass
class Session:
session_id: str
name: str = ""
cwd: str = ""
git_branch: str = ""
first_ts: str = ""
last_ts: str = ""
first_prompt: str = ""
msg_count: int = 0
file: Path = field(default_factory=Path)
def parse_session(path: Path) -> Session | None:
s = Session(session_id=path.stem, file=path)
try:
with path.open("r", errors="replace") as f:
for line in f:
try:
d = json.loads(line)
except Exception:
continue
t = d.get("type")
if t == "custom-title" and d.get("customTitle"):
s.name = d["customTitle"]
elif t == "agent-name" and not s.name and d.get("agentName"):
s.name = d["agentName"]
if not s.cwd and d.get("cwd"):
s.cwd = d["cwd"]
if not s.git_branch and d.get("gitBranch"):
s.git_branch = d["gitBranch"]
ts = d.get("timestamp")
if ts:
if not s.first_ts:
s.first_ts = ts
s.last_ts = ts
if t == "user" and not s.first_prompt:
msg = d.get("message", {})
if isinstance(msg, dict):
content = msg.get("content")
if isinstance(content, str):
s.first_prompt = content
elif isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
s.first_prompt = block.get("text", "")
break
s.msg_count += 1
elif t in ("user", "assistant"):
s.msg_count += 1
if d.get("sessionId"):
s.session_id = d["sessionId"]
except (OSError, IOError):
return None
if not s.first_ts and not s.last_ts:
return None
return s
def fmt_ts(ts: str) -> str:
if not ts:
return ""
try:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
return dt.astimezone().strftime("%m-%d %H:%M")
except Exception:
return ts[:16]
def cwd_short(cwd: str) -> str:
home = str(Path.home())
if cwd.startswith(home):
return "~" + cwd[len(home):]
return cwd
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--cwd", help="filter by cwd substring")
ap.add_argument("--name", help="filter by name (regex)")
ap.add_argument("--since", help="only sessions touched after YYYY-MM-DD")
ap.add_argument("--named", action="store_true", help="only sessions with custom title")
ap.add_argument("--json", action="store_true", help="emit JSON")
ap.add_argument("--resume", metavar="SUBSTR", help="print resume commands for matches")
ap.add_argument("--search", metavar="PHRASE", help="restrict to sessions whose transcript contains PHRASE (uses ripgrep)")
ap.add_argument("--limit", type=int, default=0, help="max rows (0 = all)")
args = ap.parse_args()
if not PROJECTS.exists():
print(f"No projects directory at {PROJECTS}", file=sys.stderr)
sys.exit(1)
files = [p for p in PROJECTS.glob("*/*.jsonl") if not p.name.startswith("agent-")]
if args.search:
import subprocess
try:
r = subprocess.run(
["rg", "-l", "-F", "--", args.search, str(PROJECTS)],
capture_output=True, text=True, check=False,
)
hits = {Path(line.strip()) for line in r.stdout.splitlines() if line.strip()}
files = [p for p in files if p in hits]
except FileNotFoundError:
print("ripgrep (rg) not found; --search requires it", file=sys.stderr)
sys.exit(1)
sessions: list[Session] = []
for p in files:
s = parse_session(p)
if s:
sessions.append(s)
if args.cwd:
sessions = [s for s in sessions if args.cwd in s.cwd]
if args.name:
rx = re.compile(args.name, re.I)
sessions = [s for s in sessions if rx.search(s.name)]
if args.named:
sessions = [s for s in sessions if s.name]
if args.since:
sessions = [s for s in sessions if s.last_ts and s.last_ts >= args.since]
if args.resume:
substr = args.resume.lower()
sessions = [s for s in sessions if substr in s.name.lower() or substr in s.session_id.lower()]
sessions.sort(key=lambda s: s.last_ts)
if args.limit:
sessions = sessions[-args.limit:]
if args.json:
print(json.dumps([s.__dict__ | {"file": str(s.file)} for s in sessions], indent=2, default=str))
return
if args.resume:
for s in sessions:
label = s.name or s.session_id[:8]
print(f"# {label} {cwd_short(s.cwd)} {fmt_ts(s.last_ts)}")
print(f"(cd {s.cwd or '.'} && claude --resume {s.session_id})")
return
# Pretty default output: name | last | cwd | preview
print(f"{'NAME':30s} {'LAST':12s} {'MSGS':>5s} {'CWD':40s} PREVIEW")
print("-" * 130)
for s in sessions:
name = (s.name or f"({s.session_id[:8]})")[:30]
last = fmt_ts(s.last_ts)
cwd = cwd_short(s.cwd)[:40]
preview = s.first_prompt.replace("\n", " ")[:60]
print(f"{name:30s} {last:12s} {s.msg_count:>5d} {cwd:40s} {preview}")
print(f"\n{len(sessions)} session(s)")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment