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