CLI and dashboard now share one cross-backend abstraction for
listing + launching bottles, so adding a backend (docker /
smolmachines) lights up in both places without separate wiring.
Backend abstraction:
- New `ActiveBottle` dataclass (`backend_name`, `slug`,
`agent_name`, `started_at`, `services`) replaces the
docker-specific `ActiveAgent`. Same field surface for the
existing dashboard consumers; `ActiveAgent` becomes a typed
alias for source-compat.
- New `BottleBackend.enumerate_active() -> Sequence[ActiveBottle]`
replaces the old `list_active() -> None` (which printed and
returned nothing). Docker implements it via the existing
compose query; smolmachines implements it via `smolvm machine
ls --json` cross-referenced with each bundle container's
`CLAUDE_BOTTLE_SIDECAR_DAEMONS` env (`backend/smolmachines/
enumerate.py`).
- New `enumerate_active_bottles()` and `known_backend_names()`
module-level helpers fold every backend into one call.
- `get_bottle_backend(name=None)` takes an optional explicit
name (precedence: arg > $CLAUDE_BOTTLE_BACKEND > "docker").
CLI:
- `./cli.py list active` enumerates every backend, prints
tab-separated `<backend>\t<slug>\t<agent>\t<services>`. The
smolmachines bottle the user was looking for now shows up.
- `./cli.py start` grows `--backend=<docker|smolmachines>`
(choices pulled live from `known_backend_names()`). Threaded
through `prepare_with_preflight(backend_name=...)` so the
resume path picks up the flag too.
Dashboard:
- Active agents pane lists both backends (the row formatter now
prefixes `[docker]` / `[smolmachines]`).
- New-agent flow inserts a backend picker modal between agent
pick and preflight (`_backend_picker_modal`). Short-circuits
when only one backend is registered.
- `discover_active_agents()` collapses to
`enumerate_active_bottles()`; `_parse_services_by_project` and
`_query_services_by_project` move to
`backend/docker/cleanup.py` where the docker enumerator owns
them.
Tests: parser + enumerate-active tests relocated to
`test_docker_enumerate_active.py`. New
`test_backend_selection.py` covers `get_bottle_backend`,
`known_backend_names`, `enumerate_active_bottles`. New
`test_cli_start_backend_flag.py` covers `--backend`'s argparse
shape + the explicit-over-env precedence.
605 unit tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PRD 0018 chunk 5. The dashboard's operator-edit verbs
(`routes edit`, `pipelock edit`) enumerated running sidecars
via `docker ps --filter name=...` prefix scans. Switch to
`docker compose ls`-based discovery so the dashboard, cleanup
CLI, and launch step all agree on what's running.
Mechanics:
- `claude_bottle/backend/docker/compose.py` grows three shared
helpers: `list_compose_projects` (the JSON parse moved out
of cleanup), `slug_from_compose_project` (inverse of
`compose_project_name`), and `list_active_slugs` (sugar over
the first two for the common "what's running?" question).
- cleanup.py drops its private `_list_compose_projects` +
`_PROJECT_PREFIX` in favor of the shared ones; `list_active`
simplifies (one compose-ls call, not two).
- dashboard.py's `_discover_sidecar_slugs` becomes
`_discover_active_with_service`: cross-references the active
slug list with a label-filtered `docker ps` so only bottles
whose given service container is actually up surface in the
edit menu. Bottles without an egress sidecar (no
bottle.egress.routes) no longer appear for `routes edit`.
3 new unit tests cover the slug ↔ compose-project naming
contract; manual probe with a fake compose project confirms
both `discover_egress_slugs` and `discover_pipelock_slugs`
return the expected slug.
PRD 0018 chunk 4. `claude-bottle cleanup` now derives its work
from `docker compose ls --all --format json`, filtered to projects
whose name starts with `claude-bottle-`. Per project: one `compose
down --volumes` removes the containers + the compose-managed
networks atomically.
The plan also enumerates three fallback buckets:
- Stray containers — `claude-bottle-*` containers with no
`com.docker.compose.project` label (left over from pre-compose
code paths). Cleared via `docker rm -f`.
- Stray networks — `claude-bottle-*` networks with no compose
project label. Cleared via `docker network rm`.
- Orphan state dirs — per-bottle `~/.claude-bottle/state/<id>/`
dirs with no live project AND no `.preserve` marker. The
`.preserve` marker (capability-block or auto-preserve-on-crash)
explicitly opts-out of reaping; manual `rm -rf` is the only
path for preserved state.
cli/cleanup.py collapses to a single y/N prompt — backend.prepare_cleanup
returns everything in one plan, backend.cleanup processes everything,
no more double-prompt for state. The CLI-side state-dir enumeration
+ `_state_summary` flags from PR #25 are gone; the backend's
orphan-detection rules subsume them.
Move the resolution, bring-up, and orphan-cleanup logic out of
backend.py into three topic-named modules. DockerBottleBackend becomes
a thin façade that wires the per-instance pipelock proxy and the
provision orchestrator into the free functions.
backend.py drops from ~360 to ~70 lines and each topic now reads
end-to-end in one place. Mirrors the existing provision/ split.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>