Files
bot-bottle/docs/prds/prd-new-named-labelled-agents.md
T

11 KiB

PRD prd-new: 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

@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 = "" 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.