# PRD 0021: Dashboard as left tmux pane, selected agent as right pane

- **Status:** Active
- **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-<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_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 <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_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 <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
