diff --git a/claude_bottle/backend/docker/bottle.py b/claude_bottle/backend/docker/bottle.py index 0a1b781..84bb1a9 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 new-window` 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/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index 04faefd..d3f9e1c 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -690,18 +690,60 @@ def _stop_bottle_flow( return f"[{slug}] stopped" +def _in_tmux() -> bool: + """True when the dashboard is running inside a tmux session. + Tmux sets `$TMUX` to the path of its server socket; if it's + set, we can shell out to `tmux new-window` to spawn claude + sessions as siblings of the dashboard pane.""" + return bool(os.environ.get("TMUX")) + + +def _build_tmux_attach_argv(docker_argv: list[str], slug: str) -> list[str]: + """Wrap a docker-exec argv with `tmux new-window -n ` so + the spawned claude session lives in its own tmux window + named after the bottle. Pure function — easy to unit-test + without shelling out to tmux.""" + return ["tmux", "new-window", "-n", slug, *docker_argv] + + +def _attach_via_tmux( + bottle, + slug: str, + *, + resume: bool, + remote_control: bool = False, +) -> str: + """Spawn claude inside `bottle` in a new tmux window. Returns + immediately — the dashboard stays running in its current + tmux pane and the operator switches to the new window via + tmux's normal nav (default `C-b n`).""" + claude_args = ["--dangerously-skip-permissions"] + if remote_control: + claude_args.append("--remote-control") + if resume: + claude_args.append("--continue") + docker_argv = bottle.claude_docker_argv(claude_args) + argv = _build_tmux_attach_argv(docker_argv, slug) + result = subprocess.run( + argv, capture_output=True, text=True, check=False, + ) + if result.returncode != 0: + err = (result.stderr or "").strip() or "unknown tmux error" + return f"tmux new-window failed: {err}" + return f"[{slug}] opened in new tmux window" + + def _attach_to_bottle( stdscr: "curses._CursesWindow", bottle, slug: str, ) -> 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.""" + """Re-attach to a running bottle. In tmux mode the claude + session opens in a new tmux window (dashboard keeps running + in this pane); without tmux it's a curses-endwin handoff that + blocks until the operator exits claude.""" + if _in_tmux(): + return _attach_via_tmux(bottle, slug, resume=True) curses.endwin() try: exit_code = attach_claude(bottle, remote_control=False, resume=True) @@ -761,8 +803,7 @@ 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. curses.endwin() try: cm = backend.launch(plan) @@ -773,6 +814,13 @@ def _new_agent_flow( raise bottles[plan.slug] = (cm, bottle, identity) + if _in_tmux(): + # Bottle is up; spawn claude in a new tmux window and + # let the dashboard come back to the foreground in this + # pane. First-attach so no `--continue`. + stdscr.refresh() + return _attach_via_tmux(bottle, plan.slug, resume=False) + try: exit_code = attach_claude(bottle, remote_control=False) capture_session_state(identity, exit_code) diff --git a/tests/unit/test_dashboard_active_agents.py b/tests/unit/test_dashboard_active_agents.py index 11486c6..2878073 100644 --- a/tests/unit/test_dashboard_active_agents.py +++ b/tests/unit/test_dashboard_active_agents.py @@ -379,6 +379,29 @@ class TestBottleForSlug(unittest.TestCase): self.assertEqual("", hint) +class TestTmuxAttachArgv(unittest.TestCase): + """Pure builder for the tmux new-window argv. The subprocess + invocation is environment-dependent; here we lock the argv + shape so a regression in the wrapping surfaces in CI.""" + + def test_wraps_docker_argv_with_named_new_window(self): + docker_argv = [ + "docker", "exec", "-it", + "claude-bottle-dev-abc", + "claude", "--dangerously-skip-permissions", "--continue", + ] + argv = dashboard._build_tmux_attach_argv(docker_argv, "dev-abc") + self.assertEqual( + ["tmux", "new-window", "-n", "dev-abc", *docker_argv], + argv, + ) + + def test_slug_lands_in_window_name_slot(self): + argv = dashboard._build_tmux_attach_argv(["docker", "exec", "x"], "my-bottle") + self.assertEqual("-n", argv[2]) + self.assertEqual("my-bottle", argv[3]) + + 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