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