cd /news/developer-tools/claude-hist · home topics developer-tools article
[ARTICLE · art-69040] src=gist.github.com ↗ pub= topic=developer-tools verified=true sentiment=· neutral

claude-hist

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`.

read16 min views1 publishedJul 22, 2026

| #!/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/[1]//; $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" |


  1. !^ ↩︎

── more in #developer-tools 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/claude-hist] indexed:0 read:16min 2026-07-22 ·