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
|
|
|
# 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)
|
2026-05-17 04:47:15 -07:00
|
|
|
# rclaude triage [--limit N] [--refresh] # Haiku-powered ranked summary of recent sessions
|
|
|
|
|
# # (uses claude-code-batch-sdk + content cache)
|
2026-05-17 22:42:01 -07:00
|
|
|
# rclaude send (--all|--host <h>|--match <pat>) [--yes] -- <text...>
|
|
|
|
|
# # broadcast a prompt to live claude-* tmux
|
|
|
|
|
# # sessions across scan_hosts. Dry-run by
|
|
|
|
|
# # default; --yes to actually deliver.
|
2026-05-17 05:16:25 -07:00
|
|
|
# rclaude resume # picker: live tmux + most-recent disk (--- separator,
|
|
|
|
|
# # deduped by uuid across hosts)
|
|
|
|
|
# rclaude resume [pattern] --on <host> # mirror picked session onto <host> (rewrites cwd via
|
|
|
|
|
# # $HOME-relative mirror) and resume there
|
2026-05-17 04:47:15 -07:00
|
|
|
# rclaude resume [pattern] # reattach / resume by uuid prefix, snippet,
|
|
|
|
|
# # tmux name, or cwd substring (interactive
|
|
|
|
|
# # picker on >1 match)
|
|
|
|
|
#
|
|
|
|
|
# Config file: $XDG_CONFIG_HOME/rclaude/config (defaults to ~/.config/rclaude/config).
|
|
|
|
|
# A plain shell fragment sourced at startup. Useful settings:
|
|
|
|
|
# RCLAUDE_TRIAGE=auto # auto-rank sessions in `resume` (default: off)
|
|
|
|
|
# RCLAUDE_TRIAGE_MODEL=haiku # Claude model for triage
|
|
|
|
|
# RCLAUDE_TRIAGE_LIMIT=100 # max sessions to triage per host
|
|
|
|
|
# RCLAUDE_TRIAGE_CONCURRENT=4 # concurrent claude subprocesses
|
|
|
|
|
# RCLAUDE_TRIAGE_BATCH=8 # sessions per claude CLI call
|
|
|
|
|
# RCLAUDE_HOSTS="apricot plum" # hosts to scan
|
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-05-17 04:47:15 -07:00
|
|
|
# Load user config if present. Lets the user set RCLAUDE_TRIAGE=auto (and
|
|
|
|
|
# friends) once instead of exporting on every invocation. Config file is a
|
|
|
|
|
# plain shell fragment sourced into the current shell.
|
|
|
|
|
if [ -r "${XDG_CONFIG_HOME:-$HOME/.config}/rclaude/config" ]; then
|
|
|
|
|
# shellcheck disable=SC1090
|
|
|
|
|
. "${XDG_CONFIG_HOME:-$HOME/.config}/rclaude/config"
|
|
|
|
|
fi
|
|
|
|
|
|
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).
|
2026-05-18 03:06:23 -07:00
|
|
|
# Default output (column-aligned consumers): one row per session jsonl:
|
|
|
|
|
# <host>\tsession\t<uuid>\t<snippet>\t<cwd> · <relative-time>\t<mtime_epoch>
|
|
|
|
|
# When the second arg is "tsv", emit a machine-readable shape with cwd and
|
|
|
|
|
# mtime_epoch as separate columns (no relative-time string):
|
|
|
|
|
# <host>\tsession\t<uuid>\t<snippet>\t<cwd>\t<mtime_epoch>
|
2026-05-17 04:02:16 -07:00
|
|
|
list_sessions_on() {
|
|
|
|
|
_host=$1
|
2026-05-18 03:06:23 -07:00
|
|
|
_fmt=${2:-pretty}
|
2026-05-17 04:02:16 -07:00
|
|
|
_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)
|
2026-05-18 03:06:23 -07:00
|
|
|
printf %s "$_raw" | awk -F'\t' -v host="$_host" -v now="$_now" -v fmt="$_fmt" '
|
2026-05-17 04:02:16 -07:00
|
|
|
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)
|
2026-05-18 03:06:23 -07:00
|
|
|
if (fmt == "tsv") {
|
|
|
|
|
printf "%s\tsession\t%s\t%s\t%s\t%s\n", host, $2, snippet, $3, $1
|
|
|
|
|
} else {
|
|
|
|
|
# col 6 = raw mtime, hidden — used for cross-host dedup/sort.
|
|
|
|
|
printf "%s\tsession\t%s\t%s\t%s · %s\t%s\n", host, $2, snippet, $3, rel(now - $1), $1
|
|
|
|
|
}
|
2026-05-17 04:02:16 -07:00
|
|
|
}
|
|
|
|
|
'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# 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 06:47:44 -07:00
|
|
|
# Filter list_sessions_on output to the rows whose uuid is in <uuid_list>
|
|
|
|
|
# (newline-separated). Centralizes the regex-build + lookup.
|
|
|
|
|
_filter_sessions_to_uuids() {
|
|
|
|
|
_host=$1; _uuids=$2
|
|
|
|
|
[ -z "$_uuids" ] && return 0
|
|
|
|
|
_re=$(printf '%s\n' "$_uuids" | sort -u | grep -v '^$' | tr '\n' '|' | sed 's/|$//')
|
|
|
|
|
[ -z "$_re" ] && return 0
|
|
|
|
|
CLAUDE_PROJECTS_LIMIT=5000 list_sessions_on "$_host" \
|
|
|
|
|
| awk -F'\t' -v r="^($_re)$" '$3 ~ r'
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 20:34:20 -07:00
|
|
|
# Durable name index — persistent record of (host, uuid, name) tuples for
|
|
|
|
|
# every session display name we've ever observed. Lives on plum, survives
|
|
|
|
|
# remote-host reboots. Source of truth for name_search_on alongside the
|
|
|
|
|
# live per-pid sessions/*.json files.
|
|
|
|
|
_NAME_INDEX=${XDG_DATA_HOME:-$HOME/.local/share}/rclaude/named-sessions.tsv
|
|
|
|
|
mkdir -p "$(dirname "$_NAME_INDEX")" 2>/dev/null
|
|
|
|
|
[ -f "$_NAME_INDEX" ] || touch "$_NAME_INDEX" 2>/dev/null
|
|
|
|
|
|
|
|
|
|
# Build a `host\tuuid\tname` table of every session display name we know
|
|
|
|
|
# about. Combines live observation (ssh into each host, scan
|
|
|
|
|
# ~/.claude/sessions/*.json) with the durable on-disk index — so even after
|
|
|
|
|
# a remote reboot wipes the per-pid metadata, names we saw previously stay
|
|
|
|
|
# searchable. Live observations are upserted into the index every call.
|
2026-05-17 07:42:01 -07:00
|
|
|
build_name_map() {
|
|
|
|
|
_py='import json, os, glob
|
|
|
|
|
for f in glob.glob(os.path.expanduser("~/.claude/sessions/*.json")):
|
|
|
|
|
try: d = json.load(open(f))
|
|
|
|
|
except Exception: continue
|
|
|
|
|
sid = d.get("sessionId") or ""
|
|
|
|
|
name = d.get("name") or ""
|
|
|
|
|
if sid and name: print(f"{sid}\t{name}")'
|
2026-05-17 20:34:20 -07:00
|
|
|
# Live pass: collect current observations into a temp file.
|
|
|
|
|
_live=$(mktemp /tmp/rclaude-namemap-live.XXXXXX 2>/dev/null || echo /tmp/rclaude-namemap-live.$$)
|
2026-05-17 07:42:01 -07:00
|
|
|
scan_hosts | while IFS= read -r _h; do
|
|
|
|
|
if is_local "$_h"; then
|
|
|
|
|
python3 -c "$_py" 2>/dev/null
|
|
|
|
|
else
|
|
|
|
|
ssh -o BatchMode=yes -o ConnectTimeout=3 "$_h" "python3 -" 2>/dev/null <<PYEOF || true
|
|
|
|
|
$_py
|
|
|
|
|
PYEOF
|
2026-05-17 20:34:20 -07:00
|
|
|
fi | awk -F'\t' -v host="$_h" 'NF>=2 {print host "\t" $1 "\t" $2}'
|
|
|
|
|
done > "$_live"
|
|
|
|
|
# Persist anything new into the durable index. Dedup by (host, uuid)
|
|
|
|
|
# keeping the most-recent name we've ever seen (later writes win).
|
|
|
|
|
if [ -s "$_live" ]; then
|
|
|
|
|
cat "$_NAME_INDEX" "$_live" \
|
|
|
|
|
| awk -F'\t' 'NF>=3 { key=$1 SUBSEP $2; row[key]=$0 } END { for (k in row) print row[k] }' \
|
|
|
|
|
> "${_NAME_INDEX}.tmp" \
|
|
|
|
|
&& mv -f "${_NAME_INDEX}.tmp" "$_NAME_INDEX"
|
|
|
|
|
fi
|
|
|
|
|
rm -f "$_live"
|
|
|
|
|
# Output: every row in the durable index. Picker uses this to enrich rows.
|
|
|
|
|
cat "$_NAME_INDEX" 2>/dev/null
|
2026-05-17 07:42:01 -07:00
|
|
|
}
|
|
|
|
|
|
2026-05-17 06:47:44 -07:00
|
|
|
# Cheap match by session display name (the `claude -n <name>` label, stored in
|
|
|
|
|
# ~/.claude/sessions/<pid>.json). Single ssh round-trip. Always included in
|
|
|
|
|
# pattern searches because the file count is bounded (one per active pid).
|
|
|
|
|
name_search_on() {
|
2026-05-17 06:17:28 -07:00
|
|
|
_host=$1; _pat=$2
|
|
|
|
|
_q=$(printf %s "$_pat" | sed "s/'/'\\\\''/g")
|
2026-05-17 06:47:44 -07:00
|
|
|
_py='import json, os, sys, glob
|
2026-05-17 06:35:35 -07:00
|
|
|
pat = os.environ.get("RCLAUDE_PAT", "").lower()
|
|
|
|
|
if not pat: sys.exit(0)
|
|
|
|
|
for f in glob.glob(os.path.expanduser("~/.claude/sessions/*.json")):
|
2026-05-17 06:47:44 -07:00
|
|
|
try: d = json.load(open(f))
|
|
|
|
|
except Exception: continue
|
2026-05-17 06:35:35 -07:00
|
|
|
name = (d.get("name") or "").lower()
|
2026-05-17 06:47:44 -07:00
|
|
|
sid = d.get("sessionId") or ""
|
|
|
|
|
if pat in name and sid: print(sid)'
|
2026-05-17 20:34:20 -07:00
|
|
|
# Live pass — per-pid metadata on <host> (vanishes on reboot).
|
2026-05-17 06:17:28 -07:00
|
|
|
if is_local "$_host"; then
|
2026-05-17 06:47:44 -07:00
|
|
|
_uuids=$(RCLAUDE_PAT="$_pat" python3 -c "$_py" 2>/dev/null || true)
|
2026-05-17 06:17:28 -07:00
|
|
|
else
|
2026-05-17 06:47:44 -07:00
|
|
|
_uuids=$(ssh -o BatchMode=yes -o ConnectTimeout=5 "$_host" \
|
2026-05-17 06:35:35 -07:00
|
|
|
"RCLAUDE_PAT='$_q' python3 -" 2>/dev/null <<PYEOF || true
|
2026-05-17 06:47:44 -07:00
|
|
|
$_py
|
2026-05-17 06:35:35 -07:00
|
|
|
PYEOF
|
|
|
|
|
)
|
2026-05-17 06:17:28 -07:00
|
|
|
fi
|
2026-05-17 20:34:20 -07:00
|
|
|
# Durable pass — names we've previously seen for THIS host, even if the
|
|
|
|
|
# per-pid file is gone now (reboot, claude crash, etc.). Case-insensitive
|
|
|
|
|
# substring match on the name column.
|
|
|
|
|
_pat_lc=$(printf %s "$_pat" | tr '[:upper:]' '[:lower:]')
|
|
|
|
|
_dur_uuids=$(awk -F'\t' -v h="$_host" -v p="$_pat_lc" '
|
|
|
|
|
BEGIN { IGNORECASE = 1 }
|
|
|
|
|
$1 == h && index(tolower($3), p) > 0 { print $2 }
|
|
|
|
|
' "$_NAME_INDEX" 2>/dev/null)
|
|
|
|
|
_all_uuids=$(printf '%s\n%s\n' "$_uuids" "$_dur_uuids" | sort -u | grep -v '^$' || true)
|
|
|
|
|
_filter_sessions_to_uuids "$_host" "$_all_uuids"
|
2026-05-17 06:47:44 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Grep the full content of every Claude session JSONL on <host> for <pat>.
|
|
|
|
|
# Expensive — only used as the final fallback when cheap searches return nothing.
|
|
|
|
|
deep_search_on() {
|
|
|
|
|
_host=$1; _pat=$2
|
|
|
|
|
_q=$(printf %s "$_pat" | sed "s/'/'\\\\''/g")
|
|
|
|
|
if is_local "$_host"; then
|
|
|
|
|
_uuids=$(grep -l -F -i -- "$_pat" "$HOME/.claude/projects/"*/*.jsonl 2>/dev/null \
|
|
|
|
|
| awk -F/ '{print $NF}' | sed 's/\.jsonl$//')
|
|
|
|
|
else
|
|
|
|
|
_uuids=$(ssh -o BatchMode=yes -o ConnectTimeout=5 "$_host" \
|
|
|
|
|
"grep -l -F -i -- '$_q' \$HOME/.claude/projects/*/*.jsonl 2>/dev/null \
|
|
|
|
|
| awk -F/ '{print \$NF}' | sed 's/\\.jsonl\$//'" 2>/dev/null || true)
|
|
|
|
|
fi
|
|
|
|
|
_filter_sessions_to_uuids "$_host" "$_uuids"
|
2026-05-17 06:17:28 -07:00
|
|
|
}
|
|
|
|
|
|
2026-05-17 05:16:25 -07:00
|
|
|
# Get $HOME on <host> (cached per host in /tmp for the life of this shell).
|
2026-05-17 17:42:06 -07:00
|
|
|
# Always returns 0 — caller distinguishes success/failure by checking whether
|
|
|
|
|
# the output is empty (e.g. unknown host, ssh refused). This matters because
|
|
|
|
|
# the script runs under `set -e`; a function returning non-zero from inside
|
|
|
|
|
# `$(...)` aborts the caller mid-flow.
|
2026-05-17 05:16:25 -07:00
|
|
|
get_home() {
|
|
|
|
|
_h=$1
|
|
|
|
|
_cache="/tmp/rclaude-home.$(whoami).$(printf %s "$_h" | tr -c 'A-Za-z0-9' '_')"
|
|
|
|
|
if [ -s "$_cache" ]; then
|
2026-05-17 17:42:06 -07:00
|
|
|
cat "$_cache"
|
|
|
|
|
return 0
|
2026-05-17 05:16:25 -07:00
|
|
|
fi
|
|
|
|
|
if is_local "$_h"; then
|
|
|
|
|
_v=$HOME
|
|
|
|
|
else
|
|
|
|
|
_v=$(ssh -o BatchMode=yes -o ConnectTimeout=3 "$_h" 'printf %s "$HOME"' 2>/dev/null || true)
|
|
|
|
|
fi
|
2026-05-17 17:42:06 -07:00
|
|
|
if [ -n "$_v" ]; then
|
|
|
|
|
printf '%s' "$_v" > "$_cache" 2>/dev/null || true
|
|
|
|
|
printf %s "$_v"
|
|
|
|
|
fi
|
|
|
|
|
return 0
|
2026-05-17 05:16:25 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Compute Claude's project-slug from a cwd path. Claude replaces every
|
|
|
|
|
# non-alphanumeric character with `-` (so `/` and `@` both become `-`).
|
|
|
|
|
# /Users/natalie/Code/@projects/@lilith → -Users-natalie-Code--projects--lilith
|
|
|
|
|
claude_slug() {
|
|
|
|
|
printf %s "$1" | sed 's|[^A-Za-z0-9]|-|g'
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 23:00:04 -07:00
|
|
|
# POSIX single-quote escape — safe for embedding arbitrary user-provided
|
|
|
|
|
# strings inside an outer single-quoted context (e.g. ssh '<remote-cmd>').
|
|
|
|
|
# sh_quote "" → ''
|
|
|
|
|
# sh_quote "hello" → 'hello'
|
|
|
|
|
# sh_quote "it's" → 'it'\''s'
|
|
|
|
|
# The classic '\'' trick: close the open quote, escape a literal ', reopen.
|
|
|
|
|
sh_quote() {
|
|
|
|
|
printf "'%s'" "$(printf %s "$1" | sed "s/'/'\\\\''/g")"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Row filter for the send subcommand. Reads TSV rows on stdin
|
|
|
|
|
# (host \t kind \t name/slug \t detail \t [cwd]) and emits the subset
|
|
|
|
|
# matching <selector> with <pattern>. Disk rows are always dropped — you
|
|
|
|
|
# can't `tmux send-keys` to a session that isn't running.
|
|
|
|
|
#
|
|
|
|
|
# selector = all → every tmux row (pattern ignored)
|
|
|
|
|
# selector = host → tmux rows where col 1 == pattern
|
|
|
|
|
# selector = match → tmux rows where pattern (case-insensitive
|
|
|
|
|
# substring) appears in col 3 (session
|
|
|
|
|
# name slug) OR col 5 (cwd, if present)
|
|
|
|
|
filter_targets() {
|
|
|
|
|
_selector=$1
|
|
|
|
|
_pattern=$2
|
|
|
|
|
awk -F'\t' -v sel="$_selector" -v pat="$_pattern" '
|
|
|
|
|
BEGIN { IGNORECASE = 1 }
|
|
|
|
|
$2 != "tmux" { next }
|
|
|
|
|
sel == "all" { print; next }
|
|
|
|
|
sel == "host" { if ($1 == pat) print; next }
|
|
|
|
|
sel == "match" {
|
|
|
|
|
if (index(tolower($3), tolower(pat)) > 0) { print; next }
|
|
|
|
|
if (NF >= 5 && index(tolower($5), tolower(pat)) > 0) { print; next }
|
|
|
|
|
}
|
|
|
|
|
'
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 05:16:25 -07:00
|
|
|
# Dedupe session rows (col 2 == "session") across hosts by UUID (col 3),
|
|
|
|
|
# keeping the row with the highest mtime (col 6). Output sorted desc by mtime.
|
|
|
|
|
dedupe_sessions() {
|
|
|
|
|
awk -F'\t' 'BEGIN{OFS=FS}
|
|
|
|
|
{ if (!($3 in mt) || $6+0 > mt[$3]) { mt[$3]=$6+0; row[$3]=$0 } }
|
|
|
|
|
END { for (u in row) print row[u] }
|
|
|
|
|
' | sort -t"$(printf '\t')" -k6,6nr
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 07:48:00 -07:00
|
|
|
# Mirror a session JSONL from <src_host>'s ~/.claude/projects/*/<uuid>.jsonl
|
2026-05-17 05:16:25 -07:00
|
|
|
# to <dst_host>'s ~/.claude/projects/<dst_slug>/<uuid>.jsonl, rewriting every
|
2026-05-17 07:48:00 -07:00
|
|
|
# `"cwd":"<src_cwd...>"` occurrence to point at <dst_cwd...>. The source
|
|
|
|
|
# project-slug is NOT computed from <src_cwd> — claude stores sessions under
|
|
|
|
|
# the slug of the cwd-at-session-start, which can differ from any later cwd
|
|
|
|
|
# the session recorded — so we search for the file by uuid instead.
|
2026-05-17 05:16:25 -07:00
|
|
|
migrate_session() {
|
|
|
|
|
_src=$1; _dst=$2; _uuid=$3; _src_cwd=$4; _dst_cwd=$5
|
|
|
|
|
_dst_slug=$(claude_slug "$_dst_cwd")
|
|
|
|
|
_dst_path="\$HOME/.claude/projects/${_dst_slug}/${_uuid}.jsonl"
|
|
|
|
|
|
|
|
|
|
if is_local "$_src"; then
|
2026-05-17 07:48:00 -07:00
|
|
|
_src_path=$(ls "$HOME/.claude/projects/"*/"${_uuid}.jsonl" 2>/dev/null | head -1)
|
|
|
|
|
[ -n "$_src_path" ] && _src_data=$(cat "$_src_path" 2>/dev/null) || _src_data=""
|
2026-05-17 05:16:25 -07:00
|
|
|
else
|
2026-05-17 07:48:00 -07:00
|
|
|
_src_data=$(ssh -o BatchMode=yes -o ConnectTimeout=5 "$_src" \
|
|
|
|
|
"cat \$(ls \$HOME/.claude/projects/*/${_uuid}.jsonl 2>/dev/null | head -1) 2>/dev/null" || true)
|
2026-05-17 05:16:25 -07:00
|
|
|
fi
|
|
|
|
|
if [ -z "$_src_data" ]; then
|
2026-05-17 07:48:00 -07:00
|
|
|
echo "rclaude: source session $_uuid not found anywhere under $_src:~/.claude/projects/" >&2
|
2026-05-17 05:16:25 -07:00
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
_rewrite_py=$(cat <<'PY'
|
|
|
|
|
import json, sys
|
|
|
|
|
old, new = sys.argv[1], sys.argv[2]
|
|
|
|
|
for line in sys.stdin:
|
|
|
|
|
try:
|
|
|
|
|
e = json.loads(line)
|
|
|
|
|
except Exception:
|
|
|
|
|
sys.stdout.write(line); continue
|
|
|
|
|
cwd = e.get("cwd")
|
|
|
|
|
if isinstance(cwd, str):
|
|
|
|
|
if cwd == old: e["cwd"] = new
|
|
|
|
|
elif cwd.startswith(old + "/"): e["cwd"] = new + cwd[len(old):]
|
|
|
|
|
sys.stdout.write(json.dumps(e) + "\n")
|
|
|
|
|
PY
|
|
|
|
|
)
|
|
|
|
|
_rewritten=$(printf '%s' "$_src_data" | python3 -c "$_rewrite_py" "$_src_cwd" "$_dst_cwd") || {
|
|
|
|
|
echo "rclaude: cwd rewrite failed" >&2; return 1; }
|
|
|
|
|
|
|
|
|
|
_mkdir="mkdir -p \$HOME/.claude/projects/${_dst_slug}"
|
|
|
|
|
if is_local "$_dst"; then
|
|
|
|
|
sh -c "$_mkdir && cat > $HOME/.claude/projects/${_dst_slug}/${_uuid}.jsonl" <<EOF
|
|
|
|
|
$_rewritten
|
|
|
|
|
EOF
|
|
|
|
|
else
|
|
|
|
|
ssh -o BatchMode=yes -o ConnectTimeout=5 "$_dst" "$_mkdir && cat > $_dst_path" <<EOF
|
|
|
|
|
$_rewritten
|
|
|
|
|
EOF
|
|
|
|
|
fi || { echo "rclaude: failed to write to $_dst" >&2; return 1; }
|
|
|
|
|
printf 'rclaude: mirrored session %s → %s (%s)\n' "$(printf %s "$_uuid" | cut -c1-8)" "$_dst" "$_dst_cwd" >&2
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 04:47:15 -07:00
|
|
|
# Pick a python that has claude_code_batch_sdk importable. Walks 3.13/.12/.11
|
|
|
|
|
# then falls back to plain python3. The SDK requires Python 3.11+. Tries each
|
|
|
|
|
# candidate with a real `-c "import claude_code_batch_sdk"` so a non-SDK
|
|
|
|
|
# install of a newer python doesn't shadow an SDK-equipped older one.
|
|
|
|
|
_PICK_PY_SNIPPET='for _p in python3.13 python3.12 python3.11 python3; do _b=$(command -v "$_p" 2>/dev/null) || continue; "$_b" -c "import claude_code_batch_sdk" 2>/dev/null && PY="$_b" && break; done; [ -z "${PY:-}" ] && { echo "rclaude: no python with claude_code_batch_sdk found" >&2; exit 2; }'
|
|
|
|
|
_REMOTE_TRIAGE_BOOT='export PATH=$HOME/.local/bin:/opt/homebrew/bin:$PATH; '"$_PICK_PY_SNIPPET"';'
|
2026-05-17 04:26:18 -07:00
|
|
|
|
2026-05-17 07:11:47 -07:00
|
|
|
# Keepalive options for interactive ssh -t sessions. Sends a keepalive every
|
|
|
|
|
# 30s; tolerates 6 consecutive failures (~3 min of network blip) before the
|
|
|
|
|
# transport drops. Pair with `tmux new-session -A` below so a manual reconnect
|
|
|
|
|
# (re-running cc/rclaude resume) lands back in the same tmux session — the
|
|
|
|
|
# work itself is already protected by tmux on the remote.
|
|
|
|
|
_SSH_LIVE_OPTS='-o ServerAliveInterval=30 -o ServerAliveCountMax=6 -o TCPKeepAlive=yes'
|
|
|
|
|
|
2026-05-17 07:23:49 -07:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Setup / dependency auto-install
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
# Cache dir for per-host setup markers. A marker file means we've already
|
|
|
|
|
# probed + installed deps on that host within RCLAUDE_SETUP_TTL days.
|
|
|
|
|
_SETUP_CACHE_DIR=${XDG_CACHE_HOME:-$HOME/.cache}/rclaude
|
2026-05-17 08:06:16 -07:00
|
|
|
|
|
|
|
|
# Archive: per-user list of session UUIDs to hide from `rclaude resume`. One
|
|
|
|
|
# uuid per line. Populated via the picker's `-` key (then a selection key).
|
|
|
|
|
_ARCHIVE_FILE=${XDG_DATA_HOME:-$HOME/.local/share}/rclaude/archived-uuids
|
|
|
|
|
mkdir -p "$(dirname "$_ARCHIVE_FILE")" 2>/dev/null
|
|
|
|
|
[ -f "$_ARCHIVE_FILE" ] || touch "$_ARCHIVE_FILE" 2>/dev/null
|
2026-05-17 07:23:49 -07:00
|
|
|
_SETUP_TTL_DAYS=${RCLAUDE_SETUP_TTL:-7}
|
|
|
|
|
|
|
|
|
|
# Detect package-manager family on <host>. Output: macos | rhel | debian | unknown.
|
|
|
|
|
detect_os_on() {
|
|
|
|
|
_h=$1
|
|
|
|
|
_probe='if [ "$(uname -s)" = "Darwin" ]; then echo macos
|
|
|
|
|
elif command -v dnf >/dev/null 2>&1; then echo rhel
|
|
|
|
|
elif command -v apt-get >/dev/null 2>&1; then echo debian
|
|
|
|
|
else echo unknown; fi'
|
|
|
|
|
if is_local "$_h"; then
|
|
|
|
|
sh -c "$_probe" 2>/dev/null
|
|
|
|
|
else
|
|
|
|
|
ssh -o BatchMode=yes -o ConnectTimeout=5 "$_h" "$_probe" 2>/dev/null
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Install <pkgs> on <host> using its native package manager. Uses sudo for
|
2026-05-17 07:29:55 -07:00
|
|
|
# system pkgs on Linux; brew (no sudo) on macOS. On bootc/Silverblue-style
|
|
|
|
|
# immutable systems (signaled by /run/ostree-booted), dnf is given
|
|
|
|
|
# `--transient` so the install lands in an overlay that survives until
|
|
|
|
|
# reboot — rclaude's per-host marker is invalidated below on reboot so it
|
|
|
|
|
# re-installs automatically.
|
2026-05-17 07:23:49 -07:00
|
|
|
install_pkgs_on() {
|
|
|
|
|
_h=$1; _os=$2; shift 2
|
|
|
|
|
_pkgs=$*
|
|
|
|
|
[ -z "$_pkgs" ] && return 0
|
2026-05-17 07:29:55 -07:00
|
|
|
_dnf_flags="-y"
|
|
|
|
|
if [ "$_os" = "rhel" ]; then
|
|
|
|
|
if _probe_on "$_h" 'test -e /run/ostree-booted && echo bootc; :' | grep -q bootc; then
|
|
|
|
|
_dnf_flags="-y --transient"
|
|
|
|
|
printf 'rclaude: %s is bootc/immutable — using --transient overlay\n' "$_h" >&2
|
|
|
|
|
fi
|
|
|
|
|
fi
|
2026-05-17 07:23:49 -07:00
|
|
|
case $_os in
|
|
|
|
|
macos) _cmd="brew install $_pkgs" ;;
|
2026-05-17 07:29:55 -07:00
|
|
|
rhel) _cmd="sudo dnf install $_dnf_flags $_pkgs" ;;
|
2026-05-17 07:23:49 -07:00
|
|
|
debian) _cmd="sudo apt-get update && sudo apt-get install -y $_pkgs" ;;
|
|
|
|
|
*)
|
|
|
|
|
echo "rclaude: don't know how to install $_pkgs on $_h ($_os) — do it manually" >&2
|
|
|
|
|
return 1 ;;
|
|
|
|
|
esac
|
|
|
|
|
printf 'rclaude: installing on %s: %s\n' "$_h" "$_pkgs" >&2
|
|
|
|
|
if is_local "$_h"; then
|
|
|
|
|
sh -c "$_cmd" >&2
|
|
|
|
|
else
|
|
|
|
|
ssh -t "$_h" "$_cmd" >&2
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Run a probe command on <host>, return its stdout. Used by setup_host.
|
|
|
|
|
_probe_on() {
|
|
|
|
|
_h=$1; _cmd=$2
|
|
|
|
|
if is_local "$_h"; then sh -c "$_cmd" 2>/dev/null
|
|
|
|
|
else ssh -o BatchMode=yes -o ConnectTimeout=5 "$_h" "$_cmd" 2>/dev/null
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Idempotently install rclaude's deps on <host>. Honors a per-host marker so
|
|
|
|
|
# we don't re-probe on every invocation. Pass `force` to bypass the marker.
|
|
|
|
|
setup_host() {
|
|
|
|
|
_h=$1; _force=${2:-}
|
|
|
|
|
mkdir -p "$_SETUP_CACHE_DIR" 2>/dev/null
|
|
|
|
|
_marker_id=$(printf %s "$_h" | tr -c 'A-Za-z0-9' '_')
|
|
|
|
|
_marker="$_SETUP_CACHE_DIR/setup-$_marker_id"
|
|
|
|
|
if [ "$_force" != "force" ] && [ -f "$_marker" ]; then
|
|
|
|
|
# Marker exists and is recent enough → assume deps are fine.
|
|
|
|
|
if [ -z "$(find "$_marker" -mtime +"$_SETUP_TTL_DAYS" 2>/dev/null)" ]; then
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
_os=$(detect_os_on "$_h")
|
|
|
|
|
if [ "$_os" = "unknown" ] || [ -z "$_os" ]; then
|
|
|
|
|
echo "rclaude: couldn't detect OS on $_h; skipping setup" >&2
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
2026-05-17 07:29:55 -07:00
|
|
|
# Probe system binaries. The trailing `; :` guarantees a 0 exit from the
|
|
|
|
|
# remote shell so `set -e` in our caller doesn't kill us when a missing
|
|
|
|
|
# binary's `command -v` returns 1 inside the loop. Newline-delim output;
|
|
|
|
|
# the awk comparison below ignores delimiter shape.
|
2026-05-17 07:23:49 -07:00
|
|
|
_missing=""
|
2026-05-17 07:29:55 -07:00
|
|
|
_have=$(_probe_on "$_h" 'for c in tmux rsync mosh; do command -v "$c" >/dev/null 2>&1 && echo "$c"; done; :')
|
2026-05-17 07:23:49 -07:00
|
|
|
for c in tmux rsync mosh; do
|
2026-05-17 07:29:55 -07:00
|
|
|
printf '%s\n' "$_have" | grep -Fxq "$c" || _missing="$_missing $c"
|
2026-05-17 07:23:49 -07:00
|
|
|
done
|
|
|
|
|
if [ -n "$_missing" ]; then
|
|
|
|
|
install_pkgs_on "$_h" "$_os" $_missing || true
|
|
|
|
|
fi
|
2026-05-17 07:29:55 -07:00
|
|
|
# Python SDK for triage. Try to install per-user without sudo. Same `; :`
|
|
|
|
|
# guard for the same reason.
|
|
|
|
|
_has_sdk=$(_probe_on "$_h" 'for p in python3.13 python3.12 python3.11 python3; do b=$(command -v "$p" 2>/dev/null) || continue; "$b" -c "import claude_code_batch_sdk" 2>/dev/null && echo "$b" && break; done; :')
|
2026-05-17 07:23:49 -07:00
|
|
|
if [ -z "$_has_sdk" ]; then
|
|
|
|
|
_pick=$(_probe_on "$_h" 'for p in python3.12 python3.11 python3; do command -v "$p" 2>/dev/null && break; done | head -1')
|
|
|
|
|
if [ -n "$_pick" ]; then
|
|
|
|
|
printf 'rclaude: installing claude-code-batch-sdk via %s on %s\n' "$_pick" "$_h" >&2
|
|
|
|
|
if is_local "$_h"; then
|
|
|
|
|
"$_pick" -m pip install --user --quiet claude-code-batch-sdk >&2 || true
|
|
|
|
|
else
|
|
|
|
|
ssh "$_h" "$_pick -m pip install --user --quiet claude-code-batch-sdk" >&2 || true
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
touch "$_marker" 2>/dev/null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Prefer mosh when available on both ends — unless explicitly disabled via
|
|
|
|
|
# RCLAUDE_TRANSPORT=ssh. Echoes "mosh" or "ssh". Caches result per host.
|
|
|
|
|
pick_transport() {
|
|
|
|
|
_h=$1
|
|
|
|
|
case ${RCLAUDE_TRANSPORT:-auto} in
|
|
|
|
|
ssh) echo ssh; return ;;
|
|
|
|
|
mosh) echo mosh; return ;;
|
|
|
|
|
esac
|
|
|
|
|
if ! command -v mosh >/dev/null 2>&1; then echo ssh; return; fi
|
|
|
|
|
_cache="/tmp/rclaude-transport.$(whoami).$(printf %s "$_h" | tr -c 'A-Za-z0-9' '_')"
|
|
|
|
|
if [ -s "$_cache" ]; then cat "$_cache"; return; fi
|
|
|
|
|
if _probe_on "$_h" 'command -v mosh-server >/dev/null 2>&1' | grep -q . \
|
|
|
|
|
|| _probe_on "$_h" 'command -v mosh-server' >/dev/null; then
|
|
|
|
|
echo mosh > "$_cache"; echo mosh
|
|
|
|
|
else
|
|
|
|
|
echo ssh > "$_cache"; echo ssh
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 04:47:15 -07:00
|
|
|
# Run the triage helper on <host> with the supplied extra args. Stdout is the
|
|
|
|
|
# raw TSV emitted by _claude-triage (one row per session).
|
|
|
|
|
list_triage_on() {
|
|
|
|
|
_host=$1
|
|
|
|
|
shift
|
|
|
|
|
_helper_dir=$(dirname "$(resolve_self)")
|
|
|
|
|
_helper="$_helper_dir/_claude-triage"
|
|
|
|
|
[ -f "$_helper" ] || return 0
|
|
|
|
|
if is_local "$_host"; then
|
2026-05-17 05:10:18 -07:00
|
|
|
# Probe PATH with Homebrew prefixes (mac) and the user's ~/.local/bin
|
|
|
|
|
# both included, in case the script is invoked from a leaner non-login
|
|
|
|
|
# context (ssh, cron, tmux without env inheritance).
|
|
|
|
|
_orig_path=$PATH
|
|
|
|
|
PATH=$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH
|
2026-05-17 04:47:15 -07:00
|
|
|
PY=""
|
|
|
|
|
for _p in python3.13 python3.12 python3.11 python3; do
|
|
|
|
|
_b=$(command -v "$_p" 2>/dev/null) || continue
|
|
|
|
|
"$_b" -c "import claude_code_batch_sdk" 2>/dev/null && PY=$_b && break
|
|
|
|
|
done
|
2026-05-17 05:10:18 -07:00
|
|
|
PATH=$_orig_path
|
2026-05-17 04:47:15 -07:00
|
|
|
if [ -z "$PY" ]; then
|
|
|
|
|
echo "rclaude: no python with claude_code_batch_sdk found locally" >&2
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
2026-05-17 22:30:01 -07:00
|
|
|
# Detach stdin: the SDK spawns claude CLI subprocesses that inherit
|
|
|
|
|
# stdin from our caller. If we're invoked inside `scan_hosts | while
|
|
|
|
|
# read`, those subprocesses eat the pipe and the loop dies after the
|
|
|
|
|
# first host. </dev/null isolates us.
|
|
|
|
|
"$PY" "$_helper" "$@" </dev/null 2>/dev/null || true
|
2026-05-17 04:26:18 -07:00
|
|
|
else
|
2026-05-17 04:47:15 -07:00
|
|
|
_args=""
|
|
|
|
|
for a in "$@"; do
|
|
|
|
|
_args="$_args $(printf %s "$a" | sed 's/"/\\"/g; s/^/"/; s/$/"/')"
|
|
|
|
|
done
|
|
|
|
|
# Stream the helper over stdin so we don't depend on it being
|
|
|
|
|
# pre-installed on the remote.
|
|
|
|
|
ssh -o BatchMode=yes -o ConnectTimeout=5 "$_host" \
|
|
|
|
|
"${_REMOTE_TRIAGE_BOOT} \$PY -${_args}" \
|
2026-05-17 22:30:01 -07:00
|
|
|
< "$_helper" 2>/dev/null || true
|
2026-05-17 04:47:15 -07:00
|
|
|
fi | awk -F'\t' -v host="$_host" '
|
|
|
|
|
# _claude-triage writes informational lines starting with "# " to
|
|
|
|
|
# stderr; the stdout we capture is pure TSV. Each row:
|
|
|
|
|
# mtime\tuuid\tcwd\tpriority\tstatus\tsummary\tnext_action
|
|
|
|
|
NF >= 7 { printf "%s\ttriage\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
|
|
|
|
host, $2, $4, $5, $6, $7, $3, $1 }
|
|
|
|
|
'
|
2026-05-17 04:26:18 -07:00
|
|
|
}
|
|
|
|
|
|
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:47:15 -07:00
|
|
|
# in the repo propagate without re-running install.sh on each host. Silent
|
|
|
|
|
# no-op if the repo fragment can't be located.
|
2026-05-17 03:43:38 -07:00
|
|
|
sync_tmux_conf() {
|
|
|
|
|
_host=$1
|
|
|
|
|
_self=$(resolve_self)
|
|
|
|
|
_repo=$(cd "$(dirname "$_self")/.." 2>/dev/null && pwd)
|
|
|
|
|
_frag="$_repo/tmux.conf"
|
|
|
|
|
[ -f "$_frag" ] || return 0
|
2026-05-17 04:47:15 -07:00
|
|
|
_remote_cmd='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
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-05-17 04:47:15 -07:00
|
|
|
cmd_triage() {
|
|
|
|
|
# Pass-through args: --limit, --refresh, --uuids ...
|
2026-05-18 03:06:23 -07:00
|
|
|
# --tsv: emit raw TSV (host\ttriage\tuuid\tpri\tstatus\tsummary\tnext_action\tcwd\tmtime)
|
|
|
|
|
# and skip the column-aligned awk formatting + header.
|
|
|
|
|
_tsv=0
|
|
|
|
|
_opts=""
|
|
|
|
|
for _a in "$@"; do
|
|
|
|
|
if [ "$_a" = "--tsv" ]; then
|
|
|
|
|
_tsv=1
|
|
|
|
|
else
|
|
|
|
|
_opts="$_opts $_a"
|
|
|
|
|
fi
|
|
|
|
|
done
|
|
|
|
|
if [ "$_tsv" -eq 0 ]; then
|
|
|
|
|
printf "%-8s %-8s %-3s %-15s %-50s %s\n" \
|
|
|
|
|
"HOST" "UUID" "PRI" "STATUS" "SUMMARY" "NEXT ACTION"
|
|
|
|
|
fi
|
2026-05-17 04:47:15 -07:00
|
|
|
scan_hosts | while IFS= read -r h; do
|
|
|
|
|
# Row format from list_triage_on:
|
|
|
|
|
# host \t triage \t uuid \t priority \t status \t summary \t next_action \t cwd \t mtime
|
2026-05-18 03:06:23 -07:00
|
|
|
if [ "$_tsv" -eq 1 ]; then
|
|
|
|
|
# shellcheck disable=SC2086
|
|
|
|
|
list_triage_on "$h" $_opts
|
|
|
|
|
else
|
|
|
|
|
# shellcheck disable=SC2086
|
|
|
|
|
list_triage_on "$h" $_opts | awk -F'\t' '
|
|
|
|
|
{ uuid8 = substr($3, 1, 8)
|
|
|
|
|
printf "%-8s %-8s %-3s %-15s %-50.50s %s\n",
|
|
|
|
|
$1, uuid8, $4, $5, $6, $7 }
|
|
|
|
|
'
|
|
|
|
|
fi
|
2026-05-17 04:47:15 -07:00
|
|
|
done
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 23:53:42 -07:00
|
|
|
cmd_list() {
|
2026-05-18 03:06:23 -07:00
|
|
|
# Positional mode + optional --tsv flag (order-agnostic).
|
|
|
|
|
# --tsv: skip the header + column-aligned awk; print raw TSV rows from
|
|
|
|
|
# list_*_on (with full uuids, separate cwd / mtime columns for sessions).
|
|
|
|
|
_mode=""
|
|
|
|
|
_tsv=0
|
|
|
|
|
for _a in "$@"; do
|
|
|
|
|
case $_a in
|
|
|
|
|
--tsv) _tsv=1 ;;
|
|
|
|
|
*) [ -z "$_mode" ] && _mode=$_a ;;
|
|
|
|
|
esac
|
|
|
|
|
done
|
|
|
|
|
_mode=${_mode:-all}
|
|
|
|
|
_sess_fmt=pretty
|
|
|
|
|
[ "$_tsv" -eq 1 ] && _sess_fmt=tsv
|
|
|
|
|
if [ "$_tsv" -eq 0 ]; then
|
|
|
|
|
printf "%-10s %-7s %-60s %s\n" "HOST" "KIND" "SESSION/CWD/UUID" "DETAIL"
|
|
|
|
|
fi
|
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"
|
2026-05-18 03:06:23 -07:00
|
|
|
list_sessions_on "$h" "$_sess_fmt" ;;
|
|
|
|
|
*) list_tmux_on "$h"
|
|
|
|
|
list_disk_on "$h" ;;
|
|
|
|
|
esac | if [ "$_tsv" -eq 1 ]; then
|
|
|
|
|
cat
|
|
|
|
|
else
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}'
|
|
|
|
|
fi
|
2026-04-25 23:53:42 -07:00
|
|
|
done
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 22:42:01 -07:00
|
|
|
# Broadcast a prompt to one, some, or all live claude-* tmux sessions across
|
|
|
|
|
# scan_hosts(). Dry-run by default — the user must pass --yes to actually
|
|
|
|
|
# deliver, since fanning text into every running agent is high-blast-radius
|
|
|
|
|
# and a typo'd selector could mis-target.
|
|
|
|
|
#
|
|
|
|
|
# Usage:
|
|
|
|
|
# rclaude send --all -- <text...>
|
|
|
|
|
# rclaude send --host <h> -- <text...>
|
|
|
|
|
# rclaude send --match <pat> -- <text...>
|
|
|
|
|
# rclaude send ... --yes -- <text...> # actually send (default: preview)
|
|
|
|
|
# rclaude send ... --dry-run -- <text...> # explicit preview (overrides --yes)
|
|
|
|
|
cmd_send() {
|
|
|
|
|
_sel=""; _pat=""; _dry=0; _yes=0
|
|
|
|
|
while [ $# -gt 0 ]; do
|
|
|
|
|
case $1 in
|
|
|
|
|
--all) [ -n "$_sel" ] && { echo "rclaude send: only one selector allowed" >&2; exit 2; }
|
|
|
|
|
_sel=all; shift ;;
|
|
|
|
|
--host) [ -n "$_sel" ] && { echo "rclaude send: only one selector allowed" >&2; exit 2; }
|
|
|
|
|
shift; _sel=host; _pat=${1:-}; [ -z "$_pat" ] && { echo "rclaude send: --host requires a value" >&2; exit 2; }; shift ;;
|
|
|
|
|
--host=*) [ -n "$_sel" ] && { echo "rclaude send: only one selector allowed" >&2; exit 2; }
|
|
|
|
|
_sel=host; _pat=${1#--host=}; shift ;;
|
|
|
|
|
--match) [ -n "$_sel" ] && { echo "rclaude send: only one selector allowed" >&2; exit 2; }
|
|
|
|
|
shift; _sel=match; _pat=${1:-}; [ -z "$_pat" ] && { echo "rclaude send: --match requires a value" >&2; exit 2; }; shift ;;
|
|
|
|
|
--match=*) [ -n "$_sel" ] && { echo "rclaude send: only one selector allowed" >&2; exit 2; }
|
|
|
|
|
_sel=match; _pat=${1#--match=}; shift ;;
|
|
|
|
|
--dry-run) _dry=1; shift ;;
|
|
|
|
|
--yes) _yes=1; shift ;;
|
|
|
|
|
--) shift; break ;;
|
|
|
|
|
-*) echo "rclaude send: unknown flag: $1" >&2; exit 2 ;;
|
|
|
|
|
*) break ;;
|
|
|
|
|
esac
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
if [ -z "$_sel" ]; then
|
|
|
|
|
cat >&2 <<'EOF'
|
|
|
|
|
usage: rclaude send (--all | --host <h> | --match <pat>) [--dry-run] [--yes] -- <text...>
|
|
|
|
|
--all target every live claude-* tmux session on scan_hosts
|
|
|
|
|
--host <h> target every claude-* session on a specific host
|
|
|
|
|
--match <pat> substring match against tmux session name (which embeds slug)
|
|
|
|
|
--dry-run preview targets and exit (default unless --yes is passed)
|
|
|
|
|
--yes actually deliver (still prints preview first)
|
|
|
|
|
EOF
|
|
|
|
|
exit 2
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
_text=$*
|
|
|
|
|
if [ -z "$_text" ]; then
|
|
|
|
|
echo "rclaude send: missing prompt text (after --)" >&2
|
|
|
|
|
exit 2
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# Gather candidate rows across all hosts, then filter.
|
|
|
|
|
_rows=$(scan_hosts | while IFS= read -r _h; do list_tmux_on "$_h"; done \
|
|
|
|
|
| filter_targets "$_sel" "$_pat")
|
|
|
|
|
|
|
|
|
|
if [ -z "$_rows" ]; then
|
|
|
|
|
echo "rclaude send: no matching sessions" >&2
|
|
|
|
|
exit 2
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# Preview is always shown, before any delivery.
|
|
|
|
|
echo "Targets:"
|
|
|
|
|
printf '%s\n' "$_rows" | awk -F '\t' '{ printf " %-12s %s\n", $1, $3 }'
|
|
|
|
|
|
|
|
|
|
if [ "$_dry" = 1 ] || [ "$_yes" != 1 ]; then
|
|
|
|
|
echo "(dry-run — pass --yes to send)"
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
_quoted_text=$(sh_quote "$_text")
|
|
|
|
|
_total=0; _failed=0
|
|
|
|
|
# Use a tempfile to drive the loop so the counters survive (a piped
|
|
|
|
|
# `while` runs in a subshell under POSIX sh and would lose mutations).
|
|
|
|
|
_rowfile=$(mktemp /tmp/rclaude-send.XXXXXX 2>/dev/null || echo /tmp/rclaude-send.$$)
|
|
|
|
|
printf '%s\n' "$_rows" > "$_rowfile"
|
|
|
|
|
while IFS=$(printf '\t') read -r _host _kind _sess _detail; do
|
|
|
|
|
[ -z "$_sess" ] && continue
|
|
|
|
|
_total=$((_total + 1))
|
|
|
|
|
if is_local "$_host"; then
|
|
|
|
|
if tmux send-keys -t "$_sess" -l -- "$_text" 2>/dev/null \
|
|
|
|
|
&& tmux send-keys -t "$_sess" Enter 2>/dev/null; then
|
|
|
|
|
:
|
|
|
|
|
else
|
|
|
|
|
_failed=$((_failed + 1))
|
|
|
|
|
echo "rclaude send: failed on $_host:$_sess" >&2
|
|
|
|
|
fi
|
|
|
|
|
else
|
|
|
|
|
_q_sess=$(sh_quote "$_sess")
|
|
|
|
|
if ssh -o BatchMode=yes -o ConnectTimeout=3 "$_host" \
|
|
|
|
|
"tmux send-keys -t $_q_sess -l -- $_quoted_text && tmux send-keys -t $_q_sess Enter" \
|
|
|
|
|
</dev/null >/dev/null 2>&1; then
|
|
|
|
|
:
|
|
|
|
|
else
|
|
|
|
|
_failed=$((_failed + 1))
|
|
|
|
|
echo "rclaude send: failed on $_host:$_sess" >&2
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
done < "$_rowfile"
|
|
|
|
|
rm -f "$_rowfile"
|
|
|
|
|
|
|
|
|
|
_sent=$((_total - _failed))
|
|
|
|
|
echo "Sent to $_sent of $_total session(s)."
|
|
|
|
|
# Exit non-zero only if *every* delivery failed.
|
|
|
|
|
[ "$_sent" -gt 0 ]
|
|
|
|
|
}
|
|
|
|
|
|
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.
|
2026-05-17 04:47:15 -07:00
|
|
|
# An empty pattern lists everything (interactive picker).
|
2026-04-25 23:53:42 -07:00
|
|
|
cmd_resume() {
|
2026-05-17 05:16:25 -07:00
|
|
|
_pattern=""
|
|
|
|
|
_on=""
|
|
|
|
|
while [ $# -gt 0 ]; do
|
|
|
|
|
case $1 in
|
|
|
|
|
--on) shift; _on=${1:-}; shift ;;
|
|
|
|
|
--on=*) _on=${1#--on=}; shift ;;
|
|
|
|
|
--all|-a) shift ;;
|
|
|
|
|
*) _pattern=$1; shift ;;
|
|
|
|
|
esac
|
|
|
|
|
done
|
|
|
|
|
# Triage = Haiku-ranks sessions before display; only useful when browsing
|
|
|
|
|
# without a pattern. Pattern searches always take the cheap path (raw
|
|
|
|
|
# sessions, no LLM) so they return quickly.
|
|
|
|
|
_triage_mode=0
|
|
|
|
|
if [ -z "$_pattern" ]; then
|
|
|
|
|
case ${RCLAUDE_TRIAGE:-off} in
|
|
|
|
|
auto|on|1|true) _triage_mode=1 ;;
|
|
|
|
|
esac
|
|
|
|
|
fi
|
|
|
|
|
_d_total=0; _d_room=0
|
|
|
|
|
if [ "$_triage_mode" = "1" ]; then
|
|
|
|
|
printf 'rclaude: triaging sessions...\n' >&2
|
2026-05-17 05:22:30 -07:00
|
|
|
# Collect tmux rows from all hosts first so they always appear in the
|
|
|
|
|
# picker even when triage produces > (35 - tmux_count) rows on a single
|
|
|
|
|
# host that would otherwise crowd them out.
|
|
|
|
|
_tmux=$(scan_hosts | while IFS= read -r h; do list_tmux_on "$h"; done)
|
2026-05-17 22:17:58 -07:00
|
|
|
# Triage rows: col 1 = host, col 3 = uuid, col 4 = priority, col 9 = mtime.
|
|
|
|
|
# Re-sort globally by (priority asc, mtime desc) for the headline view,
|
|
|
|
|
# but also enforce a per-host floor below — otherwise a host with many
|
|
|
|
|
# fresh P0/P1 sessions (e.g. local with 2k+ jsonls) takes every seat and
|
|
|
|
|
# remote hosts disappear from the picker entirely.
|
|
|
|
|
_triage_raw=$(scan_hosts | while IFS= read -r h; do list_triage_on "$h"; done)
|
|
|
|
|
_TAB=$(printf '\t')
|
|
|
|
|
_triage=$(printf '%s\n' "$_triage_raw" | grep -v '^$' \
|
|
|
|
|
| sort -t"$_TAB" -k4,4n -k9,9nr)
|
2026-05-17 05:22:30 -07:00
|
|
|
_t_count=0
|
|
|
|
|
[ -n "$_tmux" ] && _t_count=$(printf '%s\n' "$_tmux" | wc -l | tr -d ' ')
|
|
|
|
|
[ -n "$_triage" ] && _d_total=$(printf '%s\n' "$_triage" | wc -l | tr -d ' ')
|
|
|
|
|
_d_room=$((35 - _t_count))
|
|
|
|
|
[ "$_d_room" -lt 0 ] && _d_room=0
|
|
|
|
|
if [ "$_d_room" -gt 0 ] && [ -n "$_triage" ]; then
|
2026-05-17 22:17:58 -07:00
|
|
|
# Per-host floor: every host that produced rows gets at least
|
|
|
|
|
# _floor seats, so high-priority remote sessions can't be crowded
|
|
|
|
|
# out by a flood of recent local rows. Floor = max(5, room/hosts).
|
|
|
|
|
_hosts_with_rows=$(printf '%s\n' "$_triage" | awk -F'\t' '{print $1}' | sort -u)
|
|
|
|
|
_h_count=$(printf '%s\n' "$_hosts_with_rows" | grep -c . 2>/dev/null || printf 1)
|
|
|
|
|
[ "$_h_count" -lt 1 ] && _h_count=1
|
|
|
|
|
_floor=$((_d_room / _h_count))
|
|
|
|
|
[ "$_floor" -lt 5 ] && _floor=5
|
|
|
|
|
_reserved=""
|
|
|
|
|
for _h in $_hosts_with_rows; do
|
|
|
|
|
_slice=$(printf '%s\n' "$_triage" | awk -F'\t' -v h="$_h" '$1==h' \
|
|
|
|
|
| head -n "$_floor")
|
|
|
|
|
[ -z "$_slice" ] && continue
|
|
|
|
|
if [ -z "$_reserved" ]; then
|
|
|
|
|
_reserved=$_slice
|
|
|
|
|
else
|
|
|
|
|
_reserved=$(printf '%s\n%s' "$_reserved" "$_slice")
|
|
|
|
|
fi
|
|
|
|
|
done
|
|
|
|
|
_r_count=0
|
|
|
|
|
[ -n "$_reserved" ] && _r_count=$(printf '%s\n' "$_reserved" | wc -l | tr -d ' ')
|
|
|
|
|
if [ "$_r_count" -ge "$_d_room" ]; then
|
|
|
|
|
_triage_slice=$(printf '%s\n' "$_reserved" \
|
|
|
|
|
| sort -t"$_TAB" -k4,4n -k9,9nr \
|
|
|
|
|
| head -n "$_d_room")
|
|
|
|
|
else
|
|
|
|
|
_remaining=$((_d_room - _r_count))
|
|
|
|
|
_fill=$(printf '%s\n' "$_triage" | awk -F'\t' -v rsv="$_reserved" '
|
|
|
|
|
BEGIN {
|
|
|
|
|
n = split(rsv, lines, "\n")
|
|
|
|
|
for (i=1; i<=n; i++) {
|
|
|
|
|
split(lines[i], f, "\t")
|
|
|
|
|
if (f[3] != "") seen[f[3]] = 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
!($3 in seen)
|
|
|
|
|
' | head -n "$_remaining")
|
|
|
|
|
if [ -n "$_fill" ]; then
|
|
|
|
|
_triage_slice=$(printf '%s\n%s' "$_reserved" "$_fill" \
|
|
|
|
|
| sort -t"$_TAB" -k4,4n -k9,9nr)
|
|
|
|
|
else
|
|
|
|
|
_triage_slice=$(printf '%s\n' "$_reserved" \
|
|
|
|
|
| sort -t"$_TAB" -k4,4n -k9,9nr)
|
|
|
|
|
fi
|
|
|
|
|
fi
|
2026-05-17 05:22:30 -07:00
|
|
|
else
|
|
|
|
|
_triage_slice=""
|
|
|
|
|
fi
|
|
|
|
|
if [ -n "$_tmux" ] && [ -n "$_triage_slice" ]; then
|
|
|
|
|
_matches=$(printf '%s\n%s' "$_tmux" "$_triage_slice")
|
|
|
|
|
else
|
|
|
|
|
_matches=${_tmux:-$_triage_slice}
|
|
|
|
|
fi
|
2026-05-17 05:16:25 -07:00
|
|
|
elif [ -z "$_pattern" ]; then
|
|
|
|
|
_tmux=$(scan_hosts | while IFS= read -r h; do list_tmux_on "$h"; done)
|
|
|
|
|
_disk_raw=$(scan_hosts | while IFS= read -r h; do list_sessions_on "$h"; done)
|
|
|
|
|
_disk=$(printf '%s\n' "$_disk_raw" | dedupe_sessions)
|
|
|
|
|
_t_count=0
|
|
|
|
|
[ -n "$_tmux" ] && _t_count=$(printf '%s\n' "$_tmux" | wc -l | tr -d ' ')
|
|
|
|
|
[ -n "$_disk" ] && _d_total=$(printf '%s\n' "$_disk" | wc -l | tr -d ' ')
|
|
|
|
|
_d_room=$((35 - _t_count))
|
|
|
|
|
[ "$_d_room" -lt 0 ] && _d_room=0
|
|
|
|
|
if [ "$_d_room" -gt 0 ] && [ -n "$_disk" ]; then
|
|
|
|
|
_disk_slice=$(printf '%s\n' "$_disk" | head -n "$_d_room")
|
|
|
|
|
else
|
|
|
|
|
_disk_slice=""
|
|
|
|
|
fi
|
|
|
|
|
if [ -n "$_tmux" ] && [ -n "$_disk_slice" ]; then
|
|
|
|
|
_matches=$(printf '%s\n%s' "$_tmux" "$_disk_slice")
|
|
|
|
|
else
|
|
|
|
|
_matches=${_tmux:-$_disk_slice}
|
|
|
|
|
fi
|
|
|
|
|
else
|
2026-05-17 06:47:44 -07:00
|
|
|
# Pattern search ordering (cheapest → expensive):
|
|
|
|
|
# 1. session display names (~/.claude/sessions/*.json) — fast: one
|
|
|
|
|
# bounded python scan per host, hits sessions named via `claude -n`
|
|
|
|
|
# 2. first-user-message snippets + cwd — cheap row-grep
|
|
|
|
|
# Fallback (only if both empty): full-transcript grep over every jsonl
|
|
|
|
|
_name_rows=$(scan_hosts | while IFS= read -r h; do name_search_on "$h" "$_pattern"; done)
|
|
|
|
|
_cheap_all=$(scan_hosts | while IFS= read -r h; do list_search_on "$h"; done)
|
|
|
|
|
_cheap_rows=$(printf '%s\n' "$_cheap_all" | grep -F -i -- "$_pattern" || true)
|
|
|
|
|
_matches=$(printf '%s\n%s\n' "$_name_rows" "$_cheap_rows" | grep -v '^$' || true)
|
2026-05-17 05:16:25 -07:00
|
|
|
_tmux_part=$(printf '%s\n' "$_matches" | awk -F'\t' '$2=="tmux"')
|
|
|
|
|
_disk_part=$(printf '%s\n' "$_matches" | awk -F'\t' '$2!="tmux"' | dedupe_sessions)
|
|
|
|
|
if [ -n "$_tmux_part" ] && [ -n "$_disk_part" ]; then
|
|
|
|
|
_matches=$(printf '%s\n%s' "$_tmux_part" "$_disk_part")
|
|
|
|
|
else
|
|
|
|
|
_matches=${_tmux_part:-$_disk_part}
|
|
|
|
|
fi
|
2026-05-17 06:17:28 -07:00
|
|
|
if [ -z "$_matches" ]; then
|
2026-05-17 06:47:44 -07:00
|
|
|
printf 'rclaude: no name/snippet hits; searching full transcripts...\n' >&2
|
2026-05-17 06:17:28 -07:00
|
|
|
_matches=$(scan_hosts | while IFS= read -r h; do deep_search_on "$h" "$_pattern"; done | dedupe_sessions)
|
|
|
|
|
fi
|
2026-04-25 23:53:42 -07:00
|
|
|
fi
|
2026-05-17 08:06:16 -07:00
|
|
|
# Filter out archived UUIDs (added via picker `-` key, see below).
|
|
|
|
|
if [ -s "$_ARCHIVE_FILE" ]; then
|
|
|
|
|
_matches=$(printf '%s\n' "$_matches" | awk -F'\t' -v af="$_ARCHIVE_FILE" '
|
|
|
|
|
BEGIN { while ((getline u < af) > 0) arch[u]=1; close(af) }
|
|
|
|
|
($2 == "tmux") || !($3 in arch)
|
|
|
|
|
')
|
|
|
|
|
fi
|
2026-04-25 23:53:42 -07:00
|
|
|
_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"
|
2026-05-17 07:42:01 -07:00
|
|
|
# Append a trailing name column (host+uuid → display name from
|
|
|
|
|
# ~/.claude/sessions/*.json). Empty for tmux rows and for sessions
|
2026-05-17 07:48:00 -07:00
|
|
|
# with no `claude -n` label. Map is passed via tempfile because
|
|
|
|
|
# awk -v can't hold a multi-line value.
|
|
|
|
|
_name_map_f=$(mktemp /tmp/rclaude-namemap.XXXXXX 2>/dev/null || echo /tmp/rclaude-namemap.$$)
|
|
|
|
|
build_name_map > "$_name_map_f"
|
|
|
|
|
_matches=$(printf '%s\n' "$_matches" | awk -F'\t' -v OFS='\t' -v mapf="$_name_map_f" '
|
2026-05-17 07:42:01 -07:00
|
|
|
BEGIN {
|
2026-05-17 07:48:00 -07:00
|
|
|
while ((getline line < mapf) > 0) {
|
|
|
|
|
split(line, f, "\t")
|
2026-05-17 07:42:01 -07:00
|
|
|
if (f[1] && f[2]) names[f[1] SUBSEP f[2]] = f[3]
|
|
|
|
|
}
|
2026-05-17 07:48:00 -07:00
|
|
|
close(mapf)
|
2026-05-17 07:42:01 -07:00
|
|
|
}
|
|
|
|
|
{ nm = ($2=="tmux") ? "" : (($1 SUBSEP $3) in names ? names[$1 SUBSEP $3] : "")
|
|
|
|
|
print $0, nm }
|
|
|
|
|
')
|
2026-05-17 07:48:00 -07:00
|
|
|
rm -f "$_name_map_f"
|
2026-05-17 03:43:38 -07:00
|
|
|
if [ "$_count" -gt 35 ]; then
|
2026-05-17 05:16:25 -07:00
|
|
|
_orig_count=$_count
|
|
|
|
|
_matches=$(printf '%s\n' "$_matches" | head -n 35)
|
|
|
|
|
_count=35
|
|
|
|
|
printf 'rclaude: %s matches; showing 35 most recent (refine with a pattern to see others)\n' "$_orig_count" >&2
|
2026-05-17 03:43:38 -07:00
|
|
|
fi
|
2026-05-17 05:22:30 -07:00
|
|
|
# ANSI colors (only when stderr is a tty). Conservative palette —
|
|
|
|
|
# host cyan, tmux marker bold-green, priority red/yellow, statuses
|
|
|
|
|
# colored, uuid8 dimmed, key bracket bold-magenta.
|
|
|
|
|
if [ -t 2 ]; then
|
|
|
|
|
_R=$(printf '\033[0m')
|
|
|
|
|
_Chost=$(printf '\033[36m'); _Ctmux=$(printf '\033[1;32m')
|
|
|
|
|
_Cdim=$(printf '\033[2m'); _Ckey=$(printf '\033[1;35m')
|
2026-05-17 07:23:49 -07:00
|
|
|
# Triage uses P0=critical / P4=abandonable (P0 incident convention).
|
|
|
|
|
# _Cp5 / _Cp4 names are kept for diff size; what they color is the
|
|
|
|
|
# *top two* priority levels, whatever the scale is.
|
2026-05-17 05:22:30 -07:00
|
|
|
_Cp5=$(printf '\033[1;31m'); _Cp4=$(printf '\033[33m')
|
|
|
|
|
_Cblk=$(printf '\033[31m'); _Cwait=$(printf '\033[33m')
|
|
|
|
|
_Cinp=$(printf '\033[36m'); _Cdone=$(printf '\033[32m')
|
2026-05-17 07:48:00 -07:00
|
|
|
_Cname=$(printf '\033[1;35m') # display name: bold magenta
|
2026-05-17 05:22:30 -07:00
|
|
|
else
|
|
|
|
|
_R=; _Chost=; _Ctmux=; _Cdim=; _Ckey=
|
2026-05-17 07:48:00 -07:00
|
|
|
_Cp5=; _Cp4=; _Cblk=; _Cwait=; _Cinp=; _Cdone=; _Cname=
|
2026-05-17 05:22:30 -07:00
|
|
|
fi
|
|
|
|
|
# Kind column dropped — it carried no signal when all rows were the
|
|
|
|
|
# same kind. Kind is now encoded by row shape: tmux has a ▶ marker,
|
|
|
|
|
# triage shows P<n> + status, session shows the snippet plain.
|
2026-05-17 04:47:15 -07:00
|
|
|
_fmt_row='
|
2026-05-17 07:23:49 -07:00
|
|
|
function prio_c(p) { if (p=="0") return c_p5; if (p=="1") return c_p4; return "" }
|
2026-05-17 05:22:30 -07:00
|
|
|
function stat_c(s) {
|
|
|
|
|
if (s=="blocked") return c_blk
|
|
|
|
|
if (s=="waiting_on_user") return c_wait
|
|
|
|
|
if (s=="in_progress") return c_inp
|
|
|
|
|
if (s=="done") return c_done
|
|
|
|
|
return ""
|
|
|
|
|
}
|
2026-05-17 05:28:30 -07:00
|
|
|
# Last two path components of cwd, e.g. "@projects/@lilith".
|
|
|
|
|
function last2(cwd, n, parts) {
|
|
|
|
|
n = split(cwd, parts, "/")
|
|
|
|
|
if (n == 0) return ""
|
|
|
|
|
if (n == 1) return parts[1]
|
|
|
|
|
return parts[n-1] "/" parts[n]
|
|
|
|
|
}
|
|
|
|
|
# Heuristic recovery of cwd from a tmux session name like
|
|
|
|
|
# "claude-<user>-<slug>-<epoch>". Lossy (@ became -) but
|
|
|
|
|
# readable for the common cases.
|
|
|
|
|
function tmux_dir(name) {
|
|
|
|
|
sub(/^claude-[^-]+-/, "", name)
|
|
|
|
|
sub(/-[0-9]+$/, "", name)
|
|
|
|
|
gsub(/--/, "/", name)
|
|
|
|
|
return last2(name)
|
|
|
|
|
}
|
|
|
|
|
function dir_label() {
|
|
|
|
|
if ($2 == "tmux") return tmux_dir($3)
|
|
|
|
|
if ($2 == "triage") return last2($8)
|
|
|
|
|
if ($2 == "session") { split($5, h, " · "); return last2(h[1]) }
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
function fit(s, n) { return length(s) > n ? substr(s, 1, n-1) "…" : s }
|
2026-05-17 07:48:00 -07:00
|
|
|
# The trailing column (NF) is the optional display name set via
|
2026-05-17 08:00:13 -07:00
|
|
|
# `claude -n`. Promoted to column B: blank when absent.
|
2026-05-17 04:47:15 -07:00
|
|
|
function display() {
|
2026-05-17 08:00:13 -07:00
|
|
|
if ($2 == "tmux") return c_tmux "▶ " r $3
|
|
|
|
|
if ($2 == "triage") return prio_c($4) "P" $4 r " " stat_c($5) sprintf("%-15s", $5) r " " $6 " " c_dim "[" substr($3,1,8) "]" r
|
|
|
|
|
if ($2 == "session") return $4 " " c_dim "[" substr($3,1,8) "]" r
|
2026-05-17 04:47:15 -07:00
|
|
|
return $3
|
|
|
|
|
}
|
2026-05-17 19:38:40 -07:00
|
|
|
# Col B: the explicit `claude -n` display name (NF for sessions,
|
|
|
|
|
# NF-1 for tmux because tmux rows have no name column at the end).
|
2026-05-17 08:00:13 -07:00
|
|
|
function name_col() { return ($2=="tmux") ? "" : $NF }
|
2026-05-17 19:38:40 -07:00
|
|
|
# "Time ago" column. mtime is in different fields depending on
|
|
|
|
|
# row kind (session=col 6, triage=col 9). Tmux rows have no
|
|
|
|
|
# mtime — blank in that column.
|
|
|
|
|
function ago(secs, abs, s) {
|
|
|
|
|
if (secs == "" || secs+0 == 0) return ""
|
|
|
|
|
abs = now - secs+0
|
|
|
|
|
if (abs < 0) abs = 0
|
|
|
|
|
if (abs < 60) return abs "s"
|
|
|
|
|
if (abs < 3600) return int(abs/60) "m"
|
|
|
|
|
if (abs < 86400) return int(abs/3600) "h"
|
|
|
|
|
if (abs < 86400*30) return int(abs/86400) "d"
|
|
|
|
|
if (abs < 86400*365) return int(abs/(86400*30)) "mo"
|
|
|
|
|
return int(abs/(86400*365)) "y"
|
|
|
|
|
}
|
|
|
|
|
function age_col() {
|
|
|
|
|
if ($2 == "session") return ago($6)
|
|
|
|
|
if ($2 == "triage") return ago($9)
|
|
|
|
|
return "" # tmux: no age field
|
|
|
|
|
}
|
2026-05-17 05:22:30 -07:00
|
|
|
{
|
2026-05-17 19:38:40 -07:00
|
|
|
printf "%s%-10s%s %s%5s%s %s%-22s%s %s%-22s%s %s",
|
2026-05-17 08:00:13 -07:00
|
|
|
c_host, $1, r,
|
2026-05-17 19:38:40 -07:00
|
|
|
c_dim, age_col(), r,
|
2026-05-17 08:00:13 -07:00
|
|
|
c_name, fit(name_col(), 22), r,
|
|
|
|
|
c_dim, fit(dir_label(), 22), r,
|
|
|
|
|
display()
|
2026-05-17 05:22:30 -07:00
|
|
|
}
|
2026-05-17 04:47:15 -07:00
|
|
|
'
|
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 05:22:30 -07:00
|
|
|
printf '%s\n' "$_matches" | awk -F'\t' \
|
|
|
|
|
-v r="$_R" -v c_host="$_Chost" -v c_tmux="$_Ctmux" -v c_dim="$_Cdim" \
|
|
|
|
|
-v c_p5="$_Cp5" -v c_p4="$_Cp4" \
|
|
|
|
|
-v c_blk="$_Cblk" -v c_wait="$_Cwait" -v c_inp="$_Cinp" -v c_done="$_Cdone" \
|
2026-05-17 07:48:00 -07:00
|
|
|
-v c_name="$_Cname" \
|
2026-05-17 19:38:40 -07:00
|
|
|
-v now="$(date +%s)" \
|
2026-05-17 05:22:30 -07:00
|
|
|
"$_fmt_row"'{printf "\n"}' >&2
|
2026-05-17 03:43:38 -07:00
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
_i=0
|
2026-05-17 05:16:25 -07:00
|
|
|
_prev_kind=""
|
2026-05-17 03:43:38 -07:00
|
|
|
printf '%s\n' "$_matches" | while IFS= read -r _line; do
|
|
|
|
|
_i=$((_i + 1))
|
2026-05-17 05:16:25 -07:00
|
|
|
_kind_now=$(printf %s "$_line" | awk -F'\t' '{print $2}')
|
|
|
|
|
if [ "$_prev_kind" = "tmux" ] && [ "$_kind_now" != "tmux" ]; then
|
2026-05-17 05:22:30 -07:00
|
|
|
printf ' %s---%s\n' "$_Cdim" "$_R" >&2
|
2026-05-17 05:16:25 -07:00
|
|
|
fi
|
2026-05-17 03:43:38 -07:00
|
|
|
_k=$(printf %s "$_keys" | cut -c"$_i")
|
2026-05-17 05:22:30 -07:00
|
|
|
_row_text=$(printf %s "$_line" | awk -F'\t' \
|
|
|
|
|
-v r="$_R" -v c_host="$_Chost" -v c_tmux="$_Ctmux" -v c_dim="$_Cdim" \
|
|
|
|
|
-v c_p5="$_Cp5" -v c_p4="$_Cp4" \
|
|
|
|
|
-v c_blk="$_Cblk" -v c_wait="$_Cwait" -v c_inp="$_Cinp" -v c_done="$_Cdone" \
|
2026-05-17 07:48:00 -07:00
|
|
|
-v c_name="$_Cname" \
|
2026-05-17 19:38:40 -07:00
|
|
|
-v now="$(date +%s)" \
|
2026-05-17 05:22:30 -07:00
|
|
|
"$_fmt_row")
|
|
|
|
|
printf ' %s[%s]%s %s\n' "$_Ckey" "$_k" "$_R" "$_row_text" >&2
|
2026-05-17 05:16:25 -07:00
|
|
|
_prev_kind=$_kind_now
|
2026-05-17 03:43:38 -07:00
|
|
|
done
|
2026-05-17 05:16:25 -07:00
|
|
|
if [ "$_d_total" -gt "$_d_room" ] && [ "$_d_room" -gt 0 ]; then
|
2026-05-17 05:22:30 -07:00
|
|
|
printf ' %s(showing %s most recent of %s disk sessions; pass a pattern to search older)%s\n' \
|
|
|
|
|
"$_Cdim" "$_d_room" "$_d_total" "$_R" >&2
|
2026-05-17 05:16:25 -07:00
|
|
|
fi
|
2026-05-17 03:43:38 -07:00
|
|
|
_last_key=$(printf %s "$_keys" | cut -c"$_count")
|
2026-05-17 08:06:16 -07:00
|
|
|
printf '%sselect [1-%s] (- archive, q cancel):%s ' "$_Ckey" "$_last_key" "$_R" >&2
|
2026-05-17 03:43:38 -07:00
|
|
|
_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)
|
2026-05-17 08:06:16 -07:00
|
|
|
# Archive flow: `-` triggers a second prompt; the row at that key's
|
|
|
|
|
# index gets its uuid appended to $_ARCHIVE_FILE so future
|
|
|
|
|
# `rclaude resume` invocations hide it.
|
|
|
|
|
if [ "$_key" = "-" ]; then
|
|
|
|
|
printf '%sarchive which [1-%s]:%s ' "$_Ckey" "$_last_key" "$_R" >&2
|
|
|
|
|
_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
|
|
|
|
|
_row=$(printf '%s\n' "$_matches" | sed -n "${_idx}p")
|
|
|
|
|
_au=$(printf %s "$_row" | awk -F'\t' '{print $3}')
|
|
|
|
|
_akind=$(printf %s "$_row" | awk -F'\t' '{print $2}')
|
|
|
|
|
if [ "$_akind" = "tmux" ]; then
|
|
|
|
|
echo "rclaude: can't archive a live tmux row — kill the session instead (tmux kill-session)" >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
printf '%s\n' "$_au" >> "$_ARCHIVE_FILE"
|
|
|
|
|
printf 'rclaude: archived %s (hidden from future resume lists; edit %s to undo)\n' \
|
|
|
|
|
"$(printf %s "$_au" | cut -c1-8)" "$_ARCHIVE_FILE" >&2
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
2026-05-17 03:43:38 -07:00
|
|
|
[ -n "$_old" ] && stty "$_old" 2>/dev/null || true
|
2026-05-17 05:28:30 -07:00
|
|
|
# Render the keystroke we just consumed. Empty (EOF), Enter, Esc, q,
|
|
|
|
|
# and Ctrl-C all mean "cancel" — just exit cleanly.
|
|
|
|
|
case $_key in
|
|
|
|
|
''|q|Q|$(printf '\r')|$(printf '\n')|$(printf '\033')|$(printf '\003'))
|
|
|
|
|
printf '(cancelled)\n' >&2
|
|
|
|
|
exit 0
|
|
|
|
|
;;
|
|
|
|
|
esac
|
2026-05-17 03:43:38 -07:00
|
|
|
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
|
2026-05-17 05:28:30 -07:00
|
|
|
echo "rclaude: invalid selection: '$_key' (expected 1-${_last_key})" >&2
|
2026-05-17 03:43:38 -07:00
|
|
|
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:47:15 -07:00
|
|
|
# cwd column depends on kind:
|
|
|
|
|
# session → col 5, formatted "<cwd> · <rel-time>"
|
|
|
|
|
# triage → col 8, raw cwd path
|
|
|
|
|
case $_kind in
|
|
|
|
|
triage) _session_cwd=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $8}') ;;
|
|
|
|
|
session) _session_cwd=$(printf '%s\n' "$_matches" | awk -F'\t' 'NR==1{print $5}' | awk -F' · ' '{print $1}') ;;
|
|
|
|
|
*) _session_cwd="" ;;
|
|
|
|
|
esac
|
2026-04-26 00:04:09 -07:00
|
|
|
case $_kind in
|
|
|
|
|
tmux)
|
2026-05-17 05:16:25 -07:00
|
|
|
if [ -n "$_on" ] && [ "$_on" != "$_host" ]; then
|
|
|
|
|
echo "rclaude: --on can't migrate a live tmux session; detach + retry, or omit --on" >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
2026-04-26 00:04:09 -07:00
|
|
|
if is_local "$_host"; then
|
|
|
|
|
exec tmux attach -t "$_target"
|
|
|
|
|
else
|
2026-05-17 07:23:49 -07:00
|
|
|
setup_host "$_host"
|
|
|
|
|
if [ "$(pick_transport "$_host")" = "mosh" ]; then
|
|
|
|
|
exec mosh "$_host" -- tmux attach -t "$_target"
|
|
|
|
|
fi
|
2026-05-17 07:11:47 -07:00
|
|
|
exec ssh -t $_SSH_LIVE_OPTS "$_host" tmux attach -t "$_target"
|
2026-04-26 00:04:09 -07:00
|
|
|
fi
|
|
|
|
|
;;
|
|
|
|
|
disk)
|
2026-05-17 05:16:25 -07:00
|
|
|
if [ -n "$_on" ] && [ "$_on" != "$_host" ]; then
|
|
|
|
|
echo "rclaude: --on requires a specific session row (run 'rclaude resume <pattern>' to get one, not a project-level row)" >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
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:47:15 -07:00
|
|
|
session|triage)
|
2026-05-17 04:02:16 -07:00
|
|
|
# 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
|
2026-05-17 05:16:25 -07:00
|
|
|
_dst=${_on:-$_host}
|
|
|
|
|
if [ "$_dst" = "$_host" ] || { is_local "$_dst" && is_local "$_host"; }; then
|
|
|
|
|
RCLAUDE_RESUME_ID=$_target exec "$0" "$_host" "$_session_cwd"
|
|
|
|
|
fi
|
|
|
|
|
# Cross-host mirror: translate cwd via $HOME-relative mirror,
|
|
|
|
|
# copy JSONL with cwd rewritten, then launch on dst.
|
|
|
|
|
_src_home=$(get_home "$_host")
|
|
|
|
|
_dst_home=$(get_home "$_dst")
|
2026-05-17 17:42:06 -07:00
|
|
|
if [ -z "$_dst_home" ]; then
|
|
|
|
|
printf "rclaude: can't reach '%s' (ssh failed or hostname doesn't resolve)\n" "$_dst" >&2
|
|
|
|
|
# Did you mean? — match a known host whose first 3 chars
|
|
|
|
|
# match (cheap typo catch). Strips trailing .lan/.local on
|
|
|
|
|
# both sides before comparing.
|
|
|
|
|
_t_base=$(printf %s "$_dst" | sed 's/\.\(lan\|local\)$//' | cut -c1-3)
|
|
|
|
|
_hint=$(scan_hosts | while IFS= read -r _h; do
|
|
|
|
|
_hb=$(printf %s "$_h" | sed 's/\.\(lan\|local\)$//' | cut -c1-3)
|
|
|
|
|
[ "$_hb" = "$_t_base" ] && echo "$_h" && break
|
|
|
|
|
done)
|
|
|
|
|
[ -n "$_hint" ] && printf " did you mean: %s ?\n" "$_hint" >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
if [ -z "$_src_home" ]; then
|
|
|
|
|
printf "rclaude: couldn't resolve \$HOME on source '%s'\n" "$_host" >&2
|
2026-05-17 05:16:25 -07:00
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
case $_session_cwd in
|
|
|
|
|
"$_src_home") _dst_cwd=$_dst_home ;;
|
|
|
|
|
"$_src_home"/*) _dst_cwd="$_dst_home${_session_cwd#$_src_home}" ;;
|
|
|
|
|
*)
|
|
|
|
|
echo "rclaude: session cwd $_session_cwd is outside source \$HOME ($_src_home); can't mirror" >&2
|
|
|
|
|
exit 1 ;;
|
|
|
|
|
esac
|
|
|
|
|
migrate_session "$_host" "$_dst" "$_target" "$_session_cwd" "$_dst_cwd" || exit
|
2026-05-17 05:53:07 -07:00
|
|
|
# Pass src + src_cwd to the launch path so it can rsync the project
|
|
|
|
|
# tree if the dst dir doesn't exist (set RCLAUDE_MIGRATE_SYNC=none
|
|
|
|
|
# to skip and just mkdir).
|
|
|
|
|
RCLAUDE_MIGRATE_FROM=$_host RCLAUDE_MIGRATE_FROM_CWD=$_session_cwd \
|
|
|
|
|
RCLAUDE_RESUME_ID=$_target \
|
|
|
|
|
exec "$0" "$_dst" "$_dst_cwd"
|
2026-05-17 04:02:16 -07:00
|
|
|
;;
|
2026-04-26 00:04:09 -07:00
|
|
|
esac
|
2026-04-25 23:53:42 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Dispatch
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-05-17 18:42:31 -07:00
|
|
|
# Resolve the hostname THIS machine is reachable at from the remote. Used
|
|
|
|
|
# to tell the remote claude session where to forward audio/state back to.
|
|
|
|
|
# Override with RCLAUDE_BACK_HOST in config (e.g. if the local hostname
|
|
|
|
|
# isn't directly reachable from the remote — pick a wg1 mesh IP / .lan name).
|
|
|
|
|
caller_hostname() {
|
|
|
|
|
if [ -n "${RCLAUDE_BACK_HOST:-}" ]; then
|
|
|
|
|
printf %s "$RCLAUDE_BACK_HOST"
|
|
|
|
|
return
|
|
|
|
|
fi
|
|
|
|
|
_hn=$(hostname -s 2>/dev/null || hostname)
|
|
|
|
|
case $_hn in
|
|
|
|
|
*.*) printf %s "$_hn" ;;
|
|
|
|
|
*) printf '%s.lan' "$_hn" ;;
|
|
|
|
|
esac
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Guard: when sourced as a library (by tests/run-tests.sh), skip dispatch
|
|
|
|
|
# so callers can invoke individual helpers without launching anything.
|
|
|
|
|
# MUST be placed after every helper definition so all functions are
|
|
|
|
|
# available to the sourcing test runner.
|
|
|
|
|
if [ "${RCLAUDE_LIB_ONLY:-0}" = "1" ]; then
|
|
|
|
|
return 0 2>/dev/null || exit 0
|
|
|
|
|
fi
|
|
|
|
|
|
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-05-17 18:18:15 -07:00
|
|
|
cmd_voice() {
|
|
|
|
|
# `rclaude voice` — toggle / inspect the rvoice push-to-talk binding.
|
|
|
|
|
# rvoice itself is a separate script (bin/rvoice) driven by Hammerspoon.
|
|
|
|
|
# This subcommand just gates whether the Hammerspoon tap is active by
|
|
|
|
|
# writing/removing a sentinel file the lua module checks at load.
|
|
|
|
|
_flag=${XDG_STATE_HOME:-$HOME/.local/state}/rclaude/voice-disabled
|
|
|
|
|
mkdir -p "$(dirname "$_flag")" 2>/dev/null
|
|
|
|
|
_action=${1:-status}
|
|
|
|
|
case $_action in
|
|
|
|
|
on|enable)
|
|
|
|
|
rm -f "$_flag"
|
|
|
|
|
_reload_hammerspoon
|
|
|
|
|
echo "rvoice: enabled (Right ⌥ = push-to-talk)" ;;
|
|
|
|
|
off|disable)
|
|
|
|
|
: > "$_flag"
|
|
|
|
|
_reload_hammerspoon
|
|
|
|
|
echo "rvoice: disabled (delete $_flag or 'rclaude voice on' to re-enable)" ;;
|
|
|
|
|
status)
|
|
|
|
|
if [ -f "$_flag" ]; then echo "rvoice: disabled"
|
|
|
|
|
else echo "rvoice: enabled"; fi
|
|
|
|
|
command -v rvoice >/dev/null 2>&1 \
|
|
|
|
|
&& echo " rvoice binary: $(command -v rvoice)" \
|
|
|
|
|
|| echo " rvoice binary: NOT INSTALLED" ;;
|
|
|
|
|
test|target)
|
|
|
|
|
command -v rvoice >/dev/null 2>&1 || { echo "rclaude: rvoice not on PATH" >&2; exit 1; }
|
|
|
|
|
rvoice target ;;
|
|
|
|
|
log)
|
|
|
|
|
command -v rvoice >/dev/null 2>&1 && rvoice log ;;
|
|
|
|
|
*)
|
|
|
|
|
cat <<EOF
|
|
|
|
|
usage: rclaude voice {on|off|status|target|log}
|
|
|
|
|
|
|
|
|
|
on/off toggle Hammerspoon push-to-talk binding
|
|
|
|
|
status show whether voice is enabled
|
|
|
|
|
target show what host/tmux session rvoice would inject into
|
|
|
|
|
log tail the rvoice action log
|
|
|
|
|
EOF
|
|
|
|
|
exit 2 ;;
|
|
|
|
|
esac
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Tell a running Hammerspoon to reload its config (so the voice on/off
|
|
|
|
|
# sentinel takes effect immediately). No-op if Hammerspoon isn't running.
|
|
|
|
|
_reload_hammerspoon() {
|
|
|
|
|
osascript -e 'tell application "Hammerspoon" to reload config' 2>/dev/null || true
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 07:23:49 -07:00
|
|
|
cmd_setup() {
|
|
|
|
|
# Args:
|
|
|
|
|
# (none) → install on every host in scan_hosts
|
|
|
|
|
# <host> [<host>...] → install on each named host
|
|
|
|
|
# --on <host> → install on a single host (parity with `resume --on`)
|
|
|
|
|
_hosts=""
|
|
|
|
|
while [ $# -gt 0 ]; do
|
|
|
|
|
case $1 in
|
|
|
|
|
--on) shift; _hosts="$_hosts $1"; shift ;;
|
|
|
|
|
--on=*) _hosts="$_hosts ${1#--on=}"; shift ;;
|
|
|
|
|
*) _hosts="$_hosts $1"; shift ;;
|
|
|
|
|
esac
|
|
|
|
|
done
|
|
|
|
|
if [ -z "$_hosts" ]; then
|
|
|
|
|
scan_hosts | while IFS= read -r h; do setup_host "$h" force; done
|
|
|
|
|
else
|
|
|
|
|
for h in $_hosts; do setup_host "$h" force; done
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 23:53:42 -07:00
|
|
|
case ${1:-} in
|
2026-05-17 07:23:49 -07:00
|
|
|
list) shift; cmd_list "$@"; exit ;;
|
|
|
|
|
resume) shift; cmd_resume "$@"; exit ;;
|
|
|
|
|
triage) shift; cmd_triage "$@"; exit ;;
|
2026-05-17 22:42:01 -07:00
|
|
|
send) shift; cmd_send "$@"; exit ;;
|
2026-05-17 07:29:55 -07:00
|
|
|
setup|install) shift; cmd_setup "$@"; exit ;;
|
2026-05-17 18:18:15 -07:00
|
|
|
voice) shift; cmd_voice "$@"; exit ;;
|
2026-05-17 07:23:49 -07:00
|
|
|
-v|--version) cmd_version; exit ;;
|
|
|
|
|
-h|--help|help) cmd_help; 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-05-17 21:35:06 -07:00
|
|
|
# Auto-restore display name on resume. Claude's `name` field lives in
|
|
|
|
|
# ~/.claude/sessions/<pid>.json — wiped on every restart/resume — so if
|
|
|
|
|
# we know a uuid had a name previously (from the durable index), pass
|
|
|
|
|
# it back via `claude -n <name>` so the resumed pid is re-labeled.
|
|
|
|
|
# Explicit RCLAUDE_RESUME_NAME overrides any index lookup.
|
|
|
|
|
_name_flag=""
|
|
|
|
|
_name_val=${RCLAUDE_RESUME_NAME:-}
|
|
|
|
|
if [ -z "$_name_val" ] && [ -n "${RCLAUDE_RESUME_ID:-}" ] && [ -s "${_NAME_INDEX:-}" ]; then
|
|
|
|
|
_name_val=$(awk -F'\t' -v u="$RCLAUDE_RESUME_ID" '$2 == u { print $3; exit }' "$_NAME_INDEX" 2>/dev/null)
|
|
|
|
|
fi
|
|
|
|
|
if [ -n "$_name_val" ]; then
|
|
|
|
|
# Escape single quotes for safe shell embedding.
|
|
|
|
|
_name_esc=$(printf %s "$_name_val" | sed "s/'/'\\\\''/g")
|
|
|
|
|
_name_flag="-n '${_name_esc}'"
|
|
|
|
|
fi
|
2026-05-17 18:42:31 -07:00
|
|
|
# When launching on a remote host, tell its MCPs where to forward
|
|
|
|
|
# audio back to (so apricot's TTS plays on the local Mac, etc.). When
|
|
|
|
|
# local, leave the env alone — local MCPs play locally.
|
|
|
|
|
_back_env=""
|
|
|
|
|
if ! is_local "$host"; then
|
|
|
|
|
_back=$(caller_hostname)
|
|
|
|
|
_back_env="export SPEECH_PLAYBACK_HOST=${_back}; "
|
|
|
|
|
fi
|
2026-05-18 21:14:09 -07:00
|
|
|
# Supervisor-spawned sessions (clare) need MCP servers wired without the
|
|
|
|
|
# interactive trust-prompt round-trip. RCLAUDE_MCP_CONFIG forwards a JSON
|
|
|
|
|
# config path to `claude --mcp-config <path>`. Quoted to survive spaces.
|
|
|
|
|
_mcp_flag=""
|
|
|
|
|
if [ -n "${RCLAUDE_MCP_CONFIG:-}" ]; then
|
|
|
|
|
_mcp_esc=$(printf %s "$RCLAUDE_MCP_CONFIG" | sed "s/'/'\\\\''/g")
|
|
|
|
|
_mcp_flag="--mcp-config '${_mcp_esc}'"
|
|
|
|
|
fi
|
2026-04-26 00:17:14 -07:00
|
|
|
printf '%s' \
|
2026-05-18 21:14:09 -07:00
|
|
|
"${_back_env}cd ${1} && rc_t=\$(date +%s); claude ${_resume_flag} ${_name_flag} ${_mcp_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-05-18 19:38:35 -07:00
|
|
|
# RCLAUDE_DETACHED=1 → spawn the tmux session in the background and
|
|
|
|
|
# return the session name on stdout. Used by supervisor processes
|
|
|
|
|
# (e.g. `clare web`) that want to bring up a Claude session without
|
|
|
|
|
# attaching the current terminal.
|
|
|
|
|
if [ -n "${RCLAUDE_DETACHED:-}" ]; then
|
|
|
|
|
tmux new-session -d -s "$session" "$(build_inner "$dir")"
|
|
|
|
|
printf '%s\n' "$session"
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
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-05-17 05:53:07 -07:00
|
|
|
#
|
|
|
|
|
# Cross-host mirror exception: when invoked via `resume --on`, we've just
|
|
|
|
|
# migrated the session JSONL but the project files may not exist on the
|
|
|
|
|
# target. In that case auto-mkdir so the conversation can be resumed; the
|
|
|
|
|
# user can sync project files separately (rsync / git clone). The session
|
|
|
|
|
# state is what matters most for resume.
|
2026-04-26 00:10:02 -07:00
|
|
|
if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "$host" "test -d ${dir}" 2>/dev/null; then
|
2026-05-17 05:53:07 -07:00
|
|
|
if [ -n "${RCLAUDE_RESUME_ID:-}" ] && [ -n "${RCLAUDE_MIGRATE_FROM:-}" ]; then
|
|
|
|
|
# Cross-host mirror landing: try to rsync the project tree from
|
|
|
|
|
# source to dst so claude has the files it expects. Falls back to
|
|
|
|
|
# mkdir-only when sync is disabled or rsync fails. Both endpoints
|
|
|
|
|
# being remote is unsupported (no two-hop relay).
|
|
|
|
|
_sync_mode=${RCLAUDE_MIGRATE_SYNC:-rsync}
|
|
|
|
|
_src_host=$RCLAUDE_MIGRATE_FROM
|
|
|
|
|
_src_dir=$RCLAUDE_MIGRATE_FROM_CWD
|
|
|
|
|
_did_rsync=0
|
|
|
|
|
if [ "$_sync_mode" = "rsync" ] && command -v rsync >/dev/null 2>&1; then
|
|
|
|
|
_src_local=0; _dst_local=0
|
|
|
|
|
is_local "$_src_host" && _src_local=1
|
|
|
|
|
is_local "$host" && _dst_local=1
|
|
|
|
|
if [ $((_src_local + _dst_local)) -ge 1 ]; then
|
|
|
|
|
_src_arg=$([ "$_src_local" = 1 ] && printf '%s/' "$_src_dir" || printf '%s:%s/' "$_src_host" "$_src_dir")
|
|
|
|
|
_dst_arg=$([ "$_dst_local" = 1 ] && printf '%s/' "$dir" || printf '%s:%s/' "$host" "$dir")
|
2026-05-17 06:29:33 -07:00
|
|
|
printf 'rclaude: rsync -ahz --info=progress2,stats1 %s %s\n' "$_src_arg" "$_dst_arg" >&2
|
|
|
|
|
# -h human-readable, -z compress over ssh, progress2 = single
|
|
|
|
|
# rolling progress bar with rate + ETA, stats1 = summary at end.
|
|
|
|
|
if rsync -ahz --info=progress2,stats1 "$_src_arg" "$_dst_arg" >&2; then
|
2026-05-17 05:53:07 -07:00
|
|
|
_did_rsync=1
|
|
|
|
|
else
|
|
|
|
|
echo "rclaude: rsync failed; falling back to empty mkdir" >&2
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
if [ "$_did_rsync" = 0 ]; then
|
|
|
|
|
echo "rclaude: $dir doesn't exist on $host — creating empty dir for session resume." >&2
|
|
|
|
|
echo " (sync separately if needed: rsync -a $_src_dir/ $host:$dir/)" >&2
|
|
|
|
|
ssh -o BatchMode=yes -o ConnectTimeout=5 "$host" "mkdir -p ${dir}" 2>/dev/null || {
|
|
|
|
|
echo "rclaude: mkdir failed on $host: $dir" >&2; exit 1; }
|
|
|
|
|
fi
|
|
|
|
|
else
|
|
|
|
|
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-04-26 00:10:02 -07:00
|
|
|
fi
|
|
|
|
|
|
2026-05-17 07:23:49 -07:00
|
|
|
setup_host "$host"
|
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-05-20 02:13:38 -07:00
|
|
|
# RCLAUDE_DETACHED=1 → spawn the tmux session on the remote in the
|
|
|
|
|
# background and print the session name. Symmetric with the local-host
|
|
|
|
|
# detached branch above; used by supervisor processes (e.g. clare web)
|
|
|
|
|
# that bring up a remote Claude pane without attaching the calling tty.
|
2026-05-20 02:34:42 -07:00
|
|
|
#
|
2026-05-20 03:23:02 -07:00
|
|
|
# We launch tmux inside a transient `systemd-run --user --unit=<name>`
|
|
|
|
|
# user service rather than the calling ssh session's scope, with
|
|
|
|
|
# Restart=on-failure so a tmux server crash (heap-corruption bugs in
|
|
|
|
|
# older tmux builds, OOM-kill, etc.) is automatically recovered by
|
|
|
|
|
# systemd rather than waiting on Clare's 60s supervisor heartbeat.
|
|
|
|
|
#
|
|
|
|
|
# `--scope` was the old approach but a scope dies with its process — no
|
|
|
|
|
# restart. Falls back to a plain detached `tmux new-session -d` if the
|
|
|
|
|
# remote host has no systemd-run (e.g. macOS).
|
2026-05-20 02:13:38 -07:00
|
|
|
# Mosh is interactive-only — always go through ssh for detached spawn.
|
|
|
|
|
if [ -n "${RCLAUDE_DETACHED:-}" ]; then
|
2026-05-20 03:23:02 -07:00
|
|
|
# Run tmux inside a transient user scope so the tmux daemon escapes
|
|
|
|
|
# the calling ssh login session's cgroup (which systemd-logind would
|
|
|
|
|
# otherwise reap on disconnect, even with linger enabled). `--scope`
|
|
|
|
|
# exits as soon as the tmux daemon detaches; recovery from later
|
|
|
|
|
# tmux crashes is handled by Clare's supervisor heartbeat in
|
|
|
|
|
# `clare web` (re-runs ensure_running every 60s).
|
2026-05-20 02:34:42 -07:00
|
|
|
_remote_cmd="systemd-run --user --scope --collect --quiet tmux new-session -d -s '${session}' \"${inner}\" 2>/dev/null || tmux new-session -d -s '${session}' \"${inner}\""
|
|
|
|
|
ssh $_SSH_LIVE_OPTS "$host" "$_remote_cmd"
|
2026-05-20 02:13:38 -07:00
|
|
|
printf '%s\n' "$session"
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
2026-05-17 07:11:47 -07:00
|
|
|
# `new-session -A` attaches if a session of that name already exists, so
|
|
|
|
|
# re-running rclaude after a broken pipe lands you back in the same tmux
|
|
|
|
|
# session instead of erroring with "duplicate session". Combined with
|
|
|
|
|
# _SSH_LIVE_OPTS this tolerates short network drops without losing work.
|
2026-05-17 07:23:49 -07:00
|
|
|
# Mosh is preferred when available (handles sleep/roam/long blips natively);
|
|
|
|
|
# falls back to ssh+keepalives otherwise.
|
|
|
|
|
if [ "$(pick_transport "$host")" = "mosh" ]; then
|
|
|
|
|
exec mosh "$host" -- tmux new-session -A -s "${session}" "${inner}"
|
|
|
|
|
fi
|
2026-05-17 07:11:47 -07:00
|
|
|
exec ssh -t $_SSH_LIVE_OPTS "$host" "tmux new-session -A -s '${session}' \"${inner}\""
|