diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index 3db7069..3000f69 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -650,6 +650,46 @@ def _bottle_for_slug( return synth, (prompt_path or "") +def _stop_bottle_flow( + stdscr: "curses._CursesWindow", + bottles: dict, + slug: str, +) -> 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`.""" + if slug not in bottles: + return ( + 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: + # 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. + # exit_code=0 → no auto-preserve; the operator's + # existing preserve marker (if any) is honored by + # settle_state below. + try: + capture_session_state(identity, exit_code=0) + except BaseException: + pass + try: + cm.__exit__(None, None, None) + except BaseException: + pass + finally: + stdscr.refresh() + settle_state(identity) + return f"[{slug}] stopped" + + def _attach_to_bottle( stdscr: "curses._CursesWindow", bottle, @@ -927,10 +967,8 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: if focus == PANE_AGENTS: # j/k/arrow navigate the agents list. Enter re-attaches - # to the focused bottle (PRD 0020 chunk 3) — works for - # both dashboard-owned bottles and bottles discovered - # via `list_active_slugs` (the synth path resolves the - # in-container claude target from the slug). + # (PRD 0020 chunk 3); `x` explicitly stops a + # dashboard-owned bottle (chunk 4). if key in (curses.KEY_DOWN, ord("j")): selected_agent = min(selected_agent + 1, max(0, len(agents) - 1)) elif key in (curses.KEY_UP, ord("k")): @@ -943,6 +981,14 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: 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) + elif key == ord("x"): + target = _selected_agent(focus, agents, selected_agent) + if target is None: + status_line = "no agent selected" + else: + status_line = _stop_bottle_flow( + stdscr, bottles, target.slug, + ) continue if not pending: @@ -1082,7 +1128,7 @@ def _render( footer = ( "[n] new [Tab] switch [j/k] move " - "[Enter] view/attach [a/m/r] proposal [e/p] edit [q] quit" + "[Enter] view/attach [x] stop [a/m/r] proposal [e/p] edit [q] quit" ) stdscr.hline(h - 2, 0, curses.ACS_HLINE, w) stdscr.addnstr(h - 1, 0, footer, w - 1, curses.A_DIM) diff --git a/tests/unit/test_dashboard_active_agents.py b/tests/unit/test_dashboard_active_agents.py index 331e595..11486c6 100644 --- a/tests/unit/test_dashboard_active_agents.py +++ b/tests/unit/test_dashboard_active_agents.py @@ -379,6 +379,24 @@ class TestBottleForSlug(unittest.TestCase): self.assertEqual("", hint) +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 + owned path drives `cm.__exit__` against a real launch context + and belongs in integration tests.""" + + def test_non_owned_returns_cleanup_hint(self): + # stdscr is None here on purpose — the non-owned branch + # returns before any curses call. + msg = dashboard._stop_bottle_flow( + stdscr=None, # type: ignore[arg-type] + bottles={}, + slug="ghost-zzz", + ) + self.assertIn("not dashboard-owned", msg) + self.assertIn("./cli.py cleanup", msg) + + class TestOperatorEditFlowGuards(_FakeHomeMixin, unittest.TestCase): """Chunk-4 contract: the edit flow refuses when the selected agent doesn't have the required sidecar running. The discover-