From 47434868e011f2891a5cd85011333794421770f2 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 26 Apr 2026 00:04:09 -0700 Subject: [PATCH] rclaude: list/resume now cover on-disk Claude sessions, not just tmux - new bin/_claude-projects helper: prints tab-sep mtime/cwd/count for every ~/.claude/projects/ dir (parses .jsonl entries for cwd field) - list_tmux_on / list_disk_on / list_all_on: unified enumeration with KIND col - rclaude list [all|tmux|disk]: filterable view, sorted by recency - rclaude resume : matches against tmux + disk; tmux match attaches, disk match re-execs self to spawn fresh tmux + claude --continue at the cwd - helper streamed to remote via 'ssh host python3 -' so no install required on the remote side beyond python3 --- bin/_claude-projects | 50 +++++++++++++++++++++++++ bin/rclaude | 87 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 118 insertions(+), 19 deletions(-) create mode 100755 bin/_claude-projects diff --git a/bin/_claude-projects b/bin/_claude-projects new file mode 100755 index 0000000..7d249c5 --- /dev/null +++ b/bin/_claude-projects @@ -0,0 +1,50 @@ +#!/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. +# +# Output columns: \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 +# remotely via ssh. + +import json +import os +import sys +from pathlib import Path + +root = Path.home() / ".claude" / "projects" +if not root.is_dir(): + sys.exit(0) + +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 + + latest = jsonls[0] + cwd = None + try: + with latest.open() as f: + for line in f: + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if entry.get("cwd"): + cwd = entry["cwd"] + break + except OSError: + continue + if not cwd: + continue + + 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}") diff --git a/bin/rclaude b/bin/rclaude index 993f141..375b63d 100755 --- a/bin/rclaude +++ b/bin/rclaude @@ -39,12 +39,12 @@ is_local() { return 1 } -# List claude-* tmux sessions on a host. Output: one line per session, -# format: "\t\t" -list_sessions_on() { +# List claude-* tmux sessions on a host. Output one row per session: +# \ttmux\t\t +list_tmux_on() { _host=$1 if is_local "$_host"; then - command -v tmux >/dev/null 2>&1 || return 0 # no local tmux: nothing to list + command -v tmux >/dev/null 2>&1 || return 0 _raw=$(tmux ls 2>/dev/null || true) else _raw=$(ssh -o BatchMode=yes -o ConnectTimeout=3 "$_host" 'tmux ls 2>/dev/null' || true) @@ -55,11 +55,46 @@ list_sessions_on() { name=$1; sub(/:$/, "", name); $1=""; sub(/^[[:space:]]+/, ""); - printf "%s\t%s\t%s\n", host, name, $0 + printf "%s\ttmux\t%s\t%s\n", host, name, $0 } ' } +# List on-disk Claude project sessions on a host (via _claude-projects helper). +# Output one row per project: +# \tdisk\t\t> +list_disk_on() { + _host=$1 + _helper_dir=$(dirname "$0") + if is_local "$_host"; then + _raw=$("$_helper_dir/_claude-projects" 2>/dev/null || true) + else + # Send the helper over stdin so we don't depend on a pre-installed copy + # on the remote (and to dodge quoting issues). + _raw=$(ssh -o BatchMode=yes -o ConnectTimeout=3 "$_host" 'python3 -' < "$_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 { + printf "%s\tdisk\t%s\tsessions=%s, last used %s\n", host, $2, $3, rel(now - $1) + } + ' +} + +# Combined enumeration: tmux first (live), then on-disk. +list_all_on() { + list_tmux_on "$1" + list_disk_on "$1" +} + # All hosts to scan for list/resume. scan_hosts() { printf "local\n" @@ -73,19 +108,24 @@ scan_hosts() { # --------------------------------------------------------------------------- cmd_list() { - _any=0 - printf "%-10s %-50s %s\n" "HOST" "SESSION" "DETAIL" + _mode=${1:-all} # all | tmux | disk + printf "%-10s %-6s %-60s %s\n" "HOST" "KIND" "SESSION/CWD" "DETAIL" scan_hosts | while IFS= read -r h; do - list_sessions_on "$h" | while IFS="$(printf '\t')" read -r host name detail; do - printf "%-10s %-50s %s\n" "$host" "$name" "$detail" - _any=1 - done + 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}' done } +# Resume strategy: +# - 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 in that dir cmd_resume() { _pattern=${1:-} - _matches=$(scan_hosts | while IFS= read -r h; do list_sessions_on "$h"; done) + _matches=$(scan_hosts | while IFS= read -r h; do list_all_on "$h"; done) if [ -n "$_pattern" ]; then _matches=$(printf '%s\n' "$_matches" | grep -F -- "$_pattern" || true) fi @@ -97,16 +137,25 @@ cmd_resume() { fi if [ "$_count" -gt 1 ]; then echo "multiple matches; refine pattern:" >&2 - printf '%s\n' "$_matches" | awk -F'\t' '{printf " %-10s %s\n", $1, $2}' >&2 + printf '%s\n' "$_matches" | awk -F'\t' '{printf " %-10s %-6s %s\n", $1, $2, $3}' >&2 exit 1 fi _host=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $1}') - _name=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $2}') - if is_local "$_host"; then - exec tmux attach -t "$_name" - else - exec ssh -t "$_host" tmux attach -t "$_name" - fi + _kind=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $2}') + _target=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $3}') + case $_kind in + tmux) + if is_local "$_host"; then + exec tmux attach -t "$_target" + else + exec ssh -t "$_host" tmux attach -t "$_target" + fi + ;; + disk) + # Spawn a fresh tmux + claude --continue at the recorded cwd. + exec "$0" "$_host" "$_target" + ;; + esac } # ---------------------------------------------------------------------------