Claude Code · Workflow Engineering

Building claude-session-management

How a SessionEnd hook, a JSON log, a sourced zsh function, and a pair of name-keeping hooks turned ephemeral Claude Code sessions into a durable, resumable register of work.

Built 21–22 July 2026 · macOS · Claude Code CLI · name kept through /clear added later · lives in ~/coding-agents/claude-session-management

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 You, in any folder terminal on exit append if named resume-claude reads --prune moves old cd + claude --resume GUID /clear · every prompt same process, new session id name hooks restore- / capture-session-name.sh live registry + pid cache sessions/<pid>.json · $TMPDIR hooks fire sync name
Figure 1 — Three flows: the exit hook writes the log, the resume function reads it, and the name hooks keep the session's name right in the live registry when /clear starts a new session in the same process.

Everything sits in ~/coding-agents/claude-session-management/ and is reached through a single folder-level symlink at ~/.claude/claude-session-management — the same pattern used for ~/.claude/rules, settings.json, and CLAUDE.md, which point into ~/coding-agents/.claude-repo. Hooks and the shell function only ever use the ~/.claude/… path, so if the folder moves you recreate one link and nothing else changes.

3. The components

PieceFileRole
Exit loggerlog-session-end.shSessionEnd hook: detects a named session and appends an entry to the log
Name capturecapture-session-name.shUserPromptSubmit hook: copies the session name into the live registry, caches it per process, and restores the name label after /clear
Name restorerestore-session-name.shSessionStart hook (matcher clear): writes the name into the registry of the post-/clear session and arms the label repair
Shared helperssession-name-common.shFinding the Claude process, registry read/write, the per-process name cache, the U+200B nudge
Hook registrationsettings.json → hooksSessionEnd (logger), SessionStart matcher clear (restore), UserPromptSubmit (capture)
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 ~/.zshrc.claudeSources the picker so the cd persists and zsh functions are usable
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 hooks

Registered in the user-level settings.json. The exit logger is the SessionEnd entry; the other two belong to the name-keeping part described in §4.4:

"SessionEnd": [
  { "matcher": "*",
    "hooks": [ { "type": "command",
      "command": "$HOME/.claude/claude-session-management/log-session-end.sh" } ] }
],
"SessionStart": [
  /* … other SessionStart hooks … */
  { "matcher": "clear",
    "hooks": [ { "type": "command",
      "command": "$HOME/.claude/claude-session-management/restore-session-name.sh" } ] }
],
"UserPromptSubmit": [
  { "matcher": "*",
    "hooks": [ { "type": "command",
      "command": "$HOME/.claude/claude-session-management/capture-session-name.sh" } ] }
]

The logger'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.claude (sourced from ~/.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 sessionName + folder, keep each work item's latest exit, sort newest-first (by the same epoch-second instant --prune uses, see below) — 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.

Why not group by sessionId? Because Claude Code forks a new GUID on every resume: the fork inherits the transcript and the custom title, but not the identity. A work item resumed five times leaves five GUIDs in the log, all under one name. The stable identity is the sessionName, and the latest entry under that name carries the lineage tip — the GUID with the full inherited history, the only one worth resuming.

resume-claude --prune compacts the log along the same lines: only the latest entry per sessionName stays, everything superseded — repeated exits, ancestor GUIDs from earlier resumes, and exits recorded from a folder the work has since left — moves (never deletes) to the archive file. "Latest" is compared as an instant: the +0300-style offsets are normalised to epoch seconds first, so the DST fallback hour can't invert the ordering. The picker shares the same epoch definition, so both always agree on which exit is the latest.

Pruning is bulk maintenance; for a single session that has simply outlived its usefulness, the picker takes d<n> at the selection prompt. It reports how many entries the row covers, asks for confirmation, and moves the whole lineage — every logged exit and every ancestor GUID sharing that row's sessionName + folder — into the same archive. The list then reprints with fresh numbering, so the row numbers can never go stale between deletes. Both paths share one archive-append helper, so the corrupt-archive safeguard applies identically to each.

