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.
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:
/clear, which otherwise starts a fresh session that reads as unnamed everywhere;~/coding-agents/claude-session-management, reached from ~/.claude through a single symlink./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.
| Piece | File | Role |
|---|---|---|
| Exit logger | log-session-end.sh | SessionEnd hook: detects a named session and appends an entry to the log |
| Name capture | capture-session-name.sh | UserPromptSubmit hook: copies the session name into the live registry, caches it per process, and restores the name label after /clear |
| Name restore | restore-session-name.sh | SessionStart hook (matcher clear): writes the name into the registry of the post-/clear session and arms the label repair |
| Shared helpers | session-name-common.sh | Finding the Claude process, registry read/write, the per-process name cache, the U+200B nudge |
| Hook registration | settings.json → hooks | SessionEnd (logger), SessionStart matcher clear (restore), UserPromptSubmit (capture) |
| 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.claude | Sources the picker so the cd persists and zsh functions are usable |
| 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.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:
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.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.
/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:
~/.claude/sessions/<pid>.json) keeps the
folder-derived name it was given at process start (nameSource: "derived", e.g.
my-project-9a). /rename doesn't update it either: it only appends a
custom-title record and changes in-memory state. Anything that reads the live session
list sees the folder name instead of yours./rename sets alongside the title and /clear drops. After a
/clear the session looks unnamed even though the title is right.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.
| Hook | When | What it does |
|---|---|---|
capture-session-name.sh | Every 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.sh | Right 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.
Prerequisites: Claude Code CLI, jq, zsh. Full sources live in
~/.claude/claude-session-management/; the steps below are the skeleton.
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.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).~/.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.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.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.CLAUDE_SESSIONS_DIR and CLAUDE_SESSION_NAME_CACHE overrides for the same purpose.| 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 |
| 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 session | resume-claude from any folder → pick session → pick invocation (Enter = claude-max) |
| Keep the log tidy | resume-claude --prune — only the latest entry per session name stays, the rest is archived |
| Drop a session you're done with | d<n> at the picker prompt → confirm. Its entries move to the archive, never deleted |
| Inspect the raw data | jq . ~/.claude/claude-session-management/claude-session-history.json |
/clear, resuming, pruning — is automatic or one command away. An unnamed session leaves no
trace here, by design..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.UserPromptSubmit hook's stdout is
either hook JSON or text injected into the conversation — so print one JSON object or nothing, and
always exit 0.