PRD: Named / Labelled Agents #184
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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", "")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
# PRD prd-new: Named / Labelled Agents
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **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.
|
||||||
Reference in New Issue
Block a user