#!/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). # # 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 triage [--limit N] [--refresh] # Haiku-powered ranked summary of recent sessions # # (uses claude-code-batch-sdk + content cache) # rclaude send (--all|--host |--match ) [--yes] -- # # broadcast a prompt to live claude-* tmux # # sessions across scan_hosts. Dry-run by # # default; --yes to actually deliver. # rclaude resume # picker: live tmux + most-recent disk (--- separator, # # deduped by uuid across hosts) # rclaude resume [pattern] --on # mirror picked session onto (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: # \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). # Default output (column-aligned consumers): one row per session jsonl: # \tsession\t\t\t · \t # When the second arg is "tsv", emit a machine-readable shape with cwd and # mtime_epoch as separate columns (no relative-time string): # \tsession\t\t\t\t list_sessions_on() { _host=$1 _fmt=${2:-pretty} _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" -v fmt="$_fmt" ' 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) if (fmt == "tsv") { printf "%s\tsession\t%s\t%s\t%s\t%s\n", host, $2, snippet, $3, $1 } else { # 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" } # Filter list_sessions_on output to the rows whose uuid is in # (newline-separated). Centralizes the regex-build + lookup. _filter_sessions_to_uuids() { _host=$1; _uuids=$2 [ -z "$_uuids" ] && return 0 _re=$(printf '%s\n' "$_uuids" | sort -u | grep -v '^$' | tr '\n' '|' | sed 's/|$//') [ -z "$_re" ] && return 0 CLAUDE_PROJECTS_LIMIT=5000 list_sessions_on "$_host" \ | awk -F'\t' -v r="^($_re)$" '$3 ~ r' } # Durable name index — persistent record of (host, uuid, name) tuples for # every session display name we've ever observed. Lives on plum, survives # remote-host reboots. Source of truth for name_search_on alongside the # live per-pid sessions/*.json files. _NAME_INDEX=${XDG_DATA_HOME:-$HOME/.local/share}/rclaude/named-sessions.tsv mkdir -p "$(dirname "$_NAME_INDEX")" 2>/dev/null [ -f "$_NAME_INDEX" ] || touch "$_NAME_INDEX" 2>/dev/null # Build a `host\tuuid\tname` table of every session display name we know # about. Combines live observation (ssh into each host, scan # ~/.claude/sessions/*.json) with the durable on-disk index — so even after # a remote reboot wipes the per-pid metadata, names we saw previously stay # searchable. Live observations are upserted into the index every call. build_name_map() { _py='import json, os, glob for f in glob.glob(os.path.expanduser("~/.claude/sessions/*.json")): try: d = json.load(open(f)) except Exception: continue sid = d.get("sessionId") or "" name = d.get("name") or "" if sid and name: print(f"{sid}\t{name}")' # Live pass: collect current observations into a temp file. _live=$(mktemp /tmp/rclaude-namemap-live.XXXXXX 2>/dev/null || echo /tmp/rclaude-namemap-live.$$) scan_hosts | while IFS= read -r _h; do if is_local "$_h"; then python3 -c "$_py" 2>/dev/null else ssh -o BatchMode=yes -o ConnectTimeout=3 "$_h" "python3 -" 2>/dev/null <=2 {print host "\t" $1 "\t" $2}' done > "$_live" # Persist anything new into the durable index. Dedup by (host, uuid) # keeping the most-recent name we've ever seen (later writes win). if [ -s "$_live" ]; then cat "$_NAME_INDEX" "$_live" \ | awk -F'\t' 'NF>=3 { key=$1 SUBSEP $2; row[key]=$0 } END { for (k in row) print row[k] }' \ > "${_NAME_INDEX}.tmp" \ && mv -f "${_NAME_INDEX}.tmp" "$_NAME_INDEX" fi rm -f "$_live" # Output: every row in the durable index. Picker uses this to enrich rows. cat "$_NAME_INDEX" 2>/dev/null } # Cheap match by session display name (the `claude -n ` label, stored in # ~/.claude/sessions/.json). Single ssh round-trip. Always included in # pattern searches because the file count is bounded (one per active pid). name_search_on() { _host=$1; _pat=$2 _q=$(printf %s "$_pat" | sed "s/'/'\\\\''/g") _py='import json, os, sys, glob pat = os.environ.get("RCLAUDE_PAT", "").lower() if not pat: sys.exit(0) for f in glob.glob(os.path.expanduser("~/.claude/sessions/*.json")): try: d = json.load(open(f)) except Exception: continue name = (d.get("name") or "").lower() sid = d.get("sessionId") or "" if pat in name and sid: print(sid)' # Live pass — per-pid metadata on (vanishes on reboot). if is_local "$_host"; then _uuids=$(RCLAUDE_PAT="$_pat" python3 -c "$_py" 2>/dev/null || true) else _uuids=$(ssh -o BatchMode=yes -o ConnectTimeout=5 "$_host" \ "RCLAUDE_PAT='$_q' python3 -" 2>/dev/null < 0 { print $2 } ' "$_NAME_INDEX" 2>/dev/null) _all_uuids=$(printf '%s\n%s\n' "$_uuids" "$_dur_uuids" | sort -u | grep -v '^$' || true) _filter_sessions_to_uuids "$_host" "$_all_uuids" } # Grep the full content of every Claude session JSONL on for . # Expensive — only used as the final fallback when cheap searches return nothing. deep_search_on() { _host=$1; _pat=$2 _q=$(printf %s "$_pat" | sed "s/'/'\\\\''/g") if is_local "$_host"; then _uuids=$(grep -l -F -i -- "$_pat" "$HOME/.claude/projects/"*/*.jsonl 2>/dev/null \ | awk -F/ '{print $NF}' | sed 's/\.jsonl$//') else _uuids=$(ssh -o BatchMode=yes -o ConnectTimeout=5 "$_host" \ "grep -l -F -i -- '$_q' \$HOME/.claude/projects/*/*.jsonl 2>/dev/null \ | awk -F/ '{print \$NF}' | sed 's/\\.jsonl\$//'" 2>/dev/null || true) fi _filter_sessions_to_uuids "$_host" "$_uuids" } # Get $HOME on (cached per host in /tmp for the life of this shell). # Always returns 0 — caller distinguishes success/failure by checking whether # the output is empty (e.g. unknown host, ssh refused). This matters because # the script runs under `set -e`; a function returning non-zero from inside # `$(...)` aborts the caller mid-flow. 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 0 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 if [ -n "$_v" ]; then printf '%s' "$_v" > "$_cache" 2>/dev/null || true printf %s "$_v" fi return 0 } # 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' } # POSIX single-quote escape — safe for embedding arbitrary user-provided # strings inside an outer single-quoted context (e.g. ssh ''). # sh_quote "" → '' # sh_quote "hello" → 'hello' # sh_quote "it's" → 'it'\''s' # The classic '\'' trick: close the open quote, escape a literal ', reopen. sh_quote() { printf "'%s'" "$(printf %s "$1" | sed "s/'/'\\\\''/g")" } # Row filter for the send subcommand. Reads TSV rows on stdin # (host \t kind \t name/slug \t detail \t [cwd]) and emits the subset # matching with . Disk rows are always dropped — you # can't `tmux send-keys` to a session that isn't running. # # selector = all → every tmux row (pattern ignored) # selector = host → tmux rows where col 1 == pattern # selector = match → tmux rows where pattern (case-insensitive # substring) appears in col 3 (session # name slug) OR col 5 (cwd, if present) filter_targets() { _selector=$1 _pattern=$2 awk -F'\t' -v sel="$_selector" -v pat="$_pattern" ' BEGIN { IGNORECASE = 1 } $2 != "tmux" { next } sel == "all" { print; next } sel == "host" { if ($1 == pat) print; next } sel == "match" { if (index(tolower($3), tolower(pat)) > 0) { print; next } if (NF >= 5 && index(tolower($5), tolower(pat)) > 0) { print; next } } ' } # 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 's ~/.claude/projects/*/.jsonl # to 's ~/.claude/projects//.jsonl, rewriting every # `"cwd":""` occurrence to point at . The source # project-slug is NOT computed from — claude stores sessions under # the slug of the cwd-at-session-start, which can differ from any later cwd # the session recorded — so we search for the file by uuid instead. migrate_session() { _src=$1; _dst=$2; _uuid=$3; _src_cwd=$4; _dst_cwd=$5 _dst_slug=$(claude_slug "$_dst_cwd") _dst_path="\$HOME/.claude/projects/${_dst_slug}/${_uuid}.jsonl" if is_local "$_src"; then _src_path=$(ls "$HOME/.claude/projects/"*/"${_uuid}.jsonl" 2>/dev/null | head -1) [ -n "$_src_path" ] && _src_data=$(cat "$_src_path" 2>/dev/null) || _src_data="" else _src_data=$(ssh -o BatchMode=yes -o ConnectTimeout=5 "$_src" \ "cat \$(ls \$HOME/.claude/projects/*/${_uuid}.jsonl 2>/dev/null | head -1) 2>/dev/null" || true) fi if [ -z "$_src_data" ]; then echo "rclaude: source session $_uuid not found anywhere under $_src:~/.claude/projects/" >&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" < $_dst_path" <&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"';' # Keepalive options for interactive ssh -t sessions. Sends a keepalive every # 30s; tolerates 6 consecutive failures (~3 min of network blip) before the # transport drops. Pair with `tmux new-session -A` below so a manual reconnect # (re-running cc/rclaude resume) lands back in the same tmux session — the # work itself is already protected by tmux on the remote. _SSH_LIVE_OPTS='-o ServerAliveInterval=30 -o ServerAliveCountMax=6 -o TCPKeepAlive=yes' # --------------------------------------------------------------------------- # Setup / dependency auto-install # --------------------------------------------------------------------------- # Cache dir for per-host setup markers. A marker file means we've already # probed + installed deps on that host within RCLAUDE_SETUP_TTL days. _SETUP_CACHE_DIR=${XDG_CACHE_HOME:-$HOME/.cache}/rclaude # Archive: per-user list of session UUIDs to hide from `rclaude resume`. One # uuid per line. Populated via the picker's `-` key (then a selection key). _ARCHIVE_FILE=${XDG_DATA_HOME:-$HOME/.local/share}/rclaude/archived-uuids mkdir -p "$(dirname "$_ARCHIVE_FILE")" 2>/dev/null [ -f "$_ARCHIVE_FILE" ] || touch "$_ARCHIVE_FILE" 2>/dev/null _SETUP_TTL_DAYS=${RCLAUDE_SETUP_TTL:-7} # Detect package-manager family on . Output: macos | rhel | debian | unknown. detect_os_on() { _h=$1 _probe='if [ "$(uname -s)" = "Darwin" ]; then echo macos elif command -v dnf >/dev/null 2>&1; then echo rhel elif command -v apt-get >/dev/null 2>&1; then echo debian else echo unknown; fi' if is_local "$_h"; then sh -c "$_probe" 2>/dev/null else ssh -o BatchMode=yes -o ConnectTimeout=5 "$_h" "$_probe" 2>/dev/null fi } # Install on using its native package manager. Uses sudo for # system pkgs on Linux; brew (no sudo) on macOS. On bootc/Silverblue-style # immutable systems (signaled by /run/ostree-booted), dnf is given # `--transient` so the install lands in an overlay that survives until # reboot — rclaude's per-host marker is invalidated below on reboot so it # re-installs automatically. install_pkgs_on() { _h=$1; _os=$2; shift 2 _pkgs=$* [ -z "$_pkgs" ] && return 0 _dnf_flags="-y" if [ "$_os" = "rhel" ]; then if _probe_on "$_h" 'test -e /run/ostree-booted && echo bootc; :' | grep -q bootc; then _dnf_flags="-y --transient" printf 'rclaude: %s is bootc/immutable — using --transient overlay\n' "$_h" >&2 fi fi case $_os in macos) _cmd="brew install $_pkgs" ;; rhel) _cmd="sudo dnf install $_dnf_flags $_pkgs" ;; debian) _cmd="sudo apt-get update && sudo apt-get install -y $_pkgs" ;; *) echo "rclaude: don't know how to install $_pkgs on $_h ($_os) — do it manually" >&2 return 1 ;; esac printf 'rclaude: installing on %s: %s\n' "$_h" "$_pkgs" >&2 if is_local "$_h"; then sh -c "$_cmd" >&2 else ssh -t "$_h" "$_cmd" >&2 fi } # Run a probe command on , return its stdout. Used by setup_host. _probe_on() { _h=$1; _cmd=$2 if is_local "$_h"; then sh -c "$_cmd" 2>/dev/null else ssh -o BatchMode=yes -o ConnectTimeout=5 "$_h" "$_cmd" 2>/dev/null fi } # Idempotently install rclaude's deps on . Honors a per-host marker so # we don't re-probe on every invocation. Pass `force` to bypass the marker. setup_host() { _h=$1; _force=${2:-} mkdir -p "$_SETUP_CACHE_DIR" 2>/dev/null _marker_id=$(printf %s "$_h" | tr -c 'A-Za-z0-9' '_') _marker="$_SETUP_CACHE_DIR/setup-$_marker_id" if [ "$_force" != "force" ] && [ -f "$_marker" ]; then # Marker exists and is recent enough → assume deps are fine. if [ -z "$(find "$_marker" -mtime +"$_SETUP_TTL_DAYS" 2>/dev/null)" ]; then return 0 fi fi _os=$(detect_os_on "$_h") if [ "$_os" = "unknown" ] || [ -z "$_os" ]; then echo "rclaude: couldn't detect OS on $_h; skipping setup" >&2 return 0 fi # Probe system binaries. The trailing `; :` guarantees a 0 exit from the # remote shell so `set -e` in our caller doesn't kill us when a missing # binary's `command -v` returns 1 inside the loop. Newline-delim output; # the awk comparison below ignores delimiter shape. _missing="" _have=$(_probe_on "$_h" 'for c in tmux rsync mosh; do command -v "$c" >/dev/null 2>&1 && echo "$c"; done; :') for c in tmux rsync mosh; do printf '%s\n' "$_have" | grep -Fxq "$c" || _missing="$_missing $c" done if [ -n "$_missing" ]; then install_pkgs_on "$_h" "$_os" $_missing || true fi # Python SDK for triage. Try to install per-user without sudo. Same `; :` # guard for the same reason. _has_sdk=$(_probe_on "$_h" '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 && echo "$b" && break; done; :') if [ -z "$_has_sdk" ]; then _pick=$(_probe_on "$_h" 'for p in python3.12 python3.11 python3; do command -v "$p" 2>/dev/null && break; done | head -1') if [ -n "$_pick" ]; then printf 'rclaude: installing claude-code-batch-sdk via %s on %s\n' "$_pick" "$_h" >&2 if is_local "$_h"; then "$_pick" -m pip install --user --quiet claude-code-batch-sdk >&2 || true else ssh "$_h" "$_pick -m pip install --user --quiet claude-code-batch-sdk" >&2 || true fi fi fi touch "$_marker" 2>/dev/null } # Prefer mosh when available on both ends — unless explicitly disabled via # RCLAUDE_TRANSPORT=ssh. Echoes "mosh" or "ssh". Caches result per host. pick_transport() { _h=$1 case ${RCLAUDE_TRANSPORT:-auto} in ssh) echo ssh; return ;; mosh) echo mosh; return ;; esac if ! command -v mosh >/dev/null 2>&1; then echo ssh; return; fi _cache="/tmp/rclaude-transport.$(whoami).$(printf %s "$_h" | tr -c 'A-Za-z0-9' '_')" if [ -s "$_cache" ]; then cat "$_cache"; return; fi if _probe_on "$_h" 'command -v mosh-server >/dev/null 2>&1' | grep -q . \ || _probe_on "$_h" 'command -v mosh-server' >/dev/null; then echo mosh > "$_cache"; echo mosh else echo ssh > "$_cache"; echo ssh fi } # Run the triage helper on 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 # Detach stdin: the SDK spawns claude CLI subprocesses that inherit # stdin from our caller. If we're invoked inside `scan_hosts | while # read`, those subprocesses eat the pipe and the loop dies after the # first host. /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 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 ... # --tsv: emit raw TSV (host\ttriage\tuuid\tpri\tstatus\tsummary\tnext_action\tcwd\tmtime) # and skip the column-aligned awk formatting + header. _tsv=0 _opts="" for _a in "$@"; do if [ "$_a" = "--tsv" ]; then _tsv=1 else _opts="$_opts $_a" fi done if [ "$_tsv" -eq 0 ]; then printf "%-8s %-8s %-3s %-15s %-50s %s\n" \ "HOST" "UUID" "PRI" "STATUS" "SUMMARY" "NEXT ACTION" fi 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 if [ "$_tsv" -eq 1 ]; then # shellcheck disable=SC2086 list_triage_on "$h" $_opts else # 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 } ' fi done } cmd_list() { # Positional mode + optional --tsv flag (order-agnostic). # --tsv: skip the header + column-aligned awk; print raw TSV rows from # list_*_on (with full uuids, separate cwd / mtime columns for sessions). _mode="" _tsv=0 for _a in "$@"; do case $_a in --tsv) _tsv=1 ;; *) [ -z "$_mode" ] && _mode=$_a ;; esac done _mode=${_mode:-all} _sess_fmt=pretty [ "$_tsv" -eq 1 ] && _sess_fmt=tsv if [ "$_tsv" -eq 0 ]; then printf "%-10s %-7s %-60s %s\n" "HOST" "KIND" "SESSION/CWD/UUID" "DETAIL" fi 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" "$_sess_fmt" ;; *) list_tmux_on "$h" list_disk_on "$h" ;; esac | if [ "$_tsv" -eq 1 ]; then cat else 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 } }' fi done } # Broadcast a prompt to one, some, or all live claude-* tmux sessions across # scan_hosts(). Dry-run by default — the user must pass --yes to actually # deliver, since fanning text into every running agent is high-blast-radius # and a typo'd selector could mis-target. # # Usage: # rclaude send --all -- # rclaude send --host -- # rclaude send --match -- # rclaude send ... --yes -- # actually send (default: preview) # rclaude send ... --dry-run -- # explicit preview (overrides --yes) cmd_send() { _sel=""; _pat=""; _dry=0; _yes=0 while [ $# -gt 0 ]; do case $1 in --all) [ -n "$_sel" ] && { echo "rclaude send: only one selector allowed" >&2; exit 2; } _sel=all; shift ;; --host) [ -n "$_sel" ] && { echo "rclaude send: only one selector allowed" >&2; exit 2; } shift; _sel=host; _pat=${1:-}; [ -z "$_pat" ] && { echo "rclaude send: --host requires a value" >&2; exit 2; }; shift ;; --host=*) [ -n "$_sel" ] && { echo "rclaude send: only one selector allowed" >&2; exit 2; } _sel=host; _pat=${1#--host=}; shift ;; --match) [ -n "$_sel" ] && { echo "rclaude send: only one selector allowed" >&2; exit 2; } shift; _sel=match; _pat=${1:-}; [ -z "$_pat" ] && { echo "rclaude send: --match requires a value" >&2; exit 2; }; shift ;; --match=*) [ -n "$_sel" ] && { echo "rclaude send: only one selector allowed" >&2; exit 2; } _sel=match; _pat=${1#--match=}; shift ;; --dry-run) _dry=1; shift ;; --yes) _yes=1; shift ;; --) shift; break ;; -*) echo "rclaude send: unknown flag: $1" >&2; exit 2 ;; *) break ;; esac done if [ -z "$_sel" ]; then cat >&2 <<'EOF' usage: rclaude send (--all | --host | --match ) [--dry-run] [--yes] -- --all target every live claude-* tmux session on scan_hosts --host target every claude-* session on a specific host --match substring match against tmux session name (which embeds slug) --dry-run preview targets and exit (default unless --yes is passed) --yes actually deliver (still prints preview first) EOF exit 2 fi _text=$* if [ -z "$_text" ]; then echo "rclaude send: missing prompt text (after --)" >&2 exit 2 fi # Gather candidate rows across all hosts, then filter. _rows=$(scan_hosts | while IFS= read -r _h; do list_tmux_on "$_h"; done \ | filter_targets "$_sel" "$_pat") if [ -z "$_rows" ]; then echo "rclaude send: no matching sessions" >&2 exit 2 fi # Preview is always shown, before any delivery. echo "Targets:" printf '%s\n' "$_rows" | awk -F '\t' '{ printf " %-12s %s\n", $1, $3 }' if [ "$_dry" = 1 ] || [ "$_yes" != 1 ]; then echo "(dry-run — pass --yes to send)" exit 0 fi _quoted_text=$(sh_quote "$_text") _total=0; _failed=0 # Use a tempfile to drive the loop so the counters survive (a piped # `while` runs in a subshell under POSIX sh and would lose mutations). _rowfile=$(mktemp /tmp/rclaude-send.XXXXXX 2>/dev/null || echo /tmp/rclaude-send.$$) printf '%s\n' "$_rows" > "$_rowfile" while IFS=$(printf '\t') read -r _host _kind _sess _detail; do [ -z "$_sess" ] && continue _total=$((_total + 1)) if is_local "$_host"; then if tmux send-keys -t "$_sess" -l -- "$_text" 2>/dev/null \ && tmux send-keys -t "$_sess" Enter 2>/dev/null; then : else _failed=$((_failed + 1)) echo "rclaude send: failed on $_host:$_sess" >&2 fi else _q_sess=$(sh_quote "$_sess") if ssh -o BatchMode=yes -o ConnectTimeout=3 "$_host" \ "tmux send-keys -t $_q_sess -l -- $_quoted_text && tmux send-keys -t $_q_sess Enter" \ /dev/null 2>&1; then : else _failed=$((_failed + 1)) echo "rclaude send: failed on $_host:$_sess" >&2 fi fi done < "$_rowfile" rm -f "$_rowfile" _sent=$((_total - _failed)) echo "Sent to $_sent of $_total session(s)." # Exit non-zero only if *every* delivery failed. [ "$_sent" -gt 0 ] } cmd_kill() { # Kill live tmux sessions (ends the claude process inside). Mirrors # cmd_send's selector + local/remote dispatch. Used by supervisors # (clare web) to recycle stale orchestrator sessions. _sel=""; _pat=""; _yes=0 while [ $# -gt 0 ]; do case $1 in --all) _sel=all; shift ;; --host) shift; _sel=host; _pat=${1:-}; shift ;; --host=*) _sel=host; _pat=${1#--host=}; shift ;; --match) shift; _sel=match; _pat=${1:-}; shift ;; --match=*) _sel=match; _pat=${1#--match=}; shift ;; --yes) _yes=1; shift ;; *) break ;; esac done if [ -z "$_sel" ]; then cat >&2 <<'EOF' usage: rclaude kill (--all | --host | --match ) [--yes] Kills live claude-* tmux sessions (ends the claude process inside). --yes is required to actually kill (dry-run preview otherwise). EOF exit 2 fi _rows=$(scan_hosts | while IFS= read -r _h; do list_tmux_on "$_h"; done \ | filter_targets "$_sel" "$_pat") if [ -z "$_rows" ]; then echo "rclaude kill: no matching sessions" >&2 exit 2 fi echo "Targets:" printf '%s\n' "$_rows" | awk -F '\t' '{ printf " %-12s %s\n", $1, $3 }' if [ "$_yes" != 1 ]; then echo "(dry-run — pass --yes to kill)" exit 0 fi _total=0; _failed=0 _rowfile=$(mktemp /tmp/rclaude-kill.XXXXXX 2>/dev/null || echo /tmp/rclaude-kill.$$) printf '%s\n' "$_rows" > "$_rowfile" while IFS=$(printf '\t') read -r _host _kind _sess _detail; do [ -z "$_sess" ] && continue _total=$((_total + 1)) if is_local "$_host"; then tmux kill-session -t "$_sess" 2>/dev/null || _failed=$((_failed + 1)) else _q_sess=$(sh_quote "$_sess") ssh -o BatchMode=yes -o ConnectTimeout=3 "$_host" \ "tmux kill-session -t $_q_sess" /dev/null 2>&1 \ || _failed=$((_failed + 1)) fi done < "$_rowfile" rm -f "$_rowfile" _killed=$((_total - _failed)) echo "Killed $_killed of $_total session(s)." [ "$_killed" -gt 0 ] } # 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="" _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 rows: col 1 = host, col 3 = uuid, col 4 = priority, col 9 = mtime. # Re-sort globally by (priority asc, mtime desc) for the headline view, # but also enforce a per-host floor below — otherwise a host with many # fresh P0/P1 sessions (e.g. local with 2k+ jsonls) takes every seat and # remote hosts disappear from the picker entirely. _triage_raw=$(scan_hosts | while IFS= read -r h; do list_triage_on "$h"; done) _TAB=$(printf '\t') _triage=$(printf '%s\n' "$_triage_raw" | grep -v '^$' \ | sort -t"$_TAB" -k4,4n -k9,9nr) _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 # Per-host floor: every host that produced rows gets at least # _floor seats, so high-priority remote sessions can't be crowded # out by a flood of recent local rows. Floor = max(5, room/hosts). _hosts_with_rows=$(printf '%s\n' "$_triage" | awk -F'\t' '{print $1}' | sort -u) _h_count=$(printf '%s\n' "$_hosts_with_rows" | grep -c . 2>/dev/null || printf 1) [ "$_h_count" -lt 1 ] && _h_count=1 _floor=$((_d_room / _h_count)) [ "$_floor" -lt 5 ] && _floor=5 _reserved="" for _h in $_hosts_with_rows; do _slice=$(printf '%s\n' "$_triage" | awk -F'\t' -v h="$_h" '$1==h' \ | head -n "$_floor") [ -z "$_slice" ] && continue if [ -z "$_reserved" ]; then _reserved=$_slice else _reserved=$(printf '%s\n%s' "$_reserved" "$_slice") fi done _r_count=0 [ -n "$_reserved" ] && _r_count=$(printf '%s\n' "$_reserved" | wc -l | tr -d ' ') if [ "$_r_count" -ge "$_d_room" ]; then _triage_slice=$(printf '%s\n' "$_reserved" \ | sort -t"$_TAB" -k4,4n -k9,9nr \ | head -n "$_d_room") else _remaining=$((_d_room - _r_count)) _fill=$(printf '%s\n' "$_triage" | awk -F'\t' -v rsv="$_reserved" ' BEGIN { n = split(rsv, lines, "\n") for (i=1; i<=n; i++) { split(lines[i], f, "\t") if (f[3] != "") seen[f[3]] = 1 } } !($3 in seen) ' | head -n "$_remaining") if [ -n "$_fill" ]; then _triage_slice=$(printf '%s\n%s' "$_reserved" "$_fill" \ | sort -t"$_TAB" -k4,4n -k9,9nr) else _triage_slice=$(printf '%s\n' "$_reserved" \ | sort -t"$_TAB" -k4,4n -k9,9nr) fi fi 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 # Pattern search ordering (cheapest → expensive): # 1. session display names (~/.claude/sessions/*.json) — fast: one # bounded python scan per host, hits sessions named via `claude -n` # 2. first-user-message snippets + cwd — cheap row-grep # Fallback (only if both empty): full-transcript grep over every jsonl _name_rows=$(scan_hosts | while IFS= read -r h; do name_search_on "$h" "$_pattern"; done) _cheap_all=$(scan_hosts | while IFS= read -r h; do list_search_on "$h"; done) _cheap_rows=$(printf '%s\n' "$_cheap_all" | grep -F -i -- "$_pattern" || true) _matches=$(printf '%s\n%s\n' "$_name_rows" "$_cheap_rows" | grep -v '^$' || 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 if [ -z "$_matches" ]; then printf 'rclaude: no name/snippet hits; searching full transcripts...\n' >&2 _matches=$(scan_hosts | while IFS= read -r h; do deep_search_on "$h" "$_pattern"; done | dedupe_sessions) fi fi # Filter out archived UUIDs (added via picker `-` key, see below). if [ -s "$_ARCHIVE_FILE" ]; then _matches=$(printf '%s\n' "$_matches" | awk -F'\t' -v af="$_ARCHIVE_FILE" ' BEGIN { while ((getline u < af) > 0) arch[u]=1; close(af) } ($2 == "tmux") || !($3 in arch) ') 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" # Append a trailing name column (host+uuid → display name from # ~/.claude/sessions/*.json). Empty for tmux rows and for sessions # with no `claude -n` label. Map is passed via tempfile because # awk -v can't hold a multi-line value. _name_map_f=$(mktemp /tmp/rclaude-namemap.XXXXXX 2>/dev/null || echo /tmp/rclaude-namemap.$$) build_name_map > "$_name_map_f" _matches=$(printf '%s\n' "$_matches" | awk -F'\t' -v OFS='\t' -v mapf="$_name_map_f" ' BEGIN { while ((getline line < mapf) > 0) { split(line, f, "\t") if (f[1] && f[2]) names[f[1] SUBSEP f[2]] = f[3] } close(mapf) } { nm = ($2=="tmux") ? "" : (($1 SUBSEP $3) in names ? names[$1 SUBSEP $3] : "") print $0, nm } ') rm -f "$_name_map_f" 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') # Triage uses P0=critical / P4=abandonable (P0 incident convention). # _Cp5 / _Cp4 names are kept for diff size; what they color is the # *top two* priority levels, whatever the scale is. _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') _Cname=$(printf '\033[1;35m') # display name: bold magenta else _R=; _Chost=; _Ctmux=; _Cdim=; _Ckey= _Cp5=; _Cp4=; _Cblk=; _Cwait=; _Cinp=; _Cdone=; _Cname= 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 + status, session shows the snippet plain. _fmt_row=' function prio_c(p) { if (p=="0") return c_p5; if (p=="1") 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 "" } # Last two path components of cwd, e.g. "@projects/@lilith". function last2(cwd, n, parts) { n = split(cwd, parts, "/") if (n == 0) return "" if (n == 1) return parts[1] return parts[n-1] "/" parts[n] } # Heuristic recovery of cwd from a tmux session name like # "claude---". Lossy (@ became -) but # readable for the common cases. function tmux_dir(name) { sub(/^claude-[^-]+-/, "", name) sub(/-[0-9]+$/, "", name) gsub(/--/, "/", name) return last2(name) } function dir_label() { if ($2 == "tmux") return tmux_dir($3) if ($2 == "triage") return last2($8) if ($2 == "session") { split($5, h, " · "); return last2(h[1]) } return "" } function fit(s, n) { return length(s) > n ? substr(s, 1, n-1) "…" : s } # The trailing column (NF) is the optional display name set via # `claude -n`. Promoted to column B: blank when absent. 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 } # Col B: the explicit `claude -n` display name (NF for sessions, # NF-1 for tmux because tmux rows have no name column at the end). function name_col() { return ($2=="tmux") ? "" : $NF } # "Time ago" column. mtime is in different fields depending on # row kind (session=col 6, triage=col 9). Tmux rows have no # mtime — blank in that column. function ago(secs, abs, s) { if (secs == "" || secs+0 == 0) return "" abs = now - secs+0 if (abs < 0) abs = 0 if (abs < 60) return abs "s" if (abs < 3600) return int(abs/60) "m" if (abs < 86400) return int(abs/3600) "h" if (abs < 86400*30) return int(abs/86400) "d" if (abs < 86400*365) return int(abs/(86400*30)) "mo" return int(abs/(86400*365)) "y" } function age_col() { if ($2 == "session") return ago($6) if ($2 == "triage") return ago($9) return "" # tmux: no age field } { printf "%s%-10s%s %s%5s%s %s%-22s%s %s%-22s%s %s", c_host, $1, r, c_dim, age_col(), r, c_name, fit(name_col(), 22), r, c_dim, fit(dir_label(), 22), 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" \ -v c_name="$_Cname" \ -v now="$(date +%s)" \ "$_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" \ -v c_name="$_Cname" \ -v now="$(date +%s)" \ "$_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] (- archive, q cancel):%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 &2 _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 _row=$(printf '%s\n' "$_matches" | sed -n "${_idx}p") _au=$(printf %s "$_row" | awk -F'\t' '{print $3}') _akind=$(printf %s "$_row" | awk -F'\t' '{print $2}') if [ "$_akind" = "tmux" ]; then echo "rclaude: can't archive a live tmux row — kill the session instead (tmux kill-session)" >&2 exit 1 fi printf '%s\n' "$_au" >> "$_ARCHIVE_FILE" printf 'rclaude: archived %s (hidden from future resume lists; edit %s to undo)\n' \ "$(printf %s "$_au" | cut -c1-8)" "$_ARCHIVE_FILE" >&2 exit 0 fi [ -n "$_old" ] && stty "$_old" 2>/dev/null || true # Render the keystroke we just consumed. Empty (EOF), Enter, Esc, q, # and Ctrl-C all mean "cancel" — just exit cleanly. case $_key in ''|q|Q|$(printf '\r')|$(printf '\n')|$(printf '\033')|$(printf '\003')) printf '(cancelled)\n' >&2 exit 0 ;; esac 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' (expected 1-${_last_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 " · " # 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 setup_host "$_host" if [ "$(pick_transport "$_host")" = "mosh" ]; then exec mosh "$_host" -- tmux attach -t "$_target" fi exec ssh -t $_SSH_LIVE_OPTS "$_host" tmux attach -t "$_target" fi ;; disk) if [ -n "$_on" ] && [ "$_on" != "$_host" ]; then echo "rclaude: --on requires a specific session row (run 'rclaude resume ' 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 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 "$_dst_home" ]; then printf "rclaude: can't reach '%s' (ssh failed or hostname doesn't resolve)\n" "$_dst" >&2 # Did you mean? — match a known host whose first 3 chars # match (cheap typo catch). Strips trailing .lan/.local on # both sides before comparing. _t_base=$(printf %s "$_dst" | sed 's/\.\(lan\|local\)$//' | cut -c1-3) _hint=$(scan_hosts | while IFS= read -r _h; do _hb=$(printf %s "$_h" | sed 's/\.\(lan\|local\)$//' | cut -c1-3) [ "$_hb" = "$_t_base" ] && echo "$_h" && break done) [ -n "$_hint" ] && printf " did you mean: %s ?\n" "$_hint" >&2 exit 1 fi if [ -z "$_src_home" ]; then printf "rclaude: couldn't resolve \$HOME on source '%s'\n" "$_host" >&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 # Pass src + src_cwd to the launch path so it can rsync the project # tree if the dst dir doesn't exist (set RCLAUDE_MIGRATE_SYNC=none # to skip and just mkdir). RCLAUDE_MIGRATE_FROM=$_host RCLAUDE_MIGRATE_FROM_CWD=$_session_cwd \ RCLAUDE_RESUME_ID=$_target \ exec "$0" "$_dst" "$_dst_cwd" ;; esac } # --------------------------------------------------------------------------- # Dispatch # --------------------------------------------------------------------------- # Resolve the hostname THIS machine is reachable at from the remote. Used # to tell the remote claude session where to forward audio/state back to. # Override with RCLAUDE_BACK_HOST in config (e.g. if the local hostname # isn't directly reachable from the remote — pick a wg1 mesh IP / .lan name). caller_hostname() { if [ -n "${RCLAUDE_BACK_HOST:-}" ]; then printf %s "$RCLAUDE_BACK_HOST" return fi _hn=$(hostname -s 2>/dev/null || hostname) case $_hn in *.*) printf %s "$_hn" ;; *) printf '%s.lan' "$_hn" ;; esac } # Guard: when sourced as a library (by tests/run-tests.sh), skip dispatch # so callers can invoke individual helpers without launching anything. # MUST be placed after every helper definition so all functions are # available to the sourcing test runner. if [ "${RCLAUDE_LIB_ONLY:-0}" = "1" ]; then return 0 2>/dev/null || exit 0 fi 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 } cmd_voice() { # `rclaude voice` — toggle / inspect the rvoice push-to-talk binding. # rvoice itself is a separate script (bin/rvoice) driven by Hammerspoon. # This subcommand just gates whether the Hammerspoon tap is active by # writing/removing a sentinel file the lua module checks at load. _flag=${XDG_STATE_HOME:-$HOME/.local/state}/rclaude/voice-disabled mkdir -p "$(dirname "$_flag")" 2>/dev/null _action=${1:-status} case $_action in on|enable) rm -f "$_flag" _reload_hammerspoon echo "rvoice: enabled (Right ⌥ = push-to-talk)" ;; off|disable) : > "$_flag" _reload_hammerspoon echo "rvoice: disabled (delete $_flag or 'rclaude voice on' to re-enable)" ;; status) if [ -f "$_flag" ]; then echo "rvoice: disabled" else echo "rvoice: enabled"; fi command -v rvoice >/dev/null 2>&1 \ && echo " rvoice binary: $(command -v rvoice)" \ || echo " rvoice binary: NOT INSTALLED" ;; test|target) command -v rvoice >/dev/null 2>&1 || { echo "rclaude: rvoice not on PATH" >&2; exit 1; } rvoice target ;; log) command -v rvoice >/dev/null 2>&1 && rvoice log ;; *) cat </dev/null || true } cmd_setup() { # Args: # (none) → install on every host in scan_hosts # [...] → install on each named host # --on → install on a single host (parity with `resume --on`) _hosts="" while [ $# -gt 0 ]; do case $1 in --on) shift; _hosts="$_hosts $1"; shift ;; --on=*) _hosts="$_hosts ${1#--on=}"; shift ;; *) _hosts="$_hosts $1"; shift ;; esac done if [ -z "$_hosts" ]; then scan_hosts | while IFS= read -r h; do setup_host "$h" force; done else for h in $_hosts; do setup_host "$h" force; done fi } case ${1:-} in list) shift; cmd_list "$@"; exit ;; resume) shift; cmd_resume "$@"; exit ;; triage) shift; cmd_triage "$@"; exit ;; send) shift; cmd_send "$@"; exit ;; kill) shift; cmd_kill "$@"; exit ;; setup|install) shift; cmd_setup "$@"; exit ;; voice) shift; cmd_voice "$@"; exit ;; -v|--version) cmd_version; exit ;; -h|--help|help) cmd_help; 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 # Auto-restore display name on resume. Claude's `name` field lives in # ~/.claude/sessions/.json — wiped on every restart/resume — so if # we know a uuid had a name previously (from the durable index), pass # it back via `claude -n ` so the resumed pid is re-labeled. # Explicit RCLAUDE_RESUME_NAME overrides any index lookup. _name_flag="" _name_val=${RCLAUDE_RESUME_NAME:-} if [ -z "$_name_val" ] && [ -n "${RCLAUDE_RESUME_ID:-}" ] && [ -s "${_NAME_INDEX:-}" ]; then _name_val=$(awk -F'\t' -v u="$RCLAUDE_RESUME_ID" '$2 == u { print $3; exit }' "$_NAME_INDEX" 2>/dev/null) fi if [ -n "$_name_val" ]; then # Escape single quotes for safe shell embedding. _name_esc=$(printf %s "$_name_val" | sed "s/'/'\\\\''/g") _name_flag="-n '${_name_esc}'" fi # When launching on a remote host, tell its MCPs where to forward # audio back to (so apricot's TTS plays on the local Mac, etc.). When # local, leave the env alone — local MCPs play locally. _back_env="" if ! is_local "$host"; then _back=$(caller_hostname) _back_env="export SPEECH_PLAYBACK_HOST=${_back}; " fi # Supervisor-spawned sessions (clare) need MCP servers wired without the # interactive trust-prompt round-trip. RCLAUDE_MCP_CONFIG forwards a JSON # config path to `claude --mcp-config `. Quoted to survive spaces. _mcp_flag="" if [ -n "${RCLAUDE_MCP_CONFIG:-}" ]; then _mcp_esc=$(printf %s "$RCLAUDE_MCP_CONFIG" | sed "s/'/'\\\\''/g") _mcp_flag="--mcp-config '${_mcp_esc}'" fi printf '%s' \ "${_back_env}cd ${1} && rc_t=\$(date +%s); claude ${_resume_flag} ${_name_flag} ${_mcp_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 # RCLAUDE_DETACHED=1 → spawn the tmux session in the background and # return the session name on stdout. Used by supervisor processes # (e.g. `clare web`) that want to bring up a Claude session without # attaching the current terminal. if [ -n "${RCLAUDE_DETACHED:-}" ]; then tmux new-session -d -s "$session" "$(build_inner "$dir")" printf '%s\n' "$session" exit 0 fi 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). # # Cross-host mirror exception: when invoked via `resume --on`, we've just # migrated the session JSONL but the project files may not exist on the # target. In that case auto-mkdir so the conversation can be resumed; the # user can sync project files separately (rsync / git clone). The session # state is what matters most for resume. if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "$host" "test -d ${dir}" 2>/dev/null; then if [ -n "${RCLAUDE_RESUME_ID:-}" ] && [ -n "${RCLAUDE_MIGRATE_FROM:-}" ]; then # Cross-host mirror landing: try to rsync the project tree from # source to dst so claude has the files it expects. Falls back to # mkdir-only when sync is disabled or rsync fails. Both endpoints # being remote is unsupported (no two-hop relay). _sync_mode=${RCLAUDE_MIGRATE_SYNC:-rsync} _src_host=$RCLAUDE_MIGRATE_FROM _src_dir=$RCLAUDE_MIGRATE_FROM_CWD _did_rsync=0 if [ "$_sync_mode" = "rsync" ] && command -v rsync >/dev/null 2>&1; then _src_local=0; _dst_local=0 is_local "$_src_host" && _src_local=1 is_local "$host" && _dst_local=1 if [ $((_src_local + _dst_local)) -ge 1 ]; then _src_arg=$([ "$_src_local" = 1 ] && printf '%s/' "$_src_dir" || printf '%s:%s/' "$_src_host" "$_src_dir") _dst_arg=$([ "$_dst_local" = 1 ] && printf '%s/' "$dir" || printf '%s:%s/' "$host" "$dir") printf 'rclaude: rsync -ahz --info=progress2,stats1 %s %s\n' "$_src_arg" "$_dst_arg" >&2 # -h human-readable, -z compress over ssh, progress2 = single # rolling progress bar with rate + ETA, stats1 = summary at end. if rsync -ahz --info=progress2,stats1 "$_src_arg" "$_dst_arg" >&2; then _did_rsync=1 else echo "rclaude: rsync failed; falling back to empty mkdir" >&2 fi fi fi if [ "$_did_rsync" = 0 ]; then echo "rclaude: $dir doesn't exist on $host — creating empty dir for session resume." >&2 echo " (sync separately if needed: rsync -a $_src_dir/ $host:$dir/)" >&2 ssh -o BatchMode=yes -o ConnectTimeout=5 "$host" "mkdir -p ${dir}" 2>/dev/null || { echo "rclaude: mkdir failed on $host: $dir" >&2; exit 1; } fi else 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 fi setup_host "$host" sync_tmux_conf "$host" inner=$(build_inner "$dir") # RCLAUDE_DETACHED=1 → spawn the tmux session on the remote in the # background and print the session name. Symmetric with the local-host # detached branch above; used by supervisor processes (e.g. clare web) # that bring up a remote Claude pane without attaching the calling tty. # # We launch tmux inside a transient `systemd-run --user --unit=` # user service rather than the calling ssh session's scope, with # Restart=on-failure so a tmux server crash (heap-corruption bugs in # older tmux builds, OOM-kill, etc.) is automatically recovered by # systemd rather than waiting on Clare's 60s supervisor heartbeat. # # `--scope` was the old approach but a scope dies with its process — no # restart. Falls back to a plain detached `tmux new-session -d` if the # remote host has no systemd-run (e.g. macOS). # Mosh is interactive-only — always go through ssh for detached spawn. if [ -n "${RCLAUDE_DETACHED:-}" ]; then # Run tmux inside a transient user scope so the tmux daemon escapes # the calling ssh login session's cgroup (which systemd-logind would # otherwise reap on disconnect, even with linger enabled). `--scope` # exits as soon as the tmux daemon detaches; recovery from later # tmux crashes is handled by Clare's supervisor heartbeat in # `clare web` (re-runs ensure_running every 60s). _remote_cmd="systemd-run --user --scope --collect --quiet tmux new-session -d -s '${session}' \"${inner}\" 2>/dev/null || tmux new-session -d -s '${session}' \"${inner}\"" ssh $_SSH_LIVE_OPTS "$host" "$_remote_cmd" printf '%s\n' "$session" exit 0 fi # `new-session -A` attaches if a session of that name already exists, so # re-running rclaude after a broken pipe lands you back in the same tmux # session instead of erroring with "duplicate session". Combined with # _SSH_LIVE_OPTS this tolerates short network drops without losing work. # Mosh is preferred when available (handles sleep/roam/long blips natively); # falls back to ssh+keepalives otherwise. if [ "$(pick_transport "$host")" = "mosh" ]; then exec mosh "$host" -- tmux new-session -A -s "${session}" "${inner}" fi exec ssh -t $_SSH_LIVE_OPTS "$host" "tmux new-session -A -s '${session}' \"${inner}\""