#!/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 "$@"