From 8b8d66860231e8c6e8bf4cea16a2ac87546a2603 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 14:14:02 -0400 Subject: [PATCH 01/16] docs(prd-0021): dashboard as left tmux pane, selected agent as right pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draft a PRD that tightens PR #48's tmux integration from "one new window per attach" to "one persistent right pane that the dashboard's selection drives." Inside tmux (`\$TMUX` set): dashboard in the left pane; pressing Enter or `n` spawns claude in the right pane via `tmux split-window` on first attach, then `tmux respawn-pane` on subsequent attaches so the operator-focused agent is always the visible one. Outside tmux: falls back to today's handoff. Opt-in by environment; no flag. Sized into 4 chunks (pane state + create → respawn → stop integration → supersede PR #48's new-window). Seven open questions called out, the biggest being whether the dashboard should auto-exec into a fresh tmux session when launched outside one (v1 says no — operators start tmux themselves). --- docs/prds/0021-dashboard-tmux-split-pane.md | 339 ++++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 docs/prds/0021-dashboard-tmux-split-pane.md 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..f8d764b --- /dev/null +++ b/docs/prds/0021-dashboard-tmux-split-pane.md @@ -0,0 +1,339 @@ +# 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. + +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 + +PR #48 (and option 3 from `docs/research/claude-code-pane-in- +dashboard.md`) gets us partway there: when `$TMUX` is set, the +dashboard's Enter / `n` keys spawn claude in a new tmux +**window** (separate tab) instead of taking over the terminal. +That's better than the handoff in two respects — the dashboard +keeps refreshing, and multiple agents get their own panes — but +it has three real rough edges: + +1. **No simultaneous view.** A tmux window is a separate tab. + The operator switches to claude via `C-b n`, away from the + dashboard. Watching proposals queue while talking to claude + is back to the two-terminals-side-by-side workflow the + dashboard was supposed to collapse. + +2. **Window accumulation.** Each agent ever attached adds a + 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 + +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 (PRD 0020 + chunk 4): 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. +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 + +- **The embedded-emulator option (option 2 from the research + doc).** 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), but reattaching is one Enter away + after a 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 + 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. + +## Scope + +### In scope + +- A `_tmux_split_pane_layout()` helper that, on first attach, + runs `tmux split-window -h …` to create the right pane and + remembers its tmux pane id so subsequent attaches use + `tmux respawn-pane -t …` to swap content. +- Pane-id state on the main loop: `right_pane_id: str | None`. + None means "no right pane yet" → next attach creates one + via split-window. Non-None → respawn-pane. +- A `_pane_exists(pane_id)` check via `tmux list-panes` + before respawn, so a manually-closed right pane gracefully + falls back to a new split. +- Updated dispatch in `_attach_to_bottle` and `_new_agent_flow`: + - `$TMUX` set → split-pane / respawn-pane path + - `$TMUX` unset → existing handoff + - PR #48's `tmux new-window` path becomes the fallback inside + the split-pane code (e.g., if `tmux split-window` fails, or + via a config knob). +- `_stop_bottle_flow` clears `right_pane_id` if the stopped + bottle was the right-pane occupant. + +### Out of scope + +- Choosing the split direction (-h vs -v). The PRD assumes + horizontal split (left/right). Open question if the operator + wants vertical. +- 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 + +```python +# new state on _main_loop: +right_pane_id: str | None = None # tmux pane id we created +right_pane_slug: str | None = None # which bottle's session is in it +``` + +Dispatch logic: + +```python +def _spawn_into_right_pane(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 + if right_pane_id and _pane_exists(right_pane_id): + # Respawn content in the existing pane. + ok = _tmux_respawn_pane(right_pane_id, bottle, resume=resume) + if not ok: + right_pane_id = None # fall through to create + if right_pane_id is None or not _pane_exists(right_pane_id): + right_pane_id = _tmux_split_pane_create(bottle, resume=resume) + if right_pane_id is None: + # tmux failed; fall back to handoff. + return _attach_via_handoff(stdscr, bottle, slug, resume=resume) + + right_pane_slug = slug + return f"[{slug}] in right pane" +``` + +### Pane creation + respawn + +```python +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)) + result = subprocess.run( + ["tmux", "split-window", "-h", + "-P", "-F", "#{pane_id}", + *docker_argv], + capture_output=True, text=True, check=False, + ) + if result.returncode != 0: + return None + return result.stdout.strip() + + +def _tmux_respawn_pane(pane_id, bottle, *, resume) -> bool: + """Replace the pane's content via `tmux respawn-pane -t -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)) + result = subprocess.run( + ["tmux", "respawn-pane", "-k", "-t", pane_id, *docker_argv], + capture_output=True, text=True, check=False, + ) + return result.returncode == 0 +``` + +### Pane-existence check + +`tmux list-panes -F '#{pane_id}'` lists pane ids in the current +window. The dashboard checks for `right_pane_id` membership +before issuing respawn-pane; if absent (operator closed the +pane via `C-b x`), it falls back to creating a fresh split. + +### Stop integration + +`_stop_bottle_flow` already pops the bottle from the +`bottles` dict and calls `cm.__exit__`. The new bit: if the +stopped slug matches `right_pane_slug`, clear both pane +tracking variables. The tmux pane itself stays open with +docker exec's "container not found" error showing — acceptable; +the operator hits Enter on a different agent to repurpose it +(respawn-pane will replace the broken state). + +### Fallback when tmux fails + +Three failure modes worth handling: + +1. **`$TMUX` set but tmux binary not on PATH** (rare but + possible in nested containers). Detected when + `subprocess.run` raises `FileNotFoundError` — treat as not- + in-tmux, fall back to handoff. +2. **`tmux split-window` returns non-zero** (e.g., pane size + too small, server lost). Surface a status-line error and + fall back to handoff for that one keypress. +3. **Right pane manually closed.** Detected at next attach via + pane-exists check; create 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 + +Sized small. + +1. **Pane state + create path.** Add `right_pane_id` / + `right_pane_slug` to the main loop; implement + `_tmux_split_pane_create`; wire Enter / `n` to use it when + `right_pane_id is None`. No respawn yet — second attach + creates a *second* split-pane (visibly wrong, but isolated). +2. **Respawn for subsequent attaches.** Implement + `_tmux_respawn_pane` + `_pane_exists`; route second-and- + subsequent attaches through respawn instead of split. The + "wrong" behavior from chunk 1 disappears. +3. **Stop integration.** `_stop_bottle_flow` clears + `right_pane_slug` when the stopped bottle matches; tests. +4. **Replace PR #48's new-window with split-pane.** Remove the + `tmux new-window` invocation; keep `_build_tmux_attach_argv` + as a shared helper if useful. Update PR #48's tests to + match the new behavior. (If PR #48 has merged by then, this + is a follow-up commit; if not, the two PRs reconcile at + merge time.) + +## Open questions + +1. **Split direction: horizontal (`-h`) vs vertical (`-v`).** + The PRD's "left / right" framing implies `-h`. But 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. **Multi-window tmux sessions.** If the operator has the + dashboard in tmux window A and unrelated work in window B, + the split-pane goes into window A as expected (tmux + commands are window-scoped by `$TMUX_PANE` context). No + special handling needed — but worth verifying behavior + when the operator switches away from window A before the + dashboard issues a split. + +6. **Cursor highlighting in the agents pane to indicate "this + one's in the right pane right now."** The dashboard could + mark the slug currently occupying the right pane with a + `*` prefix or alternate color in the agents pane. Probably + yes for v1 — operator clarity. The state's already + tracked (`right_pane_slug`). + +7. **What about the proposals pane?** Approvals (`a`/`m`/`r`) + are unchanged — they don't open new panes, they apply + in-process. The proposals pane stays exactly as today, + just narrower because it shares the left pane with the + agents list. + +## 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; this PRD picks option 3 with a tighter + split-pane variant +- PR #48 — opt-in tmux via `tmux new-window`; this PRD + supersedes its window-mode with pane-mode From e5316be4549eccc14b4515e370f92104c34e0ce1 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 14:18:24 -0400 Subject: [PATCH 02/16] =?UTF-8?q?docs(prd-0021):=20rewrite=20as=20standalo?= =?UTF-8?q?ne=20=E2=80=94=20no=20references=20to=20closed=20PR=20#48?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/prds/0021-dashboard-tmux-split-pane.md | 393 ++++++++++---------- 1 file changed, 204 insertions(+), 189 deletions(-) diff --git a/docs/prds/0021-dashboard-tmux-split-pane.md b/docs/prds/0021-dashboard-tmux-split-pane.md index f8d764b..f4f8ebe 100644 --- a/docs/prds/0021-dashboard-tmux-split-pane.md +++ b/docs/prds/0021-dashboard-tmux-split-pane.md @@ -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 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 +(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 +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. -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 -PR #48 (and option 3 from `docs/research/claude-code-pane-in- -dashboard.md`) gets us partway there: when `$TMUX` is set, the -dashboard's Enter / `n` keys spawn claude in a new tmux -**window** (separate tab) instead of taking over the terminal. -That's better than the handoff in two respects — the dashboard -keeps refreshing, and multiple agents get their own panes — but -it has three real rough edges: +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. -1. **No simultaneous view.** A tmux window is a separate tab. - The operator switches to claude via `C-b n`, away from the - dashboard. Watching proposals queue while talking to claude - is back to the two-terminals-side-by-side workflow the - dashboard was supposed to collapse. - -2. **Window accumulation.** Each agent ever attached adds a - 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. +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 @@ -73,72 +52,73 @@ accumulation. 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 (PRD 0020 - chunk 4): if that bottle was the right-pane occupant, the - right pane is cleared (or shows a brief "stopped" message). +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. -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 -- **The embedded-emulator option (option 2 from the research - doc).** This PRD stays on the multiplexer-delegated shape. - pyte-driven in-curses rendering is a separate, much larger - decision. +- **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. + 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), but reattaching is one Enter away - after a 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`. + `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 `_tmux_split_pane_layout()` helper that, on first attach, - runs `tmux split-window -h …` to create the right pane and - remembers its tmux pane id so subsequent attaches use - `tmux respawn-pane -t …` to swap content. -- Pane-id state on the main loop: `right_pane_id: str | None`. - None means "no right pane yet" → next attach creates one - via split-window. Non-None → respawn-pane. -- A `_pane_exists(pane_id)` check via `tmux list-panes` - before respawn, so a manually-closed right pane gracefully - falls back to a new split. -- Updated dispatch in `_attach_to_bottle` and `_new_agent_flow`: - - `$TMUX` set → split-pane / respawn-pane path - - `$TMUX` unset → existing handoff - - PR #48's `tmux new-window` path becomes the fallback inside - the split-pane code (e.g., if `tmux split-window` fails, or - via a config knob). -- `_stop_bottle_flow` clears `right_pane_id` if the stopped - bottle was the right-pane occupant. +- 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). The PRD assumes - horizontal split (left/right). Open question if the operator - wants vertical. +- 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 @@ -148,134 +128,170 @@ accumulation. ### State machine +Two new fields on the main loop: + ```python -# new state on _main_loop: -right_pane_id: str | None = None # tmux pane id we created -right_pane_slug: str | None = None # which bottle's session is in it +right_pane_id: str | None = None # tmux pane id, or None +right_pane_slug: str | None = None # current occupant, or None ``` -Dispatch logic: +### Dispatch + +`_attach_to_bottle` and `_new_agent_flow` (from PRD 0020) gain +a tmux branch: ```python -def _spawn_into_right_pane(bottle, slug, *, resume) -> str: - if not _in_tmux(): - # Fall back to today's handoff. - return _attach_via_handoff(stdscr, bottle, slug, resume=resume) - +def _attach_in_tmux(bottle, slug, *, resume) -> str: nonlocal right_pane_id, right_pane_slug - if right_pane_id and _pane_exists(right_pane_id): - # Respawn content in the existing pane. - ok = _tmux_respawn_pane(right_pane_id, bottle, resume=resume) - if not ok: - right_pane_id = None # fall through to create - if right_pane_id is None or not _pane_exists(right_pane_id): - right_pane_id = _tmux_split_pane_create(bottle, resume=resume) - if right_pane_id is None: - # tmux failed; fall back to handoff. - return _attach_via_handoff(stdscr, bottle, slug, resume=resume) + # 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" ``` -### 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 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)) - result = subprocess.run( - ["tmux", "split-window", "-h", - "-P", "-F", "#{pane_id}", - *docker_argv], - capture_output=True, text=True, check=False, - ) + 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() + 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: - """Replace the pane's content via `tmux respawn-pane -t -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)) - result = subprocess.run( - ["tmux", "respawn-pane", "-k", "-t", pane_id, *docker_argv], - capture_output=True, text=True, check=False, - ) + 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 -`tmux list-panes -F '#{pane_id}'` lists pane ids in the current -window. The dashboard checks for `right_pane_id` membership -before issuing respawn-pane; if absent (operator closed the -pane via `C-b x`), it falls back to creating a fresh split. +```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` already pops the bottle from the -`bottles` dict and calls `cm.__exit__`. The new bit: if the -stopped slug matches `right_pane_slug`, clear both pane -tracking variables. The tmux pane itself stays open with -docker exec's "container not found" error showing — acceptable; -the operator hits Enter on a different agent to repurpose it -(respawn-pane will replace the broken state). +`_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** (rare but - possible in nested containers). Detected when - `subprocess.run` raises `FileNotFoundError` — treat as not- - in-tmux, fall back to handoff. -2. **`tmux split-window` returns non-zero** (e.g., pane size - too small, server lost). Surface a status-line error and - fall back to handoff for that one keypress. +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 - pane-exists check; create 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. + `_tmux_pane_exists`; create a fresh split. ## Implementation chunks Sized small. -1. **Pane state + create path.** Add `right_pane_id` / - `right_pane_slug` to the main loop; implement - `_tmux_split_pane_create`; wire Enter / `n` to use it when - `right_pane_id is None`. No respawn yet — second attach - creates a *second* split-pane (visibly wrong, but isolated). -2. **Respawn for subsequent attaches.** Implement - `_tmux_respawn_pane` + `_pane_exists`; route second-and- - subsequent attaches through respawn instead of split. The - "wrong" behavior from chunk 1 disappears. -3. **Stop integration.** `_stop_bottle_flow` clears - `right_pane_slug` when the stopped bottle matches; tests. -4. **Replace PR #48's new-window with split-pane.** Remove the - `tmux new-window` invocation; keep `_build_tmux_attach_argv` - as a shared helper if useful. Update PR #48's tests to - match the new behavior. (If PR #48 has merged by then, this - is a follow-up commit; if not, the two PRs reconcile at - merge time.) +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`. But on a + 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 @@ -292,10 +308,10 @@ Sized small. 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. + 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 @@ -304,26 +320,26 @@ Sized small. want ~50 cols and claude gets the rest. Add `-p ` or `-l ` flag? Open question; v1 uses tmux's default. -5. **Multi-window tmux sessions.** If the operator has the - dashboard in tmux window A and unrelated work in window B, - the split-pane goes into window A as expected (tmux - commands are window-scoped by `$TMUX_PANE` context). No - special handling needed — but worth verifying behavior - when the operator switches away from window A before the - dashboard issues a split. +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. **Cursor highlighting in the agents pane to indicate "this - one's in the right pane right now."** The dashboard could - mark the slug currently occupying the right pane with a - `*` prefix or alternate color in the agents pane. Probably - yes for v1 — operator clarity. The state's already - tracked (`right_pane_slug`). +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. **What about the proposals pane?** Approvals (`a`/`m`/`r`) - are unchanged — they don't open new panes, they apply - in-process. The proposals pane stays exactly as today, - just narrower because it shares the left pane with the - agents list. +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 @@ -333,7 +349,6 @@ Sized small. (`_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; 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 -- PR #48 — opt-in tmux via `tmux new-window`; this PRD - supersedes its window-mode with pane-mode From 2303cbc0befbd99b96ffc86d895115c5320e40d8 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 14:21:04 -0400 Subject: [PATCH 03/16] refactor(bottle): extract claude_docker_argv from exec_claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRD 0021 chunk 1. The tmux split-pane helpers (chunk 2+) need the same docker-exec argv that `exec_claude` builds — including the `--append-system-prompt-file ` flag the bottle's provisioner copies into place. Extract the argv construction into a pure `claude_docker_argv(argv, *, tty)` method so both foreground (`subprocess.run`) and tmux paths (`tmux respawn-pane …`) build from the same source. `exec_claude` becomes a one-liner that runs subprocess.run on the argv. No behavior change; 472 unit tests pass (7 new for the pure builder). --- claude_bottle/backend/docker/bottle.py | 17 +++++- tests/unit/test_docker_bottle.py | 84 ++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_docker_bottle.py 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/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() From 994487827704b14fbac01771fab7a9749b9209ff Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 14:26:40 -0400 Subject: [PATCH 04/16] feat(dashboard): tmux split-pane helpers + Enter dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRD 0021 chunk 2. New tmux integration: when `\$TMUX` is set and the operator presses Enter on a focused agent row, the dashboard spawns / respawns the right pane with that bottle's claude session instead of taking over the terminal via curses.endwin. Mechanics: - `_in_tmux()` — true when `\$TMUX` is set. - `_tmux_split_pane_create` — first attach: `tmux split-window -h -P -F '#{pane_id}'` opens a right pane and prints its id for tracking. - `_tmux_respawn_pane` — subsequent attaches: `tmux respawn-pane -k -t ` swaps the content without re-splitting. - `_tmux_pane_exists` — `tmux list-panes` check before respawn so a manually-closed pane gracefully falls back to a fresh split. - `_attach_in_tmux` — owns the create-or-respawn state machine, mutates `tmux_state` ({pane_id, slug}) so the main loop tracks the right-pane occupant. - `_attach_via_handoff` — the previous curses-endwin path, extracted as the fallback when tmux is missing or fails. - `_attach_to_bottle` dispatches: in tmux + state available → `_attach_in_tmux`; otherwise → handoff. Main loop gets `tmux_state: dict = {"pane_id": None, "slug": None}`. Chunks 3 + 4 wire it through the new-agent flow and the stop hook. `FileNotFoundError`-safe `subprocess.run` calls around every tmux invocation — a missing tmux binary cleanly falls back to the handoff for that keypress. 478 unit tests pass (10 new for the pure argv builders + `_claude_runtime_args`). --- claude_bottle/cli/dashboard.py | 185 +++++++++++++++++++-- tests/unit/test_dashboard_active_agents.py | 59 +++++++ 2 files changed, 232 insertions(+), 12 deletions(-) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index 04faefd..47b5a25 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -690,21 +690,123 @@ def _stop_bottle_flow( 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_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] + + +def _tmux_split_pane_create(bottle, *, resume: bool) -> str | None: + """Open a right pane via `tmux split-window -h`. Returns the + new pane's id on success, None on any failure (tmux missing, + nonzero exit, empty stdout).""" + docker_argv = bottle.claude_docker_argv( + _claude_runtime_args(resume=resume), + ) + try: + result = subprocess.run( + _build_split_pane_argv(docker_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, bottle, *, resume: bool) -> bool: + """Replace the content of `pane_id` with a fresh claude + session via `tmux respawn-pane -k`. Returns True on success.""" + docker_argv = bottle.claude_docker_argv( + _claude_runtime_args(resume=resume), + ) + try: + result = subprocess.run( + _build_respawn_pane_argv(pane_id, docker_argv), + capture_output=True, text=True, check=False, + ) + except FileNotFoundError: + return False + return result.returncode == 0 + + +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,6 +814,58 @@ 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, +) -> 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 in chunk 4).""" + pane_id = tmux_state.get("pane_id") + if pane_id and _tmux_pane_exists(pane_id): + if _tmux_respawn_pane(pane_id, bottle, resume=resume): + tmux_state["slug"] = slug + return f"[{slug}] in right pane" + # respawn failed — fall through to create a fresh split. + tmux_state["pane_id"] = None + + new_pane_id = _tmux_split_pane_create(bottle, resume=resume) + if new_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["pane_id"] = new_pane_id + tmux_state["slug"] = slug + return f"[{slug}] in right pane" + + +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: + return _attach_in_tmux( + stdscr, bottle, slug, resume=True, tmux_state=tmux_state, + ) + return _attach_via_handoff(stdscr, bottle, slug, resume=True) + + def _new_agent_flow( stdscr: "curses._CursesWindow", manifest: Manifest, @@ -912,9 +1066,14 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: # 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). @@ -1010,7 +1169,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: diff --git a/tests/unit/test_dashboard_active_agents.py b/tests/unit/test_dashboard_active_agents.py index 11486c6..000cc2a 100644 --- a/tests/unit/test_dashboard_active_agents.py +++ b/tests/unit/test_dashboard_active_agents.py @@ -379,6 +379,65 @@ class TestBottleForSlug(unittest.TestCase): self.assertEqual("", hint) +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 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 From 4991d5b3ee997ca1a2f2f81b75d0cd3a7c151db2 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 14:27:37 -0400 Subject: [PATCH 05/16] feat(dashboard): new-agent flow spawns into right tmux pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRD 0021 chunk 3. The `n` flow (PRD 0020 chunk 2) now routes the first claude session of a freshly-started bottle into the right tmux pane when `\$TMUX` is set — same `_attach_in_tmux` state machine the Enter re-attach uses, just with `resume=False` so claude starts fresh. Outside tmux the existing foreground handoff is unchanged. The compose-up phase (`backend.launch.__enter__`) still drops curses for its stderr output; we restore curses BEFORE spawning into the right pane so the dashboard re-renders alongside the new claude session instead of waiting for attach to return. --- claude_bottle/cli/dashboard.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index 47b5a25..b35db65 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -871,12 +871,14 @@ def _new_agent_flow( 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: @@ -915,8 +917,10 @@ def _new_agent_flow( backend = get_bottle_backend() # 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 — the attach itself may stay out of curses (in-tmux + # spawns into the right pane and returns) or take over + # the terminal (foreground handoff). curses.endwin() try: cm = backend.launch(plan) @@ -927,6 +931,18 @@ def _new_agent_flow( raise bottles[plan.slug] = (cm, bottle, identity) + if _in_tmux() and tmux_state is not None: + # Refresh curses BEFORE spawning into the right pane so + # the dashboard re-renders alongside the new claude + # session. + stdscr.refresh() + return _attach_in_tmux( + stdscr, bottle, plan.slug, + resume=False, tmux_state=tmux_state, + ) + + # 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) @@ -1137,7 +1153,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 From 2ba84c5ba0648b52f976166c2b6aba3ab7bc63d9 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 14:29:59 -0400 Subject: [PATCH 06/16] feat(dashboard): stop hook clears tmux state + right-pane row marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRD 0021 chunk 4 (final). Two adjustments to close the split-pane loop: 1. `_stop_bottle_flow` clears `tmux_state['slug']` when the stopped bottle was the right-pane occupant. The pane itself stays in place (claude exits with "container not found"); the operator presses Enter on a different agent to repurpose it via respawn-pane. 2. `_render` accepts `right_pane_slug` and marks the matching agents-pane row with a `*` prefix + A_BOLD (when it's not also the focused row — focused selection still wins for visibility). Gives the operator a clear visual link between which agent the dashboard says is "active right now" and which one is visible to their right. Wired through `_main_loop`: passes `tmux_state` to `_stop_bottle_flow` on `x`, and `tmux_state.get('slug')` to `_render` on every tick. 479 unit tests pass (1 new for the tmux_state-preservation on non-owned stop). PRD 0021 implementation complete pending merge. --- claude_bottle/cli/dashboard.py | 22 +++++++++++++++++++++- tests/unit/test_dashboard_active_agents.py | 14 ++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index b35db65..bede6d1 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -654,13 +654,21 @@ 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" @@ -687,6 +695,8 @@ def _stop_bottle_flow( 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" @@ -1120,6 +1130,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, ) @@ -1197,6 +1208,7 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: else: status_line = _stop_bottle_flow( stdscr, bottles, target.slug, + tmux_state=tmux_state, ) continue @@ -1244,6 +1256,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, @@ -1325,11 +1338,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/tests/unit/test_dashboard_active_agents.py b/tests/unit/test_dashboard_active_agents.py index 000cc2a..10abad4 100644 --- a/tests/unit/test_dashboard_active_agents.py +++ b/tests/unit/test_dashboard_active_agents.py @@ -455,6 +455,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 From 83ec9669c9de37a6ce77174c67d8404b69b8924f Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 14:41:53 -0400 Subject: [PATCH 07/16] feat(dashboard): route launch output into right tmux pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRD 0021 follow-up. When starting a new agent via `n` while in tmux, the dashboard now: 1. Pre-creates the right pane with `tail -F state//bringup.log`. 2. Redirects fd 2 (stderr) to that log file via dup2 — affects both Python `info()` calls AND subprocess inheritors' stderr (docker compose up, network creates, provision). 3. Runs `backend.launch().__enter__()` with the redirect in place; everything streams into the right pane via tail. 4. Restores stderr. 5. Respawns the right pane (tail → claude session). Net effect: dashboard pane stays uncluttered during bringup, and the operator watches the compose-up + provision output in the same pane that's about to hold the claude session — no visual handoff between "starting" and "started." Curses never needs to come down on the tmux path (the pane is already created in the dashboard's neighbor pane, and stderr is redirected away from the terminal entirely). If `_tmux_split_pane_tail` fails (tmux missing, server died), falls through to the existing curses-endwin handoff so the operator still gets a session. --- claude_bottle/cli/dashboard.py | 97 ++++++++++++++++++++++++++++------ 1 file changed, 82 insertions(+), 15 deletions(-) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index bede6d1..180e96f 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -12,6 +12,7 @@ chunk 3) writes routes.yaml + SIGHUPs egress; PRD 0015 from __future__ import annotations import argparse +import contextlib import curses import os import shutil @@ -29,7 +30,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, @@ -749,6 +750,52 @@ def _build_respawn_pane_argv(pane_id: str, docker_argv: list[str]) -> list[str]: 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_tail(log_path) -> str | None: + """Pre-create the right pane tailing `log_path` so the + `_new_agent_flow` launch step's redirected stderr streams + into it. Returns the new pane's id or None on failure. + The pane is later respawned with the claude session via + `_tmux_respawn_pane`.""" + argv = _build_split_pane_argv(["tail", "-F", str(log_path)]) + try: + result = subprocess.run( + argv, capture_output=True, text=True, check=False, + ) + except FileNotFoundError: + return None + if result.returncode != 0: + return None + return (result.stdout or "").strip() or None + + def _tmux_split_pane_create(bottle, *, resume: bool) -> str | None: """Open a right pane via `tmux split-window -h`. Returns the new pane's id on success, None on any failure (tmux missing, @@ -925,12 +972,42 @@ def _new_agent_flow( return f"start of {picked!r} aborted at preflight" backend = get_bottle_backend() + + if _in_tmux() and tmux_state is not None: + # PRD 0021 follow-up: pre-create the right pane tailing + # state//bringup.log, redirect fd 2 to that log + # during launch, then respawn the pane with claude. + # Net effect: compose-up + provision output streams into + # the right pane (where claude will live), the dashboard + # pane stays uncluttered, and curses doesn't need to be + # taken down at all. + log_path = bottle_state_dir(plan.slug) / "bringup.log" + log_path.parent.mkdir(parents=True, exist_ok=True) + log_path.write_text("") # empty so tail starts clean + pane_id = _tmux_split_pane_tail(log_path) + if pane_id is not None: + tmux_state["pane_id"] = pane_id + tmux_state["slug"] = plan.slug + try: + with _redirect_stderr_to_file(log_path): + cm = backend.launch(plan) + bottle = cm.__enter__() + except BaseException: + settle_state(identity) + raise + bottles[plan.slug] = (cm, bottle, identity) + # Respawn the right pane: tail → claude session. + return _attach_in_tmux( + stdscr, bottle, plan.slug, + resume=False, tmux_state=tmux_state, + ) + # pane creation failed (no tmux binary, server died) → + # fall through to the curses-endwin handoff so the + # operator still gets a session. + # Launch step writes to stderr (image build, network create, # compose up). Get out of curses' way for the duration so - # the lines render cleanly; restore curses immediately - # after — the attach itself may stay out of curses (in-tmux - # spawns into the right pane and returns) or take over - # the terminal (foreground handoff). + # the lines render cleanly; restore curses immediately after. curses.endwin() try: cm = backend.launch(plan) @@ -941,16 +1018,6 @@ def _new_agent_flow( raise bottles[plan.slug] = (cm, bottle, identity) - if _in_tmux() and tmux_state is not None: - # Refresh curses BEFORE spawning into the right pane so - # the dashboard re-renders alongside the new claude - # session. - stdscr.refresh() - return _attach_in_tmux( - stdscr, bottle, plan.slug, - resume=False, tmux_state=tmux_state, - ) - # Foreground handoff: claude owns the terminal until exit, # then we restore curses. try: From 0936c40428ca2a99a4181a6deccf7c5a4fc73a0c Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 14:50:56 -0400 Subject: [PATCH 08/16] fix(dashboard): reuse existing right pane on new-agent start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRD 0021 follow-up. The new-agent flow was calling a dedicated `_tmux_split_pane_tail` that ALWAYS created a new pane — so every `n` start spawned a fresh right pane next to any existing one, accumulating panes instead of reusing them. Replace with a generic `_ensure_right_pane(tmux_state, argv)` that respawns the dashboard's tracked right pane if one is alive, splits a new one only when none is tracked or the tracked pane was closed. Both the new-agent tail-during- bringup path AND the existing claude-attach path now route through this helper. Net effect: starting a second agent reuses the same right pane — bringup tail replaces the prior claude session, then claude (for the new agent) replaces the tail. Closing the right pane manually via `C-b x` still triggers a fresh split on the next attach. --- claude_bottle/cli/dashboard.py | 111 +++++++++++++++++---------------- 1 file changed, 56 insertions(+), 55 deletions(-) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index 180e96f..d2962b0 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -778,34 +778,15 @@ def _redirect_stderr_to_file(path): os.close(log_fd) -def _tmux_split_pane_tail(log_path) -> str | None: - """Pre-create the right pane tailing `log_path` so the - `_new_agent_flow` launch step's redirected stderr streams - into it. Returns the new pane's id or None on failure. - The pane is later respawned with the claude session via - `_tmux_respawn_pane`.""" - argv = _build_split_pane_argv(["tail", "-F", str(log_path)]) +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( - argv, capture_output=True, text=True, check=False, - ) - except FileNotFoundError: - return None - if result.returncode != 0: - return None - return (result.stdout or "").strip() or None - - -def _tmux_split_pane_create(bottle, *, resume: bool) -> str | None: - """Open a right pane via `tmux split-window -h`. Returns the - new pane's id on success, None on any failure (tmux missing, - nonzero exit, empty stdout).""" - docker_argv = bottle.claude_docker_argv( - _claude_runtime_args(resume=resume), - ) - try: - result = subprocess.run( - _build_split_pane_argv(docker_argv), + _build_split_pane_argv(argv), capture_output=True, text=True, check=False, ) except FileNotFoundError: @@ -816,15 +797,14 @@ def _tmux_split_pane_create(bottle, *, resume: bool) -> str | None: return pane_id or None -def _tmux_respawn_pane(pane_id: str, bottle, *, resume: bool) -> bool: - """Replace the content of `pane_id` with a fresh claude - session via `tmux respawn-pane -k`. Returns True on success.""" - docker_argv = bottle.claude_docker_argv( - _claude_runtime_args(resume=resume), - ) +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, docker_argv), + _build_respawn_pane_argv(pane_id, argv), capture_output=True, text=True, check=False, ) except FileNotFoundError: @@ -832,6 +812,29 @@ def _tmux_respawn_pane(pane_id: str, bottle, *, resume: bool) -> bool: return result.returncode == 0 +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 @@ -883,22 +886,16 @@ def _attach_in_tmux( 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 in chunk 4).""" - pane_id = tmux_state.get("pane_id") - if pane_id and _tmux_pane_exists(pane_id): - if _tmux_respawn_pane(pane_id, bottle, resume=resume): - tmux_state["slug"] = slug - return f"[{slug}] in right pane" - # respawn failed — fall through to create a fresh split. - tmux_state["pane_id"] = None - - new_pane_id = _tmux_split_pane_create(bottle, resume=resume) - if new_pane_id is None: + explicit-stop hook).""" + docker_argv = bottle.claude_docker_argv( + _claude_runtime_args(resume=resume), + ) + 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["pane_id"] = new_pane_id tmux_state["slug"] = slug return f"[{slug}] in right pane" @@ -974,19 +971,23 @@ def _new_agent_flow( backend = get_bottle_backend() if _in_tmux() and tmux_state is not None: - # PRD 0021 follow-up: pre-create the right pane tailing - # state//bringup.log, redirect fd 2 to that log - # during launch, then respawn the pane with claude. - # Net effect: compose-up + provision output streams into - # the right pane (where claude will live), the dashboard - # pane stays uncluttered, and curses doesn't need to be - # taken down at all. + # PRD 0021 follow-up: route the bringup output into + # the right pane. `_ensure_right_pane` reuses any + # existing right pane via respawn — so a second + # new-agent start doesn't pile up a fresh pane — and + # only splits when no pane is tracked or the tracked + # one was closed. fd 2 is redirected to the same log + # the tail is following, so both Python `info()` + # writes AND subprocess (docker compose up, network + # creates, provision) stderr stream into the right + # pane. Curses stays up the whole time. log_path = bottle_state_dir(plan.slug) / "bringup.log" log_path.parent.mkdir(parents=True, exist_ok=True) log_path.write_text("") # empty so tail starts clean - pane_id = _tmux_split_pane_tail(log_path) + pane_id = _ensure_right_pane( + tmux_state, ["tail", "-F", str(log_path)], + ) if pane_id is not None: - tmux_state["pane_id"] = pane_id tmux_state["slug"] = plan.slug try: with _redirect_stderr_to_file(log_path): @@ -996,7 +997,7 @@ def _new_agent_flow( settle_state(identity) raise bottles[plan.slug] = (cm, bottle, identity) - # Respawn the right pane: tail → claude session. + # Respawn the same pane: tail → claude session. return _attach_in_tmux( stdscr, bottle, plan.slug, resume=False, tmux_state=tmux_state, From e90d7dba769f55f265fed766f01bf2338a928774 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 15:01:56 -0400 Subject: [PATCH 09/16] fix(dashboard): repaint stdscr immediately after modal closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the operator pressed `y` on the preflight modal (or picked an agent in the picker), the modal's curses sub-window stayed on screen until the dashboard's main loop ticked again — which during a 5-10s launch made it look like the confirmation never registered. Add `_erase_modal` (touchwin + refresh on stdscr) and call it at every exit from `_preflight_modal` and `_picker_modal`. The pre-modal frame buffered on stdscr immediately overwrites the sub-window's area; the launch proceeds with a clean dashboard underneath. --- claude_bottle/cli/dashboard.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index d2962b0..8aae861 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -445,6 +445,7 @@ def _picker_modal( try: key = stdscr.getch() except KeyboardInterrupt: + _erase_modal(stdscr) return None if key == 27: # Esc @@ -452,9 +453,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 @@ -578,13 +581,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 From 933d8cf6c3d2c67457d2bcc6aa94c5c9123fb037 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 15:08:49 -0400 Subject: [PATCH 10/16] feat(dashboard): route stop output into right tmux pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRD 0021 follow-up. Mirrors the bringup-into-right-pane fix on the explicit-stop path: when `\$TMUX` is set, the stop flow respawns the right pane with `tail -F state//teardown.log` (via `_ensure_right_pane` — reuses the existing right pane if it's the agent's claude session) and redirects fd 2 to that log for the duration of `capture_session_state` + `cm.__exit__`. compose-down + network-remove messages stream into the right pane. After `settle_state` removes the state dir, the tail keeps its buffered output visible (tail -F handles file removal gracefully); the next attach respawns the pane with claude. Falls back to the existing curses-endwin path on tmux failure, or when the dashboard isn't in tmux at all. --- claude_bottle/cli/dashboard.py | 41 ++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index 8aae861..ccb0205 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -693,10 +693,8 @@ def _stop_bottle_flow( 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. @@ -711,6 +709,41 @@ def _stop_bottle_flow( cm.__exit__(None, None, None) except BaseException: pass + + if _in_tmux() and tmux_state is not None: + # Mirror the bringup path: route compose-down + state- + # settle output into the right pane via `tail -F` + fd-2 + # redirect. Reuses any existing right pane (which is + # probably the agent's own claude session) via + # _ensure_right_pane → respawn-pane. Tail-F handles the + # state-dir-being-removed-mid-tail case gracefully. + log_path = bottle_state_dir(slug) / "teardown.log" + log_path.parent.mkdir(parents=True, exist_ok=True) + log_path.write_text("") + pane_id = _ensure_right_pane( + tmux_state, ["tail", "-F", str(log_path)], + ) + if pane_id is not None: + tmux_state["slug"] = slug + try: + with _redirect_stderr_to_file(log_path): + _do_teardown() + except BaseException: + pass + settle_state(identity) + # Right pane keeps tailing the (now-removed) log — its + # final buffered output stays visible until the next + # attach respawns it. + tmux_state["slug"] = None + return f"[{slug}] stopped" + # tmux failed; fall through to the curses-endwin path. + + # 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) From 9646bc1c4cecd66f8d39ba6add88eb53404e3a2d Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 15:13:20 -0400 Subject: [PATCH 11/16] refactor(dashboard): extract _route_op_to_right_pane helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both `_new_agent_flow` (bringup) and `_stop_bottle_flow` (teardown) were doing the same five-step dance: open the log path, mkdir parents, empty the file, ensure the right pane is tailing it, redirect fd 2 to the same file. Extract into a context manager: with _route_op_to_right_pane(tmux_state, slug, log_name) as routed: if routed: Yields True when routing succeeded (fd 2 redirected, pane tailing), False on fallback conditions (not in tmux, no tmux_state, or tmux failed to spawn a pane). The fallback paths still differ between callers — bringup follows up with `_attach_in_tmux`, teardown does the curses-endwin compose-down — so the helper stops at "is stderr routed or not" and lets callers branch from there. Net diff: ~60 lines deleted, the routing-to-right-pane concept now lives in one place. --- claude_bottle/cli/dashboard.py | 134 ++++++++++++++++++--------------- 1 file changed, 74 insertions(+), 60 deletions(-) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index ccb0205..5d8bf6b 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -710,33 +710,24 @@ def _stop_bottle_flow( except BaseException: pass - if _in_tmux() and tmux_state is not None: - # Mirror the bringup path: route compose-down + state- - # settle output into the right pane via `tail -F` + fd-2 - # redirect. Reuses any existing right pane (which is - # probably the agent's own claude session) via - # _ensure_right_pane → respawn-pane. Tail-F handles the - # state-dir-being-removed-mid-tail case gracefully. - log_path = bottle_state_dir(slug) / "teardown.log" - log_path.parent.mkdir(parents=True, exist_ok=True) - log_path.write_text("") - pane_id = _ensure_right_pane( - tmux_state, ["tail", "-F", str(log_path)], - ) - if pane_id is not None: - tmux_state["slug"] = slug - try: - with _redirect_stderr_to_file(log_path): - _do_teardown() - except BaseException: - pass - settle_state(identity) - # Right pane keeps tailing the (now-removed) log — its - # final buffered output stays visible until the next - # attach respawns it. + # 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" - # tmux failed; fall through to the curses-endwin path. + return f"[{slug}] stopped" # Non-tmux: compose-down output writes to the dashboard's # terminal directly. Drop curses so the lines render cleanly, @@ -863,6 +854,42 @@ def _tmux_respawn_pane(pane_id: str, argv: list[str]) -> bool: 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 _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 @@ -1021,41 +1048,28 @@ def _new_agent_flow( backend = get_bottle_backend() - if _in_tmux() and tmux_state is not None: - # PRD 0021 follow-up: route the bringup output into - # the right pane. `_ensure_right_pane` reuses any - # existing right pane via respawn — so a second - # new-agent start doesn't pile up a fresh pane — and - # only splits when no pane is tracked or the tracked - # one was closed. fd 2 is redirected to the same log - # the tail is following, so both Python `info()` - # writes AND subprocess (docker compose up, network - # creates, provision) stderr stream into the right - # pane. Curses stays up the whole time. - log_path = bottle_state_dir(plan.slug) / "bringup.log" - 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)], + # 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) + return _attach_in_tmux( + stdscr, bottle, plan.slug, + resume=False, tmux_state=tmux_state, ) - if pane_id is not None: - tmux_state["slug"] = plan.slug - try: - with _redirect_stderr_to_file(log_path): - cm = backend.launch(plan) - bottle = cm.__enter__() - except BaseException: - settle_state(identity) - raise - bottles[plan.slug] = (cm, bottle, identity) - # Respawn the same pane: tail → claude session. - return _attach_in_tmux( - stdscr, bottle, plan.slug, - resume=False, tmux_state=tmux_state, - ) - # pane creation failed (no tmux binary, server died) → - # fall through to the curses-endwin handoff so the - # operator still gets a session. # Launch step writes to stderr (image build, network create, # compose up). Get out of curses' way for the duration so From 9622bdc619621b307b775b284ea6bd7469fc6cac Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 15:16:06 -0400 Subject: [PATCH 12/16] feat(dashboard): default focus to agents pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard is primarily an agent-management surface (PRD 0020 + 0021); landing on the proposals pane was a holdover from when proposals were the only thing the dashboard showed. Default focus is now `PANE_AGENTS`, so j/k navigates the agents list immediately on launch — the operator Tabs to proposals when something queues. Focus choice still persists across operations. --- claude_bottle/cli/dashboard.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index 5d8bf6b..9d43c64 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -1220,7 +1220,11 @@ 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). From 8d6e382af5954b1e983924e81b01577b8d72569d Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 15:21:20 -0400 Subject: [PATCH 13/16] feat(dashboard): auto-focus next agent on stop, or close pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After `x` stops a dashboard-owned bottle, slide focus to the next agent in the agents pane (the one filling the stopped row, or the new last row if the stopped was last) and respawn the right pane with that agent's claude session via `--continue`. If no agents remain, close the right pane via `tmux kill-pane`. Two new helpers: - `_tmux_close_right_pane(tmux_state)` — kills the tracked pane (if it exists) and clears pane_id / slug. - `_pick_next_after_stop(agents_before, selected_index, stopped_slug)` — pure chooser returning (new_index, agent) or None. Tested directly. Outside tmux, only the selected_agent index slides; no auto-attach (foreground handoff would take over the terminal, disruptive). 485 unit tests pass (6 new for the pick helper). --- claude_bottle/cli/dashboard.py | 58 ++++++++++++++++++++++ tests/unit/test_dashboard_active_agents.py | 49 ++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index 9d43c64..6dcff5d 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -890,6 +890,42 @@ def _route_op_to_right_pane( 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 @@ -1347,6 +1383,28 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: 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: diff --git a/tests/unit/test_dashboard_active_agents.py b/tests/unit/test_dashboard_active_agents.py index 10abad4..a202772 100644 --- a/tests/unit/test_dashboard_active_agents.py +++ b/tests/unit/test_dashboard_active_agents.py @@ -379,6 +379,55 @@ 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 From 7e20d75f002f3f76216c615e0a4b67b5fd0b7ab9 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 15:25:22 -0400 Subject: [PATCH 14/16] feat(dashboard): focus right pane on Enter re-attach (in tmux) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Enter key on a focused agents-pane row is the operator's explicit "I want to interact with this agent" signal — after respawning the right pane with claude, move tmux's keyboard focus to that pane so the operator can start typing immediately. Without this, every Enter required a manual tmux nav (C-b →) to actually use the session. Mechanics: - `_attach_in_tmux` gains `focus_right_pane: bool = False`. - When True, runs `tmux select-pane -t ` after the respawn. - `_attach_to_bottle` (the Enter handler's helper) passes True. - Other callers (new-agent flow, stop's auto-attach) leave it False so the operator stays in the dashboard for follow-up navigation. `_tmux_select_pane` is a small subprocess wrapper, best-effort on failure. --- claude_bottle/cli/dashboard.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index 6dcff5d..c82e2a7 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -995,12 +995,19 @@ def _attach_in_tmux( *, 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).""" + 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.""" docker_argv = bottle.claude_docker_argv( _claude_runtime_args(resume=resume), ) @@ -1011,9 +1018,24 @@ def _attach_in_tmux( # 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, @@ -1028,8 +1050,13 @@ def _attach_to_bottle( 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, + stdscr, bottle, slug, + resume=True, tmux_state=tmux_state, + focus_right_pane=True, ) return _attach_via_handoff(stdscr, bottle, slug, resume=True) From 1a1ba6abd55e72f59fb64d86d67e2fa1342be91c Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 15:34:21 -0400 Subject: [PATCH 15/16] fix(dashboard): fall back to fresh claude when --continue has no session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--continue` exits non-zero when an agent has been spun up but never typed at — there's no transcript to resume. Re-attaching to such an agent via Enter (tmux mode) was crashing the pane. Wrap the resume invocation in `sh -c ' --continue || '` so a failed `--continue` cleanly falls through to a fresh claude. The shell adds microseconds and the fallback only kicks in when --continue would have failed anyway. New `_build_resume_argv_with_fallback(bottle)` builds the shell-wrapped docker exec argv with proper shlex quoting (so paths-with-spaces in `--append-system-prompt-file` survive). Only the tmux re-attach path uses it; first-attach + foreground handoff are unchanged. 489 unit tests pass (4 new for the fallback builder). --- claude_bottle/cli/dashboard.py | 46 ++++++++++++++++++++-- tests/unit/test_dashboard_active_agents.py | 46 ++++++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index c82e2a7..9880c82 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -15,6 +15,7 @@ import argparse import contextlib import curses import os +import shlex import shutil import subprocess import sys @@ -773,6 +774,38 @@ def _claude_runtime_args(*, resume: bool, remote_control: bool = False) -> list[ 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 @@ -1008,9 +1041,16 @@ def _attach_in_tmux( 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.""" - docker_argv = bottle.claude_docker_argv( - _claude_runtime_args(resume=resume), - ) + 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). diff --git a/tests/unit/test_dashboard_active_agents.py b/tests/unit/test_dashboard_active_agents.py index a202772..75154fd 100644 --- a/tests/unit/test_dashboard_active_agents.py +++ b/tests/unit/test_dashboard_active_agents.py @@ -463,6 +463,52 @@ class TestTmuxPaneArgvBuilders(unittest.TestCase): 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 From ac914b6cb917fe36c58043f0fe6078dce6a1c719 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 26 May 2026 15:37:07 -0400 Subject: [PATCH 16/16] feat(dashboard): focus right pane after new-agent bringup completes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new-agent (`n`) flow's tmux branch was leaving keyboard focus in the dashboard pane after compose-up + provision finished and claude landed in the right pane — same situation as Enter re-attach before its `focus_right_pane` fix. The operator just spun an agent up; they want to type at it. Pass `focus_right_pane=True` to `_attach_in_tmux` from the new-agent flow. `tmux select-pane` runs after the respawn. --- claude_bottle/cli/dashboard.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index 9880c82..b99353c 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -1169,9 +1169,12 @@ def _new_agent_flow( 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,