Skip to content

Instantly share code, notes, and snippets.

@vhppp
Created July 22, 2026 17:05
Show Gist options
  • Select an option

  • Save vhppp/8f4aeb24e3242a2faa682c3f08acc54e to your computer and use it in GitHub Desktop.

Select an option

Save vhppp/8f4aeb24e3242a2faa682c3f08acc54e to your computer and use it in GitHub Desktop.
claude-hist
#!/usr/bin/env bash
set -euo pipefail
# claude-hist: fuzzy-find a past Claude Code session across every project and
# resume it. Search matches titles/branches first, then conversation text.
# Sessions list newest first with each parent's subagents grouped beneath it;
# selecting a subagent resumes its parent. Selecting a session cd's to its
# working directory and runs `claude --resume`. A bottom preview pane shows the
# transcript (PgUp/PgDn scroll it, arrow keys move the selection) with query
# terms highlighted.
#
# Usage: claude-hist [--rebuild]
# --rebuild Discard the metadata cache and rebuild it from scratch.
#
# Deps: fzf, jq, claude (perl optional, for preview highlighting).
PROJECTS_DIR="$HOME/.claude/projects"
CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/claude-hist"
CACHE_FILE="$CACHE_DIR/index.tsv"
C_HEADER=$'\033[1;36m' # user turns
C_CLAUDE=$'\033[1;32m' # claude turns
C_META=$'\033[2m' # dim meta line
C_LINK=$'\033[1;33m' # subagent -> parent link line
C_RESET=$'\033[0m'
usage() {
cat <<'EOF'
claude-hist: fuzzy-find a past Claude Code session across every project and
resume it. Searches session title, branch, and conversation text, newest first.
Selecting a session cd's to its working directory and runs `claude --resume`.
The preview pane shows the transcript (PgUp/PgDn scroll it; arrows move the
selection) with matched query terms highlighted.
Usage: claude-hist [--rebuild]
--rebuild Discard the metadata cache and rebuild it from scratch.
Deps: fzf, jq, claude (perl optional, for preview highlighting).
EOF
}
die() { echo "claude-hist: $*" >&2; exit 1; }
# Batch-stat command emitting "<mtime-epoch> <path>" per file in one process.
# BSD stat (macOS) and GNU stat (Linux) take different flags. Kept as an array
# so it can be handed to xargs (a function cannot).
if stat -f %m . >/dev/null 2>&1; then
STAT_CMD=(stat -f '%m %N')
else
STAT_CMD=(stat -c '%Y %n')
fi
fmt_date() {
date -r "$1" +'%Y-%m-%d %H:%M' 2>/dev/null || date -d "@$1" +'%Y-%m-%d %H:%M'
}
# Per-session metadata extracted from the .jsonl in one pass. Output (one line,
# tab-separated): name, cwd, branches, search-blob. `branches` is every distinct
# git branch the session touched, most-recent first and space-joined, so a
# session that switched branches is findable by any of them (the display shows
# only the first token). The blob is every user/claude text turn flattened onto
# one line so fzf can search it.
# shellcheck disable=SC2016 # this is a jq program, not shell; $vars are jq's
META_JQ='
def txt:
(.message.content) as $c
| if ($c|type)=="string" then $c
elif ($c|type)=="array" then ([$c[]|select(.type=="text")|.text]|join(" "))
else "" end;
[inputs] as $rows
| ($rows | map(select(.type=="ai-title")|.aiTitle) | last) as $ai
| ($rows | map(select(.type=="last-prompt")|.lastPrompt) | last) as $lp
| ($rows | map(select(.type=="user")|txt|select(length>0)) | first) as $firstuser
| ($rows | map(select(.type=="user" or .type=="assistant")|txt|select(length>0))) as $texts
| ($rows | map(.cwd // empty) | first) as $cwd
| ($rows | map(.gitBranch // empty) | map(select(length>0))) as $bl
| (reduce ($bl | reverse | .[]) as $b ([]; if index($b) then . else . + [$b] end) | join(" ")) as $branches
| (($ai // $lp // $firstuser // "(untitled)") | gsub("[\n\r\t]";" ")) as $name
| (($texts | join(" ")) | gsub("[\n\r\t]";" ")) as $blob
| [$name, ($cwd // ""), $branches, $blob] | @tsv
'
# The --preview and --emit branches below are re-invocations of this script (by
# fzf and by xargs). They sit above the dependency checks and setup so each of
# the hundreds of parallel worker processes exits early without that overhead.
# --- preview subcommand (invoked by fzf, one session file per call) ----------
# Renders a readable, plain-ASCII transcript of a session's natural-language
# turns (tool output and file dumps are omitted). The current fzf query is
# passed as the 2nd arg and its terms are reverse-video highlighted here in the
# preview (the list itself is left unhighlighted, see --color below).
if [[ "${1:-}" == "--preview" ]]; then
file="${2:?}"; query="${3:-}"
[[ -f "$file" ]] || { echo "(session file missing)"; exit 0; }
highlight=(cat)
if [[ -n "$query" ]] && command -v perl >/dev/null 2>&1; then
# shellcheck disable=SC2016 # this is a perl program; $vars are perl's
highlight=(env "QUERY=$query" perl -pe 'BEGIN{@t=grep{length} map {my $x=$_; $x=~s/^[!^]//; $x=~s/\$$//; $x} split /\s+/,$ENV{QUERY}} for my $t (@t){ s/(\Q$t\E)/\033[7m$1\033[27m/gi }')
fi
# If this file is a subagent, emit a link line naming its parent session (title
# looked up from the cache; falls back to the parent id) so the connection is
# visible while reading the subagent's transcript.
parent_line=""
if [[ "$file" == */subagents/agent-*.jsonl ]]; then
pdir="${file%/subagents/*}"; psid="${pdir##*/}"
ptitle=""
[[ -f "$CACHE_FILE" ]] && ptitle="$(awk -F'\t' -v p="$psid" '$2==p{print $5; exit}' "$CACHE_FILE")"
[[ -n "$ptitle" ]] || ptitle="(parent session)"
parent_line="${C_LINK}↳ subagent of: ${ptitle} [${psid}]${C_RESET}"
fi
{
[[ -n "$parent_line" ]] && printf '%s\n' "$parent_line"
jq -rn '
def txt:
(.message.content) as $c
| if ($c|type)=="string" then $c
elif ($c|type)=="array" then ([$c[]|select(.type=="text")|.text]|join("\n"))
else "" end;
[inputs] as $rows
| ($rows | map(.cwd // empty) | first) as $cwd
| ($rows | map(.gitBranch // empty) | map(select(length>0)) | last) as $branch
| ($rows | map(select(.type=="user" or .type=="assistant")
| {role:(.message.role//"?"), text:txt}
| select(.text|length>0))) as $msgs
| "DIR: \($cwd // "?") BRANCH: \($branch // "-") TURNS: \($msgs|length)",
($msgs[] | (if .role=="user" then ">>> USER" else "<<< CLAUDE" end), .text, "")
' "$file" 2>/dev/null | sed \
-e "1s/.*/${C_META}&${C_RESET}/" \
-e "s/^>>> USER$/${C_HEADER}&${C_RESET}/" \
-e "s/^<<< CLAUDE$/${C_CLAUDE}&${C_RESET}/"
} | "${highlight[@]}"
exit 0
fi
# Hidden worker: emit one index line for a single session file into <outdir>.
# Invoked in parallel by xargs during (re)indexing. Writes to a per-session file
# so large blob lines from concurrent workers never interleave.
if [[ "${1:-}" == "--emit" ]]; then
outdir="${2:?}"; file="${3:?}"
sid="${file##*/}"; sid="${sid%.jsonl}"
read -r emtime _ < <("${STAT_CMD[@]}" "$file")
rec="$(jq -rn "$META_JQ" "$file" 2>/dev/null || true)"
[[ -n "$rec" ]] || rec=$'(untitled)\t\t\t'
# Output filename is a hash of the full path: two files sharing a basename
# (same session id under two project dirs) must not clobber each other.
out="$outdir/$(printf '%s' "$file" | cksum | tr ' ' '-')"
printf '%s\t%s\t%s\t%s\t%s\n' "$emtime" "$sid" "$file" "$(fmt_date "$emtime")" "$rec" > "$out"
exit 0
fi
[[ "${1:-}" == "-h" || "${1:-}" == "--help" ]] && { usage; exit 0; }
if [[ $# -gt 0 && "${1:-}" != "--rebuild" ]]; then
die "unknown argument: $1 (see --help)"
fi
for cmd in fzf jq claude; do
command -v "$cmd" &>/dev/null || die "$cmd is required but not installed"
done
[[ -d "$PROJECTS_DIR" ]] || die "no Claude sessions found at $PROJECTS_DIR"
if [[ "${1:-}" == "--rebuild" ]]; then
rm -rf "$CACHE_DIR"
fi
mkdir -p "$CACHE_DIR"
# Sweep work files stranded by a crash (SIGKILL skips the EXIT trap). The
# mktemp pattern is index.XXXXXX (6 chars), which can never match index.tsv.
rm -f "$CACHE_DIR"/index.??????
SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
# The index is one combined TSV cache, one line per session:
# mtime sid file datestr name cwd branch blob
# Warm runs reuse every line whose mtime still matches, so bash never has to
# touch the (multi-MB) blobs; awk does all the heavy lifting.
tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/claude-hist.XXXXXX")"
trap 'rm -rf "$tmp_root"' EXIT
# Rescan sessions and fold changes into CACHE_FILE. Called at startup and again
# after the resumed claude session ends, so the just-closed session is indexed
# while it's hot and the next startup stays fast. Returns 1 when no sessions
# exist. A work file stranded by a crash mid-call is caught by the startup sweep.
refresh_index() {
local cur="$tmp_root/cur" regen="$tmp_root/regen" drop="$tmp_root/drop"
local emit="$tmp_root/emit" work n jobs
rm -rf "$emit"; mkdir "$emit"
# Current sessions with their mtimes: "sid <tab> mtime <tab> file".
find "$PROJECTS_DIR" -type f -name '*.jsonl' -print0 \
| xargs -0 "${STAT_CMD[@]}" 2>/dev/null \
| while read -r mtime path; do
sid="${path##*/}"; sid="${sid%.jsonl}"
printf '%s\t%s\t%s\n' "$sid" "$mtime" "$path"
done > "$cur"
[[ -s "$cur" ]] || return 1
# Compare against the cached index to find which sessions are new/changed
# (regen) and which cache lines are now invalid (drop = changed + deleted).
# Keyed by full path, not basename: the same session id can exist under two
# project dirs (a renamed project leaves the old dir behind), and a basename
# key would conflate them. No blobs are read here.
: > "$regen"; : > "$drop"
if [[ -f "$CACHE_FILE" ]]; then
awk -F'\t' -v regen="$regen" -v drop="$drop" '
FNR==NR { cm[$3]=$1; next } # index: file($3)->mtime($1)
{ curm[$3]=1
if (!($3 in cm) || cm[$3]!=$2) { print $1"\t"$2"\t"$3 > regen; print $3 > drop } }
END { for (p in cm) if (!(p in curm)) print p > drop }
' "$CACHE_FILE" "$cur"
else
cp "$cur" "$regen"
fi
if [[ -s "$regen" || -s "$drop" ]]; then
n=$(wc -l < "$regen" | tr -d ' ')
[[ "$n" -gt 0 ]] && printf 'claude-hist: indexing %s session(s)...\n' "$n" >&2
# The work file lives beside CACHE_FILE so the final rename is atomic on
# every platform (TMPDIR is often a separate filesystem on Linux).
work="$(mktemp "${CACHE_DIR}/index.XXXXXX")"
# Keep still-valid cache lines (drop changed + deleted), matched by file path.
if [[ -f "$CACHE_FILE" ]]; then
awk -F'\t' 'FNR==NR{d[$1];next} !($3 in d)' "$drop" "$CACHE_FILE" > "$work"
fi
# Regenerate changed/new sessions in parallel (one jq per file across cores).
# Each worker writes its own file so large blob lines never interleave.
if [[ -s "$regen" ]]; then
jobs="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)"
# NUL-delimit paths so ones containing spaces survive; tolerate a worker
# failing on a single bad session rather than aborting the whole tool.
cut -f3 "$regen" | tr '\n' '\0' \
| xargs -0 -P "$jobs" -n1 "$SELF" --emit "$emit" || true
cat "$emit"/* >> "$work" 2>/dev/null || true
fi
mv "$work" "$CACHE_FILE"
fi
return 0
}
refresh_index || die "no sessions to show"
# Emit the fzf list, newest first. Each line is:
# field1 <tab> sid <tab> file <tab> cwd
# fzf shows and searches ONLY field1 (--with-nth=1). fzf's --nth can't reach
# hidden columns once --with-nth is set (it re-indexes the transformed line),
# so everything searchable must live in field1. field1 = a readable, aligned
# prefix (date, first branch, dir, title) followed by a DIM tail carrying the
# full branch list, dir, and conversation text. --ansi keeps the dim tail
# matchable while de-emphasizing it visually, and (with --exact + begin
# tiebreak) prefix hits rank above log hits -- names/branches first, logs last.
# Streamed straight into fzf (not captured) to avoid holding megabytes of blob
# text in a shell variable.
# Subagents are stored at <project>/<parent-sid>/subagents/agent-<id>.jsonl, so
# the parent session id is the directory name and the parent's own .jsonl is a
# sibling. They are not independently resumable (their internal sessionId IS the
# parent's), so a subagent row resumes the PARENT while previewing its own
# transcript. Two awk passes group each parent immediately above its subagents:
# pass 1 maps each real session's mtime+title; pass 2 tags every row with a
# group sort key: parent mtime, then group id (the parent sid, so families stay
# contiguous even when two parents share an mtime), then parent-before-children,
# then chronological.
render_list() {
awk -F'\t' -v OFS='\t' '
function issub(p) { return (p ~ "/subagents/agent-[^/]*\\.jsonl$") }
function parentsid(p, x){ x=p; sub("/subagents/agent-[^/]*\\.jsonl$","",x); sub(".*/","",x); return x }
function parentpath(p,x){ x=p; sub("/subagents/agent-[^/]*\\.jsonl$",".jsonl",x); return x }
FNR==NR { if (!issub($3)) { pmtime[$3]=$1; ptitle[$3]=$5 } next }
{
s = issub($3)
psid = s ? parentsid($3) : ""
gid = s ? parentpath($3) : $3
gm = s ? ((gid in pmtime) ? pmtime[gid] : $1) : $1
pt = (gid in ptitle) ? ptitle[gid] : ""
print gm, gid, (s ? 1 : 0), $1, s, psid, pt, $2, $3, $4, $5, $6, $7, $8
}
' "$CACHE_FILE" "$CACHE_FILE" \
| sort -t$'\t' -k1,1nr -k2,2 -k3,3n -k4,4n \
| awk -F'\t' -v OFS='\t' -v home="$HOME" -v dim=$'\033[2m' -v rst=$'\033[0m' '
function clean(t){ sub(/^<[^>]*> */,"",t); sub(/^[Yy]ou are an? /,"",t); gsub(/ +/," ",t); return t }
{
issub=$5; psid=$6; ptitle=$7; sid=$8; file=$9; date=substr($10,6); name=$11; cwd=$12; branch=$13; blob=$14
dir=cwd; if (dir=="") dir="?"; else { i=index(dir,home); if (i==1) dir="~" substr(dir,length(home)+1) }
title=clean(name)
if (issub=="1") {
st=title; if (length(st) > 80) st=substr(st,1,79) "..."
field1=sprintf(" \342\206\263 %s", st) " " dim branch " " dir " " ptitle " " blob rst
print field1, (psid != "" ? psid : sid), file, cwd
} else {
dispbr=branch; sub(/ .*/,"",dispbr); if (dispbr=="") dispbr="-"
t=title; if (length(t) > 90) t=substr(t,1,89) "..."
prefix=sprintf("%s %-26.26s %-38.38s %s", date, dispbr, dir, t)
field1=prefix " " dim branch " " dir " " blob rst
print field1, sid, file, cwd
}
}
'
}
selection=$(
fzf \
--ansi \
--exact \
--delimiter=$'\t' \
--with-nth=1 \
--no-hscroll \
--tiebreak=begin,index \
--layout=reverse \
--info=inline \
--prompt='search (name, branch, or log text) > ' \
--header='enter resume | pgup/pgdn scroll preview | up/down select | esc quit' \
--preview="\"$SELF\" --preview {3} {q}" \
--preview-window='down,55%,wrap,border-top' \
--color='hl:-1,hl+:-1' \
--bind='start:preview-bottom' \
--bind='focus:preview-bottom' \
--bind='pgup:preview-page-up' \
--bind='pgdn:preview-page-down' \
--bind='shift-up:preview-up' \
--bind='shift-down:preview-down' \
< <(render_list)
) || exit 0
[[ -n "$selection" ]] || exit 0
sid=$(printf '%s' "$selection" | cut -f2)
cwd=$(printf '%s' "$selection" | cut -f4)
if [[ -z "$cwd" || ! -d "$cwd" ]]; then
echo "claude-hist: this session's directory is gone${cwd:+ ($cwd)}." >&2
echo "claude-hist: resume is keyed to that directory, so it may not be found." >&2
cwd="$HOME"
fi
echo "Resuming $sid in $cwd"
cd "$cwd" || die "cannot cd to $cwd"
# Run claude as a child (not exec) so the index can be refreshed after the
# session ends: the just-closed session is by definition stale in the cache,
# and folding it in now keeps the next startup fast.
rc=0
claude --resume "$sid" || rc=$?
refresh_index || true
# claude's own exit hint omits the directory, but resume is scoped to the
# project dir, so a bare `claude --resume <id>` fails from anywhere else.
echo ""
echo "To rejoin this session later:"
echo " (cd $(printf '%q' "$cwd") && claude --resume $sid)"
exit "$rc"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment