#!/bin/sh # rclaude — durable Claude Code sessions, local or remote. # # Two layers of resilience: # 1. tmux on 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). # # 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 # rclaude . # local, $PWD # rclaude # remote: $PWD mirrored under remote $HOME # rclaude . # same as above (explicit form) # rclaude # remote (or local) at # 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). # If $PWD is outside $HOME, falls back to the remote's $HOME. set -eu # --------------------------------------------------------------------------- # 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: # \ttmux\t\t 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: # \tdisk\t\t> 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: # \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. # # 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 _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 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_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 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_search_on "$h"; done) if [ -n "$_pattern" ]; then _matches=$(printf '%s\n' "$_matches" | grep -F -i -- "$_pattern" || true) 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 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' "$_fmt_row"'{printf "\n"}' >&2 exit 1 fi _i=0 printf '%s\n' "$_matches" | while IFS= read -r _line; do _i=$((_i + 1)) _k=$(printf %s "$_keys" | cut -c"$_i") printf ' [%s] %s\n' "$_k" \ "$(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 _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/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}') # 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 exec tmux attach -t "$_target" else exec ssh -t "$_host" tmux attach -t "$_target" fi ;; disk) # 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 } # --------------------------------------------------------------------------- # 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 ;; -v|--version) cmd_version; exit ;; esac # --------------------------------------------------------------------------- # Default behavior: launch (or reattach to) a session. # --------------------------------------------------------------------------- # Argument resolution: # `rclaude` → local, $PWD # `rclaude .` → local, $PWD # `rclaude ` → host, default dir (~ remote, $PWD local) # `rclaude ` → 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 `; 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// mapping." >&2 ;; esac exit 1 fi sync_tmux_conf "$host" inner=$(build_inner "$dir") exec ssh -t "$host" "tmux new-session -s '${session}' \"${inner}\""