Claude Code · Workflow Engineering

Building claude-session-management

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.

Built 21–22 July 2026 · macOS · Claude Code CLI · lives in the claude-workdocs private repo

1. The purpose

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:

2. Architecture at a glance

Claude Code session named via /rename SessionEnd hook log-session-end.sh claude-session-history.json name · folder · exit time · GUID resume-claude() sourced zsh function + picker …history-old.json archive filled by --prune /session-handoff + next-handoff-name.sh handoff/ <session-name>-NNN.md You, in any folder terminal on exit append if named resume-claude reads --prune moves old cd + claude --resume GUID writes
Figure 1 — Three flows share one data spine: the hook writes the log, the resume function reads it, the handoff command archives context beside it.

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.

3. The components

PieceFileRole
Exit loggerlog-session-end.shSessionEnd hook: detects a named session and appends an entry to the log
Hook registrationsettings.json → hooks.SessionEndMakes Claude Code run the logger at every session exit
The logclaude-session-history.jsonJSON array: one {sessionName, folder, exitTimestamp, sessionId} per exit
Archiveclaude-session-history-old.jsonSuperseded entries moved here by resume-claude --prune
Resume pickerclaude-session-resume.shSourced script: list, select, choose invocation, cd, resume
Shell entry pointresume-claude() in ~/.zshrcSources the picker so the cd persists and zsh functions are usable
Handoff namingnext-handoff-name.shDetects the current session, computes <name>-NNN.md (001→999→A01→…)
Handoff command/session-handoffCompacts the conversation into a numbered handoff document
Concept docclaude-session-history.mdReference documentation for the whole system

4. What we used, and how

4.1 The discovery that makes it work: transcript title records

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.

session exits any session, any reason read transcript path from hook payload custom-title? last record wins append entry tmp-file + mv exit 0, silent GUID-only session yes no
Figure 2 — The hook's decision: only sessions with a custom-title transcript record produce a log entry.

4.2 The hook (Claude Code 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:

4.3 The resume picker (a sourced zsh script)

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.

4.4 The handoff subsystem

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.

001 002 999 A01 A99 B01 Z99
Figure 3 — Handoff sequence numbers: three digits to 999, then letter pages (A01–A99, B01–B99, …) for another 2,574 documents per session name.

5. Rebuilding it from scratch

Prerequisites: Claude Code CLI, jq, zsh. Full sources live in ~/.claude/claude-session-management/; the steps below are the skeleton.

  1. Create the folder and (optionally) the symlink.
    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.
  2. Write the exit logger (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
  3. Register the hook in ~/.claude/settings.json under hooks.SessionEnd (JSON block shown in §4.2), then reload hooks (/hooks) or restart.
  4. Write the resume picker (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))'.
  5. Add the handoff pieces: 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.
  6. Test before trusting. Every script here was verified by piping synthetic hook payloads and fabricated logs through it (file-target overrides make this safe), covering: unnamed session → no entry; named → entry; append; corrupt file set-aside; symlink survival; prune idempotence; 999→A01 rollover.

6. Using it day to day

You want to…Do this
Mark a session as worth keeping/rename my-feature-work — that's the only opt-in there is
Record itNothing. Exiting the session logs it automatically
Get back to any named sessionresume-claude from any folder → pick session → pick invocation (Enter = claude-max)
Keep the log tidyresume-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 datajq . ~/.claude/claude-session-management/claude-session-history.json
The one habit that matters: name your sessions. Everything else — logging, resuming, pruning, handoffs — is automatic or one command away. An unnamed session leaves no trace here, by design.

7. Lessons worth stealing