Compare commits

...

2 Commits

Author SHA1 Message Date
didericis 04d7ca2e6a feat(agents): named and labelled agents with optional ANSI color
test / unit (pull_request) Successful in 32s
test / integration (pull_request) Successful in 43s
lint / lint (push) Successful in 1m32s
prd-number / assign-numbers (push) Successful in 17s
test / unit (push) Successful in 29s
Update Quality Badges / update-badges (push) Successful in 1m18s
test / integration (push) Successful in 45s
Chunk 1 (schema + storage): BottleSpec, ActiveAgent, and BottleMetadata
gain label and color fields. Both docker and smolmachines backends
persist them to metadata.json on prepare and surface them in
enumerate_active_agents(). AgentProvider.provision_plan() passes
label/color through to the Claude provider, which injects them into
claude.json so claude-code displays the session name and color in its
header. Codex provider accepts and ignores the knobs.

Chunk 2 (curses modal + display): cmd_start presents a two-step curses
modal — first edit the label (first keystroke replaces the pre-fill),
then optionally pick a color. cli list active renders label with ANSI
escape codes when the terminal supports it, falling back to agent_name
when no label is set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 12:12:32 -04:00
didericis f6f47c2f23 docs(prd): remove dashboard references, align with current codebase
- Dashboard no longer exists; remove all references to it
- Active agent display surface is cli list active, not a TUI pane
- Label/color rendered with ANSI escape codes in list output
- Modal called from cmd_start only, no supervisor _new_agent_flow
- Remove _format_agent_row/_color_pair_for curses design (list is
  plain text); add _ansi_color() helper design instead
