From f79954e7fccf4e7c2dabf544a30c60937f85669f Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 17 May 2026 04:02:16 -0700 Subject: [PATCH] =?UTF-8?q?feat(@scripts):=20=E2=9C=A8=20add=20session=20e?= =?UTF-8?q?numeration=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- bin/_claude-projects | 161 ++++++++++++++++++++++++++++++++++++------- bin/rclaude | 155 +++++++++++++++++++++++++++++++++-------- tmux.conf | 14 ++++ 3 files changed, 275 insertions(+), 55 deletions(-) diff --git a/bin/_claude-projects b/bin/_claude-projects index 7d249c5..cdf550e 100755 --- a/bin/_claude-projects +++ b/bin/_claude-projects @@ -1,8 +1,12 @@ #!/usr/bin/env python3 -# Internal helper for rclaude — prints one tab-separated line per Claude -# project directory under ~/.claude/projects/, sorted by most recent first. +# Internal helper for rclaude — enumerates Claude Code state from +# ~/.claude/projects/. Two modes: # -# Output columns: \t\t +# (default) one tab-separated row per project directory +# columns: \t\t +# +# --sessions one tab-separated row per session jsonl +# columns: \t\t\t # # Used by `rclaude list` and `rclaude resume` to discover sessions that exist # on disk but have no live tmux session attached. Run locally or invoked @@ -10,41 +14,146 @@ import json import os +import re +import signal import sys from pathlib import Path -root = Path.home() / ".claude" / "projects" -if not root.is_dir(): - sys.exit(0) +# Don't dump a traceback when piped into `head`. +signal.signal(signal.SIGPIPE, signal.SIG_DFL) -rows = [] -for project_dir in root.iterdir(): - if not project_dir.is_dir(): - continue - jsonls = sorted(project_dir.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True) - if not jsonls: - continue +ROOT = Path.home() / ".claude" / "projects" - latest = jsonls[0] - cwd = None +# Reading every jsonl on a host with thousands of past sessions is slow. Cap +# the per-session listing to the N most recently modified jsonls. +SESSION_LIMIT = int(os.environ.get("CLAUDE_PROJECTS_LIMIT", "500")) + +# Lines that look system-injected rather than user-typed. When picking the +# "name" of a session for fuzzy matching we want the first real user line. +SYSTEM_PREFIXES = ( + "", "", "", + "", "", + "Caveat: The messages below", + "[task-persistence]", "[tts-state]", "[VERIFY:", + "This session is being continued", "Please continue", + "[result]", "", "[Request interrupted", +) + + +def looks_system(text: str) -> bool: + s = (text or "").lstrip() + if not s: + return True + return s.startswith(SYSTEM_PREFIXES) + + +def message_text(entry) -> str: + msg = entry.get("message") or {} + content = msg.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + parts.append(block.get("text", "")) + return " ".join(parts) + return "" + + +def first_user_snippet(jsonl: Path) -> str: + """Return a one-line snippet of the first non-system user message.""" try: - with latest.open() as f: + with jsonl.open("r", encoding="utf-8", errors="replace") as f: + for line in f: + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + role = (entry.get("message") or {}).get("role") or entry.get("type") + if role != "user": + continue + text = message_text(entry) + if looks_system(text): + continue + snippet = re.sub(r"\s+", " ", text).strip() + return snippet[:120] + except OSError: + pass + return "" + + +def session_cwd(jsonl: Path) -> str | None: + try: + with jsonl.open("r", encoding="utf-8", errors="replace") as f: for line in f: try: entry = json.loads(line) except json.JSONDecodeError: continue if entry.get("cwd"): - cwd = entry["cwd"] - break + return entry["cwd"] except OSError: - continue - if not cwd: - continue + return None + return None - mtime = int(latest.stat().st_mtime) - rows.append((mtime, cwd, len(jsonls))) -rows.sort(reverse=True) -for mtime, cwd, count in rows: - print(f"{mtime}\t{cwd}\t{count}") +def list_projects(): + if not ROOT.is_dir(): + return + rows = [] + for project_dir in ROOT.iterdir(): + if not project_dir.is_dir(): + continue + jsonls = sorted( + project_dir.glob("*.jsonl"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if not jsonls: + continue + cwd = session_cwd(jsonls[0]) + if not cwd: + continue + mtime = int(jsonls[0].stat().st_mtime) + rows.append((mtime, cwd, len(jsonls))) + rows.sort(reverse=True) + for mtime, cwd, count in rows: + print(f"{mtime}\t{cwd}\t{count}") + + +def list_sessions(): + if not ROOT.is_dir(): + return + # First pass: cheap stat-only collection, then keep the N most-recent + # to bound the second (expensive) jsonl-parse pass. + candidates = [] + for project_dir in ROOT.iterdir(): + if not project_dir.is_dir(): + continue + for jsonl in project_dir.glob("*.jsonl"): + try: + mtime = int(jsonl.stat().st_mtime) + except OSError: + continue + candidates.append((mtime, jsonl)) + candidates.sort(key=lambda x: x[0], reverse=True) + for mtime, jsonl in candidates[:SESSION_LIMIT]: + uuid = jsonl.stem + cwd = session_cwd(jsonl) or "" + if not cwd: + continue + snippet = first_user_snippet(jsonl).replace("\t", " ") + print(f"{mtime}\t{uuid}\t{cwd}\t{snippet}") + + +def main(): + args = sys.argv[1:] + if args and args[0] == "--sessions": + list_sessions() + else: + list_projects() + + +if __name__ == "__main__": + main() diff --git a/bin/rclaude b/bin/rclaude index d0a54ff..f4d107d 100755 --- a/bin/rclaude +++ b/bin/rclaude @@ -14,8 +14,14 @@ # Permission mode: --dangerously-skip-permissions is on by default. Override # with RCLAUDE_PERMS=default (or any --permission-mode value). # -# Hosts scanned by `list`/`resume` default to: local + apricot. Override with -# RCLAUDE_HOSTS="apricot black quinn-vps". +# Tmux config sync: the repo's canonical tmux.conf is pushed to the target +# host's ~/.tmux.d/session-tools.conf on every launch and source-lined from +# ~/.tmux.conf. Disable with RCLAUDE_SYNC_TMUX=0, or set =once to only write +# if the source-line isn't already present. +# +# Hosts scanned by `list`/`resume` default to: local + apricot + plum (the +# non-local one is dialed; the local one is rendered as "local"). Override +# with RCLAUDE_HOSTS="apricot black quinn-vps". # # Usage: # rclaude # local, $PWD @@ -23,8 +29,11 @@ # rclaude # remote: $PWD mirrored under remote $HOME # rclaude . # same as above (explicit form) # rclaude # remote (or local) at -# rclaude list # show active sessions across hosts -# rclaude resume [pattern] # reattach (interactive if pattern matches >1) +# rclaude list # tmux + per-project disk view +# rclaude list sessions # tmux + per-session disk view (uuid + snippet) +# rclaude resume [pattern] # reattach / resume by uuid prefix, snippet, +# # tmux name, or cwd substring (interactive +# # picker on >1 match) # # Mirror semantics: if local $PWD is $HOME/X/Y, the remote dir defaults to # ~/X/Y on the remote (the remote's $HOME, not $HOME from this machine). @@ -108,23 +117,74 @@ list_disk_on() { ' } -# Combined enumeration: tmux first (live), then on-disk. +# List on-disk Claude sessions per UUID on a host (via _claude-projects --sessions). +# Output one row per session jsonl: +# \tsession\t\t\t · +list_sessions_on() { + _host=$1 + _helper_dir=$(dirname "$(resolve_self)") + if is_local "$_host"; then + _raw=$("$_helper_dir/_claude-projects" --sessions 2>/dev/null || true) + else + _raw=$(ssh -o BatchMode=yes -o ConnectTimeout=3 "$_host" 'python3 - --sessions' < "$_helper_dir/_claude-projects" 2>/dev/null || true) + fi + _now=$(date +%s) + printf %s "$_raw" | awk -F'\t' -v host="$_host" -v now="$_now" ' + function rel(secs, abs, s) { + abs = (secs < 0) ? -secs : secs + if (abs < 60) s = abs " seconds" + else if (abs < 3600) s = int(abs/60) " min" + else if (abs < 86400) s = int(abs/3600) " hours" + else s = int(abs/86400) " days" + return s " ago" + } + NF >= 3 { + snippet = ($4 == "" ? "(no user text)" : $4) + printf "%s\tsession\t%s\t%s\t%s · %s\n", host, $2, snippet, $3, rel(now - $1) + } + ' +} + +# Combined enumeration: tmux first (live), then on-disk per-project. list_all_on() { list_tmux_on "$1" list_disk_on "$1" } +# Resume-search enumeration: tmux + per-session UUIDs/snippets. +list_search_on() { + list_tmux_on "$1" + list_sessions_on "$1" +} + # Push the canonical session-tools tmux fragment to and ensure # ~/.tmux.conf sources it. Idempotent; runs on every launch so config changes -# in the repo propagate without re-running install.sh on each host. Silent -# no-op if the repo fragment can't be located. +# in the repo propagate without re-running install.sh on each host. +# +# Controlled by RCLAUDE_SYNC_TMUX: +# 1 (default) auto-sync on every launch +# 0 never sync (e.g. host has a hand-tuned tmux config you don't +# want clobbered by a stray source-file line) +# once sync only if the source-file line isn't already present; +# useful on hosts where you've audited the fragment once and +# don't want repeated writes +# +# Silent no-op if the repo fragment can't be located. sync_tmux_conf() { _host=$1 + _mode=${RCLAUDE_SYNC_TMUX:-1} + case $_mode in + 0|off|no|false) return 0 ;; + esac _self=$(resolve_self) _repo=$(cd "$(dirname "$_self")/.." 2>/dev/null && pwd) _frag="$_repo/tmux.conf" [ -f "$_frag" ] || return 0 - _remote_cmd='mkdir -p ~/.tmux.d && cat > ~/.tmux.d/session-tools.conf && { grep -q "session-tools.conf" ~/.tmux.conf 2>/dev/null || printf "source-file ~/.tmux.d/session-tools.conf\n" >> ~/.tmux.conf; } && tmux source-file ~/.tmux.conf 2>/dev/null || true' + _guard="" + case $_mode in + once) _guard='grep -q "session-tools.conf" ~/.tmux.conf 2>/dev/null && exit 0;' ;; + esac + _remote_cmd="${_guard} "'mkdir -p ~/.tmux.d && cat > ~/.tmux.d/session-tools.conf && { grep -q "session-tools.conf" ~/.tmux.conf 2>/dev/null || printf "source-file ~/.tmux.d/session-tools.conf\n" >> ~/.tmux.conf; } && tmux source-file ~/.tmux.conf 2>/dev/null || true' if is_local "$_host"; then sh -c "$_remote_cmd" < "$_frag" 2>/dev/null || true else @@ -132,10 +192,14 @@ sync_tmux_conf() { fi } -# All hosts to scan for list/resume. +# All hosts to scan for list/resume. Defaults to "apricot plum" so resume +# discovery works symmetrically from either host (the local one is rendered +# as "local" and remote ones are filtered to drop any host that matches the +# current machine). scan_hosts() { printf "local\n" - for h in ${RCLAUDE_HOSTS:-apricot}; do + for h in ${RCLAUDE_HOSTS:-apricot plum}; do + is_local "$h" && continue printf "%s\n" "$h" done } @@ -145,28 +209,44 @@ scan_hosts() { # --------------------------------------------------------------------------- cmd_list() { - _mode=${1:-all} # all | tmux | disk - printf "%-10s %-6s %-60s %s\n" "HOST" "KIND" "SESSION/CWD" "DETAIL" + _mode=${1:-all} # all | tmux | disk | sessions + printf "%-10s %-7s %-60s %s\n" "HOST" "KIND" "SESSION/CWD/UUID" "DETAIL" scan_hosts | while IFS= read -r h; do case $_mode in - tmux) list_tmux_on "$h" ;; - disk) list_disk_on "$h" ;; - *) list_all_on "$h" ;; - esac | awk -F'\t' '{printf "%-10s %-6s %-60s %s\n", $1, $2, $3, $4}' + tmux) list_tmux_on "$h" ;; + disk) list_disk_on "$h" ;; + sessions|--sessions) + list_tmux_on "$h" + list_sessions_on "$h" ;; + *) list_all_on "$h" ;; + esac | awk -F'\t' '{ + # Tmux/disk rows: $3 is the display target. Session rows: $3=uuid, + # $4=snippet (show snippet, abbreviate uuid into DETAIL via $5). + if ($2 == "session") { + uuid_short = substr($3, 1, 8) + detail = (NF >= 5 ? $5 : "") " [" uuid_short "]" + printf "%-10s %-7s %-60.60s %s\n", $1, $2, $4, detail + } else { + printf "%-10s %-7s %-60.60s %s\n", $1, $2, $3, $4 + } + }' done } # Resume strategy: -# - 1 match → attach directly -# - 2+ matches → single-key picker (1-9 then a-z, max 35) -# - matches a tmux row → ssh+tmux attach (preserves the live conversation) -# - matches a disk row → re-exec self with ` ` so the normal -# launch path spins up a fresh tmux + claude --continue +# - 1 match → attach directly +# - 2+ matches → single-key picker (1-9 then a-z, max 35) +# - matches a tmux row → ssh+tmux attach (preserves the live conversation) +# - matches a session UUID → ssh+tmux+claude --resume at recorded cwd +# - matches a snippet/cwd → same as session (the row identifies a UUID) +# +# Pattern matching is case-insensitive substring across host/kind/uuid/snippet/cwd. +# An empty pattern lists everything (interactive picker). cmd_resume() { _pattern=${1:-} - _matches=$(scan_hosts | while IFS= read -r h; do list_all_on "$h"; done) + _matches=$(scan_hosts | while IFS= read -r h; do list_search_on "$h"; done) if [ -n "$_pattern" ]; then - _matches=$(printf '%s\n' "$_matches" | grep -F -- "$_pattern" || true) + _matches=$(printf '%s\n' "$_matches" | grep -F -i -- "$_pattern" || true) fi _count=0 [ -n "$_matches" ] && _count=$(printf '%s\n' "$_matches" | wc -l | tr -d ' ') @@ -180,9 +260,13 @@ cmd_resume() { echo "too many matches ($_count); refine pattern" >&2 exit 1 fi + # For sessions, display the human-readable snippet (col 4) rather + # than the bare UUID (col 3); for tmux/disk the existing col 3 is + # already the right thing to show. + _fmt_row='function display(){ if ($2 == "session") return $4 " [" substr($3,1,8) "]"; return $3 } { printf "%-10s %-7s %s", $1, $2, display() }' if [ ! -t 0 ] || [ ! -t 2 ]; then echo "multiple matches and no tty for picker; refine pattern:" >&2 - printf '%s\n' "$_matches" | awk -F'\t' '{printf " %-10s %-6s %s\n", $1, $2, $3}' >&2 + printf '%s\n' "$_matches" | awk -F'\t' "$_fmt_row"'{printf "\n"}' >&2 exit 1 fi _i=0 @@ -190,7 +274,7 @@ cmd_resume() { _i=$((_i + 1)) _k=$(printf %s "$_keys" | cut -c"$_i") printf ' [%s] %s\n' "$_k" \ - "$(printf %s "$_line" | awk -F'\t' '{printf "%-10s %-4s %s", $1, $2, $3}')" >&2 + "$(printf %s "$_line" | awk -F'\t' "$_fmt_row")" >&2 done _last_key=$(printf %s "$_keys" | cut -c"$_count") printf 'select [1-%s]: ' "$_last_key" >&2 @@ -216,6 +300,9 @@ cmd_resume() { _host=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $1}') _kind=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $2}') _target=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $3}') + # Sessions carry their cwd in column 5 (formatted " · "); + # extract the cwd half for the launch dir. + _session_cwd=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $5}' | awk -F' · ' '{print $1}') case $_kind in tmux) if is_local "$_host"; then @@ -225,11 +312,17 @@ cmd_resume() { fi ;; disk) - # Spawn tmux + claude --continue at the recorded cwd. The env var - # tells the launch path below to add --continue (it doesn't by - # default, since --continue is the explicit-resume behavior). + # Spawn tmux + claude --continue at the recorded cwd. RCLAUDE_RESUME=1 exec "$0" "$_host" "$_target" ;; + session) + # Spawn tmux + claude --resume at the session's recorded cwd. + if [ -z "$_session_cwd" ]; then + echo "rclaude: session $_target has no recorded cwd" >&2 + exit 1 + fi + RCLAUDE_RESUME_ID=$_target exec "$0" "$_host" "$_session_cwd" + ;; esac } @@ -316,7 +409,11 @@ build_inner() { # creates a new uniquely-named tmux session. Reattach to a live session # via `rclaude resume `; disk-resume after host death likewise. _resume_flag="" - [ "${RCLAUDE_RESUME:-0}" = "1" ] && _resume_flag="--continue" + if [ -n "${RCLAUDE_RESUME_ID:-}" ]; then + _resume_flag="--resume ${RCLAUDE_RESUME_ID}" + elif [ "${RCLAUDE_RESUME:-0}" = "1" ]; then + _resume_flag="--continue" + fi printf '%s' \ "cd ${1} && rc_t=\$(date +%s); claude ${_resume_flag} ${flag}; rc_ec=\$?; " \ "rc_e=\$(date +%s); rc_d=\$((rc_e - rc_t)); " \ diff --git a/tmux.conf b/tmux.conf index cbcaa69..ca41103 100644 --- a/tmux.conf +++ b/tmux.conf @@ -15,3 +15,17 @@ set -g history-limit 50000 # passed as arrow keys to the inner app (which would scroll Claude history etc.) # Hold Option on macOS to bypass tmux and use native terminal selection. set -g mouse on + +# Resize panes to the active client's size, not the smallest attached. Matters +# when reattaching the same durable session from different terminals (phone, +# laptop, IDE pane) — otherwise the layout collapses to the smallest viewer. +setw -g aggressive-resize on + +# Forward copy-mode selections to the OS clipboard via OSC 52, so mouse-drag +# or `y` in copy mode actually lands in the local terminal's clipboard even +# across ssh + tmux. +set -g set-clipboard on + +# Keep window indexes contiguous when one closes; quiet the activity flag spam. +set -g renumber-windows on +setw -g monitor-activity off