session-tools/bin/rclaude
Natalie 47434868e0 rclaude: list/resume now cover on-disk Claude sessions, not just tmux
- new bin/_claude-projects helper: prints tab-sep mtime/cwd/count for every
  ~/.claude/projects/ dir (parses .jsonl entries for cwd field)
- list_tmux_on / list_disk_on / list_all_on: unified enumeration with KIND col
- rclaude list [all|tmux|disk]: filterable view, sorted by recency
- rclaude resume <pattern>: matches against tmux + disk; tmux match attaches,
  disk match re-execs self to spawn fresh tmux + claude --continue at the cwd
- helper streamed to remote via 'ssh host python3 -' so no install required
  on the remote side beyond python3
2026-04-26 00:04:09 -07:00

220 lines
7.5 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
cd "$dir"
exec tmux new-session -A -s "$session" "exec claude --continue ${flag}"
fi
inner="cd ${dir} && exec claude --continue ${flag}"
exec ssh -t "$host" "tmux new-session -A -s '${session}' \"${inner}\""