feat(dashboard): route launch output into right tmux pane
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/<slug>/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.
This commit is contained in:
@@ -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/<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.
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user