docs(prd-0021): rewrite as standalone — no references to closed PR #48
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 1m6s

PR #48 closed; treat the implementation as starting from
main, where no tmux integration exists yet. The PRD now
describes the full design (including the `_in_tmux` detection
+ helper scaffolding) as fresh work. Sized into 4 chunks:
`claude_docker_argv` refactor → tmux helpers + pane state +
`_attach_to_bottle` dispatch → new-agent flow → stop +
indicator.

Same design as before — opt-in by `\$TMUX`, split-window-then-
respawn, falls back to handoff on tmux failure or missing
binary. No external references to PR #48.
This commit is contained in:
2026-05-26 14:18:24 -04:00
parent 8b8d668602
commit e5316be454
+193 -178
View File
@@ -10,55 +10,34 @@ When the dashboard runs inside tmux, lay it out as the **left
pane** of a two-pane window with the **selected agent's claude pane** of a two-pane window with the **selected agent's claude
session in the right pane**. Pressing Enter on an agent in the session in the right pane**. Pressing Enter on an agent in the
agents pane swaps the right pane to that agent's session agents pane swaps the right pane to that agent's session
(respawning with `claude --continue` so the conversation (respawning with `claude --continue` so the conversation picks
picks up where it left off). Pressing `n` to start a new agent up where it left off). Pressing `n` to start a new agent spawns
spawns it directly into the right pane. The dashboard is the it directly into the right pane. The dashboard is the
operator's persistent left-hand surface; claude is always operator's persistent left-hand surface; claude is always
visible to its right. visible to its right.
Outside tmux, fall back to today's handoff (`curses.endwin` Outside tmux, fall back to today's handoff (`curses.endwin →
foreground claude → `stdscr.refresh`). The split-pane UX is foreground claude → stdscr.refresh`). The split-pane UX is
opt-in by environment — running the dashboard inside a tmux opt-in by environment — running the dashboard inside a tmux
session enables it, no flag required. session enables it, no flag required.
This supersedes the "new-window per agent" shape of PR #48
(option 3 from the research doc) with a tighter,
selection-driven model where the right pane reflects what the
operator is *currently focused on* rather than accumulating one
window per ever-attached bottle.
## Problem ## Problem
PR #48 (and option 3 from `docs/research/claude-code-pane-in- The dashboard's "Enter to attach" (PRD 0020 chunk 3) and its
dashboard.md`) gets us partway there: when `$TMUX` is set, the new-agent `n` flow both take over the terminal for the
dashboard's Enter / `n` keys spawn claude in a new tmux duration of the claude session via `curses.endwin`. While
**window** (separate tab) instead of taking over the terminal. claude has the screen, the dashboard's proposal queue and
That's better than the handoff in two respects — the dashboard agents pane are invisible. Any tool call that lands during a
keeps refreshing, and multiple agents get their own panes — but claude session is silent until the operator exits back to the
it has three real rough edges: dashboard.
1. **No simultaneous view.** A tmux window is a separate tab. The split-pane shape this PRD proposes keeps both surfaces
The operator switches to claude via `C-b n`, away from the visible: dashboard always on the left, the currently-selected
dashboard. Watching proposals queue while talking to claude agent on the right. The dashboard's existing selection model
is back to the two-terminals-side-by-side workflow the (PRD 0019) drives which agent occupies the right pane —
dashboard was supposed to collapse. moving the cursor in the agents pane is a no-op; pressing
Enter swaps the right pane to that agent's session. One
2. **Window accumulation.** Each agent ever attached adds a window, two panes, no terminal handoff.
tmux window. After a day with 10 bottles the operator has 10
windows to navigate; the dashboard's agents pane and tmux's
window list duplicate each other.
3. **Selection doesn't drive layout.** The dashboard's
selection model (PRD 0019) already knows which agent the
operator cares about right now. The new-window shape ignores
that — the operator has to manually switch tmux windows
independent of the dashboard's cursor position.
The split-pane shape this PRD proposes uses the same selection
the dashboard already tracks: cursor on agent X + Enter →
right pane shows agent X. Move cursor to agent Y + Enter →
right pane respawns with agent Y. One window, two panes, no
accumulation.
## Goals / Success Criteria ## Goals / Success Criteria
@@ -73,72 +52,73 @@ accumulation.
3. Pressing `n` to start a new agent (the existing chunk-2 flow 3. Pressing `n` to start a new agent (the existing chunk-2 flow
from PRD 0020) directs the spawned claude session into the from PRD 0020) directs the spawned claude session into the
right pane instead of taking over the terminal. right pane instead of taking over the terminal.
4. Pressing `x` to stop a dashboard-owned bottle (PRD 0020 4. Pressing `x` to stop a dashboard-owned bottle: if that
chunk 4): if that bottle was the right-pane occupant, the bottle was the right-pane occupant, the right pane is
right pane is cleared (or shows a brief "stopped" message). cleared (or shows a brief "stopped" message).
5. Closing the right pane manually via tmux (e.g., `C-b x`) 5. Closing the right pane manually via tmux (e.g., `C-b x`)
leaves the dashboard intact; the next Enter creates a fresh leaves the dashboard intact; the next Enter creates a fresh
right pane. right pane.
6. Outside tmux (`$TMUX` unset), the dashboard's Enter / `n` 6. Outside tmux (`$TMUX` unset), the dashboard's Enter / `n`
behavior falls back to today's handoff. No tmux dependency behavior falls back to today's handoff. No tmux dependency
for non-tmux users. for non-tmux users.
7. The two-pane layout works whether the operator pre-arranges
tmux themselves or lets the dashboard set it up. Both paths
land at the same place.
## Non-goals ## Non-goals
- **The embedded-emulator option (option 2 from the research - **The embedded-emulator option** from
doc).** This PRD stays on the multiplexer-delegated shape. `docs/research/claude-code-pane-in-dashboard.md`. This PRD
pyte-driven in-curses rendering is a separate, much larger stays on the multiplexer-delegated shape. pyte-driven
decision. in-curses rendering is a separate, much larger decision.
- **Multi-pane / grid layout.** No "show 4 agents at once." - **Multi-pane / grid layout.** No "show 4 agents at once."
One left, one right. Picking that as a constraint dramatically One left, one right. Picking that as a constraint
simplifies the state machine. dramatically simplifies the state machine.
- **Persistence across dashboard exits.** The tmux session - **Persistence across dashboard exits.** The tmux session
state (which agent is in the right pane) doesn't survive a state (which agent is in the right pane) doesn't survive a
dashboard restart. The bottles themselves persist (PRD 0020's dashboard restart. The bottles themselves persist (PRD 0020's
`q`-doesn't-tear-down), but reattaching is one Enter away `q`-doesn't-tear-down); reattaching is one Enter away after a
after a fresh launch. fresh launch.
- **Replacing PR #48 entirely while it's in flight.** This PRD
ships AFTER PR #48 lands (or merges over it). The shared
helpers (`_attach_via_tmux`, `_build_tmux_attach_argv`) carry
forward; the dispatch logic changes from `new-window` to
`split-window` / `respawn-pane`.
- **Cross-tmux-session orchestration.** The dashboard owns - **Cross-tmux-session orchestration.** The dashboard owns
panes in its own session. Other tmux sessions on the same panes in its own session. Other tmux sessions on the same
host are untouched. host are untouched.
- **A "right pane is detached" mode** where claude runs in a - **A "right pane is detached" mode** where claude runs in a
pane that's not visible until expanded. Out of v1. 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 ## Scope
### In scope ### In scope
- A `_tmux_split_pane_layout()` helper that, on first attach, - A `_in_tmux()` helper that returns True when `$TMUX` is set,
runs `tmux split-window -h …` to create the right pane and with `FileNotFoundError`-safe `subprocess.run` calls
remembers its tmux pane id so subsequent attaches use around every tmux invocation so a missing tmux binary
`tmux respawn-pane -t <id> …` to swap content. cleanly falls back to handoff mode.
- Pane-id state on the main loop: `right_pane_id: str | None`. - Two new state fields on the main loop: `right_pane_id: str |
None means "no right pane yet" → next attach creates one None` (the tmux pane id we created — None = "no right pane
via split-window. Non-None → respawn-pane. yet, next attach must split") and `right_pane_slug: str |
- A `_pane_exists(pane_id)` check via `tmux list-panes` None` (which bottle is currently in the right pane, for
before respawn, so a manually-closed right pane gracefully the stop + indicator hooks).
falls back to a new split. - A `_tmux_split_pane_create(bottle, *, resume) -> str | None`
- Updated dispatch in `_attach_to_bottle` and `_new_agent_flow`: helper that runs `tmux split-window -h -P -F '#{pane_id}' …`
- `$TMUX` set → split-pane / respawn-pane path and returns the new pane id, or None on failure.
- `$TMUX` unset → existing handoff - A `_tmux_respawn_pane(pane_id, bottle, *, resume) -> bool`
- PR #48's `tmux new-window` path becomes the fallback inside helper that runs `tmux respawn-pane -k -t <id> …` and
the split-pane code (e.g., if `tmux split-window` fails, or returns success.
via a config knob). - A `_tmux_pane_exists(pane_id) -> bool` check via
- `_stop_bottle_flow` clears `right_pane_id` if the stopped `tmux list-panes -F '#{pane_id}'` so a manually-closed right
bottle was the right-pane occupant. 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 ### Out of scope
- Choosing the split direction (-h vs -v). The PRD assumes - Choosing the split direction (-h vs -v) as a runtime knob.
horizontal split (left/right). Open question if the operator v1 is `-h` (left / right). See open question #1.
wants vertical.
- Sizing the split (e.g., 40/60 vs 50/50). v1 takes the tmux - Sizing the split (e.g., 40/60 vs 50/50). v1 takes the tmux
default; sizing knob deferred. default; sizing knob deferred.
- Persisting the right-pane occupant slug to disk so a fresh - Persisting the right-pane occupant slug to disk so a fresh
@@ -148,134 +128,170 @@ accumulation.
### State machine ### State machine
Two new fields on the main loop:
```python ```python
# new state on _main_loop: right_pane_id: str | None = None # tmux pane id, or None
right_pane_id: str | None = None # tmux pane id we created right_pane_slug: str | None = None # current occupant, or None
right_pane_slug: str | None = None # which bottle's session is in it
``` ```
Dispatch logic: ### Dispatch
`_attach_to_bottle` and `_new_agent_flow` (from PRD 0020) gain
a tmux branch:
```python ```python
def _spawn_into_right_pane(bottle, slug, *, resume) -> str: def _attach_in_tmux(bottle, slug, *, resume) -> str:
if not _in_tmux():
# Fall back to today's handoff.
return _attach_via_handoff(stdscr, bottle, slug, resume=resume)
nonlocal right_pane_id, right_pane_slug nonlocal right_pane_id, right_pane_slug
if right_pane_id and _pane_exists(right_pane_id):
# Respawn content in the existing pane. # If we have a remembered pane and it still exists, respawn it.
ok = _tmux_respawn_pane(right_pane_id, bottle, resume=resume) if right_pane_id and _tmux_pane_exists(right_pane_id):
if not ok: if _tmux_respawn_pane(right_pane_id, bottle, resume=resume):
right_pane_id = None # fall through to create right_pane_slug = slug
if right_pane_id is None or not _pane_exists(right_pane_id): return f"[{slug}] in right pane"
right_pane_id = _tmux_split_pane_create(bottle, resume=resume) right_pane_id = None # respawn failed; fall through
if right_pane_id is None:
# 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. # tmux failed; fall back to handoff.
return _attach_via_handoff(stdscr, bottle, slug, resume=resume) return _attach_via_handoff(stdscr, bottle, slug, resume=resume)
right_pane_id = pane_id
right_pane_slug = slug right_pane_slug = slug
return f"[{slug}] in right pane" return f"[{slug}] in right pane"
``` ```
### Pane creation + respawn 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 ```python
def _tmux_split_pane_create(bottle, *, resume) -> str | None: def _tmux_split_pane_create(bottle, *, resume) -> str | None:
"""Open a right pane via `tmux split-window -h -P -F '#{pane_id}'`.
The -P -F combo prints the new pane's id to stdout so we
can track it for respawn."""
docker_argv = bottle.claude_docker_argv(_claude_args(resume=resume)) docker_argv = bottle.claude_docker_argv(_claude_args(resume=resume))
try:
result = subprocess.run( result = subprocess.run(
["tmux", "split-window", "-h", ["tmux", "split-window", "-h",
"-P", "-F", "#{pane_id}", "-P", "-F", "#{pane_id}",
*docker_argv], *docker_argv],
capture_output=True, text=True, check=False, capture_output=True, text=True, check=False,
) )
except FileNotFoundError:
return None
if result.returncode != 0: if result.returncode != 0:
return None return None
return result.stdout.strip() 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: def _tmux_respawn_pane(pane_id, bottle, *, resume) -> bool:
"""Replace the pane's content via `tmux respawn-pane -t <id> -k …`.
-k kills the existing process before respawning. Idempotent
on a pane that's already empty."""
docker_argv = bottle.claude_docker_argv(_claude_args(resume=resume)) docker_argv = bottle.claude_docker_argv(_claude_args(resume=resume))
try:
result = subprocess.run( result = subprocess.run(
["tmux", "respawn-pane", "-k", "-t", pane_id, *docker_argv], ["tmux", "respawn-pane", "-k", "-t", pane_id, *docker_argv],
capture_output=True, text=True, check=False, capture_output=True, text=True, check=False,
) )
except FileNotFoundError:
return False
return result.returncode == 0 return result.returncode == 0
``` ```
### Pane-existence check ### Pane-existence check
`tmux list-panes -F '#{pane_id}'` lists pane ids in the current ```python
window. The dashboard checks for `right_pane_id` membership def _tmux_pane_exists(pane_id) -> bool:
before issuing respawn-pane; if absent (operator closed the try:
pane via `C-b x`), it falls back to creating a fresh split. 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 integration
`_stop_bottle_flow` already pops the bottle from the `_stop_bottle_flow` (from PRD 0020 chunk 4) gets a small
`bottles` dict and calls `cm.__exit__`. The new bit: if the addition: after the bottle is torn down, if `right_pane_slug
stopped slug matches `right_pane_slug`, clear both pane == stopped_slug`, clear it. The tmux pane itself stays open
tracking variables. The tmux pane itself stays open with with claude's "container not found" error showing. The next
docker exec's "container not found" error showing — acceptable; Enter on a different agent will respawn it.
the operator hits Enter on a different agent to repurpose it
(respawn-pane will replace the broken state). ### 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 ### Fallback when tmux fails
Three failure modes worth handling: Three failure modes worth handling:
1. **`$TMUX` set but tmux binary not on PATH** (rare but 1. **`$TMUX` set but tmux binary not on PATH.** `subprocess.run`
possible in nested containers). Detected when raises `FileNotFoundError` — treat as not-in-tmux, fall back
`subprocess.run` raises `FileNotFoundError` — treat as not- to handoff. Should be rare but happens in nested
in-tmux, fall back to handoff. containers.
2. **`tmux split-window` returns non-zero** (e.g., pane size 2. **`tmux split-window` / `respawn-pane` returns non-zero.**
too small, server lost). Surface a status-line error and Surface a status-line error and fall back to handoff for
fall back to handoff for that one keypress. that one keypress. The dashboard stays usable.
3. **Right pane manually closed.** Detected at next attach via 3. **Right pane manually closed.** Detected at next attach via
pane-exists check; create fresh split. `_tmux_pane_exists`; create a fresh split.
### Interaction with PR #48
PR #48 introduced `_in_tmux()`, `_attach_via_tmux` (using
`tmux new-window`), and `_build_tmux_attach_argv`. This PRD
reuses the first; rewrites the second; the third (window
argv builder) becomes one of several tmux-argv builders this
PRD introduces. Net change: PR #48's new-window behavior is
replaced by split-pane behavior. The diff is moderate but
contained — the abstraction shape carries through.
## Implementation chunks ## Implementation chunks
Sized small. Sized small.
1. **Pane state + create path.** Add `right_pane_id` / 1. **`claude_docker_argv` refactor.** Pure-ish split of
`right_pane_slug` to the main loop; implement `DockerBottle.exec_claude` so both foreground and tmux
`_tmux_split_pane_create`; wire Enter / `n` to use it when paths build on the same argv. No behavior change for the
`right_pane_id is None`. No respawn yet — second attach existing tests.
creates a *second* split-pane (visibly wrong, but isolated). 2. **tmux helpers + pane state.** Add `_in_tmux`,
2. **Respawn for subsequent attaches.** Implement `_tmux_split_pane_create`, `_tmux_respawn_pane`,
`_tmux_respawn_pane` + `_pane_exists`; route second-and- `_tmux_pane_exists`, plus the two new state fields on the
subsequent attaches through respawn instead of split. The main loop. Wire `_attach_to_bottle` to dispatch through
"wrong" behavior from chunk 1 disappears. tmux when in tmux, fall back to handoff otherwise.
3. **Stop integration.** `_stop_bottle_flow` clears 3. **New-agent flow integration.** `_new_agent_flow` (PRD
`right_pane_slug` when the stopped bottle matches; tests. 0020 chunk 2) gains the same tmux dispatch — first attach
4. **Replace PR #48's new-window with split-pane.** Remove the on a freshly-started agent goes into the right pane.
`tmux new-window` invocation; keep `_build_tmux_attach_argv` 4. **Stop integration + right-pane indicator.**
as a shared helper if useful. Update PR #48's tests to `_stop_bottle_flow` clears `right_pane_slug` when matched;
match the new behavior. (If PR #48 has merged by then, this `_format_agent_row` (PRD 0019 chunk 2) gets a `*` prefix
is a follow-up commit; if not, the two PRs reconcile at when its slug matches `right_pane_slug`.
merge time.)
## Open questions ## Open questions
1. **Split direction: horizontal (`-h`) vs vertical (`-v`).** 1. **Split direction: horizontal (`-h`) vs vertical (`-v`).**
The PRD's "left / right" framing implies `-h`. But on a The PRD's "left / right" framing implies `-h`. On a
short-and-wide terminal a vertical (top / bottom) split short-and-wide terminal a vertical (top / bottom) split
might give the dashboard more vertical real estate. Pick might give the dashboard more vertical real estate. Pick
`-h` for v1; revisit if the dashboard's agents-pane row `-h` for v1; revisit if the dashboard's agents-pane row
@@ -292,10 +308,10 @@ Sized small.
3. **Dashboard launched OUTSIDE tmux but tmux is installed.** 3. **Dashboard launched OUTSIDE tmux but tmux is installed.**
Should the dashboard auto-exec itself inside a fresh tmux Should the dashboard auto-exec itself inside a fresh tmux
session to get the split-pane experience? Convenient but session to get the split-pane experience? Convenient but
surprising (`./cli.py dashboard` shouldn't silently change surprising (`./cli.py dashboard` shouldn't silently
what session you're in). v1 leaves this off — operators change what session you're in). v1 leaves this off —
who want split-pane mode start tmux themselves and then operators who want split-pane mode start tmux themselves
run the dashboard. and then run the dashboard.
4. **Pane size after split.** tmux's default for `split-window 4. **Pane size after split.** tmux's default for `split-window
-h` is 50/50. The dashboard's column widths are designed -h` is 50/50. The dashboard's column widths are designed
@@ -304,26 +320,26 @@ Sized small.
want ~50 cols and claude gets the rest. Add `-p <pct>` or want ~50 cols and claude gets the rest. Add `-p <pct>` or
`-l <cols>` flag? Open question; v1 uses tmux's default. `-l <cols>` flag? Open question; v1 uses tmux's default.
5. **Multi-window tmux sessions.** If the operator has the 5. **Cursor highlighting in the agents pane to indicate "this
dashboard in tmux window A and unrelated work in window B, one's in the right pane right now."** Resolved YES in
the split-pane goes into window A as expected (tmux scope — `*` prefix on the matching row. Open: should it
commands are window-scoped by `$TMUX_PANE` context). No also use color, and which color doesn't conflict with the
special handling needed — but worth verifying behavior PRD-0019 focus indicator?
when the operator switches away from window A before the
dashboard issues a split.
6. **Cursor highlighting in the agents pane to indicate "this 6. **Concurrent dashboards in different tmux windows.**
one's in the right pane right now."** The dashboard could Multiple `./cli.py dashboard` invocations in different
mark the slug currently occupying the right pane with a tmux windows would each create their own right pane
`*` prefix or alternate color in the agents pane. Probably probably fine, each has its own state, but worth
yes for v1 — operator clarity. The state's already verifying that `tmux list-panes` is scoped to the right
tracked (`right_pane_slug`). window context.
7. **What about the proposals pane?** Approvals (`a`/`m`/`r`) 7. **`$TMUX_PANE` vs `$TMUX`.** Tmux sets both. We only check
are unchanged — they don't open new panes, they apply `$TMUX` (presence of the server socket); `$TMUX_PANE` is
in-process. The proposals pane stays exactly as today, the dashboard's own pane id, which we could capture to be
just narrower because it shares the left pane with the more precise about `tmux split-window -t <pane>` (split
agents list. relative to the dashboard's pane specifically). Probably
not needed since `split-window` defaults to the current
active pane, but worth verifying.
## References ## References
@@ -333,7 +349,6 @@ Sized small.
(`_attach_to_bottle`, `_new_agent_flow`, `_stop_bottle_flow` (`_attach_to_bottle`, `_new_agent_flow`, `_stop_bottle_flow`
— the three hooks this PRD changes) — the three hooks this PRD changes)
- `docs/research/claude-code-pane-in-dashboard.md` — the - `docs/research/claude-code-pane-in-dashboard.md` — the
three-option survey; this PRD picks option 3 with a tighter three-option survey of how to surface claude inside the
dashboard; this PRD picks option 3 with a tighter
split-pane variant split-pane variant
- PR #48 — opt-in tmux via `tmux new-window`; this PRD
supersedes its window-mode with pane-mode