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