fix(dashboard): hoist claude_argv to Bottle ABC so smolmachines pane attach works
test / unit (pull_request) Successful in 27s
test / integration (pull_request) Successful in 42s
test / unit (push) Successful in 26s
test / integration (push) Successful in 45s

Launching a smolmachines agent from the dashboard inside tmux
crashed with

  AttributeError: 'SmolmachinesBottle' object has no attribute
  'claude_docker_argv'

because the tmux pane-respawn path called
`bottle.claude_docker_argv(...)` directly — a method that only
existed on DockerBottle. The foreground-handoff path (curses
endwin → subprocess.run → restore) doesn't hit it; it goes
through `bottle.exec_claude` which is on the ABC.

- Move the argv builder onto the `Bottle` ABC as
  `claude_argv(argv, *, tty=True) -> list[str]`. Both backends
  implement it; both `exec_claude` impls collapse to
  `subprocess.run(self.claude_argv(argv, tty=tty), check=False)`.

- DockerBottle: rename `claude_docker_argv` → `claude_argv`,
  body unchanged.

- SmolmachinesBottle: extract the argv-building from
  `exec_claude` into `claude_argv`; the new method returns the
  full `smolvm machine exec --name … -- runuser -u node --
  claude …` argv. The `runuser` switch lives on the
  exec-framing prefix so the dashboard's
  `_build_resume_argv_with_fallback` split-at-"claude" trick
  keeps the UID switch when wrapping the claude tail in
  `sh -c "… --continue || …"`.

- Dashboard: drop the docker-specific wording — local + helper
  arg names `docker_argv` → `claude_argv`; docstrings on
  `_build_resume_argv_with_fallback`, `_build_split_pane_argv`,
  `_build_respawn_pane_argv` now say "backend-exec argv". The
  shell-fallback wrap is unchanged; the existing logic works
  for smolmachines because `claude` is still the marker token.

Tests:
- `tests/unit/test_smolmachines_bottle.py` (new): locks down
  the smolmachines argv shape — prompt-file flag injection,
  guest-env `-e K=V` forwarding, TTY toggle, runuser-precedes-
  claude invariant.
- `test_docker_bottle.py`: TestClaudeDockerArgv →
  TestClaudeArgv; method renames follow.
- `test_dashboard_active_agents.py`: docstring follow.

615 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit was merged in pull request #81.
This commit is contained in:
2026-05-27 19:52:02 -04:00
parent 5e0130b56f
commit 3103266053
7 changed files with 179 additions and 50 deletions
+26 -19
View File
@@ -762,7 +762,7 @@ def _in_tmux() -> bool:
def _claude_runtime_args(*, resume: bool, remote_control: bool = False) -> list[str]:
"""The argv the dashboard hands to `bottle.claude_docker_argv`
"""The argv the dashboard hands to `bottle.claude_argv`
on every attach — matches what `attach_claude` builds for the
foreground handoff so both surfaces produce the same claude
invocation."""
@@ -777,28 +777,35 @@ def _claude_runtime_args(*, resume: bool, remote_control: bool = False) -> list[
def _build_resume_argv_with_fallback(
bottle, *, remote_control: bool = False,
) -> list[str]:
"""Build a docker-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.
`--continue` exits non-zero when an agent has been spun up
but never typed at — there's no transcript to resume. The
shell-level `||` wrapper makes that case start a fresh
session instead of crashing the pane. The trade-off: we
invoke `sh -c` inside the container, so the command is two
invoke `sh -c` inside the bottle, so the command is two
`claude` invocations behind a tiny shell rather than one
direct exec. Acceptable; the shell adds microseconds and
the fallback only kicks in when --continue would have
failed anyway."""
failed anyway.
Works across backends because `bottle.claude_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`."""
base_args = ["--dangerously-skip-permissions"]
if remote_control:
base_args.append("--remote-control")
base_docker = bottle.claude_docker_argv(base_args)
# Split docker-prefix from the claude-and-args tail so we
# can compose `<claude…> --continue || <claude…>` inside
base_exec = bottle.claude_argv(base_args)
# Split exec-framing prefix from the claude-and-args tail so
# we can compose `<claude…> --continue || <claude…>` inside
# `sh -c`. The `claude` token is the marker.
claude_idx = base_docker.index("claude")
prefix = base_docker[:claude_idx]
claude_cmd = " ".join(shlex.quote(a) for a in base_docker[claude_idx:])
claude_idx = base_exec.index("claude")
prefix = base_exec[:claude_idx]
claude_cmd = " ".join(shlex.quote(a) for a in base_exec[claude_idx:])
return [
*prefix,
"sh", "-c",
@@ -806,23 +813,23 @@ def _build_resume_argv_with_fallback(
]
def _build_split_pane_argv(docker_argv: list[str]) -> list[str]:
"""Pure helper: wrap a docker-exec argv with `tmux split-window
def _build_split_pane_argv(claude_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
`respawn-pane` calls."""
return [
"tmux", "split-window", "-h",
"-P", "-F", "#{pane_id}",
*docker_argv,
*claude_argv,
]
def _build_respawn_pane_argv(pane_id: str, docker_argv: list[str]) -> list[str]:
"""Pure helper: wrap a docker-exec argv with `tmux respawn-pane
def _build_respawn_pane_argv(pane_id: str, claude_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, *docker_argv]
return ["tmux", "respawn-pane", "-k", "-t", pane_id, *claude_argv]
@contextlib.contextmanager
@@ -1046,12 +1053,12 @@ def _attach_in_tmux(
# 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.
docker_argv = _build_resume_argv_with_fallback(bottle)
claude_argv = _build_resume_argv_with_fallback(bottle)
else:
docker_argv = bottle.claude_docker_argv(
claude_argv = bottle.claude_argv(
_claude_runtime_args(resume=False),
)
pane_id = _ensure_right_pane(tmux_state, docker_argv)
pane_id = _ensure_right_pane(tmux_state, claude_argv)
if pane_id is None:
# tmux failed (missing binary, server died, size error).
# One status-line failover to the curses handoff so the