docs: name bot-bottle, not ./cli.py, in every instruction
prd-number-check / require-numbered-prds (pull_request) Successful in 11s
tracker-policy-pr / check-pr (pull_request) Successful in 14s
test / unit (pull_request) Successful in 59s
test / integration-docker (pull_request) Successful in 1m6s
test / coverage (push) Successful in 21s
test / image-input-builds (push) Successful in 44s
test / image-input-builds (pull_request) Successful in 1m15s
test / unit (push) Successful in 57s
test / coverage (pull_request) Successful in 20s
lint / lint (push) Successful in 1m3s
Update Quality Badges / update-badges (push) Successful in 1m12s
test / integration-docker (push) Successful in 1m0s

`doctor` told users to run `./cli.py backend setup`. cli.py is a four-line
wrapper at the repo root that calls bot_bottle.cli:main, and it does not ship —
[tool.setuptools.packages.find] includes only bot_bottle*, so anyone who
installed rather than cloned has no such file. Confirmed against a real
install: the user's tree contains exactly one executable, `bot-bottle`, and no
cli.py anywhere, while doctor recommended `./cli.py` three times. Invisible in
development, where ./cli.py works fine from a checkout, which is why only a
clean-install test surfaced it.

The two entry points are the same code, so the fix is to name the one that
always exists. 141 replacements across 45 files: the runtime messages that
caused this, plus README, docs, PRDs, research notes and test prose, so nothing
teaches the invocation a user cannot run.

scripts/demo.sh is deliberately untouched — it *executes* ./cli.py from a
checkout, where that is the correct and available path.

