refactor(agent): use agent-neutral runtime names

Assisted-by: Codex
This commit is contained in:
2026-05-28 17:59:24 -04:00
parent c08b09dc9f
commit 1cbedc91c0
23 changed files with 200 additions and 191 deletions
+11 -6
View File
@@ -9,11 +9,13 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
PROVIDER_CLAUDE = "claude"
PROVIDER_CODEX = "codex"
PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX})
PromptMode = Literal["append_file", "read_prompt_file"]
@dataclass(frozen=True)
@@ -24,7 +26,7 @@ class AgentProviderRuntime:
dockerfile: str
auth_role: str
placeholder_env: str
prompt_mode: str
prompt_mode: PromptMode
bypass_args: tuple[str, ...]
resume_args: tuple[str, ...]
remote_control_args: tuple[str, ...]
@@ -41,7 +43,7 @@ _RUNTIMES = {
dockerfile=str(_REPO_ROOT / "Dockerfile.claude"),
auth_role="claude_code_oauth",
placeholder_env="CLAUDE_CODE_OAUTH_TOKEN",
prompt_mode="claude_append_file",
prompt_mode="append_file",
bypass_args=("--dangerously-skip-permissions",),
resume_args=("--continue",),
remote_control_args=("--remote-control",),
@@ -53,7 +55,7 @@ _RUNTIMES = {
dockerfile=str(_REPO_ROOT / "Dockerfile.codex"),
auth_role="codex_auth",
placeholder_env="OPENAI_API_KEY",
prompt_mode="codex_read_prompt_file",
prompt_mode="read_prompt_file",
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
resume_args=("resume", "--last"),
remote_control_args=(),
@@ -66,13 +68,16 @@ def runtime_for(template: str) -> AgentProviderRuntime:
def prompt_args(
prompt_mode: str, prompt_path: str | None, *, argv: list[str] | None = None,
prompt_mode: PromptMode,
prompt_path: str | None,
*,
argv: list[str] | None = None,
) -> list[str]:
if not prompt_path:
return []
if prompt_mode == "claude_append_file":
if prompt_mode == "append_file":
return ["--append-system-prompt-file", prompt_path]
if prompt_mode == "codex_read_prompt_file":
if prompt_mode == "read_prompt_file":
if argv and "resume" in argv:
return []
return [f"Read and follow the instructions in {prompt_path}."]
+8 -8
View File
@@ -130,8 +130,8 @@ class ActiveAgent:
class Bottle(ABC):
"""Handle to a running bottle. Yielded by a backend's launch step.
`exec_claude` runs `claude` inside the bottle and blocks until the
session ends. `exec` runs a POSIX shell script inside the bottle
`exec_agent` runs the selected agent CLI inside the bottle and
blocks until the session ends. `exec` runs a POSIX shell script inside the bottle
and returns the captured result. `cp_in` copies a host path into
the bottle. `close` is an idempotent alias for context-manager
teardown.
@@ -140,11 +140,11 @@ class Bottle(ABC):
name: str
@abstractmethod
def claude_argv(
def agent_argv(
self, argv: list[str], *, tty: bool = True,
) -> list[str]:
"""Return the host-side argv that runs `claude <argv>`
inside the bottle. Used by `exec_claude` for foreground
"""Return the host-side argv that runs the selected agent
inside the bottle. Used by `exec_agent` for foreground
handoffs and by the dashboard's tmux `respawn-pane` flow,
which needs the argv up front (it spawns claude in a tmux
pane rather than as a child of the current process).
@@ -155,7 +155,7 @@ class Bottle(ABC):
...
@abstractmethod
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: ...
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int: ...
@abstractmethod
def exec(self, script: str, *, user: str = "node") -> ExecResult:
@@ -270,7 +270,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
backend-specific terms (Docker: resolved container name; fly:
machine id). Returns the in-container prompt path if a prompt
was provisioned, else None — the Bottle handle uses it to
decide whether to add --append-system-prompt-file to claude's
decide whether to add provider-specific prompt args to the agent's
argv.
Default orchestration: ca → prompt → skills → git →
@@ -305,7 +305,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
"""Copy the prompt file into the running bottle. Returns the
in-container path iff the agent has a non-empty prompt;
callers use the return value to decide whether to add
--append-system-prompt-file to claude's argv."""
provider-specific prompt args to the agent's argv."""
@abstractmethod
def provision_skills(self, plan: PlanT, target: str) -> None:
+6 -7
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import subprocess
from typing import Callable
from ...agent_provider import prompt_args
from ...agent_provider import PromptMode, prompt_args
from .. import Bottle, ExecResult
@@ -19,12 +19,11 @@ class DockerBottle(Bottle):
prompt_path_in_container: str | None,
*,
agent_command: str = "claude",
agent_prompt_mode: str = "claude_append_file",
agent_prompt_mode: PromptMode = "append_file",
):
self.name = container
self._teardown = teardown
self._prompt_path = prompt_path_in_container
self._agent_command = agent_command
self._agent_prompt_mode = agent_prompt_mode
self.agent_command = agent_command
self.agent_provider_template = (
@@ -32,7 +31,7 @@ class DockerBottle(Bottle):
)
self._closed = False
def claude_argv(
def agent_argv(
self, argv: list[str], *, tty: bool = True,
) -> list[str]:
full_argv = list(argv)
@@ -42,12 +41,12 @@ class DockerBottle(Bottle):
cmd = ["docker", "exec"]
if tty:
cmd.append("-it")
cmd.extend([self.name, self._agent_command, *full_argv])
cmd.extend([self.name, self.agent_command, *full_argv])
return cmd
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int:
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
return subprocess.run(
self.claude_argv(argv, tty=tty), check=False,
self.agent_argv(argv, tty=tty), check=False,
).returncode
def exec(self, script: str, *, user: str = "node") -> ExecResult:
+2 -1
View File
@@ -11,6 +11,7 @@ import sys
from dataclasses import dataclass, field
from pathlib import Path
from ...agent_provider import PromptMode
from ...egress import EgressPlan
from ...git_gate import GitGatePlan
from ...log import info
@@ -52,7 +53,7 @@ class DockerBottlePlan(BottlePlan):
supervise_plan: SupervisePlan | None
use_runsc: bool
agent_command: str = "claude"
agent_prompt_mode: str = "claude_append_file"
agent_prompt_mode: PromptMode = "append_file"
agent_provider_template: str = "claude"
def print(self, *, remote_control: bool) -> None:
+2 -2
View File
@@ -23,7 +23,7 @@ The flow is:
entries inherit without rendering values into the file).
8. Provision (CA install, prompt copy, skills, git, supervise
config) — unchanged, uses `docker exec`.
9. Yield a DockerBottle handle. `exec_claude` runs claude via
9. Yield a DockerBottle handle. `exec_agent` runs claude via
`docker exec -it` exactly like the pre-compose world.
Teardown (ExitStack callbacks fire in reverse):
@@ -204,7 +204,7 @@ def launch(
# the agent container by its known name.
prompt_path = provision(plan, plan.container_name)
# Step 9: yield. exec_claude continues to use `docker exec -it`
# Step 9: yield. exec_agent continues to use `docker exec -it`
# — the agent runs `sleep infinity` per the renderer's
# service spec.
yield DockerBottle(
+20 -21
View File
@@ -1,15 +1,15 @@
"""SmolmachinesBottle — running-instance handle (PRD 0023 chunk 2d).
Routes `exec_claude` / `exec` / `cp_in` through `smolvm machine
Routes `exec_agent` / `exec` / `cp_in` through `smolvm machine
exec` / `smolvm machine cp`. The handle is yielded by `launch`
and torn down via the surrounding ExitStack on context exit;
`close` is a no-op idempotent alias so the BottleBackend ABC's
context-manager contract is satisfied.
User context: `smolvm machine exec` runs commands as root in the
VM, but the agent image's USER is `node` and claude-code refuses
to run as root with `--dangerously-skip-permissions`. Both
`exec_claude` and `exec` switch to the requested user (default
VM, but the agent image's USER is `node` and agent CLIs may refuse
to run as root in bypass modes. Both
`exec_agent` and `exec` switch to the requested user (default
`node`) via `runuser -u <user> --` and set `HOME` / `USER`
through `smolvm -e` — avoiding `runuser -l`'s login-shell wiring
(PAM session setup, /etc/profile sourcing) which can hang on a
@@ -21,7 +21,7 @@ import subprocess
import sys
from typing import Mapping
from ...agent_provider import prompt_args
from ...agent_provider import PromptMode, prompt_args
from .. import Bottle, ExecResult
from . import pty_resize as _pty_resize
from . import smolvm as _smolvm
@@ -34,8 +34,8 @@ from . import smolvm as _smolvm
_PTY_RESIZE_SCRIPT = _pty_resize.__file__
# Per-user env the agent image's USER (node) expects. claude
# reads ~/.claude.json + writes session state under ~/.claude/;
# Per-user env the agent image's USER (node) expects. Some providers
# write session state under the user's home directory;
# bare `runuser -u` inherits root's HOME=/root, which claude
# can't write to. Set HOME / USER explicitly through smolvm -e
# so the child process sees them.
@@ -74,7 +74,7 @@ class SmolmachinesBottle(Bottle):
prompt_path: str | None = None,
guest_env: Mapping[str, str] | None = None,
agent_command: str = "claude",
agent_prompt_mode: str = "claude_append_file",
agent_prompt_mode: PromptMode = "append_file",
) -> None:
self.name = machine_name
# In-VM path to the agent's prompt file. None when the
@@ -86,14 +86,13 @@ class SmolmachinesBottle(Bottle):
# Forwarded on every `smolvm machine exec` via `-e K=V`
# because exec doesn't inherit from machine_create's env.
self._guest_env = dict(guest_env or {})
self._agent_command = agent_command
self._agent_prompt_mode = agent_prompt_mode
self.agent_command = agent_command
self.agent_provider_template = (
"codex" if agent_command == "codex" else "claude"
)
def claude_argv(
def agent_argv(
self, argv: list[str], *, tty: bool = True,
) -> list[str]:
flags = ["smolvm", "machine", "exec", "--name", self.name]
@@ -101,17 +100,17 @@ class SmolmachinesBottle(Bottle):
flags += ["-i", "-t"]
flags += _env_flags_for("node")
flags += _guest_env_flags(self._guest_env)
claude_tail = [self._agent_command]
agent_tail = [self.agent_command]
provider_prompt_args = prompt_args(
self._agent_prompt_mode, self._prompt_path, argv=argv,
)
if self._agent_prompt_mode == "codex_read_prompt_file":
claude_tail += argv
claude_tail += provider_prompt_args
if self._agent_prompt_mode == "read_prompt_file":
agent_tail += argv
agent_tail += provider_prompt_args
else:
claude_tail += provider_prompt_args
claude_tail += argv
flags += ["--", "runuser", "-u", "node", "--", *claude_tail]
agent_tail += provider_prompt_args
agent_tail += argv
flags += ["--", "runuser", "-u", "node", "--", *agent_tail]
if not tty:
# No PTY allocated — no SIGWINCH to forward, no resize
# bridge needed. Skip the wrapper so non-interactive
@@ -123,10 +122,10 @@ class SmolmachinesBottle(Bottle):
self.name, "--", *flags,
]
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int:
"""Run `claude` interactively inside the VM as the `node`
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
"""Run the selected agent interactively inside the VM as the `node`
user. Inherits the operator's terminal (stdin / stdout /
stderr) so the session feels native. Blocks until claude
stderr) so the session feels native. Blocks until the agent
exits; returns the in-VM exit code.
We bypass the captured-output `machine_exec` helper here
@@ -138,7 +137,7 @@ class SmolmachinesBottle(Bottle):
avoid login-shell wiring. HOME / USER come from `smolvm
-e` instead, which sets them on the process env."""
return subprocess.run(
self.claude_argv(argv, tty=tty), check=False,
self.agent_argv(argv, tty=tty), check=False,
).returncode
def exec(self, script: str, *, user: str = "node") -> ExecResult:
@@ -12,6 +12,7 @@ import sys
from dataclasses import dataclass
from pathlib import Path
from ...agent_provider import PromptMode
from ...egress import EgressPlan
from ...git_gate import GitGatePlan
from ...log import info
@@ -93,7 +94,7 @@ class SmolmachinesBottlePlan(BottlePlan):
agent_git_gate_host: str = ""
agent_supervise_url: str = ""
agent_command: str = "claude"
agent_prompt_mode: str = "claude_append_file"
agent_prompt_mode: PromptMode = "append_file"
agent_provider_template: str = "claude"
agent_dockerfile_path: str = ""
+1 -1
View File
@@ -183,7 +183,7 @@ def launch(
# Stamp the URLs onto the plan + guest_env. provision_git
# and provision_supervise read the plan fields; the agent
# reads guest_env on every exec_claude.
# reads guest_env on every exec_agent.
#
# NO_PROXY has to include the per-bottle loopback alias —
# otherwise claude's HTTPS_PROXY catches direct calls to
@@ -24,7 +24,7 @@ process that:
extra signalling.
3. Waits on the child and exits with its returncode.
The dashboard's tmux pane respawn calls `bottle.claude_argv`
The dashboard's tmux pane respawn calls `bottle.agent_argv`
which now prepends `[sys.executable, -m, ..., <machine>, --, ...]`
to the smolvm argv. Foreground handoff (curses endwin →
subprocess.run) goes through the same path so behavior is
+59 -59
View File
@@ -74,8 +74,8 @@ from ..supervise import (
)
from ._common import PROG, USER_CWD
from .start import (
attach_claude,
capture_session_state,
attach_agent,
capture_claude_session_state,
prepare_with_preflight,
settle_state,
)
@@ -650,7 +650,7 @@ def _bottle_for_slug(
if slug in bottles:
_cm, bottle, _identity = bottles[slug]
return bottle, ""
# The container hosting the agent's claude process is named
# The container hosting the agent's agent process is named
# `bot-bottle-<slug>` — set by the compose renderer
# (no service suffix on the agent service, by design).
container_name = f"bot-bottle-{slug}"
@@ -705,7 +705,7 @@ def _stop_bottle_flow(
# settle_state below.
try:
if getattr(bottle, "agent_provider_template", "claude") == "claude":
capture_session_state(identity, exit_code=0)
capture_claude_session_state(identity, exit_code=0)
except BaseException:
pass
try:
@@ -715,7 +715,7 @@ def _stop_bottle_flow(
# Mirror the bringup path's stderr → right-pane routing.
# Reuses any existing right pane (which is probably the
# agent's own claude session) via `_ensure_right_pane`; the
# agent's own agent session) via `_ensure_right_pane`; the
# final buffered output stays visible after settle_state
# removes the state dir (tail-F handles file removal).
try:
@@ -752,7 +752,7 @@ def _stop_bottle_flow(
# pane of a two-pane window with the operator's currently-selected
# agent in the right pane. First attach creates the right pane via
# `tmux split-window`; subsequent attaches respawn that pane with
# the new agent's claude session. The dashboard remembers the
# the new agent's agent session. The dashboard remembers the
# pane id + occupant slug in `tmux_state` so the same pane is
# reused across attaches.
@@ -763,14 +763,14 @@ def _in_tmux() -> bool:
return bool(os.environ.get("TMUX"))
def _claude_runtime_args(
*, resume: bool, remote_control: bool = False, provider_template: str = "claude",
def _agent_runtime_args(
*, resume: bool, remote_control: bool = False, agent_provider_template: str = "claude",
) -> list[str]:
"""The argv the dashboard hands to `bottle.claude_argv`
on every attach — matches what `attach_claude` builds for the
"""The argv the dashboard hands to `bottle.agent_argv`
on every attach — matches what `attach_agent` builds for the
foreground handoff so both surfaces produce the same claude
invocation."""
runtime = runtime_for(provider_template)
runtime = runtime_for(agent_provider_template)
args = list(runtime.bypass_args)
if remote_control:
args.extend(runtime.remote_control_args)
@@ -780,7 +780,7 @@ def _claude_runtime_args(
def _build_resume_argv_with_fallback(
bottle, *, remote_control: bool = False, provider_template: str = "claude",
bottle, *, remote_control: bool = False, agent_provider_template: str = "claude",
) -> list[str]:
"""Build a backend-exec argv that runs `claude --continue` and
falls back to plain `claude` if no prior session exists.
@@ -795,44 +795,44 @@ def _build_resume_argv_with_fallback(
the fallback only kicks in when --continue would have
failed anyway.
Works across backends because `bottle.claude_argv` always
Works across backends because `bottle.agent_argv` always
surfaces the `claude` token preceded by the backend's exec
framing (docker: `docker exec -it <c>`; smolmachines:
`smolvm machine exec --name <m> -- runuser -u node --`).
Splitting at `claude` keeps the framing as the prefix and
wraps just the claude tail in `sh -c`."""
if provider_template != "claude":
return bottle.claude_argv(
_claude_runtime_args(
wraps just the agent tail in `sh -c`."""
if agent_provider_template != "claude":
return bottle.agent_argv(
_agent_runtime_args(
resume=True,
remote_control=remote_control,
provider_template=provider_template,
agent_provider_template=agent_provider_template,
)
)
base_args = _claude_runtime_args(
base_args = _agent_runtime_args(
resume=False,
remote_control=remote_control,
provider_template=provider_template,
agent_provider_template=agent_provider_template,
)
base_exec = bottle.claude_argv(base_args)
# Split exec-framing prefix from the claude-and-args tail so
base_exec = bottle.agent_argv(base_args)
# Split exec-framing prefix from the agent-and-args tail so
# we can compose `<claude…> --continue || <claude…>` inside
# `sh -c`. The provider command token is the marker.
command = getattr(bottle, "agent_command", runtime_for(provider_template).command)
claude_idx = base_exec.index(command)
prefix = base_exec[:claude_idx]
claude_cmd = " ".join(shlex.quote(a) for a in base_exec[claude_idx:])
command = getattr(bottle, "agent_command", runtime_for(agent_provider_template).command)
agent_idx = base_exec.index(command)
prefix = base_exec[:agent_idx]
agent_cmd = " ".join(shlex.quote(a) for a in base_exec[agent_idx:])
resume_args = " ".join(
shlex.quote(a) for a in runtime_for(provider_template).resume_args
shlex.quote(a) for a in runtime_for(agent_provider_template).resume_args
)
return [
*prefix,
"sh", "-c",
f"{claude_cmd} {resume_args} || {claude_cmd}",
f"{agent_cmd} {resume_args} || {agent_cmd}",
]
def _build_split_pane_argv(claude_argv: list[str]) -> list[str]:
def _build_split_pane_argv(agent_argv: list[str]) -> list[str]:
"""Pure helper: wrap a backend-exec argv with `tmux split-window
-h -P -F '#{pane_id}'`. The `-P -F` combo tells tmux to print
the new pane's id on stdout so we can track it for later
@@ -840,15 +840,15 @@ def _build_split_pane_argv(claude_argv: list[str]) -> list[str]:
return [
"tmux", "split-window", "-h",
"-P", "-F", "#{pane_id}",
*claude_argv,
*agent_argv,
]
def _build_respawn_pane_argv(pane_id: str, claude_argv: list[str]) -> list[str]:
def _build_respawn_pane_argv(pane_id: str, agent_argv: list[str]) -> list[str]:
"""Pure helper: wrap a backend-exec argv with `tmux respawn-pane
-k -t <pane_id>`. `-k` kills the existing process in the pane
before respawning."""
return ["tmux", "respawn-pane", "-k", "-t", pane_id, *claude_argv]
return ["tmux", "respawn-pane", "-k", "-t", pane_id, *agent_argv]
@contextlib.contextmanager
@@ -952,7 +952,7 @@ def _route_op_to_right_pane(
def _tmux_close_right_pane(tmux_state: dict) -> None:
"""Close the tracked right pane via `tmux kill-pane`. Clears
both pane_id and slug in `tmux_state`. Used after the last
dashboard-owned agent is stopped — no claude session left
dashboard-owned agent is stopped — no agent session left
to host, so the pane shouldn't linger."""
pane_id = tmux_state.get("pane_id")
if pane_id and _tmux_pane_exists(pane_id):
@@ -992,7 +992,7 @@ def _ensure_right_pane(tmux_state: dict, argv: list[str]) -> str | None:
returns the pane id on success, None on failure.
This is the single place where "respawn or create" lives —
used by `_attach_in_tmux` for claude sessions AND by
used by `_attach_in_tmux` for agent sessions AND by
`_new_agent_flow` for the bringup-log tail. Without this,
every new-agent start would pile up a fresh right pane
instead of reusing the one already next to the dashboard."""
@@ -1037,18 +1037,18 @@ def _attach_via_handoff(
`_attach_in_tmux` when tmux misbehaves)."""
curses.endwin()
try:
provider_template = getattr(bottle, "agent_provider_template", "claude")
exit_code = attach_claude(
agent_provider_template = getattr(bottle, "agent_provider_template", "claude")
exit_code = attach_agent(
bottle,
remote_control=False,
resume=resume,
provider_template=provider_template,
agent_provider_template=agent_provider_template,
)
except BaseException:
stdscr.refresh()
raise
stdscr.refresh()
return f"[{slug}] claude session ended (exit {exit_code})"
return f"[{slug}] agent session ended (exit {exit_code})"
def _attach_in_tmux(
@@ -1067,28 +1067,28 @@ def _attach_in_tmux(
explicit-stop hook).
`focus_right_pane=True` runs `tmux select-pane` after the
respawn so the operator is dropped into claude immediately.
respawn so the operator is dropped into agent immediately.
The Enter re-attach key passes this; passive paths (the
auto-attach after a stop) leave it False so the operator
stays in the dashboard pane."""
if resume:
provider_template = getattr(bottle, "agent_provider_template", "claude")
agent_provider_template = getattr(bottle, "agent_provider_template", "claude")
# `--continue` exits non-zero when no prior session
# exists (agent spun up but never typed at). Wrap with a
# shell-level fallback so the pane lands in a fresh
# claude instead of crashing.
claude_argv = _build_resume_argv_with_fallback(
bottle, provider_template=provider_template,
# agent instead of crashing.
agent_argv = _build_resume_argv_with_fallback(
bottle, agent_provider_template=agent_provider_template,
)
else:
provider_template = getattr(bottle, "agent_provider_template", "claude")
claude_argv = bottle.claude_argv(
_claude_runtime_args(
agent_provider_template = getattr(bottle, "agent_provider_template", "claude")
agent_argv = bottle.agent_argv(
_agent_runtime_args(
resume=False,
provider_template=provider_template,
agent_provider_template=agent_provider_template,
),
)
pane_id = _ensure_right_pane(tmux_state, claude_argv)
pane_id = _ensure_right_pane(tmux_state, agent_argv)
if pane_id is None:
# tmux failed (missing binary, server died, size error).
# One status-line failover to the curses handoff so the
@@ -1121,7 +1121,7 @@ def _attach_to_bottle(
tmux_state: dict | None = None,
) -> str:
"""Re-attach to a running bottle. Inside tmux (`$TMUX` set +
`tmux_state` provided) the claude session opens in the
`tmux_state` provided) the agent session opens in the
right pane (created on first attach, respawned on
subsequent). Outside tmux it's a curses-endwin handoff that
blocks until the operator exits claude. Re-attach always uses
@@ -1129,7 +1129,7 @@ def _attach_to_bottle(
if _in_tmux() and tmux_state is not None:
# Enter re-attach is an explicit "I want to interact with
# this agent" signal — move tmux focus to the right pane
# so keypresses land in claude instead of the dashboard.
# so keypresses land in agent instead of the dashboard.
return _attach_in_tmux(
stdscr, bottle, slug,
resume=True, tmux_state=tmux_state,
@@ -1147,7 +1147,7 @@ def _new_agent_flow(
) -> str:
"""Open the picker, prepare + preflight (modal), launch
(enter the context manager but DON'T close it), then route
the first claude session into the right pane (in-tmux) or
the first agent session into the right pane (in-tmux) or
foreground handoff (otherwise). Returns a status-line message
for the dashboard footer. The (cm, bottle) tuple lands in
`bottles` keyed by slug; chunk 4 uses it for explicit stop."""
@@ -1235,20 +1235,20 @@ def _new_agent_flow(
raise
bottles[plan.slug] = (cm, bottle, identity)
# Foreground handoff: claude owns the terminal until exit,
# Foreground handoff: the agent owns the terminal until exit,
# then we restore curses.
try:
provider_template = getattr(plan, "agent_provider_template", "claude")
exit_code = attach_claude(
agent_provider_template = getattr(plan, "agent_provider_template", "claude")
exit_code = attach_agent(
bottle,
remote_control=False,
provider_template=provider_template,
agent_provider_template=agent_provider_template,
)
if provider_template == "claude":
capture_session_state(identity, exit_code)
if agent_provider_template == "claude":
capture_claude_session_state(identity, exit_code)
finally:
stdscr.refresh()
return f"[{plan.slug}] claude session ended (exit {exit_code})"
return f"[{plan.slug}] agent session ended (exit {exit_code})"
finally:
# stage_dir was the prepare scratch dir; after PRD 0018
# chunk 2 it holds nothing the running bottle needs. Reap
@@ -1540,7 +1540,7 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
# PRD 0021 follow-up: after stop, slide focus
# to the next agent in the list (the one that
# filled the stopped row) and respawn the
# right pane with its claude session. If
# right pane with its agent session. If
# nothing's left, close the right pane.
pick = _pick_next_after_stop(
agents, selected_agent, target.slug,
+22 -18
View File
@@ -4,7 +4,7 @@ session ends.
The launch core is shared with `cli.py resume <identity>` and (PRD
0020 chunk 1+) the dashboard's in-process start flow: see the
public helpers `prepare_with_preflight`, `attach_claude`, and the
public helpers `prepare_with_preflight`, `attach_agent`, and the
private orchestrator `_launch_bottle`.
"""
@@ -113,12 +113,13 @@ def prepare_with_preflight(
return plan, identity
def attach_claude(
def attach_agent(
bottle: Bottle, *, remote_control: bool = False, resume: bool = False,
provider_template: str = "claude",
agent_provider_template: str = "claude",
) -> int:
"""Run claude inside `bottle` as an interactive session. Blocks
until the session ends; returns the claude process's exit code.
"""Run the selected provider CLI inside `bottle` as an
interactive session. Blocks until the session ends; returns the
agent process's exit code.
`resume=True` adds `--continue` so claude picks up its most
recent session non-interactively (no session-picker prompt)
@@ -130,25 +131,28 @@ def attach_claude(
Used as the inner step of `./cli.py start` (one-shot) and by the
dashboard, which calls it from inside a `curses.endwin
stdscr.refresh()` handoff so the curses surface gets out of the
terminal's way while claude has it."""
runtime = runtime_for(provider_template)
terminal's way while the agent has it."""
runtime = runtime_for(agent_provider_template)
info(
f"attaching interactive {provider_template} session "
f"attaching interactive {agent_provider_template} session "
"(Ctrl-D or 'exit' to leave; container will be removed)"
)
claude_args = list(runtime.bypass_args)
agent_args = list(runtime.bypass_args)
if remote_control:
claude_args.extend(runtime.remote_control_args)
agent_args.extend(runtime.remote_control_args)
if resume:
claude_args.extend(runtime.resume_args)
return bottle.exec_claude(claude_args, tty=True)
agent_args.extend(runtime.resume_args)
return bottle.exec_agent(agent_args, tty=True)
def capture_session_state(identity: str, exit_code: int) -> None:
def capture_claude_session_state(identity: str, exit_code: int) -> None:
"""Inside the launch context, while the container is still
alive: snapshot the transcript and mark for preservation if
claude crashed. Public for the dashboard's death-handling path
(PRD 0020 open question 3)."""
# FIXME: this captures Claude-specific session state. A follow-up
# spike should explore freezing provider-neutral container state
# instead of relying on each agent's transcript layout.
if not identity:
return
snapshot_transcript(identity)
@@ -218,11 +222,11 @@ def _launch_bottle(
backend = get_bottle_backend(backend_name)
with backend.launch(plan) as bottle:
provider_template = getattr(plan, "agent_provider_template", "claude")
exit_code = attach_claude(
agent_provider_template = getattr(plan, "agent_provider_template", "claude")
exit_code = attach_agent(
bottle,
remote_control=remote_control,
provider_template=provider_template,
agent_provider_template=agent_provider_template,
)
info(
f"session ended (exit {exit_code}); "
@@ -236,8 +240,8 @@ def _launch_bottle(
# way. snapshot_transcript is best-effort so the
# capability-block path's prior snapshot isn't clobbered
# when the container is already gone.
if provider_template == "claude":
capture_session_state(identity, exit_code)
if agent_provider_template == "claude":
capture_claude_session_state(identity, exit_code)
return 0
finally:
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
+7 -7
View File
@@ -478,7 +478,7 @@ class EgressConfig:
@classmethod
def from_dict(
cls, bottle_name: str, raw: object, *, provider_template: str = "claude",
cls, bottle_name: str, raw: object, *, agent_provider_template: str = "claude",
) -> "EgressConfig":
d = _as_json_object(raw, f"bottle '{bottle_name}' egress")
routes_raw = d.get("routes")
@@ -495,7 +495,7 @@ class EgressConfig:
for i, entry in enumerate(routes_list)
)
_validate_egress_routes(
bottle_name, routes, provider_template=provider_template,
bottle_name, routes, agent_provider_template=agent_provider_template,
)
for k in d:
if k != "routes":
@@ -589,7 +589,7 @@ class Bottle:
egress = (
EgressConfig.from_dict(
name, d["egress"],
provider_template=agent_provider.template,
agent_provider_template=agent_provider.template,
)
if "egress" in d
else EgressConfig()
@@ -887,7 +887,7 @@ def _validate_egress_routes(
bottle_name: str,
routes: tuple[EgressRoute, ...],
*,
provider_template: str = "claude",
agent_provider_template: str = "claude",
) -> None:
"""Cross-validation for `bottle.egress.routes`:
@@ -919,14 +919,14 @@ def _validate_egress_routes(
f"routes with role {role!r} (hosts: {hosts}); this role drives a "
f"single launch-step side effect — pick one."
)
allowed_roles = PROVIDER_EGRESS_ROLES[provider_template]
allowed_roles = PROVIDER_EGRESS_ROLES[agent_provider_template]
for route in routes:
for role in route.Role:
if role not in allowed_roles:
die(
f"bottle '{bottle_name}' egress route for host "
f"{route.Host!r} has role {role!r}, but provider "
f"{provider_template!r} only accepts roles "
f"{agent_provider_template!r} only accepts roles "
f"{', '.join(sorted(allowed_roles)) or '(none)'}"
)
@@ -1141,7 +1141,7 @@ def _merge_bottles(
)
_validate_egress_routes(
name, merged_egress.routes,
provider_template=merged_agent_provider.template,
agent_provider_template=merged_agent_provider.template,
)
return Bottle(