11 KiB
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
- 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.
- 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. labelandcolorare stored inBottleMetadataand written to the bottle'smetadata.json. Both fields default to""(empty / unset).ActiveAgentcarrieslabelandcolor;enumerate_active()reads them frommetadata.json._format_agent_rowuses the label when non-empty (falling back toagent_name). If a non-empty color is set and the terminal supports it, the label substring is rendered in that color.BottleSpeccarrieslabelandcolor; the docker backend'spreparestep copies them intoBottleMetadata.ClaudeAgentProvider.provision_plan()inbot_bottle/contrib/claude/agent_provider.pywriteslabel→"name"andcolor→"color"into the generatedclaude.json, alongside the existing fields. Fields are omitted when empty.- The dashboard's
_new_agent_flow(PRD 0020) includes the label+color step between agent selection and the backend picker. cmd_start(CLI) includes the label+color step after argument validation and before prepare-with-preflight.- 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.jsonis 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
@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:
@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
@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:
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:
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:
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:
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 = ""andcolor: str = ""toBottleSpec,BottleMetadata, andActiveAgent. docker/prepare.py: copyspec.label/spec.colorintoBottleMetadata.docker/enumerate.py: copymetadata.label/metadata.colorintoActiveAgent.- Add
label: str = ""andcolor: str = ""keyword params toAgentProvider.provision_plan()(ABC),ClaudeAgentProvider.provision_plan()(uses them in theclaude.jsonwrite), and theagent_provision_plan()shim. Other providers (CodexAgentProvider) accept the params and ignore them. docker/prepare.pyandsmolmachines/prepare.py: passspec.label/spec.colortoagent_provision_plan().- Smolmachines backend: parallel changes to metadata read/write and
ActiveAgentconstruction. - No prompt changes; no UI changes. All existing behavior is identical.
Chunk 2 — prompts + display
start.py: add_text_prompt_labeland_text_prompt_color; call them incmd_startbefore_launch_bottle; passlabel/colorintoBottleSpec.dashboard.py: add_label_color_modal(drop-and-resume); call it in_new_agent_flow; pass label/color intoBottleSpec; add_color_pair_forhelper; update_format_agent_rowto usea.labelwith color rendering.
Open questions
None.