4.4 Keeping the name through /clear

/clear doesn't clear a session — it starts a new one in the same process: new session id, new transcript. Claude Code carries the title across (the new transcript opens with a custom-title record, so the exit logger still works), but two other copies of the name go wrong:

Two hooks and a shared helper file fix both, keyed on the one thing /clear doesn't change — the Claude Code process. A hook finds it by walking up its parent processes until one has a ~/.claude/sessions/<pid>.json file.

HookWhenWhat it does
capture-session-name.shEvery prompt (UserPromptSubmit) Reads the name (hook input session_title, then the transcript's last custom-title, then the registry if the user set it), writes it into the registry with nameSource: "user", and caches it in $TMPDIR/claude-session-names/<pid>.title. If the session has no title at all but the cache does, it hands the name back as sessionTitle.
restore-session-name.shRight after /clear (SessionStart, matcher clear) Writes the carried-over name into the registry and arms the label repair for the next prompt. It can't return the name itself: Claude Code discards a SessionStart hook's sessionTitle when the source is clear.

The label repair. The only way a hook can set the agent name is returning sessionTitle from UserPromptSubmit, and Claude Code ignores it when it equals the current title — which, right after a /clear, it always does. So on the first prompt after a /clear the capture hook returns the name with an invisible U+200B (zero-width space) appended. It differs, so it gets through and sets the agent name. On the next prompt the hook sees the marker and returns the clean name, which now differs again, and the session settles on its real name with the label back. U+200B is the one change that is invisible in the label and survives Claude Code's own clean-up of control characters. Everything that writes the name to disk — registry, cache, exit log — strips it first.

Safety rules for these hooks. They must never disturb the session: every helper fails soft and both hooks always exit 0. The capture hook prints a hook JSON object or nothing, because any other stdout from a UserPromptSubmit hook is injected into the conversation. Registry rewrites are read-modify-write and only replace the file if the result still parses with its pid and sessionId. PIDs get recycled, so the cache and the repair marker are stamped with the process start time (procStart) and ignored when it doesn't match. And an unnamed session stays unnamed: names Claude Code invented (nameSource derived or auto) are never copied anywhere.

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 ~/coding-agents/claude-session-management
    ln -sfn ~/coding-agents/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
    Strip U+200B from name before building the entry (see §4.4).
  3. Register the hooks in ~/.claude/settings.json: hooks.SessionEnd, a hooks.SessionStart block with matcher clear, and hooks.UserPromptSubmit (JSON in §4.2), then reload hooks (/hooks) or restart.
  4. Write the resume picker (claude-session-resume.sh) and the resume-claude() function in your zsh config (here ~/.zshrc.claude, sourced from ~/.zshrc). Remember: the script is sourced — use return, never exit; don't set -e/pipefail; dedupe with jq 'group_by([.sessionName, .folder]) | map(max_by(epoch))', where epoch turns exitTimestamp into epoch seconds — not by sessionId, since every resume forks a fresh GUID under the same name.
  5. Add the name hooks: session-name-common.sh (find the Claude process from the hook, registry read/write, the per-process cache, the U+200B nudge), restore-session-name.sh and capture-session-name.sh, as described in §4.4.
  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; DST-fallback ordering. The name hooks take CLAUDE_SESSIONS_DIR and CLAUDE_SESSION_NAME_CACHE overrides for the same purpose.

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
Clear the context but keep working under the same name/clear. The name stays in the registry, and the label above the input box comes back with your next prompt
Get back to any named sessionresume-claude from any folder → pick session → pick invocation (Enter = claude-max)
Keep the log tidyresume-claude --prune — only the latest entry per session name stays, the rest is archived
Drop a session you're done withd<n> at the picker prompt → confirm. Its entries move to the archive, never deleted
Inspect the raw datajq . ~/.claude/claude-session-management/claude-session-history.json
The one habit that matters: name your sessions. Everything else — logging, keeping the name through /clear, resuming, pruning — is automatic or one command away. An unnamed session leaves no trace here, by design.

7. Lessons worth stealing

☰ View all