Last active
May 16, 2026 23:14
-
-
Save ginjo/ca71d7725f05e8652c25b93c3391651a to your computer and use it in GitHub Desktop.
List ClaudeCode sessions in table format.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # Session manager for claude-code. | |
| # | |
| # Lists sessions with names, dates, and first-message snippets — working around | |
| # the claude-code bug where session names are not shown in the built-in selector. | |
| # | |
| # Requires: python3, claude (in PATH) | |
| # No external dependencies (fzf not required). | |
| # | |
| # Expects Claude data to be in the default location '~/.claude/'. | |
| # | |
| # Usage: | |
| # Run this script from the directory of the project you're listing sessions for. | |
| # | |
| # ./sessions.sh — list sessions for the current project (newest first) | |
| # ./sessions.sh -a — list sessions for all projects (shows project column) | |
| # ./sessions.sh -b — show git branch column | |
| # ./sessions.sh -c — show context size (tokens + % of 200k window) | |
| # ./sessions.sh -o — sort oldest first (default is newest first) | |
| # ./sessions.sh -s — interactive numbered selector to resume a session | |
| # ./sessions.sh <int> — set max message snippet length (default $MAX_LEN) | |
| # ./sessions.sh -s -a -b -c 120 — flags may be combined in any order | |
| # | |
| # Notes: | |
| # -s presents a numbered list; enter the number to resume, or q/Enter to quit. | |
| # When using -s with -a, only sessions belonging to the current project dir can | |
| # be resumed. To resume a session from another project, cd there first. | |
| SELECT=0 | |
| ALL=0 | |
| BRANCH=0 | |
| CONTEXT=0 | |
| OLDEST=0 | |
| MAX_LEN=30 | |
| usage() { | |
| cat <<EOF | |
| Usage: $(basename "$0") [flags] [max_msg_length] | |
| Run this script from the directory of the project you're listing sessions for. | |
| (no args) List sessions for the current project (newest first) | |
| -a List sessions for all projects | |
| -b Show git branch column | |
| -c Show context size (tokens and % of 200k window) | |
| -o Sort oldest first (default is newest first) | |
| -s Interactive numbered selector to resume a session | |
| -h Show this help and exit | |
| <int> Set max message snippet length (default $MAX_LEN) | |
| Flags may be combined in any order, e.g.: -s -a -b -c 120 | |
| EOF | |
| } | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| -s) SELECT=1; shift ;; | |
| -a) ALL=1; shift ;; | |
| -b) BRANCH=1; shift ;; | |
| -c) CONTEXT=1; shift ;; | |
| -o) OLDEST=1; shift ;; | |
| -h) usage; exit 0 ;; | |
| ''|*[!0-9]*) echo "Unknown argument: $1" >&2; exit 1 ;; | |
| *) MAX_LEN="${1:-MAX_LEN}"; shift ;; | |
| esac | |
| done | |
| # Convert current working dir to the project key used by claude: /foo/bar -> -foo-bar | |
| CURRENT_PROJECT="$(pwd | tr '/' '-')" | |
| list_sessions() { | |
| python3 - "$1" "$2" "$3" "$4" "$5" <<'PYEOF' | |
| import os, json, glob, re, sys | |
| snippet_len = int(sys.argv[1]) if len(sys.argv) > 1 and sys.argv[1] else 0 | |
| project_filter = sys.argv[2] if len(sys.argv) > 2 and sys.argv[2] else None | |
| show_branch = sys.argv[3] == "1" if len(sys.argv) > 3 else False | |
| show_context = sys.argv[4] == "1" if len(sys.argv) > 4 else False | |
| oldest_first = sys.argv[5] == "1" if len(sys.argv) > 5 else False | |
| CONTEXT_WINDOW = 200_000 | |
| def extract_text(content): | |
| if isinstance(content, str): | |
| text = content.strip() | |
| if text.startswith("<"): | |
| return "" | |
| return text.replace("\n", " ")[:snippet_len] | |
| if isinstance(content, list): | |
| for block in content: | |
| if isinstance(block, dict) and block.get("type") == "text": | |
| text = block.get("text", "").strip() | |
| if text and not text.startswith("<"): | |
| return text.replace("\n", " ")[:snippet_len] | |
| return "" | |
| sessions = {} | |
| for path in glob.glob(os.path.expanduser("~/.claude/projects/**/*.jsonl"), recursive=True): | |
| if "/subagents/" in path: | |
| continue | |
| # Extract project dir name (the component after ~/.claude/projects/) | |
| parts = path.split(os.sep) | |
| try: | |
| proj_idx = parts.index("projects") + 1 | |
| project = parts[proj_idx] | |
| except (ValueError, IndexError): | |
| project = "" | |
| if project_filter and project != project_filter: | |
| continue | |
| with open(path) as f: | |
| for line in f: | |
| try: | |
| obj = json.loads(line) | |
| sid = obj.get("sessionId") | |
| if not sid: | |
| continue | |
| ts = obj.get("timestamp", "") | |
| if sid not in sessions: | |
| sessions[sid] = {"ts": ts, "project": project, "name": "", "rename_ts": "", "first_msg": "", "asst_snippet": "", "branch": obj.get("gitBranch", ""), "max_input_tokens": 0, "max_cached_tokens": 0} | |
| elif not sessions[sid]["branch"] and obj.get("gitBranch"): | |
| sessions[sid]["branch"] = obj["gitBranch"] | |
| elif ts and (not sessions[sid]["ts"] or ts < sessions[sid]["ts"]): | |
| sessions[sid]["ts"] = ts | |
| if obj.get("type") == "assistant": | |
| usage = obj.get("message", {}).get("usage", {}) | |
| if isinstance(usage, dict): | |
| cached = usage.get("cache_read_input_tokens", 0) | |
| total_input = (usage.get("input_tokens", 0) + | |
| usage.get("cache_creation_input_tokens", 0) + | |
| cached) | |
| if total_input > sessions[sid]["max_input_tokens"]: | |
| sessions[sid]["max_input_tokens"] = total_input | |
| sessions[sid]["max_cached_tokens"] = cached | |
| content = obj.get("message", {}).get("content", "") | |
| rename_content = None | |
| if isinstance(content, str) and "/rename" in content: | |
| rename_content = content | |
| elif obj.get("type") == "system" and obj.get("subtype") == "local_command": | |
| c = obj.get("content", "") | |
| if isinstance(c, str) and "/rename" in c: | |
| rename_content = c | |
| if rename_content: | |
| m = re.search(r'<command-args>(.*?)</command-args>', rename_content) | |
| if m: | |
| name = m.group(1).strip() | |
| if not sessions[sid]["name"] or ts > sessions[sid]["rename_ts"]: | |
| sessions[sid]["name"] = name | |
| sessions[sid]["rename_ts"] = ts | |
| if not sessions[sid]["first_msg"]: | |
| if obj.get("type") == "user" and obj.get("userType") == "external": | |
| text = extract_text(content) | |
| if text: | |
| sessions[sid]["first_msg"] = text | |
| if not sessions[sid]["asst_snippet"] and obj.get("type") == "assistant": | |
| text = extract_text(obj.get("message", {}).get("content", "")) | |
| if text: | |
| sessions[sid]["asst_snippet"] = f"[asst] {text}" | |
| except: | |
| pass | |
| for sid, s in sorted(sessions.items(), key=lambda x: x[1]["ts"], reverse=not oldest_first): | |
| snippet = s["first_msg"] or s["asst_snippet"] | |
| branch_col = f" {s['branch']:20s}" if show_branch else "" | |
| if show_context: | |
| tok = s["max_input_tokens"] | |
| cached = s["max_cached_tokens"] | |
| pct = tok / CONTEXT_WINDOW * 100 if tok else 0 | |
| #ctx_col = f" {cached/1000:>5.1f}k cached {tok/1000:>5.1f}k ({pct:4.1f}%)" | |
| ctx_col = f" {tok/1000:>5.1f}k ({pct:4.1f}%) {cached/1000:>5.1f}k cached" | |
| else: | |
| ctx_col = "" | |
| if project_filter is None: | |
| proj = s["project"].lstrip("-") | |
| print(f"{s['ts'][:10]} {proj:30s} {s['name']:35s}{branch_col}{ctx_col} {sid} {snippet}") | |
| else: | |
| print(f"{s['ts'][:10]} {s['name']:40s}{branch_col}{ctx_col} {sid} {snippet}") | |
| PYEOF | |
| } | |
| # Pass project filter unless -a was given | |
| FILTER="" | |
| [[ $ALL -eq 0 ]] && FILTER="$CURRENT_PROJECT" | |
| if [[ $SELECT -eq 1 ]]; then | |
| # Build the session list and present a numbered menu | |
| mapfile -t LINES < <(list_sessions "$MAX_LEN" "$FILTER" "$BRANCH" "$CONTEXT" "$OLDEST") | |
| if [[ ${#LINES[@]} -eq 0 ]]; then | |
| echo "No sessions found." >&2 | |
| exit 0 | |
| fi | |
| for i in "${!LINES[@]}"; do | |
| printf "%3d %s\n" "$((i+1))" "${LINES[$i]}" | |
| done | |
| echo "" | |
| read -r -p "Select session number to resume (or q to quit): " CHOICE | |
| if [[ "$CHOICE" == "q" || "$CHOICE" == "Q" || -z "$CHOICE" ]]; then | |
| exit 0 | |
| fi | |
| if ! [[ "$CHOICE" =~ ^[0-9]+$ ]] || (( CHOICE < 1 || CHOICE > ${#LINES[@]} )); then | |
| echo "Invalid selection." >&2 | |
| exit 1 | |
| fi | |
| SELECTED_LINE="${LINES[$((CHOICE-1))]}" | |
| # When -a is active, the project column is shown — block cross-project resumption | |
| if [[ $ALL -eq 1 ]]; then | |
| SELECTED_PROJECT=$(echo "$SELECTED_LINE" | awk '{print $2}') | |
| CURRENT_PROJECT_DISPLAY="${CURRENT_PROJECT#-}" | |
| if [[ "$SELECTED_PROJECT" != "$CURRENT_PROJECT_DISPLAY" ]]; then | |
| echo "That session belongs to project '$SELECTED_PROJECT'." >&2 | |
| echo "Please cd to that project directory first, then run this script again." >&2 | |
| exit 1 | |
| fi | |
| fi | |
| SID=$(echo "$SELECTED_LINE" | grep -Eo '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') | |
| if [[ -z "$SID" ]]; then | |
| echo "Could not extract session ID." >&2 | |
| exit 1 | |
| fi | |
| claude --resume "$SID" | |
| else | |
| list_sessions "$MAX_LEN" "$FILTER" "$BRANCH" "$CONTEXT" "$OLDEST" | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment