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(
+2 -2
View File
@@ -62,7 +62,7 @@ The feature is **done** when all of the following ship:
`Bottle`) plus a `bot_bottle/backend/docker/` subpackage
containing the `DockerBottleBackend` implementation.
- `DockerBottleBackend.launch(plan)` returns a context manager
yielding a `Bottle` handle exposing `exec_claude(argv, *, tty=True)`,
yielding a `Bottle` handle exposing `exec_agent(argv, *, tty=True)`,
`cp_in(host, ctr)`, and teardown on context exit.
- Every existing `subprocess.run(["docker", ...])` call in
`cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`, and
@@ -205,7 +205,7 @@ and a Docker subpackage:
- **`bot_bottle/cli/start.py`** — replace the inline docker
orchestration with `backend = get_bottle_backend(); plan =
backend.prepare(spec, stage_dir=...); with backend.launch(plan) as
bottle: bottle.exec_claude(...)`. The y/N preflight is rendered by
bottle: bottle.exec_agent(...)`. The y/N preflight is rendered by
`plan.print(...)`.
- **`bot_bottle/manifest.py`** — drop the `runtime` field from the
Bottle dataclass and its validation. Existing manifests with
+1 -1
View File
@@ -312,7 +312,7 @@ existing prefix scan as a fallback for one release.
2. **How does `claude` reach the agent's TTY?** Decided: keep
today's `docker exec -it` model. Agent runs `sleep infinity`
under compose; `DockerBottle.exec_claude` runs
under compose; `DockerBottle.exec_agent` runs
`docker exec -it bot-bottle-<slug> claude ...` exactly like
today. Compose owns the lifecycle (so `compose logs` includes
the agent's stdout, `compose down` tears it down), but the
@@ -154,7 +154,7 @@ Today's flow:
```
./cli.py start agent
└─ with backend.launch(plan) as bottle: ← bottle alive while inside `with`
bottle.exec_claude([...], tty=True) ← blocks until claude exits
bottle.exec_agent([...], tty=True) ← blocks until claude exits
# context exits → compose down → state cleanup
```
@@ -171,7 +171,7 @@ The proposed dashboard-driven flow:
# operator interacts via:
curses.endwin()
bottle.exec_claude([...], tty=True) ← blocks; returns on Ctrl-D
bottle.exec_agent([...], tty=True) ← blocks; returns on Ctrl-D
stdscr.refresh()
# bottle is STILL ALIVE — only the claude process exited
@@ -265,7 +265,7 @@ if modal proves fiddly.
Same handoff pattern the new-agent flow uses. For an agent the
dashboard started this session, the dashboard holds the
`DockerBottle` handle in its `bottles` dict and calls
`bottle.exec_claude(...)`. For an agent it discovered via
`bottle.exec_agent(...)`. For an agent it discovered via
`list_active_slugs` (previous-dashboard or external start),
the dashboard synthesizes a one-shot `DockerBottle` from the
slug — container name is `bot-bottle-<slug>`, no prompt
@@ -304,17 +304,17 @@ acting surface, not a lifetime owner.
Sized for one PR each.
1. **Refactor `_launch_bottle` so the launch + exec_claude
1. **Refactor `_launch_bottle` so the launch + exec_agent
pieces are separable.** Today's `cli/start.py` runs both
inside one function. Extract `prepare_with_preflight(spec,
*, render_preflight, prompt_yes)` and `attach_claude(bottle,
*, render_preflight, prompt_yes)` and `attach_agent(bottle,
*, remote_control)`. The CLI's existing one-shot use binds
them as before; the dashboard binds them with curses-aware
render + prompt callables. No behavior change.
2. **Agent picker modal + new-agent flow.** New key `n` opens
the picker; `prepare_with_preflight` runs against the
selected agent; on Y, `backend.launch(plan)` enters the
dashboard's ExitStack; handoff invokes `attach_claude`.
dashboard's ExitStack; handoff invokes `attach_agent`.
3. **Re-attach via Enter on owned agents-pane row.** Looks up
the slug in the dashboard's `bottles` map; if present →
handoff; else → status-line hint pointing at `./cli.py
@@ -390,7 +390,7 @@ Sized for one PR each.
- PRD 0019 — active-agents pane + selection model (the
agents-pane row the re-attach + stop verbs hook into)
- `docs/research/claude-code-pane-in-dashboard.md` — option 1
(handoff) is what `attach_claude` implements here; options 2
(handoff) is what `attach_agent` implements here; options 2
/ 3 are out of scope for this PRD
- `bot_bottle/cli/start.py:_launch_bottle` — the function
chunk 1 extracts the prepare + attach pieces out of
+5 -5
View File
@@ -163,7 +163,7 @@ def _attach_in_tmux(bottle, slug, *, resume) -> str:
The non-tmux path is unchanged from PRD 0020 — `_attach_via_
handoff` is what those two flows already do today (curses.
endwin → attach_claude → stdscr.refresh).
endwin → attach_agent → stdscr.refresh).
### Pane creation
@@ -230,10 +230,10 @@ def _tmux_pane_exists(pane_id) -> bool:
The tmux helpers need the full docker-exec argv for claude —
specifically including the `--append-system-prompt-file <path>`
flag that `DockerBottle.exec_claude` appends today when the
bottle has a prompt path. Refactor: split `exec_claude` into a
flag that `DockerBottle.exec_agent` appends today when the
bottle has a prompt path. Refactor: split `exec_agent` into a
pure `claude_docker_argv(args, *, tty)` that returns the argv
and a thin `exec_claude` that calls `subprocess.run` on it.
and a thin `exec_agent` that calls `subprocess.run` on it.
Both the tmux path AND the existing foreground path use the
same argv builder.
@@ -272,7 +272,7 @@ Three failure modes worth handling:
Sized small.
1. **`claude_docker_argv` refactor.** Pure-ish split of
`DockerBottle.exec_claude` so both foreground and tmux
`DockerBottle.exec_agent` so both foreground and tmux
paths build on the same argv. No behavior change for the
existing tests.
2. **tmux helpers + pane state.** Add `_in_tmux`,
@@ -176,7 +176,7 @@ bottle so the suite is ~15s wall-clock total instead of
bottle: dev
---
(no prompt — exec_claude isn't called)
(no prompt — exec_agent isn't called)
```
```yaml
+2 -2
View File
@@ -317,7 +317,7 @@ The feature is **done** when all of the following ship:
bot_bottle/backend/smolmachines/
__init__.py re-exports SmolmachinesBottleBackend
backend.py SmolmachinesBottleBackend façade
bottle.py SmolmachinesBottle (exec_claude / exec / cp_in / close)
bottle.py SmolmachinesBottle (exec_agent / exec / cp_in / close)
bottle_plan.py SmolmachinesBottlePlan + .print()
bottle_cleanup_plan.py SmolmachinesBottleCleanupPlan
prepare.py resolve_plan(spec, stage_dir, ...) -> SmolmachinesBottlePlan
@@ -436,7 +436,7 @@ Three changes vs. the Docker backend:
layer enforces it.
3. Provisioning: CA install → prompt → skills → git → supervise
config, each via `smolvm machine exec` / `smolvm machine cp`.
4. Yield a `SmolmachinesBottle` whose `exec_claude` / `exec` /
4. Yield a `SmolmachinesBottle` whose `exec_agent` / `exec` /
`cp_in` all funnel through `smolvm machine exec` /
`smolvm machine cp`.
5. Teardown: stop and delete the VM → stop + remove the bundle
+1 -1
View File
@@ -3,7 +3,7 @@ container that mimics the agent's name + filesystem layout (PRD 0016).
The real `cli.py start <agent>` flow is too heavy for an integration
test (it builds the agent image, brings up all the sidecars, attaches
an interactive claude session). Instead, this test stages the
an interactive agent session). Instead, this test stages the
minimum the orchestrator interacts with:
- A lightweight `alpine:latest sleep infinity` container named
+5 -5
View File
@@ -1,7 +1,7 @@
"""Unit: cli/start.py session-end state capture (crash preservation).
The launch-context machinery is covered by integration; this isolates
the post-exec_claude decision: snapshot transcript + mark for
the post-exec_agent decision: snapshot transcript + mark for
preservation if non-zero exit, no-op for clean exit."""
import tempfile
@@ -45,25 +45,25 @@ class TestCaptureSessionState(_FakeHomeMixin, unittest.TestCase):
self._teardown_fake_home()
def test_clean_exit_snapshots_but_does_not_mark(self):
start_mod.capture_session_state("dev-abc", exit_code=0)
start_mod.capture_claude_session_state("dev-abc", exit_code=0)
self.assertEqual(["dev-abc"], self._snap_calls)
self.assertFalse(bottle_state.is_preserved("dev-abc"))
def test_crash_snapshots_and_marks(self):
start_mod.capture_session_state("dev-abc", exit_code=137)
start_mod.capture_claude_session_state("dev-abc", exit_code=137)
self.assertEqual(["dev-abc"], self._snap_calls)
self.assertTrue(bottle_state.is_preserved("dev-abc"))
def test_ctrl_c_treated_as_crash(self):
# SIGINT delivers exit 130; the operator may have Ctrl-C'd
# because something went wrong, so we preserve.
start_mod.capture_session_state("dev-abc", exit_code=130)
start_mod.capture_claude_session_state("dev-abc", exit_code=130)
self.assertTrue(bottle_state.is_preserved("dev-abc"))
def test_empty_identity_is_noop(self):
# Backends without an identity field shouldn't crash this
# path (the _identity_from_plan helper falls back to "").
start_mod.capture_session_state("", exit_code=137)
start_mod.capture_claude_session_state("", exit_code=137)
self.assertEqual([], self._snap_calls)
+5 -5
View File
@@ -369,24 +369,24 @@ class TestResumeArgvWithFallback(unittest.TestCase):
class TestClaudeRuntimeArgs(unittest.TestCase):
"""The argv passed to `bottle.claude_argv` on each
"""The argv passed to `bottle.agent_argv` on each
attach. Locked here so the tmux + foreground paths build
identical claude invocations."""
identical agent invocations."""
def test_default_skip_permissions_only(self):
self.assertEqual(
["--dangerously-skip-permissions"],
dashboard._claude_runtime_args(resume=False),
dashboard._agent_runtime_args(resume=False),
)
def test_resume_appends_continue(self):
self.assertEqual(
["--dangerously-skip-permissions", "--continue"],
dashboard._claude_runtime_args(resume=True),
dashboard._agent_runtime_args(resume=True),
)
def test_remote_control(self):
args = dashboard._claude_runtime_args(
args = dashboard._agent_runtime_args(
resume=False, remote_control=True,
)
self.assertIn("--remote-control", args)
+15 -15
View File
@@ -1,6 +1,6 @@
"""Unit: DockerBottle's argv builder (PRD 0021 chunk 1).
`claude_argv` is the pure helper that `exec_claude` and the
`agent_argv` is the pure helper that `exec_agent` and the
PRD-0021 tmux helpers both build on. It encodes two non-trivial
rules the optional `--append-system-prompt-file` flag and the
optional `-it` for TTY mode that we lock down here so the tmux
@@ -28,20 +28,20 @@ def _codex_bottle(prompt_path: str | None = None) -> DockerBottle:
teardown=lambda: None,
prompt_path_in_container=prompt_path,
agent_command="codex",
agent_prompt_mode="codex_read_prompt_file",
agent_prompt_mode="read_prompt_file",
)
class TestClaudeArgv(unittest.TestCase):
def test_minimal_argv_no_prompt(self):
argv = _bottle().claude_argv([])
argv = _bottle().agent_argv([])
self.assertEqual(
["docker", "exec", "-it", "bot-bottle-dev-abc", "claude"],
argv,
)
def test_appends_passed_args_after_claude(self):
argv = _bottle().claude_argv(
argv = _bottle().agent_argv(
["--dangerously-skip-permissions", "--continue"],
)
self.assertEqual(
@@ -51,7 +51,7 @@ class TestClaudeArgv(unittest.TestCase):
)
def test_appends_prompt_file_flag_when_set(self):
argv = _bottle("/home/node/.bot-bottle-prompt.txt").claude_argv(
argv = _bottle("/home/node/.bot-bottle-prompt.txt").agent_argv(
["--dangerously-skip-permissions"],
)
self.assertEqual(
@@ -63,34 +63,34 @@ class TestClaudeArgv(unittest.TestCase):
)
def test_no_prompt_flag_when_none(self):
argv = _bottle(None).claude_argv(["--continue"])
argv = _bottle(None).agent_argv(["--continue"])
self.assertNotIn("--append-system-prompt-file", argv)
def test_empty_prompt_string_is_treated_as_no_prompt(self):
# Matches the existing exec_claude behavior: falsy
# Matches the existing exec_agent behavior: falsy
# prompt_path means "skip the flag." The synth path in
# dashboard.py relies on this when metadata is missing.
argv = _bottle("").claude_argv(["--continue"])
argv = _bottle("").agent_argv(["--continue"])
self.assertNotIn("--append-system-prompt-file", argv)
def test_tty_false_drops_it_flag(self):
argv = _bottle().claude_argv([], tty=False)
argv = _bottle().agent_argv([], tty=False)
self.assertEqual(
["docker", "exec", "bot-bottle-dev-abc", "claude"],
argv,
)
def test_caller_argv_not_mutated(self):
# `claude_argv` builds `full_argv` from a copy, so a
# `agent_argv` builds `full_argv` from a copy, so a
# caller passing a long-lived list (e.g., the dashboard's
# _claude_args fixture) doesn't get extra flags appended to
# _agent_args fixture) doesn't get extra flags appended to
# it on subsequent calls.
original = ["--continue"]
_bottle("/x").claude_argv(original)
_bottle("/x").agent_argv(original)
self.assertEqual(["--continue"], original)
def test_codex_provider_uses_codex_command(self):
argv = _codex_bottle().claude_argv(
argv = _codex_bottle().agent_argv(
["--dangerously-bypass-approvals-and-sandbox"],
)
self.assertEqual(
@@ -100,7 +100,7 @@ class TestClaudeArgv(unittest.TestCase):
)
def test_codex_provider_passes_prompt_reference_as_initial_prompt(self):
argv = _codex_bottle("/home/node/.bot-bottle-prompt.txt").claude_argv([])
argv = _codex_bottle("/home/node/.bot-bottle-prompt.txt").agent_argv([])
self.assertEqual(
["docker", "exec", "-it", "bot-bottle-dev-abc", "codex",
"Read and follow the instructions in "
@@ -109,7 +109,7 @@ class TestClaudeArgv(unittest.TestCase):
)
def test_codex_resume_does_not_append_initial_prompt(self):
argv = _codex_bottle("/home/node/.bot-bottle-prompt.txt").claude_argv(
argv = _codex_bottle("/home/node/.bot-bottle-prompt.txt").agent_argv(
["--dangerously-bypass-approvals-and-sandbox", "resume", "--last"],
)
self.assertEqual(
+15 -15
View File
@@ -1,9 +1,9 @@
"""Unit: SmolmachinesBottle's `claude_argv` builder.
"""Unit: SmolmachinesBottle's `agent_argv` builder.
The dashboard's tmux pane-respawn path calls `bottle.claude_argv`
The dashboard's tmux pane-respawn path calls `bottle.agent_argv`
directly (it spawns claude inside a tmux pane rather than as a
child of the current process), so the argv shape is the
non-trivial part. `exec_claude` is a thin wrapper around the same
non-trivial part. `exec_agent` is a thin wrapper around the same
builder + `subprocess.run`; we lock the shape here.
The TTY-mode argv is wrapped in the pty_resize helper (issue #82
@@ -40,7 +40,7 @@ class TestClaudeArgvWrapped(unittest.TestCase):
"""TTY-mode argv: pty_resize wrapper + inner smolvm exec."""
def test_pty_resize_wrapper_prefix(self):
argv = _bottle().claude_argv([])
argv = _bottle().agent_argv([])
# Absolute script path (not `-m <dotted>`) so the tmux
# pane's cwd doesn't matter — see the `_PTY_RESIZE_SCRIPT`
# docstring in bottle.py.
@@ -53,7 +53,7 @@ class TestClaudeArgvWrapped(unittest.TestCase):
)
def test_minimal_inner_argv_no_prompt(self):
argv = _unwrap(_bottle().claude_argv([]))
argv = _unwrap(_bottle().agent_argv([]))
self.assertEqual(
[
"smolvm", "machine", "exec", "--name",
@@ -69,7 +69,7 @@ class TestClaudeArgvWrapped(unittest.TestCase):
)
def test_appends_passed_args_after_claude(self):
argv = _unwrap(_bottle().claude_argv(
argv = _unwrap(_bottle().agent_argv(
["--dangerously-skip-permissions", "--continue"],
))
self.assertEqual(
@@ -79,7 +79,7 @@ class TestClaudeArgvWrapped(unittest.TestCase):
def test_appends_prompt_file_flag_when_set(self):
argv = _unwrap(
_bottle("/home/node/.bot-bottle-prompt.txt").claude_argv(
_bottle("/home/node/.bot-bottle-prompt.txt").agent_argv(
["--dangerously-skip-permissions"],
)
)
@@ -94,11 +94,11 @@ class TestClaudeArgvWrapped(unittest.TestCase):
)
def test_no_prompt_flag_when_none(self):
argv = _bottle(None).claude_argv(["--continue"])
argv = _bottle(None).agent_argv(["--continue"])
self.assertNotIn("--append-system-prompt-file", argv)
def test_empty_prompt_string_is_treated_as_no_prompt(self):
argv = _bottle("").claude_argv(["--continue"])
argv = _bottle("").agent_argv(["--continue"])
self.assertNotIn("--append-system-prompt-file", argv)
def test_guest_env_forwarded_as_e_flags(self):
@@ -106,7 +106,7 @@ class TestClaudeArgvWrapped(unittest.TestCase):
None,
HTTPS_PROXY="http://127.0.0.1:1234",
NO_PROXY="localhost",
).claude_argv([]))
).agent_argv([]))
self.assertIn("-e", argv)
self.assertIn("HTTPS_PROXY=http://127.0.0.1:1234", argv)
self.assertIn("NO_PROXY=localhost", argv)
@@ -116,11 +116,11 @@ class TestClaudeArgvWrapped(unittest.TestCase):
# the `claude` token to split exec-framing from the claude
# tail. `runuser -u node --` must sit on the prefix side so
# the shell wrap inherits the UID switch.
argv = _bottle().claude_argv([])
claude_idx = argv.index("claude")
argv = _bottle().agent_argv([])
agent_idx = argv.index("claude")
self.assertEqual(
["runuser", "-u", "node", "--"],
argv[claude_idx - 4:claude_idx],
argv[agent_idx - 4:agent_idx],
)
@@ -129,12 +129,12 @@ class TestClaudeArgvNoTTY(unittest.TestCase):
PTY whose SIGWINCH we'd need to bridge."""
def test_no_wrapper_when_tty_false(self):
argv = _bottle().claude_argv([], tty=False)
argv = _bottle().agent_argv([], tty=False)
self.assertEqual("smolvm", argv[0])
self.assertFalse(any("pty_resize" in a for a in argv))
def test_tty_false_drops_it_flags(self):
argv = _bottle().claude_argv([], tty=False)
argv = _bottle().agent_argv([], tty=False)
self.assertNotIn("-i", argv)
self.assertNotIn("-t", argv)