# PRD 0066: Separate agent and bottle selection - **Status:** Active - **Author:** claude - **Created:** 2026-06-25 - **Issue:** #269 ## Summary Agents and bottles are two separate concerns: agents carry a system prompt and skills; bottles carry infrastructure configuration (egress, git-gate, env, agent provider). Today an agent's manifest file hard-codes a single `bottle:` reference, which prevents the same agent prompt from being reused across projects that need different bottle configurations. This PRD decouples them: at launch time, after choosing the agent, the operator picks an ordered list of bottles via a multi-select picker. The selected bottles are merged in order (later entries override earlier ones) to produce the effective bottle for the session. ## Problem The current `bottle: ` field on an agent manifest file binds the agent permanently to one bottle. To use the same system prompt with a different bottle (e.g. `claude-implementer` at home vs. at a client site that needs a different egress policy), the operator must duplicate the agent file and change the `bottle:` field. Duplicate agent files drift out of sync. ## Goals / Success Criteria 1. `bottle:` in an agent's frontmatter becomes optional. Existing manifests with `bottle:` continue to work unchanged (backward compat). 2. After selecting an agent (via the existing single-select picker), a new multi-select bottle picker appears showing all available bottles. 3. The multi-select picker pre-populates with the agent's `bottle:` value when present. 4. Confirming with one or more bottles selected uses those bottles, merged in selection order, as the effective bottle for the session. 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. 6. The ordered bottle list is stored in launch metadata so `./cli.py resume` uses the same bottles. 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 selection, an ordered "Selected: ..." summary line, Ctrl-D to confirm, and Esc/q to cancel the whole start operation. 9. Unit tests cover: multi-select widget (filter, toggle, confirm, cancel), the `cmd_start` bottle-picker step, and the manifest `load_for_agent` runtime-bottle-merge path. ## Non-goals - Reordering the selection list from within the picker (order = insertion order; drag-and-drop is out of scope). - Storing bottle selection history / MRU. - Changes to `./cli.py edit`, `./cli.py list`, or `./cli.py info`. - Removing the `bottle:` key from the agent schema (it stays, now optional). ## Design ### `bot_bottle/cli/tui.py` — `filter_multiselect` ```python def filter_multiselect( items: list[str], *, title: str = "", initial: list[str] | None = None, tty_path: str = "/dev/tty", ) -> list[str] | None: """Multi-select variant of filter_select. Returns the ordered list of selected items, or None on cancel. Press Space/Enter to toggle the item under the cursor. Press Ctrl-D to confirm. Press Esc/q to cancel. """ ``` Layout: ``` Select bottles Filter: _ ───────────────────────────────────────── > [*] claude [ ] dev [ ] codex ───────────────────────────────────────── Selected (in order): claude ───────────────────────────────────────── [↑↓/jk] move [Space] toggle [Ctrl-D] done [Esc] cancel ``` `initial` pre-populates the ordered selection. `None` means no pre-selection. Items added are appended in insertion order; items removed leave the remaining order unchanged. ### `bot_bottle/manifest_schema.py` — optional `bottle:` `bottle` moves from `AGENT_KEYS_REQUIRED` to `AGENT_KEYS_OPTIONAL`. ### `bot_bottle/manifest_agent.py` — optional `bottle:` `ManifestAgent.bottle` changes from `str` (required) to `str = ""`. `from_dict` no longer requires the key to be present; the bottle-exists validation is skipped when the key is absent. ### `bot_bottle/manifest_loader.py` — `scan_bottle_names` ```python def scan_bottle_names(bottles_dir: Path) -> list[str]: """Scan /*.md and return sorted bottle names.""" ``` ### `bot_bottle/manifest.py` — `ManifestIndex` changes **`all_bottle_names` property** — analogous to `all_agent_names`; scans `home_md / "bottles"` in lazy mode, returns `sorted(self.bottles.keys())` in eager mode. **`load_for_agent(agent_name, bottle_names: tuple[str, ...] = ())`** — new `bottle_names` parameter. When non-empty, the listed bottles are resolved and merged in order (index 0 is the base; each subsequent bottle is applied on top using the same field-merge rules as `extends:`). The result replaces the bottle that `agent.bottle` would have provided. When empty, falls back to `agent.bottle`. Raises ManifestError if neither `bottle_names` nor `agent.bottle` is set. ### `bot_bottle/manifest_extends.py` — `merge_bottles_runtime` ```python def merge_bottles_runtime(bottles: list[ManifestBottle]) -> ManifestBottle: """Merge an ordered list of pre-resolved ManifestBottle objects. Index 0 is the base; each subsequent entry overrides the previous using the same rules as the file-based extends machinery: - env: dict merge, later wins - git_user: per-field overlay, later wins on non-empty - git (repos): union by name, later wins per-name - egress.routes: concatenate - agent_provider, supervise: later bottle's value replaces earlier """ ``` This function operates on already-parsed `ManifestBottle` objects, so it does not need to touch the raw-dict path. ### `bot_bottle/backend/__init__.py` — `BottleSpec` + `_validate` `BottleSpec` gains `bottle_names: tuple[str, ...] = ()`. `BottleBackend._validate` passes `spec.bottle_names` to `load_for_agent`: ```python manifest = spec.manifest.load_for_agent(spec.agent_name, spec.bottle_names) ``` The preflight print updates `info(f"bottle: {agent.bottle}")` to display the effective bottle name(s). When `spec.bottle_names` is non-empty those are shown; when empty and `agent.bottle` is set, the agent's `bottle:` is shown. ### `bot_bottle/bottle_state.py` — persist bottle names `BottleMetadata` gains `bottle_names: tuple[str, ...] = ()`. `read_metadata` reads this from JSON (default `()`). `write_launch_metadata` passes `spec.bottle_names` through. ### `bot_bottle/cli/start.py` — bottle multiselect step After agent selection, before the name/color modal: ```python available_bottle_names = manifest.all_bottle_names # Peek at agent's bottle default for pre-population initial_bottle = _peek_agent_bottle(manifest, agent_name) initial = [initial_bottle] if initial_bottle else [] bottle_names_list = tui.filter_multiselect( available_bottle_names, title="Select bottles", initial=initial, ) if bottle_names_list is None: return 0 # user cancelled bottle_names = tuple(bottle_names_list) ``` `_peek_agent_bottle` reads the agent file's frontmatter without full parsing, returning the `bottle:` value or `""` when absent. `BottleSpec` is built with `bottle_names=bottle_names`. ### `bot_bottle/cli/resume.py` — bottle names from metadata ```python spec = BottleSpec( ... bottle_names=tuple(metadata.bottle_names), ) ``` ## Implementation chunks 1. **Schema + model** — `manifest_schema.py`, `manifest_agent.py` (optional `bottle:`), `manifest_loader.py` (`scan_bottle_names`), `manifest.py` (`all_bottle_names`, `load_for_agent` signature), `manifest_extends.py` (`merge_bottles_runtime`), `bottle_state.py` (`bottle_names` field), `resolve_common.py` (thread through). 2. **Backend** — `BottleSpec.bottle_names`, `_validate`, preflight print. 3. **TUI** — `filter_multiselect` in `tui.py` + unit tests. 4. **CLI wiring** — `start.py` bottle picker step, `resume.py` metadata load. 5. **Tests** — `test_cli_start_selector.py` bottle-picker cases, `test_manifest_agent.py` optional-bottle cases, new `test_manifest_bottle_merge.py` for `merge_bottles_runtime`. ## Open questions None.