session-tools/bin/rclaude
Natalie 77cd1c74b1 rclaude: pre-flight remote dir existence; loud error instead of silent ssh-close
Catches typos and Claude-instruction-style aliases (@proj/@apps/@pkg) that
look like paths but only resolve in the assistant's mental model, not in any
shell. Without the check the cd inside the tmux command failed silently, the
pane died, the session closed, ssh exited — surfaced to the user as a bare
'Connection to <host> closed.'
2026-04-26 00:10:02 -07:00

238 lines
8.3 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).
#
# Re-running with the same target lands you back in the same conversation:
# tmux reattaches if alive; claude --continue picks up from
# ~/.claude/projects/<encoded-cwd>/ otherwise.
#
# Permission mode: --dangerously-skip-permissions is on by default. Override
# with RCLAUDE_PERMS=default (or any --permission-mode value).
#
# Hosts scanned by `list`/`resume` default to: local + apricot. Override with
# RCLAUDE_HOSTS="apricot black quinn-vps".
#
# Usage:
# rclaude # local, $PWD
# rclaude . # local, $PWD
# rclaude <host> # remote $HOME on <host>
# rclaude <host> <dir> # remote (or local) at <dir>
# rclaude list # show active sessions across hosts
# rclaude resume [pattern] # reattach (interactive if pattern matches >1)
set -eu
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
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 "$0")
if is_local "$_host"; then
_raw=$("$_helper_dir/_claude-projects" 2>/dev/null || true)
else
# Send the helper over stdin so we don't depend on a pre-installed copy
# on the remote (and to dodge quoting issues).
_raw=$(ssh -o BatchMode=yes -o ConnectTimeout=3 "$_host" 'python3 -' < "$_helper_dir/_claude-projects" 2>/dev/null || true)
fi
_now=$(date +%s)
printf %s "$_raw" | awk -F'\t' -v host="$_host" -v now="$_now" '
function rel(secs, abs, s) {
abs = (secs < 0) ? -secs : secs
if (abs < 60) s = abs " seconds"
else if (abs < 3600) s = int(abs/60) " min"
else if (abs < 86400) s = int(abs/3600) " hours"
else s = int(abs/86400) " days"
return s " ago"
}
NF >= 3 {
printf "%s\tdisk\t%s\tsessions=%s, last used %s\n", host, $2, $3, rel(now - $1)
}
'
}
# Combined enumeration: tmux first (live), then on-disk.
list_all_on() {
list_tmux_on "$1"
list_disk_on "$1"
}
# All hosts to scan for list/resume.
scan_hosts() {
printf "local\n"
for h in ${RCLAUDE_HOSTS:-apricot}; do
printf "%s\n" "$h"
done
}
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
cmd_list() {
_mode=${1:-all} # all | tmux | disk
printf "%-10s %-6s %-60s %s\n" "HOST" "KIND" "SESSION/CWD" "DETAIL"
scan_hosts | while IFS= read -r h; do
case $_mode in
tmux) list_tmux_on "$h" ;;
disk) list_disk_on "$h" ;;
*) list_all_on "$h" ;;
esac | awk -F'\t' '{printf "%-10s %-6s %-60s %s\n", $1, $2, $3, $4}'
done
}
# Resume strategy:
# - matches a tmux row → ssh+tmux attach (preserves the live conversation)
# - matches a disk row → re-exec self with `<host> <cwd>` so the normal
# launch path spins up a fresh tmux + claude --continue in that dir
cmd_resume() {
_pattern=${1:-}
_matches=$(scan_hosts | while IFS= read -r h; do list_all_on "$h"; done)
if [ -n "$_pattern" ]; then
_matches=$(printf '%s\n' "$_matches" | grep -F -- "$_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
echo "multiple matches; refine pattern:" >&2
printf '%s\n' "$_matches" | awk -F'\t' '{printf " %-10s %-6s %s\n", $1, $2, $3}' >&2
exit 1
fi
_host=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $1}')
_kind=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $2}')
_target=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $3}')
case $_kind in
tmux)
if is_local "$_host"; then
exec tmux attach -t "$_target"
else
exec ssh -t "$_host" tmux attach -t "$_target"
fi
;;
disk)
# Spawn a fresh tmux + claude --continue at the recorded cwd.
exec "$0" "$_host" "$_target"
;;
esac
}
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
case ${1:-} in
list) shift; cmd_list "$@"; exit ;;
resume) shift; cmd_resume "$@"; 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
if [ "$dir" = "." ]; then
echo "error: '.' as dir requires a local target; pass an explicit remote path" >&2
exit 2
fi
[ -z "$dir" ] && dir=\~
fi
slug=$(printf %s "$dir" | sed -e 's|^[~/]*||' -e 's|[^A-Za-z0-9]|-|g')
[ -z "$slug" ] && slug=home
session="claude-$(whoami)-${slug}"
perms=${RCLAUDE_PERMS:-bypass}
case $perms in
bypass) flag="--dangerously-skip-permissions" ;;
*) flag="--permission-mode $perms" ;;
esac
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
exec tmux new-session -A -s "$session" "exec claude --continue ${flag}"
fi
# Remote: pre-flight the directory on the remote host 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"
# error 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
inner="cd ${dir} && exec claude --continue ${flag}"
exec ssh -t "$host" "tmux new-session -A -s '${session}' \"${inner}\""