- Clarify slug-suffix caveat: modal appears before prepare() mints
  the slug so default label falls back to agent_name

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 12:04:17 -04:00
13 changed files with 371 additions and 81 deletions
+6
View File
@@ -139,6 +139,8 @@ class AgentProvider(ABC):
forward_host_credentials: bool = False, forward_host_credentials: bool = False,
host_env: dict[str, str] | None = None, host_env: dict[str, str] | None = None,
trusted_project_path: str = "", trusted_project_path: str = "",
label: str = "",
color: str = "",
) -> AgentProvisionPlan: ) -> AgentProvisionPlan:
"""Build the declarative AgentProvisionPlan for one launch. """Build the declarative AgentProvisionPlan for one launch.
Backends call this during `prepare` and consume the result as Backends call this during `prepare` and consume the result as
@@ -326,6 +328,8 @@ def agent_provision_plan(
forward_host_credentials: bool = False, forward_host_credentials: bool = False,
host_env: dict[str, str] | None = None, host_env: dict[str, str] | None = None,
trusted_project_path: str = "", trusted_project_path: str = "",
label: str = "",
color: str = "",
) -> AgentProvisionPlan: ) -> AgentProvisionPlan:
"""Back-compat shim — `prepare` callers stay the same; the work """Back-compat shim — `prepare` callers stay the same; the work
now lives on the provider plugin.""" now lives on the provider plugin."""
@@ -338,6 +342,8 @@ def agent_provision_plan(
forward_host_credentials=forward_host_credentials, forward_host_credentials=forward_host_credentials,
host_env=host_env, host_env=host_env,
trusted_project_path=trusted_project_path, trusted_project_path=trusted_project_path,
label=label,
color=color,
) )
+4
View File
@@ -67,6 +67,8 @@ class BottleSpec:
# (`cli.py resume <identity>`) sets this to continue an existing # (`cli.py resume <identity>`) sets this to continue an existing
# bottle's state. Empty string for a fresh `start`. # bottle's state. Empty string for a fresh `start`.
identity: str = "" identity: str = ""
label: str = ""
color: str = ""
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -189,6 +191,8 @@ class ActiveAgent:
agent_name: str # from metadata.json; "?" if missing agent_name: str # from metadata.json; "?" if missing
started_at: str # ISO 8601 from metadata.json; "" if missing started_at: str # ISO 8601 from metadata.json; "" if missing
services: tuple[str, ...] # alphabetical services: tuple[str, ...] # alphabetical
label: str = ""
color: str = ""
class Bottle(ABC): class Bottle(ABC):
@@ -109,6 +109,8 @@ class BottleMetadata:
# for state dirs written before PRD 0040; callers default to "docker" # for state dirs written before PRD 0040; callers default to "docker"
# for backward compatibility. # for backward compatibility.
backend: str = "" backend: str = ""
label: str = ""
color: str = ""
def metadata_path(identity: str) -> Path: def metadata_path(identity: str) -> Path:
@@ -144,6 +146,8 @@ def read_metadata(identity: str) -> BottleMetadata | None:
started_at=str(raw_typed.get("started_at", "")), started_at=str(raw_typed.get("started_at", "")),
compose_project=str(raw_typed.get("compose_project", "")), compose_project=str(raw_typed.get("compose_project", "")),
backend=str(raw_typed.get("backend", "")), backend=str(raw_typed.get("backend", "")),
label=str(raw_typed.get("label", "")),
color=str(raw_typed.get("color", "")),
) )
+2
View File
@@ -39,6 +39,8 @@ def enumerate_active() -> list[ActiveAgent]:
agent_name=metadata.agent_name if metadata else "?", agent_name=metadata.agent_name if metadata else "?",
started_at=metadata.started_at if metadata else "", started_at=metadata.started_at if metadata else "",
services=tuple(sorted(services)), services=tuple(sorted(services)),
label=metadata.label if metadata else "",
color=metadata.color if metadata else "",
)) ))
return out return out
+4
View File
@@ -80,6 +80,8 @@ def resolve_plan(
started_at=datetime.now(timezone.utc).isoformat(), started_at=datetime.now(timezone.utc).isoformat(),
compose_project=f"bot-bottle-{slug}", compose_project=f"bot-bottle-{slug}",
backend="docker", backend="docker",
label=spec.label,
color=spec.color,
)) ))
# Clear any leftover preserve marker from a prior capability-block # Clear any leftover preserve marker from a prior capability-block
# so this fresh launch can be cleaned up at session-end unless # so this fresh launch can be cleaned up at session-end unless
@@ -191,6 +193,8 @@ def resolve_plan(
auth_token=provider.auth_token, auth_token=provider.auth_token,
host_env=dict(os.environ), host_env=dict(os.environ),
trusted_project_path=workspace_plan.workdir, trusted_project_path=workspace_plan.workdir,
label=spec.label,
color=spec.color,
) )
guest_env = dict(agent_provision.guest_env) guest_env = dict(agent_provision.guest_env)
for key, val in agent_provision.env_vars.items(): for key, val in agent_provision.env_vars.items():
@@ -64,6 +64,8 @@ def enumerate_active() -> list[ActiveAgent]:
agent_name=metadata.agent_name if metadata else "?", agent_name=metadata.agent_name if metadata else "?",
started_at=metadata.started_at if metadata else "", started_at=metadata.started_at if metadata else "",
services=services_by_slug.get(slug, ()), services=services_by_slug.get(slug, ()),
label=metadata.label if metadata else "",
color=metadata.color if metadata else "",
)) ))
return out return out
@@ -73,6 +73,8 @@ def resolve_plan(
started_at=datetime.now(timezone.utc).isoformat(), started_at=datetime.now(timezone.utc).isoformat(),
compose_project="", compose_project="",
backend="smolmachines", backend="smolmachines",
label=spec.label,
color=spec.color,
)) ))
subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug) subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug)
@@ -136,6 +138,8 @@ def resolve_plan(
auth_token=provider.auth_token, auth_token=provider.auth_token,
host_env=dict(os.environ), host_env=dict(os.environ),
trusted_project_path=workspace_plan.workdir, trusted_project_path=workspace_plan.workdir,
label=spec.label,
color=spec.color,
) )
merged_guest_env = dict(agent_provision.guest_env) merged_guest_env = dict(agent_provision.guest_env)
for key, val in agent_provision.env_vars.items(): for key, val in agent_provision.env_vars.items():
+40 -5
View File
@@ -3,12 +3,47 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import os
import sys import sys
from ..backend import enumerate_active_agents from ..backend import enumerate_active_agents
from ..manifest import Manifest from ..manifest import Manifest
from ._common import PROG, USER_CWD from ._common import PROG, USER_CWD
_ANSI_COLOR_CODES: dict[str, str] = {
"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",
}
_ANSI_RESET = "\033[0m"
def _ansi_label(text: str, color: str) -> str:
if not color:
return text
if not sys.stdout.isatty():
return text
term = os.environ.get("TERM", "")
if term in ("dumb", ""):
return text
code = _ANSI_COLOR_CODES.get(color)
if not code:
return text
return f"{code}{text}{_ANSI_RESET}"
def cmd_list(argv: list[str]) -> int: def cmd_list(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} list", add_help=True) parser = argparse.ArgumentParser(prog=f"{PROG} list", add_help=True)
@@ -27,11 +62,11 @@ def cmd_list(argv: list[str]) -> int:
if not active: if not active:
print("no active bot-bottle bottles", file=sys.stderr) print("no active bot-bottle bottles", file=sys.stderr)
return 0 return 0
# One line per bottle: `<backend>\t<slug>\t<agent>\t<status>`. # One line per bottle: `<backend>\t<slug>\t<label>\t<services>`.
# Tab-separated keeps the format stable for shell pipelines; # Tab-separated keeps the format stable for shell pipelines.
# the dashboard renders the same data through its own
# formatter.
for b in active: for b in active:
services = ",".join(b.services) if b.services else "-" services = ",".join(b.services) if b.services else "-"
print(f"{b.backend_name}\t{b.slug}\t{b.agent_name}\t{services}") display_name = b.label if b.label else b.agent_name
colored_name = _ansi_label(display_name, b.color)
print(f"{b.backend_name}\t{b.slug}\t{colored_name}\t{services}")
return 0 return 0
+4
View File
@@ -80,11 +80,15 @@ def cmd_start(argv: list[str]) -> int:
if backend_name is None: if backend_name is None:
return 0 return 0
label, color = tui.name_color_modal(default_label=agent_name)
spec = BottleSpec( spec = BottleSpec(
manifest=manifest, manifest=manifest,
agent_name=agent_name, agent_name=agent_name,
copy_cwd=args.cwd, copy_cwd=args.cwd,
user_cwd=USER_CWD, user_cwd=USER_CWD,
label=label,
color=color,
) )
return _launch_bottle( return _launch_bottle(
spec, spec,
+217
View File
@@ -3,6 +3,7 @@
Exposed surface: Exposed surface:
filter_select(items, *, title="", tty_path="/dev/tty") -> str | None filter_select(items, *, title="", tty_path="/dev/tty") -> str | None
name_color_modal(default_label, *, tty_path="/dev/tty") -> (str, str)
Opens /dev/tty directly so the picker works even when stdout/stdin are Opens /dev/tty directly so the picker works even when stdout/stdin are
redirected. Returns the selected item or None on cancel. redirected. Returns the selected item or None on cancel.
@@ -218,3 +219,219 @@ def _addstr_safe(screen: Any, row: int, col: int, text: str, attr: int = curses.
screen.addstr(row, col, text, attr) screen.addstr(row, col, text, attr)
except curses.error: except curses.error:
pass pass
# ---------------------------------------------------------------------------
# name_color_modal — two-step label + color picker
# ---------------------------------------------------------------------------
_ANSI_COLORS = [
"red", "green", "blue", "yellow", "magenta", "cyan", "white", "black",
"bright-red", "bright-green", "bright-blue", "bright-yellow",
"bright-magenta", "bright-cyan", "bright-white", "bright-black",
]
_CURSES_COLOR_MAP: dict[str, int] = {
"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,
}
_COLOR_NONE = "(none)"
def name_color_modal(
default_label: str,
*,
tty_path: str = "/dev/tty",
) -> tuple[str, str]:
"""Present a two-step curses modal: first edit the agent label,
then optionally pick a color.
Returns ``(label, color)`` where ``color`` is one of the 16 ANSI
color name strings or ``""`` for no color. Falls back to
``(default_label, "")`` on any error (terminal too small, not a tty).
"""
try:
tty_fd = open(tty_path, "r+b", buffering=0) # pylint: disable=consider-using-with
except OSError:
return default_label, ""
try:
fd_dup = os.dup(tty_fd.fileno())
return _run_name_color(default_label, tty_fd=fd_dup)
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
return default_label, ""
finally:
tty_fd.close()
def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]:
import io
orig_stdin = sys.__stdin__
orig_stdout = sys.__stdout__
try:
tty_text = io.TextIOWrapper(io.FileIO(tty_fd, mode="r+"), write_through=True)
sys.__stdin__ = tty_text # type: ignore[assignment]
sys.__stdout__ = tty_text # type: ignore[assignment]
os.environ.setdefault("TERM", "xterm-256color")
screen = curses.initscr()
curses.noecho()
curses.cbreak()
screen.keypad(True)
try:
label = _label_step(screen, default_label)
color = _color_step(screen, label)
finally:
screen.keypad(False)
curses.nocbreak()
curses.echo()
curses.endwin()
finally:
sys.__stdin__ = orig_stdin # type: ignore[assignment]
sys.__stdout__ = orig_stdout # type: ignore[assignment]
return label, color
def _label_step(screen: Any, default_label: str) -> str:
"""Step 1: edit the label. First printable key replaces the
pre-fill; subsequent keys append. Enter confirms."""
text = default_label
replaced = False # True once the user has typed their first char
while True:
_render_label(screen, text)
try:
key = screen.getch()
except KeyboardInterrupt:
return default_label
if key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r")):
return text.strip() or default_label
if key in (curses.KEY_BACKSPACE, _KEY_BACKSPACE_WIN, 127):
if replaced:
text = text[:-1]
else:
text = ""
replaced = True
elif 32 <= key <= 126:
if not replaced:
text = chr(key)
replaced = True
else:
text += chr(key)
def _render_label(screen: Any, text: str) -> None:
screen.erase()
rows, cols = screen.getmaxyx()
sep = "" * min(cols - 1, 40)
_addstr_safe(screen, 0, 0, "Name agent", curses.A_BOLD)
_addstr_safe(screen, 1, 0, sep)
_addstr_safe(screen, 2, 0, text[:cols - 1], curses.A_REVERSE)
_addstr_safe(screen, 3, 0, sep)
if rows > 5:
_addstr_safe(screen, 5, 0, "[any key] edit [Enter] confirm", curses.A_DIM)
screen.refresh()
def _color_step(screen: Any, confirmed_label: str) -> str:
"""Step 2: pick a color from the list, or skip."""
items = [_COLOR_NONE] + _ANSI_COLORS
cursor = 0
# Initialise color pairs once; index 0 = none, 1..16 = palette.
color_attrs = _init_color_pairs()
while True:
_render_color(screen, items, cursor, confirmed_label, color_attrs)
try:
key = screen.getch()
except KeyboardInterrupt:
return ""
if key in (ord("q"), _KEY_ESC):
return ""
if key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r")):
chosen = items[cursor]
return "" if chosen == _COLOR_NONE else chosen
if key in (curses.KEY_UP, ord("k")) and cursor > 0:
cursor -= 1
elif key in (curses.KEY_DOWN, ord("j")) and cursor < len(items) - 1:
cursor += 1
def _init_color_pairs() -> dict[str, int]:
"""Return {color_name: curses_attr} for the palette items."""
attrs: dict[str, int] = {_COLOR_NONE: curses.A_NORMAL}
try:
curses.start_color()
curses.use_default_colors()
pair_idx = 2 # pair 1 reserved for other uses
for name in _ANSI_COLORS:
base = name.replace("bright-", "")
fg = _CURSES_COLOR_MAP.get(base, curses.COLOR_WHITE)
try:
curses.init_pair(pair_idx, fg, -1)
attr = curses.color_pair(pair_idx)
if name.startswith("bright-"):
attr |= curses.A_BOLD
attrs[name] = attr
pair_idx += 1
except curses.error:
attrs[name] = curses.A_NORMAL
except curses.error:
for name in _ANSI_COLORS:
attrs[name] = curses.A_NORMAL
return attrs
def _render_color(
screen: Any,
items: list[str],
cursor: int,
confirmed_label: str,
color_attrs: dict[str, int],
) -> None:
screen.erase()
rows, cols = screen.getmaxyx()
sep = "" * min(cols - 1, 40)
_addstr_safe(screen, 0, 0, "Name agent", curses.A_BOLD)
_addstr_safe(screen, 1, 0, sep)
_addstr_safe(screen, 2, 0, confirmed_label[:cols - 1])
_addstr_safe(screen, 3, 0, sep)
_addstr_safe(screen, 4, 0, "Color (optional)", curses.A_BOLD)
list_start = 5
list_rows = rows - list_start - 2
scroll = max(0, cursor - list_rows + 1)
visible = items[scroll: scroll + list_rows]
for idx, name in enumerate(visible):
abs_idx = scroll + idx
row = list_start + idx
if row >= rows - 2:
break
prefix = "> " if abs_idx == cursor else " "
attr = color_attrs.get(name, curses.A_NORMAL)
if abs_idx == cursor:
attr |= curses.A_REVERSE
_addstr_safe(screen, row, 0, (prefix + name)[:cols - 1], attr)
_addstr_safe(screen, rows - 2, 0, sep)
_addstr_safe(
screen, rows - 1, 0,
"[↑↓/jk] move [Enter] select [Esc/q] skip",
curses.A_DIM,
)
screen.refresh()
+9 -2
View File
@@ -68,6 +68,8 @@ class ClaudeAgentProvider(AgentProvider):
forward_host_credentials: bool = False, forward_host_credentials: bool = False,
host_env: dict[str, str] | None = None, host_env: dict[str, str] | None = None,
trusted_project_path: str = "", trusted_project_path: str = "",
label: str = "",
color: str = "",
) -> AgentProvisionPlan: ) -> AgentProvisionPlan:
del forward_host_credentials, host_env # Codex-only knobs del forward_host_credentials, host_env # Codex-only knobs
resolved_guest_env = dict(guest_env or {}) resolved_guest_env = dict(guest_env or {})
@@ -80,12 +82,17 @@ class ClaudeAgentProvider(AgentProvider):
claude_config = state_dir / "claude.json" claude_config = state_dir / "claude.json"
claude_projects = {guest_home: {"hasTrustDialogAccepted": True}} claude_projects = {guest_home: {"hasTrustDialogAccepted": True}}
claude_projects[trusted_path] = {"hasTrustDialogAccepted": True} claude_projects[trusted_path] = {"hasTrustDialogAccepted": True}
claude_config.write_text(json.dumps({ payload: dict[str, object] = {
"hasCompletedOnboarding": True, "hasCompletedOnboarding": True,
"theme": "dark", "theme": "dark",
"bypassPermissionsModeAccepted": True, "bypassPermissionsModeAccepted": True,
"projects": claude_projects, "projects": claude_projects,
}, indent=2) + "\n") }
if label:
payload["name"] = label
if color:
payload["color"] = color
claude_config.write_text(json.dumps(payload, indent=2) + "\n")
claude_config.chmod(0o600) claude_config.chmod(0o600)
files = ( files = (
AgentProvisionFile(claude_config, f"{guest_home}/.claude.json"), AgentProvisionFile(claude_config, f"{guest_home}/.claude.json"),
+3 -1
View File
@@ -76,8 +76,10 @@ class CodexAgentProvider(AgentProvider):
forward_host_credentials: bool = False, forward_host_credentials: bool = False,
host_env: dict[str, str] | None = None, host_env: dict[str, str] | None = None,
trusted_project_path: str = "", trusted_project_path: str = "",
label: str = "",
color: str = "",
) -> AgentProvisionPlan: ) -> AgentProvisionPlan:
del auth_token # Claude-only knob del auth_token, label, color # Claude-only knobs
resolved_guest_env = dict(guest_env or {}) resolved_guest_env = dict(guest_env or {})
trusted_path = trusted_project_path or guest_home trusted_path = trusted_project_path or guest_home
+72 -73
View File
@@ -12,22 +12,22 @@ 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. modal pre-fills the label with the current agent name pattern (e.g.
`implementer-a3f9`) and leaves color unset; Enter with no changes accepts `implementer-a3f9`) and leaves color unset; Enter with no changes accepts
those defaults. Store both in the bottle's `metadata.json`. Display the label — those defaults. Store both in the bottle's `metadata.json`. Display the label —
rendered in the chosen color — in the supervisor's active-agents pane, rendered in the chosen ANSI color — in `cli list active` output, replacing
replacing the bare manifest key. Inject the label and color into the the bare manifest key. Inject the label and color into the in-container
in-container `claude.json` as `name` / `color` so Claude Code can surface them `claude.json` as `name` / `color` so Claude Code can surface them in its own
in its own harness when upstream support lands. harness when upstream support lands.
## Problem ## Problem
The supervisor's agents pane identifies each running instance by its manifest `cli list active` identifies each running instance by its manifest agent key
agent key (e.g., `implementer`) plus a random slug suffix. When an operator (e.g., `implementer`) plus a random slug suffix. When an operator runs three
runs three `implementer` bottles simultaneously — one each for three different `implementer` bottles simultaneously — one each for three different repos —
repos — the pane shows: the output shows:
``` ```
[docker] a3f9 implementer started 14:02:11 [egress,pipelock] docker a3f9 implementer egress,pipelock
[docker] b81c implementer started 14:03:45 [egress,pipelock] docker b81c implementer egress,pipelock
[docker] d220 implementer started 14:05:01 [egress,pipelock] docker d220 implementer egress,pipelock
``` ```
There is no way to tell which bottle is working on which task without attaching There is no way to tell which bottle is working on which task without attaching
@@ -37,34 +37,33 @@ which breaks the moment they switch windows.
## Goals / Success Criteria ## Goals / Success Criteria
1. After the operator selects an agent (supervisor picker or CLI argument), a 1. After the operator selects an agent (picker or CLI argument) and backend,
curses modal appears before the backend picker / preflight. The modal a curses modal appears before the preflight. The modal pre-fills the label
pre-fills the label with `<agent_name>-<slug_suffix>` (the same pattern with `<agent_name>-<slug_suffix>` (the same pattern currently shown in
currently shown in the agents pane). No color is pre-selected. `list active`). No color is pre-selected.
2. In the modal, any printable keystroke immediately replaces the pre-filled 2. In the modal, any printable keystroke immediately replaces the pre-filled
label and starts building the new name. Backspace edits normally. Enter label and starts building the new name. Backspace edits normally. Enter
at any point confirms — accepting the pre-fill if nothing was typed, or at any point confirms — accepting the pre-fill if nothing was typed, or
the in-progress text otherwise. the in-progress text otherwise.
3. After the label field is confirmed, the modal presents color selection: 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 a list of the 16 ANSI color names the operator can navigate with arrow
keys, or Enter with no selection to skip color entirely. keys, or Enter / Esc with no selection to skip color entirely.
4. `label` and `color` are stored in `BottleMetadata` and written to the 4. `label` and `color` are stored in `BottleMetadata` and written to the
bottle's `metadata.json`. Both fields default to `""` (empty / unset). bottle's `metadata.json`. Both fields default to `""` (empty / unset).
5. `ActiveAgent` carries `label` and `color`; `enumerate_active()` reads them 5. `ActiveAgent` carries `label` and `color`; `enumerate_active()` reads them
from `metadata.json`. from `metadata.json`.
6. The supervisor's agent row uses the label when non-empty (falling back to 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, `agent_name`). If a non-empty color is set and the terminal supports it,
the label substring is rendered in that color. the label is prefixed with the appropriate ANSI escape code and reset
afterward.
7. `BottleSpec` carries `label` and `color`; both backends' `prepare` steps 7. `BottleSpec` carries `label` and `color`; both backends' `prepare` steps
copy them into `BottleMetadata`. copy them into `BottleMetadata`.
8. `ClaudeAgentProvider.provision_plan()` writes `label``"name"` and 8. `ClaudeAgentProvider.provision_plan()` writes `label``"name"` and
`color``"color"` into the generated `claude.json`. Fields are omitted `color``"color"` into the generated `claude.json`. Fields are omitted
when empty. when empty.
9. The supervisor's `_new_agent_flow` includes the modal between agent 9. `cmd_start` calls `name_color_modal` after backend selection and before
selection and the backend picker. `_launch_bottle`; passes `label` / `color` into `BottleSpec`.
10. `cmd_start` (CLI) shows the same modal (via the shared `tui` module) 10. All existing unit tests stay green; no new tests are required for this
before `_launch_bottle`; passes `label` / `color` into `BottleSpec`.
11. 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 change (the label/color fields are thin plumbing with no branching logic
worth unit-testing beyond the already-tested metadata read/write path). worth unit-testing beyond the already-tested metadata read/write path).
@@ -73,12 +72,8 @@ which breaks the moment they switch windows.
- Showing the agent label inside the Claude Code TUI (status line, terminal - Showing the agent label inside the Claude Code TUI (status line, terminal
title, custom header). That requires upstream Claude Code / codex support. title, custom header). That requires upstream Claude Code / codex support.
Writing to `claude.json` is best-effort scaffolding for when that lands. Writing to `claude.json` is best-effort scaffolding for when that lands.
- Per-bottle color affecting anything outside the supervisor agents pane.
- Validating or constraining label content beyond the 64-byte printable cap. - Validating or constraining label content beyond the 64-byte printable cap.
- Persisting color-pair state across supervisor restarts (color pairs are
initialized fresh each session).
- Editing the label or color of an already-running bottle. - Editing the label or color of an already-running bottle.
- Exposing label/color via `./cli.py list` (out of scope for v1).
## Design ## Design
@@ -96,13 +91,13 @@ BottleSpec.label, BottleSpec.color
└─► contrib/claude/agent_provider.py → claude.json {"name": label, "color": color} └─► contrib/claude/agent_provider.py → claude.json {"name": label, "color": color}
(omitted when empty) (omitted when empty)
supervisor refresh cli list active
enumerate_active() → read_metadata(slug) → ActiveAgent.label / .color enumerate_active() → read_metadata(slug) → ActiveAgent.label / .color
agent row → label (colored) in the row string cmd_list → label (with ANSI color) in the row string
``` ```
### BottleSpec changes ### BottleSpec changes
@@ -162,42 +157,48 @@ class ActiveAgent:
constructing each `ActiveAgent`. The smolmachines backend gets the same constructing each `ActiveAgent`. The smolmachines backend gets the same
additions for symmetry. additions for symmetry.
### Agent row rendering ### `cli list active` rendering
The display name logic becomes: The current row format is tab-separated:
`{backend}\t{slug}\t{agent_name}\t{services}`
With labels it becomes:
```python ```python
display_name = a.label if a.label else a.agent_name display_name = a.label if a.label else a.agent_name
``` ```
Color rendering uses the existing `_try_init_green()` pattern as a model. Color is rendered via ANSI escape codes. A small `_ansi_color(color_name)`
A `_color_pair_for(color_name)` helper initialises a fresh curses color pair helper returns the appropriate escape prefix for the 16 named colors, or `""`
for the requested named color and returns its attr (or 0 on failure). Color when the name is unrecognised or the terminal doesn't support color
pairs are allocated lazily and cached in a `dict[str, int]` that lives for (`NO_COLOR` env var or `not sys.stdout.isatty()`).
the duration of the supervisor session.
The 16 ANSI color name → curses constant mapping: The 16 ANSI color name → escape mapping:
| Name | curses constant | | Name | ANSI code |
|------|----------------| |------|-----------|
| `black` | `curses.COLOR_BLACK` | | `black` | `\033[30m` |
| `red` | `curses.COLOR_RED` | | `red` | `\033[31m` |
| `green` | `curses.COLOR_GREEN` | | `green` | `\033[32m` |
| `yellow` | `curses.COLOR_YELLOW` | | `yellow` | `\033[33m` |
| `blue` | `curses.COLOR_BLUE` | | `blue` | `\033[34m` |
| `magenta` | `curses.COLOR_MAGENTA` | | `magenta` | `\033[35m` |
| `cyan` | `curses.COLOR_CYAN` | | `cyan` | `\033[36m` |
| `white` | `curses.COLOR_WHITE` | | `white` | `\033[37m` |
| `bright-*` | same constant + `curses.A_BOLD` | | `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` |
Terminals that don't support color fall back to plain text (the helper returns Reset is `\033[0m`. Applied around the label substring only.
0, which ORed in is a no-op).
### The label+color modal ### The label+color modal
A single curses modal (`name_color_modal` in `bot_bottle/cli/tui.py`) handles 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. It is both label and color in two sequential steps within the same window.
called identically from the supervisor flow and from `cmd_start`.
```python ```python
label, color = name_color_modal(default_label=f"{agent_name}-{slug_suffix}") label, color = name_color_modal(default_label=f"{agent_name}-{slug_suffix}")
@@ -237,9 +238,9 @@ the field was never edited, or the typed text otherwise.
``` ```
The list starts with `(none)` selected. Arrow keys move the cursor; Enter The list starts with `(none)` selected. Arrow keys move the cursor; Enter
confirms the highlighted choice; Esc or `q` skips color (equivalent to confirms the highlighted choice; Esc or `q` skips color. Each color name in
selecting `(none)`). Each color name in the list is rendered in its own color the list is rendered in its own curses color so the operator can preview the
so the operator can preview the palette. palette.
The function returns `(label, color)` — both strings, `color` is `""` when The function returns `(label, color)` — both strings, `color` is `""` when
`(none)` is selected or the step is skipped. `(none)` is selected or the step is skipped.
@@ -247,16 +248,15 @@ The function returns `(label, color)` — both strings, `color` is `""` when
### Slug suffix for the default label ### Slug suffix for the default label
The default label is `<agent_name>-<slug_suffix>`, where `slug_suffix` is the 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 the agents last four characters of the slug (the same short hash shown in `list active`).
pane). Both the supervisor and `cmd_start` have access to the slug after
`bottle_identity()` is called; the default label is computed there and passed
to `name_color_modal`.
In `cmd_start` the slug is not yet known at the time the modal appears (it is In `cmd_start` the slug is minted inside `prepare`, after the modal appears.
minted inside `prepare`). The modal is therefore called with The modal is therefore called with the manifest agent key as a fallback
`default_label=args.name` (the manifest agent key) as a simpler fallback; the (`default_label=agent_name`). Once `prepare` returns the plan (which contains
supervisor path, which has the slug available from `_new_agent_flow`, uses the the slug), the `BottleSpec` is not reconstructed — the label entered by the
full `<agent_name>-<slug_suffix>` form. 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 ### Claude Code config injection
@@ -266,8 +266,8 @@ Per PRD 0050, the `claude.json` trust-marker file is written by
`color: str = ""` keyword parameters to `provision_plan()` on both the `color: str = ""` keyword parameters to `provision_plan()` on both the
`AgentProvider` ABC and `ClaudeAgentProvider`, and to the `AgentProvider` ABC and `ClaudeAgentProvider`, and to the
`agent_provision_plan()` shim in `agent_provider.py`. Both `prepare.py` `agent_provision_plan()` shim in `agent_provider.py`. Both `prepare.py`
modules pass `spec.label` / `spec.color`; other providers accept the params modules pass `spec.label` / `spec.color`; `CodexAgentProvider` accepts the
and ignore them. params and ignores them.
In `ClaudeAgentProvider.provision_plan()`: In `ClaudeAgentProvider.provision_plan()`:
@@ -301,18 +301,17 @@ Two PRs, each independently mergeable.
`AgentProvider.provision_plan()` (ABC), `ClaudeAgentProvider.provision_plan()` `AgentProvider.provision_plan()` (ABC), `ClaudeAgentProvider.provision_plan()`
(uses them in the `claude.json` write), and the `agent_provision_plan()` shim. (uses them in the `claude.json` write), and the `agent_provision_plan()` shim.
`CodexAgentProvider` accepts the params and ignores them. `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. - No prompt changes; no UI changes. All existing behavior is identical.
### Chunk 2 — modal + display ### Chunk 2 — modal
- `bot_bottle/cli/tui.py`: add `name_color_modal(default_label)` implementing - `bot_bottle/cli/tui.py`: add `name_color_modal(default_label)` implementing
the two-step curses window described above. the two-step curses window described above.
- Supervisor `_new_agent_flow`: call `name_color_modal` after agent selection, - `cmd_start`: call `name_color_modal(default_label=agent_name)` after backend
pass `label` / `color` into `BottleSpec`. selection and before `_launch_bottle`; pass `label` / `color` into
- `cmd_start`: call `name_color_modal(default_label=args.name)` before `BottleSpec`.
`_launch_bottle`; pass `label` / `color` into `BottleSpec`.
- Supervisor agent row: add `_color_pair_for` helper; update row rendering to
use `a.label` with color.
## Open questions ## Open questions