diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index dcc79b2..4384d98 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -139,6 +139,8 @@ class AgentProvider(ABC): forward_host_credentials: bool = False, host_env: dict[str, str] | None = None, trusted_project_path: str = "", + label: str = "", + color: str = "", ) -> AgentProvisionPlan: """Build the declarative AgentProvisionPlan for one launch. Backends call this during `prepare` and consume the result as @@ -326,6 +328,8 @@ def agent_provision_plan( forward_host_credentials: bool = False, host_env: dict[str, str] | None = None, trusted_project_path: str = "", + label: str = "", + color: str = "", ) -> AgentProvisionPlan: """Back-compat shim — `prepare` callers stay the same; the work now lives on the provider plugin.""" @@ -338,6 +342,8 @@ def agent_provision_plan( forward_host_credentials=forward_host_credentials, host_env=host_env, trusted_project_path=trusted_project_path, + label=label, + color=color, ) diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index 52b5f56..1912809 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -67,6 +67,8 @@ class BottleSpec: # (`cli.py resume `) sets this to continue an existing # bottle's state. Empty string for a fresh `start`. identity: str = "" + label: str = "" + color: str = "" @dataclass(frozen=True) @@ -189,6 +191,8 @@ class ActiveAgent: agent_name: str # from metadata.json; "?" if missing started_at: str # ISO 8601 from metadata.json; "" if missing services: tuple[str, ...] # alphabetical + label: str = "" + color: str = "" class Bottle(ABC): diff --git a/bot_bottle/backend/docker/bottle_state.py b/bot_bottle/backend/docker/bottle_state.py index 673a278..80c1eea 100644 --- a/bot_bottle/backend/docker/bottle_state.py +++ b/bot_bottle/backend/docker/bottle_state.py @@ -109,6 +109,8 @@ class BottleMetadata: # for state dirs written before PRD 0040; callers default to "docker" # for backward compatibility. backend: str = "" + label: str = "" + color: str = "" 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", "")), compose_project=str(raw_typed.get("compose_project", "")), backend=str(raw_typed.get("backend", "")), + label=str(raw_typed.get("label", "")), + color=str(raw_typed.get("color", "")), ) diff --git a/bot_bottle/backend/docker/enumerate.py b/bot_bottle/backend/docker/enumerate.py index b57fc83..9348d82 100644 --- a/bot_bottle/backend/docker/enumerate.py +++ b/bot_bottle/backend/docker/enumerate.py @@ -39,6 +39,8 @@ def enumerate_active() -> list[ActiveAgent]: agent_name=metadata.agent_name if metadata else "?", started_at=metadata.started_at if metadata else "", services=tuple(sorted(services)), + label=metadata.label if metadata else "", + color=metadata.color if metadata else "", )) return out diff --git a/bot_bottle/backend/docker/prepare.py b/bot_bottle/backend/docker/prepare.py index 19a130e..73ce414 100644 --- a/bot_bottle/backend/docker/prepare.py +++ b/bot_bottle/backend/docker/prepare.py @@ -80,6 +80,8 @@ def resolve_plan( started_at=datetime.now(timezone.utc).isoformat(), compose_project=f"bot-bottle-{slug}", backend="docker", + label=spec.label, + color=spec.color, )) # Clear any leftover preserve marker from a prior capability-block # so this fresh launch can be cleaned up at session-end unless @@ -191,6 +193,8 @@ def resolve_plan( auth_token=provider.auth_token, host_env=dict(os.environ), trusted_project_path=workspace_plan.workdir, + label=spec.label, + color=spec.color, ) guest_env = dict(agent_provision.guest_env) for key, val in agent_provision.env_vars.items(): diff --git a/bot_bottle/backend/smolmachines/enumerate.py b/bot_bottle/backend/smolmachines/enumerate.py index f1a81ff..c01e4ac 100644 --- a/bot_bottle/backend/smolmachines/enumerate.py +++ b/bot_bottle/backend/smolmachines/enumerate.py @@ -64,6 +64,8 @@ def enumerate_active() -> list[ActiveAgent]: agent_name=metadata.agent_name if metadata else "?", started_at=metadata.started_at if metadata else "", services=services_by_slug.get(slug, ()), + label=metadata.label if metadata else "", + color=metadata.color if metadata else "", )) return out diff --git a/bot_bottle/backend/smolmachines/prepare.py b/bot_bottle/backend/smolmachines/prepare.py index 87c13f6..d69fb85 100644 --- a/bot_bottle/backend/smolmachines/prepare.py +++ b/bot_bottle/backend/smolmachines/prepare.py @@ -73,6 +73,8 @@ def resolve_plan( started_at=datetime.now(timezone.utc).isoformat(), compose_project="", backend="smolmachines", + label=spec.label, + color=spec.color, )) subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug) @@ -136,6 +138,8 @@ def resolve_plan( auth_token=provider.auth_token, host_env=dict(os.environ), trusted_project_path=workspace_plan.workdir, + label=spec.label, + color=spec.color, ) merged_guest_env = dict(agent_provision.guest_env) for key, val in agent_provision.env_vars.items(): diff --git a/bot_bottle/cli/list.py b/bot_bottle/cli/list.py index 715b983..e895b72 100644 --- a/bot_bottle/cli/list.py +++ b/bot_bottle/cli/list.py @@ -3,12 +3,47 @@ from __future__ import annotations import argparse +import os import sys from ..backend import enumerate_active_agents from ..manifest import Manifest 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: parser = argparse.ArgumentParser(prog=f"{PROG} list", add_help=True) @@ -27,11 +62,11 @@ def cmd_list(argv: list[str]) -> int: if not active: print("no active bot-bottle bottles", file=sys.stderr) return 0 - # One line per bottle: `\t\t\t`. - # Tab-separated keeps the format stable for shell pipelines; - # the dashboard renders the same data through its own - # formatter. + # One line per bottle: `\t\t