feat(@scripts): add resume session functionality with cross-host mirroring

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-17 05:16:25 -07:00
parent 795e40c69a
commit 4f30666964

View file

@ -28,6 +28,10 @@
# rclaude list sessions # tmux + per-session disk view (uuid + snippet)
# rclaude triage [--limit N] [--refresh] # Haiku-powered ranked summary of recent sessions
# # (uses claude-code-batch-sdk + content cache)
# 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
# rclaude resume [pattern] # reattach / resume by uuid prefix, snippet,
# # tmux name, or cwd substring (interactive
# # picker on >1 match)
@ -154,7 +158,8 @@ list_sessions_on() {
}
NF >= 3 {
snippet = ($4 == "" ? "(no user text)" : $4)
printf "%s\tsession\t%s\t%s\t%s · %s\n", host, $2, snippet, $3, rel(now - $1)
# 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
}
'
}
@ -171,6 +176,88 @@ list_search_on() {
list_sessions_on "$1"
}
# Get $HOME on <host> (cached per host in /tmp for the life of this shell).
get_home() {
_h=$1
_cache="/tmp/rclaude-home.$(whoami).$(printf %s "$_h" | tr -c 'A-Za-z0-9' '_')"
if [ -s "$_cache" ]; then
cat "$_cache"; return
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
[ -n "$_v" ] && printf '%s' "$_v" > "$_cache" && printf %s "$_v"
}
# 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'
}
# 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
}
# Mirror a session JSONL from <src_host>'s ~/.claude/projects/<src_slug>/<uuid>.jsonl
# to <dst_host>'s ~/.claude/projects/<dst_slug>/<uuid>.jsonl, rewriting every
# `"cwd":"<src_cwd...>"` occurrence to point at <dst_cwd...>.
migrate_session() {
_src=$1; _dst=$2; _uuid=$3; _src_cwd=$4; _dst_cwd=$5
_src_slug=$(claude_slug "$_src_cwd")
_dst_slug=$(claude_slug "$_dst_cwd")
_src_path="\$HOME/.claude/projects/${_src_slug}/${_uuid}.jsonl"
_dst_path="\$HOME/.claude/projects/${_dst_slug}/${_uuid}.jsonl"
if is_local "$_src"; then
_src_data=$(cat "$HOME/.claude/projects/${_src_slug}/${_uuid}.jsonl" 2>/dev/null || true)
else
_src_data=$(ssh -o BatchMode=yes -o ConnectTimeout=5 "$_src" "cat $_src_path" 2>/dev/null || true)
fi
if [ -z "$_src_data" ]; then
echo "rclaude: source session not found on $_src ($_src_path)" >&2
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
}
# 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
@ -308,24 +395,61 @@ cmd_list() {
# Pattern matching is case-insensitive substring across host/kind/uuid/snippet/cwd.
# An empty pattern lists everything (interactive picker).
cmd_resume() {
_pattern=${1:-}
# When triage is enabled, the search space is tmux rows + Haiku-triaged
# session rows (ranked by priority). Otherwise fall back to raw session
# rows with first-user-message snippets.
case ${RCLAUDE_TRIAGE:-off} in
auto|on|1|true)
printf 'rclaude: triaging sessions...\n' >&2
_matches=$(scan_hosts | while IFS= read -r h; do
list_tmux_on "$h"
list_triage_on "$h"
done)
;;
*)
_matches=$(scan_hosts | while IFS= read -r h; do list_search_on "$h"; done)
;;
esac
if [ -n "$_pattern" ]; then
_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
_matches=$(scan_hosts | while IFS= read -r h; do
list_tmux_on "$h"
list_triage_on "$h"
done)
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
_matches=$(scan_hosts | while IFS= read -r h; do list_search_on "$h"; done)
_matches=$(printf '%s\n' "$_matches" | grep -F -i -- "$_pattern" || true)
_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
fi
_count=0
[ -n "$_matches" ] && _count=$(printf '%s\n' "$_matches" | wc -l | tr -d ' ')
@ -336,8 +460,10 @@ cmd_resume() {
if [ "$_count" -gt 1 ]; then
_keys="123456789abcdefghijklmnopqrstuvwxyz"
if [ "$_count" -gt 35 ]; then
echo "too many matches ($_count); refine pattern" >&2
exit 1
_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
fi
# Per-row display: triage rows surface priority + status + summary;
# session rows show the user-message snippet; tmux/disk show $3.
@ -355,12 +481,22 @@ cmd_resume() {
exit 1
fi
_i=0
_prev_kind=""
printf '%s\n' "$_matches" | while IFS= read -r _line; do
_i=$((_i + 1))
_kind_now=$(printf %s "$_line" | awk -F'\t' '{print $2}')
if [ "$_prev_kind" = "tmux" ] && [ "$_kind_now" != "tmux" ]; then
printf ' ---\n' >&2
fi
_k=$(printf %s "$_keys" | cut -c"$_i")
printf ' [%s] %s\n' "$_k" \
"$(printf %s "$_line" | awk -F'\t' "$_fmt_row")" >&2
_prev_kind=$_kind_now
done
if [ "$_d_total" -gt "$_d_room" ] && [ "$_d_room" -gt 0 ]; then
printf ' (showing %s most recent of %s disk sessions; pass a pattern to search older)\n' \
"$_d_room" "$_d_total" >&2
fi
_last_key=$(printf %s "$_keys" | cut -c"$_count")
printf 'select [1-%s]: ' "$_last_key" >&2
_old=$(stty -g 2>/dev/null || true)
@ -395,6 +531,10 @@ cmd_resume() {
esac
case $_kind in
tmux)
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
if is_local "$_host"; then
exec tmux attach -t "$_target"
else
@ -402,6 +542,10 @@ cmd_resume() {
fi
;;
disk)
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
# Spawn tmux + claude --continue at the recorded cwd.
RCLAUDE_RESUME=1 exec "$0" "$_host" "$_target"
;;
@ -411,7 +555,27 @@ cmd_resume() {
echo "rclaude: session $_target has no recorded cwd" >&2
exit 1
fi
RCLAUDE_RESUME_ID=$_target exec "$0" "$_host" "$_session_cwd"
_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")
if [ -z "$_src_home" ] || [ -z "$_dst_home" ]; then
echo "rclaude: couldn't resolve \$HOME on $_host or $_dst" >&2
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
RCLAUDE_RESUME_ID=$_target exec "$0" "$_dst" "$_dst_cwd"
;;
esac
}