docs(prd): add PRD 0051 (named/labelled agents, renumbered from 0049)
This commit is contained in:
@@ -0,0 +1,294 @@
|
|||||||
|
# PRD 0051: Named / Labelled Agents
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **Author:** didericis
|
||||||
|
- **Created:** 2026-06-03
|
||||||
|
- **Issue:** #171
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
At agent launch time, prompt the operator for a short human-readable label
|
||||||
|
(defaulting to the manifest agent key) and an optional color from the 16-color
|
||||||
|
ANSI palette. Store both in the bottle's `metadata.json`. Display the label —
|
||||||
|
rendered in the chosen color — in the dashboard's active-agents pane, replacing
|
||||||
|
the bare manifest key. Inject the label and color into the in-container
|
||||||
|
`claude.json` as `name` / `color` so Claude Code can surface them in its own
|
||||||
|
harness when upstream support lands.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The dashboard's agents pane identifies each running instance by its manifest
|
||||||
|
agent key (e.g., `implementer`) plus a random slug suffix. When an operator
|
||||||
|
runs three `implementer` bottles simultaneously — one each for three different
|
||||||
|
repos — the pane shows:
|
||||||
|
|
||||||
|
```
|
||||||
|
[docker] a3f9 implementer started 14:02:11 [egress,pipelock]
|
||||||
|
[docker] b81c implementer started 14:03:45 [egress,pipelock]
|
||||||
|
[docker] d220 implementer started 14:05:01 [egress,pipelock]
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no way to tell which bottle is working on which task without attaching
|
||||||
|
to each one in turn. The slug is opaque; the manifest key is shared. Operators
|
||||||
|
working a multi-bottle session resort to keeping a mental map of slug→task,
|
||||||
|
which breaks the moment they switch windows.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
1. After the operator selects an agent name (dashboard picker or CLI argument),
|
||||||
|
they are prompted for a label. The prompt suggests the manifest key as the
|
||||||
|
default; pressing Enter (or providing no input) accepts it. The label may
|
||||||
|
contain any printable characters up to 64 bytes.
|
||||||
|
2. After the label prompt, the operator is optionally prompted for a color from
|
||||||
|
the 16-color ANSI palette (names: `black`, `red`, `green`, `yellow`, `blue`,
|
||||||
|
`magenta`, `cyan`, `white`, `bright-black`, `bright-red`, `bright-green`,
|
||||||
|
`bright-yellow`, `bright-blue`, `bright-magenta`, `bright-cyan`,
|
||||||
|
`bright-white`). Pressing Enter without a selection skips color entirely.
|
||||||
|
3. `label` and `color` are stored in `BottleMetadata` and written to the
|
||||||
|
bottle's `metadata.json`. Both fields default to `""` (empty / unset).
|
||||||
|
4. `ActiveAgent` carries `label` and `color`; `enumerate_active()` reads them
|
||||||
|
from `metadata.json`.
|
||||||
|
5. `_format_agent_row` uses the label when non-empty (falling back to
|
||||||
|
`agent_name`). If a non-empty color is set and the terminal supports it, the
|
||||||
|
label substring is rendered in that color.
|
||||||
|
6. `BottleSpec` carries `label` and `color`; the docker backend's `prepare`
|
||||||
|
step copies them into `BottleMetadata`.
|
||||||
|
7. `ClaudeAgentProvider.provision_plan()` in
|
||||||
|
`bot_bottle/contrib/claude/agent_provider.py` writes `label` → `"name"` and
|
||||||
|
`color` → `"color"` into the generated `claude.json`, alongside the existing
|
||||||
|
fields. Fields are omitted when empty.
|
||||||
|
8. The dashboard's `_new_agent_flow` (PRD 0020) includes the label+color step
|
||||||
|
between agent selection and the backend picker.
|
||||||
|
9. `cmd_start` (CLI) includes the label+color step after argument validation
|
||||||
|
and before prepare-with-preflight.
|
||||||
|
10. All existing unit tests stay green; no new tests are required for this
|
||||||
|
change (the label/color fields are thin plumbing with no branching logic
|
||||||
|
worth unit-testing beyond the already-tested metadata read/write path).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Showing the agent label inside the Claude Code TUI (status line, terminal
|
||||||
|
title, custom header). That requires upstream Claude Code / codex support.
|
||||||
|
Writing to `claude.json` is best-effort scaffolding for when that lands.
|
||||||
|
- Per-bottle color affecting anything outside the dashboard agents pane (e.g.,
|
||||||
|
proposal-pane highlights, log prefixes).
|
||||||
|
- Validating or constraining label content beyond the 64-byte printable cap.
|
||||||
|
- Persisting color-pair state across dashboard restarts (color pairs are
|
||||||
|
initialized fresh each session).
|
||||||
|
- Editing the label or color of an already-running bottle.
|
||||||
|
- Exposing label/color via `./cli.py list` (out of scope for v1; trivial to
|
||||||
|
add later since the field will be in metadata).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
operator input
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
BottleSpec.label, BottleSpec.color
|
||||||
|
│
|
||||||
|
├─► docker/prepare.py → BottleMetadata.label / .color → metadata.json
|
||||||
|
│
|
||||||
|
└─► contrib/claude/agent_provider.py → claude.json {"name": label, "color": color}
|
||||||
|
(omitted when empty)
|
||||||
|
|
||||||
|
dashboard refresh
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
enumerate_active() → read_metadata(slug) → ActiveAgent.label / .color
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
_format_agent_row → label (colored) in the row string
|
||||||
|
```
|
||||||
|
|
||||||
|
### BottleSpec changes
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BottleSpec:
|
||||||
|
manifest: Manifest
|
||||||
|
agent_name: str
|
||||||
|
copy_cwd: bool
|
||||||
|
user_cwd: str
|
||||||
|
identity: str = ""
|
||||||
|
label: str = "" # operator-chosen display name; defaults to agent_name at render time
|
||||||
|
color: str = "" # one of the 16 ANSI color names, or "" for terminal default
|
||||||
|
```
|
||||||
|
|
||||||
|
`label` and `color` default to `""` so all existing callers remain valid with
|
||||||
|
no changes.
|
||||||
|
|
||||||
|
### BottleMetadata changes
|
||||||
|
|
||||||
|
Add two new fields with backward-compatible defaults:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class BottleMetadata:
|
||||||
|
identity: str
|
||||||
|
agent_name: str
|
||||||
|
cwd: str
|
||||||
|
copy_cwd: bool
|
||||||
|
started_at: str
|
||||||
|
compose_project: str
|
||||||
|
backend: str
|
||||||
|
label: str = ""
|
||||||
|
color: str = ""
|
||||||
|
```
|
||||||
|
|
||||||
|
`metadata.json` written by older bot-bottle versions won't have these keys;
|
||||||
|
`read_metadata` already uses `dict.get` with defaults, so existing slugs load
|
||||||
|
cleanly with `label=""`, `color=""`.
|
||||||
|
|
||||||
|
### ActiveAgent changes
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ActiveAgent:
|
||||||
|
backend_name: str
|
||||||
|
slug: str
|
||||||
|
agent_name: str
|
||||||
|
started_at: str
|
||||||
|
services: tuple[str, ...]
|
||||||
|
label: str = ""
|
||||||
|
color: str = ""
|
||||||
|
```
|
||||||
|
|
||||||
|
`enumerate_active()` copies `label` and `color` out of `BottleMetadata` when
|
||||||
|
constructing each `ActiveAgent`. The smolmachines backend gets the same
|
||||||
|
additions for symmetry; it reads from its own metadata path.
|
||||||
|
|
||||||
|
### Dashboard row rendering
|
||||||
|
|
||||||
|
`_format_agent_row` already falls through cleanly on missing fields. The
|
||||||
|
change is:
|
||||||
|
|
||||||
|
```python
|
||||||
|
display_name = a.label if a.label else a.agent_name
|
||||||
|
```
|
||||||
|
|
||||||
|
Color rendering uses the existing `_try_init_green()` pattern as a model.
|
||||||
|
A `_color_pair_for(color_name)` helper initialises a fresh curses color pair
|
||||||
|
for the requested named color and returns its attr (or 0 on failure). Each
|
||||||
|
unique color in the active agent list gets its own pair index. Color pairs are
|
||||||
|
allocated lazily and cached in a `dict[str, int]` that lives for the duration
|
||||||
|
of the dashboard session.
|
||||||
|
|
||||||
|
The 16 ANSI color name → curses constant mapping:
|
||||||
|
|
||||||
|
| Name | curses constant |
|
||||||
|
|------|----------------|
|
||||||
|
| `black` | `curses.COLOR_BLACK` |
|
||||||
|
| `red` | `curses.COLOR_RED` |
|
||||||
|
| `green` | `curses.COLOR_GREEN` |
|
||||||
|
| `yellow` | `curses.COLOR_YELLOW` |
|
||||||
|
| `blue` | `curses.COLOR_BLUE` |
|
||||||
|
| `magenta` | `curses.COLOR_MAGENTA` |
|
||||||
|
| `cyan` | `curses.COLOR_CYAN` |
|
||||||
|
| `white` | `curses.COLOR_WHITE` |
|
||||||
|
| `bright-*` | same constant + `curses.A_BOLD` |
|
||||||
|
|
||||||
|
Terminals that don't support color fall back to plain text (the helper returns
|
||||||
|
0, which ORed in is a no-op — same pattern as `_try_init_green`).
|
||||||
|
|
||||||
|
### Label + color prompt — dashboard
|
||||||
|
|
||||||
|
In `_new_agent_flow`, after `_picker_modal` returns a non-None name and before
|
||||||
|
`_backend_picker_modal`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
label, color = _label_color_modal(stdscr, default_label=picked)
|
||||||
|
```
|
||||||
|
|
||||||
|
`_label_color_modal` uses `curses.endwin()` → text-mode prompts → restore
|
||||||
|
(the same drop-and-resume pattern as the existing editor flow and preflight
|
||||||
|
Y/N). Two sequential prompts:
|
||||||
|
|
||||||
|
```
|
||||||
|
bot-bottle: agent label [implementer]: <operator types>
|
||||||
|
bot-bottle: color (red/green/blue/… or Enter to skip): <operator types>
|
||||||
|
```
|
||||||
|
|
||||||
|
Invalid color names are silently ignored (treated as empty). The function
|
||||||
|
returns `(label, color)` — both strings, both possibly `""`.
|
||||||
|
|
||||||
|
### Label + color prompt — CLI
|
||||||
|
|
||||||
|
In `cmd_start`, after argument parsing and before `_launch_bottle`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
label = _text_prompt_label(args.name)
|
||||||
|
color = _text_prompt_color()
|
||||||
|
```
|
||||||
|
|
||||||
|
`_text_prompt_label(default)` writes `"bot-bottle: agent label [{default}]: "`
|
||||||
|
to stderr and returns the stripped input (or `default` if blank).
|
||||||
|
`_text_prompt_color()` writes the color prompt and returns the stripped input
|
||||||
|
(or `""` if blank or invalid).
|
||||||
|
|
||||||
|
Both use `read_tty_line()` (already in `start.py`) for the read.
|
||||||
|
|
||||||
|
### Claude Code config injection
|
||||||
|
|
||||||
|
Per PRD 0050, the `claude.json` trust-marker file is written by
|
||||||
|
`ClaudeAgentProvider.provision_plan()` in
|
||||||
|
`bot_bottle/contrib/claude/agent_provider.py`. Add `label: str = ""` and
|
||||||
|
`color: str = ""` keyword parameters to `provision_plan()` on both the
|
||||||
|
`AgentProvider` ABC and `ClaudeAgentProvider`, and to the
|
||||||
|
`agent_provision_plan()` shim in `agent_provider.py`. The docker and
|
||||||
|
smolmachines `prepare.py` modules pass `spec.label` / `spec.color` when
|
||||||
|
calling `agent_provision_plan()`; other providers accept the params and ignore
|
||||||
|
them.
|
||||||
|
|
||||||
|
In `ClaudeAgentProvider.provision_plan()`, expand the JSON payload
|
||||||
|
conditionally:
|
||||||
|
|
||||||
|
```python
|
||||||
|
payload = {
|
||||||
|
"hasCompletedOnboarding": True,
|
||||||
|
"theme": "dark",
|
||||||
|
"bypassPermissionsModeAccepted": True,
|
||||||
|
"projects": claude_projects,
|
||||||
|
}
|
||||||
|
if label:
|
||||||
|
payload["name"] = label
|
||||||
|
if color:
|
||||||
|
payload["color"] = color
|
||||||
|
claude_config.write_text(json.dumps(payload, indent=2) + "\n")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation chunks
|
||||||
|
|
||||||
|
Two PRs, each independently mergeable.
|
||||||
|
|
||||||
|
### Chunk 1 — schema + storage
|
||||||
|
|
||||||
|
- Add `label: str = ""` and `color: str = ""` to `BottleSpec`,
|
||||||
|
`BottleMetadata`, and `ActiveAgent`.
|
||||||
|
- `docker/prepare.py`: copy `spec.label` / `spec.color` into `BottleMetadata`.
|
||||||
|
- `docker/enumerate.py`: copy `metadata.label` / `metadata.color` into
|
||||||
|
`ActiveAgent`.
|
||||||
|
- Add `label: str = ""` and `color: str = ""` keyword params to
|
||||||
|
`AgentProvider.provision_plan()` (ABC), `ClaudeAgentProvider.provision_plan()`
|
||||||
|
(uses them in the `claude.json` write), and the `agent_provision_plan()` shim.
|
||||||
|
Other providers (`CodexAgentProvider`) accept the params and ignore them.
|
||||||
|
- `docker/prepare.py` and `smolmachines/prepare.py`: pass `spec.label` /
|
||||||
|
`spec.color` to `agent_provision_plan()`.
|
||||||
|
- Smolmachines backend: parallel changes to metadata read/write and
|
||||||
|
`ActiveAgent` construction.
|
||||||
|
- No prompt changes; no UI changes. All existing behavior is identical.
|
||||||
|
|
||||||
|
### Chunk 2 — prompts + display
|
||||||
|
|
||||||
|
- `start.py`: add `_text_prompt_label` and `_text_prompt_color`; call them in
|
||||||
|
`cmd_start` before `_launch_bottle`; pass `label` / `color` into `BottleSpec`.
|
||||||
|
- `dashboard.py`: add `_label_color_modal` (drop-and-resume); call it in
|
||||||
|
`_new_agent_flow`; pass label/color into `BottleSpec`; add
|
||||||
|
`_color_pair_for` helper; update `_format_agent_row` to use `a.label` with
|
||||||
|
color rendering.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None.
|
||||||
Reference in New Issue
Block a user