# List recent Claude Code sessions across all project directories, sorted by real last-activity time

> Source: <https://gist.github.com/sleep/9b5192a7655497afab512512c205cbf0>
> Published: 2026-08-14 17:25:40+00:00

| #!/usr/bin/env bash | |
| # List the most recent Claude Code sessions across all directories. | |
| # | |
| # Sessions live at ~/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl. The | |
| # encoded directory name replaces "/" with "-", which is ambiguous for paths | |
| # that contain real hyphens, so the working directory is read from the "cwd" | |
| # field inside the transcript instead. | |
| set -uo pipefail | |
| PROJECTS_DIR="${CLAUDE_PROJECTS_DIR:-$HOME/.claude/projects}" | |
| COUNT=30 | |
| SHOW_ID=0 | |
| # Only the head and tail of each transcript are parsed. Transcripts reach | |
| # hundreds of MB, and everything needed lives at one end or the other: cwd and | |
| # the opening prompt near the start, the latest timestamp and title near the end. | |
| HEAD_BYTES=262144 | |
| TAIL_BYTES=262144 | |
| usage() { | |
| cat <<'EOF' | |
| Usage: claude-history.sh [-n COUNT] [-d PROJECTS_DIR] [-i] | |
| -n COUNT number of sessions to list (default: 30) | |
| -d DIR projects directory (default: $CLAUDE_PROJECTS_DIR or ~/.claude/projects) | |
| -i show session IDs and a ready-to-run resume command | |
| -h this help | |
| Sorted by the last activity timestamp recorded inside each transcript, not by | |
| file mtime -- mtimes drift when unrelated tooling touches the files. | |
| EOF | |
| } | |
| while getopts ':n:d:ih' opt; do | |
| case "$opt" in | |
| n) COUNT="$OPTARG" ;; | |
| d) PROJECTS_DIR="$OPTARG" ;; | |
| i) SHOW_ID=1 ;; | |
| h) | |
| usage | |
| exit 0 | |
| ;; | |
| *) | |
| usage >&2 | |
| exit 2 | |
| ;; | |
| esac | |
| done | |
| command -v jq >/dev/null || { | |
| echo "claude-history: jq is required" >&2 | |
| exit 1 | |
| } | |
| [ -d "$PROJECTS_DIR" ] || { | |
| echo "claude-history: no such directory: $PROJECTS_DIR" >&2 | |
| exit 1 | |
| } | |
| # mtime is always >= the last real activity, so it is a safe over-approximation | |
| # for narrowing the candidate pool before the accurate (but costlier) ranking. | |
| # The pool is deliberately larger than COUNT so that files with inflated mtimes | |
| # cannot push genuinely recent sessions out of contention. | |
| POOL=$((COUNT * 5)) | |
| [ "$POOL" -lt 150 ] && POOL=150 | |
| # Depth 2 keeps this to real sessions: subagent and workflow transcripts are | |
| # nested deeper under <session-id>/subagents/ and cannot be resumed. | |
| candidates=$( | |
| find "$PROJECTS_DIR" -maxdepth 2 -type f -name '*.jsonl' \ | |
| -exec stat -f '%m %N' {} + 2>/dev/null || | |
| find "$PROJECTS_DIR" -maxdepth 2 -type f -name '*.jsonl' \ | |
| -printf '%T@ %p\n' 2>/dev/null | |
| ) | |
| candidates=$(printf '%s\n' "$candidates" | sort -rn | head -n "$POOL" | cut -d' ' -f2-) | |
| [ -n "$candidates" ] || { | |
| echo "claude-history: no sessions found in $PROJECTS_DIR" >&2 | |
| exit 1 | |
| } | |
| # Newest ai-title wins; Claude rewrites it as a session's subject drifts. | |
| scan_tail() { | |
| tail -c "$TAIL_BYTES" "$1" 2>/dev/null | | |
| jq -Rr 'fromjson? // empty | |
| | if .type == "ai-title" and (.aiTitle // "") != "" then "A\t" + .aiTitle | |
| elif (.timestamp // "") != "" then "T\t" + .timestamp | |
| else empty end' 2>/dev/null | | |
| awk -F'\t' '$1=="T"{ts=$2} $1=="A"{title=$2} END{printf "%s\t%s\n", ts, title}' | |
| } | |
| # Fallback label: the first thing the human actually typed. Slash commands, | |
| # hook output, and the resume caveat are all recorded as user turns, so they are | |
| # filtered out -- none of them describe what the session was for. | |
| scan_head() { | |
| head -c "$HEAD_BYTES" "$1" 2>/dev/null | | |
| jq -Rr 'fromjson? // empty | |
| | select(.isSidechain != true and .isMeta != true) | |
| | ( if (.entrypoint // "") != "" then "E\t" + .entrypoint else empty end ), | |
| ( if (.cwd // "") != "" then "C\t" + .cwd else empty end ), | |
| ( select(.type == "user") | |
| | .message.content | |
| | if type == "string" then . | |
| elif type == "array" then (map(select(.type? == "text") | .text) | join(" ")) | |
| else empty end | |
| | select(type == "string") | |
| | select(test("^\\s*(<command-name>|<command-message>|<local-command|<system-reminder>|Caveat:|\\[Request interrupted)") | not) | |
| | gsub("\\s+"; " ") | sub("^ +"; "") | |
| | select(length > 0) | |
| | "P\t" + . )' 2>/dev/null | | |
| awk -F'\t' ' | |
| $1=="E" && entry=="" {entry=$2} | |
| $1=="C" && cwd=="" {cwd=$2} | |
| $1=="P" && prompt=="" {prompt=$2} | |
| cwd!="" && prompt!="" && entry!="" {exit} | |
| END{printf "%s\t%s\t%s\n", entry, cwd, prompt}' | |
| } | |
| rows="" | |
| while IFS= read -r f; do | |
| [ -n "$f" ] || continue | |
| IFS=$'\t' read -r ts title <<<"$(scan_tail "$f")" | |
| IFS=$'\t' read -r entrypoint cwd prompt <<<"$(scan_head "$f")" | |
| # Claude Code spawns headless helper runs (conversation-title generation and | |
| # friends) that land in $TMPDIR and are indistinguishable from real sessions | |
| # by path alone. They record entrypoint "sdk-cli" rather than "cli". | |
| [ "${entrypoint:-cli}" = "sdk-cli" ] && continue | |
| if [ -z "${ts:-}" ]; then | |
| mtime=$(stat -f '%m' "$f" 2>/dev/null || stat -c '%Y' "$f" 2>/dev/null) | |
| ts=$(date -u -r "$mtime" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || | |
| date -u -d "@$mtime" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) | |
| fi | |
| task="${title:-${prompt:-}}" | |
| [ -n "$task" ] || task="(no prompt recorded)" | |
| # ISO-8601 UTC strings sort correctly as plain text, so no per-file date | |
| # conversion is needed here -- only the surviving rows get converted below. | |
| rows+="${ts}"$'\t'"${cwd:-?}"$'\t'"${task}"$'\t'"$(basename "$f" .jsonl)"$'\n' | |
| done <<<"$candidates" | |
| term_width=$(tput cols 2>/dev/null || echo 120) | |
| [ "$term_width" -lt 60 ] && term_width=60 | |
| printf '%s' "$rows" | sort -r | head -n "$COUNT" | | |
| while IFS=$'\t' read -r ts cwd task id; do | |
| epoch=$(TZ=UTC date -j -f '%Y-%m-%dT%H:%M:%S' "${ts%.*}" '+%s' 2>/dev/null || | |
| date -u -d "$ts" '+%s' 2>/dev/null) | |
| when=$(date -r "$epoch" '+%Y-%m-%d %H:%M' 2>/dev/null || | |
| date -d "@$epoch" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "${ts%T*}") | |
| short_cwd="${cwd/#$HOME/\~}" | |
| [ ${#short_cwd} -gt 32 ] && short_cwd="…${short_cwd: -31}" | |
| task_width=$((term_width - 52)) | |
| [ "$task_width" -lt 20 ] && task_width=20 | |
| [ ${#task} -gt "$task_width" ] && task="${task:0:$((task_width - 1))}…" | |
| printf '%-16s %-32s %s\n' "$when" "$short_cwd" "$task" | |
| if [ "$SHOW_ID" -eq 1 ]; then | |
| printf '%-16s cd %s && claude --resume %s\n' '' "$cwd" "$id" | |
| fi | |
| done |
