Wraps the inner claude invocation so a fast (under 2s) or nonzero exit shows '[rclaude] claude exited in Ns with code N' and waits for enter, instead of silently dying and dragging the whole tmux session + ssh transport down with it (surfaces to user as bare 'Connection closed.'). Normal interactive exits still close cleanly.
255 lines
9.2 KiB
Bash
Executable file
255 lines
9.2 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
|
|
|
|
# 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.
|
|
printf '%s' \
|
|
"cd ${1} && rc_t=\$(date +%s); claude --continue ${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
|
|
exec tmux new-session -A -s "$session" "$(build_inner "$dir")"
|
|
fi
|
|
|
|
# Remote: pre-flight the directory so a typo or missing path fails loudly
|
|
# here instead of silently killing the tmux pane and closing the ssh
|
|
# transport (which looks like a generic 'Connection closed' to the user).
|
|
if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "$host" "test -d ${dir}" 2>/dev/null; then
|
|
echo "rclaude: directory not found on $host: $dir" >&2
|
|
case $dir in
|
|
*@proj/*|*@apps/*|*@pkg/*)
|
|
echo " hint: '@proj/@apps/@pkg' are Claude-instruction aliases, not real shell paths." >&2
|
|
echo " See ~/.claude/instructions/project-paths.md for the real ~/Code/<bucket>/<project> mapping." >&2
|
|
;;
|
|
esac
|
|
exit 1
|
|
fi
|
|
|
|
inner=$(build_inner "$dir")
|
|
exec ssh -t "$host" "tmux new-session -A -s '${session}' \"${inner}\""
|