fix(dashboard): reuse existing right pane on new-agent start
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m13s

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.
This commit is contained in:
2026-05-26 14:50:56 -04:00
parent 83ec9669c9
commit 0936c40428
+56 -55
View File
@@ -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/<slug>/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,