# PRD 0021: Dashboard as left tmux pane, selected agent as right pane - **Status:** Superseded by [PRD 0049](0049-strip-dashboard-to-supervisor-tui.md) - **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 bot-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_agent → 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_agent` appends today when the bottle has a prompt path. Refactor: split `exec_agent` into a pure `claude_docker_argv(args, *, tty)` that returns the argv and a thin `exec_agent` 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_agent` 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