diff --git a/claude_bottle/backend/docker/bottle.py b/claude_bottle/backend/docker/bottle.py index 0a1b781..4670748 100644 --- a/claude_bottle/backend/docker/bottle.py +++ b/claude_bottle/backend/docker/bottle.py @@ -28,7 +28,15 @@ class DockerBottle(Bottle): self._prompt_path = prompt_path_in_container 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) if self._prompt_path: full_argv.extend(["--append-system-prompt-file", self._prompt_path]) @@ -36,7 +44,12 @@ class DockerBottle(Bottle): if tty: cmd.append("-it") 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: # Pipe via stdin to `sh -s` so the caller never has to worry diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index 04faefd..b99353c 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -12,8 +12,10 @@ chunk 3) writes routes.yaml + SIGHUPs egress; PRD 0015 from __future__ import annotations import argparse +import contextlib import curses import os +import shlex import shutil import subprocess import sys @@ -29,7 +31,7 @@ from ..backend.docker.capability_apply import ( CapabilityApplyError, 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 ( compose_project_name, list_active_slugs, @@ -444,6 +446,7 @@ def _picker_modal( try: key = stdscr.getch() except KeyboardInterrupt: + _erase_modal(stdscr) return None if key == 27: # Esc @@ -451,9 +454,11 @@ def _picker_modal( query = "" selected = 0 continue + _erase_modal(stdscr) return None if key in (curses.KEY_ENTER, 10, 13): if filtered: + _erase_modal(stdscr) return filtered[selected] continue if key in (curses.KEY_DOWN, ord("\x0e")): # KEY_DOWN, Ctrl-N @@ -577,13 +582,28 @@ def _preflight_modal( try: key = stdscr.getch() except KeyboardInterrupt: + _erase_modal(stdscr) return False if key in (ord("y"), ord("Y")): + _erase_modal(stdscr) return True if key in (ord("n"), ord("N"), 27, curses.KEY_ENTER, 10, 13): + _erase_modal(stdscr) 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: """Capture `plan.print` output by temporarily redirecting stderr. Plan rendering is stderr-bound (existing behavior the @@ -654,22 +674,28 @@ def _stop_bottle_flow( stdscr: "curses._CursesWindow", bottles: dict, slug: str, + *, + tmux_state: dict | None = None, ) -> str: """Explicit per-bottle teardown (PRD 0020 chunk 4). Pops the (cm, bottle, identity) tuple from the dashboard's bottles map, snapshots the transcript best-effort, drives the launch context's __exit__ (compose down + network remove), and 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: return ( f"[{slug}] not dashboard-owned — use ./cli.py cleanup" ) cm, _bottle, identity = bottles.pop(slug) - # compose-down writes to stderr; drop curses so the lines - # render cleanly. Same pattern as the attach handoff. - curses.endwin() - try: + + def _do_teardown() -> None: # Best-effort snapshot before teardown so the operator # can still inspect the agent's last state via the # preserved transcript dir even after explicit stop. @@ -684,27 +710,310 @@ def _stop_bottle_flow( cm.__exit__(None, None, None) except BaseException: 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: stdscr.refresh() settle_state(identity) + if tmux_state is not None and tmux_state.get("slug") == slug: + tmux_state["slug"] = None 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 ` --continue || ` 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 `. `-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//` 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", bottle, slug: str, + *, + resume: bool, ) -> str: - """Handoff: curses.endwin → attach claude → curses refresh. - Re-entry into a running bottle from the dashboard always - passes `--resume` so claude picks up its prior conversation - 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.""" + """Foreground handoff: curses.endwin → attach claude → curses + refresh. The non-tmux path (and the failover from + `_attach_in_tmux` when tmux misbehaves).""" curses.endwin() try: - exit_code = attach_claude(bottle, remote_control=False, resume=True) + exit_code = attach_claude( + bottle, remote_control=False, resume=resume, + ) except BaseException: stdscr.refresh() raise @@ -712,17 +1021,99 @@ def _attach_to_bottle( 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 ` — 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( stdscr: "curses._CursesWindow", manifest: Manifest, bottles: dict, agents_now: list[ActiveAgent], + tmux_state: dict | None = None, ) -> str: """Open the picker, prepare + preflight (modal), launch - (enter the context manager but DON'T close it), handoff to - claude. Returns a status-line message for the dashboard footer. - The (cm, bottle) tuple lands in `bottles` keyed by slug; chunks - 3/4 use it for re-attach and explicit stop.""" + (enter the context manager but DON'T close it), then route + the first claude session into the right pane (in-tmux) or + foreground handoff (otherwise). Returns a status-line message + for the dashboard footer. The (cm, bottle) tuple lands in + `bottles` keyed by slug; chunk 4 uses it for explicit stop.""" names = sorted(manifest.agents.keys()) picked = _picker_modal(stdscr, names, _running_counts(bottles, agents_now)) if picked is None: @@ -759,10 +1150,36 @@ def _new_agent_flow( return f"start of {picked!r} aborted at preflight" 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, # compose up). Get out of curses' way for the duration so - # the lines render cleanly. The handoff stays endwin'd until - # claude exits, then we refresh. + # the lines render cleanly; restore curses immediately after. curses.endwin() try: cm = backend.launch(plan) @@ -773,6 +1190,8 @@ def _new_agent_flow( raise bottles[plan.slug] = (cm, bottle, identity) + # Foreground handoff: claude owns the terminal until exit, + # then we restore curses. try: exit_code = attach_claude(bottle, remote_control=False) capture_session_state(identity, exit_code) @@ -907,14 +1326,23 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: first_seen: dict[str, float] = {} selected = 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 = "" # PRD 0020: bottles spun up from inside this dashboard session. # Each entry: slug -> (context-manager, Bottle handle, identity). # We hold the context manager so chunk 4's `x` can call __exit__ - # on it; chunk 5 quit-cleanup intentionally does NOT iterate this - # dict (the user wants quit to leave bottles running). + # on it; quit (`q`) intentionally does NOT iterate this dict + # (the user wants quit to leave bottles running). 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 # doesn't fail to start in a directory with no manifest (e.g., # when the operator is purely watching pre-existing bottles). @@ -945,6 +1373,7 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: agents=agents, selected_agent=selected_agent, focus=focus, + right_pane_slug=tmux_state.get("slug"), 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: status_line = f"manifest load failed: {e}" continue - status_line = _new_agent_flow(stdscr, manifest, bottles, agents) + status_line = _new_agent_flow( + stdscr, manifest, bottles, agents, tmux_state=tmux_state, + ) continue if key in (ord("e"), ord("p")): # PRD 0019 chunk 4: agent-scoped edits. Only fire when @@ -1010,7 +1441,9 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: else: manifest = manifest_cache[0] # may be None; that's ok 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"): target = _selected_agent(focus, agents, selected_agent) if target is None: @@ -1018,7 +1451,30 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: else: status_line = _stop_bottle_flow( 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 if not pending: @@ -1065,6 +1521,7 @@ def _render( agents: list[ActiveAgent] | None = None, selected_agent: int = 0, focus: str = PANE_PROPOSALS, + right_pane_slug: str | None = None, first_seen: dict[str, float] | None = None, now: float | None = None, green_attr: int = 0, @@ -1146,11 +1603,18 @@ def _render( if row >= h - 3: break line = _format_agent_row(a, w - 1) + in_right_pane = (a.slug == right_pane_slug) if agents_focused and i == selected_agent: # Replace the leading " " cursor with "> " and # highlight the whole row. line = "> " + line[2:] 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: attr = curses.A_NORMAL stdscr.addnstr(row, 0, line, w - 1, attr) diff --git a/docs/prds/0021-dashboard-tmux-split-pane.md b/docs/prds/0021-dashboard-tmux-split-pane.md new file mode 100644 index 0000000..f4f8ebe --- /dev/null +++ b/docs/prds/0021-dashboard-tmux-split-pane.md @@ -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- 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 …` 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 …` 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 ` +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 ` or + `-l ` 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 ` (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 diff --git a/tests/unit/test_dashboard_active_agents.py b/tests/unit/test_dashboard_active_agents.py index 11486c6..75154fd 100644 --- a/tests/unit/test_dashboard_active_agents.py +++ b/tests/unit/test_dashboard_active_agents.py @@ -379,6 +379,160 @@ class TestBottleForSlug(unittest.TestCase): 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 ' --continue || '`. + 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): """Explicit per-bottle stop (PRD 0020 chunk 4). The non-owned 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("./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): """Chunk-4 contract: the edit flow refuses when the selected diff --git a/tests/unit/test_docker_bottle.py b/tests/unit/test_docker_bottle.py new file mode 100644 index 0000000..01bf890 --- /dev/null +++ b/tests/unit/test_docker_bottle.py @@ -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()