Created
July 22, 2026 17:05
-
-
Save vhppp/8f4aeb24e3242a2faa682c3f08acc54e to your computer and use it in GitHub Desktop.
claude-hist
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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