235 lines
8.9 KiB
Bash
Executable File
235 lines
8.9 KiB
Bash
Executable File
#!/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/<session>.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 <shell_pid>: 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 <session> [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 <src.json> [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 "$@"
|