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:
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
9
background-shell/.claude-plugin/plugin.json
Normal file
9
background-shell/.claude-plugin/plugin.json
Normal 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
1
background-shell/bin/bgsh
Symbolic link
@@ -0,0 +1 @@
|
||||
../skills/background-shell/scripts/bgsh
|
||||
222
background-shell/skills/background-shell/SKILL.md
Normal file
222
background-shell/skills/background-shell/SKILL.md
Normal 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.
|
||||
@@ -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 |
|
||||
405
background-shell/skills/background-shell/scripts/bgsh
Executable file
405
background-shell/skills/background-shell/scripts/bgsh
Executable 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
|
||||
Reference in New Issue
Block a user