diff --git a/README.md b/README.md index ae7124a..cd386e3 100644 --- a/README.md +++ b/README.md @@ -498,6 +498,13 @@ a terminal leaf). A terminal that has since exited reopens as a fresh shell in the recorded directory, as it would without herdr-core. Shells in the core can also be reached from anywhere else with `hc ls` and `hc attach`. +Since the shells outlive every restart, so does anything wrong with them — an +environment variable they started with, say. `mise run fresh-shells` replaces +them all while keeping the layout: it quits playpen (saving the session), ends +every terminal playpen started in the core, and relaunches, so each pane comes +back as a new shell in its last directory. It lists what is running and asks +first, and it is safe to run from inside one of those panes. + The file is `session.json`, and it lives in the **state** directory rather than beside `layouts.json` and `settings.json`: diff --git a/mise.toml b/mise.toml index cbc66f9..f2d2caf 100644 --- a/mise.toml +++ b/mise.toml @@ -184,6 +184,10 @@ done rmdir "$backups" 2>/dev/null || true ''' +[tasks.fresh-shells] +description = "Replace every herdr-core shell playpen keeps with a fresh one, keeping the layout" +run = "tools/fresh-shells.sh" + [tasks.install] description = "Install playpen into ~/.local with a desktop entry and icon" depends = ["build"] diff --git a/tools/fresh-shells.sh b/tools/fresh-shells.sh new file mode 100755 index 0000000..b46aa0d --- /dev/null +++ b/tools/fresh-shells.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# Replace every shell playpen keeps in herdr-core with a fresh one, keeping the +# layout. +# +# tools/fresh-shells.sh list what will be ended, ask, then do it +# tools/fresh-shells.sh -y don't ask +# +# Acts on the hc session in $HC_SESSION (the default one when unset), and +# relaunches with $PLAYPEN_LAUNCHER (the installed launcher when unset). +# +# A program's environment is fixed when it starts. Shells that came from a core +# with something wrong in its environment keep it for good, and since playpen +# reattaches to them on every launch, restarting playpen never clears it. And +# `hc server restart` cannot either: it is a planned restart that keeps every +# program running for the next core to adopt. +# +# So, in order: +# 1. Quit playpen with SIGTERM, which saves the session first. This has to +# come before the shells go: a pane whose shell exits closes, and closing +# the last tab deletes the session. +# 2. Kill every terminal playpen started in the core (label playpen=1). +# 3. Start playpen again. It restores the session, finds those terminals gone, +# and opens a fresh shell in each pane's last directory. +# +# Whatever is running in those shells ends with them — agents included. + +set -euo pipefail + +unit_prefix="playpen-fresh-shells" +launcher="${PLAYPEN_LAUNCHER:-$HOME/.local/bin/playpen}" + +# By path: the detached half runs under systemd, whose PATH has no ~/.local/bin. +hc_bin=$(command -v hc || echo "$HOME/.local/bin/hc") + +die() { echo "fresh-shells: $*" >&2; exit 1; } +say() { echo "fresh-shells: $*"; } + +# Every hc call runs without this process's Claude Code markers. Any hc command +# starts a core when none is running, and a core started with them hands them +# to every terminal it ever runs — which is how this script came to be needed. +hc() { + local unset=() + local name + for name in $(compgen -e); do + case "$name" in CLAUDE*) unset+=(-u "$name") ;; esac + done + env "${unset[@]}" "$hc_bin" "$@" +} + +[ -x "$hc_bin" ] || die "hc is not installed" +command -v jq >/dev/null || die "needs jq" +[ -x "$launcher" ] || die "no playpen launcher at $launcher (run mise run install)" + +yes=false +[ "${1:-}" = "-y" ] && yes=true + +terminals() { + hc ls --label playpen=1 --json | jq -r '.terminals[].address' +} + +# Whether this script is running in something step 1 or 2 would end: a pane of +# playpen's own (a local shell, which quitting hangs up), or a terminal in the +# core that step 2 kills. +inside_playpen() { + if [ -n "${HC_TERMINAL_ID:-}" ] && + [ "$(hc get "$HC_TERMINAL_ID" --json 2>/dev/null | jq -r '.labels.playpen // empty')" = 1 ]; then + return 0 + fi + local pid=$$ + while [ "$pid" -gt 1 ]; do + [ "$(cat "/proc/$pid/comm" 2>/dev/null)" = playpen ] && return 0 + pid=$(awk '{print $4}' "/proc/$pid/stat" 2>/dev/null) || return 1 + done + return 1 +} + +if [ -z "${PLAYPEN_FRESH_SHELLS_DETACHED:-}" ]; then + mapfile -t addrs < <(terminals) + if [ "${#addrs[@]}" -eq 0 ]; then + say "playpen has no terminals in herdr-core; nothing to do" + exit 0 + fi + + echo "These ${#addrs[@]} terminals will be ended, and everything running in them:" + for a in "${addrs[@]}"; do + hc get "$a" --json | jq -r '" \(.address) \((.foreground.name // "?"))\t\(.foreground.cwd // .cwd)"' + done + if ! $yes; then + read -r -p "Quit playpen, end them, and reopen with fresh shells? [y/N] " answer + case "$answer" in y | Y | yes) ;; *) say "nothing done"; exit 0 ;; esac + fi + + # Hand the rest to systemd, which is not inside anything about to end. + if inside_playpen; then + unit="$unit_prefix-$$" + systemd-run --user --collect --quiet --unit="$unit" \ + --setenv=PLAYPEN_FRESH_SHELLS_DETACHED=1 \ + ${HC_SESSION:+--setenv=HC_SESSION="$HC_SESSION"} \ + ${PLAYPEN_LAUNCHER:+--setenv=PLAYPEN_LAUNCHER="$PLAYPEN_LAUNCHER"} \ + "$(readlink -f "$0")" -y + say "this shell is one of them, so the rest runs on its own as $unit." + say "playpen will close and come back in a few seconds." + say "if it doesn't: journalctl --user -u $unit" + exit 0 + fi +fi + +# 1. Quit playpen, saving the session. Only the ones on this hc session: a +# playpen talking to another core has nothing to do with these terminals. +mapfile -t pids < <( + for p in $(pgrep -u "$(id -u)" -x playpen); do + theirs=$(tr '\0' '\n' <"/proc/$p/environ" 2>/dev/null | sed -n 's/^HC_SESSION=//p') + [ "$theirs" = "${HC_SESSION:-}" ] && echo "$p" + done +) +if [ "${#pids[@]}" -gt 0 ]; then + say "quitting playpen (${pids[*]})" + kill -TERM "${pids[@]}" + for _ in $(seq 1 100); do + alive=false + for p in "${pids[@]}"; do kill -0 "$p" 2>/dev/null && alive=true; done + $alive || break + sleep 0.1 + done + # Better to leave the shells alone than to end them under a playpen that is + # still up, which would close its panes and lose the layout. + $alive && die "playpen did not quit within 10s; nothing else done" +fi + +# 2. End playpen's terminals. +mapfile -t addrs < <(terminals) +say "ending ${#addrs[@]} terminals" +for a in "${addrs[@]}"; do + # `rm` terminals are gone the moment they exit, so kill reports them as + # "removed" even when it worked. + hc kill "$a" >/dev/null 2>&1 || true +done +for _ in $(seq 1 100); do + [ -z "$(terminals)" ] && break + sleep 0.1 +done +left=$(terminals) +[ -n "$left" ] && die "still running: $(echo "$left" | tr '\n' ' ')— not relaunching" + +# 3. Start playpen again, from systemd rather than from this shell, so it gets +# the session's environment and not whatever this script was run from. +systemd-run --user --collect --quiet --unit="app-playpen-$$" \ + ${HC_SESSION:+--setenv=HC_SESSION="$HC_SESSION"} "$launcher" +say "playpen relaunched with fresh shells"