docs(prd-0021): dashboard as left tmux pane, selected agent as right pane #49

Merged
didericis merged 16 commits from dashboard-tmux-split-pane into main 2026-05-26 15:40:55 -04:00
5 changed files with 1111 additions and 28 deletions
+15 -2
View File
@@ -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
View File
@@ -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)
+354
View File
@@ -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
+168
View File
@@ -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
+84
View File
@@ -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()