diff --git a/bin/rclaude b/bin/rclaude index e140c1e..04cf43a 100755 --- a/bin/rclaude +++ b/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 # mirror picked session onto (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 (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 's ~/.claude/projects//.jsonl +# to 's ~/.claude/projects//.jsonl, rewriting every +# `"cwd":""` occurrence to point at . +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" < $_dst_path" <&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 ' 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 }