From dde11f3170e4eda95a0dab6aea2c9fde52362d59 Mon Sep 17 00:00:00 2001 From: movq Date: Tue, 28 Jul 2026 18:25:21 -0500 Subject: [PATCH] feat(background-shell): add persistent tmux session plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .claude-plugin/marketplace.json | 13 +- background-shell/.claude-plugin/plugin.json | 9 + background-shell/bin/bgsh | 1 + .../skills/background-shell/SKILL.md | 222 ++++++++++ .../references/remote-sessions.md | 138 ++++++ .../skills/background-shell/scripts/bgsh | 405 ++++++++++++++++++ 6 files changed, 784 insertions(+), 4 deletions(-) create mode 100644 background-shell/.claude-plugin/plugin.json create mode 120000 background-shell/bin/bgsh create mode 100644 background-shell/skills/background-shell/SKILL.md create mode 100644 background-shell/skills/background-shell/references/remote-sessions.md create mode 100755 background-shell/skills/background-shell/scripts/bgsh diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3e5ce3f..162fe50 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -13,22 +13,27 @@ { "name": "genealogy", "source": "./genealogy", - "description": "Genealogy research methodology — GPS evidence evaluation, FAN workflows, research journal, claim promotion, and transcription." + "description": "Genealogy research methodology \u2014 GPS evidence evaluation, FAN workflows, research journal, claim promotion, and transcription." }, { "name": "composer", "source": "./composer", - "description": "Discovery-phase companion that walks a project from \"I want an app that…\" to a tree of stories in the issue tracker. Pairs with symphony." + "description": "Discovery-phase companion that walks a project from \"I want an app that\u2026\" to a tree of stories in the issue tracker. Pairs with symphony." }, { "name": "symphony", "source": "./symphony", - "description": "Tracker-agnostic orchestrator daemon — polls the issue tracker, dispatches isolated worker agents per issue, reconciles state. Pairs with composer." + "description": "Tracker-agnostic orchestrator daemon \u2014 polls the issue tracker, dispatches isolated worker agents per issue, reconciles state. Pairs with composer." }, { "name": "fiction-assistance", "source": "./fiction-assistance", - "description": "General-purpose fiction-writing guidance and analysis — proofreading, consistency and believability, story stats, representation tests, developmental and strategic review, world-building extraction, timeline construction, final-review synthesis. Reports only; never edits." + "description": "General-purpose fiction-writing guidance and analysis \u2014 proofreading, consistency and believability, story stats, representation tests, developmental and strategic review, world-building extraction, timeline construction, final-review synthesis. Reports only; never edits." + }, + { + "name": "background-shell", + "source": "./background-shell", + "description": "Persistent named tmux sessions for work ordinary tool calls cannot hold: interactive prompts, REPLs, TUIs, and jobs that must outlive an SSH connection. Ships the bgsh helper plus a registry that keeps remote sessions findable." } ] } diff --git a/background-shell/.claude-plugin/plugin.json b/background-shell/.claude-plugin/plugin.json new file mode 100644 index 0000000..e7b0825 --- /dev/null +++ b/background-shell/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "background-shell", + "version": "1.0.0", + "description": "Persistent named tmux sessions for work that ordinary tool calls cannot hold: interactive prompts, REPLs, TUIs, and jobs that must outlive an SSH connection. Ships the bgsh helper plus a registry that keeps remote sessions findable from the machine that started them.", + "author": { + "name": "Adam Knight" + }, + "keywords": ["tmux", "shell", "ssh", "session", "remote", "interactive", "tty"] +} diff --git a/background-shell/bin/bgsh b/background-shell/bin/bgsh new file mode 120000 index 0000000..9dec6f5 --- /dev/null +++ b/background-shell/bin/bgsh @@ -0,0 +1 @@ +../skills/background-shell/scripts/bgsh \ No newline at end of file diff --git a/background-shell/skills/background-shell/SKILL.md b/background-shell/skills/background-shell/SKILL.md new file mode 100644 index 0000000..74eab48 --- /dev/null +++ b/background-shell/skills/background-shell/SKILL.md @@ -0,0 +1,222 @@ +--- +name: background-shell +description: >- + Last resort for shell work that ordinary tool calls cannot hold. Runs commands in a + persistent, named tmux session — local or on a remote host — via the `bgsh` helper. Use + ONLY when a command prompts interactively or a REPL must stay alive; when driving or + screenshotting a full-screen TUI; when work must outlive the SSH connection; or when a + Bash call already hung on an unexpected prompt or timed out half-done. Do NOT use for + everyday work — one-shot commands, file edits, greps and builds belong in + Bash/Read/Edit/Grep, which are faster and properly permission-gated. For a merely long + job, prefer a backgrounded Bash call. If a plain Bash call would work, use Bash. +--- + +# Background shell + +A named tmux session is a shell that outlives the tool call. `bgsh` handles the mechanics; +this file covers the judgment it can't encode. + +**Requires `tmux` and `jq` locally, and `tmux` plus `bash` on any remote target.** + +## Do not reach for this first + +The built-in tools already give a persistent working directory and full output capture, and +a backgrounded `Bash` call already handles long jobs. tmux costs you permission-gating, +clean output, and simplicity. + +**If you can't name which trigger applies, use `Bash`.** + +## Guardrail: the wrapper does not launder the payload + +A session is not a privilege escape hatch. Neither `tmux send-keys 'sudo …'` nor +`bgsh run cc-x 'sudo …'` literally matches a `Bash(sudo …)` permission rule, but both are +exactly as gated in intent. + +**Host mutation stays on plain `Bash`** — config-management applies (ansible/pyinfra/salt), +ad-hoc `sudo`, writes to `/etc` or systemd units, destructive `rm`. Judge the payload, not +the wrapper. If it would need approval typed directly, it needs approval typed into a pane. + +If your harness supports classifier-style rules, add one stating that a wrapped payload is +judged as the payload; literal string rules cannot see a payload that arrives as a script +path. + +## bgsh + +Installed on `PATH` by the plugin — just call `bgsh`. (If you copied this skill outside a +plugin, run the `scripts/bgsh` next to this file.) + +```bash +bgsh ls # local + registry roll call — ALWAYS do this first +bgsh new cc-logs "nginx 502 triage" +bgsh run cc-logs 'tail -n 200 error.log' # TTY (default): output stays in the pane, cd persists +bgsh peek cc-logs [lines] # read the pane — works for local and remote +bgsh run --log cc-logs 'long-thing' # ALTERNATE: capture to a file instead (one-shot) +bgsh out cc-logs # read the --log file +bgsh wait cc-logs [timeout] # blocks; exits WITH the payload's exit code +bgsh attach cc-logs # prints the attach command for the human +bgsh kill cc-logs # kill + deregister + remove logs + +bgsh new --on buildbox cc-build "nightly build" # remote: creates AND registers +bgsh run --log cc-build 'make all' # remote sessions are --log only (see below) +bgsh out cc-build +bgsh remote-ls # verify every registry entry against its host +bgsh prune # drop ONLY verified-stale entries +``` + +**Names are prefixed `cc-` automatically.** `bgsh new build "…"` creates `cc-build`; attach +with `tmux attach -t cc-build`, not `-t build`. + +State lives in `~/.claude/background-shell/` (`targets.json`, `remote-sessions.json`, +`logs/`); override the whole directory with `BG_HOME`. + +**TTY is the default mode** — the command is typed into the pane and its output stays +there, so `cd`, exported vars, and a held `ssh` all persist across calls. That is the point +of a session. `--log` is the alternate for a one-shot payload you want captured rather than +watched; it wraps in `( )` and redirects to a file. + +**Remote sessions are `--log` only.** tty mode is refused for them, because a remote pane's +output cannot be correlated the same way — use `bgsh run --log` then `bgsh out`. `bgsh peek` +works on both and shows the raw pane. + +> [!warning] `--log` on a pane that is holding an `ssh` writes to the remote host. +> The redirect uses a local path but is performed by whatever shell the pane is running, so +> it resolves on the far side and fails silently. `bgsh` refuses the common cases (`ssh`, +> `mosh`, `telnet`, `kubectl`, `docker`, `autossh`) — use tty mode and `bgsh peek`. + +> [!warning] `bgsh run` sends **shell** syntax — it cannot drive a REPL. +> The sentinel it appends (`; printf …`) is a shell command. Sent into a `python` or `node` +> pane it is a syntax error and your command never runs. For a REPL, use raw +> `tmux send-keys -t cc-name 'expr' Enter` and read with `bgsh peek`; there is no exit code +> and `bgsh wait` will not work. + +`bgsh run --dry` prints the exact `send-keys` it would issue. The script is a *constructor, +not a proxy* — it builds real tmux sessions you inspect with real `tmux ls`, and `@purpose` +on the session stays the source of truth. + +Targets live in `~/.claude/background-shell/targets.json`, which starts empty. Add your +hosts there once rather than repeating `--reach`: + +```json +{ "buildbox": "ssh -o ConnectTimeout=8 -o BatchMode=yes user@buildbox.example.com" } +``` + +**Start every session's background-shell work with `bgsh ls`.** A prior conversation may +have left sessions running; they are invisible state that outlived the context that +understood them, and this session is the one that has to clean up. `cmd=` distinguishes busy +from idle: a real command name means something is running, a bare `zsh`/`bash` means it +finished or never started. + +If `bgsh wait` reports **"session is GONE"**, the payload exited the session's own shell +(tty mode does not subshell-wrap, so `exit` really exits). The output is lost with the pane: +re-create with `bgsh new` and re-run, wrapping the payload in `( )` or using `--log`. + +## Where the session lives — default is LOCAL + +**Run tmux on this machine and let the pane hold one long-lived `ssh`.** Do not open a new +SSH connection per tool call: each one is a fresh handshake and, worse, a fresh shell, so no +remote state survives between calls. + +```bash +bgsh new cc-web1 "ssh to web1 — log triage" +bgsh run cc-web1 'ssh user@web1.example.com' # one handshake; cwd and env now persist +``` + +Put tmux **on the remote host** only when the job must survive the connection itself dying: + +| Session lives | When | +|---|---| +| **Local** (default) | Interactive work, REPLs, TUI development, and *any* remote work you are actively driving — one `ssh` inside the pane. | +| **Remote** (`--on`) | A long job you launch and check back on later, or a detached agent. The network dropping must not kill it. Auto-registered. | + +The tell: **if you're going to sit with it, keep tmux local; if you're going to walk away +from it, put tmux on the far end.** If the job is merely long with nobody attached and +nothing to survive, a backgrounded `Bash` call beats both. + +Remote sessions are tracked in a local registry so they stay findable — +[references/remote-sessions.md](references/remote-sessions.md) documents the contract, +including why the registry stores *where to look* and never *what is running*. + +## Credential hygiene + +Interactive auth invites passwords and 2FA into a pane. Typed secrets aren't echoed, so the +pane is mostly fine — but do **not** `tmux pipe-pane` a session doing interactive auth. A +transcript log is a durable artifact, as is the remote shell history. `bgsh kill` removes +its own logs; anything you created by hand is yours to delete. + +## Driving a headless agent on a remote host + +For diagnostics that must run *on* the box. Check that `tmux` and the agent CLI are actually +installed there first — don't assume. + +**Headless, not the TUI.** `claude -p "…" --output-format json` writes parseable output; +scraping an interactive TUI means parsing a redrawing screen. + +A quick diagnostic (~10 s) needs no remote tmux — run it through the `ssh` already alive in a +local pane, or a plain `Bash` call. Use `bgsh new --on ` when the agent will run long +enough that you'd rather not hold the connection open. + +```bash +mkdir -p ~/.cache/cc-bg +claude -p "$PROMPT" \ + --append-system-prompt "You are a remote diagnostic sub-agent invoked over SSH by a Claude Code session on another machine. You are read-mostly: investigate and report findings; do not mutate host state. You have no privileges a local session lacks." \ + --output-format json > ~/.cache/cc-bg/agent.json 2> ~/.cache/cc-bg/agent.err +``` + +Send stderr to its **own** file — `2>&1` puts warnings inside the JSON you're about to parse. +Read `.result` for the reply, `.session_id` for the resume handle, and `.permission_denials` +to see whether it was blocked. Continue with `claude -p "…" --resume `; that is +where statefulness lives, not in the pane. Clean up `~/.cache/cc-bg` yourself — `bgsh kill` +does not know about these files. + +The sub-agent is **not** more privileged than a local session — whatever guardrails that +host enforces still apply to it. Do not use a remote agent to get a change applied without +review. + +## The human can attach + +`bgsh attach NAME` prints the command. This is the main reason to prefer tmux over a hidden +background process: the user can watch and take the wheel in the same session. When a +session is doing something long or interactive, **tell them the name** so they can look. +`bgsh new` sets the status bar to the session's purpose, so an attaching human sees +immediately what they walked into. They will also see `__BG_…_RC=0` lines after each command +— that is the exit-code sentinel, not an error. + +## Why bgsh does it this way — do not "simplify" these + +Every item below is a real failure hit while building this, and **every one produced output +identical to a clean negative.** They are why the script exists. Each guard is also +commented at its edit site in `bgsh`; keep the two in sync. + +| Guard in `bgsh` | The failure it prevents | +|---|---| +| `set-option window-size manual` | The default is `latest`: a human attaching permanently resizes the session, so every later `capture-pane` wraps at *their* terminal width. | +| `capture-pane -pJ` | Without `-J`, long lines are split at the pane width mid-token. | +| `grep -oE '…_RC=[0-9]+'` | The pane also holds the *echoed command*, which contains `…_RC=%s`. `[0-9]*` matches that and reports done mid-run. `-J` makes it worse by reassembling that line. | +| Sentinel tag is per-**run**, not per-session | Scrollback keeps earlier sentinels; a session-wide tag makes `wait` return the *previous* run's exit code immediately. | +| tty mode is the default | Redirecting a held session's stdout to a file blinds the pane and strands the sentinel where `wait` cannot see it. | +| `--log` refuses an ssh-holding pane | The redirect is performed by the pane's current shell, so a local path resolves on the remote host and fails silently. | +| `--log` payload wrapped in `( )` | A bare `exit` — or a `{ …; exit 1; }` brace group — exits the session's **own** shell and destroys the session, stranding the sentinel. | +| `peek` follows a registered session to its host | Reading local tmux for a remote session returns empty and exits 0 — indistinguishable from "no output". | +| `kill` deregisters only on a reachable host | Otherwise an unreachable kill drops the only pointer to a session that may still be running. | +| Remote payload written to a script, path sent | `ssh` → `send-keys` → shell is three quoting layers; `"$?"` written inline arrives as the literal text `$?`. | +| `mkdir -p` + `set -euo pipefail` remotely | Otherwise a failed setup step still exits 0, nothing runs, and no sentinel ever appears. | +| Remote command on **stdin**, never a quoted `eval` arg | `eval` strips the quotes locally, so the remote shell sees a bare `#{session_name}` and treats `#` as a **comment**, truncating everything after it. Reports every session STALE. | +| Registry read into an array before looping | `ssh` inside `while read` eats the loop's stdin — only the first entry is ever checked. | +| Unknown `--on` target aborts | `reach_for` dies inside `$( )`, a subshell — without explicit propagation it falls through and creates a *local* session under a remote name. | +| Written in **bash**, not zsh | zsh does not word-split unquoted variables; a `$SSH_CMD` holding a multi-word command fails as "no such file or directory". | +| `has-session` guard before create | `new-session` on an existing name errors `duplicate session`. | +| `prune` requires a *verified* stale | An unreachable host is not evidence a session ended; dropping the entry loses the only pointer to something still running. | + +## Check the agent before believing a failure + +If a remote step fails with `communication with agent failed` or `Permission denied +(publickey)`, run `ssh-add -l` before concluding anything about the host. SSH agents +(1Password, gpg-agent, Secretive, or a plain `ssh-agent` that has been restarted) routinely +drop identities — and a dead agent makes every registry entry look STALE. Fallback: + +```bash +ssh -o IdentityAgent=none -i ~/.ssh/ user@host … +``` + +`bgsh remote-ls` reports **UNSURE** rather than STALE when a host is unreachable, precisely +so this never gets mistaken for a real negative. diff --git a/background-shell/skills/background-shell/references/remote-sessions.md b/background-shell/skills/background-shell/references/remote-sessions.md new file mode 100644 index 0000000..3ef9a3c --- /dev/null +++ b/background-shell/skills/background-shell/references/remote-sessions.md @@ -0,0 +1,138 @@ +# Remote session registry + +> [!note] `bgsh` does all of this for you. +> `bgsh new --on TARGET` registers, `bgsh kill` deregisters, `bgsh remote-ls` verifies, and +> `bgsh prune` drops only verified-stale entries. This file is the **contract** — the schema, +> the invariants, and the raw `jq` if you ever need to repair the registry by hand. + +Local accounting for tmux sessions left running **on other machines** — build boxes, cluster +nodes, a container, anywhere `tmux ls` on this machine cannot see. + +## What this is, and what it deliberately is not + +**It is a registry of *where to look*. It is not a record of what is running.** + +`@purpose` on the session itself stays authoritative for what a session is doing — it has +the session's exact lifetime and cannot go stale. This file only answers "which targets +might be holding my sessions," which is the one question live discovery genuinely cannot +answer from here. + +That split is what keeps it honest: **a wrong entry costs one wasted `ssh`, never a wrong +belief.** Every read verifies against the host before reporting anything. + +Local-only sessions are **not** registered. `tmux ls` already finds those. + +## Location and shape + +`~/.claude/background-shell/remote-sessions.json` — user-level global state, not per-project. +A remote session outlives the conversation and the repo that started it. + +```json +[ + { + "target": "buildbox", + "reach": "ssh -o ConnectTimeout=8 -o BatchMode=yes user@buildbox.example.com", + "session": "cc-build", + "purpose": "watching the nightly build", + "created": "2026-01-15T20:55:42Z" + } +] +``` + +`reach` is the **full command prefix that gets a shell on that target**, stored verbatim. +This is the field that earns the file: reconnecting may need a jump host, a non-default key, +or a container exec, and that is exactly what you will not remember later. + +```jsonc +"reach": "ssh user@web1.example.com" // plain +"reach": "ssh -J bastion.example.com user@db1.internal" // jump host +"reach": "ssh user@dockerhost.example.com docker exec -i myservice" // inside a container +"reach": "ssh -o IdentityAgent=none -i ~/.ssh/ user@web1.example.com" // agent dropped keys +``` + +No initialization step: `bgsh` creates the file idempotently on every invocation. Do **not** +`echo '[]' >` it by hand — that truncates a populated registry. + +## Register — in the same block that creates the remote session + +```bash +REG=~/.claude/background-shell/remote-sessions.json +jq --arg t buildbox \ + --arg r 'ssh user@buildbox.example.com' \ + --arg s cc-build \ + --arg p 'watching the nightly build' \ + --arg c "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '. += [{target:$t, reach:$r, session:$s, purpose:$p, created:$c}]' \ + "$REG" > "$REG.tmp" && mv "$REG.tmp" "$REG" +``` + +Write via `.tmp` + `mv` so an interrupted write cannot truncate the registry. + +## Roll call — verify, never trust + +```bash +REG=~/.claude/background-shell/remote-sessions.json +entries=(); while IFS= read -r l; do entries+=("$l"); done \ + < <(jq -r '.[] | [.target,.reach,.session] | @tsv' "$REG") + +for e in "${entries[@]}"; do + IFS=$'\t' read -r target reach session <<< "$e" + out=$(eval "$reach" bash -s </dev/null +tmux ls -F '#{session_name}|#{pane_current_command}|#{@purpose}' 2>/dev/null | grep "^$session|" +REMOTE +) + if [ -n "$out" ]; then printf 'LIVE %-10s %s\n' "$target" "$out" + elif eval "$reach" true 2>/dev/null; then + printf 'STALE %-10s %s\n' "$target" "$session" + else + printf 'UNSURE %-10s %s (host unreachable — NOT evidence it ended)\n' "$target" "$session" + fi +done +``` + +> [!important] Three states, not two. +> An empty result means "not found **or** could not ask". Collapsing that to STALE is how a +> down host, a wrong network, or a dead SSH agent gets a live session pruned. `bgsh +> remote-ls` implements exactly this; the loop above is only for hand repair. + +`LIVE` lines carry the session's own `@purpose` — read from the session, not from this file, +so the two can never silently disagree. + +> [!warning] Two bugs this loop is shaped to avoid — both were hit while writing it. +> +> **1. Do not put the remote command in `eval` as a quoted argument.** `eval` strips the +> quotes locally, so the remote shell receives a bare `#{session_name}` — and `#` starts a +> **comment**, silently truncating the command. Every session then reports STALE while +> plainly alive. Send the script on **stdin** (`bash -s < +> **2. Do not let `ssh` eat the loop's stdin.** Piping `jq` straight into `while read` +> and calling `ssh` inside consumes the remaining entries — only the first is ever checked. +> Read into an array first (as above), or use `ssh -n`. +> +> Both failures look exactly like a clean negative result. A dead SSH agent produces a third +> indistinguishable false STALE — see the skill's "Check the agent before believing a +> failure" section. + +## Deregister — when you kill the session + +```bash +REG=~/.claude/background-shell/remote-sessions.json +jq --arg s cc-build 'map(select(.session != $s))' "$REG" > "$REG.tmp" && mv "$REG.tmp" "$REG" +``` + +## Prune + +Anything the roll call reports `STALE` is gone from the host; drop it with the same +deregister filter. Prune **only** on a verified STALE — an unreachable host (down, on the +wrong network, dead SSH agent) is not evidence the session ended, and dropping the entry +loses the only pointer to a session that is still burning resources. + +## Lifecycle + +| When | Do | +|---|---| +| Creating a session on a remote target | Register it, same command block | +| Start of any background-shell work | Roll call, adopt or kill what you find | +| Killing a remote session | Deregister | +| Roll call says STALE | Prune | +| Host unreachable | Leave the entry alone, say so, retry later | diff --git a/background-shell/skills/background-shell/scripts/bgsh b/background-shell/skills/background-shell/scripts/bgsh new file mode 100755 index 0000000..307dc43 --- /dev/null +++ b/background-shell/skills/background-shell/scripts/bgsh @@ -0,0 +1,405 @@ +#!/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": "", + # 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 ''" + 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" </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" < $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" </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__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" </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" </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" </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" </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" </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