Flip Status: Draft -> Active for the 23 PRDs whose work has shipped to main (including 0027, now that PR #95 has merged). Leaves the terminal-status PRDs unchanged: 0007 and 0010 (Superseded) and 0014 (Retargeted) were replaced, not shipped as-is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
14 KiB
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
- When the operator runs
./cli.py dashboardfrom inside a tmux session ($TMUXset), the dashboard establishes a two-pane layout: dashboard in the left pane, an initially- empty right pane reserved for claude sessions. - 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. - Pressing
nto 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. - Pressing
xto stop a dashboard-owned bottle: if that bottle was the right-pane occupant, the right pane is cleared (or shows a brief "stopped" message). - Closing the right pane manually via tmux (e.g.,
C-b x) leaves the dashboard intact; the next Enter creates a fresh right pane. - Outside tmux (
$TMUXunset), the dashboard's Enter /nbehavior 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$TMUXis set, withFileNotFoundError-safesubprocess.runcalls 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") andright_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 | Nonehelper that runstmux 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) -> boolhelper that runstmux respawn-pane -k -t <id> …and returns success. - A
_tmux_pane_exists(pane_id) -> boolcheck viatmux list-panes -F '#{pane_id}'so a manually-closed right pane gracefully falls back to a new split. - Modified
_attach_to_bottleand_new_agent_flow(PRD 0020) to dispatch through the tmux helpers when$TMUXis set, falling back to the existing handoff otherwise. _stop_bottle_flowupdated to clearright_pane_slugwhen 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:
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:
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.
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.
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
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:
$TMUXset but tmux binary not on PATH.subprocess.runraisesFileNotFoundError— treat as not-in-tmux, fall back to handoff. Should be rare but happens in nested containers.tmux split-window/respawn-panereturns non-zero. Surface a status-line error and fall back to handoff for that one keypress. The dashboard stays usable.- Right pane manually closed. Detected at next attach via
_tmux_pane_exists; create a fresh split.
Implementation chunks
Sized small.
claude_docker_argvrefactor. Pure-ish split ofDockerBottle.exec_agentso both foreground and tmux paths build on the same argv. No behavior change for the existing tests.- 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_bottleto dispatch through tmux when in tmux, fall back to handoff otherwise. - 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. - Stop integration + right-pane indicator.
_stop_bottle_flowclearsright_pane_slugwhen matched;_format_agent_row(PRD 0019 chunk 2) gets a*prefix when its slug matchesright_pane_slug.
Open questions
-
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-hfor v1; revisit if the dashboard's agents-pane row count proves cramped. -
What happens to claude when the right pane is killed?
tmux kill-paneSIGHUPs 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--continueand picks up the conversation — so the operator loses nothing except the pane's screen buffer. Document this; no special handling needed. -
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 dashboardshouldn'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. -
Pane size after split. tmux's default for
split-window -his 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. -
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? -
Concurrent dashboards in different tmux windows. Multiple
./cli.py dashboardinvocations in different tmux windows would each create their own right pane — probably fine, each has its own state, but worth verifying thattmux list-panesis scoped to the right window context. -
$TMUX_PANEvs$TMUX. Tmux sets both. We only check$TMUX(presence of the server socket);$TMUX_PANEis the dashboard's own pane id, which we could capture to be more precise abouttmux split-window -t <pane>(split relative to the dashboard's pane specifically). Probably not needed sincesplit-windowdefaults 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