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.'
238 lines
8.3 KiB
Bash
Executable file
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}\""
|