feat(@scripts): ✨ add resume session functionality with cross-host mirroring
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
795e40c69a
commit
4f30666964
1 changed files with 185 additions and 21 deletions
206
bin/rclaude
206
bin/rclaude
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue