2026-04-25 23:24:02 -07:00
|
|
|
#!/bin/sh
|
2026-04-25 23:53:42 -07:00
|
|
|
# rclaude — durable Claude Code sessions, local or remote.
|
2026-04-25 23:24:02 -07:00
|
|
|
#
|
2026-04-25 23:53:42 -07:00
|
|
|
# Two layers of resilience:
|
|
|
|
|
# 1. tmux on <host> survives terminal/transport drops.
|
2026-04-25 23:24:02 -07:00
|
|
|
# 2. `claude --continue` resumes the per-directory session from disk after
|
2026-04-25 23:53:42 -07:00
|
|
|
# the host itself dies (reboot, crash, OOM).
|
2026-04-25 23:24:02 -07:00
|
|
|
#
|
2026-04-26 15:47:26 -07:00
|
|
|
# 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`.
|
2026-04-25 23:39:21 -07:00
|
|
|
#
|
2026-04-25 23:53:42 -07:00
|
|
|
# Permission mode: --dangerously-skip-permissions is on by default. Override
|
|
|
|
|
# with RCLAUDE_PERMS=default (or any --permission-mode value).
|
2026-04-25 23:24:02 -07:00
|
|
|
#
|
2026-05-17 04:02:16 -07:00
|
|
|
# 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".
|
2026-04-25 23:24:02 -07:00
|
|
|
#
|
|
|
|
|
# Usage:
|
2026-04-25 23:53:42 -07:00
|
|
|
# rclaude # local, $PWD
|
|
|
|
|
# rclaude . # local, $PWD
|
2026-05-17 03:31:04 -07:00
|
|
|
# rclaude <host> # remote: $PWD mirrored under remote $HOME
|
|
|
|
|
# rclaude <host> . # same as above (explicit form)
|
2026-04-25 23:53:42 -07:00
|
|
|
# rclaude <host> <dir> # remote (or local) at <dir>
|
2026-05-17 04:02:16 -07:00
|
|
|
# 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)
|
2026-05-17 03:31:04 -07:00
|
|
|
#
|
|
|
|
|
# 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.
|
2026-04-25 23:24:02 -07:00
|
|
|
|
|
|
|
|
set -eu
|
|
|
|
|
|
2026-04-25 23:53:42 -07:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Helpers
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-04-26 21:06:05 -07:00
|
|
|
# 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"
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 23:53:42 -07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 00:04:09 -07:00
|
|
|
# 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() {
|
2026-04-25 23:53:42 -07:00
|
|
|
_host=$1
|
|
|
|
|
if is_local "$_host"; then
|
2026-04-26 00:04:09 -07:00
|
|
|
command -v tmux >/dev/null 2>&1 || return 0
|
2026-04-25 23:53:42 -07:00
|
|
|
_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:]]+/, "");
|
2026-04-26 00:04:09 -07:00
|
|
|
printf "%s\ttmux\t%s\t%s\n", host, name, $0
|
2026-04-25 23:53:42 -07:00
|
|
|
}
|
|
|
|
|
'
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 00:04:09 -07:00
|
|
|
# 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
|
2026-04-26 21:06:05 -07:00
|
|
|
_helper_dir=$(dirname "$(resolve_self)")
|
2026-04-26 00:04:09 -07:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
'
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 04:02:16 -07:00
|
|
|
# List on-disk Claude sessions per UUID on a host (via _claude-projects --sessions).
|
|
|
|
|
# Output one row per session jsonl:
|
|
|
|
|
# <host>\tsession\t<uuid>\t<snippet>\t<cwd> · <relative-time>
|
|
|
|
|
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.
|
2026-04-26 00:04:09 -07:00
|
|
|
list_all_on() {
|
|
|
|
|
list_tmux_on "$1"
|
|
|
|
|
list_disk_on "$1"
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 04:02:16 -07:00
|
|
|
# Resume-search enumeration: tmux + per-session UUIDs/snippets.
|
|
|
|
|
list_search_on() {
|
|
|
|
|
list_tmux_on "$1"
|
|
|
|
|
list_sessions_on "$1"
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 03:43:38 -07:00
|
|
|
# Push the canonical session-tools tmux fragment to <host> and ensure
|
|
|
|
|
# ~/.tmux.conf sources it. Idempotent; runs on every launch so config changes
|
2026-05-17 04:02:16 -07:00
|
|
|
# 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.
|
2026-05-17 03:43:38 -07:00
|
|
|
sync_tmux_conf() {
|
|
|
|
|
_host=$1
|
2026-05-17 04:02:16 -07:00
|
|
|
_mode=${RCLAUDE_SYNC_TMUX:-1}
|
|
|
|
|
case $_mode in
|
|
|
|
|
0|off|no|false) return 0 ;;
|
|
|
|
|
esac
|
2026-05-17 03:43:38 -07:00
|
|
|
_self=$(resolve_self)
|
|
|
|
|
_repo=$(cd "$(dirname "$_self")/.." 2>/dev/null && pwd)
|
|
|
|
|
_frag="$_repo/tmux.conf"
|
|
|
|
|
[ -f "$_frag" ] || return 0
|
2026-05-17 04:02:16 -07:00
|
|
|
_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'
|
2026-05-17 03:43:38 -07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 04:02:16 -07:00
|
|
|
# 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).
|
2026-04-25 23:53:42 -07:00
|
|
|
scan_hosts() {
|
|
|
|
|
printf "local\n"
|
2026-05-17 04:02:16 -07:00
|
|
|
for h in ${RCLAUDE_HOSTS:-apricot plum}; do
|
|
|
|
|
is_local "$h" && continue
|
2026-04-25 23:53:42 -07:00
|
|
|
printf "%s\n" "$h"
|
|
|
|
|
done
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Subcommands
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
cmd_list() {
|
2026-05-17 04:02:16 -07:00
|
|
|
_mode=${1:-all} # all | tmux | disk | sessions
|
|
|
|
|
printf "%-10s %-7s %-60s %s\n" "HOST" "KIND" "SESSION/CWD/UUID" "DETAIL"
|
2026-04-25 23:53:42 -07:00
|
|
|
scan_hosts | while IFS= read -r h; do
|
2026-04-26 00:04:09 -07:00
|
|
|
case $_mode in
|
2026-05-17 04:02:16 -07:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}'
|
2026-04-25 23:53:42 -07:00
|
|
|
done
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 00:04:09 -07:00
|
|
|
# Resume strategy:
|
2026-05-17 04:02:16 -07:00
|
|
|
# - 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 <uuid> 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).
|
2026-04-25 23:53:42 -07:00
|
|
|
cmd_resume() {
|
|
|
|
|
_pattern=${1:-}
|
2026-05-17 04:02:16 -07:00
|
|
|
_matches=$(scan_hosts | while IFS= read -r h; do list_search_on "$h"; done)
|
2026-04-25 23:53:42 -07:00
|
|
|
if [ -n "$_pattern" ]; then
|
2026-05-17 04:02:16 -07:00
|
|
|
_matches=$(printf '%s\n' "$_matches" | grep -F -i -- "$_pattern" || true)
|
2026-04-25 23:53:42 -07:00
|
|
|
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
|
2026-05-17 03:43:38 -07:00
|
|
|
_keys="123456789abcdefghijklmnopqrstuvwxyz"
|
|
|
|
|
if [ "$_count" -gt 35 ]; then
|
|
|
|
|
echo "too many matches ($_count); refine pattern" >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
2026-05-17 04:02:16 -07:00
|
|
|
# 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() }'
|
2026-05-17 03:43:38 -07:00
|
|
|
if [ ! -t 0 ] || [ ! -t 2 ]; then
|
|
|
|
|
echo "multiple matches and no tty for picker; refine pattern:" >&2
|
2026-05-17 04:02:16 -07:00
|
|
|
printf '%s\n' "$_matches" | awk -F'\t' "$_fmt_row"'{printf "\n"}' >&2
|
2026-05-17 03:43:38 -07:00
|
|
|
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" \
|
2026-05-17 04:02:16 -07:00
|
|
|
"$(printf %s "$_line" | awk -F'\t' "$_fmt_row")" >&2
|
2026-05-17 03:43:38 -07:00
|
|
|
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/tty || true)
|
|
|
|
|
[ -n "$_old" ] && stty "$_old" 2>/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")
|
2026-04-25 23:53:42 -07:00
|
|
|
fi
|
|
|
|
|
_host=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $1}')
|
2026-04-26 00:04:09 -07:00
|
|
|
_kind=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $2}')
|
|
|
|
|
_target=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $3}')
|
2026-05-17 04:02:16 -07:00
|
|
|
# Sessions carry their cwd in column 5 (formatted "<cwd> · <rel-time>");
|
|
|
|
|
# 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}')
|
2026-04-26 00:04:09 -07:00
|
|
|
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)
|
2026-05-17 04:02:16 -07:00
|
|
|
# Spawn tmux + claude --continue at the recorded cwd.
|
2026-04-26 01:01:37 -07:00
|
|
|
RCLAUDE_RESUME=1 exec "$0" "$_host" "$_target"
|
2026-04-26 00:04:09 -07:00
|
|
|
;;
|
2026-05-17 04:02:16 -07:00
|
|
|
session)
|
|
|
|
|
# Spawn tmux + claude --resume <uuid> 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"
|
|
|
|
|
;;
|
2026-04-26 00:04:09 -07:00
|
|
|
esac
|
2026-04-25 23:53:42 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Dispatch
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-04-26 01:11:39 -07:00
|
|
|
cmd_version() {
|
2026-04-26 21:06:05 -07:00
|
|
|
_self=$(resolve_self)
|
2026-04-26 01:11:39 -07:00
|
|
|
_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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 23:53:42 -07:00
|
|
|
case ${1:-} in
|
2026-04-26 01:11:39 -07:00
|
|
|
list) shift; cmd_list "$@"; exit ;;
|
|
|
|
|
resume) shift; cmd_resume "$@"; exit ;;
|
|
|
|
|
-v|--version) cmd_version; exit ;;
|
2026-04-25 23:53:42 -07:00
|
|
|
esac
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Default behavior: launch (or reattach to) a session.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-04-25 23:41:33 -07:00
|
|
|
# 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:-}
|
2026-04-25 23:24:02 -07:00
|
|
|
fi
|
|
|
|
|
|
2026-04-25 23:41:33 -07:00
|
|
|
# Defaults + `.` expansion now that we know whether we're local or remote.
|
|
|
|
|
if is_local "$host"; then
|
|
|
|
|
case ${dir:-.} in
|
|
|
|
|
.|"") dir=$PWD ;;
|
|
|
|
|
esac
|
|
|
|
|
else
|
2026-05-17 03:31:04 -07:00
|
|
|
# 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
|
2026-04-25 23:41:33 -07:00
|
|
|
fi
|
|
|
|
|
fi
|
2026-04-25 23:24:02 -07:00
|
|
|
|
|
|
|
|
slug=$(printf %s "$dir" | sed -e 's|^[~/]*||' -e 's|[^A-Za-z0-9]|-|g')
|
|
|
|
|
[ -z "$slug" ] && slug=home
|
2026-04-26 15:47:26 -07:00
|
|
|
session="claude-$(whoami)-${slug}-$(date +%s)"
|
2026-04-25 23:24:02 -07:00
|
|
|
|
|
|
|
|
perms=${RCLAUDE_PERMS:-bypass}
|
|
|
|
|
case $perms in
|
|
|
|
|
bypass) flag="--dangerously-skip-permissions" ;;
|
|
|
|
|
*) flag="--permission-mode $perms" ;;
|
|
|
|
|
esac
|
|
|
|
|
|
2026-04-26 00:17:14 -07:00
|
|
|
# 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.
|
2026-04-26 01:01:37 -07:00
|
|
|
#
|
2026-04-26 15:47:26 -07:00
|
|
|
# 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 <pattern>`; disk-resume after host death likewise.
|
2026-04-26 01:01:37 -07:00
|
|
|
_resume_flag=""
|
2026-05-17 04:02:16 -07:00
|
|
|
if [ -n "${RCLAUDE_RESUME_ID:-}" ]; then
|
|
|
|
|
_resume_flag="--resume ${RCLAUDE_RESUME_ID}"
|
|
|
|
|
elif [ "${RCLAUDE_RESUME:-0}" = "1" ]; then
|
|
|
|
|
_resume_flag="--continue"
|
|
|
|
|
fi
|
2026-04-26 00:17:14 -07:00
|
|
|
printf '%s' \
|
2026-04-26 01:01:37 -07:00
|
|
|
"cd ${1} && rc_t=\$(date +%s); claude ${_resume_flag} ${flag}; rc_ec=\$?; " \
|
2026-04-26 00:17:14 -07:00
|
|
|
"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"
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 23:39:21 -07:00
|
|
|
if is_local "$host"; then
|
2026-04-25 23:53:42 -07:00
|
|
|
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
|
2026-04-26 00:10:02 -07:00
|
|
|
if ! cd "$dir" 2>/dev/null; then
|
|
|
|
|
echo "rclaude: local directory not found: $dir" >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
2026-05-17 03:43:38 -07:00
|
|
|
sync_tmux_conf local
|
2026-04-26 15:47:26 -07:00
|
|
|
exec tmux new-session -s "$session" "$(build_inner "$dir")"
|
2026-04-25 23:39:21 -07:00
|
|
|
fi
|
|
|
|
|
|
2026-04-26 00:17:14 -07:00
|
|
|
# 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).
|
2026-04-26 00:10:02 -07:00
|
|
|
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
|
|
|
|
|
|
2026-05-17 03:43:38 -07:00
|
|
|
sync_tmux_conf "$host"
|
2026-04-26 00:17:14 -07:00
|
|
|
inner=$(build_inner "$dir")
|
2026-04-26 15:47:26 -07:00
|
|
|
exec ssh -t "$host" "tmux new-session -s '${session}' \"${inner}\""
|