# claude-hist

> Source: <https://gist.github.com/vhppp/8f4aeb24e3242a2faa682c3f08acc54e>
> Published: 2026-07-22 17:05:06+00:00

| #!/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" |
