diff --git a/scripts/tmux-resurrect.sh b/scripts/tmux-resurrect.sh new file mode 100755 index 0000000..40dc1b6 --- /dev/null +++ b/scripts/tmux-resurrect.sh @@ -0,0 +1,234 @@ +#!/usr/bin/env bash +# +# tmux-resurrect.sh — snapshot and restore the exact layout of a tmux session +# on Dankclaw, including each pane's start directory and foreground command. +# +# Use it after a reboot (or when the display session is gone) to reopen the +# same windows/panes on the Dankclaw console: +# +# scripts/tmux-resurrect.sh snapshot # record the live session to JSON +# scripts/tmux-resurrect.sh restore # rebuild it, then attach +# +# Data file: ~/.tmux-resurrect/.json (set TMUX_RES_DIR to relocate). +# Re-run snapshot as the session changes so the restore stays faithful. +# +# How each pane's command is captured: a tmux pane runs one main shell +# (#{pane_pid}). The foreground command is that shell's direct child — chosen +# as the child that launched its own child (ssh, sudo ...) or the first child +# when none did (btop sitting at the top of a pane). An idle shell yields no +# command and is left at its prompt on restore. +set -euo pipefail + +TMUX_RES_DIR="${TMUX_RES_DIR:-$HOME/.tmux-resurrect}" +DEFAULT_SESSION="0" + +die() { printf 'tmux-resurrect: %s\n' "$*" >&2; exit 1; } +b64() { base64 -w0; } +b64d() { base64 -d; } + +# fg_command : print the foreground command of a tmux pane, or the +# empty string when the shell is idle. +fg_command() { + local pid="$1" kids=() kid cmd_pid="" + mapfile -t kids < <(pgrep -P "$pid" 2>/dev/null || true) + if [ "${#kids[@]}" -eq 0 ]; then return 0; fi + # Prefer a child that launched its own child (a launcher such as ssh or + # sudo); fall back to the first direct child (e.g. btop at the top). + for kid in "${kids[@]}"; do + if pgrep -P "$kid" >/dev/null 2>&1; then + cmd_pid="$kid"; break + fi + done + if [ -z "$cmd_pid" ]; then cmd_pid="${kids[0]}"; fi + tr '\0' ' ' < "/proc/$cmd_pid/cmdline" 2>/dev/null | sed -E 's/[[:space:]]+$//' +} + +# snapshot [dest.json] +snapshot() { + local session="${1:-$DEFAULT_SESSION}" dest="${2:-}" + local exists + exists=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep -Fxq "$session" && echo 1 || echo 0) + [ "$exists" = 1 ] || die "no tmux session named '$session'" + mkdir -p "$TMUX_RES_DIR" + [ -z "$dest" ] && dest="$TMUX_RES_DIR/$session.json" + python3 - "$dest" "$session" 3< <( + { + while IFS=$'\t' read -r widx wname wlayout; do + printf 'W\t%s\t%s\t%s\n' "$widx" "$(printf '%s' "$wname" | b64)" "$(printf '%s' "$wlayout" | b64)" + while IFS=$'\t' read -r pidx ppid pdir; do + pcmd=$(fg_command "$ppid") + printf 'P\t%s\t%s\t%s\t%s\n' "$widx" "$pidx" \ + "$(printf '%s' "$pdir" | b64)" "$(printf '%s' "$pcmd" | b64)" + done < <(tmux list-panes -t "$session:$widx" \ + -F $'#{pane_index}\t#{pane_pid}\t#{pane_current_path}' | sort -n -k1,1) + done < <(tmux list-windows -t "$session" \ + -F $'#{window_index}\t#{window_name}\t#{window_layout}' | sort -n -k1,1) + } + ) > /dev/null <<'PY' +import sys, json, base64, os +dest, session = sys.argv[1], sys.argv[2] +wmap, panes = {}, {} +for line in os.fdopen(3).read().splitlines(): + if not line: + continue + p = line.split('\t') + if p[0] == 'W': + wmap[int(p[1])] = {"name": base64.b64decode(p[2]).decode(), + "layout": base64.b64decode(p[3]).decode()} + elif p[0] == 'P': + widx = int(p[1]) + panes.setdefault(widx, []).append({ + "index": int(p[2]), + "dir": base64.b64decode(p[3]).decode(), + "cmd": base64.b64decode(p[4]).decode(), + }) +windows = [] +for widx in sorted(wmap): + windows.append({ + "index": widx, + "name": wmap[widx]["name"], + "layout": wmap[widx]["layout"], + "panes": sorted(panes.get(widx, []), key=lambda x: x["index"]), + }) +with open(dest, "w") as f: + json.dump({"session": session, "windows": windows}, f, indent=2, ensure_ascii=False) + f.write("\n") +PY + printf 'snapshot: wrote %s (session %s)\n' "$dest" "$session" +} + +# emit_plan [session]: python reads the JSON and prints a base64 +# TSV plan (W/P records) for the bash orchestrator to consume. +emit_plan() { + python3 - "$1" "$2" <<'PY' +import sys, json, base64 +src, session = sys.argv[1], sys.argv[2] +with open(src) as f: + data = json.load(f) +wins = data.get("windows", []) +if session: + wins = [w for w in wins if str(w.get("index")) == "0"] +def b64(s): return base64.b64encode(s.encode()).decode() +for w in wins: + print('W\t%s\t%s\t%s' % (w["index"], b64(w["name"]), b64(w["layout"]))) + for pan in w["panes"]: + print('P\t%s\t%s\t%s\t%s' % (w["index"], pan["index"], + b64(pan["dir"]), b64(pan["cmd"]))) +PY +} + +# restore [session] [--no-attach] [--dry-run] +restore() { + local session="${1:-$DEFAULT_SESSION}" + shift || true + local keep=0 noattach=0 dryrun=0 a + for a in "$@"; do + case "$a" in + --keep) keep=1 ;; + --no-attach) noattach=1 ;; + --dry-run) dryrun=1 ;; + *) die "unknown restore option: $a" ;; + esac + done + local dest="$TMUX_RES_DIR/$session.json" + [ -f "$dest" ] || die "no snapshot at $dest (run snapshot first)" + + # Restore wrapper: run tmux, or print the command under --dry-run. + tmuxr() { + if [ "$dryrun" = 1 ]; then printf ' tmux %s\n' "$*"; else tmux "$@"; fi + } + + local target_exists + target_exists=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep -Fxq "$session" && echo 1 || echo 0) + if [ "$target_exists" = 1 ]; then + if [ "$keep" = 1 ]; then + die "session '$session' already exists; rebuild without --keep or save a copy" + fi + if [ "$dryrun" = 1 ]; then printf ' # kill existing session %s\n' "$session" + else tmux kill-session --force "$session"; fi + fi + + # Parse the plan into flat, window-major lists. + local -a WNAME=() WLAY=() WSTART=() PD=() PC=() + local tag idx nameb layoutb dirb cmdb + while IFS=$'\t' read -r tag idx nameb layoutb dirb cmdb; do + case "$tag" in + W) WNAME+=("$(printf '%s' "$nameb" | b64d)"); WLAY+=("$(printf '%s' "$layoutb" | b64d)"); WSTART+=("${#PD[@]}") ;; + P) PD+=("$(printf '%s' "$dirb" | b64d)"); PC+=("$(printf '%s' "$cmdb" | b64d)") ;; + esac + done < <(emit_plan "$dest" "") + + local n=${#WNAME[@]} w first last npanes i curwin wname wlayout dir cmd + for ((w = 0; w < n; w++)); do + first=${WSTART[w]} + if (( w + 1 < n )); then last=${WSTART[w + 1]}; else last=${#PD[@]}; fi + npanes=$(( last - first )) + wname="${WNAME[w]}"; wlayout="${WLAY[w]}" + if [ "$dryrun" = 1 ]; then + if (( w == 0 )); then + tmuxr new-session -d -s "$session" -n "$wname" -c "${PD[first]}" + else + tmuxr new-window -d -t "$session" -n "$wname" -c "${PD[first]}" + fi + curwin="$session:$wname" + elif (( w == 0 )); then + curwin=$(tmux new-session -d -P -F '#{window_id}' \ + -s "$session" -n "$wname" -c "${PD[first]}") + else + curwin=$(tmux new-window -d -P -F '#{window_id}' \ + -t "$session" -n "$wname" -c "${PD[first]}") + fi + # Create the remaining panes in recorded order (index == creation order). + for ((i = first + 1; i < last; i++)); do + tmuxr split-window -t "$curwin" -c "${PD[i]}" + done + # Restore the exact geometry for this window. + tmuxr select-layout -t "$curwin" "$wlayout" + # Launch each pane's recorded command, matched to panes by ascending + # pane index (so this is correct regardless of base-index). + local -a ordered=() + mapfile -t ordered < <(tmux list-panes -t "$curwin" \ + -F $'#{pane_id}\t#{pane_target}' | sort -n -k1,1) + local k=0 tgt + for tgt in "${ordered[@]}"; do + cmd="${PC[first + k]}" + if [ -n "$cmd" ]; then tmuxr send-keys -t "$tgt" "$cmd" Enter; fi + k=$((k + 1)) + done + done + + if [ "$noattach" = 1 ]; then + return 0 + elif [ -t 0 ]; then + tmuxr attach -t "$session" + elif [ "$dryrun" = 0 ]; then + echo "session '$session' rebuilt; attach with: tmux attach -t $session" + fi +} + +usage() { + cat <<'USAGE' +tmux-resurrect.sh — snapshot & restore a tmux session on Dankclaw. + +Usage: + tmux-resurrect.sh snapshot [session] [dest.json] record the live session + tmux-resurrect.sh restore [session] [options] rebuild the session + options: + --keep skip if the session already exists (default: rebuild) + --no-attach rebuild without attaching to this terminal + --dry-run print the commands instead of running them +USAGE +} + +main() { + local cmd="${1:-snapshot}" + shift || true + case "$cmd" in + snapshot) snapshot "$@" ;; + restore) restore "$@" ;; + -h|--help|help) usage ;; + *) die "unknown command: $cmd (use snapshot|restore)" ;; + esac +} + +main "$@"