session-tools/bin/rclaude
Natalie 4b464dfc82 fix(@scripts): 🐛 improve session triage display balancing
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-17 05:22:30 -07:00

763 lines
32 KiB
Bash
Executable file

#!/bin/sh
# rclaude — durable Claude Code sessions, local or remote.
#
# Two layers of resilience:
# 1. tmux on <host> survives terminal/transport drops.
# 2. `claude --continue` resumes the per-directory session from disk after
# the host itself dies (reboot, crash, OOM).
#
# Each invocation starts a fresh Claude session in a new named tmux window.
# To reattach an existing session: `rclaude resume [pattern]`
# To resume a Claude conversation from disk after host loss: `rclaude resume` picks
# up the on-disk session via `claude --continue`.
#
# 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 + 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
# rclaude . # local, $PWD
# rclaude <host> # remote: $PWD mirrored under remote $HOME
# rclaude <host> . # same as above (explicit form)
# rclaude <host> <dir> # remote (or local) at <dir>
# rclaude list # tmux + per-project disk view
# rclaude list sessions # tmux + per-session disk view (uuid + snippet)
# rclaude triage [--limit N] [--refresh] # Haiku-powered ranked summary of recent sessions
# # (uses claude-code-batch-sdk + content cache)
# rclaude resume # picker: live tmux + most-recent disk (--- separator,
# # deduped by uuid across hosts)
# rclaude resume [pattern] --on <host> # mirror picked session onto <host> (rewrites cwd via
# # $HOME-relative mirror) and resume there
# rclaude resume [pattern] # reattach / resume by uuid prefix, snippet,
# # tmux name, or cwd substring (interactive
# # picker on >1 match)
#
# Config file: $XDG_CONFIG_HOME/rclaude/config (defaults to ~/.config/rclaude/config).
# A plain shell fragment sourced at startup. Useful settings:
# RCLAUDE_TRIAGE=auto # auto-rank sessions in `resume` (default: off)
# RCLAUDE_TRIAGE_MODEL=haiku # Claude model for triage
# RCLAUDE_TRIAGE_LIMIT=100 # max sessions to triage per host
# RCLAUDE_TRIAGE_CONCURRENT=4 # concurrent claude subprocesses
# RCLAUDE_TRIAGE_BATCH=8 # sessions per claude CLI call
# RCLAUDE_HOSTS="apricot plum" # hosts to scan
#
# 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).
# If $PWD is outside $HOME, falls back to the remote's $HOME.
set -eu
# Load user config if present. Lets the user set RCLAUDE_TRIAGE=auto (and
# friends) once instead of exporting on every invocation. Config file is a
# plain shell fragment sourced into the current shell.
if [ -r "${XDG_CONFIG_HOME:-$HOME/.config}/rclaude/config" ]; then
# shellcheck disable=SC1090
. "${XDG_CONFIG_HOME:-$HOME/.config}/rclaude/config"
fi
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Resolve $0 to its real path, correctly handling relative symlinks at each hop.
resolve_self() {
_rs=$0
while [ -L "$_rs" ]; do
_link=$(readlink "$_rs")
case "$_link" in
/*) _rs="$_link" ;;
*) _rs="$(dirname "$_rs")/$_link" ;;
esac
done
printf '%s' "$_rs"
}
is_local() {
case $1 in
local|localhost|127.0.0.1|::1) return 0 ;;
esac
[ "$1" = "$(hostname)" ] && return 0
[ "$1" = "$(hostname -s 2>/dev/null)" ] && return 0
return 1
}
# List claude-* tmux sessions on a host. Output one row per session:
# <host>\ttmux\t<session_name>\t<detail_from_tmux_ls>
list_tmux_on() {
_host=$1
if is_local "$_host"; then
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)
fi
# tmux ls lines look like: claude-foo: 1 windows (created ...) [80x24]
printf %s "$_raw" | awk -v host="$_host" '
/^claude-/ {
name=$1; sub(/:$/, "", name);
$1="";
sub(/^[[:space:]]+/, "");
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:
# <host>\tdisk\t<cwd>\t<sessions=N, last used <relative-time>>
list_disk_on() {
_host=$1
_helper_dir=$(dirname "$(resolve_self)")
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)
}
'
}
# List on-disk Claude sessions per UUID on a host (via _claude-projects --sessions).
# Output one row per session jsonl:
# <host>\tsession\t<uuid>\t<snippet>\t<cwd> · <relative-time>
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)
# col 6 = raw mtime, hidden — used for cross-host dedup/sort.
printf "%s\tsession\t%s\t%s\t%s · %s\t%s\n", host, $2, snippet, $3, rel(now - $1), $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"
}
# Get $HOME on <host> (cached per host in /tmp for the life of this shell).
get_home() {
_h=$1
_cache="/tmp/rclaude-home.$(whoami).$(printf %s "$_h" | tr -c 'A-Za-z0-9' '_')"
if [ -s "$_cache" ]; then
cat "$_cache"; return
fi
if is_local "$_h"; then
_v=$HOME
else
_v=$(ssh -o BatchMode=yes -o ConnectTimeout=3 "$_h" 'printf %s "$HOME"' 2>/dev/null || true)
fi
[ -n "$_v" ] && printf '%s' "$_v" > "$_cache" && printf %s "$_v"
}
# Compute Claude's project-slug from a cwd path. Claude replaces every
# non-alphanumeric character with `-` (so `/` and `@` both become `-`).
# /Users/natalie/Code/@projects/@lilith → -Users-natalie-Code--projects--lilith
claude_slug() {
printf %s "$1" | sed 's|[^A-Za-z0-9]|-|g'
}
# Dedupe session rows (col 2 == "session") across hosts by UUID (col 3),
# keeping the row with the highest mtime (col 6). Output sorted desc by mtime.
dedupe_sessions() {
awk -F'\t' 'BEGIN{OFS=FS}
{ if (!($3 in mt) || $6+0 > mt[$3]) { mt[$3]=$6+0; row[$3]=$0 } }
END { for (u in row) print row[u] }
' | sort -t"$(printf '\t')" -k6,6nr
}
# Mirror a session JSONL from <src_host>'s ~/.claude/projects/<src_slug>/<uuid>.jsonl
# to <dst_host>'s ~/.claude/projects/<dst_slug>/<uuid>.jsonl, rewriting every
# `"cwd":"<src_cwd...>"` occurrence to point at <dst_cwd...>.
migrate_session() {
_src=$1; _dst=$2; _uuid=$3; _src_cwd=$4; _dst_cwd=$5
_src_slug=$(claude_slug "$_src_cwd")
_dst_slug=$(claude_slug "$_dst_cwd")
_src_path="\$HOME/.claude/projects/${_src_slug}/${_uuid}.jsonl"
_dst_path="\$HOME/.claude/projects/${_dst_slug}/${_uuid}.jsonl"
if is_local "$_src"; then
_src_data=$(cat "$HOME/.claude/projects/${_src_slug}/${_uuid}.jsonl" 2>/dev/null || true)
else
_src_data=$(ssh -o BatchMode=yes -o ConnectTimeout=5 "$_src" "cat $_src_path" 2>/dev/null || true)
fi
if [ -z "$_src_data" ]; then
echo "rclaude: source session not found on $_src ($_src_path)" >&2
return 1
fi
_rewrite_py=$(cat <<'PY'
import json, sys
old, new = sys.argv[1], sys.argv[2]
for line in sys.stdin:
try:
e = json.loads(line)
except Exception:
sys.stdout.write(line); continue
cwd = e.get("cwd")
if isinstance(cwd, str):
if cwd == old: e["cwd"] = new
elif cwd.startswith(old + "/"): e["cwd"] = new + cwd[len(old):]
sys.stdout.write(json.dumps(e) + "\n")
PY
)
_rewritten=$(printf '%s' "$_src_data" | python3 -c "$_rewrite_py" "$_src_cwd" "$_dst_cwd") || {
echo "rclaude: cwd rewrite failed" >&2; return 1; }
_mkdir="mkdir -p \$HOME/.claude/projects/${_dst_slug}"
if is_local "$_dst"; then
sh -c "$_mkdir && cat > $HOME/.claude/projects/${_dst_slug}/${_uuid}.jsonl" <<EOF
$_rewritten
EOF
else
ssh -o BatchMode=yes -o ConnectTimeout=5 "$_dst" "$_mkdir && cat > $_dst_path" <<EOF
$_rewritten
EOF
fi || { echo "rclaude: failed to write to $_dst" >&2; return 1; }
printf 'rclaude: mirrored session %s → %s (%s)\n' "$(printf %s "$_uuid" | cut -c1-8)" "$_dst" "$_dst_cwd" >&2
}
# Pick a python that has claude_code_batch_sdk importable. Walks 3.13/.12/.11
# then falls back to plain python3. The SDK requires Python 3.11+. Tries each
# candidate with a real `-c "import claude_code_batch_sdk"` so a non-SDK
# install of a newer python doesn't shadow an SDK-equipped older one.
_PICK_PY_SNIPPET='for _p in python3.13 python3.12 python3.11 python3; do _b=$(command -v "$_p" 2>/dev/null) || continue; "$_b" -c "import claude_code_batch_sdk" 2>/dev/null && PY="$_b" && break; done; [ -z "${PY:-}" ] && { echo "rclaude: no python with claude_code_batch_sdk found" >&2; exit 2; }'
_REMOTE_TRIAGE_BOOT='export PATH=$HOME/.local/bin:/opt/homebrew/bin:$PATH; '"$_PICK_PY_SNIPPET"';'
# Run the triage helper on <host> with the supplied extra args. Stdout is the
# raw TSV emitted by _claude-triage (one row per session).
list_triage_on() {
_host=$1
shift
_helper_dir=$(dirname "$(resolve_self)")
_helper="$_helper_dir/_claude-triage"
[ -f "$_helper" ] || return 0
if is_local "$_host"; then
# Probe PATH with Homebrew prefixes (mac) and the user's ~/.local/bin
# both included, in case the script is invoked from a leaner non-login
# context (ssh, cron, tmux without env inheritance).
_orig_path=$PATH
PATH=$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH
PY=""
for _p in python3.13 python3.12 python3.11 python3; do
_b=$(command -v "$_p" 2>/dev/null) || continue
"$_b" -c "import claude_code_batch_sdk" 2>/dev/null && PY=$_b && break
done
PATH=$_orig_path
if [ -z "$PY" ]; then
echo "rclaude: no python with claude_code_batch_sdk found locally" >&2
return 1
fi
"$PY" "$_helper" "$@" 2>/dev/null || true
else
_args=""
for a in "$@"; do
_args="$_args $(printf %s "$a" | sed 's/"/\\"/g; s/^/"/; s/$/"/')"
done
# Stream the helper over stdin so we don't depend on it being
# pre-installed on the remote.
ssh -o BatchMode=yes -o ConnectTimeout=5 "$_host" \
"${_REMOTE_TRIAGE_BOOT} \$PY -${_args}" \
< "$_helper" 2>/dev/null || true
fi | awk -F'\t' -v host="$_host" '
# _claude-triage writes informational lines starting with "# " to
# stderr; the stdout we capture is pure TSV. Each row:
# mtime\tuuid\tcwd\tpriority\tstatus\tsummary\tnext_action
NF >= 7 { printf "%s\ttriage\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
host, $2, $4, $5, $6, $7, $3, $1 }
'
}
# Push the canonical session-tools tmux fragment to <host> 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.
sync_tmux_conf() {
_host=$1
_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'
if is_local "$_host"; then
sh -c "$_remote_cmd" < "$_frag" 2>/dev/null || true
else
ssh -o BatchMode=yes -o ConnectTimeout=5 "$_host" "$_remote_cmd" < "$_frag" 2>/dev/null || true
fi
}
# 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 plum}; do
is_local "$h" && continue
printf "%s\n" "$h"
done
}
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
cmd_triage() {
# Pass-through args: --limit, --refresh, --uuids ...
_opts="$*"
printf "%-8s %-8s %-3s %-15s %-50s %s\n" \
"HOST" "UUID" "PRI" "STATUS" "SUMMARY" "NEXT ACTION"
scan_hosts | while IFS= read -r h; do
# Row format from list_triage_on:
# host \t triage \t uuid \t priority \t status \t summary \t next_action \t cwd \t mtime
# shellcheck disable=SC2086
list_triage_on "$h" $_opts | awk -F'\t' '
{ uuid8 = substr($3, 1, 8)
printf "%-8s %-8s %-3s %-15s %-50.50s %s\n",
$1, uuid8, $4, $5, $6, $7 }
'
done
}
cmd_list() {
_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" ;;
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 session UUID → ssh+tmux+claude --resume <uuid> 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=""
_on=""
while [ $# -gt 0 ]; do
case $1 in
--on) shift; _on=${1:-}; shift ;;
--on=*) _on=${1#--on=}; shift ;;
--all|-a) shift ;;
*) _pattern=$1; shift ;;
esac
done
# Triage = Haiku-ranks sessions before display; only useful when browsing
# without a pattern. Pattern searches always take the cheap path (raw
# sessions, no LLM) so they return quickly.
_triage_mode=0
if [ -z "$_pattern" ]; then
case ${RCLAUDE_TRIAGE:-off} in
auto|on|1|true) _triage_mode=1 ;;
esac
fi
_d_total=0; _d_room=0
if [ "$_triage_mode" = "1" ]; then
printf 'rclaude: triaging sessions...\n' >&2
# Collect tmux rows from all hosts first so they always appear in the
# picker even when triage produces > (35 - tmux_count) rows on a single
# host that would otherwise crowd them out.
_tmux=$(scan_hosts | while IFS= read -r h; do list_tmux_on "$h"; done)
_triage=$(scan_hosts | while IFS= read -r h; do list_triage_on "$h"; done)
_t_count=0
[ -n "$_tmux" ] && _t_count=$(printf '%s\n' "$_tmux" | wc -l | tr -d ' ')
[ -n "$_triage" ] && _d_total=$(printf '%s\n' "$_triage" | wc -l | tr -d ' ')
_d_room=$((35 - _t_count))
[ "$_d_room" -lt 0 ] && _d_room=0
if [ "$_d_room" -gt 0 ] && [ -n "$_triage" ]; then
_triage_slice=$(printf '%s\n' "$_triage" | head -n "$_d_room")
else
_triage_slice=""
fi
if [ -n "$_tmux" ] && [ -n "$_triage_slice" ]; then
_matches=$(printf '%s\n%s' "$_tmux" "$_triage_slice")
else
_matches=${_tmux:-$_triage_slice}
fi
elif [ -z "$_pattern" ]; then
_tmux=$(scan_hosts | while IFS= read -r h; do list_tmux_on "$h"; done)
_disk_raw=$(scan_hosts | while IFS= read -r h; do list_sessions_on "$h"; done)
_disk=$(printf '%s\n' "$_disk_raw" | dedupe_sessions)
_t_count=0
[ -n "$_tmux" ] && _t_count=$(printf '%s\n' "$_tmux" | wc -l | tr -d ' ')
[ -n "$_disk" ] && _d_total=$(printf '%s\n' "$_disk" | wc -l | tr -d ' ')
_d_room=$((35 - _t_count))
[ "$_d_room" -lt 0 ] && _d_room=0
if [ "$_d_room" -gt 0 ] && [ -n "$_disk" ]; then
_disk_slice=$(printf '%s\n' "$_disk" | head -n "$_d_room")
else
_disk_slice=""
fi
if [ -n "$_tmux" ] && [ -n "$_disk_slice" ]; then
_matches=$(printf '%s\n%s' "$_tmux" "$_disk_slice")
else
_matches=${_tmux:-$_disk_slice}
fi
else
_matches=$(scan_hosts | while IFS= read -r h; do list_search_on "$h"; done)
_matches=$(printf '%s\n' "$_matches" | grep -F -i -- "$_pattern" || true)
_tmux_part=$(printf '%s\n' "$_matches" | awk -F'\t' '$2=="tmux"')
_disk_part=$(printf '%s\n' "$_matches" | awk -F'\t' '$2!="tmux"' | dedupe_sessions)
if [ -n "$_tmux_part" ] && [ -n "$_disk_part" ]; then
_matches=$(printf '%s\n%s' "$_tmux_part" "$_disk_part")
else
_matches=${_tmux_part:-$_disk_part}
fi
fi
_count=0
[ -n "$_matches" ] && _count=$(printf '%s\n' "$_matches" | wc -l | tr -d ' ')
if [ "$_count" -eq 0 ]; then
echo "no matching sessions${_pattern:+ for pattern '$_pattern'}" >&2
exit 1
fi
if [ "$_count" -gt 1 ]; then
_keys="123456789abcdefghijklmnopqrstuvwxyz"
if [ "$_count" -gt 35 ]; then
_orig_count=$_count
_matches=$(printf '%s\n' "$_matches" | head -n 35)
_count=35
printf 'rclaude: %s matches; showing 35 most recent (refine with a pattern to see others)\n' "$_orig_count" >&2
fi
# ANSI colors (only when stderr is a tty). Conservative palette —
# host cyan, tmux marker bold-green, priority red/yellow, statuses
# colored, uuid8 dimmed, key bracket bold-magenta.
if [ -t 2 ]; then
_R=$(printf '\033[0m')
_Chost=$(printf '\033[36m'); _Ctmux=$(printf '\033[1;32m')
_Cdim=$(printf '\033[2m'); _Ckey=$(printf '\033[1;35m')
_Cp5=$(printf '\033[1;31m'); _Cp4=$(printf '\033[33m')
_Cblk=$(printf '\033[31m'); _Cwait=$(printf '\033[33m')
_Cinp=$(printf '\033[36m'); _Cdone=$(printf '\033[32m')
else
_R=; _Chost=; _Ctmux=; _Cdim=; _Ckey=
_Cp5=; _Cp4=; _Cblk=; _Cwait=; _Cinp=; _Cdone=
fi
# Kind column dropped — it carried no signal when all rows were the
# same kind. Kind is now encoded by row shape: tmux has a ▶ marker,
# triage shows P<n> + status, session shows the snippet plain.
_fmt_row='
function prio_c(p) { if (p=="5") return c_p5; if (p=="4") return c_p4; return "" }
function stat_c(s) {
if (s=="blocked") return c_blk
if (s=="waiting_on_user") return c_wait
if (s=="in_progress") return c_inp
if (s=="done") return c_done
return ""
}
function display() {
if ($2 == "tmux")
return c_tmux "▶ " r $3
if ($2 == "triage")
return prio_c($4) "P" $4 r " " stat_c($5) sprintf("%-15s", $5) r " " $6 " " c_dim "[" substr($3,1,8) "]" r
if ($2 == "session")
return $4 " " c_dim "[" substr($3,1,8) "]" r
return $3
}
{
printf "%s%-10s%s %s", c_host, $1, r, 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' \
-v r="$_R" -v c_host="$_Chost" -v c_tmux="$_Ctmux" -v c_dim="$_Cdim" \
-v c_p5="$_Cp5" -v c_p4="$_Cp4" \
-v c_blk="$_Cblk" -v c_wait="$_Cwait" -v c_inp="$_Cinp" -v c_done="$_Cdone" \
"$_fmt_row"'{printf "\n"}' >&2
exit 1
fi
_i=0
_prev_kind=""
printf '%s\n' "$_matches" | while IFS= read -r _line; do
_i=$((_i + 1))
_kind_now=$(printf %s "$_line" | awk -F'\t' '{print $2}')
if [ "$_prev_kind" = "tmux" ] && [ "$_kind_now" != "tmux" ]; then
printf ' %s---%s\n' "$_Cdim" "$_R" >&2
fi
_k=$(printf %s "$_keys" | cut -c"$_i")
_row_text=$(printf %s "$_line" | awk -F'\t' \
-v r="$_R" -v c_host="$_Chost" -v c_tmux="$_Ctmux" -v c_dim="$_Cdim" \
-v c_p5="$_Cp5" -v c_p4="$_Cp4" \
-v c_blk="$_Cblk" -v c_wait="$_Cwait" -v c_inp="$_Cinp" -v c_done="$_Cdone" \
"$_fmt_row")
printf ' %s[%s]%s %s\n' "$_Ckey" "$_k" "$_R" "$_row_text" >&2
_prev_kind=$_kind_now
done
if [ "$_d_total" -gt "$_d_room" ] && [ "$_d_room" -gt 0 ]; then
printf ' %s(showing %s most recent of %s disk sessions; pass a pattern to search older)%s\n' \
"$_Cdim" "$_d_room" "$_d_total" "$_R" >&2
fi
_last_key=$(printf %s "$_keys" | cut -c"$_count")
printf '%sselect [1-%s]:%s ' "$_Ckey" "$_last_key" "$_R" >&2
_old=$(stty -g 2>/dev/null || true)
stty -icanon -echo min 1 time 0 2>/dev/null || true
_key=$(dd bs=1 count=1 2>/dev/null </dev/tty || true)
[ -n "$_old" ] && stty "$_old" 2>/dev/null || true
printf '%s\n' "$_key" >&2
_idx=0
_c=1
while [ "$_c" -le "$_count" ]; do
if [ "$(printf %s "$_keys" | cut -c"$_c")" = "$_key" ]; then
_idx=$_c; break
fi
_c=$((_c + 1))
done
if [ "$_idx" -eq 0 ]; then
echo "rclaude: invalid selection: '$_key'" >&2
exit 1
fi
_matches=$(printf '%s\n' "$_matches" | sed -n "${_idx}p")
fi
_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}')
# cwd column depends on kind:
# session → col 5, formatted "<cwd> · <rel-time>"
# triage → col 8, raw cwd path
case $_kind in
triage) _session_cwd=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $8}') ;;
session) _session_cwd=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $5}' | awk -F' · ' '{print $1}') ;;
*) _session_cwd="" ;;
esac
case $_kind in
tmux)
if [ -n "$_on" ] && [ "$_on" != "$_host" ]; then
echo "rclaude: --on can't migrate a live tmux session; detach + retry, or omit --on" >&2
exit 1
fi
if is_local "$_host"; then
exec tmux attach -t "$_target"
else
exec ssh -t "$_host" tmux attach -t "$_target"
fi
;;
disk)
if [ -n "$_on" ] && [ "$_on" != "$_host" ]; then
echo "rclaude: --on requires a specific session row (run 'rclaude resume <pattern>' to get one, not a project-level row)" >&2
exit 1
fi
# Spawn tmux + claude --continue at the recorded cwd.
RCLAUDE_RESUME=1 exec "$0" "$_host" "$_target"
;;
session|triage)
# Spawn tmux + claude --resume <uuid> at the session's recorded cwd.
if [ -z "$_session_cwd" ]; then
echo "rclaude: session $_target has no recorded cwd" >&2
exit 1
fi
_dst=${_on:-$_host}
if [ "$_dst" = "$_host" ] || { is_local "$_dst" && is_local "$_host"; }; then
RCLAUDE_RESUME_ID=$_target exec "$0" "$_host" "$_session_cwd"
fi
# Cross-host mirror: translate cwd via $HOME-relative mirror,
# copy JSONL with cwd rewritten, then launch on dst.
_src_home=$(get_home "$_host")
_dst_home=$(get_home "$_dst")
if [ -z "$_src_home" ] || [ -z "$_dst_home" ]; then
echo "rclaude: couldn't resolve \$HOME on $_host or $_dst" >&2
exit 1
fi
case $_session_cwd in
"$_src_home") _dst_cwd=$_dst_home ;;
"$_src_home"/*) _dst_cwd="$_dst_home${_session_cwd#$_src_home}" ;;
*)
echo "rclaude: session cwd $_session_cwd is outside source \$HOME ($_src_home); can't mirror" >&2
exit 1 ;;
esac
migrate_session "$_host" "$_dst" "$_target" "$_session_cwd" "$_dst_cwd" || exit
RCLAUDE_RESUME_ID=$_target exec "$0" "$_dst" "$_dst_cwd"
;;
esac
}
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
cmd_version() {
_self=$(resolve_self)
_repo=$(cd "$(dirname "$_self")/.." 2>/dev/null && pwd)
if [ -d "$_repo/.git" ] && command -v git >/dev/null 2>&1; then
_sha=$(git -C "$_repo" rev-parse --short HEAD 2>/dev/null)
_dirty=""
[ -n "$(git -C "$_repo" status --porcelain 2>/dev/null)" ] && _dirty="-dirty"
_date=$(git -C "$_repo" log -1 --format=%cd --date=short HEAD 2>/dev/null)
printf 'rclaude (session-tools) %s%s %s %s\n' "$_sha" "$_dirty" "$_date" "$_repo"
else
printf 'rclaude (session-tools) %s\n' "$_repo"
fi
}
case ${1:-} in
list) shift; cmd_list "$@"; exit ;;
resume) shift; cmd_resume "$@"; exit ;;
triage) shift; cmd_triage "$@"; exit ;;
-v|--version) cmd_version; exit ;;
esac
# ---------------------------------------------------------------------------
# Default behavior: launch (or reattach to) a session.
# ---------------------------------------------------------------------------
# Argument resolution:
# `rclaude` → local, $PWD
# `rclaude .` → local, $PWD
# `rclaude <host>` → host, default dir (~ remote, $PWD local)
# `rclaude <host> <dir>` → host, dir (with `.` resolving to $PWD)
if [ $# -eq 0 ] || [ "${1:-}" = "." ]; then
host=local
dir=$PWD
else
host=$1
dir=${2:-}
fi
# Defaults + `.` expansion now that we know whether we're local or remote.
if is_local "$host"; then
case ${dir:-.} in
.|"") dir=$PWD ;;
esac
else
# Remote default: mirror local $PWD relative to $HOME onto the remote's
# $HOME. Same behavior for omitted dir or explicit `.`. Falls back to
# remote $HOME if local $PWD isn't under $HOME.
if [ "$dir" = "." ] || [ -z "$dir" ]; then
case $PWD in
"$HOME") dir=\~ ;;
"$HOME"/*) _rel=${PWD#"$HOME"/}; dir="~/$_rel" ;;
*) dir=\~ ;;
esac
fi
fi
slug=$(printf %s "$dir" | sed -e 's|^[~/]*||' -e 's|[^A-Za-z0-9]|-|g')
[ -z "$slug" ] && slug=home
session="claude-$(whoami)-${slug}-$(date +%s)"
perms=${RCLAUDE_PERMS:-bypass}
case $perms in
bypass) flag="--dangerously-skip-permissions" ;;
*) flag="--permission-mode $perms" ;;
esac
# Inner command for the tmux pane. If claude exits nonzero OR ends in under
# 2 seconds (usually a misconfig: missing dir, locked session, crashed
# claude), the pane stays open with the exit code visible instead of
# silently dying and dragging the whole tmux session + ssh transport down
# with it. A real interactive session lasts much longer than 2s, so a clean
# /exit closes the pane normally.
build_inner() {
# Single-line, single-quote-safe. Variables prefixed with rc_ to avoid
# collision with anything in the user's shell.
#
# Note: launch path uses plain `claude` (fresh session). Each invocation
# creates a new uniquely-named tmux session. Reattach to a live session
# via `rclaude resume <pattern>`; disk-resume after host death likewise.
_resume_flag=""
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)); " \
"if [ \$rc_ec -ne 0 ] || [ \$rc_d -lt 2 ]; then " \
"printf '\\n[rclaude] claude exited in %ds with code %d\\n' \$rc_d \$rc_ec; " \
"printf '[rclaude] press enter to close pane (or Ctrl-b d to detach)... '; " \
"read rc_; fi"
}
if is_local "$host"; then
if ! command -v tmux >/dev/null 2>&1; then
echo "rclaude: tmux not installed locally — install via 'brew install tmux' (macOS) or your package manager" >&2
exit 1
fi
if ! cd "$dir" 2>/dev/null; then
echo "rclaude: local directory not found: $dir" >&2
exit 1
fi
sync_tmux_conf local
exec tmux new-session -s "$session" "$(build_inner "$dir")"
fi
# Remote: pre-flight the directory so a typo or missing path fails loudly
# here instead of silently killing the tmux pane and closing the ssh
# transport (which looks like a generic 'Connection closed' to the user).
if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "$host" "test -d ${dir}" 2>/dev/null; then
echo "rclaude: directory not found on $host: $dir" >&2
case $dir in
*@proj/*|*@apps/*|*@pkg/*)
echo " hint: '@proj/@apps/@pkg' are Claude-instruction aliases, not real shell paths." >&2
echo " See ~/.claude/instructions/project-paths.md for the real ~/Code/<bucket>/<project> mapping." >&2
;;
esac
exit 1
fi
sync_tmux_conf "$host"
inner=$(build_inner "$dir")
exec ssh -t "$host" "tmux new-session -s '${session}' \"${inner}\""