Files
playpen/tools/fresh-shells.sh

150 lines
5.6 KiB
Bash
Executable File

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