How a SessionEnd hook, a JSON log, a sourced zsh function, and one slash command turned ephemeral Claude Code sessions into a durable, resumable, hand-off-able register of work.
Claude Code identifies every session by a GUID. That is fine for the machine and useless for a
human: after a few weeks of daily use, dozens of sessions accumulate per project and the resume
picker becomes a wall of look-alike topic titles. The insight this system is built on is simple:
the sessions worth returning to are exactly the ones you bothered to name with
/rename.
So the goal was a small system that:
claude-workdocs, reached from ~/.claude through a single symlink.Everything sits in ~/claude-workdocs/.claude/claude-session-management/ and is reached
through a single folder-level symlink at ~/.claude/claude-session-management — the same
pattern the repo already uses for ~/.claude/rules, settings.json, and
CLAUDE.md. Move the repo, recreate one link, and the whole system moves with it.
| Piece | File | Role |
|---|---|---|
| Exit logger | log-session-end.sh | SessionEnd hook: detects a named session and appends an entry to the log |
| Hook registration | settings.json → hooks.SessionEnd | Makes Claude Code run the logger at every session exit |
| The log | claude-session-history.json | JSON array: one {sessionName, folder, exitTimestamp, sessionId} per exit |
| Archive | claude-session-history-old.json | Superseded entries moved here by resume-claude --prune |
| Resume picker | claude-session-resume.sh | Sourced script: list, select, choose invocation, cd, resume |
| Shell entry point | resume-claude() in ~/.zshrc | Sources the picker so the cd persists and zsh functions are usable |
| Handoff naming | next-handoff-name.sh | Detects the current session, computes <name>-NNN.md (001→999→A01→…) |
| Handoff command | /session-handoff | Compacts the conversation into a numbered handoff document |
| Concept doc | claude-session-history.md | Reference documentation for the whole system |
The SessionEnd hook payload contains session_id, transcript_path,
cwd, and reason — but not the session's name. Digging into the
transcript .jsonl files revealed two distinct record types:
{"type":"ai-title","aiTitle":"Locate biks-claude-tools marketplace folder", ...}
{"type":"custom-title","customTitle":"claude-workspace-management", ...}
ai-title is the auto-generated topic title every session gets;
custom-title appears only when a human runs /rename. That is the
whole filter: grep the transcript for the last custom-title record. Present → log it.
Absent → exit silently. The log records intent, not noise.
custom-title transcript record produce a log entry.SessionEnd)Registered in the user-level settings.json:
"SessionEnd": [
{
"matcher": "*",
"hooks": [
{ "type": "command",
"command": "$HOME/.claude/claude-session-management/log-session-end.sh" }
]
}
]
The script's safety habits, all of which earned their keep during testing:
mv over the target.mv onto a symlink replaces the
link, not the target. The script resolves with readlink first, so the
repo wiring can never be silently disconnected.claude-session-history.json.corrupt.<epoch>, never overwritten.CLAUDE_SESSION_HISTORY_FILE
override, so every code path was pipe-tested with synthetic hook payloads before registration.The one non-obvious design decision: resume-claude must be a shell function that
sources the script, not an executable. A subprocess can neither change your
terminal's directory nor see the zsh functions (claudey, claude-max,
deep-claude…) you use to launch Claude with different configurations. Sourcing runs
the logic inside your interactive shell, so the cd sticks and any invocation works:
# ~/.zshrc
resume-claude() {
source "$HOME/.claude/claude-session-management/claude-session-resume.sh" "$@"
}
The picker itself is jq doing the heavy lifting — group the log by
sessionId, keep each session's latest exit, sort newest-first — followed by two
prompts (which session, which invocation, default claude-max) and finally
cd <folder> && <invocation> --resume <GUID>. Being sourced also
imposes discipline: no set -e, no exit (only return) — you are
running inside the user's live shell.
resume-claude --prune compacts the log: the latest entry per session stays, everything
superseded moves (never deletes) to the archive file.
Handoffs reuse an idea from the mattpocock-skills plugin, whose
handoff skill sets disable-model-invocation: true — it cannot be called
through the Skill tool, only typed by a human. The fix was to replicate its (small, good)
prompt inside our own /session-handoff command: summarize the conversation for a fresh
agent, add a "Suggested skills" section, reference existing artifacts by path instead of
duplicating them, redact secrets, and honor an optional argument describing the next session's focus.
The current session is identified without any environment variable, via the live registry
~/.claude/sessions/*.json: the busy session in the current folder with the
freshest statusUpdatedAt is, by construction, the one running the command — and its
registry entry carries the session name.
Prerequisites: Claude Code CLI, jq, zsh. Full sources live in
~/.claude/claude-session-management/; the steps below are the skeleton.
mkdir -p ~/claude-workdocs/.claude/claude-session-management/handoff
ln -s ~/claude-workdocs/.claude/claude-session-management \
~/.claude/claude-session-management
Skip the symlink and use a plain ~/.claude/claude-session-management folder if you
don't keep your Claude config in a repo.log-session-end.sh). Core logic:
INPUT=$(cat) # hook payload on stdin
transcript=$(jq -r '.transcript_path // empty' <<< "$INPUT")
cwd=$(jq -r '.cwd // empty' <<< "$INPUT")
sid=$(jq -r '.session_id // empty' <<< "$INPUT")
name=$(grep '"type":"custom-title"' "$transcript" | tail -1 \
| jq -r '.customTitle // empty')
[ -n "$name" ] || exit 0 # unnamed -> not our business
entry=$(jq -n --arg n "$name" --arg c "$cwd" \
--arg t "$(date '+%Y-%m-%dT%H:%M:%S%z')" --arg s "$sid" \
'{sessionName:$n, folder:$c, exitTimestamp:$t, sessionId:$s}')
# append atomically: jq '. + [$e]' -> tmp -> mv~/.claude/settings.json under
hooks.SessionEnd (JSON block shown in §4.2), then reload hooks
(/hooks) or restart.claude-session-resume.sh) and the
resume-claude() function in ~/.zshrc. Remember: the script is
sourced — use return, never exit; don't set
-e/pipefail; dedupe with
jq 'group_by(.sessionId) | map(max_by(.exitTimestamp))'.next-handoff-name.sh (session detection
from ~/.claude/sessions/ + the 001→999→A01 sequence) and a
/session-handoff command markdown in ~/.claude/commands/ that runs the
helper, writes the handoff, and reports the path.| You want to… | Do this |
|---|---|
| Mark a session as worth keeping | /rename my-feature-work — that's the only opt-in there is |
| Record it | Nothing. Exiting the session logs it automatically |
| Get back to any named session | resume-claude from any folder → pick session → pick invocation (Enter = claude-max) |
| Keep the log tidy | resume-claude --prune — latest entry per session stays, the rest is archived |
| Hand work off with context | /session-handoff (optionally: /session-handoff focus for next time) inside the session |
| Inspect the raw data | jq . ~/.claude/claude-session-management/claude-session-history.json |
.jsonl transcripts carry
structured records (custom-title, ai-title, …) that hooks can query with
grep + jq — no private APIs needed.mv replaces links, not targets.
Any script that writes through a symlinked config tree must resolve first.exit, no
set -e, local everywhere — you're a guest in the user's shell.