# PRD 0054: Named / Labelled Agents

- **Status:** Active
- **Author:** didericis
- **Created:** 2026-06-03
- **Issue:** #171

## Summary

At agent launch time, present the operator with a curses modal to optionally
set a human-readable label and color for the agent before it launches. The
modal pre-fills the label with the current agent name pattern (e.g.
`implementer-a3f9`) and leaves color unset; Enter with no changes accepts
those defaults. Store both in the bottle's `metadata.json`. Display the label —
rendered in the chosen ANSI color — in `cli list active` output, 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

`cli list active` 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 output shows:

```
docker  a3f9  implementer  egress,pipelock
docker  b81c  implementer  egress,pipelock
docker  d220  implementer  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 (picker or CLI argument) and backend,
   a curses modal appears before the preflight. The modal pre-fills the label
   with `<agent_name>-<slug_suffix>` (the same pattern currently shown in
   `list active`). No color is pre-selected.
2. In the modal, any printable keystroke immediately replaces the pre-filled
   label and starts building the new name. Backspace edits normally. Enter
   at any point confirms — accepting the pre-fill if nothing was typed, or
   the in-progress text otherwise.
3. After the label field is confirmed, the modal presents color selection:
   a list of the 16 ANSI color names the operator can navigate with arrow
   keys, or Enter / Esc with no selection to skip color entirely.
4. `label` and `color` are stored in `BottleMetadata` and written to the
   bottle's `metadata.json`. Both fields default to `""` (empty / unset).
5. `ActiveAgent` carries `label` and `color`; `enumerate_active()` reads them
   from `metadata.json`.
6. `cli list active` shows the label when non-empty (falling back to
   `agent_name`). If a non-empty color is set and the terminal supports it,
   the label is prefixed with the appropriate ANSI escape code and reset
   afterward.
7. `BottleSpec` carries `label` and `color`; both backends' `prepare` steps
   copy them into `BottleMetadata`.
8. `ClaudeAgentProvider.provision_plan()` writes `label` → `"name"` and
   `color` → `"color"` into the generated `claude.json`. Fields are omitted
   when empty.
9. `cmd_start` calls `name_color_modal` after backend selection and before
   `_launch_bottle`; passes `label` / `color` into `BottleSpec`.
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.
- Validating or constraining label content beyond the 64-byte printable cap.
- Editing the label or color of an already-running bottle.

## Design

### Data flow

```
operator input (modal)
     │
     ▼
BottleSpec.label, BottleSpec.color
     │
     ├─► docker/prepare.py  → BottleMetadata.label / .color → metadata.json
     ├─► smolmachines/prepare.py → BottleMetadata.label / .color → metadata.json
     │
     └─► contrib/claude/agent_provider.py → claude.json {"name": label, "color": color}
                                                             (omitted when empty)

cli list active
     │
     ▼
enumerate_active() → read_metadata(slug) → ActiveAgent.label / .color
     │
     ▼
cmd_list → label (with ANSI color) 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.

### `cli list active` rendering

The current row format is tab-separated:
`{backend}\t{slug}\t{agent_name}\t{services}`

With labels it becomes:
```python
display_name = a.label if a.label else a.agent_name
```

Color is rendered via ANSI escape codes. A small `_ansi_color(color_name)`
helper returns the appropriate escape prefix for the 16 named colors, or `""`
when the name is unrecognised or the terminal doesn't support color
(`NO_COLOR` env var or `not sys.stdout.isatty()`).

The 16 ANSI color name → escape mapping:

| Name | ANSI code |
|------|-----------|
| `black` | `\033[30m` |
| `red` | `\033[31m` |
| `green` | `\033[32m` |
| `yellow` | `\033[33m` |
| `blue` | `\033[34m` |
| `magenta` | `\033[35m` |
| `cyan` | `\033[36m` |
| `white` | `\033[37m` |
| `bright-black` | `\033[90m` |
| `bright-red` | `\033[91m` |
| `bright-green` | `\033[92m` |
| `bright-yellow` | `\033[93m` |
| `bright-blue` | `\033[94m` |
| `bright-magenta` | `\033[95m` |
| `bright-cyan` | `\033[96m` |
| `bright-white` | `\033[97m` |

Reset is `\033[0m`. Applied around the label substring only.

### The label+color modal

A single curses modal (`name_color_modal` in `bot_bottle/cli/tui.py`) handles
both label and color in two sequential steps within the same window.

```python
label, color = name_color_modal(default_label=f"{agent_name}-{slug_suffix}")
```

**Step 1 — label.** The window renders:

```
  Name agent
  ──────────────────────────────────────
  implementer-a3f9
  ──────────────────────────────────────
  [any key] edit  [Enter] confirm
```

The pre-filled text is shown in the input field. Any printable keystroke
immediately clears the pre-fill and starts a new name from that character
(first-keystroke-replaces semantics). Subsequent keystrokes append normally.
Backspace edits from the right. Enter confirms — accepting the pre-fill if
the field was never edited, or the typed text otherwise.

**Step 2 — color.** After confirming the label, the window transitions to:

```
  Name agent
  ──────────────────────────────────────
  implementer-a3f9  ← confirmed label
  ──────────────────────────────────────
  Color (optional)
  > (none)
    red
    green
    blue
    …
  ──────────────────────────────────────
  [↑↓] move  [Enter] select  [Esc] skip
```

The list starts with `(none)` selected. Arrow keys move the cursor; Enter
confirms the highlighted choice; Esc or `q` skips color. Each color name in
the list is rendered in its own curses color so the operator can preview the
palette.

The function returns `(label, color)` — both strings, `color` is `""` when
`(none)` is selected or the step is skipped.

### Slug suffix for the default label

The default label is `<agent_name>-<slug_suffix>`, where `slug_suffix` is the
last four characters of the slug (the same short hash shown in `list active`).

In `cmd_start` the slug is minted inside `prepare`, after the modal appears.
The modal is therefore called with the manifest agent key as a fallback
(`default_label=agent_name`). Once `prepare` returns the plan (which contains
the slug), the `BottleSpec` is not reconstructed — the label entered by the
operator is already in the spec. The full `<agent_name>-<slug_suffix>` form is
only available for display in subsequent `list active` calls once the bottle
is running.

### 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`. Both `prepare.py`
modules pass `spec.label` / `spec.color`; `CodexAgentProvider` accepts the
params and ignores them.

In `ClaudeAgentProvider.provision_plan()`:

```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` and `smolmachines/prepare.py`: copy `spec.label` /
  `spec.color` into `BottleMetadata`; pass them to `agent_provision_plan()`.
- `docker/enumerate.py` and smolmachines equivalent: 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.
  `CodexAgentProvider` accepts the params and ignores them.
- `cmd_list`: update `list active` row to use `label` when non-empty, with
  ANSI color escape codes.
- No prompt changes; no UI changes. All existing behavior is identical.

### Chunk 2 — modal

- `bot_bottle/cli/tui.py`: add `name_color_modal(default_label)` implementing
  the two-step curses window described above.
- `cmd_start`: call `name_color_modal(default_label=agent_name)` after backend
  selection and before `_launch_bottle`; pass `label` / `color` into
  `BottleSpec`.

## Open questions

None.
