refactor(agent): use agent-neutral runtime names
Assisted-by: Codex
This commit is contained in:
@@ -9,11 +9,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
PROVIDER_CLAUDE = "claude"
|
PROVIDER_CLAUDE = "claude"
|
||||||
PROVIDER_CODEX = "codex"
|
PROVIDER_CODEX = "codex"
|
||||||
PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX})
|
PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX})
|
||||||
|
PromptMode = Literal["append_file", "read_prompt_file"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -24,7 +26,7 @@ class AgentProviderRuntime:
|
|||||||
dockerfile: str
|
dockerfile: str
|
||||||
auth_role: str
|
auth_role: str
|
||||||
placeholder_env: str
|
placeholder_env: str
|
||||||
prompt_mode: str
|
prompt_mode: PromptMode
|
||||||
bypass_args: tuple[str, ...]
|
bypass_args: tuple[str, ...]
|
||||||
resume_args: tuple[str, ...]
|
resume_args: tuple[str, ...]
|
||||||
remote_control_args: tuple[str, ...]
|
remote_control_args: tuple[str, ...]
|
||||||
@@ -41,7 +43,7 @@ _RUNTIMES = {
|
|||||||
dockerfile=str(_REPO_ROOT / "Dockerfile.claude"),
|
dockerfile=str(_REPO_ROOT / "Dockerfile.claude"),
|
||||||
auth_role="claude_code_oauth",
|
auth_role="claude_code_oauth",
|
||||||
placeholder_env="CLAUDE_CODE_OAUTH_TOKEN",
|
placeholder_env="CLAUDE_CODE_OAUTH_TOKEN",
|
||||||
prompt_mode="claude_append_file",
|
prompt_mode="append_file",
|
||||||
bypass_args=("--dangerously-skip-permissions",),
|
bypass_args=("--dangerously-skip-permissions",),
|
||||||
resume_args=("--continue",),
|
resume_args=("--continue",),
|
||||||
remote_control_args=("--remote-control",),
|
remote_control_args=("--remote-control",),
|
||||||
@@ -53,7 +55,7 @@ _RUNTIMES = {
|
|||||||
dockerfile=str(_REPO_ROOT / "Dockerfile.codex"),
|
dockerfile=str(_REPO_ROOT / "Dockerfile.codex"),
|
||||||
auth_role="codex_auth",
|
auth_role="codex_auth",
|
||||||
placeholder_env="OPENAI_API_KEY",
|
placeholder_env="OPENAI_API_KEY",
|
||||||
prompt_mode="codex_read_prompt_file",
|
prompt_mode="read_prompt_file",
|
||||||
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
|
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
|
||||||
resume_args=("resume", "--last"),
|
resume_args=("resume", "--last"),
|
||||||
remote_control_args=(),
|
remote_control_args=(),
|
||||||
@@ -66,13 +68,16 @@ def runtime_for(template: str) -> AgentProviderRuntime:
|
|||||||
|
|
||||||
|
|
||||||
def prompt_args(
|
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]:
|
) -> list[str]:
|
||||||
if not prompt_path:
|
if not prompt_path:
|
||||||
return []
|
return []
|
||||||
if prompt_mode == "claude_append_file":
|
if prompt_mode == "append_file":
|
||||||
return ["--append-system-prompt-file", prompt_path]
|
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:
|
if argv and "resume" in argv:
|
||||||
return []
|
return []
|
||||||
return [f"Read and follow the instructions in {prompt_path}."]
|
return [f"Read and follow the instructions in {prompt_path}."]
|
||||||
|
|||||||
@@ -130,8 +130,8 @@ class ActiveAgent:
|
|||||||
class Bottle(ABC):
|
class Bottle(ABC):
|
||||||
"""Handle to a running bottle. Yielded by a backend's launch step.
|
"""Handle to a running bottle. Yielded by a backend's launch step.
|
||||||
|
|
||||||
`exec_claude` runs `claude` inside the bottle and blocks until the
|
`exec_agent` runs the selected agent CLI inside the bottle and
|
||||||
session ends. `exec` runs a POSIX shell script inside the bottle
|
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
|
and returns the captured result. `cp_in` copies a host path into
|
||||||
the bottle. `close` is an idempotent alias for context-manager
|
the bottle. `close` is an idempotent alias for context-manager
|
||||||
teardown.
|
teardown.
|
||||||
@@ -140,11 +140,11 @@ class Bottle(ABC):
|
|||||||
name: str
|
name: str
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def claude_argv(
|
def agent_argv(
|
||||||
self, argv: list[str], *, tty: bool = True,
|
self, argv: list[str], *, tty: bool = True,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Return the host-side argv that runs `claude <argv>`
|
"""Return the host-side argv that runs the selected agent
|
||||||
inside the bottle. Used by `exec_claude` for foreground
|
inside the bottle. Used by `exec_agent` for foreground
|
||||||
handoffs and by the dashboard's tmux `respawn-pane` flow,
|
handoffs and by the dashboard's tmux `respawn-pane` flow,
|
||||||
which needs the argv up front (it spawns claude in a tmux
|
which needs the argv up front (it spawns claude in a tmux
|
||||||
pane rather than as a child of the current process).
|
pane rather than as a child of the current process).
|
||||||
@@ -155,7 +155,7 @@ class Bottle(ABC):
|
|||||||
...
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: ...
|
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
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:
|
backend-specific terms (Docker: resolved container name; fly:
|
||||||
machine id). Returns the in-container prompt path if a prompt
|
machine id). Returns the in-container prompt path if a prompt
|
||||||
was provisioned, else None — the Bottle handle uses it to
|
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.
|
argv.
|
||||||
|
|
||||||
Default orchestration: ca → prompt → skills → git →
|
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
|
"""Copy the prompt file into the running bottle. Returns the
|
||||||
in-container path iff the agent has a non-empty prompt;
|
in-container path iff the agent has a non-empty prompt;
|
||||||
callers use the return value to decide whether to add
|
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
|
@abstractmethod
|
||||||
def provision_skills(self, plan: PlanT, target: str) -> None:
|
def provision_skills(self, plan: PlanT, target: str) -> None:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import subprocess
|
import subprocess
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from ...agent_provider import prompt_args
|
from ...agent_provider import PromptMode, prompt_args
|
||||||
from .. import Bottle, ExecResult
|
from .. import Bottle, ExecResult
|
||||||
|
|
||||||
|
|
||||||
@@ -19,12 +19,11 @@ class DockerBottle(Bottle):
|
|||||||
prompt_path_in_container: str | None,
|
prompt_path_in_container: str | None,
|
||||||
*,
|
*,
|
||||||
agent_command: str = "claude",
|
agent_command: str = "claude",
|
||||||
agent_prompt_mode: str = "claude_append_file",
|
agent_prompt_mode: PromptMode = "append_file",
|
||||||
):
|
):
|
||||||
self.name = container
|
self.name = container
|
||||||
self._teardown = teardown
|
self._teardown = teardown
|
||||||
self._prompt_path = prompt_path_in_container
|
self._prompt_path = prompt_path_in_container
|
||||||
self._agent_command = agent_command
|
|
||||||
self._agent_prompt_mode = agent_prompt_mode
|
self._agent_prompt_mode = agent_prompt_mode
|
||||||
self.agent_command = agent_command
|
self.agent_command = agent_command
|
||||||
self.agent_provider_template = (
|
self.agent_provider_template = (
|
||||||
@@ -32,7 +31,7 @@ class DockerBottle(Bottle):
|
|||||||
)
|
)
|
||||||
self._closed = False
|
self._closed = False
|
||||||
|
|
||||||
def claude_argv(
|
def agent_argv(
|
||||||
self, argv: list[str], *, tty: bool = True,
|
self, argv: list[str], *, tty: bool = True,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
full_argv = list(argv)
|
full_argv = list(argv)
|
||||||
@@ -42,12 +41,12 @@ class DockerBottle(Bottle):
|
|||||||
cmd = ["docker", "exec"]
|
cmd = ["docker", "exec"]
|
||||||
if tty:
|
if tty:
|
||||||
cmd.append("-it")
|
cmd.append("-it")
|
||||||
cmd.extend([self.name, self._agent_command, *full_argv])
|
cmd.extend([self.name, self.agent_command, *full_argv])
|
||||||
return cmd
|
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(
|
return subprocess.run(
|
||||||
self.claude_argv(argv, tty=tty), check=False,
|
self.agent_argv(argv, tty=tty), check=False,
|
||||||
).returncode
|
).returncode
|
||||||
|
|
||||||
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import sys
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...agent_provider import PromptMode
|
||||||
from ...egress import EgressPlan
|
from ...egress import EgressPlan
|
||||||
from ...git_gate import GitGatePlan
|
from ...git_gate import GitGatePlan
|
||||||
from ...log import info
|
from ...log import info
|
||||||
@@ -52,7 +53,7 @@ class DockerBottlePlan(BottlePlan):
|
|||||||
supervise_plan: SupervisePlan | None
|
supervise_plan: SupervisePlan | None
|
||||||
use_runsc: bool
|
use_runsc: bool
|
||||||
agent_command: str = "claude"
|
agent_command: str = "claude"
|
||||||
agent_prompt_mode: str = "claude_append_file"
|
agent_prompt_mode: PromptMode = "append_file"
|
||||||
agent_provider_template: str = "claude"
|
agent_provider_template: str = "claude"
|
||||||
|
|
||||||
def print(self, *, remote_control: bool) -> None:
|
def print(self, *, remote_control: bool) -> None:
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ The flow is:
|
|||||||
entries inherit without rendering values into the file).
|
entries inherit without rendering values into the file).
|
||||||
8. Provision (CA install, prompt copy, skills, git, supervise
|
8. Provision (CA install, prompt copy, skills, git, supervise
|
||||||
config) — unchanged, uses `docker exec`.
|
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.
|
`docker exec -it` exactly like the pre-compose world.
|
||||||
|
|
||||||
Teardown (ExitStack callbacks fire in reverse):
|
Teardown (ExitStack callbacks fire in reverse):
|
||||||
@@ -204,7 +204,7 @@ def launch(
|
|||||||
# the agent container by its known name.
|
# the agent container by its known name.
|
||||||
prompt_path = provision(plan, plan.container_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
|
# — the agent runs `sleep infinity` per the renderer's
|
||||||
# service spec.
|
# service spec.
|
||||||
yield DockerBottle(
|
yield DockerBottle(
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"""SmolmachinesBottle — running-instance handle (PRD 0023 chunk 2d).
|
"""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`
|
exec` / `smolvm machine cp`. The handle is yielded by `launch`
|
||||||
and torn down via the surrounding ExitStack on context exit;
|
and torn down via the surrounding ExitStack on context exit;
|
||||||
`close` is a no-op idempotent alias so the BottleBackend ABC's
|
`close` is a no-op idempotent alias so the BottleBackend ABC's
|
||||||
context-manager contract is satisfied.
|
context-manager contract is satisfied.
|
||||||
|
|
||||||
User context: `smolvm machine exec` runs commands as root in the
|
User context: `smolvm machine exec` runs commands as root in the
|
||||||
VM, but the agent image's USER is `node` and claude-code refuses
|
VM, but the agent image's USER is `node` and agent CLIs may refuse
|
||||||
to run as root with `--dangerously-skip-permissions`. Both
|
to run as root in bypass modes. Both
|
||||||
`exec_claude` and `exec` switch to the requested user (default
|
`exec_agent` and `exec` switch to the requested user (default
|
||||||
`node`) via `runuser -u <user> --` and set `HOME` / `USER`
|
`node`) via `runuser -u <user> --` and set `HOME` / `USER`
|
||||||
through `smolvm -e` — avoiding `runuser -l`'s login-shell wiring
|
through `smolvm -e` — avoiding `runuser -l`'s login-shell wiring
|
||||||
(PAM session setup, /etc/profile sourcing) which can hang on a
|
(PAM session setup, /etc/profile sourcing) which can hang on a
|
||||||
@@ -21,7 +21,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from typing import Mapping
|
from typing import Mapping
|
||||||
|
|
||||||
from ...agent_provider import prompt_args
|
from ...agent_provider import PromptMode, prompt_args
|
||||||
from .. import Bottle, ExecResult
|
from .. import Bottle, ExecResult
|
||||||
from . import pty_resize as _pty_resize
|
from . import pty_resize as _pty_resize
|
||||||
from . import smolvm as _smolvm
|
from . import smolvm as _smolvm
|
||||||
@@ -34,8 +34,8 @@ from . import smolvm as _smolvm
|
|||||||
_PTY_RESIZE_SCRIPT = _pty_resize.__file__
|
_PTY_RESIZE_SCRIPT = _pty_resize.__file__
|
||||||
|
|
||||||
|
|
||||||
# Per-user env the agent image's USER (node) expects. claude
|
# Per-user env the agent image's USER (node) expects. Some providers
|
||||||
# reads ~/.claude.json + writes session state under ~/.claude/;
|
# write session state under the user's home directory;
|
||||||
# bare `runuser -u` inherits root's HOME=/root, which claude
|
# bare `runuser -u` inherits root's HOME=/root, which claude
|
||||||
# can't write to. Set HOME / USER explicitly through smolvm -e
|
# can't write to. Set HOME / USER explicitly through smolvm -e
|
||||||
# so the child process sees them.
|
# so the child process sees them.
|
||||||
@@ -74,7 +74,7 @@ class SmolmachinesBottle(Bottle):
|
|||||||
prompt_path: str | None = None,
|
prompt_path: str | None = None,
|
||||||
guest_env: Mapping[str, str] | None = None,
|
guest_env: Mapping[str, str] | None = None,
|
||||||
agent_command: str = "claude",
|
agent_command: str = "claude",
|
||||||
agent_prompt_mode: str = "claude_append_file",
|
agent_prompt_mode: PromptMode = "append_file",
|
||||||
) -> None:
|
) -> None:
|
||||||
self.name = machine_name
|
self.name = machine_name
|
||||||
# In-VM path to the agent's prompt file. None when the
|
# 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`
|
# Forwarded on every `smolvm machine exec` via `-e K=V`
|
||||||
# because exec doesn't inherit from machine_create's env.
|
# because exec doesn't inherit from machine_create's env.
|
||||||
self._guest_env = dict(guest_env or {})
|
self._guest_env = dict(guest_env or {})
|
||||||
self._agent_command = agent_command
|
|
||||||
self._agent_prompt_mode = agent_prompt_mode
|
self._agent_prompt_mode = agent_prompt_mode
|
||||||
self.agent_command = agent_command
|
self.agent_command = agent_command
|
||||||
self.agent_provider_template = (
|
self.agent_provider_template = (
|
||||||
"codex" if agent_command == "codex" else "claude"
|
"codex" if agent_command == "codex" else "claude"
|
||||||
)
|
)
|
||||||
|
|
||||||
def claude_argv(
|
def agent_argv(
|
||||||
self, argv: list[str], *, tty: bool = True,
|
self, argv: list[str], *, tty: bool = True,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
flags = ["smolvm", "machine", "exec", "--name", self.name]
|
flags = ["smolvm", "machine", "exec", "--name", self.name]
|
||||||
@@ -101,17 +100,17 @@ class SmolmachinesBottle(Bottle):
|
|||||||
flags += ["-i", "-t"]
|
flags += ["-i", "-t"]
|
||||||
flags += _env_flags_for("node")
|
flags += _env_flags_for("node")
|
||||||
flags += _guest_env_flags(self._guest_env)
|
flags += _guest_env_flags(self._guest_env)
|
||||||
claude_tail = [self._agent_command]
|
agent_tail = [self.agent_command]
|
||||||
provider_prompt_args = prompt_args(
|
provider_prompt_args = prompt_args(
|
||||||
self._agent_prompt_mode, self._prompt_path, argv=argv,
|
self._agent_prompt_mode, self._prompt_path, argv=argv,
|
||||||
)
|
)
|
||||||
if self._agent_prompt_mode == "codex_read_prompt_file":
|
if self._agent_prompt_mode == "read_prompt_file":
|
||||||
claude_tail += argv
|
agent_tail += argv
|
||||||
claude_tail += provider_prompt_args
|
agent_tail += provider_prompt_args
|
||||||
else:
|
else:
|
||||||
claude_tail += provider_prompt_args
|
agent_tail += provider_prompt_args
|
||||||
claude_tail += argv
|
agent_tail += argv
|
||||||
flags += ["--", "runuser", "-u", "node", "--", *claude_tail]
|
flags += ["--", "runuser", "-u", "node", "--", *agent_tail]
|
||||||
if not tty:
|
if not tty:
|
||||||
# No PTY allocated — no SIGWINCH to forward, no resize
|
# No PTY allocated — no SIGWINCH to forward, no resize
|
||||||
# bridge needed. Skip the wrapper so non-interactive
|
# bridge needed. Skip the wrapper so non-interactive
|
||||||
@@ -123,10 +122,10 @@ class SmolmachinesBottle(Bottle):
|
|||||||
self.name, "--", *flags,
|
self.name, "--", *flags,
|
||||||
]
|
]
|
||||||
|
|
||||||
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int:
|
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
|
||||||
"""Run `claude` interactively inside the VM as the `node`
|
"""Run the selected agent interactively inside the VM as the `node`
|
||||||
user. Inherits the operator's terminal (stdin / stdout /
|
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.
|
exits; returns the in-VM exit code.
|
||||||
|
|
||||||
We bypass the captured-output `machine_exec` helper here
|
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
|
avoid login-shell wiring. HOME / USER come from `smolvm
|
||||||
-e` instead, which sets them on the process env."""
|
-e` instead, which sets them on the process env."""
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
self.claude_argv(argv, tty=tty), check=False,
|
self.agent_argv(argv, tty=tty), check=False,
|
||||||
).returncode
|
).returncode
|
||||||
|
|
||||||
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import sys
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ...agent_provider import PromptMode
|
||||||
from ...egress import EgressPlan
|
from ...egress import EgressPlan
|
||||||
from ...git_gate import GitGatePlan
|
from ...git_gate import GitGatePlan
|
||||||
from ...log import info
|
from ...log import info
|
||||||
@@ -93,7 +94,7 @@ class SmolmachinesBottlePlan(BottlePlan):
|
|||||||
agent_git_gate_host: str = ""
|
agent_git_gate_host: str = ""
|
||||||
agent_supervise_url: str = ""
|
agent_supervise_url: str = ""
|
||||||
agent_command: str = "claude"
|
agent_command: str = "claude"
|
||||||
agent_prompt_mode: str = "claude_append_file"
|
agent_prompt_mode: PromptMode = "append_file"
|
||||||
agent_provider_template: str = "claude"
|
agent_provider_template: str = "claude"
|
||||||
agent_dockerfile_path: str = ""
|
agent_dockerfile_path: str = ""
|
||||||
|
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ def launch(
|
|||||||
|
|
||||||
# Stamp the URLs onto the plan + guest_env. provision_git
|
# Stamp the URLs onto the plan + guest_env. provision_git
|
||||||
# and provision_supervise read the plan fields; the agent
|
# 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 —
|
# NO_PROXY has to include the per-bottle loopback alias —
|
||||||
# otherwise claude's HTTPS_PROXY catches direct calls to
|
# otherwise claude's HTTPS_PROXY catches direct calls to
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ process that:
|
|||||||
extra signalling.
|
extra signalling.
|
||||||
3. Waits on the child and exits with its returncode.
|
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>, --, ...]`
|
which now prepends `[sys.executable, -m, ..., <machine>, --, ...]`
|
||||||
to the smolvm argv. Foreground handoff (curses endwin →
|
to the smolvm argv. Foreground handoff (curses endwin →
|
||||||
subprocess.run) goes through the same path so behavior is
|
subprocess.run) goes through the same path so behavior is
|
||||||
|
|||||||
+59
-59
@@ -74,8 +74,8 @@ from ..supervise import (
|
|||||||
)
|
)
|
||||||
from ._common import PROG, USER_CWD
|
from ._common import PROG, USER_CWD
|
||||||
from .start import (
|
from .start import (
|
||||||
attach_claude,
|
attach_agent,
|
||||||
capture_session_state,
|
capture_claude_session_state,
|
||||||
prepare_with_preflight,
|
prepare_with_preflight,
|
||||||
settle_state,
|
settle_state,
|
||||||
)
|
)
|
||||||
@@ -650,7 +650,7 @@ def _bottle_for_slug(
|
|||||||
if slug in bottles:
|
if slug in bottles:
|
||||||
_cm, bottle, _identity = bottles[slug]
|
_cm, bottle, _identity = bottles[slug]
|
||||||
return bottle, ""
|
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
|
# `bot-bottle-<slug>` — set by the compose renderer
|
||||||
# (no service suffix on the agent service, by design).
|
# (no service suffix on the agent service, by design).
|
||||||
container_name = f"bot-bottle-{slug}"
|
container_name = f"bot-bottle-{slug}"
|
||||||
@@ -705,7 +705,7 @@ def _stop_bottle_flow(
|
|||||||
# settle_state below.
|
# settle_state below.
|
||||||
try:
|
try:
|
||||||
if getattr(bottle, "agent_provider_template", "claude") == "claude":
|
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:
|
except BaseException:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
@@ -715,7 +715,7 @@ def _stop_bottle_flow(
|
|||||||
|
|
||||||
# Mirror the bringup path's stderr → right-pane routing.
|
# Mirror the bringup path's stderr → right-pane routing.
|
||||||
# Reuses any existing right pane (which is probably the
|
# 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
|
# final buffered output stays visible after settle_state
|
||||||
# removes the state dir (tail-F handles file removal).
|
# removes the state dir (tail-F handles file removal).
|
||||||
try:
|
try:
|
||||||
@@ -752,7 +752,7 @@ def _stop_bottle_flow(
|
|||||||
# pane of a two-pane window with the operator's currently-selected
|
# pane of a two-pane window with the operator's currently-selected
|
||||||
# agent in the right pane. First attach creates the right pane via
|
# agent in the right pane. First attach creates the right pane via
|
||||||
# `tmux split-window`; subsequent attaches respawn that pane with
|
# `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
|
# pane id + occupant slug in `tmux_state` so the same pane is
|
||||||
# reused across attaches.
|
# reused across attaches.
|
||||||
|
|
||||||
@@ -763,14 +763,14 @@ def _in_tmux() -> bool:
|
|||||||
return bool(os.environ.get("TMUX"))
|
return bool(os.environ.get("TMUX"))
|
||||||
|
|
||||||
|
|
||||||
def _claude_runtime_args(
|
def _agent_runtime_args(
|
||||||
*, resume: bool, remote_control: bool = False, provider_template: str = "claude",
|
*, resume: bool, remote_control: bool = False, agent_provider_template: str = "claude",
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""The argv the dashboard hands to `bottle.claude_argv`
|
"""The argv the dashboard hands to `bottle.agent_argv`
|
||||||
on every attach — matches what `attach_claude` builds for the
|
on every attach — matches what `attach_agent` builds for the
|
||||||
foreground handoff so both surfaces produce the same claude
|
foreground handoff so both surfaces produce the same claude
|
||||||
invocation."""
|
invocation."""
|
||||||
runtime = runtime_for(provider_template)
|
runtime = runtime_for(agent_provider_template)
|
||||||
args = list(runtime.bypass_args)
|
args = list(runtime.bypass_args)
|
||||||
if remote_control:
|
if remote_control:
|
||||||
args.extend(runtime.remote_control_args)
|
args.extend(runtime.remote_control_args)
|
||||||
@@ -780,7 +780,7 @@ def _claude_runtime_args(
|
|||||||
|
|
||||||
|
|
||||||
def _build_resume_argv_with_fallback(
|
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]:
|
) -> list[str]:
|
||||||
"""Build a backend-exec argv that runs `claude --continue` and
|
"""Build a backend-exec argv that runs `claude --continue` and
|
||||||
falls back to plain `claude` if no prior session exists.
|
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
|
the fallback only kicks in when --continue would have
|
||||||
failed anyway.
|
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
|
surfaces the `claude` token preceded by the backend's exec
|
||||||
framing (docker: `docker exec -it <c>`; smolmachines:
|
framing (docker: `docker exec -it <c>`; smolmachines:
|
||||||
`smolvm machine exec --name <m> -- runuser -u node --`).
|
`smolvm machine exec --name <m> -- runuser -u node --`).
|
||||||
Splitting at `claude` keeps the framing as the prefix and
|
Splitting at `claude` keeps the framing as the prefix and
|
||||||
wraps just the claude tail in `sh -c`."""
|
wraps just the agent tail in `sh -c`."""
|
||||||
if provider_template != "claude":
|
if agent_provider_template != "claude":
|
||||||
return bottle.claude_argv(
|
return bottle.agent_argv(
|
||||||
_claude_runtime_args(
|
_agent_runtime_args(
|
||||||
resume=True,
|
resume=True,
|
||||||
remote_control=remote_control,
|
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,
|
resume=False,
|
||||||
remote_control=remote_control,
|
remote_control=remote_control,
|
||||||
provider_template=provider_template,
|
agent_provider_template=agent_provider_template,
|
||||||
)
|
)
|
||||||
base_exec = bottle.claude_argv(base_args)
|
base_exec = bottle.agent_argv(base_args)
|
||||||
# Split exec-framing prefix from the claude-and-args tail so
|
# Split exec-framing prefix from the agent-and-args tail so
|
||||||
# we can compose `<claude…> --continue || <claude…>` inside
|
# we can compose `<claude…> --continue || <claude…>` inside
|
||||||
# `sh -c`. The provider command token is the marker.
|
# `sh -c`. The provider command token is the marker.
|
||||||
command = getattr(bottle, "agent_command", runtime_for(provider_template).command)
|
command = getattr(bottle, "agent_command", runtime_for(agent_provider_template).command)
|
||||||
claude_idx = base_exec.index(command)
|
agent_idx = base_exec.index(command)
|
||||||
prefix = base_exec[:claude_idx]
|
prefix = base_exec[:agent_idx]
|
||||||
claude_cmd = " ".join(shlex.quote(a) for a in base_exec[claude_idx:])
|
agent_cmd = " ".join(shlex.quote(a) for a in base_exec[agent_idx:])
|
||||||
resume_args = " ".join(
|
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 [
|
return [
|
||||||
*prefix,
|
*prefix,
|
||||||
"sh", "-c",
|
"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
|
"""Pure helper: wrap a backend-exec argv with `tmux split-window
|
||||||
-h -P -F '#{pane_id}'`. The `-P -F` combo tells tmux to print
|
-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
|
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 [
|
return [
|
||||||
"tmux", "split-window", "-h",
|
"tmux", "split-window", "-h",
|
||||||
"-P", "-F", "#{pane_id}",
|
"-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
|
"""Pure helper: wrap a backend-exec argv with `tmux respawn-pane
|
||||||
-k -t <pane_id>`. `-k` kills the existing process in the pane
|
-k -t <pane_id>`. `-k` kills the existing process in the pane
|
||||||
before respawning."""
|
before respawning."""
|
||||||
return ["tmux", "respawn-pane", "-k", "-t", pane_id, *claude_argv]
|
return ["tmux", "respawn-pane", "-k", "-t", pane_id, *agent_argv]
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
@@ -952,7 +952,7 @@ def _route_op_to_right_pane(
|
|||||||
def _tmux_close_right_pane(tmux_state: dict) -> None:
|
def _tmux_close_right_pane(tmux_state: dict) -> None:
|
||||||
"""Close the tracked right pane via `tmux kill-pane`. Clears
|
"""Close the tracked right pane via `tmux kill-pane`. Clears
|
||||||
both pane_id and slug in `tmux_state`. Used after the last
|
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."""
|
to host, so the pane shouldn't linger."""
|
||||||
pane_id = tmux_state.get("pane_id")
|
pane_id = tmux_state.get("pane_id")
|
||||||
if pane_id and _tmux_pane_exists(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.
|
returns the pane id on success, None on failure.
|
||||||
|
|
||||||
This is the single place where "respawn or create" lives —
|
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,
|
`_new_agent_flow` for the bringup-log tail. Without this,
|
||||||
every new-agent start would pile up a fresh right pane
|
every new-agent start would pile up a fresh right pane
|
||||||
instead of reusing the one already next to the dashboard."""
|
instead of reusing the one already next to the dashboard."""
|
||||||
@@ -1037,18 +1037,18 @@ def _attach_via_handoff(
|
|||||||
`_attach_in_tmux` when tmux misbehaves)."""
|
`_attach_in_tmux` when tmux misbehaves)."""
|
||||||
curses.endwin()
|
curses.endwin()
|
||||||
try:
|
try:
|
||||||
provider_template = getattr(bottle, "agent_provider_template", "claude")
|
agent_provider_template = getattr(bottle, "agent_provider_template", "claude")
|
||||||
exit_code = attach_claude(
|
exit_code = attach_agent(
|
||||||
bottle,
|
bottle,
|
||||||
remote_control=False,
|
remote_control=False,
|
||||||
resume=resume,
|
resume=resume,
|
||||||
provider_template=provider_template,
|
agent_provider_template=agent_provider_template,
|
||||||
)
|
)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
stdscr.refresh()
|
stdscr.refresh()
|
||||||
raise
|
raise
|
||||||
stdscr.refresh()
|
stdscr.refresh()
|
||||||
return f"[{slug}] claude session ended (exit {exit_code})"
|
return f"[{slug}] agent session ended (exit {exit_code})"
|
||||||
|
|
||||||
|
|
||||||
def _attach_in_tmux(
|
def _attach_in_tmux(
|
||||||
@@ -1067,28 +1067,28 @@ def _attach_in_tmux(
|
|||||||
explicit-stop hook).
|
explicit-stop hook).
|
||||||
|
|
||||||
`focus_right_pane=True` runs `tmux select-pane` after the
|
`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
|
The Enter re-attach key passes this; passive paths (the
|
||||||
auto-attach after a stop) leave it False so the operator
|
auto-attach after a stop) leave it False so the operator
|
||||||
stays in the dashboard pane."""
|
stays in the dashboard pane."""
|
||||||
if resume:
|
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
|
# `--continue` exits non-zero when no prior session
|
||||||
# exists (agent spun up but never typed at). Wrap with a
|
# exists (agent spun up but never typed at). Wrap with a
|
||||||
# shell-level fallback so the pane lands in a fresh
|
# shell-level fallback so the pane lands in a fresh
|
||||||
# claude instead of crashing.
|
# agent instead of crashing.
|
||||||
claude_argv = _build_resume_argv_with_fallback(
|
agent_argv = _build_resume_argv_with_fallback(
|
||||||
bottle, provider_template=provider_template,
|
bottle, agent_provider_template=agent_provider_template,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
provider_template = getattr(bottle, "agent_provider_template", "claude")
|
agent_provider_template = getattr(bottle, "agent_provider_template", "claude")
|
||||||
claude_argv = bottle.claude_argv(
|
agent_argv = bottle.agent_argv(
|
||||||
_claude_runtime_args(
|
_agent_runtime_args(
|
||||||
resume=False,
|
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:
|
if pane_id is None:
|
||||||
# tmux failed (missing binary, server died, size error).
|
# tmux failed (missing binary, server died, size error).
|
||||||
# One status-line failover to the curses handoff so the
|
# One status-line failover to the curses handoff so the
|
||||||
@@ -1121,7 +1121,7 @@ def _attach_to_bottle(
|
|||||||
tmux_state: dict | None = None,
|
tmux_state: dict | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Re-attach to a running bottle. Inside tmux (`$TMUX` set +
|
"""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
|
right pane (created on first attach, respawned on
|
||||||
subsequent). Outside tmux it's a curses-endwin handoff that
|
subsequent). Outside tmux it's a curses-endwin handoff that
|
||||||
blocks until the operator exits claude. Re-attach always uses
|
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:
|
if _in_tmux() and tmux_state is not None:
|
||||||
# Enter re-attach is an explicit "I want to interact with
|
# Enter re-attach is an explicit "I want to interact with
|
||||||
# this agent" signal — move tmux focus to the right pane
|
# 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(
|
return _attach_in_tmux(
|
||||||
stdscr, bottle, slug,
|
stdscr, bottle, slug,
|
||||||
resume=True, tmux_state=tmux_state,
|
resume=True, tmux_state=tmux_state,
|
||||||
@@ -1147,7 +1147,7 @@ def _new_agent_flow(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Open the picker, prepare + preflight (modal), launch
|
"""Open the picker, prepare + preflight (modal), launch
|
||||||
(enter the context manager but DON'T close it), then route
|
(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
|
foreground handoff (otherwise). Returns a status-line message
|
||||||
for the dashboard footer. The (cm, bottle) tuple lands in
|
for the dashboard footer. The (cm, bottle) tuple lands in
|
||||||
`bottles` keyed by slug; chunk 4 uses it for explicit stop."""
|
`bottles` keyed by slug; chunk 4 uses it for explicit stop."""
|
||||||
@@ -1235,20 +1235,20 @@ def _new_agent_flow(
|
|||||||
raise
|
raise
|
||||||
bottles[plan.slug] = (cm, bottle, identity)
|
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.
|
# then we restore curses.
|
||||||
try:
|
try:
|
||||||
provider_template = getattr(plan, "agent_provider_template", "claude")
|
agent_provider_template = getattr(plan, "agent_provider_template", "claude")
|
||||||
exit_code = attach_claude(
|
exit_code = attach_agent(
|
||||||
bottle,
|
bottle,
|
||||||
remote_control=False,
|
remote_control=False,
|
||||||
provider_template=provider_template,
|
agent_provider_template=agent_provider_template,
|
||||||
)
|
)
|
||||||
if provider_template == "claude":
|
if agent_provider_template == "claude":
|
||||||
capture_session_state(identity, exit_code)
|
capture_claude_session_state(identity, exit_code)
|
||||||
finally:
|
finally:
|
||||||
stdscr.refresh()
|
stdscr.refresh()
|
||||||
return f"[{plan.slug}] claude session ended (exit {exit_code})"
|
return f"[{plan.slug}] agent session ended (exit {exit_code})"
|
||||||
finally:
|
finally:
|
||||||
# stage_dir was the prepare scratch dir; after PRD 0018
|
# stage_dir was the prepare scratch dir; after PRD 0018
|
||||||
# chunk 2 it holds nothing the running bottle needs. Reap
|
# 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
|
# PRD 0021 follow-up: after stop, slide focus
|
||||||
# to the next agent in the list (the one that
|
# to the next agent in the list (the one that
|
||||||
# filled the stopped row) and respawn the
|
# 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.
|
# nothing's left, close the right pane.
|
||||||
pick = _pick_next_after_stop(
|
pick = _pick_next_after_stop(
|
||||||
agents, selected_agent, target.slug,
|
agents, selected_agent, target.slug,
|
||||||
|
|||||||
+22
-18
@@ -4,7 +4,7 @@ session ends.
|
|||||||
|
|
||||||
The launch core is shared with `cli.py resume <identity>` and (PRD
|
The launch core is shared with `cli.py resume <identity>` and (PRD
|
||||||
0020 chunk 1+) the dashboard's in-process start flow: see the
|
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`.
|
private orchestrator `_launch_bottle`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -113,12 +113,13 @@ def prepare_with_preflight(
|
|||||||
return plan, identity
|
return plan, identity
|
||||||
|
|
||||||
|
|
||||||
def attach_claude(
|
def attach_agent(
|
||||||
bottle: Bottle, *, remote_control: bool = False, resume: bool = False,
|
bottle: Bottle, *, remote_control: bool = False, resume: bool = False,
|
||||||
provider_template: str = "claude",
|
agent_provider_template: str = "claude",
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Run claude inside `bottle` as an interactive session. Blocks
|
"""Run the selected provider CLI inside `bottle` as an
|
||||||
until the session ends; returns the claude process's exit code.
|
interactive session. Blocks until the session ends; returns the
|
||||||
|
agent process's exit code.
|
||||||
|
|
||||||
`resume=True` adds `--continue` so claude picks up its most
|
`resume=True` adds `--continue` so claude picks up its most
|
||||||
recent session non-interactively (no session-picker prompt) —
|
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
|
Used as the inner step of `./cli.py start` (one-shot) and by the
|
||||||
dashboard, which calls it from inside a `curses.endwin → … →
|
dashboard, which calls it from inside a `curses.endwin → … →
|
||||||
stdscr.refresh()` handoff so the curses surface gets out of the
|
stdscr.refresh()` handoff so the curses surface gets out of the
|
||||||
terminal's way while claude has it."""
|
terminal's way while the agent has it."""
|
||||||
runtime = runtime_for(provider_template)
|
runtime = runtime_for(agent_provider_template)
|
||||||
info(
|
info(
|
||||||
f"attaching interactive {provider_template} session "
|
f"attaching interactive {agent_provider_template} session "
|
||||||
"(Ctrl-D or 'exit' to leave; container will be removed)"
|
"(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:
|
if remote_control:
|
||||||
claude_args.extend(runtime.remote_control_args)
|
agent_args.extend(runtime.remote_control_args)
|
||||||
if resume:
|
if resume:
|
||||||
claude_args.extend(runtime.resume_args)
|
agent_args.extend(runtime.resume_args)
|
||||||
return bottle.exec_claude(claude_args, tty=True)
|
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
|
"""Inside the launch context, while the container is still
|
||||||
alive: snapshot the transcript and mark for preservation if
|
alive: snapshot the transcript and mark for preservation if
|
||||||
claude crashed. Public for the dashboard's death-handling path
|
claude crashed. Public for the dashboard's death-handling path
|
||||||
(PRD 0020 open question 3)."""
|
(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:
|
if not identity:
|
||||||
return
|
return
|
||||||
snapshot_transcript(identity)
|
snapshot_transcript(identity)
|
||||||
@@ -218,11 +222,11 @@ def _launch_bottle(
|
|||||||
|
|
||||||
backend = get_bottle_backend(backend_name)
|
backend = get_bottle_backend(backend_name)
|
||||||
with backend.launch(plan) as bottle:
|
with backend.launch(plan) as bottle:
|
||||||
provider_template = getattr(plan, "agent_provider_template", "claude")
|
agent_provider_template = getattr(plan, "agent_provider_template", "claude")
|
||||||
exit_code = attach_claude(
|
exit_code = attach_agent(
|
||||||
bottle,
|
bottle,
|
||||||
remote_control=remote_control,
|
remote_control=remote_control,
|
||||||
provider_template=provider_template,
|
agent_provider_template=agent_provider_template,
|
||||||
)
|
)
|
||||||
info(
|
info(
|
||||||
f"session ended (exit {exit_code}); "
|
f"session ended (exit {exit_code}); "
|
||||||
@@ -236,8 +240,8 @@ def _launch_bottle(
|
|||||||
# way. snapshot_transcript is best-effort so the
|
# way. snapshot_transcript is best-effort so the
|
||||||
# capability-block path's prior snapshot isn't clobbered
|
# capability-block path's prior snapshot isn't clobbered
|
||||||
# when the container is already gone.
|
# when the container is already gone.
|
||||||
if provider_template == "claude":
|
if agent_provider_template == "claude":
|
||||||
capture_session_state(identity, exit_code)
|
capture_claude_session_state(identity, exit_code)
|
||||||
return 0
|
return 0
|
||||||
finally:
|
finally:
|
||||||
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
|
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
|
||||||
|
|||||||
@@ -478,7 +478,7 @@ class EgressConfig:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(
|
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":
|
) -> "EgressConfig":
|
||||||
d = _as_json_object(raw, f"bottle '{bottle_name}' egress")
|
d = _as_json_object(raw, f"bottle '{bottle_name}' egress")
|
||||||
routes_raw = d.get("routes")
|
routes_raw = d.get("routes")
|
||||||
@@ -495,7 +495,7 @@ class EgressConfig:
|
|||||||
for i, entry in enumerate(routes_list)
|
for i, entry in enumerate(routes_list)
|
||||||
)
|
)
|
||||||
_validate_egress_routes(
|
_validate_egress_routes(
|
||||||
bottle_name, routes, provider_template=provider_template,
|
bottle_name, routes, agent_provider_template=agent_provider_template,
|
||||||
)
|
)
|
||||||
for k in d:
|
for k in d:
|
||||||
if k != "routes":
|
if k != "routes":
|
||||||
@@ -589,7 +589,7 @@ class Bottle:
|
|||||||
egress = (
|
egress = (
|
||||||
EgressConfig.from_dict(
|
EgressConfig.from_dict(
|
||||||
name, d["egress"],
|
name, d["egress"],
|
||||||
provider_template=agent_provider.template,
|
agent_provider_template=agent_provider.template,
|
||||||
)
|
)
|
||||||
if "egress" in d
|
if "egress" in d
|
||||||
else EgressConfig()
|
else EgressConfig()
|
||||||
@@ -887,7 +887,7 @@ def _validate_egress_routes(
|
|||||||
bottle_name: str,
|
bottle_name: str,
|
||||||
routes: tuple[EgressRoute, ...],
|
routes: tuple[EgressRoute, ...],
|
||||||
*,
|
*,
|
||||||
provider_template: str = "claude",
|
agent_provider_template: str = "claude",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Cross-validation for `bottle.egress.routes`:
|
"""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"routes with role {role!r} (hosts: {hosts}); this role drives a "
|
||||||
f"single launch-step side effect — pick one."
|
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 route in routes:
|
||||||
for role in route.Role:
|
for role in route.Role:
|
||||||
if role not in allowed_roles:
|
if role not in allowed_roles:
|
||||||
die(
|
die(
|
||||||
f"bottle '{bottle_name}' egress route for host "
|
f"bottle '{bottle_name}' egress route for host "
|
||||||
f"{route.Host!r} has role {role!r}, but provider "
|
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)'}"
|
f"{', '.join(sorted(allowed_roles)) or '(none)'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1141,7 +1141,7 @@ def _merge_bottles(
|
|||||||
)
|
)
|
||||||
_validate_egress_routes(
|
_validate_egress_routes(
|
||||||
name, merged_egress.routes,
|
name, merged_egress.routes,
|
||||||
provider_template=merged_agent_provider.template,
|
agent_provider_template=merged_agent_provider.template,
|
||||||
)
|
)
|
||||||
|
|
||||||
return Bottle(
|
return Bottle(
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ The feature is **done** when all of the following ship:
|
|||||||
`Bottle`) plus a `bot_bottle/backend/docker/` subpackage
|
`Bottle`) plus a `bot_bottle/backend/docker/` subpackage
|
||||||
containing the `DockerBottleBackend` implementation.
|
containing the `DockerBottleBackend` implementation.
|
||||||
- `DockerBottleBackend.launch(plan)` returns a context manager
|
- `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.
|
`cp_in(host, ctr)`, and teardown on context exit.
|
||||||
- Every existing `subprocess.run(["docker", ...])` call in
|
- Every existing `subprocess.run(["docker", ...])` call in
|
||||||
`cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`, and
|
`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
|
- **`bot_bottle/cli/start.py`** — replace the inline docker
|
||||||
orchestration with `backend = get_bottle_backend(); plan =
|
orchestration with `backend = get_bottle_backend(); plan =
|
||||||
backend.prepare(spec, stage_dir=...); with backend.launch(plan) as
|
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(...)`.
|
`plan.print(...)`.
|
||||||
- **`bot_bottle/manifest.py`** — drop the `runtime` field from the
|
- **`bot_bottle/manifest.py`** — drop the `runtime` field from the
|
||||||
Bottle dataclass and its validation. Existing manifests with
|
Bottle dataclass and its validation. Existing manifests with
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ existing prefix scan as a fallback for one release.
|
|||||||
|
|
||||||
2. **How does `claude` reach the agent's TTY?** Decided: keep
|
2. **How does `claude` reach the agent's TTY?** Decided: keep
|
||||||
today's `docker exec -it` model. Agent runs `sleep infinity`
|
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
|
`docker exec -it bot-bottle-<slug> claude ...` exactly like
|
||||||
today. Compose owns the lifecycle (so `compose logs` includes
|
today. Compose owns the lifecycle (so `compose logs` includes
|
||||||
the agent's stdout, `compose down` tears it down), but the
|
the agent's stdout, `compose down` tears it down), but the
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ Today's flow:
|
|||||||
```
|
```
|
||||||
./cli.py start agent
|
./cli.py start agent
|
||||||
└─ with backend.launch(plan) as bottle: ← bottle alive while inside `with`
|
└─ 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
|
# context exits → compose down → state cleanup
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ The proposed dashboard-driven flow:
|
|||||||
|
|
||||||
# operator interacts via:
|
# operator interacts via:
|
||||||
curses.endwin()
|
curses.endwin()
|
||||||
bottle.exec_claude([...], tty=True) ← blocks; returns on Ctrl-D
|
bottle.exec_agent([...], tty=True) ← blocks; returns on Ctrl-D
|
||||||
stdscr.refresh()
|
stdscr.refresh()
|
||||||
# bottle is STILL ALIVE — only the claude process exited
|
# 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
|
Same handoff pattern the new-agent flow uses. For an agent the
|
||||||
dashboard started this session, the dashboard holds the
|
dashboard started this session, the dashboard holds the
|
||||||
`DockerBottle` handle in its `bottles` dict and calls
|
`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),
|
`list_active_slugs` (previous-dashboard or external start),
|
||||||
the dashboard synthesizes a one-shot `DockerBottle` from the
|
the dashboard synthesizes a one-shot `DockerBottle` from the
|
||||||
slug — container name is `bot-bottle-<slug>`, no prompt
|
slug — container name is `bot-bottle-<slug>`, no prompt
|
||||||
@@ -304,17 +304,17 @@ acting surface, not a lifetime owner.
|
|||||||
|
|
||||||
Sized for one PR each.
|
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
|
pieces are separable.** Today's `cli/start.py` runs both
|
||||||
inside one function. Extract `prepare_with_preflight(spec,
|
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
|
*, remote_control)`. The CLI's existing one-shot use binds
|
||||||
them as before; the dashboard binds them with curses-aware
|
them as before; the dashboard binds them with curses-aware
|
||||||
render + prompt callables. No behavior change.
|
render + prompt callables. No behavior change.
|
||||||
2. **Agent picker modal + new-agent flow.** New key `n` opens
|
2. **Agent picker modal + new-agent flow.** New key `n` opens
|
||||||
the picker; `prepare_with_preflight` runs against the
|
the picker; `prepare_with_preflight` runs against the
|
||||||
selected agent; on Y, `backend.launch(plan)` enters 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
|
3. **Re-attach via Enter on owned agents-pane row.** Looks up
|
||||||
the slug in the dashboard's `bottles` map; if present →
|
the slug in the dashboard's `bottles` map; if present →
|
||||||
handoff; else → status-line hint pointing at `./cli.py
|
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
|
- PRD 0019 — active-agents pane + selection model (the
|
||||||
agents-pane row the re-attach + stop verbs hook into)
|
agents-pane row the re-attach + stop verbs hook into)
|
||||||
- `docs/research/claude-code-pane-in-dashboard.md` — option 1
|
- `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
|
/ 3 are out of scope for this PRD
|
||||||
- `bot_bottle/cli/start.py:_launch_bottle` — the function
|
- `bot_bottle/cli/start.py:_launch_bottle` — the function
|
||||||
chunk 1 extracts the prepare + attach pieces out of
|
chunk 1 extracts the prepare + attach pieces out of
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ def _attach_in_tmux(bottle, slug, *, resume) -> str:
|
|||||||
|
|
||||||
The non-tmux path is unchanged from PRD 0020 — `_attach_via_
|
The non-tmux path is unchanged from PRD 0020 — `_attach_via_
|
||||||
handoff` is what those two flows already do today (curses.
|
handoff` is what those two flows already do today (curses.
|
||||||
endwin → attach_claude → stdscr.refresh).
|
endwin → attach_agent → stdscr.refresh).
|
||||||
|
|
||||||
### Pane creation
|
### Pane creation
|
||||||
|
|
||||||
@@ -230,10 +230,10 @@ def _tmux_pane_exists(pane_id) -> bool:
|
|||||||
|
|
||||||
The tmux helpers need the full docker-exec argv for claude —
|
The tmux helpers need the full docker-exec argv for claude —
|
||||||
specifically including the `--append-system-prompt-file <path>`
|
specifically including the `--append-system-prompt-file <path>`
|
||||||
flag that `DockerBottle.exec_claude` appends today when the
|
flag that `DockerBottle.exec_agent` appends today when the
|
||||||
bottle has a prompt path. Refactor: split `exec_claude` into a
|
bottle has a prompt path. Refactor: split `exec_agent` into a
|
||||||
pure `claude_docker_argv(args, *, tty)` that returns the argv
|
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
|
Both the tmux path AND the existing foreground path use the
|
||||||
same argv builder.
|
same argv builder.
|
||||||
|
|
||||||
@@ -272,7 +272,7 @@ Three failure modes worth handling:
|
|||||||
Sized small.
|
Sized small.
|
||||||
|
|
||||||
1. **`claude_docker_argv` refactor.** Pure-ish split of
|
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
|
paths build on the same argv. No behavior change for the
|
||||||
existing tests.
|
existing tests.
|
||||||
2. **tmux helpers + pane state.** Add `_in_tmux`,
|
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
|
bottle: dev
|
||||||
---
|
---
|
||||||
|
|
||||||
(no prompt — exec_claude isn't called)
|
(no prompt — exec_agent isn't called)
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ The feature is **done** when all of the following ship:
|
|||||||
bot_bottle/backend/smolmachines/
|
bot_bottle/backend/smolmachines/
|
||||||
__init__.py re-exports SmolmachinesBottleBackend
|
__init__.py re-exports SmolmachinesBottleBackend
|
||||||
backend.py SmolmachinesBottleBackend façade
|
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_plan.py SmolmachinesBottlePlan + .print()
|
||||||
bottle_cleanup_plan.py SmolmachinesBottleCleanupPlan
|
bottle_cleanup_plan.py SmolmachinesBottleCleanupPlan
|
||||||
prepare.py resolve_plan(spec, stage_dir, ...) -> SmolmachinesBottlePlan
|
prepare.py resolve_plan(spec, stage_dir, ...) -> SmolmachinesBottlePlan
|
||||||
@@ -436,7 +436,7 @@ Three changes vs. the Docker backend:
|
|||||||
layer enforces it.
|
layer enforces it.
|
||||||
3. Provisioning: CA install → prompt → skills → git → supervise
|
3. Provisioning: CA install → prompt → skills → git → supervise
|
||||||
config, each via `smolvm machine exec` / `smolvm machine cp`.
|
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` /
|
`cp_in` all funnel through `smolvm machine exec` /
|
||||||
`smolvm machine cp`.
|
`smolvm machine cp`.
|
||||||
5. Teardown: stop and delete the VM → stop + remove the bundle
|
5. Teardown: stop and delete the VM → stop + remove the bundle
|
||||||
|
|||||||
@@ -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
|
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
|
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:
|
minimum the orchestrator interacts with:
|
||||||
|
|
||||||
- A lightweight `alpine:latest sleep infinity` container named
|
- A lightweight `alpine:latest sleep infinity` container named
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Unit: cli/start.py session-end state capture (crash preservation).
|
"""Unit: cli/start.py session-end state capture (crash preservation).
|
||||||
|
|
||||||
The launch-context machinery is covered by integration; this isolates
|
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."""
|
preservation if non-zero exit, no-op for clean exit."""
|
||||||
|
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -45,25 +45,25 @@ class TestCaptureSessionState(_FakeHomeMixin, unittest.TestCase):
|
|||||||
self._teardown_fake_home()
|
self._teardown_fake_home()
|
||||||
|
|
||||||
def test_clean_exit_snapshots_but_does_not_mark(self):
|
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.assertEqual(["dev-abc"], self._snap_calls)
|
||||||
self.assertFalse(bottle_state.is_preserved("dev-abc"))
|
self.assertFalse(bottle_state.is_preserved("dev-abc"))
|
||||||
|
|
||||||
def test_crash_snapshots_and_marks(self):
|
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.assertEqual(["dev-abc"], self._snap_calls)
|
||||||
self.assertTrue(bottle_state.is_preserved("dev-abc"))
|
self.assertTrue(bottle_state.is_preserved("dev-abc"))
|
||||||
|
|
||||||
def test_ctrl_c_treated_as_crash(self):
|
def test_ctrl_c_treated_as_crash(self):
|
||||||
# SIGINT delivers exit 130; the operator may have Ctrl-C'd
|
# SIGINT delivers exit 130; the operator may have Ctrl-C'd
|
||||||
# because something went wrong, so we preserve.
|
# 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"))
|
self.assertTrue(bottle_state.is_preserved("dev-abc"))
|
||||||
|
|
||||||
def test_empty_identity_is_noop(self):
|
def test_empty_identity_is_noop(self):
|
||||||
# Backends without an identity field shouldn't crash this
|
# Backends without an identity field shouldn't crash this
|
||||||
# path (the _identity_from_plan helper falls back to "").
|
# 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)
|
self.assertEqual([], self._snap_calls)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -369,24 +369,24 @@ class TestResumeArgvWithFallback(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestClaudeRuntimeArgs(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
|
attach. Locked here so the tmux + foreground paths build
|
||||||
identical claude invocations."""
|
identical agent invocations."""
|
||||||
|
|
||||||
def test_default_skip_permissions_only(self):
|
def test_default_skip_permissions_only(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["--dangerously-skip-permissions"],
|
["--dangerously-skip-permissions"],
|
||||||
dashboard._claude_runtime_args(resume=False),
|
dashboard._agent_runtime_args(resume=False),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_resume_appends_continue(self):
|
def test_resume_appends_continue(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["--dangerously-skip-permissions", "--continue"],
|
["--dangerously-skip-permissions", "--continue"],
|
||||||
dashboard._claude_runtime_args(resume=True),
|
dashboard._agent_runtime_args(resume=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_remote_control(self):
|
def test_remote_control(self):
|
||||||
args = dashboard._claude_runtime_args(
|
args = dashboard._agent_runtime_args(
|
||||||
resume=False, remote_control=True,
|
resume=False, remote_control=True,
|
||||||
)
|
)
|
||||||
self.assertIn("--remote-control", args)
|
self.assertIn("--remote-control", args)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Unit: DockerBottle's argv builder (PRD 0021 chunk 1).
|
"""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
|
PRD-0021 tmux helpers both build on. It encodes two non-trivial
|
||||||
rules — the optional `--append-system-prompt-file` flag and the
|
rules — the optional `--append-system-prompt-file` flag and the
|
||||||
optional `-it` for TTY mode — that we lock down here so the tmux
|
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,
|
teardown=lambda: None,
|
||||||
prompt_path_in_container=prompt_path,
|
prompt_path_in_container=prompt_path,
|
||||||
agent_command="codex",
|
agent_command="codex",
|
||||||
agent_prompt_mode="codex_read_prompt_file",
|
agent_prompt_mode="read_prompt_file",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestClaudeArgv(unittest.TestCase):
|
class TestClaudeArgv(unittest.TestCase):
|
||||||
def test_minimal_argv_no_prompt(self):
|
def test_minimal_argv_no_prompt(self):
|
||||||
argv = _bottle().claude_argv([])
|
argv = _bottle().agent_argv([])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["docker", "exec", "-it", "bot-bottle-dev-abc", "claude"],
|
["docker", "exec", "-it", "bot-bottle-dev-abc", "claude"],
|
||||||
argv,
|
argv,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_appends_passed_args_after_claude(self):
|
def test_appends_passed_args_after_claude(self):
|
||||||
argv = _bottle().claude_argv(
|
argv = _bottle().agent_argv(
|
||||||
["--dangerously-skip-permissions", "--continue"],
|
["--dangerously-skip-permissions", "--continue"],
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -51,7 +51,7 @@ class TestClaudeArgv(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_appends_prompt_file_flag_when_set(self):
|
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"],
|
["--dangerously-skip-permissions"],
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -63,34 +63,34 @@ class TestClaudeArgv(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_no_prompt_flag_when_none(self):
|
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)
|
self.assertNotIn("--append-system-prompt-file", argv)
|
||||||
|
|
||||||
def test_empty_prompt_string_is_treated_as_no_prompt(self):
|
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
|
# prompt_path means "skip the flag." The synth path in
|
||||||
# dashboard.py relies on this when metadata is missing.
|
# 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)
|
self.assertNotIn("--append-system-prompt-file", argv)
|
||||||
|
|
||||||
def test_tty_false_drops_it_flag(self):
|
def test_tty_false_drops_it_flag(self):
|
||||||
argv = _bottle().claude_argv([], tty=False)
|
argv = _bottle().agent_argv([], tty=False)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["docker", "exec", "bot-bottle-dev-abc", "claude"],
|
["docker", "exec", "bot-bottle-dev-abc", "claude"],
|
||||||
argv,
|
argv,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_caller_argv_not_mutated(self):
|
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
|
# 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.
|
# it on subsequent calls.
|
||||||
original = ["--continue"]
|
original = ["--continue"]
|
||||||
_bottle("/x").claude_argv(original)
|
_bottle("/x").agent_argv(original)
|
||||||
self.assertEqual(["--continue"], original)
|
self.assertEqual(["--continue"], original)
|
||||||
|
|
||||||
def test_codex_provider_uses_codex_command(self):
|
def test_codex_provider_uses_codex_command(self):
|
||||||
argv = _codex_bottle().claude_argv(
|
argv = _codex_bottle().agent_argv(
|
||||||
["--dangerously-bypass-approvals-and-sandbox"],
|
["--dangerously-bypass-approvals-and-sandbox"],
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -100,7 +100,7 @@ class TestClaudeArgv(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_codex_provider_passes_prompt_reference_as_initial_prompt(self):
|
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(
|
self.assertEqual(
|
||||||
["docker", "exec", "-it", "bot-bottle-dev-abc", "codex",
|
["docker", "exec", "-it", "bot-bottle-dev-abc", "codex",
|
||||||
"Read and follow the instructions in "
|
"Read and follow the instructions in "
|
||||||
@@ -109,7 +109,7 @@ class TestClaudeArgv(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_codex_resume_does_not_append_initial_prompt(self):
|
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"],
|
["--dangerously-bypass-approvals-and-sandbox", "resume", "--last"],
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|||||||
@@ -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
|
directly (it spawns claude inside a tmux pane rather than as a
|
||||||
child of the current process), so the argv shape is the
|
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.
|
builder + `subprocess.run`; we lock the shape here.
|
||||||
|
|
||||||
The TTY-mode argv is wrapped in the pty_resize helper (issue #82
|
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."""
|
"""TTY-mode argv: pty_resize wrapper + inner smolvm exec."""
|
||||||
|
|
||||||
def test_pty_resize_wrapper_prefix(self):
|
def test_pty_resize_wrapper_prefix(self):
|
||||||
argv = _bottle().claude_argv([])
|
argv = _bottle().agent_argv([])
|
||||||
# Absolute script path (not `-m <dotted>`) so the tmux
|
# Absolute script path (not `-m <dotted>`) so the tmux
|
||||||
# pane's cwd doesn't matter — see the `_PTY_RESIZE_SCRIPT`
|
# pane's cwd doesn't matter — see the `_PTY_RESIZE_SCRIPT`
|
||||||
# docstring in bottle.py.
|
# docstring in bottle.py.
|
||||||
@@ -53,7 +53,7 @@ class TestClaudeArgvWrapped(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_minimal_inner_argv_no_prompt(self):
|
def test_minimal_inner_argv_no_prompt(self):
|
||||||
argv = _unwrap(_bottle().claude_argv([]))
|
argv = _unwrap(_bottle().agent_argv([]))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[
|
[
|
||||||
"smolvm", "machine", "exec", "--name",
|
"smolvm", "machine", "exec", "--name",
|
||||||
@@ -69,7 +69,7 @@ class TestClaudeArgvWrapped(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_appends_passed_args_after_claude(self):
|
def test_appends_passed_args_after_claude(self):
|
||||||
argv = _unwrap(_bottle().claude_argv(
|
argv = _unwrap(_bottle().agent_argv(
|
||||||
["--dangerously-skip-permissions", "--continue"],
|
["--dangerously-skip-permissions", "--continue"],
|
||||||
))
|
))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -79,7 +79,7 @@ class TestClaudeArgvWrapped(unittest.TestCase):
|
|||||||
|
|
||||||
def test_appends_prompt_file_flag_when_set(self):
|
def test_appends_prompt_file_flag_when_set(self):
|
||||||
argv = _unwrap(
|
argv = _unwrap(
|
||||||
_bottle("/home/node/.bot-bottle-prompt.txt").claude_argv(
|
_bottle("/home/node/.bot-bottle-prompt.txt").agent_argv(
|
||||||
["--dangerously-skip-permissions"],
|
["--dangerously-skip-permissions"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -94,11 +94,11 @@ class TestClaudeArgvWrapped(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_no_prompt_flag_when_none(self):
|
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)
|
self.assertNotIn("--append-system-prompt-file", argv)
|
||||||
|
|
||||||
def test_empty_prompt_string_is_treated_as_no_prompt(self):
|
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)
|
self.assertNotIn("--append-system-prompt-file", argv)
|
||||||
|
|
||||||
def test_guest_env_forwarded_as_e_flags(self):
|
def test_guest_env_forwarded_as_e_flags(self):
|
||||||
@@ -106,7 +106,7 @@ class TestClaudeArgvWrapped(unittest.TestCase):
|
|||||||
None,
|
None,
|
||||||
HTTPS_PROXY="http://127.0.0.1:1234",
|
HTTPS_PROXY="http://127.0.0.1:1234",
|
||||||
NO_PROXY="localhost",
|
NO_PROXY="localhost",
|
||||||
).claude_argv([]))
|
).agent_argv([]))
|
||||||
self.assertIn("-e", argv)
|
self.assertIn("-e", argv)
|
||||||
self.assertIn("HTTPS_PROXY=http://127.0.0.1:1234", argv)
|
self.assertIn("HTTPS_PROXY=http://127.0.0.1:1234", argv)
|
||||||
self.assertIn("NO_PROXY=localhost", 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
|
# the `claude` token to split exec-framing from the claude
|
||||||
# tail. `runuser -u node --` must sit on the prefix side so
|
# tail. `runuser -u node --` must sit on the prefix side so
|
||||||
# the shell wrap inherits the UID switch.
|
# the shell wrap inherits the UID switch.
|
||||||
argv = _bottle().claude_argv([])
|
argv = _bottle().agent_argv([])
|
||||||
claude_idx = argv.index("claude")
|
agent_idx = argv.index("claude")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["runuser", "-u", "node", "--"],
|
["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."""
|
PTY whose SIGWINCH we'd need to bridge."""
|
||||||
|
|
||||||
def test_no_wrapper_when_tty_false(self):
|
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.assertEqual("smolvm", argv[0])
|
||||||
self.assertFalse(any("pty_resize" in a for a in argv))
|
self.assertFalse(any("pty_resize" in a for a in argv))
|
||||||
|
|
||||||
def test_tty_false_drops_it_flags(self):
|
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("-i", argv)
|
||||||
self.assertNotIn("-t", argv)
|
self.assertNotIn("-t", argv)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user