Verified end to end: a sandbox install now reports
"Run: bot-bottle backend setup --backend=firecracker", and bot-bottle is on
that user's PATH. Unit suite unchanged against the pre-existing baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
This commit was merged in pull request #529.
This commit is contained in:
2026-07-27 10:20:17 -04:00
parent 5c499d290b
commit cd9f023f3d
45 changed files with 135 additions and 135 deletions
+7 -7
View File
@@ -15,7 +15,7 @@
## Features ## Features
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist; per-route path/method/header `matches` filtering; outbound DLP scanning for known tokens and secrets, inbound DLP scanning for prompt-injection attempts; DoH and arbitrary hosts blocked by default. - **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist; per-route path/method/header `matches` filtering; outbound DLP scanning for known tokens and secrets, inbound DLP scanning for prompt-injection attempts; DoH and arbitrary hosts blocked by default.
- **Per-route token-match policy** — each egress route picks what happens when the outbound DLP catches a token via `dlp.outbound_on_match`: `supervise` (default) holds the request and surfaces it in `./cli.py supervise` for approval (an approved value is remembered for the life of the proxy); `redact` scrubs the value and forwards; `block` is a hard `403`. Cuts false-positive friction without weakening default-deny. - **Per-route token-match policy** — each egress route picks what happens when the outbound DLP catches a token via `dlp.outbound_on_match`: `supervise` (default) holds the request and surfaces it in `bot-bottle supervise` for approval (an approved value is remembered for the life of the proxy); `redact` scrubs the value and forwards; `block` is a hard `403`. Cuts false-positive friction without weakening default-deny.
- **Tokens the agent never sees** — host secrets live in a gateway; the agent dials `http://gateway:9099/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only. - **Tokens the agent never sees** — host secrets live in a gateway; the agent dials `http://gateway:9099/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only.
- **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential. - **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential.
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load. - **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
@@ -39,7 +39,7 @@ On the legacy Docker backend, the same logical bottle is two containers per agen
The Docker topology looks like this: The Docker topology looks like this:
``` ```
host ( ./cli.py ) host ( bot-bottle )
starts │ stops starts │ stops
@@ -87,7 +87,7 @@ The installer is a bootstrapper: it finds a suitable Python, installs bot-bottle
**A backend**, which the installer deliberately does *not* install for you — `doctor` reports what's missing afterwards. On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The Firecracker backend (Linux) requires Docker on the host for the gateway plus the `firecracker` binary and KVM. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`. **A backend**, which the installer deliberately does *not* install for you — `doctor` reports what's missing afterwards. On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The Firecracker backend (Linux) requires Docker on the host for the gateway plus the `firecracker` binary and KVM. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where neither Apple Container nor KVM is available and Docker is the desired backend. Use `BOT_BOTTLE_BACKEND=docker bot-bottle start <agent>` on hosts where neither Apple Container nor KVM is available and Docker is the desired backend.
> **CI (macOS Apple Container):** the advisory `integration-macos` job in `.gitea/workflows/pre-release-test.yml` runs only on manual dispatch. It targets a self-hosted host-mode runner labelled `macos`; Apple Container cannot run inside the Linux pull-request runner. Provision an Apple Silicon host with the `container` CLI running and Python ≥ 3.11 plus `coverage` on the launchd service's explicit `PATH`. The infra container is a singleton (`bot-bottle-mac-infra`), so keep runner concurrency at 1. Its coverage is reported separately and never feeds the required pull-request gate. > **CI (macOS Apple Container):** the advisory `integration-macos` job in `.gitea/workflows/pre-release-test.yml` runs only on manual dispatch. It targets a self-hosted host-mode runner labelled `macos`; Apple Container cannot run inside the Linux pull-request runner. Provision an Apple Silicon host with the `container` CLI running and Python ≥ 3.11 plus `coverage` on the launchd service's explicit `PATH`. The infra container is a singleton (`bot-bottle-mac-infra`), so keep runner concurrency at 1. Its coverage is reported separately and never feeds the required pull-request gate.
@@ -180,10 +180,10 @@ On Linux, a KVM-capable host defaults to the Firecracker backend. It needs:
- **`/dev/kvm`** present and accessible. Load `kvm-intel` or `kvm-amd` (and enable virtualization in BIOS/firmware). The invoking user must be in the `kvm` group: `sudo usermod -aG kvm "$USER"` then re-login. bot-bottle preflights this and reports exactly what's missing. - **`/dev/kvm`** present and accessible. Load `kvm-intel` or `kvm-amd` (and enable virtualization in BIOS/firmware). The invoking user must be in the `kvm` group: `sudo usermod -aG kvm "$USER"` then re-login. bot-bottle preflights this and reports exactly what's missing.
- **`firecracker`** on `PATH`: grab a release from <https://github.com/firecracker-microvm/firecracker/releases>. Start flows print this pointer when the binary is missing. - **`firecracker`** on `PATH`: grab a release from <https://github.com/firecracker-microvm/firecracker/releases>. Start flows print this pointer when the binary is missing.
- **Docker** for the gateway and image build. - **Docker** for the gateway and image build.
- **A one-time privileged network setup** — the per-bottle TAP pool plus the fail-closed `nftables` isolation table. Run `./cli.py backend setup --backend=firecracker` for the host-appropriate config (a NixOS module, a `sudo` script elsewhere); `./cli.py backend status --backend=firecracker` reports what's present, including whether the pool range collides with an existing route. The pool defaults to `10.243.0.0/16` (an obscure RFC-1918 block that dodges docker/libvirt/LAN and, deliberately, Tailscale's `100.64.0.0/10` CGNAT range); override with `BOT_BOTTLE_FC_IP_BASE` if it clashes on your host. - **A one-time privileged network setup** — the per-bottle TAP pool plus the fail-closed `nftables` isolation table. Run `bot-bottle backend setup --backend=firecracker` for the host-appropriate config (a NixOS module, a `sudo` script elsewhere); `bot-bottle backend status --backend=firecracker` reports what's present, including whether the pool range collides with an existing route. The pool defaults to `10.243.0.0/16` (an obscure RFC-1918 block that dodges docker/libvirt/LAN and, deliberately, Tailscale's `100.64.0.0/10` CGNAT range); override with `BOT_BOTTLE_FC_IP_BASE` if it clashes on your host.
```sh ```sh
BOT_BOTTLE_BACKEND=firecracker ./cli.py start <agent> BOT_BOTTLE_BACKEND=firecracker bot-bottle start <agent>
``` ```
> **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. For the network pool, consume the flake module — `imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ]; services.bot-bottle-firecracker = { enable = true; owner = "you"; };` — then `nixos-rebuild switch` (imperative nft/TAP rules don't survive a rebuild; channel users can `imports = [ <bot-bottle>/nix/firecracker-netpool.nix ]`). `firecracker` isn't in nixpkgs by default as a user binary — install the release binary (pin the version) and put it on `PATH`. > **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. For the network pool, consume the flake module — `imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ]; services.bot-bottle-firecracker = { enable = true; owner = "you"; };` — then `nixos-rebuild switch` (imperative nft/TAP rules don't survive a rebuild; channel users can `imports = [ <bot-bottle>/nix/firecracker-netpool.nix ]`). `firecracker` isn't in nixpkgs by default as a user binary — install the release binary (pin the version) and put it on `PATH`.
@@ -191,7 +191,7 @@ BOT_BOTTLE_BACKEND=firecracker ./cli.py start <agent>
> **CI:** Firecracker integration runs in the manually dispatched `.gitea/workflows/pre-release-test.yml` on a self-hosted runner labelled `kvm`; privileged KVM hosts never execute unreviewed PR code automatically. Provision it like a normal Firecracker host: `firecracker` on `PATH`, `/dev/kvm`, the cached guest kernel and static dropbear, and the persistent TAP/nft pool. The required pull-request workflow runs unit plus the complete Docker integration suite on `ubuntu-latest`; see `docs/ci.md`. > **CI:** Firecracker integration runs in the manually dispatched `.gitea/workflows/pre-release-test.yml` on a self-hosted runner labelled `kvm`; privileged KVM hosts never execute unreviewed PR code automatically. Provision it like a normal Firecracker host: `firecracker` on `PATH`, `/dev/kvm`, the cached guest kernel and static dropbear, and the persistent TAP/nft pool. The required pull-request workflow runs unit plus the complete Docker integration suite on `ubuntu-latest`; see `docs/ci.md`.
```sh ```sh
./cli.py start <agent> # builds the image on first run, drops you into claude bot-bottle start <agent> # builds the image on first run, drops you into claude
``` ```
## Manifest ## Manifest
@@ -267,7 +267,7 @@ You help maintain Gitea-hosted projects.
| `dlp.outbound_on_match` | no | What to do when an outbound token is detected: `supervise` (default for manifest routes — hold for operator approval), `redact` (scrub the value and forward), or `block` (hard 403). Agent-provider routes (e.g. `api.anthropic.com`) default to `redact`. | | `dlp.outbound_on_match` | no | What to do when an outbound token is detected: `supervise` (default for manifest routes — hold for operator approval), `redact` (scrub the value and forward), or `block` (hard 403). Agent-provider routes (e.g. `api.anthropic.com`) default to `redact`. |
| `git.fetch` | no | `true` permits smart HTTP clone/fetch (`git-upload-pack`) for this host. Push (`git-receive-pack`) remains blocked. | | `git.fetch` | no | `true` permits smart HTTP clone/fetch (`git-upload-pack`) for this host. Push (`git-receive-pack`) remains blocked. |
When an outbound DLP detector matches a token, the route's `dlp.outbound_on_match` policy decides what happens. Under the default `supervise`, the proxy queues an `egress-token-allow` proposal for the operator's `./cli.py supervise` TUI and holds the request open until it is answered (or `EGRESS_TOKEN_ALLOW_TIMEOUT_SECONDS`, default 300s, elapses — after which it fails closed). The operator never sees the raw token, only the host, method, path, and a redacted snippet; approving adds the value to an in-memory safelist for the life of the egress proxy. Under `redact`, the matched value is scrubbed from the body, headers, and path and the request is forwarded (failing closed if a match lands somewhere unredactable, like the hostname). Under `block` it stays a hard `403`. Structural blocks (CRLF injection) and not-in-allowlist host blocks are always hard `403`s regardless of policy. When an outbound DLP detector matches a token, the route's `dlp.outbound_on_match` policy decides what happens. Under the default `supervise`, the proxy queues an `egress-token-allow` proposal for the operator's `bot-bottle supervise` TUI and holds the request open until it is answered (or `EGRESS_TOKEN_ALLOW_TIMEOUT_SECONDS`, default 300s, elapses — after which it fails closed). The operator never sees the raw token, only the host, method, path, and a redacted snippet; approving adds the value to an in-memory safelist for the life of the egress proxy. Under `redact`, the matched value is scrubbed from the body, headers, and path and the request is forwarded (failing closed if a match lands somewhere unredactable, like the hostname). Under `block` it stays a hard `403`. Structural blocks (CRLF injection) and not-in-allowlist host blocks are always hard `403`s regardless of policy.
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`. More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
+1 -1
View File
@@ -226,7 +226,7 @@ class AgentProvider(ABC):
initial task in a non-interactive (headless) session. initial task in a non-interactive (headless) session.
Called only when ``--prompt`` is passed to Called only when ``--prompt`` is passed to
``./cli.py start --headless``; the returned args are appended ``bot-bottle start --headless``; the returned args are appended
after the provider's ``bypass_args`` and ``startup_args``.""" after the provider's ``bypass_args`` and ``startup_args``."""
def provision_ca(self, bottle: "Bottle", plan: "BottlePlan") -> None: def provision_ca(self, bottle: "Bottle", plan: "BottlePlan") -> None:
+3 -3
View File
@@ -531,7 +531,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
host-appropriate config or commands. Prints to stdout/stderr and host-appropriate config or commands. Prints to stdout/stderr and
returns a shell exit code (0 = nothing to report / success). A returns a shell exit code (0 = nothing to report / success). A
backend that needs no host setup prints a short note and returns backend that needs no host setup prints a short note and returns
0. Invoked generically by `./cli.py backend setup [--backend=…]` 0. Invoked generically by `bot-bottle backend setup [--backend=…]`
so operators can provision any backend without a so operators can provision any backend without a
backend-specific command. Classmethod (like `is_available`) — backend-specific command. Classmethod (like `is_available`) —
it's a host query, not per-bottle state.""" it's a host query, not per-bottle state."""
@@ -548,14 +548,14 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
stderr. When quiet=True returns the status code silently — stderr. When quiet=True returns the status code silently —
useful for cheap programmatic checks. useful for cheap programmatic checks.
Invoked by `./cli.py backend status [--backend=…]` (quiet=False) Invoked by `bot-bottle backend status [--backend=…]` (quiet=False)
and by is_backend_ready() (caller-controlled).""" and by is_backend_ready() (caller-controlled)."""
@classmethod @classmethod
@abstractmethod @abstractmethod
def teardown(cls) -> int: def teardown(cls) -> int:
"""Undo `setup()` — the inverse operation, surfaced as """Undo `setup()` — the inverse operation, surfaced as
`./cli.py backend teardown [--backend=…]` (uninstall). Symmetric `bot-bottle backend teardown [--backend=…]` (uninstall). Symmetric
with setup: where setup is advisory (prints the privileged with setup: where setup is advisory (prints the privileged
commands / declarative config to apply), teardown prints the commands / declarative config to apply), teardown prints the
commands / config change to remove the host prerequisites. A commands / config change to remove the host prerequisites. A
+3 -3
View File
@@ -8,7 +8,7 @@ pointer, and `status()` reports whether docker is usable.
This is intentionally minimal; a richer version (daemon config checks, This is intentionally minimal; a richer version (daemon config checks,
gVisor/runsc install guidance, rootless-docker hints) is tracked gVisor/runsc install guidance, rootless-docker hints) is tracked
separately. Reached via `DockerBottleBackend.setup` / `.status`, which separately. Reached via `DockerBottleBackend.setup` / `.status`, which
the generic `./cli.py backend {setup,status}` dispatches to. the generic `bot-bottle backend {setup,status}` dispatches to.
""" """
from __future__ import annotations from __future__ import annotations
@@ -69,7 +69,7 @@ def teardown() -> int:
sys.stderr.write( sys.stderr.write(
"Docker backend: nothing to undo — it provisions no privileged host " "Docker backend: nothing to undo — it provisions no privileged host "
"state (networks and the gateway are per-launch and are " "state (networks and the gateway are per-launch and are "
"removed by `./cli.py cleanup`). Docker itself is left installed.\n" "removed by `bot-bottle cleanup`). Docker itself is left installed.\n"
) )
return 0 return 0
@@ -89,5 +89,5 @@ def status() -> int:
runsc = _docker_on_path() and _util.runsc_available() runsc = _docker_on_path() and _util.runsc_available()
sys.stderr.write(f"gVisor runsc runtime: {'registered' if runsc else 'not registered (optional)'}\n") sys.stderr.write(f"gVisor runsc runtime: {'registered' if runsc else 'not registered (optional)'}\n")
if not ok: if not ok:
sys.stderr.write("\nRun: ./cli.py backend setup --backend=docker\n") sys.stderr.write("\nRun: bot-bottle backend setup --backend=docker\n")
return 0 if ok else 1 return 0 if ok else 1
+1 -1
View File
@@ -204,7 +204,7 @@ def boot_vm(
Records the PID.""" Records the PID."""
if not netpool.tap_present(slot.iface): if not netpool.tap_present(slot.iface):
die(f"infra link {slot.iface} not present.\n" die(f"infra link {slot.iface} not present.\n"
f" ./cli.py backend setup --backend=firecracker") f" bot-bottle backend setup --backend=firecracker")
run_dir.mkdir(parents=True, exist_ok=True) run_dir.mkdir(parents=True, exist_ok=True)
rootfs = run_dir / "rootfs.ext4" rootfs = run_dir / "rootfs.ext4"
@@ -108,7 +108,7 @@ def verify_isolation(private_key: Path, guest_ip: str) -> None:
die(f"ISOLATION FAILURE: the VM reached the host canary " die(f"ISOLATION FAILURE: the VM reached the host canary "
f"{canary_ip}:{canary_port}. The egress boundary is not in " f"{canary_ip}:{canary_port}. The egress boundary is not in "
f"force — refusing to run the agent (fail-closed). Verify the " f"force — refusing to run the agent (fail-closed). Verify the "
f"nft table with: ./cli.py backend setup --backend=firecracker") f"nft table with: bot-bottle backend setup --backend=firecracker")
if result.returncode == 2: if result.returncode == 2:
die("isolation probe inconclusive: the guest has no python3/bash/nc " die("isolation probe inconclusive: the guest has no python3/bash/nc "
"to run the connectivity test. Refusing to continue " "to run the connectivity test. Refusing to continue "
+3 -3
View File
@@ -3,7 +3,7 @@ config renderers (shell command + NixOS module) shown to operators.
The Firecracker backend needs a privileged one-time network setup: The Firecracker backend needs a privileged one-time network setup:
a pool of point-to-point TAP devices (owned by the invoking user, so a pool of point-to-point TAP devices (owned by the invoking user, so
`./cli.py start` never needs root) and a dedicated nftables table that `bot-bottle start` never needs root) and a dedicated nftables table that
isolates every VM. The pool parameters live in exactly one place isolates every VM. The pool parameters live in exactly one place
`netpool.defaults.env`, a plain KEY=VALUE file next to this module `netpool.defaults.env`, a plain KEY=VALUE file next to this module
and every consumer reads *that*: this module (below), the shell script and every consumer reads *that*: this module (below), the shell script
@@ -315,11 +315,11 @@ def allocate(slug: str) -> tuple[Slot, IO[str]]:
return s, handle return s, handle
die(f"Firecracker TAP pool exhausted ({pool_size()} slots, all in " die(f"Firecracker TAP pool exhausted ({pool_size()} slots, all in "
f"use). Stop a running bottle or raise BOT_BOTTLE_FC_POOL_SIZE " f"use). Stop a running bottle or raise BOT_BOTTLE_FC_POOL_SIZE "
f"and re-run `./cli.py backend setup --backend=firecracker`.") f"and re-run `bot-bottle backend setup --backend=firecracker`.")
raise AssertionError("unreachable") raise AssertionError("unreachable")
# --- config renderers (shown by `./cli.py backend setup`) ----------- # --- config renderers (shown by `bot-bottle backend setup`) -----------
# The persistent unit is the portable install: the same systemd oneshot # The persistent unit is the portable install: the same systemd oneshot
# on every systemd distro (Debian/Ubuntu/Fedora/RHEL/Arch/…). # on every systemd distro (Debian/Ubuntu/Fedora/RHEL/Arch/…).
+5 -5
View File
@@ -8,7 +8,7 @@ bundled setup script. `status()` reports what's present, including
whether the pool range collides with an existing route. whether the pool range collides with an existing route.
Called through `FirecrackerBottleBackend.setup` / `.status`, which the Called through `FirecrackerBottleBackend.setup` / `.status`, which the
generic `./cli.py backend {setup,status}` command dispatches to. generic `bot-bottle backend {setup,status}` command dispatches to.
""" """
from __future__ import annotations from __future__ import annotations
@@ -34,7 +34,7 @@ _UNIT_PATH = Path("/etc/systemd/system") / netpool.SYSTEMD_UNIT
def _owner() -> str: def _owner() -> str:
# Under `sudo`, USER is root but SUDO_USER is the real invoker — the # Under `sudo`, USER is root but SUDO_USER is the real invoker — the
# TAPs must be owned by them so `./cli.py start` stays rootless. # TAPs must be owned by them so `bot-bottle start` stays rootless.
return os.environ.get("SUDO_USER") or os.environ.get("USER") or "youruser" return os.environ.get("SUDO_USER") or os.environ.get("USER") or "youruser"
@@ -161,7 +161,7 @@ def _setup_systemd() -> None:
if rc == 0: if rc == 0:
sys.stderr.write( sys.stderr.write(
f"Installed and started {netpool.SYSTEMD_UNIT}. Verify with " f"Installed and started {netpool.SYSTEMD_UNIT}. Verify with "
f"`./cli.py backend status --backend=firecracker`.\n" f"`bot-bottle backend status --backend=firecracker`.\n"
) )
else: else:
sys.stderr.write( sys.stderr.write(
@@ -181,7 +181,7 @@ def _setup_systemd() -> None:
) )
sys.stderr.write( sys.stderr.write(
f"\n(Or re-run this as root to install it directly: " f"\n(Or re-run this as root to install it directly: "
f"sudo ./cli.py backend setup --backend=firecracker)\n" f"sudo bot-bottle backend setup --backend=firecracker)\n"
) )
@@ -334,7 +334,7 @@ def status() -> int:
sys.stderr.write(f"range overlap: none (base {netpool.ip_base()})\n") sys.stderr.write(f"range overlap: none (base {netpool.ip_base()})\n")
_report_persistence() _report_persistence()
if not ok: if not ok:
sys.stderr.write("\nRun: ./cli.py backend setup --backend=firecracker\n") sys.stderr.write("\nRun: bot-bottle backend setup --backend=firecracker\n")
return 0 if ok else 1 return 0 if ok else 1
+4 -4
View File
@@ -9,7 +9,7 @@ generation.
The privileged network setup (TAP pool + nft table) is a one-time The privileged network setup (TAP pool + nft table) is a one-time
operator step see `netpool.py`, `scripts/firecracker-netpool.sh`, operator step see `netpool.py`, `scripts/firecracker-netpool.sh`,
and `./cli.py backend setup --backend=firecracker`. and `bot-bottle backend setup --backend=firecracker`.
""" """
from __future__ import annotations from __future__ import annotations
@@ -132,18 +132,18 @@ def _require_network_pool() -> None:
f"{netpool.pool_size()} slots) overlaps existing routes: " f"{netpool.pool_size()} slots) overlaps existing routes: "
f"{detail}. This can shadow or be shadowed by that route; " f"{detail}. This can shadow or be shadowed by that route; "
f"set BOT_BOTTLE_FC_IP_BASE to a free range and re-run " f"set BOT_BOTTLE_FC_IP_BASE to a free range and re-run "
f"./cli.py backend setup --backend=firecracker.") f"bot-bottle backend setup --backend=firecracker.")
missing = netpool.missing_taps() missing = netpool.missing_taps()
if missing: if missing:
die(f"network pool incomplete — missing TAP devices: " die(f"network pool incomplete — missing TAP devices: "
f"{', '.join(missing)}.\n ./cli.py backend setup --backend=firecracker") f"{', '.join(missing)}.\n bot-bottle backend setup --backend=firecracker")
if shutil.which("nft") is not None and not netpool.nft_table_present(): if shutil.which("nft") is not None and not netpool.nft_table_present():
# nft is queryable and says the table is absent — that's a # nft is queryable and says the table is absent — that's a
# definite, catchable misconfiguration; fail early. # definite, catchable misconfiguration; fail early.
warn(f"isolation table `inet {netpool.NFT_TABLE}` not found via nft. " warn(f"isolation table `inet {netpool.NFT_TABLE}` not found via nft. "
"If this is a permissions issue it will be re-checked " "If this is a permissions issue it will be re-checked "
"empirically after boot; otherwise run: " "empirically after boot; otherwise run: "
"./cli.py backend setup --backend=firecracker") "bot-bottle backend setup --backend=firecracker")
# --- rootfs pipeline (rootless) ------------------------------------- # --- rootfs pipeline (rootless) -------------------------------------
+2 -2
View File
@@ -43,7 +43,7 @@ class Freezer(ABC):
Calls _freeze for the backend-specific snapshot, then writes the Calls _freeze for the backend-specific snapshot, then writes the
committed image reference to per-bottle state and marks the bottle committed image reference to per-bottle state and marks the bottle
preserved so the next `./cli.py resume` boots from the snapshot. preserved so the next `bot-bottle resume` boots from the snapshot.
Raises CommitCancelled if the user declines an interactive Raises CommitCancelled if the user declines an interactive
confirmation prompt (e.g. the macos-container stop prompt). confirmation prompt (e.g. the macos-container stop prompt).
@@ -51,7 +51,7 @@ class Freezer(ABC):
image_ref = self._freeze(agent) image_ref = self._freeze(agent)
write_committed_image(agent.slug, image_ref) write_committed_image(agent.slug, image_ref)
mark_preserved(agent.slug) mark_preserved(agent.slug)
info(f"to resume from this snapshot: ./cli.py resume {agent.slug}") info(f"to resume from this snapshot: bot-bottle resume {agent.slug}")
self._export_hint(agent.slug, image_ref) self._export_hint(agent.slug, image_ref)
@abstractmethod @abstractmethod
+2 -2
View File
@@ -5,7 +5,7 @@ Like Docker, this backend needs no privileged network-pool provisioning
running. `setup()` points at the install/`container system start` steps; running. `setup()` points at the install/`container system start` steps;
`status()` reports readiness. Reached via `status()` reports readiness. Reached via
`MacosContainerBottleBackend.setup` / `.status`, dispatched from the `MacosContainerBottleBackend.setup` / `.status`, dispatched from the
generic `./cli.py backend {setup,status}`. generic `bot-bottle backend {setup,status}`.
""" """
from __future__ import annotations from __future__ import annotations
@@ -75,5 +75,5 @@ def status() -> int:
if not _service_running(): if not _service_running():
ok = False ok = False
if not ok: if not ok:
sys.stderr.write("\nRun: ./cli.py backend setup --backend=macos-container\n") sys.stderr.write("\nRun: bot-bottle backend setup --backend=macos-container\n")
return 0 if ok else 1 return 0 if ok else 1
+1 -1
View File
@@ -86,7 +86,7 @@ def _print_vm_install_instructions() -> None:
info("Then start the service: container system start") info("Then start the service: container system start")
else: else:
info("Install Firecracker: https://github.com/firecracker-microvm/firecracker/releases") info("Install Firecracker: https://github.com/firecracker-microvm/firecracker/releases")
info("Configure the host: ./cli.py backend setup") info("Configure the host: bot-bottle backend setup")
def _auto_select_backend(prompt: bool = True) -> str: def _auto_select_backend(prompt: bool = True) -> str:
+3 -3
View File
@@ -1,9 +1,9 @@
"""`backend` CLI command — generic host setup/status across backends. """`backend` CLI command — generic host setup/status across backends.
`./cli.py backend setup [--backend=NAME]` provisions (or points at how `bot-bottle backend setup [--backend=NAME]` provisions (or points at how
to provision) the chosen backend's one-time host prerequisites. to provision) the chosen backend's one-time host prerequisites.
`./cli.py backend status [--backend=NAME]` reports readiness. `bot-bottle backend status [--backend=NAME]` reports readiness.
`./cli.py backend teardown [--backend=NAME]` undoes setup (uninstall). `bot-bottle backend teardown [--backend=NAME]` undoes setup (uninstall).
All dispatch to the backend's `setup()` / `status()` / `teardown()` All dispatch to the backend's `setup()` / `status()` / `teardown()`
classmethods, so there are no backend-specific commands swapping classmethods, so there are no backend-specific commands swapping
+1 -1
View File
@@ -1,7 +1,7 @@
"""cleanup: stop and remove all orphaned bot-bottle resources. """cleanup: stop and remove all orphaned bot-bottle resources.
Walks every registered backend (docker, firecracker, macos-container) Walks every registered backend (docker, firecracker, macos-container)
so a single `./cli.py cleanup` reaps every backend's leftovers — a so a single `bot-bottle cleanup` reaps every backend's leftovers — a
firecracker bottle's VM processes and run dirs won't survive a firecracker bottle's VM processes and run dirs won't survive a
docker-only cleanup pass (issue addressed alongside #77). docker-only cleanup pass (issue addressed alongside #77).
+2 -2
View File
@@ -4,7 +4,7 @@ Docker bottles are committed to a local Docker image. Macos-container
bottles are exported and rebuilt as a local Apple Container image. bottles are exported and rebuilt as a local Apple Container image.
Firecracker bottles stream the guest rootfs out over SSH and rebuild a Firecracker bottles stream the guest rootfs out over SSH and rebuild a
local Docker image. The resulting reference is stored in per-bottle local Docker image. The resulting reference is stored in per-bottle
state so the next `./cli.py resume <slug>` boots from the snapshot state so the next `bot-bottle resume <slug>` boots from the snapshot
instead of rebuilding from the Dockerfile. instead of rebuilding from the Dockerfile.
""" """
@@ -37,7 +37,7 @@ def cmd_commit(argv: list[str]) -> int:
if slug is None: if slug is None:
active = enumerate_active_agents() active = enumerate_active_agents()
if not active: if not active:
die("no active bottles; start one with `./cli.py start`") die("no active bottles; start one with `bot-bottle start`")
choices = [a.slug for a in active] choices = [a.slug for a in active]
slug = tui.filter_select(choices, title="Select bottle to commit") slug = tui.filter_select(choices, title="Select bottle to commit")
if slug is None: if slug is None:
+1 -1
View File
@@ -8,7 +8,7 @@ override and transcript snapshot under the same state dir.
Use case: an interrupted or preserved bottle needs to be relaunched; Use case: an interrupted or preserved bottle needs to be relaunched;
the operator runs the operator runs
./cli.py resume <identity> bot-bottle resume <identity>
to bring up the replacement from the recorded state. to bring up the replacement from the recorded state.
""" """
+6 -6
View File
@@ -203,19 +203,19 @@ def _start_headless(
if not os.isatty(stdin_fd): if not os.isatty(stdin_fd):
die( die(
"--headless requires a PTY on stdin; run via:\n" "--headless requires a PTY on stdin; run via:\n"
" script -q /dev/null ./cli.py start ..." " script -q /dev/null bot-bottle start ..."
) )
agent_name = args.name agent_name = args.name
if not agent_name: if not agent_name:
die("--headless requires an agent name: ./cli.py start <agent> --headless") die("--headless requires an agent name: bot-bottle start <agent> --headless")
manifest.require_agent(agent_name) # raises ManifestError if unknown manifest.require_agent(agent_name) # raises ManifestError if unknown
prompt = args.prompt prompt = args.prompt
if not prompt: if not prompt:
die( die(
"--headless requires --prompt: " "--headless requires --prompt: "
"./cli.py start <agent> --headless --prompt 'Do the thing'" "bot-bottle start <agent> --headless --prompt 'Do the thing'"
) )
if args.bottle: if args.bottle:
@@ -319,9 +319,9 @@ def attach_agent(
`resume=True` adds `--continue` so claude picks up its most `resume=True` adds `--continue` so claude picks up its most
recent session non-interactively (no session-picker prompt). recent session non-interactively (no session-picker prompt).
First-attach paths (`./cli.py start`) leave it False. First-attach paths (`bot-bottle start`) leave it False.
Used as the inner step of `./cli.py start`.""" Used as the inner step of `bot-bottle start`."""
runtime = runtime_for(agent_provider_template) runtime = runtime_for(agent_provider_template)
info( info(
f"attaching interactive {agent_provider_template} session " f"attaching interactive {agent_provider_template} session "
@@ -354,7 +354,7 @@ def settle_state(identity: str) -> None:
if not identity: if not identity:
return return
if is_preserved(identity): if is_preserved(identity):
info(f"to resume this bottle: ./cli.py resume {identity}") info(f"to resume this bottle: bot-bottle resume {identity}")
return return
cleanup_state(identity) cleanup_state(identity)
+1 -1
View File
@@ -110,7 +110,7 @@ def discover_pending() -> list[QueuedProposal]:
def _approval_status(qp: QueuedProposal, verb: str) -> str: def _approval_status(qp: QueuedProposal, verb: str) -> str:
"""Status-line text after a successful approval.""" """Status-line text after a successful approval."""
base = f"{verb} {qp.proposal.tool} for [{qp.label}]" base = f"{verb} {qp.proposal.tool} for [{qp.label}]"
return f"{base}; resume: ./cli.py resume {qp.label}" return f"{base}; resume: bot-bottle resume {qp.label}"
def _detail_lines( def _detail_lines(
+1 -1
View File
@@ -385,7 +385,7 @@ PY
;; ;;
esac esac
echo "git-gate: queued # gitleaks:allow supervisor approval $proposal_id" >&2 echo "git-gate: queued # gitleaks:allow supervisor approval $proposal_id" >&2
echo "git-gate: approve with './cli.py supervise' to continue this push" >&2 echo "git-gate: approve with 'bot-bottle supervise' to continue this push" >&2
waited=0 waited=0
while [ "$waited" -lt "$timeout" ]; do while [ "$waited" -lt "$timeout" ]; do
status=$(PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" python3 - "$proposal_id" <<'PY' status=$(PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" python3 - "$proposal_id" <<'PY'
+2 -2
View File
@@ -1,4 +1,4 @@
# VHS tape — drives `./cli.py start demo` interactively and asks # VHS tape — drives `bot-bottle start demo` interactively and asks
# claude (the AI) to run four probes via natural-language prompts. # claude (the AI) to run four probes via natural-language prompts.
# Setup (manifest + dummy SSH key + image pre-warm) and teardown # Setup (manifest + dummy SSH key + image pre-warm) and teardown
# happen outside the tape; record via `bash scripts/demo-record.sh`, # happen outside the tape; record via `bash scripts/demo-record.sh`,
@@ -29,7 +29,7 @@ Show
# defaults), one git upstream (unreachable on purpose so gitleaks runs # defaults), one git upstream (unreachable on purpose so gitleaks runs
# before the gate would forward), and a FAKE_TOKEN env var shaped like # before the gate would forward), and a FAKE_TOKEN env var shaped like
# a GitHub PAT. # a GitHub PAT.
Type "./cli.py start demo" Type "bot-bottle start demo"
Enter Enter
Sleep 8s Sleep 8s
+1 -1
View File
@@ -132,7 +132,7 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
can revisit, but the v1 of this PRD is one file = one bottle. can revisit, but the v1 of this PRD is one file = one bottle.
- **Hot-reload.** Changes to manifest files take effect at next - **Hot-reload.** Changes to manifest files take effect at next
`./cli.py start`; we do not watch the directory. `bot-bottle start`; we do not watch the directory.
## Scope ## Scope
+2 -2
View File
@@ -290,7 +290,7 @@ After this PRD:
### Cleanup CLI ### Cleanup CLI
`./cli.py cleanup` switches from "list every container with prefix `bot-bottle cleanup` switches from "list every container with prefix
`bot-bottle-` and every network with prefix `bot-bottle-net-` `bot-bottle-` and every network with prefix `bot-bottle-net-`
or `bot-bottle-egress-`" to: or `bot-bottle-egress-`" to:
@@ -369,7 +369,7 @@ Sized for one PR each, in order.
`docker compose up -d` + attach + teardown. Per-sidecar `start()`/ `docker compose up -d` + attach + teardown. Per-sidecar `start()`/
`stop()` lifecycle methods deleted in the same chunk. Compose- `stop()` lifecycle methods deleted in the same chunk. Compose-
log dump on teardown added. log dump on teardown added.
4. **Cleanup CLI on compose.** Switch `./cli.py cleanup` to 4. **Cleanup CLI on compose.** Switch `bot-bottle cleanup` to
`docker compose ls`-based discovery; keep prefix-scan as `docker compose ls`-based discovery; keep prefix-scan as
fallback for one release. fallback for one release.
5. **Dashboard.** Decide on the discovery question (open question 5. **Dashboard.** Decide on the discovery question (open question
+4 -4
View File
@@ -37,7 +37,7 @@ Two rough edges in the current dashboard:
shows only pending proposals. If no agent has called a tool, shows only pending proposals. If no agent has called a tool,
the screen reads "no pending proposals" — even when five the screen reads "no pending proposals" — even when five
bottles are quietly working. The operator has to `docker bottles are quietly working. The operator has to `docker
compose ls` (or `./cli.py cleanup -n` to see the y/N preview) compose ls` (or `bot-bottle cleanup -n` to see the y/N preview)
to find out what's actually live. to find out what's actually live.
2. **`e` / `p` re-discover-and-disambiguate every invocation.** 2. **`e` / `p` re-discover-and-disambiguate every invocation.**
@@ -82,12 +82,12 @@ the "operator wants to make an unprompted change" case.
global across bottles. Filtering ("show me only this agent's global across bottles. Filtering ("show me only this agent's
proposals") might be a follow-up but isn't this PRD. proposals") might be a follow-up but isn't this PRD.
- **Agent lifecycle from the dashboard.** Starting / stopping - **Agent lifecycle from the dashboard.** Starting / stopping
agents stays in `./cli.py start` / `./cli.py cleanup`. The agents stays in `bot-bottle start` / `bot-bottle cleanup`. The
dashboard reads state; it doesn't change it. dashboard reads state; it doesn't change it.
- **Preserved-but-not-running bottles.** The active-agents list - **Preserved-but-not-running bottles.** The active-agents list
is strictly "what's running now" (cross-referenced from is strictly "what's running now" (cross-referenced from
`docker compose ls`). Preserved state dirs without a live `docker compose ls`). Preserved state dirs without a live
project don't appear — `./cli.py resume <identity>` is the project don't appear — `bot-bottle resume <identity>` is the
path for those. path for those.
- **A separate per-agent detail view.** The agent rows are - **A separate per-agent detail view.** The agent rows are
one-line summaries. Pressing Enter on a proposal still drops one-line summaries. Pressing Enter on a proposal still drops
@@ -125,7 +125,7 @@ the "operator wants to make an unprompted change" case.
- Changes to proposal handling (`a` / `m` / `r` / Enter all - Changes to proposal handling (`a` / `m` / `r` / Enter all
unchanged). unchanged).
- Changes to the queue-dir / supervise sidecar protocol. - Changes to the queue-dir / supervise sidecar protocol.
- New CLI surface beyond what's in `./cli.py dashboard`. - New CLI surface beyond what's in `bot-bottle dashboard`.
- Touching the manifest, compose renderer, launch lifecycle. - Touching the manifest, compose renderer, launch lifecycle.
## Proposed design ## Proposed design
@@ -14,8 +14,8 @@
Today the dashboard is read-only: it surfaces pending proposals Today the dashboard is read-only: it surfaces pending proposals
and active agents (PRD 0019) but can't *start* an agent or and active agents (PRD 0019) but can't *start* an agent or
*re-enter* one. The operator's path is split — they launch *re-enter* one. The operator's path is split — they launch
agents from one terminal (`./cli.py start <name>`), and watch agents from one terminal (`bot-bottle start <name>`), and watch
them from another (`./cli.py dashboard`). them from another (`bot-bottle dashboard`).
This PRD collapses that split. The dashboard becomes the This PRD collapses that split. The dashboard becomes the
operator's single surface: pressing a key opens an agent picker, operator's single surface: pressing a key opens an agent picker,
@@ -31,7 +31,7 @@ claude session AND the dashboard process. Exit claude → back to
dashboard, bottle still running. Start another agent → two dashboard, bottle still running. Start another agent → two
bottles up at once. Quit the dashboard → bottles continue bottles up at once. Quit the dashboard → bottles continue
running. Teardown is **always explicit**: the operator presses running. Teardown is **always explicit**: the operator presses
`x` on an agent, or runs `./cli.py cleanup` later. `x` on an agent, or runs `bot-bottle cleanup` later.
## Problem ## Problem
@@ -45,7 +45,7 @@ Two real frictions today:
open and the dashboard's "active agents" pane is hopelessly open and the dashboard's "active agents" pane is hopelessly
behind reality because they just spawned three in a row. behind reality because they just spawned three in a row.
2. **`./cli.py start` ties the bottle to a single claude 2. **`bot-bottle start` ties the bottle to a single claude
session.** The start command's `ExitStack` brings the bottle session.** The start command's `ExitStack` brings the bottle
up, runs claude, and tears down on Ctrl-D — fine for a one- up, runs claude, and tears down on Ctrl-D — fine for a one-
shot session, wrong for "let me bounce in and out of this shot session, wrong for "let me bounce in and out of this
@@ -60,7 +60,7 @@ captures full-merged logs per bottle (PRD 0018). It already
## Goals / Success Criteria ## Goals / Success Criteria
1. From inside `./cli.py dashboard`, pressing `n` (new) opens 1. From inside `bot-bottle dashboard`, pressing `n` (new) opens
an agent picker listing every agent defined in the manifest. an agent picker listing every agent defined in the manifest.
Selecting one runs `prepare → preflight → launch`. Selecting one runs `prepare → preflight → launch`.
2. The preflight Y/N summary renders cleanly — either as a 2. The preflight Y/N summary renders cleanly — either as a
@@ -83,7 +83,7 @@ captures full-merged logs per bottle (PRD 0018). It already
state cleanup) without quitting the dashboard. state cleanup) without quitting the dashboard.
7. Quitting the dashboard (`q`) leaves every running bottle 7. Quitting the dashboard (`q`) leaves every running bottle
running. Bottle teardown is always explicit (per-bottle `x` running. Bottle teardown is always explicit (per-bottle `x`
or `./cli.py cleanup`). The next `./cli.py dashboard` or `bot-bottle cleanup`). The next `bot-bottle dashboard`
invocation re-discovers them via `list_active_slugs()` and invocation re-discovers them via `list_active_slugs()` and
surfaces re-attach for any it can reconstruct context for surfaces re-attach for any it can reconstruct context for
(see "Cross-dashboard re-attach" below). (see "Cross-dashboard re-attach" below).
@@ -94,13 +94,13 @@ captures full-merged logs per bottle (PRD 0018). It already
embedded-emulator option from the research doc is out of embedded-emulator option from the research doc is out of
scope. The handoff (option 1) is the v1; option 2 is a scope. The handoff (option 1) is the v1; option 2 is a
separate PRD if and when handoff is observably insufficient. separate PRD if and when handoff is observably insufficient.
- **Adopting bottles started by an out-of-dashboard `./cli.py - **Adopting bottles started by an out-of-dashboard `bot-bottle
start` invocation.** Those have their own ExitStack-owner and start` invocation.** Those have their own ExitStack-owner and
the dashboard treats them as read-only-watch (already does the dashboard treats them as read-only-watch (already does
today). Re-attach only applies to bottles the *current today). Re-attach only applies to bottles the *current
dashboard process* started. dashboard process* started.
- **Resurrecting an out-of-process bottle into a new dashboard - **Resurrecting an out-of-process bottle into a new dashboard
with full re-attach.** A bottle started by `./cli.py start` with full re-attach.** A bottle started by `bot-bottle start`
in another terminal — or by a previous dashboard run, now in another terminal — or by a previous dashboard run, now
exited — appears in the agents pane (already does, PRD 0019) exited — appears in the agents pane (already does, PRD 0019)
and can be re-attached via `docker exec -it claude` because and can be re-attached via `docker exec -it claude` because
@@ -109,12 +109,12 @@ captures full-merged logs per bottle (PRD 0018). It already
context object to drive teardown — e.g., the context object to drive teardown — e.g., the
ExitStack-tracked CA + state cleanup `_settle_state` performs ExitStack-tracked CA + state cleanup `_settle_state` performs
today. Cross-dashboard re-attach uses the existing today. Cross-dashboard re-attach uses the existing
`./cli.py cleanup` for teardown, not an `x` keypress (see `bot-bottle cleanup` for teardown, not an `x` keypress (see
open questions). open questions).
- **Multi-window UI.** Single curses window, two existing - **Multi-window UI.** Single curses window, two existing
panes (proposals + agents); the agent picker is a modal, not panes (proposals + agents); the agent picker is a modal, not
a third pane. a third pane.
- **Removing `./cli.py start`.** Stays as the script-friendly / - **Removing `bot-bottle start`.** Stays as the script-friendly /
legacy entry point. The dashboard is the new default. legacy entry point. The dashboard is the new default.
## Scope ## Scope
@@ -140,7 +140,7 @@ captures full-merged logs per bottle (PRD 0018). It already
### Out of scope ### Out of scope
- Changes to `./cli.py start` itself. It keeps its current - Changes to `bot-bottle start` itself. It keeps its current
shape; the dashboard reuses its internal pieces (backend. shape; the dashboard reuses its internal pieces (backend.
prepare / backend.launch) without reaching through the CLI prepare / backend.launch) without reaching through the CLI
layer. layer.
@@ -157,7 +157,7 @@ captures full-merged logs per bottle (PRD 0018). It already
Today's flow: Today's flow:
``` ```
./cli.py start agent bot-bottle start agent
└─ with backend.launch(plan) as bottle: ← bottle alive while inside `with` └─ with backend.launch(plan) as bottle: ← bottle alive while inside `with`
bottle.exec_agent([...], tty=True) ← blocks until claude exits bottle.exec_agent([...], tty=True) ← blocks until claude exits
# context exits → compose down → state cleanup # context exits → compose down → state cleanup
@@ -166,7 +166,7 @@ Today's flow:
The proposed dashboard-driven flow: The proposed dashboard-driven flow:
``` ```
./cli.py dashboard bot-bottle dashboard
└─ bottles: dict[str, tuple[ContextManager, DockerBottle]] = {} └─ bottles: dict[str, tuple[ContextManager, DockerBottle]] = {}
# operator presses `n`, picks agent # operator presses `n`, picks agent
@@ -205,7 +205,7 @@ Two shifts:
evaluation, state-dir reap) doesn't fire on a quit-while- evaluation, state-dir reap) doesn't fire on a quit-while-
running bottle. It DOES fire when the operator explicitly running bottle. It DOES fire when the operator explicitly
stops via `x`, because that calls `cm.__exit__`. For stops via `x`, because that calls `cm.__exit__`. For
bottles a previous dashboard quit on, `./cli.py cleanup` bottles a previous dashboard quit on, `bot-bottle cleanup`
is the path — its compose-down + state-reap logic is the path — its compose-down + state-reap logic
already covers the case. already covers the case.
@@ -213,7 +213,7 @@ Two shifts:
When the dashboard discovers a bottle in `discover_active_agents` When the dashboard discovers a bottle in `discover_active_agents`
that it didn't itself start (a previous-dashboard or external that it didn't itself start (a previous-dashboard or external
`./cli.py start` bottle), Enter still attaches via `docker exec `bot-bottle start` bottle), Enter still attaches via `docker exec
-it … claude` — the agent container is running `sleep infinity` -it … claude` — the agent container is running `sleep infinity`
exactly the same way regardless of who started it. The only exactly the same way regardless of who started it. The only
thing the current dashboard lacks for those bottles is the thing the current dashboard lacks for those bottles is the
@@ -221,8 +221,8 @@ launch-context object needed to drive a clean teardown via
`x`. `x`.
For v1 we surface this honestly: pressing `x` on a non-owned For v1 we surface this honestly: pressing `x` on a non-owned
agent shows a status hint pointing at `./cli.py cleanup` (or agent shows a status hint pointing at `bot-bottle cleanup` (or
`./cli.py cleanup` targeted at the slug if we add that flag `bot-bottle cleanup` targeted at the slug if we add that flag
later). The agent stays alive; the operator handles teardown later). The agent stays alive; the operator handles teardown
out-of-band. Enter (re-attach) works for both owned and out-of-band. Enter (re-attach) works for both owned and
non-owned bottles. non-owned bottles.
@@ -288,7 +288,7 @@ agents pane.
`x` on a non-owned agent (discovered via `list_active_slugs` `x` on a non-owned agent (discovered via `list_active_slugs`
but not in `bottles` dict): no-op with status hint pointing but not in `bottles` dict): no-op with status hint pointing
at `./cli.py cleanup` (the existing path that tears down at `bot-bottle cleanup` (the existing path that tears down
ANY bot-bottle compose project plus reaps state dirs). ANY bot-bottle compose project plus reaps state dirs).
### Dashboard quit ### Dashboard quit
@@ -300,7 +300,7 @@ the `docker compose` project keeps running. The next dashboard
invocation discovers the bottles via `list_active_slugs` and invocation discovers the bottles via `list_active_slugs` and
surfaces re-attach. surfaces re-attach.
This is a real departure from today's `./cli.py start` This is a real departure from today's `bot-bottle start`
semantics (which couples bottle lifetime to the process via semantics (which couples bottle lifetime to the process via
ExitStack). It's intentional: the dashboard is a watching + ExitStack). It's intentional: the dashboard is a watching +
acting surface, not a lifetime owner. acting surface, not a lifetime owner.
@@ -322,7 +322,7 @@ Sized for one PR each.
dashboard's ExitStack; handoff invokes `attach_agent`. dashboard's ExitStack; handoff invokes `attach_agent`.
3. **Re-attach via Enter on owned agents-pane row.** Looks up 3. **Re-attach via Enter on owned agents-pane row.** Looks up
the slug in the dashboard's `bottles` map; if present → the slug in the dashboard's `bottles` map; if present →
handoff; else → status-line hint pointing at `./cli.py handoff; else → status-line hint pointing at `bot-bottle
resume`. resume`.
4. **Explicit per-bottle stop (`x` keybinding).** Pop the 4. **Explicit per-bottle stop (`x` keybinding).** Pop the
bottle's `close` callback off the stack, call it, refresh. bottle's `close` callback off the stack, call it, refresh.
@@ -369,7 +369,7 @@ Sized for one PR each.
bottles dict goes out of scope without invoking `__exit__`, bottles dict goes out of scope without invoking `__exit__`,
so the `docker compose` projects keep running. Bottle so the `docker compose` projects keep running. Bottle
teardown is always explicit: per-bottle `x` (for teardown is always explicit: per-bottle `x` (for
dashboard-owned), or `./cli.py cleanup` (for everything). dashboard-owned), or `bot-bottle cleanup` (for everything).
## Open questions ## Open questions
+3 -3
View File
@@ -46,7 +46,7 @@ window, two panes, no terminal handoff.
## Goals / Success Criteria ## Goals / Success Criteria
1. When the operator runs `./cli.py dashboard` from inside a 1. When the operator runs `bot-bottle dashboard` from inside a
tmux session (`$TMUX` set), the dashboard establishes a tmux session (`$TMUX` set), the dashboard establishes a
two-pane layout: dashboard in the left pane, an initially- two-pane layout: dashboard in the left pane, an initially-
empty right pane reserved for claude sessions. empty right pane reserved for claude sessions.
@@ -313,7 +313,7 @@ Sized small.
3. **Dashboard launched OUTSIDE tmux but tmux is installed.** 3. **Dashboard launched OUTSIDE tmux but tmux is installed.**
Should the dashboard auto-exec itself inside a fresh tmux Should the dashboard auto-exec itself inside a fresh tmux
session to get the split-pane experience? Convenient but session to get the split-pane experience? Convenient but
surprising (`./cli.py dashboard` shouldn't silently surprising (`bot-bottle dashboard` shouldn't silently
change what session you're in). v1 leaves this off — change what session you're in). v1 leaves this off —
operators who want split-pane mode start tmux themselves operators who want split-pane mode start tmux themselves
and then run the dashboard. and then run the dashboard.
@@ -332,7 +332,7 @@ Sized small.
PRD-0019 focus indicator? PRD-0019 focus indicator?
6. **Concurrent dashboards in different tmux windows.** 6. **Concurrent dashboards in different tmux windows.**
Multiple `./cli.py dashboard` invocations in different Multiple `bot-bottle dashboard` invocations in different
tmux windows would each create their own right pane — tmux windows would each create their own right pane —
probably fine, each has its own state, but worth probably fine, each has its own state, but worth
verifying that `tmux list-panes` is scoped to the right verifying that `tmux list-panes` is scoped to the right
+1 -1
View File
@@ -14,7 +14,7 @@ Today bot-bottle is hard-wired around Claude Code assumptions. When Claude runs
## Goals / Success Criteria ## Goals / Success Criteria
- A Codex agent can be started from the dashboard and via `./cli.py start` alongside a Claude agent. - A Codex agent can be started from the dashboard and via `bot-bottle start` alongside a Claude agent.
- The manifest can express the agent provider/template and, where needed, a custom agent Dockerfile. - The manifest can express the agent provider/template and, where needed, a custom agent Dockerfile.
- Claude-specific default egress/auth behavior is no longer implicit; provider-specific auth is expressed through explicit bottle egress routes and roles. - Claude-specific default egress/auth behavior is no longer implicit; provider-specific auth is expressed through explicit bottle egress routes and roles.
- The launcher preserves required infrastructure behavior for sidecars, egress, pipelock, supervisor MCP, CA handling, git, and shell basics. - The launcher preserves required infrastructure behavior for sidecars, egress, pipelock, supervisor MCP, CA handling, git, and shell basics.
@@ -30,7 +30,7 @@ across every bottle spin-up. This has several consequences:
to grant that access. to grant that access.
- **Manual rotation burden.** Operators must manage key files on disk, keeping - **Manual rotation burden.** Operators must manage key files on disk, keeping
them secure, rotating them on a schedule, and distributing them across hosts them secure, rotating them on a schedule, and distributing them across hosts
that run `./cli.py start`. that run `bot-bottle start`.
## Goals / Success Criteria ## Goals / Success Criteria
@@ -6,7 +6,7 @@
## Summary ## Summary
The `./cli.py dashboard` command has grown from its PRD 0013 roots The `bot-bottle dashboard` command has grown from its PRD 0013 roots
(triage supervise proposals) into a parallel-agent control surface (triage supervise proposals) into a parallel-agent control surface
(PRDs 0019/0020/0021): an active-agents pane, agent picker + start, (PRDs 0019/0020/0021): an active-agents pane, agent picker + start,
re-attach, per-bottle stop, tmux split-pane handoff, operator- re-attach, per-bottle stop, tmux split-pane handoff, operator-
@@ -21,7 +21,7 @@ proposals, approve / modify / reject each one, write audit entries,
deliver the response that unblocks the agent's tool call. Everything deliver the response that unblocks the agent's tool call. Everything
that's about *starting / re-entering / stopping* bottles, or about that's about *starting / re-entering / stopping* bottles, or about
*operator-initiated* config edits, comes out. The command is renamed *operator-initiated* config edits, comes out. The command is renamed
`./cli.py supervise` so the name matches what it does after the cut. `bot-bottle supervise` so the name matches what it does after the cut.
Future agent-management UX is explicitly punted: if and when a Future agent-management UX is explicitly punted: if and when a
control surface for parallel agents resurfaces, the working control surface for parallel agents resurfaces, the working
@@ -41,9 +41,9 @@ Three concrete pains, all downstream of the dashboard's growth:
ExitStack-free bottle ownership are intricate enough that ExitStack-free bottle ownership are intricate enough that
shipping the next polish increment costs more than it returns. shipping the next polish increment costs more than it returns.
2. **No clear ownership of "starts and stops bottles".** Today 2. **No clear ownership of "starts and stops bottles".** Today
that responsibility is split: `./cli.py start` owns one-shot that responsibility is split: `bot-bottle start` owns one-shot
sessions; the dashboard owns multi-session bottles it started sessions; the dashboard owns multi-session bottles it started
itself; `./cli.py cleanup` owns everything else. The dashboard itself; `bot-bottle cleanup` owns everything else. The dashboard
tracking its own `bottles: dict[str, (cm, bottle, identity)]` tracking its own `bottles: dict[str, (cm, bottle, identity)]`
that doesn't survive a quit is a confusing third lane. that doesn't survive a quit is a confusing third lane.
3. **Wrong target shape for a "manage many agents" UI.** The 3. **Wrong target shape for a "manage many agents" UI.** The
@@ -97,12 +97,12 @@ problem is everything that got bolted onto that core after.
dashboard. After this PRD they don't exist anywhere — operators dashboard. After this PRD they don't exist anywhere — operators
who need ad-hoc edits use the same path the agents do (call the who need ad-hoc edits use the same path the agents do (call the
supervise tool from inside the bottle) or hand-edit the host- supervise tool from inside the bottle) or hand-edit the host-
side files and restart the sidecar. Adding a `./cli.py routes side files and restart the sidecar. Adding a `bot-bottle routes
edit <slug>` verb is a follow-up if the loss bites. edit <slug>` verb is a follow-up if the loss bites.
- **Removing `./cli.py start` or changing its semantics.** Start - **Removing `bot-bottle start` or changing its semantics.** Start
remains the one-shot launch path. PRD 0020's bottle-outlives- remains the one-shot launch path. PRD 0020's bottle-outlives-
process model is removed; the only path to a long-running process model is removed; the only path to a long-running
bottle is `./cli.py start` (foreground) plus `cli.py cleanup` bottle is `bot-bottle start` (foreground) plus `cli.py cleanup`
for teardown. for teardown.
- **Removing the supervise-sidecar protocol or any of the three - **Removing the supervise-sidecar protocol or any of the three
block-remediation engines.** PRDs 00130016 stay Active. The block-remediation engines.** PRDs 00130016 stay Active. The
@@ -122,8 +122,8 @@ problem is everything that got bolted onto that core after.
### In scope ### In scope
- **Rename the subcommand.** `./cli.py dashboard` becomes - **Rename the subcommand.** `bot-bottle dashboard` becomes
`./cli.py supervise`. The module moves from `bot_bottle/cli/ `bot-bottle supervise`. The module moves from `bot_bottle/cli/
dashboard.py` to `bot_bottle/cli/supervise.py`. The dispatcher dashboard.py` to `bot_bottle/cli/supervise.py`. The dispatcher
in `bot_bottle/cli/__init__.py` and the help text both update. in `bot_bottle/cli/__init__.py` and the help text both update.
- **Strip the curses loop to proposal-only.** The remaining - **Strip the curses loop to proposal-only.** The remaining
@@ -167,7 +167,7 @@ problem is everything that got bolted onto that core after.
- Any new feature in the supervise TUI. The cut is purely - Any new feature in the supervise TUI. The cut is purely
subtractive (except for the rename). subtractive (except for the rename).
- Behavior changes in `./cli.py start`, `cli.py cleanup`, - Behavior changes in `bot-bottle start`, `cli.py cleanup`,
`cli.py resume`, `cli.py list`, `cli.py info`, `cli.py edit`, `cli.py resume`, `cli.py list`, `cli.py info`, `cli.py edit`,
`cli.py init` — unchanged. `cli.py init` — unchanged.
- Changes to the supervise sidecar (`supervise_server.py`, - Changes to the supervise sidecar (`supervise_server.py`,
@@ -181,7 +181,7 @@ problem is everything that got bolted onto that core after.
### Final shape of the TUI ### Final shape of the TUI
After this PRD the `./cli.py supervise` curses surface is: After this PRD the `bot-bottle supervise` curses surface is:
``` ```
bot-bottle supervise (3 pending) bot-bottle supervise (3 pending)
@@ -307,8 +307,8 @@ The PR closes issue #174.
1. **`e` / `p` operator-initiated edits — gone for good or 1. **`e` / `p` operator-initiated edits — gone for good or
moved to a separate CLI verb?** The PRD removes them with no moved to a separate CLI verb?** The PRD removes them with no
replacement. The simplest replacement is `./cli.py routes replacement. The simplest replacement is `bot-bottle routes
edit <slug>` and `./cli.py pipelock edit <slug>`, sharing edit <slug>` and `bot-bottle pipelock edit <slug>`, sharing
the existing `apply_routes_change` / `apply_allowlist_change` the existing `apply_routes_change` / `apply_allowlist_change`
engines. If the loss is felt within the first parallel engines. If the loss is felt within the first parallel
run after this lands, that follow-up is a small PR. Leaving run after this lands, that follow-up is a small PR. Leaving
+9 -9
View File
@@ -7,12 +7,12 @@
## Summary ## Summary
When `./cli.py start` is run without an agent name, or without a backend When `bot-bottle start` is run without an agent name, or without a backend
explicitly specified, the user currently gets an argparse error (missing explicitly specified, the user currently gets an argparse error (missing
positional) or falls through to the `docker` default silently. This PRD positional) or falls through to the `docker` default silently. This PRD
adds a terminal UI that appears in those gaps: a filter-select screen adds a terminal UI that appears in those gaps: a filter-select screen
built with `curses` that lets the operator pick the agent and/or backend built with `curses` that lets the operator pick the agent and/or backend
interactively rather than memorising names or consulting `./cli.py list`. interactively rather than memorising names or consulting `bot-bottle list`.
## Problem ## Problem
@@ -29,15 +29,15 @@ visible.
## Goals / Success Criteria ## Goals / Success Criteria
1. `./cli.py start` (no arguments) shows an interactive agent selector; 1. `bot-bottle start` (no arguments) shows an interactive agent selector;
the selected name is used exactly as if it had been passed on the the selected name is used exactly as if it had been passed on the
command line. command line.
2. `./cli.py start <name>` (no `--backend`, no `BOT_BOTTLE_BACKEND`) 2. `bot-bottle start <name>` (no `--backend`, no `BOT_BOTTLE_BACKEND`)
shows an interactive backend selector; the selected backend is used shows an interactive backend selector; the selected backend is used
exactly as if `--backend=<selected>` had been passed. exactly as if `--backend=<selected>` had been passed.
3. `./cli.py start <name> --backend=<b>` (both explicit) shows neither 3. `bot-bottle start <name> --backend=<b>` (both explicit) shows neither
screen — no behavioural change from today. screen — no behavioural change from today.
4. `./cli.py start` (no arguments, no env backend) shows the agent 4. `bot-bottle start` (no arguments, no env backend) shows the agent
selector first, then the backend selector. selector first, then the backend selector.
5. The filter-select widget is a standalone utility 5. The filter-select widget is a standalone utility
(`bot_bottle/cli/tui.py`) shared by both selectors. (`bot_bottle/cli/tui.py`) shared by both selectors.
@@ -57,7 +57,7 @@ visible.
- No pagination beyond what fits in the terminal window (scroll via - No pagination beyond what fits in the terminal window (scroll via
cursor movement is sufficient for typical agent counts). cursor movement is sufficient for typical agent counts).
- No multi-select; exactly one item is chosen per invocation. - No multi-select; exactly one item is chosen per invocation.
- No changes to `./cli.py resume`, `./cli.py list`, or any other - No changes to `bot-bottle resume`, `bot-bottle list`, or any other
subcommand. subcommand.
## Design ## Design
@@ -83,7 +83,7 @@ def filter_select(
The widget renders to the tty file descriptor opened via `curses.initscr` The widget renders to the tty file descriptor opened via `curses.initscr`
(or `curses.newterm` on the tty fd so stdout remains clean for callers (or `curses.newterm` on the tty fd so stdout remains clean for callers
that pipe `./cli.py`). that pipe `bot-bottle`).
Layout (full-width, minimal): Layout (full-width, minimal):
@@ -140,7 +140,7 @@ agent picker can populate itself from the real manifest. The same
`filter_select` opens `/dev/tty` and feeds it as the input file to `filter_select` opens `/dev/tty` and feeds it as the input file to
`curses.wrapper`-equivalent code (using `curses.newterm` to avoid `curses.wrapper`-equivalent code (using `curses.newterm` to avoid
clobbering the caller's stdout/stderr). This keeps the picker clobbering the caller's stdout/stderr). This keeps the picker
composable — callers can pipe `./cli.py` output without the curses composable — callers can pipe `bot-bottle` output without the curses
draw sequences contaminating the pipe. draw sequences contaminating the pipe.
## Implementation chunks ## Implementation chunks
+3 -3
View File
@@ -30,12 +30,12 @@ snapshot before a planned host reboot or hardware migration.
## Goals / Success Criteria ## Goals / Success Criteria
- `./cli.py commit [<slug>]` takes a snapshot of the running agent and - `bot-bottle commit [<slug>]` takes a snapshot of the running agent and
stores it as a local artifact. stores it as a local artifact.
- Without a slug argument the command shows the same interactive picker - Without a slug argument the command shows the same interactive picker
as `start` (the list of active slugs). as `start` (the list of active slugs).
- The committed artifact reference is stored in per-bottle state so - The committed artifact reference is stored in per-bottle state so
that the next `./cli.py resume <slug>` automatically uses the that the next `bot-bottle resume <slug>` automatically uses the
snapshot instead of rebuilding from the Dockerfile. snapshot instead of rebuilding from the Dockerfile.
- `mark_preserved` is called so the state dir survives the normal - `mark_preserved` is called so the state dir survives the normal
session-end cleanup. session-end cleanup.
@@ -81,7 +81,7 @@ to the committed `.smolmachine` artifact.
### `commit` command ### `commit` command
``` ```
./cli.py commit [<slug>] bot-bottle commit [<slug>]
``` ```
1. Resolve slug (arg or interactive picker from `enumerate_active_agents`). 1. Resolve slug (arg or interactive picker from `enumerate_active_agents`).
@@ -37,7 +37,7 @@ egress policy), the operator must duplicate the agent file and change the
selection order, as the effective bottle for the session. selection order, as the effective bottle for the session.
5. Confirming with an empty selection falls back to the agent's `bottle:` field. 5. Confirming with an empty selection falls back to the agent's `bottle:` field.
If neither is set, a ManifestError is raised pointing the operator at the fix. If neither is set, a ManifestError is raised pointing the operator at the fix.
6. The ordered bottle list is stored in launch metadata so `./cli.py resume` 6. The ordered bottle list is stored in launch metadata so `bot-bottle resume`
uses the same bottles. uses the same bottles.
7. The preflight summary (`y/N` screen) shows the effective bottle name(s). 7. The preflight summary (`y/N` screen) shows the effective bottle name(s).
8. The multi-select picker supports incremental filtering, Space/Enter to toggle 8. The multi-select picker supports incremental filtering, Space/Enter to toggle
@@ -52,7 +52,7 @@ egress policy), the operator must duplicate the agent file and change the
- Reordering the selection list from within the picker (order = insertion order; - Reordering the selection list from within the picker (order = insertion order;
drag-and-drop is out of scope). drag-and-drop is out of scope).
- Storing bottle selection history / MRU. - Storing bottle selection history / MRU.
- Changes to `./cli.py edit`, `./cli.py list`, or `./cli.py info`. - Changes to `bot-bottle edit`, `bot-bottle list`, or `bot-bottle info`.
- Removing the `bottle:` key from the agent schema (it stays, now optional). - Removing the `bottle:` key from the agent schema (it stays, now optional).
## Design ## Design
+1 -1
View File
@@ -74,7 +74,7 @@ macOS-only for v1. Three concrete blockers:
## Goals / Success Criteria ## Goals / Success Criteria
- `BOT_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>` launches, - `BOT_BOTTLE_BACKEND=smolmachines bot-bottle start <agent>` launches,
runs, and tears down a bottle on a Linux host with `/dev/kvm`. runs, and tears down a bottle on a Linux host with `/dev/kvm`.
- The TSI allowlist is enforced on Linux: PRD 0022's - The TSI allowlist is enforced on Linux: PRD 0022's
`tests/integration/test_sandbox_escape.py` passes against `tests/integration/test_sandbox_escape.py` passes against
+2 -2
View File
@@ -39,7 +39,7 @@ end-to-end runner that would catch the *next* macOS-only launch regression.)
PR #470 (#414) already made the integration suite backend-agnostic: PR #470 (#414) already made the integration suite backend-agnostic:
`skip_unless_selected_backend_available()` gates on the *selected* backend's `skip_unless_selected_backend_available()` gates on the *selected* backend's
own `is_backend_ready()` rather than `docker_available()`, and each own `is_backend_ready()` rather than `docker_available()`, and each
integration job runs `./cli.py backend status --backend=<name>` as a preflight integration job runs `bot-bottle backend status --backend=<name>` as a preflight
that fails loudly when the backend is missing. That is the machinery this job that fails loudly when the backend is missing. That is the machinery this job
plugs into; this PRD supplies the runner and the job. plugs into; this PRD supplies the runner and the job.
@@ -98,7 +98,7 @@ Modeled on `integration-firecracker`:
- `concurrency: { group: integration-macos-infra, cancel-in-progress: false }` - `concurrency: { group: integration-macos-infra, cancel-in-progress: false }`
to serialize runs against the singleton. to serialize runs against the singleton.
- **Preflight**`command -v container`, `container system status`, then - **Preflight**`command -v container`, `container system status`, then
`./cli.py backend status --backend=macos-container`; any failure exits `bot-bottle backend status --backend=macos-container`; any failure exits
non-zero so a misprovisioned runner fails loudly instead of silently non-zero so a misprovisioned runner fails loudly instead of silently
skipping. skipping.
- Run the integration suite under coverage with - Run the integration suite under coverage with
+1 -1
View File
@@ -16,7 +16,7 @@ verifies host prerequisites after install.
## Problem ## Problem
There is currently no install path for new users. The only way to run There is currently no install path for new users. The only way to run
bot-bottle is to clone the repo and invoke `./cli.py`. This blocks any bot-bottle is to clone the repo and invoke `bot-bottle`. This blocks any
public demo: readers want `curl | sh` or `pipx install`, not a manual public demo: readers want `curl | sh` or `pipx install`, not a manual
clone-and-configure flow. There is also no single command that tells a clone-and-configure flow. There is also no single command that tells a
user whether their host is actually ready to run a bottle. user whether their host is actually ready to run a bottle.
+6 -6
View File
@@ -6,7 +6,7 @@ Can bot-bottle grow a built-in supervisor — TUI inventory plus PR-feedback rou
## Context ## Context
bot-bottle today is a fleet *executor*: `./cli.py start <agent>` brings up one bottle (agent container + pipelock + optional git-gate + optional cred-proxy on a per-bottle internal network), and `cli.py` tears it down when the session ends. There is no inventory view, no idle-detection, no automated reaction to PR or CI events. In parallel use, a human is the supervisor — opening one terminal per bottle, switching between them, and watching upstream PR state by hand. bot-bottle today is a fleet *executor*: `bot-bottle start <agent>` brings up one bottle (agent container + pipelock + optional git-gate + optional cred-proxy on a per-bottle internal network), and `cli.py` tears it down when the session ends. There is no inventory view, no idle-detection, no automated reaction to PR or CI events. In parallel use, a human is the supervisor — opening one terminal per bottle, switching between them, and watching upstream PR state by hand.
A separate survey of the broader ecosystem ([agent control dashboards research, mid-2026](https://gitea.dideric.is/didericis/consilium-research/src/branch/main/developer-workflow/agent-control-dashboards-2026-05-24.md)) sorts dashboards into five tiers (session managers, parallel runners, Kanban boards, mission-control SPAs, observability backends). The earlier first-pass conclusion was that a full SPA tier conflicts with bot-bottle's isolation model. This doc reconsiders the smaller question: a TUI supervisor in the existing Python CLI. A separate survey of the broader ecosystem ([agent control dashboards research, mid-2026](https://gitea.dideric.is/didericis/consilium-research/src/branch/main/developer-workflow/agent-control-dashboards-2026-05-24.md)) sorts dashboards into five tiers (session managers, parallel runners, Kanban boards, mission-control SPAs, observability backends). The earlier first-pass conclusion was that a full SPA tier conflicts with bot-bottle's isolation model. This doc reconsiders the smaller question: a TUI supervisor in the existing Python CLI.
@@ -25,13 +25,13 @@ A supervisor doesn't have to be heavy. A TUI built into the existing Python CLI,
Three layers, each independently useful, in order of ambition: Three layers, each independently useful, in order of ambition:
### 1. `./cli.py status` — read-only inventory ### 1. `bot-bottle status` — read-only inventory
Reads `docker ps` filtered by a bottle label and tails each bottle's session log. Reports per bottle: name, agent, uptime, last-activity timestamp, token spend if available, associated PR/branch if recorded. Reads `docker ps` filtered by a bottle label and tails each bottle's session log. Reports per bottle: name, agent, uptime, last-activity timestamp, token spend if available, associated PR/branch if recorded.
No new daemons. No new ports. No new credentials. ~100 lines. No new daemons. No new ports. No new credentials. ~100 lines.
### 2. `./cli.py watch` — TUI over the same data ### 2. `bot-bottle watch` — TUI over the same data
Same data as `status`, rendered with auto-refresh and keyboard shortcuts that shell out to the existing `cli.py attach / stop / start` commands. Same data as `status`, rendered with auto-refresh and keyboard shortcuts that shell out to the existing `cli.py attach / stop / start` commands.
@@ -39,7 +39,7 @@ Library choice: prefer the stdlib `curses` module to stay stdlib-first; fall bac
This is the Claude Squad / tmux-agent-status pattern, applied to bottles instead of tmux sessions. The whole category exists *because* a TUI is the lightweight shape that doesn't require what the SPA tier requires. This is the Claude Squad / tmux-agent-status pattern, applied to bottles instead of tmux sessions. The whole category exists *because* a TUI is the lightweight shape that doesn't require what the SPA tier requires.
### 3. `./cli.py supervise` — PR feedback router ### 3. `bot-bottle supervise` — PR feedback router
The optional, more ambitious layer. The bottle manifest gains an optional field: The optional, more ambitious layer. The bottle manifest gains an optional field:
@@ -49,7 +49,7 @@ pr_watch:
branch: agent/task-42 branch: agent/task-42
``` ```
`./cli.py supervise` polls the named upstream for new review comments and CI failures on `branch`. When one fires, it surfaces as a desktop notification or a flash in the TUI. The human decides what to do with the feedback — there is no autonomous loop that feeds the comment back into a bottle's next prompt (see "Where to be conservative" for why). `bot-bottle supervise` polls the named upstream for new review comments and CI failures on `branch`. When one fires, it surfaces as a desktop notification or a flash in the TUI. The human decides what to do with the feedback — there is no autonomous loop that feeds the comment back into a bottle's next prompt (see "Where to be conservative" for why).
The polling token is a **host** token (the same `GH_PAT` / Gitea token the host already keeps in shell env), not a bottle credential. The supervisor never holds bottle secrets. The polling token is a **host** token (the same `GH_PAT` / Gitea token the host already keeps in shell env), not a bottle credential. The supervisor never holds bottle secrets.
@@ -62,7 +62,7 @@ The load-bearing question is whether the supervisor introduces the privileged-ch
| Reaching into running bottles | Supervisor reads `docker ps` and host-side log files. The host already sees both — Docker is the trust boundary, the supervisor is on the host side of it. | | Reaching into running bottles | Supervisor reads `docker ps` and host-side log files. The host already sees both — Docker is the trust boundary, the supervisor is on the host side of it. |
| Holding bottle credentials | The polling token is a host token. The supervisor never receives `bottle.cred_proxy.routes` entries; it has no path to them. | | Holding bottle credentials | The polling token is a host token. The supervisor never receives `bottle.cred_proxy.routes` entries; it has no path to them. |
| Bridging between bottles | The supervisor does not relay state from bottle A to bottle B. It relays *upstream PR state* to a bottle's next prompt — and only if the manifest opts in. | | Bridging between bottles | The supervisor does not relay state from bottle A to bottle B. It relays *upstream PR state* to a bottle's next prompt — and only if the manifest opts in. |
| New attack surface | All "control" actions go through `./cli.py start <agent>`, which already enforces the manifest. The supervisor is an automated caller of the existing CLI, not a parallel control plane. | | New attack surface | All "control" actions go through `bot-bottle start <agent>`, which already enforces the manifest. The supervisor is an automated caller of the existing CLI, not a parallel control plane. |
The boundary stays at the bottle wall. The supervisor looks outward at git/PR state and downward at Docker; it does not look *inward* through pipelock. The boundary stays at the bottle wall. The supervisor looks outward at git/PR state and downward at Docker; it does not look *inward* through pipelock.
@@ -13,11 +13,11 @@ What's the cheapest path to that, and where does it bottom out?
## What "interact" means ## What "interact" means
Today the flow is bimodal. `./cli.py start <agent>` brings the Today the flow is bimodal. `bot-bottle start <agent>` brings the
bottle up and immediately drops you into an interactive bottle up and immediately drops you into an interactive
`docker exec -it bot-bottle-<slug> claude ...` — claude-code `docker exec -it bot-bottle-<slug> claude ...` — claude-code
owns the whole terminal until you Ctrl-D out, at which point the owns the whole terminal until you Ctrl-D out, at which point the
bottle tears down. The dashboard (`./cli.py dashboard`) is a bottle tears down. The dashboard (`bot-bottle dashboard`) is a
*separate* invocation that watches across bottles but never *separate* invocation that watches across bottles but never
exposes the claude TUI itself. exposes the claude TUI itself.
@@ -107,7 +107,7 @@ What's not good:
This is the v1 the project's existing code-shape strongly This is the v1 the project's existing code-shape strongly
prefers. It clears the bar of "let me talk to claude-code prefers. It clears the bar of "let me talk to claude-code
without quitting `./cli.py dashboard`." without quitting `bot-bottle dashboard`."
## Option 2: Embedded emulator ## Option 2: Embedded emulator
+2 -2
View File
@@ -84,7 +84,7 @@ dangerous changes before they left*.
(`supervise_gitleaks_allow`, [`git_gate_render.py`](../../bot_bottle/git_gate_render.py)). (`supervise_gitleaks_allow`, [`git_gate_render.py`](../../bot_bottle/git_gate_render.py)).
Extend the same flow to **high-risk file classes**: any commit touching Extend the same flow to **high-risk file classes**: any commit touching
CI/build/deploy scripts, auth/crypto code, egress config, or CI/build/deploy scripts, auth/crypto code, egress config, or
adding/changing dependencies → route to `./cli.py supervise`. This is adding/changing dependencies → route to `bot-bottle supervise`. This is
attribution/policy, not detection, and it's the strongest thing here — attribution/policy, not detection, and it's the strongest thing here —
a human on exactly the temporal-escape surfaces. a human on exactly the temporal-escape surfaces.
4. **LLM semantic diff-review — the behavioral backstop.** The only 4. **LLM semantic diff-review — the behavioral backstop.** The only
@@ -146,7 +146,7 @@ preserves the bottom-up distribution funnel.
**governed code-egress review**, not "we resell inference" (the **governed code-egress review**, not "we resell inference" (the
monetization notes explicitly warn against reselling compute). monetization notes explicitly warn against reselling compute).
- **The web-console supervise/review flow — the strongest anchor.** Turn - **The web-console supervise/review flow — the strongest anchor.** Turn
the CLI `./cli.py supervise` approval into a real review surface: the CLI `bot-bottle supervise` approval into a real review surface:
rendered diff + finding context, approve/reject, **who-approved audit rendered diff + finding context, approve/reject, **who-approved audit
trail, RBAC on approvers, mobile/phone-control** (ties to the trail, RBAC on approvers, mobile/phone-control** (ties to the
dashboard/vault north star). This is "central enforcement + dashboard/vault north star). This is "central enforcement +
@@ -100,7 +100,7 @@ resolver globs each directory.
becomes file ops (mkdir, mv, rm) instead of editing one file. becomes file ops (mkdir, mv, rm) instead of editing one file.
Power users prefer that; new users may not. Power users prefer that; new users may not.
- Discovery requires `ls`, not "grep one file." Tooling helps - Discovery requires `ls`, not "grep one file." Tooling helps
(e.g. `./cli.py list`) but the manifest is no longer a single (e.g. `bot-bottle list`) but the manifest is no longer a single
artifact to email or ship. artifact to email or ship.
- Atomicity: swapping a bottle name across agents touches - Atomicity: swapping a bottle name across agents touches
multiple files. Git handles this fine; a one-shot text editor multiple files. Git handles this fine; a one-shot text editor
@@ -364,7 +364,7 @@ wins; none of the body-prose or dependency story.
warns / ignores / breaks. If it warns, we'd want a different warns / ignores / breaks. If it warns, we'd want a different
field name (e.g. `bot-bottle-bottle`) or a namespaced block. field name (e.g. `bot-bottle-bottle`) or a namespaced block.
- **Migration story.** Is the project willing to ship a one-shot - **Migration story.** Is the project willing to ship a one-shot
`./cli.py migrate-manifest` command that does the JSON → MD `bot-bottle migrate-manifest` command that does the JSON → MD
conversion? Or do users just rewrite by hand from the new docs? conversion? Or do users just rewrite by hand from the new docs?
- **Bottle file body content.** If most bottle .md files have an - **Bottle file body content.** If most bottle .md files have an
empty body, is the MD-with-frontmatter format still warranted? empty body, is the MD-with-frontmatter format still warranted?
+1 -1
View File
@@ -34,7 +34,7 @@ on top of working onboarding.
A first-time user today goes through five steps: install Docker, A first-time user today goes through five steps: install Docker,
install `uv`, set `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`, write install `uv`, set `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`, write
`bot-bottle.json`, run `./cli.py start`. One of those is `bot-bottle.json`, run `bot-bottle start`. One of those is
"author a JSON manifest." Polished tools in this category let "author a JSON manifest." Polished tools in this category let
users skip that step on day one. The fix is an `init` subcommand users skip that step on day one. The fix is an `init` subcommand
that drops a working `bot-bottle.json` with a default `coder` that drops a working `bot-bottle.json` with a default `coder`
+1 -1
View File
@@ -131,7 +131,7 @@ The minimum-viable workflow, no bot-bottle code changes:
3. SSH in. 3. SSH in.
4. `git clone` bot-bottle on the VM, drop a manifest in place, 4. `git clone` bot-bottle on the VM, drop a manifest in place,
inject `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` via the provider's secrets path. inject `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN` via the provider's secrets path.
5. `./cli.py start <agent>` — the existing launcher handles the rest. 5. `bot-bottle start <agent>` — the existing launcher handles the rest.
6. On exit: destroy the VM. No host artifacts persist. 6. On exit: destroy the VM. No host artifacts persist.
For the "VPN pivot" failure mode, see For the "VPN pivot" failure mode, see
+1 -1
View File
@@ -9,7 +9,7 @@
# #
# Why a pool + one-time setup: creating a TAP and assigning it an IP # Why a pool + one-time setup: creating a TAP and assigning it an IP
# needs CAP_NET_ADMIN. Pre-creating user-owned, pre-addressed TAPs # needs CAP_NET_ADMIN. Pre-creating user-owned, pre-addressed TAPs
# means `./cli.py start` never needs root. The nft table is static # means `bot-bottle start` never needs root. The nft table is static
# (keyed on the `bbfc*` interface wildcard), so it covers every slot # (keyed on the `bbfc*` interface wildcard), so it covers every slot
# without per-launch changes. # without per-launch changes.
# #
+2 -2
View File
@@ -95,7 +95,7 @@ BOT_BOTTLE_RUN_CANARIES=1 python -m scripts.unittest_gate \
``` ```
4. Skip guards live in `tests._backend` and gate on the backend's own 4. Skip guards live in `tests._backend` and gate on the backend's own
readiness check, `bot_bottle.backend.has_backend` — the same probe readiness check, `bot_bottle.backend.has_backend` — the same probe
behind `./cli.py backend status`: behind `bot-bottle backend status`:
- Backend-agnostic tests (go through `get_bottle_backend()`) decorate - Backend-agnostic tests (go through `get_bottle_backend()`) decorate
the class with `@skip_unless_selected_backend_available()` — the test the class with `@skip_unless_selected_backend_available()` — the test
runs against whichever backend `BOT_BOTTLE_BACKEND` selects and skips runs against whichever backend `BOT_BOTTLE_BACKEND` selects and skips
@@ -105,7 +105,7 @@ BOT_BOTTLE_RUN_CANARIES=1 python -m scripts.unittest_gate \
`backend.docker.*`, …) decorate with `@skip_unless_backend("docker")` `backend.docker.*`, …) decorate with `@skip_unless_backend("docker")`
so they no-op under a run targeting a different backend. so they no-op under a run targeting a different backend.
Each CI integration job runs `./cli.py backend status --backend=<name>` Each CI integration job runs `bot-bottle backend status --backend=<name>`
as a preflight, which prints a clear per-check summary and exits non-zero as a preflight, which prints a clear per-check summary and exits non-zero
when the backend is missing — so absent infrastructure fails the job when the backend is missing — so absent infrastructure fails the job
instead of hiding among per-test `unittest.skip` lines. instead of hiding among per-test `unittest.skip` lines.
+1 -1
View File
@@ -2,7 +2,7 @@
Each integration test targets the backend named by ``BOT_BOTTLE_BACKEND`` Each integration test targets the backend named by ``BOT_BOTTLE_BACKEND``
(default ``docker``) and gates on that backend's full readiness check — (default ``docker``) and gates on that backend's full readiness check —
``is_backend_ready()`` (equivalent to ``./cli.py backend status``), not just ``is_backend_ready()`` (equivalent to ``bot-bottle backend status``), not just
a binary-on-PATH probe. When the backend is not ready, diagnostic output is a binary-on-PATH probe. When the backend is not ready, diagnostic output is
printed during test discovery so the operator sees a concrete reason for each printed during test discovery so the operator sees a concrete reason for each
skip. skip.
+1 -1
View File
@@ -1,7 +1,7 @@
"""Tests for the backend-aware skip guards in ``tests/_backend.py``. """Tests for the backend-aware skip guards in ``tests/_backend.py``.
The guards delegate their readiness check to The guards delegate their readiness check to
``bot_bottle.backend.is_backend_ready`` (the probe behind ``./cli.py backend ``bot_bottle.backend.is_backend_ready`` (the probe behind ``bot-bottle backend
status``); here that probe is mocked so the unit job asserts the status``); here that probe is mocked so the unit job asserts the
selection/skip logic without either backend present on the runner. selection/skip logic without either backend present on the runner.
""" """
+1 -1
View File
@@ -1,6 +1,6 @@
"""Unit: the generic `backend` CLI command. """Unit: the generic `backend` CLI command.
`./cli.py backend {setup,status} [--backend=NAME]` resolves a backend `bot-bottle backend {setup,status} [--backend=NAME]` resolves a backend
and dispatches to its `setup()` / `status()` classmethods no and dispatches to its `setup()` / `status()` classmethods no
backend-specific commands. Also covers the docker backend's own backend-specific commands. Also covers the docker backend's own
setup/status logic (the firecracker path is exercised by setup/status logic (the firecracker path is exercised by