Files
claude-plugins/background-shell/skills/background-shell/scripts/bgsh
movq dde11f3170 feat(background-shell): add persistent tmux session plugin
Adds a background-shell plugin providing the `bgsh` helper: named tmux
sessions that survive across tool calls and SSH disconnects, for work
ordinary tool calls cannot hold — interactive prompts, REPLs, TUIs, and
jobs that must outlive a connection.

The skill description is written as a hard gate: over-triggering is the
primary failure mode, so it redirects to plain Bash for everyday work.

- tty is the default mode (output stays in the pane, cd persists);
  --log is the alternate for one-shot captured payloads
- a local registry keeps remote sessions findable, storing *where to
  look* and never *what is running* — every read verifies against the
  host, and prune refuses to act on an unreachable one (UNSURE != STALE)
- bin/bgsh is on the plugin PATH, so no symlink step is needed
- SKILL.md carries a "why bgsh does it this way" table: each row is a
  real failure whose output was indistinguishable from success

Passes `claude plugin validate` with no warnings; bgsh is shellcheck
clean and bash 3.2 compatible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:25:21 -05:00

406 lines
18 KiB
Bash
Executable File

#!/usr/bin/env bash
# bgsh — background shell sessions (tmux), local and remote.
#
# Encodes the mechanics that are silently wrong when hand-typed: pane geometry,
# sentinel/exit-code correlation, quoting across ssh->send-keys->shell, and keeping the
# remote-session registry in step with reality. Judgment (when to use this at all, local vs
# remote, permission guardrails) lives in ../SKILL.md.
#
# The script is a CONSTRUCTOR, not a proxy: it builds real tmux sessions that you inspect
# with real tmux. `@purpose` on the session stays the source of truth.
#
# bash 3.2 compatible (stock macOS): no mapfile, no associative arrays, no ${v,,}.
set -uo pipefail
BG_HOME="${BG_HOME:-$HOME/.claude/background-shell}"
REG="$BG_HOME/remote-sessions.json"
TARGETS="$BG_HOME/targets.json"
LOGS="$BG_HOME/logs"
# Deliberately an unexpanded tilde: this string is interpolated into heredocs that the
# REMOTE shell parses, so it must expand there, not here. Replacing it with $HOME expands
# locally and silently points every remote path at this Mac's home directory.
# shellcheck disable=SC2088
REMOTE_DIR='~/.cache/cc-bg'
GEOM_X=200
GEOM_Y=50
die() { printf 'bgsh: %s\n' "$*" >&2; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; }
init() {
mkdir -p "$BG_HOME" "$LOGS"
[ -f "$REG" ] || printf '[]\n' > "$REG"
# Starts empty on purpose: guessing someone else's hosts is worse than requiring --reach
# once. Add entries as "name": "<full command prefix that gets a shell on that target>",
# e.g. "buildbox": "ssh -o ConnectTimeout=8 -o BatchMode=yes user@buildbox.example.com"
[ -f "$TARGETS" ] || printf '{}\n' > "$TARGETS"
}
# Atomic registry write: never truncate on interrupt.
reg_write() { cat > "$REG.tmp" && mv "$REG.tmp" "$REG"; }
reach_for() { # target -> reach string
local t="$1" r
r=$(jq -r --arg t "$t" '.[$t] // empty' "$TARGETS")
[ -n "$r" ] || die "unknown target '$t' — add it to $TARGETS or pass --reach '<cmd>'"
printf '%s' "$r"
}
# A sentinel tag must identify the RUN, not the session: the pane keeps scrollback, so a
# completed sentinel from an earlier run still matches and `wait` would return that run's
# exit code immediately. The tag is recorded locally so both local and remote reads agree.
new_nonce() {
local n; n="${1}_$(date +%s)$$"
printf '%s' "$n" > "$LOGS/${1}.nonce"
printf '%s' "$n"
}
cur_nonce() { cat "$LOGS/${1}.nonce" 2>/dev/null; }
reg_reach() { jq -r --arg s "$1" '.[] | select(.session==$s) | .reach' "$REG" | head -1; }
reg_has() { [ -n "$(reg_reach "$1")" ]; }
# Run a script (stdin) on a remote target. The command is NEVER passed as a quoted
# argument — that is what makes the remote shell see a bare '#' and treat it as a comment,
# silently truncating everything after it.
remote_exec() { local reach="$1"; eval "$reach" bash -s; }
# ---------------------------------------------------------------- new
cmd_new() {
local target="" reach="" name="" purpose=""
while [ $# -gt 0 ]; do
case "$1" in
--on) target="$2"; shift 2 ;;
--reach) reach="$2"; shift 2 ;;
-*) die "unknown flag: $1" ;;
*) if [ -z "$name" ]; then name="$1"; else purpose="$purpose${purpose:+ }$1"; fi; shift ;;
esac
done
[ -n "$name" ] || die "usage: bgsh new [--on TARGET|--reach CMD] NAME \"purpose\""
[ -n "$purpose" ] || die "a purpose is required — an unlabelled session is the thing this exists to prevent"
case "$name" in cc-*) ;; *) name="cc-$name" ;; esac
# reach_for dies on an unknown target, but it is called in $( ) — a subshell — so its
# exit(1) would only kill the subshell and we would fall through to creating a LOCAL
# session under a name meant for a remote host. Propagate the failure explicitly.
if [ -z "$reach" ] && [ -n "$target" ]; then
reach=$(reach_for "$target") || exit 1
fi
if [ -z "$reach" ]; then
if tmux has-session -t "$name" 2>/dev/null; then
printf 'exists (local): %s :: %s\n' "$name" "$(tmux show-options -v -t "$name" @purpose 2>/dev/null)"
return 0
fi
tmux new-session -d -s "$name" -x "$GEOM_X" -y "$GEOM_Y" || die "could not create $name"
# window-size manual: without it, a human attaching permanently resizes the session
# and every later capture-pane wraps at their terminal width instead of $GEOM_X.
tmux set-option -t "$name" window-size manual >/dev/null
tmux set-option -t "$name" @purpose "$purpose" >/dev/null
tmux set-option -t "$name" status-right '#[bold] #{@purpose} ' >/dev/null
printf 'created (local): %s :: %s\n' "$name" "$purpose"
else
remote_exec "$reach" <<EOS || die "remote create failed on ${target:-$reach}"
mkdir -p $REMOTE_DIR
tmux has-session -t $name 2>/dev/null || {
tmux new-session -d -s $name -x $GEOM_X -y $GEOM_Y
tmux set-option -t $name window-size manual
tmux set-option -t $name @purpose '$purpose'
tmux set-option -t $name status-right '#[bold] #{@purpose} '
}
EOS
# Register in the SAME operation that created it. Split these and the registry drifts,
# which is the exact failure the registry exists to prevent.
jq --arg t "${target:-custom}" --arg r "$reach" --arg s "$name" --arg p "$purpose" \
--arg c "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'map(select(.session != $s)) + [{target:$t,reach:$r,session:$s,purpose:$p,created:$c}]' \
"$REG" | reg_write
printf 'created (%s): %s :: %s [registered]\n' "${target:-remote}" "$name" "$purpose"
fi
}
# ---------------------------------------------------------------- run / peek
#
# DEFAULT MODE IS TTY: the command is typed into the pane and its output stays there, so
# this drives a real terminal — a held ssh, a REPL, a TUI. `cd` and exported vars persist,
# which is the whole point of a session.
#
# `--log` is the ALTERNATE mode: stdout/stderr are redirected to a file for a one-shot
# payload you want to capture rather than watch. It is wrong for a held session — it hides
# output from the pane and strands the sentinel where `wait` (which greps the pane) cannot
# see it. Read tty output with `peek`, logged output with `out`.
cmd_run() {
local dry=0 log=0
while [ $# -gt 0 ]; do
case "$1" in
--dry) dry=1; shift ;;
--log) log=1; shift ;;
*) break ;;
esac
done
local name="${1:-}"; shift || true
local cmd="$*"
[ -n "$name" ] && [ -n "$cmd" ] || die "usage: bgsh run [--dry] [--log] NAME 'command'"
case "$name" in cc-*) ;; *) name="cc-$name" ;; esac
local reach; reach=$(reg_reach "$name")
# ---- tty mode (default) ----
if [ "$log" = 0 ]; then
[ -z "$reach" ] || die "$name is a REMOTE session — those are launch-and-walk-away; use --log. \
To drive a tty on another host, hold an ssh inside a LOCAL session instead."
tmux has-session -t "$name" 2>/dev/null || die "no such local session: $name (bgsh new first)"
# No ( ) wrap: cd and exports must persist across calls. A payload that calls `exit`
# therefore ends the session — in a held ssh that is how you leave, not a bug.
local nonce; nonce=$(new_nonce "$name")
local payload="$cmd; printf '\\n__BG_${nonce}_RC=%s\\n' \"\$?\""
if [ "$dry" = 1 ]; then printf 'would send-keys -t %s:\n %s\n' "$name" "$payload"; return 0; fi
tmux send-keys -t "$name" "$payload" Enter
printf 'sent (tty): %s — read with: bgsh peek %s\n' "$name" "$name"
return 0
fi
# ---- --log mode ----
if [ -z "$reach" ]; then
tmux has-session -t "$name" 2>/dev/null || die "no such local session: $name (bgsh new first)"
# --log redirects using a LOCAL path, but the redirect is performed by whatever shell the
# pane is currently running. If the pane is holding an ssh, that path is resolved on the
# REMOTE host, where it does not exist — the payload fails silently.
case "$(tmux display-message -p -t "$name" '#{pane_current_command}' 2>/dev/null)" in
ssh*|mosh*|telnet|kubectl|docker|autossh) # ssh* also covers sshpass
die "$name is holding an $(tmux display-message -p -t "$name" '#{pane_current_command}') \
session, so --log would redirect to a local path on the REMOTE host and fail silently. \
Use tty mode (drop --log) and read with: bgsh peek $name" ;;
esac
local out="$LOGS/$name.out"
# ( ) subshell here because a logged payload is one-shot: a bare `exit` would otherwise
# kill the session's own shell and strand the sentinel forever.
local nonce; nonce=$(new_nonce "$name")
local payload="( $cmd ) > '$out' 2>&1; printf '\\n__BG_${nonce}_RC=%s\\n' \"\$?\""
if [ "$dry" = 1 ]; then printf 'would send-keys -t %s:\n %s\n' "$name" "$payload"; return 0; fi
tmux send-keys -t "$name" "$payload" Enter
printf 'sent (log): %s -> %s\n' "$name" "$out"
else
if [ "$dry" = 1 ]; then printf 'would run on %s in %s:\n ( %s )\n' "$reach" "$name" "$cmd"; return 0; fi
# Write a script and send only its PATH. Inline commands cross ssh -> send-keys ->
# shell (three quoting layers); "$?" written inline arrives as the literal text $?.
local nonce; nonce=$(new_nonce "$name")
remote_exec "$reach" <<EOS || die "remote run failed"
mkdir -p $REMOTE_DIR
cat > $REMOTE_DIR/$name.sh <<'SCRIPT'
#!/usr/bin/env bash
( $cmd ) > $REMOTE_DIR/$name.out 2>&1
printf '\n__BG_${nonce}_RC=%s\n' "\$?"
SCRIPT
chmod +x $REMOTE_DIR/$name.sh
tmux send-keys -t $name '$REMOTE_DIR/$name.sh' Enter
EOS
printf 'sent (log, %s): %s -> %s/%s.out\n' "$reach" "$name" "$REMOTE_DIR" "$name"
fi
}
cmd_peek() {
local name="${1:-}" lines="${2:-40}"
[ -n "$name" ] || die "usage: bgsh peek NAME [lines]"
case "$name" in cc-*) ;; *) name="cc-$name" ;; esac
local reach; reach=$(reg_reach "$name")
# Without this, peeking a REMOTE session reads local tmux, finds nothing, and exits 0 —
# indistinguishable from "the job produced no output".
if [ -n "$reach" ]; then
remote_exec "$reach" <<EOS 2>/dev/null | grep -vE '^$|__BG_.*_RC=' | tail -n "$lines"
tmux capture-pane -pJ -S -5000 -t $name 2>/dev/null
EOS
return 0
fi
tmux has-session -t "$name" 2>/dev/null || die "no such local session: $name"
tmux capture-pane -pJ -S -5000 -t "$name" 2>/dev/null \
| grep -vE '^$|__BG_.*_RC=' | tail -n "$lines"
}
# ---------------------------------------------------------------- wait
# Sentinel grep REQUIRES digits: the pane also contains the echoed command line, which
# literally includes __BG_<name>_RC=%s. [0-9]* matches that and reports done mid-run.
sentinel_local() {
local n; n=$(cur_nonce "$1"); [ -n "$n" ] || return 0
tmux capture-pane -pJ -S -2000 -t "$1" 2>/dev/null | grep -oE "__BG_${n}_RC=[0-9]+" | tail -1
}
sentinel_remote() {
local n; n=$(cur_nonce "$1"); [ -n "$n" ] || return 0
remote_exec "$2" <<EOS 2>/dev/null
tmux capture-pane -pJ -S -2000 -t $1 2>/dev/null | grep -oE '__BG_${n}_RC=[0-9]+' | tail -1
EOS
}
cmd_wait() {
local name="${1:-}" timeout="${2:-300}"
[ -n "$name" ] || die "usage: bgsh wait NAME [timeout_seconds]"
case "$name" in cc-*) ;; *) name="cc-$name" ;; esac
local reach; reach=$(reg_reach "$name")
local waited=0 hit=""
while [ "$waited" -lt "$timeout" ]; do
if [ -z "$reach" ]; then
tmux has-session -t "$name" 2>/dev/null || die "session $name is GONE (payload may have exited the shell)"
hit=$(sentinel_local "$name")
else
hit=$(sentinel_remote "$name" "$reach")
fi
[ -n "$hit" ] && { printf '%s\n' "$hit"; return "${hit##*=}"; }
sleep 3; waited=$((waited + 3))
done
printf 'bgsh: no sentinel after %ss — still running, hung, or never started\n' "$timeout" >&2
[ -z "$reach" ] && printf ' pane is running: %s\n' \
"$(tmux display-message -p -t "$name" '#{pane_current_command}' 2>/dev/null)" >&2
return 124
}
# ---------------------------------------------------------------- out
cmd_out() {
local name="${1:-}"; [ -n "$name" ] || die "usage: bgsh out NAME"
case "$name" in cc-*) ;; *) name="cc-$name" ;; esac
local reach; reach=$(reg_reach "$name")
if [ -z "$reach" ]; then
[ -f "$LOGS/$name.out" ] || die "no output yet for $name"
cat "$LOGS/$name.out"
else
remote_exec "$reach" <<EOS
cat $REMOTE_DIR/$name.out 2>/dev/null || echo "bgsh: no output yet for $name" >&2
EOS
fi
}
# ---------------------------------------------------------------- ls / remote-ls
cmd_ls() {
printf '== local ==\n'
tmux ls -F '#{session_name} started=#{t:session_created} cmd=#{pane_current_command} :: #{@purpose}' 2>/dev/null \
|| printf '(none)\n'
local n; n=$(jq 'length' "$REG")
[ "$n" -gt 0 ] && { printf '\n'; cmd_remote_ls; }
return 0
}
cmd_remote_ls() {
printf '== remote (registry: %s) ==\n' "$REG"
local n; n=$(jq 'length' "$REG")
[ "$n" -eq 0 ] && { printf '(none registered)\n'; return 0; }
# Read into an array FIRST. Calling ssh inside `while read` consumes the loop's stdin
# and silently checks only the first entry.
local entries=() line
while IFS= read -r line; do entries+=("$line"); done \
< <(jq -r '.[] | [.target,.reach,.session] | @tsv' "$REG")
local e target reach session out
for e in "${entries[@]}"; do
IFS=$(printf '\t') read -r target reach session <<< "$e"
out=$(remote_exec "$reach" <<EOS 2>/dev/null
tmux ls -F '#{session_name}|#{pane_current_command}|#{@purpose}' 2>/dev/null | grep "^$session|"
EOS
)
if [ -n "$out" ]; then printf 'LIVE %-8s %s\n' "$target" "$out"
elif remote_exec "$reach" <<< 'echo ok' >/dev/null 2>&1; then
printf 'STALE %-8s %s (host reachable, session gone -> bgsh prune)\n' "$target" "$session"
else
printf 'UNSURE %-8s %s (host unreachable — NOT evidence it ended; check ssh-add -l)\n' "$target" "$session"
fi
done
}
# ---------------------------------------------------------------- prune / kill
cmd_prune() {
local entries=() line
while IFS= read -r line; do entries+=("$line"); done \
< <(jq -r '.[] | [.target,.reach,.session] | @tsv' "$REG")
[ "${#entries[@]}" -eq 0 ] && { printf 'registry empty\n'; return 0; }
local e target reach session out
for e in "${entries[@]}"; do
IFS=$(printf '\t') read -r target reach session <<< "$e"
out=$(remote_exec "$reach" <<EOS 2>/dev/null
tmux has-session -t $session 2>/dev/null && echo LIVE
EOS
)
if [ -n "$out" ]; then printf 'keep %s (live)\n' "$session"; continue; fi
# Only prune on a VERIFIED stale. An unreachable host is not evidence the session
# ended, and dropping the entry loses the only pointer to something still running.
if remote_exec "$reach" <<< 'echo ok' >/dev/null 2>&1; then
jq --arg s "$session" 'map(select(.session != $s))' "$REG" | reg_write
printf 'prune %s (verified gone)\n' "$session"
else
printf 'keep %s (host unreachable — refusing to prune on no evidence)\n' "$session"
fi
done
}
cmd_kill() {
local name="${1:-}"; [ -n "$name" ] || die "usage: bgsh kill NAME"
case "$name" in cc-*) ;; *) name="cc-$name" ;; esac
local reach; reach=$(reg_reach "$name")
if [ -z "$reach" ]; then
tmux kill-session -t "$name" 2>/dev/null && printf 'killed (local): %s\n' "$name" || printf 'no local session: %s\n' "$name"
rm -f "$LOGS/$name.out"
else
# Deregister ONLY if the host actually answered. Dropping the entry after an
# unreachable kill loses the only pointer to a session that may still be running —
# the exact failure `prune` refuses to make.
if remote_exec "$reach" <<EOS
tmux kill-session -t $name 2>/dev/null && echo "killed (remote): $name" || echo "no remote session: $name"
rm -f $REMOTE_DIR/$name.sh $REMOTE_DIR/$name.out
EOS
then
jq --arg s "$name" 'map(select(.session != $s))' "$REG" | reg_write
printf 'deregistered: %s\n' "$name"
else
printf 'bgsh: %s unreachable — session NOT killed and entry KEPT (retry later)\n' "$reach" >&2
return 1
fi
fi
}
cmd_attach() {
local name="${1:-}"; [ -n "$name" ] || die "usage: bgsh attach NAME"
case "$name" in cc-*) ;; *) name="cc-$name" ;; esac
local reach; reach=$(reg_reach "$name")
printf 'Run this yourself (bgsh cannot attach for you):\n'
if [ -z "$reach" ]; then printf ' tmux attach -t %s # ^b d to detach\n' "$name"
else printf ' %s -t "tmux attach -t %s"\n' "$reach" "$name"; fi
}
usage() {
cat <<'EOF'
bgsh — background shell sessions (tmux), local and remote
bgsh ls local + registry roll call
bgsh new [--on TGT|--reach CMD] NAME "purpose"
bgsh run [--dry] NAME 'command' TTY (default): output stays in the pane, cd persists
bgsh run --log NAME 'command' alternate: capture to a file instead (one-shot)
bgsh peek NAME [lines] read the pane (tty mode)
bgsh wait NAME [timeout=300] blocks; exits with the payload's exit code
bgsh out NAME read the --log file
bgsh attach NAME prints the attach command
bgsh remote-ls verify every registry entry
bgsh prune drop only VERIFIED-stale entries
bgsh kill NAME kill + deregister + remove logs
Remote sessions are registered automatically by `new --on`. Local ones are not — tmux ls
already finds those. Guardrail: host mutation (config-management apply, sudo, /etc) stays on plain
Bash; a session is not a way around a permission prompt.
EOF
}
need tmux; need jq; init
case "${1:-}" in
ls) shift; cmd_ls "$@" ;;
new) shift; cmd_new "$@" ;;
run) shift; cmd_run "$@" ;;
send) shift; cmd_run "$@" ;;
peek) shift; cmd_peek "$@" ;;
wait) shift; cmd_wait "$@" ;;
out) shift; cmd_out "$@" ;;
attach) shift; cmd_attach "$@" ;;
remote-ls) shift; cmd_remote_ls "$@" ;;
prune) shift; cmd_prune "$@" ;;
kill) shift; cmd_kill "$@" ;;
""|-h|--help|help) usage ;;
*) die "unknown subcommand: $1 (try: bgsh help)" ;;
esac