docs(prd-0021): dashboard as left tmux pane, selected agent as right pane #49
@@ -28,7 +28,15 @@ class DockerBottle(Bottle):
|
|||||||
self._prompt_path = prompt_path_in_container
|
self._prompt_path = prompt_path_in_container
|
||||||
self._closed = False
|
self._closed = False
|
||||||
|
|
||||||
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int:
|
def claude_docker_argv(
|
||||||
|
self, argv: list[str], *, tty: bool = True,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return the full `docker exec` argv for running claude in
|
||||||
|
this bottle. Public so callers that want to spawn claude
|
||||||
|
somewhere other than the dashboard's foreground (e.g.,
|
||||||
|
`tmux split-window` / `tmux respawn-pane` from the dashboard
|
||||||
|
when `$TMUX` is set) can build on the same command without
|
||||||
|
duplicating the `--append-system-prompt-file` plumbing."""
|
||||||
full_argv = list(argv)
|
full_argv = list(argv)
|
||||||
if self._prompt_path:
|
if self._prompt_path:
|
||||||
full_argv.extend(["--append-system-prompt-file", self._prompt_path])
|
full_argv.extend(["--append-system-prompt-file", self._prompt_path])
|
||||||
@@ -36,7 +44,12 @@ class DockerBottle(Bottle):
|
|||||||
if tty:
|
if tty:
|
||||||
cmd.append("-it")
|
cmd.append("-it")
|
||||||
cmd.extend([self.name, "claude", *full_argv])
|
cmd.extend([self.name, "claude", *full_argv])
|
||||||
return subprocess.run(cmd, check=False).returncode
|
return cmd
|
||||||
|
|
||||||
|
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int:
|
||||||
|
return subprocess.run(
|
||||||
|
self.claude_docker_argv(argv, tty=tty), check=False,
|
||||||
|
).returncode
|
||||||
|
|
||||||
def exec(self, script: str) -> ExecResult:
|
def exec(self, script: str) -> ExecResult:
|
||||||
# Pipe via stdin to `sh -s` so the caller never has to worry
|
# Pipe via stdin to `sh -s` so the caller never has to worry
|
||||||
|
|||||||
+490
-26
@@ -12,8 +12,10 @@ chunk 3) writes routes.yaml + SIGHUPs egress; PRD 0015
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import contextlib
|
||||||
import curses
|
import curses
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -29,7 +31,7 @@ from ..backend.docker.capability_apply import (
|
|||||||
CapabilityApplyError,
|
CapabilityApplyError,
|
||||||
apply_capability_change,
|
apply_capability_change,
|
||||||
)
|
)
|
||||||
from ..backend.docker.bottle_state import read_metadata
|
from ..backend.docker.bottle_state import bottle_state_dir, read_metadata
|
||||||
from ..backend.docker.compose import (
|
from ..backend.docker.compose import (
|
||||||
compose_project_name,
|
compose_project_name,
|
||||||
list_active_slugs,
|
list_active_slugs,
|
||||||
@@ -444,6 +446,7 @@ def _picker_modal(
|
|||||||
try:
|
try:
|
||||||
key = stdscr.getch()
|
key = stdscr.getch()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
_erase_modal(stdscr)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if key == 27: # Esc
|
if key == 27: # Esc
|
||||||
@@ -451,9 +454,11 @@ def _picker_modal(
|
|||||||
query = ""
|
query = ""
|
||||||
selected = 0
|
selected = 0
|
||||||
continue
|
continue
|
||||||
|
_erase_modal(stdscr)
|
||||||
return None
|
return None
|
||||||
if key in (curses.KEY_ENTER, 10, 13):
|
if key in (curses.KEY_ENTER, 10, 13):
|
||||||
if filtered:
|
if filtered:
|
||||||
|
_erase_modal(stdscr)
|
||||||
return filtered[selected]
|
return filtered[selected]
|
||||||
continue
|
continue
|
||||||
if key in (curses.KEY_DOWN, ord("\x0e")): # KEY_DOWN, Ctrl-N
|
if key in (curses.KEY_DOWN, ord("\x0e")): # KEY_DOWN, Ctrl-N
|
||||||
@@ -577,13 +582,28 @@ def _preflight_modal(
|
|||||||
try:
|
try:
|
||||||
key = stdscr.getch()
|
key = stdscr.getch()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
_erase_modal(stdscr)
|
||||||
return False
|
return False
|
||||||
if key in (ord("y"), ord("Y")):
|
if key in (ord("y"), ord("Y")):
|
||||||
|
_erase_modal(stdscr)
|
||||||
return True
|
return True
|
||||||
if key in (ord("n"), ord("N"), 27, curses.KEY_ENTER, 10, 13):
|
if key in (ord("n"), ord("N"), 27, curses.KEY_ENTER, 10, 13):
|
||||||
|
_erase_modal(stdscr)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _erase_modal(stdscr: "curses._CursesWindow") -> None:
|
||||||
|
"""Force-redraw the dashboard's pre-modal frame so a modal
|
||||||
|
sub-window's content stops showing. Curses tracks the modal
|
||||||
|
via the newwin sub-window we created; touchwin + refresh
|
||||||
|
on stdscr repaints stdscr's last buffered frame over the
|
||||||
|
sub-window's area. Without this, the modal stays on screen
|
||||||
|
until the dashboard's main loop ticks again — which during
|
||||||
|
a long-running launch is several seconds away."""
|
||||||
|
stdscr.touchwin()
|
||||||
|
stdscr.refresh()
|
||||||
|
|
||||||
|
|
||||||
def _capture_preflight_text(plan) -> str:
|
def _capture_preflight_text(plan) -> str:
|
||||||
"""Capture `plan.print` output by temporarily redirecting
|
"""Capture `plan.print` output by temporarily redirecting
|
||||||
stderr. Plan rendering is stderr-bound (existing behavior the
|
stderr. Plan rendering is stderr-bound (existing behavior the
|
||||||
@@ -654,22 +674,28 @@ def _stop_bottle_flow(
|
|||||||
stdscr: "curses._CursesWindow",
|
stdscr: "curses._CursesWindow",
|
||||||
bottles: dict,
|
bottles: dict,
|
||||||
slug: str,
|
slug: str,
|
||||||
|
*,
|
||||||
|
tmux_state: dict | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Explicit per-bottle teardown (PRD 0020 chunk 4). Pops the
|
"""Explicit per-bottle teardown (PRD 0020 chunk 4). Pops the
|
||||||
(cm, bottle, identity) tuple from the dashboard's bottles
|
(cm, bottle, identity) tuple from the dashboard's bottles
|
||||||
map, snapshots the transcript best-effort, drives the launch
|
map, snapshots the transcript best-effort, drives the launch
|
||||||
context's __exit__ (compose down + network remove), and
|
context's __exit__ (compose down + network remove), and
|
||||||
settles the state dir. A non-owned slug is a no-op with a
|
settles the state dir. A non-owned slug is a no-op with a
|
||||||
hint pointing at `./cli.py cleanup`."""
|
hint pointing at `./cli.py cleanup`.
|
||||||
|
|
||||||
|
PRD 0021: clears `tmux_state['slug']` when the stopped
|
||||||
|
bottle was the right-pane occupant. The pane itself is
|
||||||
|
left in place — the operator presses Enter on a different
|
||||||
|
agent to repurpose it (respawn-pane replaces the broken
|
||||||
|
state)."""
|
||||||
if slug not in bottles:
|
if slug not in bottles:
|
||||||
return (
|
return (
|
||||||
f"[{slug}] not dashboard-owned — use ./cli.py cleanup"
|
f"[{slug}] not dashboard-owned — use ./cli.py cleanup"
|
||||||
)
|
)
|
||||||
cm, _bottle, identity = bottles.pop(slug)
|
cm, _bottle, identity = bottles.pop(slug)
|
||||||
# compose-down writes to stderr; drop curses so the lines
|
|
||||||
# render cleanly. Same pattern as the attach handoff.
|
def _do_teardown() -> None:
|
||||||
curses.endwin()
|
|
||||||
try:
|
|
||||||
# Best-effort snapshot before teardown so the operator
|
# Best-effort snapshot before teardown so the operator
|
||||||
# can still inspect the agent's last state via the
|
# can still inspect the agent's last state via the
|
||||||
# preserved transcript dir even after explicit stop.
|
# preserved transcript dir even after explicit stop.
|
||||||
@@ -684,27 +710,310 @@ def _stop_bottle_flow(
|
|||||||
cm.__exit__(None, None, None)
|
cm.__exit__(None, None, None)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# final buffered output stays visible after settle_state
|
||||||
|
# removes the state dir (tail-F handles file removal).
|
||||||
|
try:
|
||||||
|
with _route_op_to_right_pane(
|
||||||
|
tmux_state, slug, "teardown.log",
|
||||||
|
) as routed:
|
||||||
|
if routed:
|
||||||
|
_do_teardown()
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
|
if routed:
|
||||||
|
settle_state(identity)
|
||||||
|
if tmux_state is not None:
|
||||||
|
tmux_state["slug"] = None
|
||||||
|
return f"[{slug}] stopped"
|
||||||
|
|
||||||
|
# Non-tmux: compose-down output writes to the dashboard's
|
||||||
|
# terminal directly. Drop curses so the lines render cleanly,
|
||||||
|
# restore after.
|
||||||
|
curses.endwin()
|
||||||
|
try:
|
||||||
|
_do_teardown()
|
||||||
finally:
|
finally:
|
||||||
stdscr.refresh()
|
stdscr.refresh()
|
||||||
settle_state(identity)
|
settle_state(identity)
|
||||||
|
if tmux_state is not None and tmux_state.get("slug") == slug:
|
||||||
|
tmux_state["slug"] = None
|
||||||
return f"[{slug}] stopped"
|
return f"[{slug}] stopped"
|
||||||
|
|
||||||
|
|
||||||
def _attach_to_bottle(
|
# --- tmux split-pane integration (PRD 0021) --------------------------------
|
||||||
|
#
|
||||||
|
# When `$TMUX` is set the dashboard lays itself out as the left
|
||||||
|
# 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
|
||||||
|
# pane id + occupant slug in `tmux_state` so the same pane is
|
||||||
|
# reused across attaches.
|
||||||
|
|
||||||
|
|
||||||
|
def _in_tmux() -> bool:
|
||||||
|
"""True when the dashboard is running inside a tmux session.
|
||||||
|
Tmux sets `$TMUX` to the path of its server socket."""
|
||||||
|
return bool(os.environ.get("TMUX"))
|
||||||
|
|
||||||
|
|
||||||
|
def _claude_runtime_args(*, resume: bool, remote_control: bool = False) -> list[str]:
|
||||||
|
"""The argv the dashboard hands to `bottle.claude_docker_argv`
|
||||||
|
on every attach — matches what `attach_claude` builds for the
|
||||||
|
foreground handoff so both surfaces produce the same claude
|
||||||
|
invocation."""
|
||||||
|
args = ["--dangerously-skip-permissions"]
|
||||||
|
if remote_control:
|
||||||
|
args.append("--remote-control")
|
||||||
|
if resume:
|
||||||
|
args.append("--continue")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def _build_resume_argv_with_fallback(
|
||||||
|
bottle, *, remote_control: bool = False,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build a docker-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
|
||||||
|
`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."""
|
||||||
|
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
|
||||||
|
# `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:])
|
||||||
|
return [
|
||||||
|
*prefix,
|
||||||
|
"sh", "-c",
|
||||||
|
f"{claude_cmd} --continue || {claude_cmd}",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_split_pane_argv(docker_argv: list[str]) -> list[str]:
|
||||||
|
"""Pure helper: wrap a docker-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,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
-k -t <pane_id>`. `-k` kills the existing process in the pane
|
||||||
|
before respawning."""
|
||||||
|
return ["tmux", "respawn-pane", "-k", "-t", pane_id, *docker_argv]
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _redirect_stderr_to_file(path):
|
||||||
|
"""Redirect file descriptor 2 (stderr) to `path` for the
|
||||||
|
duration of the with-block.
|
||||||
|
|
||||||
|
Both Python sys.stderr writes AND subprocess inheritors'
|
||||||
|
stderr land in the file because fd 2 is what they share.
|
||||||
|
Used by `_new_agent_flow` (PRD 0021 follow-up) to route
|
||||||
|
`backend.launch`'s compose-up + provision output into a
|
||||||
|
log file the right tmux pane is tailing — so the dashboard
|
||||||
|
pane stays uncluttered."""
|
||||||
|
log_fd = os.open(
|
||||||
|
str(path), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o644,
|
||||||
|
)
|
||||||
|
saved_fd = os.dup(2)
|
||||||
|
try:
|
||||||
|
sys.stderr.flush()
|
||||||
|
os.dup2(log_fd, 2)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
sys.stderr.flush()
|
||||||
|
os.dup2(saved_fd, 2)
|
||||||
|
finally:
|
||||||
|
os.close(saved_fd)
|
||||||
|
os.close(log_fd)
|
||||||
|
|
||||||
|
|
||||||
|
def _tmux_split_pane_create(argv: list[str]) -> str | None:
|
||||||
|
"""Open a right pane running `argv` via `tmux split-window
|
||||||
|
-h`. Returns the new pane's id on success, None on any
|
||||||
|
failure (tmux missing, nonzero exit, empty stdout). Generic
|
||||||
|
over `argv` so both the tail-during-bringup path and the
|
||||||
|
claude-attach path can build on it."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
_build_split_pane_argv(argv),
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
pane_id = (result.stdout or "").strip()
|
||||||
|
return pane_id or None
|
||||||
|
|
||||||
|
|
||||||
|
def _tmux_respawn_pane(pane_id: str, argv: list[str]) -> bool:
|
||||||
|
"""Replace the content of `pane_id` with `argv` via `tmux
|
||||||
|
respawn-pane -k`. Returns True on success. Generic over
|
||||||
|
`argv` so the same helper handles tail→claude transitions
|
||||||
|
and slug→slug claude transitions."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
_build_respawn_pane_argv(pane_id, argv),
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _route_op_to_right_pane(
|
||||||
|
tmux_state: dict | None,
|
||||||
|
slug: str,
|
||||||
|
log_name: str,
|
||||||
|
):
|
||||||
|
"""Run an operation with its stderr routed into the right
|
||||||
|
tmux pane via `tail -F`.
|
||||||
|
|
||||||
|
Yields True when routing succeeded — the with-block runs
|
||||||
|
with fd 2 redirected to `state/<slug>/<log_name>` and the
|
||||||
|
right pane is tailing the same file. Yields False otherwise
|
||||||
|
(not in tmux, no tmux_state, or tmux failed to spawn the
|
||||||
|
pane) — the caller decides how to fall back.
|
||||||
|
|
||||||
|
Used identically by the bringup flow (log_name='bringup.log')
|
||||||
|
and the teardown flow ('teardown.log'). The fallback paths
|
||||||
|
differ between callers — bringup follows up with
|
||||||
|
`_attach_in_tmux`, teardown does the curses-endwin direct
|
||||||
|
compose-down — so the helper stops at "stderr is now routed
|
||||||
|
or it isn't" and lets callers branch from there."""
|
||||||
|
if not _in_tmux() or tmux_state is None:
|
||||||
|
yield False
|
||||||
|
return
|
||||||
|
log_path = bottle_state_dir(slug) / log_name
|
||||||
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_path.write_text("") # empty so tail starts clean
|
||||||
|
pane_id = _ensure_right_pane(tmux_state, ["tail", "-F", str(log_path)])
|
||||||
|
if pane_id is None:
|
||||||
|
yield False
|
||||||
|
return
|
||||||
|
tmux_state["slug"] = slug
|
||||||
|
with _redirect_stderr_to_file(log_path):
|
||||||
|
yield True
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
to host, so the pane shouldn't linger."""
|
||||||
|
pane_id = tmux_state.get("pane_id")
|
||||||
|
if pane_id and _tmux_pane_exists(pane_id):
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["tmux", "kill-pane", "-t", pane_id],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
tmux_state["pane_id"] = None
|
||||||
|
tmux_state["slug"] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_next_after_stop(
|
||||||
|
agents_before: list[ActiveAgent],
|
||||||
|
selected_index: int,
|
||||||
|
stopped_slug: str,
|
||||||
|
) -> tuple[int, ActiveAgent] | None:
|
||||||
|
"""After stopping `stopped_slug` from the agents list, choose
|
||||||
|
the agent that should take focus next. The agent below the
|
||||||
|
stopped row (which slides up to fill its index) is the
|
||||||
|
natural pick; if the stopped agent was last, the row above
|
||||||
|
instead. Returns (new_index, agent) or None if no agents
|
||||||
|
remain. Pure — easy to unit-test."""
|
||||||
|
new_agents = [a for a in agents_before if a.slug != stopped_slug]
|
||||||
|
if not new_agents:
|
||||||
|
return None
|
||||||
|
new_index = min(max(selected_index, 0), len(new_agents) - 1)
|
||||||
|
return new_index, new_agents[new_index]
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_right_pane(tmux_state: dict, argv: list[str]) -> str | None:
|
||||||
|
"""Run `argv` in the dashboard's right pane — respawn an
|
||||||
|
existing tracked pane if one is alive, split-window to
|
||||||
|
create one otherwise. Updates `tmux_state['pane_id']` and
|
||||||
|
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
|
||||||
|
`_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."""
|
||||||
|
pane_id = tmux_state.get("pane_id")
|
||||||
|
if pane_id and _tmux_pane_exists(pane_id):
|
||||||
|
if _tmux_respawn_pane(pane_id, argv):
|
||||||
|
return pane_id
|
||||||
|
# respawn failed — fall through to create a fresh split.
|
||||||
|
tmux_state["pane_id"] = None
|
||||||
|
new_pane_id = _tmux_split_pane_create(argv)
|
||||||
|
if new_pane_id is not None:
|
||||||
|
tmux_state["pane_id"] = new_pane_id
|
||||||
|
return new_pane_id
|
||||||
|
|
||||||
|
|
||||||
|
def _tmux_pane_exists(pane_id: str) -> bool:
|
||||||
|
"""True when `pane_id` appears in `tmux list-panes -F
|
||||||
|
'#{pane_id}'`. Used before respawn-pane to detect a pane the
|
||||||
|
operator manually closed via `C-b x`; an absent pane id means
|
||||||
|
we need to create a fresh split."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["tmux", "list-panes", "-F", "#{pane_id}"],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False
|
||||||
|
return pane_id in (result.stdout or "").splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_via_handoff(
|
||||||
stdscr: "curses._CursesWindow",
|
stdscr: "curses._CursesWindow",
|
||||||
bottle,
|
bottle,
|
||||||
slug: str,
|
slug: str,
|
||||||
|
*,
|
||||||
|
resume: bool,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Handoff: curses.endwin → attach claude → curses refresh.
|
"""Foreground handoff: curses.endwin → attach claude → curses
|
||||||
Re-entry into a running bottle from the dashboard always
|
refresh. The non-tmux path (and the failover from
|
||||||
passes `--resume` so claude picks up its prior conversation
|
`_attach_in_tmux` when tmux misbehaves)."""
|
||||||
rather than starting a fresh transcript — the first attach
|
|
||||||
happens via `_new_agent_flow` which sets up the transcript
|
|
||||||
in the first place. Returns the post-attach status-line
|
|
||||||
message."""
|
|
||||||
curses.endwin()
|
curses.endwin()
|
||||||
try:
|
try:
|
||||||
exit_code = attach_claude(bottle, remote_control=False, resume=True)
|
exit_code = attach_claude(
|
||||||
|
bottle, remote_control=False, resume=resume,
|
||||||
|
)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
stdscr.refresh()
|
stdscr.refresh()
|
||||||
raise
|
raise
|
||||||
@@ -712,17 +1021,99 @@ def _attach_to_bottle(
|
|||||||
return f"[{slug}] claude session ended (exit {exit_code})"
|
return f"[{slug}] claude session ended (exit {exit_code})"
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_in_tmux(
|
||||||
|
stdscr: "curses._CursesWindow",
|
||||||
|
bottle,
|
||||||
|
slug: str,
|
||||||
|
*,
|
||||||
|
resume: bool,
|
||||||
|
tmux_state: dict,
|
||||||
|
focus_right_pane: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Spawn / respawn the right pane with `bottle`'s claude
|
||||||
|
session. Mutates `tmux_state` ({'pane_id': str|None,
|
||||||
|
'slug': str|None}) so the main loop can track which slug is
|
||||||
|
in the right pane (used by the agents-pane indicator + the
|
||||||
|
explicit-stop hook).
|
||||||
|
|
||||||
|
`focus_right_pane=True` runs `tmux select-pane` after the
|
||||||
|
respawn so the operator is dropped into claude 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:
|
||||||
|
# `--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.
|
||||||
|
docker_argv = _build_resume_argv_with_fallback(bottle)
|
||||||
|
else:
|
||||||
|
docker_argv = bottle.claude_docker_argv(
|
||||||
|
_claude_runtime_args(resume=False),
|
||||||
|
)
|
||||||
|
pane_id = _ensure_right_pane(tmux_state, docker_argv)
|
||||||
|
if pane_id is None:
|
||||||
|
# tmux failed (missing binary, server died, size error).
|
||||||
|
# One status-line failover to the curses handoff so the
|
||||||
|
# operator still gets a session.
|
||||||
|
return _attach_via_handoff(stdscr, bottle, slug, resume=resume)
|
||||||
|
tmux_state["slug"] = slug
|
||||||
|
if focus_right_pane:
|
||||||
|
_tmux_select_pane(pane_id)
|
||||||
|
return f"[{slug}] in right pane"
|
||||||
|
|
||||||
|
|
||||||
|
def _tmux_select_pane(pane_id: str) -> None:
|
||||||
|
"""`tmux select-pane -t <id>` — moves tmux's keyboard focus
|
||||||
|
to the pane. Best-effort; failure is silent (logged only via
|
||||||
|
subprocess's stderr, which we suppress)."""
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["tmux", "select-pane", "-t", pane_id],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_to_bottle(
|
||||||
|
stdscr: "curses._CursesWindow",
|
||||||
|
bottle,
|
||||||
|
slug: str,
|
||||||
|
*,
|
||||||
|
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
|
||||||
|
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
|
||||||
|
`--continue` — first attach happens via `_new_agent_flow`."""
|
||||||
|
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.
|
||||||
|
return _attach_in_tmux(
|
||||||
|
stdscr, bottle, slug,
|
||||||
|
resume=True, tmux_state=tmux_state,
|
||||||
|
focus_right_pane=True,
|
||||||
|
)
|
||||||
|
return _attach_via_handoff(stdscr, bottle, slug, resume=True)
|
||||||
|
|
||||||
|
|
||||||
def _new_agent_flow(
|
def _new_agent_flow(
|
||||||
stdscr: "curses._CursesWindow",
|
stdscr: "curses._CursesWindow",
|
||||||
manifest: Manifest,
|
manifest: Manifest,
|
||||||
bottles: dict,
|
bottles: dict,
|
||||||
agents_now: list[ActiveAgent],
|
agents_now: list[ActiveAgent],
|
||||||
|
tmux_state: dict | None = None,
|
||||||
) -> 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), handoff to
|
(enter the context manager but DON'T close it), then route
|
||||||
claude. Returns a status-line message for the dashboard footer.
|
the first claude session into the right pane (in-tmux) or
|
||||||
The (cm, bottle) tuple lands in `bottles` keyed by slug; chunks
|
foreground handoff (otherwise). Returns a status-line message
|
||||||
3/4 use it for re-attach and explicit stop."""
|
for the dashboard footer. The (cm, bottle) tuple lands in
|
||||||
|
`bottles` keyed by slug; chunk 4 uses it for explicit stop."""
|
||||||
names = sorted(manifest.agents.keys())
|
names = sorted(manifest.agents.keys())
|
||||||
picked = _picker_modal(stdscr, names, _running_counts(bottles, agents_now))
|
picked = _picker_modal(stdscr, names, _running_counts(bottles, agents_now))
|
||||||
if picked is None:
|
if picked is None:
|
||||||
@@ -759,10 +1150,36 @@ def _new_agent_flow(
|
|||||||
return f"start of {picked!r} aborted at preflight"
|
return f"start of {picked!r} aborted at preflight"
|
||||||
|
|
||||||
backend = get_bottle_backend()
|
backend = get_bottle_backend()
|
||||||
|
|
||||||
|
# PRD 0021 follow-up: in tmux, route the launch step's
|
||||||
|
# stderr (Python info() + subprocess inheritors) into
|
||||||
|
# the right pane via tail. On success, fall through to
|
||||||
|
# `_attach_in_tmux` which respawns the same pane with
|
||||||
|
# claude. On failure, fall through to the curses-endwin
|
||||||
|
# handoff so the operator still gets a session.
|
||||||
|
try:
|
||||||
|
with _route_op_to_right_pane(
|
||||||
|
tmux_state, plan.slug, "bringup.log",
|
||||||
|
) as routed:
|
||||||
|
if routed:
|
||||||
|
cm = backend.launch(plan)
|
||||||
|
bottle = cm.__enter__()
|
||||||
|
except BaseException:
|
||||||
|
settle_state(identity)
|
||||||
|
raise
|
||||||
|
if routed:
|
||||||
|
bottles[plan.slug] = (cm, bottle, identity)
|
||||||
|
# Move tmux focus to the right pane — the operator
|
||||||
|
# just spun this agent up, they want to type at it.
|
||||||
|
return _attach_in_tmux(
|
||||||
|
stdscr, bottle, plan.slug,
|
||||||
|
resume=False, tmux_state=tmux_state,
|
||||||
|
focus_right_pane=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Launch step writes to stderr (image build, network create,
|
# Launch step writes to stderr (image build, network create,
|
||||||
# compose up). Get out of curses' way for the duration so
|
# compose up). Get out of curses' way for the duration so
|
||||||
# the lines render cleanly. The handoff stays endwin'd until
|
# the lines render cleanly; restore curses immediately after.
|
||||||
# claude exits, then we refresh.
|
|
||||||
curses.endwin()
|
curses.endwin()
|
||||||
try:
|
try:
|
||||||
cm = backend.launch(plan)
|
cm = backend.launch(plan)
|
||||||
@@ -773,6 +1190,8 @@ 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,
|
||||||
|
# then we restore curses.
|
||||||
try:
|
try:
|
||||||
exit_code = attach_claude(bottle, remote_control=False)
|
exit_code = attach_claude(bottle, remote_control=False)
|
||||||
capture_session_state(identity, exit_code)
|
capture_session_state(identity, exit_code)
|
||||||
@@ -907,14 +1326,23 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
|
|||||||
first_seen: dict[str, float] = {}
|
first_seen: dict[str, float] = {}
|
||||||
selected = 0
|
selected = 0
|
||||||
selected_agent = 0
|
selected_agent = 0
|
||||||
focus = PANE_PROPOSALS
|
# Default focus on agents — the dashboard is now primarily an
|
||||||
|
# agent-management surface (PRD 0020 + 0021). The operator can
|
||||||
|
# Tab to proposals when something queues; until then, j/k go
|
||||||
|
# through the agents list.
|
||||||
|
focus = PANE_AGENTS
|
||||||
status_line = ""
|
status_line = ""
|
||||||
# PRD 0020: bottles spun up from inside this dashboard session.
|
# PRD 0020: bottles spun up from inside this dashboard session.
|
||||||
# Each entry: slug -> (context-manager, Bottle handle, identity).
|
# Each entry: slug -> (context-manager, Bottle handle, identity).
|
||||||
# We hold the context manager so chunk 4's `x` can call __exit__
|
# We hold the context manager so chunk 4's `x` can call __exit__
|
||||||
# on it; chunk 5 quit-cleanup intentionally does NOT iterate this
|
# on it; quit (`q`) intentionally does NOT iterate this dict
|
||||||
# dict (the user wants quit to leave bottles running).
|
# (the user wants quit to leave bottles running).
|
||||||
bottles: dict[str, tuple] = {}
|
bottles: dict[str, tuple] = {}
|
||||||
|
# PRD 0021: tmux split-pane state. Empty when not in tmux or
|
||||||
|
# before the first attach. Mutated by `_attach_in_tmux` /
|
||||||
|
# `_stop_bottle_flow` to track which bottle's session is in
|
||||||
|
# the right pane right now.
|
||||||
|
tmux_state: dict = {"pane_id": None, "slug": None}
|
||||||
# Manifest is loaded lazily on first `n` so the dashboard
|
# Manifest is loaded lazily on first `n` so the dashboard
|
||||||
# doesn't fail to start in a directory with no manifest (e.g.,
|
# doesn't fail to start in a directory with no manifest (e.g.,
|
||||||
# when the operator is purely watching pre-existing bottles).
|
# when the operator is purely watching pre-existing bottles).
|
||||||
@@ -945,6 +1373,7 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
|
|||||||
agents=agents,
|
agents=agents,
|
||||||
selected_agent=selected_agent,
|
selected_agent=selected_agent,
|
||||||
focus=focus,
|
focus=focus,
|
||||||
|
right_pane_slug=tmux_state.get("slug"),
|
||||||
first_seen=first_seen, now=now, green_attr=green_attr,
|
first_seen=first_seen, now=now, green_attr=green_attr,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -978,7 +1407,9 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
status_line = f"manifest load failed: {e}"
|
status_line = f"manifest load failed: {e}"
|
||||||
continue
|
continue
|
||||||
status_line = _new_agent_flow(stdscr, manifest, bottles, agents)
|
status_line = _new_agent_flow(
|
||||||
|
stdscr, manifest, bottles, agents, tmux_state=tmux_state,
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
if key in (ord("e"), ord("p")):
|
if key in (ord("e"), ord("p")):
|
||||||
# PRD 0019 chunk 4: agent-scoped edits. Only fire when
|
# PRD 0019 chunk 4: agent-scoped edits. Only fire when
|
||||||
@@ -1010,7 +1441,9 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
|
|||||||
else:
|
else:
|
||||||
manifest = manifest_cache[0] # may be None; that's ok
|
manifest = manifest_cache[0] # may be None; that's ok
|
||||||
bottle, _hint = _bottle_for_slug(target.slug, bottles, manifest)
|
bottle, _hint = _bottle_for_slug(target.slug, bottles, manifest)
|
||||||
status_line = _attach_to_bottle(stdscr, bottle, target.slug)
|
status_line = _attach_to_bottle(
|
||||||
|
stdscr, bottle, target.slug, tmux_state=tmux_state,
|
||||||
|
)
|
||||||
elif key == ord("x"):
|
elif key == ord("x"):
|
||||||
target = _selected_agent(focus, agents, selected_agent)
|
target = _selected_agent(focus, agents, selected_agent)
|
||||||
if target is None:
|
if target is None:
|
||||||
@@ -1018,7 +1451,30 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
|
|||||||
else:
|
else:
|
||||||
status_line = _stop_bottle_flow(
|
status_line = _stop_bottle_flow(
|
||||||
stdscr, bottles, target.slug,
|
stdscr, bottles, target.slug,
|
||||||
|
tmux_state=tmux_state,
|
||||||
)
|
)
|
||||||
|
# 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
|
||||||
|
# nothing's left, close the right pane.
|
||||||
|
pick = _pick_next_after_stop(
|
||||||
|
agents, selected_agent, target.slug,
|
||||||
|
)
|
||||||
|
if pick is None:
|
||||||
|
_tmux_close_right_pane(tmux_state)
|
||||||
|
else:
|
||||||
|
new_index, next_agent = pick
|
||||||
|
selected_agent = new_index
|
||||||
|
if _in_tmux():
|
||||||
|
manifest = manifest_cache[0]
|
||||||
|
bottle, _hint = _bottle_for_slug(
|
||||||
|
next_agent.slug, bottles, manifest,
|
||||||
|
)
|
||||||
|
_attach_in_tmux(
|
||||||
|
stdscr, bottle, next_agent.slug,
|
||||||
|
resume=True, tmux_state=tmux_state,
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not pending:
|
if not pending:
|
||||||
@@ -1065,6 +1521,7 @@ def _render(
|
|||||||
agents: list[ActiveAgent] | None = None,
|
agents: list[ActiveAgent] | None = None,
|
||||||
selected_agent: int = 0,
|
selected_agent: int = 0,
|
||||||
focus: str = PANE_PROPOSALS,
|
focus: str = PANE_PROPOSALS,
|
||||||
|
right_pane_slug: str | None = None,
|
||||||
first_seen: dict[str, float] | None = None,
|
first_seen: dict[str, float] | None = None,
|
||||||
now: float | None = None,
|
now: float | None = None,
|
||||||
green_attr: int = 0,
|
green_attr: int = 0,
|
||||||
@@ -1146,11 +1603,18 @@ def _render(
|
|||||||
if row >= h - 3:
|
if row >= h - 3:
|
||||||
break
|
break
|
||||||
line = _format_agent_row(a, w - 1)
|
line = _format_agent_row(a, w - 1)
|
||||||
|
in_right_pane = (a.slug == right_pane_slug)
|
||||||
if agents_focused and i == selected_agent:
|
if agents_focused and i == selected_agent:
|
||||||
# Replace the leading " " cursor with "> " and
|
# Replace the leading " " cursor with "> " and
|
||||||
# highlight the whole row.
|
# highlight the whole row.
|
||||||
line = "> " + line[2:]
|
line = "> " + line[2:]
|
||||||
attr = curses.A_REVERSE
|
attr = curses.A_REVERSE
|
||||||
|
elif in_right_pane:
|
||||||
|
# PRD 0021: `*` marks the agent currently in the
|
||||||
|
# right tmux pane so the operator can see at a
|
||||||
|
# glance which session is visible to their right.
|
||||||
|
line = "* " + line[2:]
|
||||||
|
attr = curses.A_BOLD
|
||||||
else:
|
else:
|
||||||
attr = curses.A_NORMAL
|
attr = curses.A_NORMAL
|
||||||
stdscr.addnstr(row, 0, line, w - 1, attr)
|
stdscr.addnstr(row, 0, line, w - 1, attr)
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
# PRD 0021: Dashboard as left tmux pane, selected agent as right pane
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **Author:** didericis
|
||||||
|
- **Created:** 2026-05-26
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
When the dashboard runs inside tmux, lay it out as the **left
|
||||||
|
pane** of a two-pane window with the **selected agent's claude
|
||||||
|
session in the right pane**. Pressing Enter on an agent in the
|
||||||
|
agents pane swaps the right pane to that agent's session
|
||||||
|
(respawning with `claude --continue` so the conversation picks
|
||||||
|
up where it left off). Pressing `n` to start a new agent spawns
|
||||||
|
it directly into the right pane. The dashboard is the
|
||||||
|
operator's persistent left-hand surface; claude is always
|
||||||
|
visible to its right.
|
||||||
|
|
||||||
|
Outside tmux, fall back to today's handoff (`curses.endwin →
|
||||||
|
foreground claude → stdscr.refresh`). The split-pane UX is
|
||||||
|
opt-in by environment — running the dashboard inside a tmux
|
||||||
|
session enables it, no flag required.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The dashboard's "Enter to attach" (PRD 0020 chunk 3) and its
|
||||||
|
new-agent `n` flow both take over the terminal for the
|
||||||
|
duration of the claude session via `curses.endwin`. While
|
||||||
|
claude has the screen, the dashboard's proposal queue and
|
||||||
|
agents pane are invisible. Any tool call that lands during a
|
||||||
|
claude session is silent until the operator exits back to the
|
||||||
|
dashboard.
|
||||||
|
|
||||||
|
The split-pane shape this PRD proposes keeps both surfaces
|
||||||
|
visible: dashboard always on the left, the currently-selected
|
||||||
|
agent on the right. The dashboard's existing selection model
|
||||||
|
(PRD 0019) drives which agent occupies the right pane —
|
||||||
|
moving the cursor in the agents pane is a no-op; pressing
|
||||||
|
Enter swaps the right pane to that agent's session. One
|
||||||
|
window, two panes, no terminal handoff.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
1. When the operator runs `./cli.py dashboard` from inside a
|
||||||
|
tmux session (`$TMUX` set), the dashboard establishes a
|
||||||
|
two-pane layout: dashboard in the left pane, an initially-
|
||||||
|
empty right pane reserved for claude sessions.
|
||||||
|
2. Pressing Enter on a focused agent row spawns / respawns the
|
||||||
|
right pane with `docker exec -it claude-bottle-<slug> claude
|
||||||
|
--continue --dangerously-skip-permissions`. The right pane's
|
||||||
|
prior content (if any) is replaced.
|
||||||
|
3. Pressing `n` to start a new agent (the existing chunk-2 flow
|
||||||
|
from PRD 0020) directs the spawned claude session into the
|
||||||
|
right pane instead of taking over the terminal.
|
||||||
|
4. Pressing `x` to stop a dashboard-owned bottle: if that
|
||||||
|
bottle was the right-pane occupant, the right pane is
|
||||||
|
cleared (or shows a brief "stopped" message).
|
||||||
|
5. Closing the right pane manually via tmux (e.g., `C-b x`)
|
||||||
|
leaves the dashboard intact; the next Enter creates a fresh
|
||||||
|
right pane.
|
||||||
|
6. Outside tmux (`$TMUX` unset), the dashboard's Enter / `n`
|
||||||
|
behavior falls back to today's handoff. No tmux dependency
|
||||||
|
for non-tmux users.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- **The embedded-emulator option** from
|
||||||
|
`docs/research/claude-code-pane-in-dashboard.md`. This PRD
|
||||||
|
stays on the multiplexer-delegated shape. pyte-driven
|
||||||
|
in-curses rendering is a separate, much larger decision.
|
||||||
|
- **Multi-pane / grid layout.** No "show 4 agents at once."
|
||||||
|
One left, one right. Picking that as a constraint
|
||||||
|
dramatically simplifies the state machine.
|
||||||
|
- **Persistence across dashboard exits.** The tmux session
|
||||||
|
state (which agent is in the right pane) doesn't survive a
|
||||||
|
dashboard restart. The bottles themselves persist (PRD 0020's
|
||||||
|
`q`-doesn't-tear-down); reattaching is one Enter away after a
|
||||||
|
fresh launch.
|
||||||
|
- **Cross-tmux-session orchestration.** The dashboard owns
|
||||||
|
panes in its own session. Other tmux sessions on the same
|
||||||
|
host are untouched.
|
||||||
|
- **A "right pane is detached" mode** where claude runs in a
|
||||||
|
pane that's not visible until expanded. Out of v1.
|
||||||
|
- **Auto-execing into a fresh tmux session** when launched
|
||||||
|
outside one. See open question #3.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
- A `_in_tmux()` helper that returns True when `$TMUX` is set,
|
||||||
|
with `FileNotFoundError`-safe `subprocess.run` calls
|
||||||
|
around every tmux invocation so a missing tmux binary
|
||||||
|
cleanly falls back to handoff mode.
|
||||||
|
- Two new state fields on the main loop: `right_pane_id: str |
|
||||||
|
None` (the tmux pane id we created — None = "no right pane
|
||||||
|
yet, next attach must split") and `right_pane_slug: str |
|
||||||
|
None` (which bottle is currently in the right pane, for
|
||||||
|
the stop + indicator hooks).
|
||||||
|
- A `_tmux_split_pane_create(bottle, *, resume) -> str | None`
|
||||||
|
helper that runs `tmux split-window -h -P -F '#{pane_id}' …`
|
||||||
|
and returns the new pane id, or None on failure.
|
||||||
|
- A `_tmux_respawn_pane(pane_id, bottle, *, resume) -> bool`
|
||||||
|
helper that runs `tmux respawn-pane -k -t <id> …` and
|
||||||
|
returns success.
|
||||||
|
- A `_tmux_pane_exists(pane_id) -> bool` check via
|
||||||
|
`tmux list-panes -F '#{pane_id}'` so a manually-closed right
|
||||||
|
pane gracefully falls back to a new split.
|
||||||
|
- Modified `_attach_to_bottle` and `_new_agent_flow` (PRD
|
||||||
|
0020) to dispatch through the tmux helpers when `$TMUX` is
|
||||||
|
set, falling back to the existing handoff otherwise.
|
||||||
|
- `_stop_bottle_flow` updated to clear `right_pane_slug` when
|
||||||
|
the stopped bottle matches.
|
||||||
|
- An indicator in the agents pane (e.g., `*` prefix or
|
||||||
|
alternate attr) marking the row whose bottle is currently
|
||||||
|
in the right pane.
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- Choosing the split direction (-h vs -v) as a runtime knob.
|
||||||
|
v1 is `-h` (left / right). See open question #1.
|
||||||
|
- Sizing the split (e.g., 40/60 vs 50/50). v1 takes the tmux
|
||||||
|
default; sizing knob deferred.
|
||||||
|
- Persisting the right-pane occupant slug to disk so a fresh
|
||||||
|
dashboard restart can restore it.
|
||||||
|
|
||||||
|
## Proposed design
|
||||||
|
|
||||||
|
### State machine
|
||||||
|
|
||||||
|
Two new fields on the main loop:
|
||||||
|
|
||||||
|
```python
|
||||||
|
right_pane_id: str | None = None # tmux pane id, or None
|
||||||
|
right_pane_slug: str | None = None # current occupant, or None
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dispatch
|
||||||
|
|
||||||
|
`_attach_to_bottle` and `_new_agent_flow` (from PRD 0020) gain
|
||||||
|
a tmux branch:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _attach_in_tmux(bottle, slug, *, resume) -> str:
|
||||||
|
nonlocal right_pane_id, right_pane_slug
|
||||||
|
|
||||||
|
# If we have a remembered pane and it still exists, respawn it.
|
||||||
|
if right_pane_id and _tmux_pane_exists(right_pane_id):
|
||||||
|
if _tmux_respawn_pane(right_pane_id, bottle, resume=resume):
|
||||||
|
right_pane_slug = slug
|
||||||
|
return f"[{slug}] in right pane"
|
||||||
|
right_pane_id = None # respawn failed; fall through
|
||||||
|
|
||||||
|
# Otherwise create a fresh split.
|
||||||
|
pane_id = _tmux_split_pane_create(bottle, resume=resume)
|
||||||
|
if pane_id is None:
|
||||||
|
# tmux failed; fall back to handoff.
|
||||||
|
return _attach_via_handoff(stdscr, bottle, slug, resume=resume)
|
||||||
|
right_pane_id = pane_id
|
||||||
|
right_pane_slug = slug
|
||||||
|
return f"[{slug}] in right pane"
|
||||||
|
```
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
### Pane creation
|
||||||
|
|
||||||
|
`tmux split-window -h -P -F '#{pane_id}'` opens a right pane
|
||||||
|
and prints its pane id (e.g., `%12`). The `-h` is horizontal
|
||||||
|
split (left/right); the `-P -F` combo emits the new pane's
|
||||||
|
identifier for tracking.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _tmux_split_pane_create(bottle, *, resume) -> str | None:
|
||||||
|
docker_argv = bottle.claude_docker_argv(_claude_args(resume=resume))
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["tmux", "split-window", "-h",
|
||||||
|
"-P", "-F", "#{pane_id}",
|
||||||
|
*docker_argv],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
return result.stdout.strip() or None
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pane respawn
|
||||||
|
|
||||||
|
`tmux respawn-pane -k -t <id> …` replaces the pane's content
|
||||||
|
without re-splitting. `-k` kills the existing process first
|
||||||
|
(claude session ends; the underlying bottle is untouched).
|
||||||
|
`--continue` on the new claude invocation picks up the prior
|
||||||
|
conversation.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _tmux_respawn_pane(pane_id, bottle, *, resume) -> bool:
|
||||||
|
docker_argv = bottle.claude_docker_argv(_claude_args(resume=resume))
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["tmux", "respawn-pane", "-k", "-t", pane_id, *docker_argv],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
return result.returncode == 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pane-existence check
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _tmux_pane_exists(pane_id) -> bool:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["tmux", "list-panes", "-F", "#{pane_id}"],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False
|
||||||
|
return pane_id in result.stdout.splitlines()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bottle.claude_docker_argv
|
||||||
|
|
||||||
|
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
|
||||||
|
pure `claude_docker_argv(args, *, tty)` that returns the argv
|
||||||
|
and a thin `exec_claude` that calls `subprocess.run` on it.
|
||||||
|
Both the tmux path AND the existing foreground path use the
|
||||||
|
same argv builder.
|
||||||
|
|
||||||
|
### Stop integration
|
||||||
|
|
||||||
|
`_stop_bottle_flow` (from PRD 0020 chunk 4) gets a small
|
||||||
|
addition: after the bottle is torn down, if `right_pane_slug
|
||||||
|
== stopped_slug`, clear it. The tmux pane itself stays open
|
||||||
|
with claude's "container not found" error showing. The next
|
||||||
|
Enter on a different agent will respawn it.
|
||||||
|
|
||||||
|
### Indicator in agents pane
|
||||||
|
|
||||||
|
When `right_pane_slug` matches an agent row's slug, render
|
||||||
|
that row with a `*` prefix (and / or reverse-video, if the
|
||||||
|
focus indicator doesn't already conflict). Gives the operator
|
||||||
|
a clear visual link between dashboard selection and right-
|
||||||
|
pane content.
|
||||||
|
|
||||||
|
### Fallback when tmux fails
|
||||||
|
|
||||||
|
Three failure modes worth handling:
|
||||||
|
|
||||||
|
1. **`$TMUX` set but tmux binary not on PATH.** `subprocess.run`
|
||||||
|
raises `FileNotFoundError` — treat as not-in-tmux, fall back
|
||||||
|
to handoff. Should be rare but happens in nested
|
||||||
|
containers.
|
||||||
|
2. **`tmux split-window` / `respawn-pane` returns non-zero.**
|
||||||
|
Surface a status-line error and fall back to handoff for
|
||||||
|
that one keypress. The dashboard stays usable.
|
||||||
|
3. **Right pane manually closed.** Detected at next attach via
|
||||||
|
`_tmux_pane_exists`; create a fresh split.
|
||||||
|
|
||||||
|
## Implementation chunks
|
||||||
|
|
||||||
|
Sized small.
|
||||||
|
|
||||||
|
1. **`claude_docker_argv` refactor.** Pure-ish split of
|
||||||
|
`DockerBottle.exec_claude` 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`,
|
||||||
|
`_tmux_split_pane_create`, `_tmux_respawn_pane`,
|
||||||
|
`_tmux_pane_exists`, plus the two new state fields on the
|
||||||
|
main loop. Wire `_attach_to_bottle` to dispatch through
|
||||||
|
tmux when in tmux, fall back to handoff otherwise.
|
||||||
|
3. **New-agent flow integration.** `_new_agent_flow` (PRD
|
||||||
|
0020 chunk 2) gains the same tmux dispatch — first attach
|
||||||
|
on a freshly-started agent goes into the right pane.
|
||||||
|
4. **Stop integration + right-pane indicator.**
|
||||||
|
`_stop_bottle_flow` clears `right_pane_slug` when matched;
|
||||||
|
`_format_agent_row` (PRD 0019 chunk 2) gets a `*` prefix
|
||||||
|
when its slug matches `right_pane_slug`.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. **Split direction: horizontal (`-h`) vs vertical (`-v`).**
|
||||||
|
The PRD's "left / right" framing implies `-h`. On a
|
||||||
|
short-and-wide terminal a vertical (top / bottom) split
|
||||||
|
might give the dashboard more vertical real estate. Pick
|
||||||
|
`-h` for v1; revisit if the dashboard's agents-pane row
|
||||||
|
count proves cramped.
|
||||||
|
|
||||||
|
2. **What happens to claude when the right pane is killed?**
|
||||||
|
`tmux kill-pane` SIGHUPs the process inside; claude
|
||||||
|
shuts down. The bottle stays up (compose project is
|
||||||
|
independent of the pane). Next attach starts a new claude
|
||||||
|
process with `--continue` and picks up the conversation —
|
||||||
|
so the operator loses nothing except the pane's screen
|
||||||
|
buffer. Document this; no special handling needed.
|
||||||
|
|
||||||
|
3. **Dashboard launched OUTSIDE tmux but tmux is installed.**
|
||||||
|
Should the dashboard auto-exec itself inside a fresh tmux
|
||||||
|
session to get the split-pane experience? Convenient but
|
||||||
|
surprising (`./cli.py dashboard` shouldn't silently
|
||||||
|
change what session you're in). v1 leaves this off —
|
||||||
|
operators who want split-pane mode start tmux themselves
|
||||||
|
and then run the dashboard.
|
||||||
|
|
||||||
|
4. **Pane size after split.** tmux's default for `split-window
|
||||||
|
-h` is 50/50. The dashboard's column widths are designed
|
||||||
|
for ~80 cols; if the terminal is 160 cols total, 50/50 is
|
||||||
|
fine. On narrower terminals (~120) the dashboard might
|
||||||
|
want ~50 cols and claude gets the rest. Add `-p <pct>` or
|
||||||
|
`-l <cols>` flag? Open question; v1 uses tmux's default.
|
||||||
|
|
||||||
|
5. **Cursor highlighting in the agents pane to indicate "this
|
||||||
|
one's in the right pane right now."** Resolved YES in
|
||||||
|
scope — `*` prefix on the matching row. Open: should it
|
||||||
|
also use color, and which color doesn't conflict with the
|
||||||
|
PRD-0019 focus indicator?
|
||||||
|
|
||||||
|
6. **Concurrent dashboards in different tmux windows.**
|
||||||
|
Multiple `./cli.py dashboard` invocations in different
|
||||||
|
tmux windows would each create their own right pane —
|
||||||
|
probably fine, each has its own state, but worth
|
||||||
|
verifying that `tmux list-panes` is scoped to the right
|
||||||
|
window context.
|
||||||
|
|
||||||
|
7. **`$TMUX_PANE` vs `$TMUX`.** Tmux sets both. We only check
|
||||||
|
`$TMUX` (presence of the server socket); `$TMUX_PANE` is
|
||||||
|
the dashboard's own pane id, which we could capture to be
|
||||||
|
more precise about `tmux split-window -t <pane>` (split
|
||||||
|
relative to the dashboard's pane specifically). Probably
|
||||||
|
not needed since `split-window` defaults to the current
|
||||||
|
active pane, but worth verifying.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- PRD 0019 — agents pane + selection model (the source of
|
||||||
|
truth for which agent is "selected" right now)
|
||||||
|
- PRD 0020 — start + attach from the dashboard
|
||||||
|
(`_attach_to_bottle`, `_new_agent_flow`, `_stop_bottle_flow`
|
||||||
|
— the three hooks this PRD changes)
|
||||||
|
- `docs/research/claude-code-pane-in-dashboard.md` — the
|
||||||
|
three-option survey of how to surface claude inside the
|
||||||
|
dashboard; this PRD picks option 3 with a tighter
|
||||||
|
split-pane variant
|
||||||
@@ -379,6 +379,160 @@ class TestBottleForSlug(unittest.TestCase):
|
|||||||
self.assertEqual("", hint)
|
self.assertEqual("", hint)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPickNextAfterStop(unittest.TestCase):
|
||||||
|
"""After `x` stops a bottle, the dashboard slides focus to
|
||||||
|
the next agent — the one filling the stopped row, or the
|
||||||
|
new last row if the stopped was last. Pure helper, easy
|
||||||
|
to unit-test."""
|
||||||
|
|
||||||
|
def _agent(self, slug: str) -> dashboard.ActiveAgent:
|
||||||
|
return dashboard.ActiveAgent(
|
||||||
|
slug=slug, agent_name=slug, started_at="", services=(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_list_returns_none(self):
|
||||||
|
self.assertIsNone(
|
||||||
|
dashboard._pick_next_after_stop([], 0, "anything"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_only_agent_being_stopped_returns_none(self):
|
||||||
|
# Stopping the last agent → nothing to focus.
|
||||||
|
agents = [self._agent("only")]
|
||||||
|
self.assertIsNone(
|
||||||
|
dashboard._pick_next_after_stop(agents, 0, "only"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_middle_row_slides_up_to_same_index(self):
|
||||||
|
agents = [self._agent("a"), self._agent("b"), self._agent("c")]
|
||||||
|
# Cursor was on "b" at index 1; stopping "b" → "c" now sits
|
||||||
|
# at index 1 and takes focus.
|
||||||
|
out = dashboard._pick_next_after_stop(agents, 1, "b")
|
||||||
|
self.assertEqual((1, self._agent("c")), out)
|
||||||
|
|
||||||
|
def test_last_row_wraps_to_new_last(self):
|
||||||
|
agents = [self._agent("a"), self._agent("b"), self._agent("c")]
|
||||||
|
# Cursor on "c" at index 2; stopping "c" leaves a 2-agent
|
||||||
|
# list — index 2 is out of bounds, fall back to new last (1).
|
||||||
|
out = dashboard._pick_next_after_stop(agents, 2, "c")
|
||||||
|
self.assertEqual((1, self._agent("b")), out)
|
||||||
|
|
||||||
|
def test_first_row(self):
|
||||||
|
agents = [self._agent("a"), self._agent("b")]
|
||||||
|
out = dashboard._pick_next_after_stop(agents, 0, "a")
|
||||||
|
self.assertEqual((0, self._agent("b")), out)
|
||||||
|
|
||||||
|
def test_clamps_negative_selection(self):
|
||||||
|
# Defensive: a stale negative index doesn't crash.
|
||||||
|
agents = [self._agent("a"), self._agent("b")]
|
||||||
|
out = dashboard._pick_next_after_stop(agents, -1, "a")
|
||||||
|
self.assertEqual((0, self._agent("b")), out)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTmuxPaneArgvBuilders(unittest.TestCase):
|
||||||
|
"""Pure argv builders for the tmux split-pane integration
|
||||||
|
(PRD 0021 chunk 2). The subprocess invocation itself is
|
||||||
|
environment-dependent; here we lock the wrapping shape so
|
||||||
|
a regression surfaces in CI without needing a real tmux."""
|
||||||
|
|
||||||
|
DOCKER_ARGV = [
|
||||||
|
"docker", "exec", "-it",
|
||||||
|
"claude-bottle-dev-abc",
|
||||||
|
"claude", "--dangerously-skip-permissions", "--continue",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_split_pane_argv_horizontal_with_pane_id_capture(self):
|
||||||
|
argv = dashboard._build_split_pane_argv(self.DOCKER_ARGV)
|
||||||
|
self.assertEqual(
|
||||||
|
["tmux", "split-window", "-h",
|
||||||
|
"-P", "-F", "#{pane_id}",
|
||||||
|
*self.DOCKER_ARGV],
|
||||||
|
argv,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_respawn_pane_argv_kills_existing_process(self):
|
||||||
|
argv = dashboard._build_respawn_pane_argv("%12", self.DOCKER_ARGV)
|
||||||
|
self.assertEqual(
|
||||||
|
["tmux", "respawn-pane", "-k", "-t", "%12", *self.DOCKER_ARGV],
|
||||||
|
argv,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_respawn_pane_argv_threads_pane_id_unmodified(self):
|
||||||
|
# Pane ids contain `%`; make sure we pass them straight
|
||||||
|
# through to `-t` without quoting or substitution surprises.
|
||||||
|
argv = dashboard._build_respawn_pane_argv("%abc.123", ["sh"])
|
||||||
|
self.assertIn("%abc.123", argv)
|
||||||
|
|
||||||
|
|
||||||
|
class TestResumeArgvWithFallback(unittest.TestCase):
|
||||||
|
"""The `claude --continue || claude` shell fallback for the
|
||||||
|
tmux re-attach path. Without it, an agent that's been spun
|
||||||
|
up but never typed at crashes the pane on Enter because
|
||||||
|
--continue has no session to resume."""
|
||||||
|
|
||||||
|
def _bottle(self, prompt_path: str | None = None):
|
||||||
|
from claude_bottle.backend.docker.bottle import DockerBottle
|
||||||
|
return DockerBottle(
|
||||||
|
container="claude-bottle-dev-abc",
|
||||||
|
teardown=lambda: None,
|
||||||
|
prompt_path_in_container=prompt_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_wraps_in_sh_c_with_or_fallback(self):
|
||||||
|
argv = dashboard._build_resume_argv_with_fallback(self._bottle())
|
||||||
|
# Must end with `sh -c '<cmd> --continue || <cmd>'`.
|
||||||
|
self.assertEqual(
|
||||||
|
["docker", "exec", "-it", "claude-bottle-dev-abc", "sh", "-c"],
|
||||||
|
argv[:6],
|
||||||
|
)
|
||||||
|
inner = argv[6]
|
||||||
|
self.assertIn("--continue", inner)
|
||||||
|
self.assertIn("||", inner)
|
||||||
|
# Both branches mention claude.
|
||||||
|
self.assertEqual(2, inner.count("claude"))
|
||||||
|
|
||||||
|
def test_inner_args_quoted_safely(self):
|
||||||
|
# Paths with spaces would break naive concatenation.
|
||||||
|
bottle = self._bottle("/home/with space/.prompt")
|
||||||
|
argv = dashboard._build_resume_argv_with_fallback(bottle)
|
||||||
|
inner = argv[-1]
|
||||||
|
# shlex.quote should single-quote any token with a space.
|
||||||
|
self.assertIn("'/home/with space/.prompt'", inner)
|
||||||
|
|
||||||
|
def test_includes_skip_permissions(self):
|
||||||
|
argv = dashboard._build_resume_argv_with_fallback(self._bottle())
|
||||||
|
self.assertIn("--dangerously-skip-permissions", argv[-1])
|
||||||
|
|
||||||
|
def test_includes_prompt_file_flag_when_set(self):
|
||||||
|
bottle = self._bottle("/home/node/.claude-bottle-prompt.txt")
|
||||||
|
argv = dashboard._build_resume_argv_with_fallback(bottle)
|
||||||
|
self.assertIn("--append-system-prompt-file", argv[-1])
|
||||||
|
self.assertIn("/home/node/.claude-bottle-prompt.txt", argv[-1])
|
||||||
|
|
||||||
|
|
||||||
|
class TestClaudeRuntimeArgs(unittest.TestCase):
|
||||||
|
"""The argv passed to `bottle.claude_docker_argv` on each
|
||||||
|
attach. Locked here so the tmux + foreground paths build
|
||||||
|
identical claude invocations."""
|
||||||
|
|
||||||
|
def test_default_skip_permissions_only(self):
|
||||||
|
self.assertEqual(
|
||||||
|
["--dangerously-skip-permissions"],
|
||||||
|
dashboard._claude_runtime_args(resume=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resume_appends_continue(self):
|
||||||
|
self.assertEqual(
|
||||||
|
["--dangerously-skip-permissions", "--continue"],
|
||||||
|
dashboard._claude_runtime_args(resume=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_remote_control(self):
|
||||||
|
args = dashboard._claude_runtime_args(
|
||||||
|
resume=False, remote_control=True,
|
||||||
|
)
|
||||||
|
self.assertIn("--remote-control", args)
|
||||||
|
|
||||||
|
|
||||||
class TestStopBottleFlow(unittest.TestCase):
|
class TestStopBottleFlow(unittest.TestCase):
|
||||||
"""Explicit per-bottle stop (PRD 0020 chunk 4). The non-owned
|
"""Explicit per-bottle stop (PRD 0020 chunk 4). The non-owned
|
||||||
path is the one safe to test without curses + docker — the
|
path is the one safe to test without curses + docker — the
|
||||||
@@ -396,6 +550,20 @@ class TestStopBottleFlow(unittest.TestCase):
|
|||||||
self.assertIn("not dashboard-owned", msg)
|
self.assertIn("not dashboard-owned", msg)
|
||||||
self.assertIn("./cli.py cleanup", msg)
|
self.assertIn("./cli.py cleanup", msg)
|
||||||
|
|
||||||
|
def test_non_owned_does_not_touch_tmux_state(self):
|
||||||
|
# PRD 0021: a stop on an unknown slug should never clear
|
||||||
|
# the right-pane occupant tracking, even if the slugs
|
||||||
|
# happen to match (defensive — non-owned can't be in the
|
||||||
|
# right pane via the dashboard's normal flow anyway).
|
||||||
|
tmux_state = {"pane_id": "%5", "slug": "live-bbb"}
|
||||||
|
dashboard._stop_bottle_flow(
|
||||||
|
stdscr=None, # type: ignore[arg-type]
|
||||||
|
bottles={},
|
||||||
|
slug="ghost-zzz",
|
||||||
|
tmux_state=tmux_state,
|
||||||
|
)
|
||||||
|
self.assertEqual({"pane_id": "%5", "slug": "live-bbb"}, tmux_state)
|
||||||
|
|
||||||
|
|
||||||
class TestOperatorEditFlowGuards(_FakeHomeMixin, unittest.TestCase):
|
class TestOperatorEditFlowGuards(_FakeHomeMixin, unittest.TestCase):
|
||||||
"""Chunk-4 contract: the edit flow refuses when the selected
|
"""Chunk-4 contract: the edit flow refuses when the selected
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Unit: DockerBottle's argv builder (PRD 0021 chunk 1).
|
||||||
|
|
||||||
|
`claude_docker_argv` is the pure helper that `exec_claude` 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
|
||||||
|
path can rely on identical behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from claude_bottle.backend.docker.bottle import DockerBottle
|
||||||
|
|
||||||
|
|
||||||
|
def _bottle(prompt_path: str | None = None) -> DockerBottle:
|
||||||
|
return DockerBottle(
|
||||||
|
container="claude-bottle-dev-abc",
|
||||||
|
teardown=lambda: None,
|
||||||
|
prompt_path_in_container=prompt_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestClaudeDockerArgv(unittest.TestCase):
|
||||||
|
def test_minimal_argv_no_prompt(self):
|
||||||
|
argv = _bottle().claude_docker_argv([])
|
||||||
|
self.assertEqual(
|
||||||
|
["docker", "exec", "-it", "claude-bottle-dev-abc", "claude"],
|
||||||
|
argv,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_appends_passed_args_after_claude(self):
|
||||||
|
argv = _bottle().claude_docker_argv(
|
||||||
|
["--dangerously-skip-permissions", "--continue"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["docker", "exec", "-it", "claude-bottle-dev-abc", "claude",
|
||||||
|
"--dangerously-skip-permissions", "--continue"],
|
||||||
|
argv,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_appends_prompt_file_flag_when_set(self):
|
||||||
|
argv = _bottle("/home/node/.claude-bottle-prompt.txt").claude_docker_argv(
|
||||||
|
["--dangerously-skip-permissions"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["docker", "exec", "-it", "claude-bottle-dev-abc", "claude",
|
||||||
|
"--dangerously-skip-permissions",
|
||||||
|
"--append-system-prompt-file",
|
||||||
|
"/home/node/.claude-bottle-prompt.txt"],
|
||||||
|
argv,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_prompt_flag_when_none(self):
|
||||||
|
argv = _bottle(None).claude_docker_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
|
||||||
|
# prompt_path means "skip the flag." The synth path in
|
||||||
|
# dashboard.py relies on this when metadata is missing.
|
||||||
|
argv = _bottle("").claude_docker_argv(["--continue"])
|
||||||
|
self.assertNotIn("--append-system-prompt-file", argv)
|
||||||
|
|
||||||
|
def test_tty_false_drops_it_flag(self):
|
||||||
|
argv = _bottle().claude_docker_argv([], tty=False)
|
||||||
|
self.assertEqual(
|
||||||
|
["docker", "exec", "claude-bottle-dev-abc", "claude"],
|
||||||
|
argv,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_caller_argv_not_mutated(self):
|
||||||
|
# `claude_docker_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
|
||||||
|
# it on subsequent calls.
|
||||||
|
original = ["--continue"]
|
||||||
|
_bottle("/x").claude_docker_argv(original)
|
||||||
|
self.assertEqual(["--continue"], original)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user