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>
This commit is contained in:
movq
2026-07-28 18:25:21 -05:00
parent 187863fa16
commit dde11f3170
6 changed files with 784 additions and 4 deletions

View File

@@ -13,22 +13,27 @@
{ {
"name": "genealogy", "name": "genealogy",
"source": "./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", "name": "composer",
"source": "./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", "name": "symphony",
"source": "./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", "name": "fiction-assistance",
"source": "./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."
} }
] ]
} }

View File

@@ -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"]
}

1
background-shell/bin/bgsh Symbolic link
View File

@@ -0,0 +1 @@
../skills/background-shell/scripts/bgsh

View File

@@ -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 <host>` 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 <session_id>`; 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/<key> 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.

View File

@@ -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/<key> 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 <<REMOTE 2>/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 <<REMOTE`) so it is parsed once.
>
> **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 |

View File

@@ -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": "<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