From 8a09e32fcc6a9c5cb0caa6cb001b9b8f97c5e39f Mon Sep 17 00:00:00 2001 From: didericis Date: Wed, 3 Jun 2026 13:15:05 -0400 Subject: [PATCH 01/12] docs(prd): add PRD 0050 -- strip dashboard to supervisor tui --- .../0050-strip-dashboard-to-supervisor-tui.md | 346 ++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 docs/prds/0050-strip-dashboard-to-supervisor-tui.md diff --git a/docs/prds/0050-strip-dashboard-to-supervisor-tui.md b/docs/prds/0050-strip-dashboard-to-supervisor-tui.md new file mode 100644 index 0000000..8d17173 --- /dev/null +++ b/docs/prds/0050-strip-dashboard-to-supervisor-tui.md @@ -0,0 +1,346 @@ + +- **Status:** Draft +- **Author:** didericis +- **Created:** 2026-06-03 +- **Issue:** #174 + +## Summary + +The `./cli.py dashboard` command has grown from its PRD 0013 roots +(triage supervise proposals) into a parallel-agent control surface +(PRDs 0019/0020/0021): an active-agents pane, agent picker + start, +re-attach, per-bottle stop, tmux split-pane handoff, operator- +initiated `routes`/`pipelock` edits. Each chunk is reasonable on its +own; together they make the dashboard the largest CLI file in the +repo and the thing most likely to break on a rough edge (curses / +tmux / docker-exec / metadata-discovery interactions). + +This PRD reverses that scope creep. The dashboard is reduced to the +**supervise-plane triage TUI** it was in PRDs 0013–0016: list pending +proposals, approve / modify / reject each one, write audit entries, +deliver the response that unblocks the agent's tool call. Everything +that's about *starting / re-entering / stopping* bottles, or about +*operator-initiated* config edits, comes out. The command is renamed +`./cli.py supervise` so the name matches what it does after the cut. + +Future agent-management UX is explicitly punted: if and when a +control surface for parallel agents resurfaces, the working +assumption (per the issue) is that a web GUI — usable from mobile +— is a better second pass than another round of curses iteration. +That decision is not in this PRD's scope; this PRD only removes the +half-built local-curses path so we stop maintaining it. + +## Problem + +Three concrete pains, all downstream of the dashboard's growth: + +1. **Surface area vs. polish.** `dashboard.py` is ~1740 lines; + `dashboard_model.py` adds another ~420. The interactions among + curses, modals, tmux split-pane, docker-exec handoff, agent + provider templates, metadata-driven re-attach, and + ExitStack-free bottle ownership are intricate enough that + shipping the next polish increment costs more than it returns. +2. **No clear ownership of "starts and stops bottles".** Today + that responsibility is split: `./cli.py start` owns one-shot + sessions; the dashboard owns multi-session bottles it started + itself; `./cli.py cleanup` owns everything else. The dashboard + tracking its own `bottles: dict[str, (cm, bottle, identity)]` + that doesn't survive a quit is a confusing third lane. +3. **Wrong target shape for a "manage many agents" UI.** The + parallel-agent experience the dashboard reaches for is mobile- + meaningful — checking in on agents from a phone is the high- + value case — and curses inside an SSH session is the wrong + tool for that. Continuing to polish a local-only TUI delays + the right next investment. + +The triage half of the dashboard isn't suffering from any of these. +Pending proposals are a small, well-scoped, real workload, and the +PRD 0013–0016 surface for handling them is the right shape. The +problem is everything that got bolted onto that core after. + +## Goals / Success Criteria + +1. The supervise TUI starts up, lists pending proposals across all + running bottles, and supports approve / modify / reject + the + `--once` non-interactive mode — exactly as PRDs 0013–0016 + specified, minus everything 0019/0020/0021 added. +2. The CLI subcommand is renamed `supervise` (was `dashboard`). The + old name is not aliased — this PRD is intentionally a + compat/breaking change (the issue carries the + `Compat/Breaking` label). +3. `dashboard.py` shrinks to a single proposal-triage curses loop: + no agents pane, no Tab pane switching, no agent picker, no + start / re-attach / stop verbs, no tmux split-pane, no + `e`/`p` operator-edit verbs, no per-process `bottles` dict. +4. `dashboard_model.py` is collapsed into whatever + `supervise.py` (CLI) needs; the model module is removed if it + has no purpose after the cut. +5. The proposal-side apply paths in `bot_bottle/backend/docker/ + egress_apply.py`, `pipelock_apply.py`, and `capability_apply.py` + are unchanged — they are still called by the approve path. +6. The supervise-sidecar / proposal-queue protocol (PRD 0013) is + unchanged: the agent's experience is identical. +7. The previously-active PRDs that this one undoes are marked + `Superseded by PRD 0049`: + - PRD 0019 — active-agents pane + agent-scoped edit verbs + - PRD 0020 — start / re-attach / stop from the dashboard + - PRD 0021 — tmux split-pane + +## Non-goals + +- **A web GUI for managing agents.** The issue floats this as a + second pass; this PRD does not design or commit to it. The cut + is "remove the path we no longer want to invest in", not + "build the replacement". +- **A separate CLI for operator-initiated routes / pipelock + edits.** Today those edits live as `e` / `p` keys inside the + dashboard. After this PRD they don't exist anywhere — operators + who need ad-hoc edits use the same path the agents do (call the + supervise tool from inside the bottle) or hand-edit the host- + side files and restart the sidecar. Adding a `./cli.py routes + edit ` verb is a follow-up if the loss bites. +- **Removing `./cli.py start` or changing its semantics.** Start + remains the one-shot launch path. PRD 0020's bottle-outlives- + process model is removed; the only path to a long-running + bottle is `./cli.py start` (foreground) plus `cli.py cleanup` + for teardown. +- **Removing the supervise-sidecar protocol or any of the three + block-remediation engines.** PRDs 0013–0016 stay Active. The + agent's view of the world doesn't change. +- **Renaming `dashboard` anywhere other than the CLI entry + point.** The dashboard-related docs (PRDs, decision records, + research notes) keep their historical references — they + describe the state of the world at the time they were written, + and the Status: Superseded line is the marker that the world + has moved on. +- **Migrating the proposal-queue file layout.** The queue still + lives at `~/.bot-bottle/queue//`; the audit log still + lives at `~/.bot-bottle/audit/-.log`. The CLI + surface changes; the on-disk surface does not. + +## Scope + +### In scope + +- **Rename the subcommand.** `./cli.py dashboard` becomes + `./cli.py supervise`. The module moves from `bot_bottle/cli/ + dashboard.py` to `bot_bottle/cli/supervise.py`. The dispatcher + in `bot_bottle/cli/__init__.py` and the help text both update. +- **Strip the curses loop to proposal-only.** The remaining + surface is: list pending proposals (with the new-arrival bell + + green highlight from PRD 0013), Enter for detail view, + `a`/`m`/`r` for approve / modify / reject, `q` to quit. No + agents pane, no Tab, no agent picker, no `n`/`x`/`e`/`p`, no + tmux dispatch, no `bottles` dict on the main loop. +- **Drop unused helpers.** `_picker_modal`, `_preflight_modal`, + `_backend_picker_modal`, `_new_agent_flow`, `_attach_to_bottle`, + `_attach_in_tmux`, `_attach_via_handoff`, `_tmux_*`, + `_ensure_right_pane`, `_redirect_stderr_to_file`, + `_route_op_to_right_pane`, `_stop_bottle_flow`, + `_operator_edit_*_flow`, `operator_edit_routes`, + `operator_edit_allowlist`, and their imports come out. +- **Collapse the model module.** `dashboard_model.py`'s + proposal-side helpers (`QueuedProposal`, `discover_pending`, + `_approval_status`, `_is_recent`, `_detail_lines`, + `_failed_url_host`, `_proposed_payload_label`, + `_suffix_for_tool`, `_REFRESH_INTERVAL_MS`, + `_NEW_PROPOSAL_HIGHLIGHT_SEC`) move back into + `supervise.py` (CLI) or into `bot_bottle/supervise.py` + (the daemon-side module) — wherever they fit. The agents / + picker / tmux helpers in that module (`PANE_*`, + `_filter_agents`, `_running_counts`, `_format_agent_row`, + `_selection_status`, `_selected_agent`, `_bottle_for_slug`, + `_pick_next_after_stop`, `_agent_runtime_args`, + `_build_resume_argv_with_fallback`, `_build_split_pane_argv`, + `_build_respawn_pane_argv`, `_in_tmux`, + `discover_active_agents`) are deleted. +- **Mark superseded PRDs.** The Status line on PRDs 0019, 0020, + and 0021 changes to `Superseded by [PRD 0049](0049-strip- + dashboard-to-supervisor-tui.md)`. +- **Test cleanup.** Any test that targets a removed surface (the + agent picker, the tmux split helpers, the start-from-dashboard + flow, the operator-edit flows, `discover_active_agents`) + comes out. Tests covering proposal triage stay. +- **Help / usage strings.** `bot_bottle/cli/__init__.py`'s usage + block updates the command name and one-liner. + +### Out of scope + +- Any new feature in the supervise TUI. The cut is purely + subtractive (except for the rename). +- Behavior changes in `./cli.py start`, `cli.py cleanup`, + `cli.py resume`, `cli.py list`, `cli.py info`, `cli.py edit`, + `cli.py init` — unchanged. +- Changes to the supervise sidecar (`supervise_server.py`, + `supervise.py` daemon module). The wire protocol stays. +- Changes to the routes / pipelock / capability apply engines. +- Migration helpers, deprecation warnings, or a transitional + `dashboard` alias for `supervise`. The label on the issue says + Compat/Breaking; the rename is a hard cutover. + +## Proposed design + +### Final shape of the TUI + +After this PRD the `./cli.py supervise` curses surface is: + +``` +bot-bottle supervise (3 pending) +───────────────────────────────────────────────────────── +> 03:14:22 [implementer-cy7a6] egress-block abc123… add +github.com/foo + 03:13:55 [researcher-9xqs1] pipelock-block def456… allow +registry.npmjs.org + 03:13:10 [implementer-cy7a6] capability-block ghi789… install +ripgrep + +───────────────────────────────────────────────────────── +[j/k] move [Enter] view [a] approve [m] modify [r] reject [q] quit +``` + +- One pane. No Tab. `j` / `k` / arrows move through the queue. +- Enter opens the existing detail view (justification + + proposed-file body + the green pipelock host-extraction hint). + `a` / `m` / `r` work from both the list view and the detail + view, same as today. +- `q` / Esc quits. There are no dashboard-owned bottles, so no + per-process teardown decision — `q` just exits. +- The new-arrival bell + green highlight + (if in tmux) the + `tmux select-pane` jump back to the supervise pane stay, + because they're real wins for the operator's "I was typing at + claude and a proposal landed" case. They don't require any of + the pane-management code being removed. + +### Code organisation + +After the cut, the CLI module looks roughly like: + +``` +bot_bottle/cli/supervise.py + - cmd_supervise(argv) + - _list_once() # --once mode + - _main_loop(stdscr) # proposal-only + - _render(stdscr, pending, ...) + - _detail_view(stdscr, qp, ...) + - _modify(stdscr, qp) + - _prompt(stdscr, label) + - _write_crash_log(exc) + - approve(qp, *, notes, final_file) + - reject(qp, *, reason) + - QueuedProposal, discover_pending + - _detail_lines, _approval_status, + _is_recent, _failed_url_host, + _proposed_payload_label, + _suffix_for_tool +``` + +`dashboard_model.py` has no purpose once the agents / picker / +tmux helpers are gone, so it is removed and the surviving +proposal-side helpers move into `supervise.py` directly. The +PRD-0013 refactor that split model out (`refactor: extract +dashboard state/model layer into dashboard_model.py`) was +load-bearing for the bigger dashboard surface; with the surface +shrunk back, the split is no longer justified. + +### Removed PRDs: how to mark them + +The three superseded PRDs keep their bodies intact. Only the +Status line at the top changes: + +``` +- **Status:** Superseded by [PRD +0049](0049-strip-dashboard-to-supervisor-tui.md) +``` + +The PRD's own Goals / Success Criteria are left as the historical +record of what the feature shipped — readers tracing back from the +code or the git log land in a PRD that explains what once was, with +a clear pointer forward. No PRD body is rewritten. + +### Tests to keep, tests to remove + +Keep: +- `tests/cli/test_dashboard*.py` cases that exercise + `discover_pending`, `approve`, `reject`, `_detail_lines`, + `_is_recent`, `_approval_status`, `_failed_url_host`, + `_proposed_payload_label`, `_suffix_for_tool`, + `_modify` / `edit_in_editor`. +- `tests/cli/test_dashboard_once.py` (or equivalent) — the + `--once` listing mode. + +Remove: +- Any test of `_picker_modal`, `_preflight_modal`, + `_backend_picker_modal`, `_new_agent_flow`, `_attach_*`, + `_tmux_*`, `_route_op_to_right_pane`, + `_redirect_stderr_to_file`, `_stop_bottle_flow`, + `_operator_edit_*`, `_filter_agents`, `_running_counts`, + `_format_agent_row`, `_selection_status`, + `_selected_agent`, `_bottle_for_slug`, + `_pick_next_after_stop`, `_agent_runtime_args`, + `_build_*_argv`, `discover_active_agents`. +- The test files that exist solely to cover those (e.g., + `test_dashboard_picker.py`, `test_dashboard_tmux.py`, + `test_dashboard_attach.py`, `test_dashboard_agents.py` — + whichever of these exist after the file walk). + +Files are renamed `test_supervise_*.py` to mirror the module +rename. The rename is mechanical; no test logic changes. + +## Implementation chunks + +Sized for a single PR each. + +1. **Strip + rename in one cut.** Move `bot_bottle/cli/ + dashboard.py` to `bot_bottle/cli/supervise.py`, delete the + removed helpers, delete `dashboard_model.py`, inline the + surviving helpers, update the dispatcher + usage in + `bot_bottle/cli/__init__.py`, rename tests to match, mark + PRDs 0019/0020/0021 as superseded. One commit per logical + piece inside the PR (rename, strip, supersede notes, + tests). +2. **Activate PRD 0049.** Flip this PRD's Status line from + Draft to Active in the same PR as chunk 1 once the + implementation lands. (The repo convention is that a PRD's + shipping commit is also the Status flip — see the recent + `docs(prd): activate PRD 0048…` commit shape.) + +The PR closes issue #174. + +## Open questions + +1. **`e` / `p` operator-initiated edits — gone for good or + moved to a separate CLI verb?** The PRD removes them with no + replacement. The simplest replacement is `./cli.py routes + edit ` and `./cli.py pipelock edit `, sharing + the existing `apply_routes_change` / `apply_allowlist_change` + engines. If the loss is felt within the first parallel + run after this lands, that follow-up is a small PR. Leaving + it for a separate PRD so this one stays subtractive. + +2. **`--once` output shape.** The text listing today emits one + proposal per line. Worth keeping exactly as-is for + scripting consumers; this PRD does not change it. Flagging + only because the rename could tempt a tweak. + +3. **Audit-log entry shape for an unprompted edit applied via + a future `routes edit` CLI verb.** Today's + `operator_edit_routes` writes an `ACTION_OPERATOR_EDIT` + audit entry. With those flows removed the constant has no + callers inside this PRD's scope. Keep the constant exported + from `supervise.py` (it's already an `__all__` member) so a + follow-up CLI verb can re-use the same audit shape without + re-introducing dead code first. + +## References + +- Issue +[#174](https://gitea.dideric.is/didericis/bot-bottle/issues/174) + — the request: "strip the dashboard down into just a TUI for + managing agent requests for new egress routes and new + capabilities." +- PRD 0013 — supervise plane foundation (the floor this PRD + reverts the dashboard to). +- PRDs 0014 / 0015 / 0016 — block-remediation engines that the + supervise TUI continues to drive on approve. +- PRDs 0019 / 0020 / 0021 — the bolted-on capabilities this PRD + removes. \ No newline at end of file -- 2.52.0 From 5c17f0de958ace9ee1069a90acfcb349f9136c28 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 3 Jun 2026 17:19:41 +0000 Subject: [PATCH 02/12] docs(prd): rename strip dashboard PRD to 0049 --- ...upervisor-tui.md => 0049-strip-dashboard-to-supervisor-tui.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/prds/{0050-strip-dashboard-to-supervisor-tui.md => 0049-strip-dashboard-to-supervisor-tui.md} (100%) diff --git a/docs/prds/0050-strip-dashboard-to-supervisor-tui.md b/docs/prds/0049-strip-dashboard-to-supervisor-tui.md similarity index 100% rename from docs/prds/0050-strip-dashboard-to-supervisor-tui.md rename to docs/prds/0049-strip-dashboard-to-supervisor-tui.md -- 2.52.0 From 6f0a42159f9f580128952bc9c365f98987197b12 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 3 Jun 2026 17:23:40 +0000 Subject: [PATCH 03/12] refactor(cli): rename dashboard command to supervise --- bot_bottle/cli/__init__.py | 10 +- bot_bottle/cli/dashboard.py | 1741 ----------------------------- bot_bottle/cli/dashboard_model.py | 421 ------- bot_bottle/cli/supervise.py | 620 ++++++++++ 4 files changed, 625 insertions(+), 2167 deletions(-) delete mode 100644 bot_bottle/cli/dashboard.py delete mode 100644 bot_bottle/cli/dashboard_model.py create mode 100644 bot_bottle/cli/supervise.py diff --git a/bot_bottle/cli/__init__.py b/bot_bottle/cli/__init__.py index c8abbcc..4bbed24 100644 --- a/bot_bottle/cli/__init__.py +++ b/bot_bottle/cli/__init__.py @@ -1,6 +1,6 @@ """Main CLI dispatcher. -Commands: cleanup, dashboard, edit, info, init, list, resume, start +Commands: cleanup, edit, info, init, list, resume, start, supervise """ from __future__ import annotations @@ -12,24 +12,24 @@ from ..manifest import ManifestError from ._common import PROG from . import list as _list_mod from .cleanup import cmd_cleanup -from .dashboard import cmd_dashboard from .edit import cmd_edit from .info import cmd_info from .init import cmd_init from .resume import cmd_resume from .start import cmd_start +from .supervise import cmd_supervise cmd_list = _list_mod.cmd_list COMMANDS = { "cleanup": cmd_cleanup, - "dashboard": cmd_dashboard, "edit": cmd_edit, "info": cmd_info, "init": cmd_init, "list": cmd_list, "resume": cmd_resume, "start": cmd_start, + "supervise": cmd_supervise, } @@ -37,13 +37,13 @@ def usage() -> None: sys.stderr.write(f"usage: {PROG} [args...]\n\n") sys.stderr.write("Commands:\n") sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n") - sys.stderr.write(" dashboard view + approve/modify/reject pending supervise proposals (PRD 0013)\n") sys.stderr.write(" edit open an agent in vim for editing\n") sys.stderr.write(" info print env, skills, and prompt details for a named agent\n") sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n") sys.stderr.write(" list list available agents or active containers\n") sys.stderr.write(" resume re-launch a bottle by its identity (continues state from PRD 0016)\n") - sys.stderr.write(" start boot a container for a named agent and attach an interactive session\n\n") + sys.stderr.write(" start boot a container for a named agent and attach an interactive session\n") + sys.stderr.write(" supervise view + approve/modify/reject pending supervise proposals (PRD 0013)\n\n") sys.stderr.write(f"Run '{PROG} --help' for command-specific usage.\n") diff --git a/bot_bottle/cli/dashboard.py b/bot_bottle/cli/dashboard.py deleted file mode 100644 index edcad09..0000000 --- a/bot_bottle/cli/dashboard.py +++ /dev/null @@ -1,1741 +0,0 @@ -"""dashboard: list pending supervise proposals across all bottles and -act on them (approve / modify / reject). PRD 0013 v1. - -Curses-based TUI; modify-then-approve shells out to $EDITOR. The -approval handlers wire to the per-tool remediation engines: -PRD 0014 (egress, retargeted from cred-proxy in PRD 0017 -chunk 3) writes routes.yaml + SIGHUPs egress; PRD 0015 -(pipelock) writes the allowlist + restarts pipelock; PRD 0016 -(capability) rebuilds the bottle Dockerfile. -""" - -from __future__ import annotations - -import argparse -import contextlib -import curses -import os -import shutil -import subprocess -import sys -import tempfile -import time -import traceback -from datetime import datetime, timezone -from pathlib import Path - -from .. import supervise as _supervise -from ..backend import ( - ActiveAgent, - BottleSpec, - get_bottle_backend, - known_backend_names, -) -from ..backend.docker.bottle_state import bottle_state_dir, read_metadata -from ..backend.docker.capability_apply import ( - CapabilityApplyError, - apply_capability_change, -) -from ..backend.docker.egress_apply import ( - EgressApplyError, - add_route, - apply_routes_change, - fetch_current_routes, -) -from ..backend.docker.pipelock_apply import ( - PipelockApplyError, - apply_allowlist_change, - fetch_current_allowlist, - parse_allowlist_content, - render_allowlist_content, -) -from ..log import Die, error, info -from ..manifest import Manifest, ManifestError -from ..supervise import ( - ACTION_OPERATOR_EDIT, - COMPONENT_FOR_TOOL, - AuditEntry, - Proposal, - Response, - STATUS_APPROVED, - STATUS_MODIFIED, - STATUS_REJECTED, - TOOL_CAPABILITY_BLOCK, - TOOL_EGRESS_BLOCK, - TOOL_PIPELOCK_BLOCK, - archive_proposal, - render_diff, - write_audit_entry, - write_response, -) -from ._common import PROG, USER_CWD -from .dashboard_model import ( - PANE_AGENTS, - PANE_PROPOSALS, - QueuedProposal, - _NEW_PROPOSAL_HIGHLIGHT_SEC, - _REFRESH_INTERVAL_MS, - _agent_runtime_args, - _approval_status, - _bottle_for_slug, - _build_respawn_pane_argv, - _build_resume_argv_with_fallback, - _build_split_pane_argv, - _detail_lines, - _failed_url_host, - _filter_agents, - _format_agent_row, - _in_tmux, - _is_recent, - _pick_next_after_stop, - _proposed_payload_label, - _running_counts, - _selected_agent, - _selection_status, - _suffix_for_tool, - discover_active_agents, - discover_pending, -) -from .start import ( - attach_agent, - capture_claude_session_state, - prepare_with_preflight, - settle_state, -) - - -# Errors any remediation engine may raise. Caught by the TUI key -# handlers and surfaced in the status line so a failed apply keeps -# the proposal pending rather than crashing curses. -ApplyError = (EgressApplyError, PipelockApplyError, CapabilityApplyError) - - -# --- Operator actions ------------------------------------------------------ - - -def approve( - qp: QueuedProposal, - *, - notes: str = "", - final_file: str | None = None, -) -> None: - """Apply the proposal to the running sidecar, write the response - file the agent's tool call is waiting on, and append an audit - entry. If `final_file` is provided the status is `modified`; - otherwise `approved`. - - Raises EgressApplyError if the egress-block apply - fails (sidecar down, invalid routes content survived the - operator's modify). On failure no response is written and no - audit entry is appended — the proposal stays pending so the - operator can fix the input and retry.""" - status = STATUS_MODIFIED if final_file is not None else STATUS_APPROVED - file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file - - diff_before, diff_after = "", "" - if qp.proposal.tool == TOOL_EGRESS_BLOCK: - # The proposal is a single-route JSON; add_route fetches the - # current routes from the running egress, merges the - # new route in, and applies the full merged file. The - # audit log gets the BEFORE/AFTER of the full file so the - # diff renders cleanly even though the agent only proposed - # one entry. - diff_before, diff_after = add_route( - qp.proposal.bottle_slug, file_to_apply, - ) - elif qp.proposal.tool == TOOL_PIPELOCK_BLOCK: - diff_before, diff_after = _apply_pipelock_url( - qp.proposal.bottle_slug, file_to_apply, - ) - elif qp.proposal.tool == TOOL_CAPABILITY_BLOCK: - _meta = read_metadata(qp.proposal.bottle_slug) - if _meta is not None and not _meta.compose_project: - raise CapabilityApplyError( - "capability-block remediation is not supported for smolmachines " - "bottles. Reject this proposal or handle the capability change " - "manually, then restart the bottle." - ) - diff_before, diff_after = apply_capability_change( - qp.proposal.bottle_slug, file_to_apply, - ) - - response = Response( - proposal_id=qp.proposal.id, - status=status, - notes=notes, - final_file=final_file, - ) - write_response(qp.queue_dir, response) - _write_audit( - qp, action=status, notes=notes, - diff_before=diff_before, diff_after=diff_after, - ) - if qp.proposal.tool == TOOL_CAPABILITY_BLOCK: - # The supervise sidecar was torn down by apply_capability_change, - # so it can't archive its own proposal+response. Archive here so - # dashboard.discover_pending stops surfacing the resolved - # proposal forever. - archive_proposal(qp.queue_dir, qp.proposal.id) - - -def reject(qp: QueuedProposal, *, reason: str) -> None: - """Write a rejection response and an audit entry. No remediation - apply happens on reject — the agent sees the rejection and - decides whether to retry / give up.""" - response = Response( - proposal_id=qp.proposal.id, - status=STATUS_REJECTED, - notes=reason, - final_file=None, - ) - write_response(qp.queue_dir, response) - _write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="") - - -def operator_edit_routes(slug: str, new_content: str) -> tuple[str, str]: - """Apply an operator-initiated routes.yaml change (no agent - proposal). Used by the `routes edit ` TUI verb and - available for scripted use. Returns (before, after) like - apply_routes_change. Writes an audit entry tagged - ACTION_OPERATOR_EDIT to distinguish from tool-call approvals. - - Raises EgressApplyError on failure.""" - before, after = apply_routes_change(slug, new_content) - write_audit_entry(AuditEntry( - timestamp=datetime.now(timezone.utc).isoformat(), - bottle_slug=slug, - component="egress", - operator_action=ACTION_OPERATOR_EDIT, - operator_notes="", - justification="", - diff=render_diff(before, after, label="egress"), - )) - return before, after - - -def _apply_pipelock_url(slug: str, failed_url: str) -> tuple[str, str]: - """pipelock-block proposals carry a single failed URL, not a - full allowlist. Extract the host, merge into the running - allowlist, and hand the merged content to apply_allowlist_change. - The full URL (with path) is preserved on the proposal for the - operator's read; only the host ends up in pipelock's allowlist. - - Pipelock 2.3.0's api_allowlist is hostname-only (verified by - inspecting the binary's strict preset; the only "path" fields in - pipelock's schema are about local filesystem paths under sandbox - / file_sentry / taint). Approving pipelock-block opens the - entire host, not the URL's path. - - Path-level enforcement was the open question this function's - earlier docstring flagged; PRD 0017 answered it by putting - egress in front of pipelock. The agent's - `egress-block` tool now proposes routes.yaml changes that - can include a `path_allowlist`. Use that tool for path-level - follow-ups; this one stays hostname-only because pipelock is - still the last hostname gate before egress.""" - import urllib.parse - parsed = urllib.parse.urlsplit(failed_url.strip()) - host = parsed.hostname or "" - if not host: - raise PipelockApplyError( - f"proposed failed_url has no extractable host: {failed_url!r}" - ) - current = fetch_current_allowlist(slug) - hosts = parse_allowlist_content(current) - if host not in hosts: - hosts.append(host) - return apply_allowlist_change(slug, render_allowlist_content(hosts)) - - -def operator_edit_allowlist(slug: str, new_content: str) -> tuple[str, str]: - """Apply an operator-initiated pipelock allowlist change (no - agent proposal). Used by the `pipelock edit ` TUI verb - and available for scripted use. Returns (before, after) like - apply_allowlist_change. Writes an audit entry tagged - ACTION_OPERATOR_EDIT to distinguish from tool-call approvals. - - Raises PipelockApplyError on failure.""" - before, after = apply_allowlist_change(slug, new_content) - write_audit_entry(AuditEntry( - timestamp=datetime.now(timezone.utc).isoformat(), - bottle_slug=slug, - component="pipelock", - operator_action=ACTION_OPERATOR_EDIT, - operator_notes="", - justification="", - diff=render_diff(before, after, label="pipelock"), - )) - return before, after - - -def _write_audit( - qp: QueuedProposal, - *, - action: str, - notes: str, - diff_before: str, - diff_after: str, -) -> None: - """Audit log for egress / pipelock tools. capability-block - has no audit log (its changes are captured by the bottle's - rebuild record + git history per PRD 0016). - - For egress-block + pipelock-block approvals the (before, - after) come from the apply_*_change return — a real - fetched-from-sidecar diff. For rejections both are empty strings - and the audit diff renders as empty.""" - component = COMPONENT_FOR_TOOL.get(qp.proposal.tool) - if component is None: - return - write_audit_entry(AuditEntry( - timestamp=datetime.now(timezone.utc).isoformat(), - bottle_slug=qp.proposal.bottle_slug, - component=component, - operator_action=action, - operator_notes=notes, - justification=qp.proposal.justification, - diff=render_diff(diff_before, diff_after, label=component), - )) - - -# --- $EDITOR integration -------------------------------------------------- - - -def edit_in_editor(content: str, *, suffix: str = ".tmp") -> str | None: - """Suspend curses (caller is responsible for that), drop `content` - to a temp file, exec $EDITOR on it, return the edited content. - Returns None if the edit was a no-op.""" - editor = os.environ.get("EDITOR", "vim") - with tempfile.NamedTemporaryFile( - mode="w", suffix=suffix, delete=False, prefix="supervise-modify.", - ) as f: - f.write(content) - path = f.name - try: - subprocess.run([editor, path], check=False) - with open(path) as f: - edited = f.read() - return edited if edited != content else None - finally: - try: - os.unlink(path) - except OSError: - pass - - -# --- New-agent flow (PRD 0020 chunks 1+2) ---------------------------------- -# -# `n` opens a picker modal listing the manifest's agents (with a -# running-count next to each). Selecting one runs prepare → preflight -# (modal) → backend.launch().__enter__() → handoff (curses.endwin → -# claude → refresh). The returned (cm, bottle) lives in the main -# loop's `bottles` dict; chunks 3/4 wire Enter / `x` to act on it. - - -def _picker_modal( - stdscr: "curses._CursesWindow", - names: list[str], - running_counts: dict[str, int], -) -> str | None: - """Modal agent picker. Type to filter; j/k or arrows to - navigate; Enter to confirm; Esc to abort (first press clears - filter if any, second press exits).""" - selected = 0 - query = "" - while True: - filtered = _filter_agents(query, names) - if not filtered: - selected = 0 - elif selected >= len(filtered): - selected = len(filtered) - 1 - elif selected < 0: - selected = 0 - - _draw_picker_modal(stdscr, names, filtered, selected, query, running_counts) - try: - key = stdscr.getch() - except KeyboardInterrupt: - _erase_modal(stdscr) - return None - - if key == 27: # Esc - if query: - 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 - if filtered: - selected = min(selected + 1, len(filtered) - 1) - continue - if key in (curses.KEY_UP, ord("\x10")): # KEY_UP, Ctrl-P - if filtered: - selected = max(selected - 1, 0) - continue - if key in (curses.KEY_BACKSPACE, 127, 8): - query = query[:-1] - continue - # Printable character → append to filter - if 32 <= key < 127: - query += chr(key) - continue - # Anything else: ignore - - -def _draw_picker_modal( - stdscr: "curses._CursesWindow", - all_names: list[str], - filtered: list[str], - selected: int, - query: str, - running_counts: dict[str, int], -) -> None: - """Render the picker modal. Width fits the longest name plus - the `(N running)` suffix; height fits all filtered items plus - a header line, filter line, and border — capped at 80% of - screen height with a scrollable inner list if necessary.""" - h, w = stdscr.getmaxyx() - label_width = max( - (len(n) for n in all_names), default=10, - ) - suffix_width = len(" (99 running)") - inner_width = max(label_width + suffix_width, len("filter: ") + 20, 40) - box_w = min(inner_width + 4, max(20, w - 4)) - max_list_rows = max(3, int(h * 0.6)) - list_rows = min(len(filtered) if filtered else 1, max_list_rows) - box_h = list_rows + 5 # border (2) + title (1) + filter (1) + spacer (1) - box_h = min(box_h, max(7, h - 4)) - top = max(0, (h - box_h) // 2) - left = max(0, (w - box_w) // 2) - - win = curses.newwin(box_h, box_w, top, left) - win.erase() - win.box() - win.addnstr(0, 2, " start agent ", box_w - 4, curses.A_BOLD) - - win.addnstr(1, 2, f"filter: {query}", box_w - 4) - win.hline(2, 1, curses.ACS_HLINE, box_w - 2) - - list_start_row = 3 - visible_rows = box_h - list_start_row - 1 - if not filtered: - empty_message = ( - "(no agents configured)" - if not all_names else "(no agents match filter)" - ) - win.addnstr( - list_start_row, 2, - empty_message, - box_w - 4, curses.A_DIM, - ) - else: - # Simple windowing around `selected`. - first = max(0, selected - visible_rows + 1) - if selected < first: - first = selected - for i, name in enumerate(filtered[first:first + visible_rows]): - row = list_start_row + i - count = running_counts.get(name, 0) - suffix = f" ({count} running)" if count else "" - line = f" {name}{suffix}" - attr = curses.A_REVERSE if (first + i) == selected else curses.A_NORMAL - win.addnstr(row, 1, line, box_w - 2, attr) - - win.addnstr( - box_h - 1, 2, - " Enter: start Esc: cancel type: filter ", - box_w - 4, curses.A_DIM, - ) - win.refresh() - - -def _preflight_modal( - stdscr: "curses._CursesWindow", - plan_text: str, -) -> bool: - """Modal preflight confirmation. `plan_text` is the multi-line - summary the renderer produced; we draw it in a centered box - with `[y/N]` at the bottom and capture the next keypress.""" - lines = plan_text.splitlines() or [""] - h, w = stdscr.getmaxyx() - inner_width = max( - max((len(line) for line in lines), default=10), - len("launch this agent? [y/N]"), - ) - box_w = min(inner_width + 4, max(20, w - 4)) - box_h = min(len(lines) + 5, max(7, h - 4)) - top = max(0, (h - box_h) // 2) - left = max(0, (w - box_w) // 2) - - win = curses.newwin(box_h, box_w, top, left) - win.erase() - win.box() - win.addnstr(0, 2, " launch agent ", box_w - 4, curses.A_BOLD) - for i, line in enumerate(lines[: box_h - 4]): - win.addnstr(1 + i, 2, line, box_w - 4) - win.addnstr( - box_h - 2, 2, - "launch this agent? [y/N]", - box_w - 4, curses.A_BOLD, - ) - win.addnstr( - box_h - 1, 2, - " y: launch N / Esc: abort ", - box_w - 4, curses.A_DIM, - ) - win.refresh() - - while True: - 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 _backend_picker_modal( - stdscr: "curses._CursesWindow", - agent_name: str, -) -> str | None: - """Modal "which backend to launch this agent on?" picker. Up/ - Down + Enter to confirm, Esc / N to abort. Returns the chosen - backend name or None on abort. - - Defaults to the first known backend (`docker` lexicographically), - which keeps existing-muscle-memory flows quiet — the modal only - surfaces a choice; it doesn't surprise the operator by jumping - to smolmachines. The picker exists so operators can opt in to - smolmachines without setting BOT_BOTTLE_BACKEND beforehand - (issue #77).""" - names = list(known_backend_names()) - if len(names) <= 1: - return names[0] if names else None - selected = 0 - h, w = stdscr.getmaxyx() - box_w = min(60, max(20, w - 4)) - box_h = min(len(names) + 6, max(8, h - 4)) - top = max(0, (h - box_h) // 2) - left = max(0, (w - box_w) // 2) - - while True: - win = curses.newwin(box_h, box_w, top, left) - win.erase() - win.box() - win.addnstr(0, 2, " choose backend ", box_w - 4, curses.A_BOLD) - win.addnstr( - 1, 2, - f"launching {agent_name!r}; pick a backend:", - box_w - 4, - ) - for i, name in enumerate(names): - marker = "▶" if i == selected else " " - attr = curses.A_REVERSE if i == selected else 0 - win.addnstr(3 + i, 2, f"{marker} {name}", box_w - 4, attr) - win.addnstr( - box_h - 2, 2, - " Enter: confirm Esc / N: abort ↑/↓: move ", - box_w - 4, curses.A_DIM, - ) - win.refresh() - - try: - key = stdscr.getch() - except KeyboardInterrupt: - _erase_modal(stdscr) - return None - if key in (curses.KEY_UP,): - selected = (selected - 1) % len(names) - elif key in (curses.KEY_DOWN,): - selected = (selected + 1) % len(names) - elif key in (curses.KEY_ENTER, 10, 13): - _erase_modal(stdscr) - return names[selected] - elif key in (ord("n"), ord("N"), 27): - _erase_modal(stdscr) - return None - - -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 - CLI relies on); for the modal we want it as a string.""" - import io - import contextlib - buf = io.StringIO() - with contextlib.redirect_stderr(buf): - plan.print(remote_control=False) - return buf.getvalue().strip("\n") - - -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`. - - 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" - ) - cm, bottle, identity = bottles.pop(slug) - - 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. - # exit_code=0 → no auto-preserve; the operator's - # existing preserve marker (if any) is honored by - # settle_state below. - try: - if getattr(bottle, "agent_provider_template", "claude") == "claude": - capture_claude_session_state(identity, exit_code=0) - except BaseException: - pass - try: - cm.__exit__(None, None, None) - except BaseException: - pass - - # Mirror the bringup path's stderr → right-pane routing. - # Reuses any existing right pane (which is probably the - # agent's own agent 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" - - # 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) - if tmux_state is not None and tmux_state.get("slug") == slug: - tmux_state["slug"] = None - return f"[{slug}] stopped" - - -# --- 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 agent session. The dashboard remembers the -# pane id + occupant slug in `tmux_state` so the same pane is -# reused across attaches. - - -@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_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( - _build_split_pane_argv(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, 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, argv), - capture_output=True, text=True, check=False, - ) - except FileNotFoundError: - return False - 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 _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 agent 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 _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 agent 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 - 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: - """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: - agent_provider_template = getattr(bottle, "agent_provider_template", "claude") - exit_code = attach_agent( - bottle, - remote_control=False, - resume=resume, - agent_provider_template=agent_provider_template, - ) - except BaseException: - stdscr.refresh() - raise - stdscr.refresh() - return f"[{slug}] agent session ended (exit {exit_code})" - - -def _attach_in_tmux( - stdscr: "curses._CursesWindow", - bottle, - slug: str, - *, - 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). - - `focus_right_pane=True` runs `tmux select-pane` after the - respawn so the operator is dropped into agent 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.""" - if resume: - agent_provider_template = getattr(bottle, "agent_provider_template", "claude") - # `--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 - # agent instead of crashing. - agent_argv = _build_resume_argv_with_fallback( - bottle, agent_provider_template=agent_provider_template, - ) - else: - agent_provider_template = getattr(bottle, "agent_provider_template", "claude") - agent_argv = bottle.agent_argv( - _agent_runtime_args( - resume=False, - agent_provider_template=agent_provider_template, - ), - ) - pane_id = _ensure_right_pane(tmux_state, agent_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["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, - slug: str, - *, - tmux_state: dict | None = None, -) -> str: - """Re-attach to a running bottle. Inside tmux (`$TMUX` set + - `tmux_state` provided) the agent 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: - # 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 agent instead of the dashboard. - return _attach_in_tmux( - stdscr, bottle, slug, - resume=True, tmux_state=tmux_state, - focus_right_pane=True, - ) - return _attach_via_handoff(stdscr, bottle, slug, resume=True) - - -def _new_agent_flow( - stdscr: "curses._CursesWindow", - 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), then route - the first agent 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: - if not names: - return "no agents configured; create ~/.bot-bottle/agents/*.md" - return "agent start aborted" - - # Backend picker (issue #77): operator chooses docker / - # smolmachines per launch. With only one backend installed - # the modal short-circuits (no need to ask). - backend_name = _backend_picker_modal(stdscr, picked) - if backend_name is None: - return f"start of {picked!r} aborted at backend select" - - spec = BottleSpec( - manifest=manifest, - agent_name=picked, - copy_cwd=False, - user_cwd=USER_CWD, - ) - # Modal preflight + prompt. `prepare_with_preflight` calls - # render_preflight(plan) once, then prompt_yes() to decide. We - # split the two: render captures the text into a closure, the - # prompt draws the modal + reads y/N. - captured: dict[str, str] = {} - - def _render(plan) -> None: - captured["text"] = _capture_preflight_text(plan) - - def _prompt() -> bool: - return _preflight_modal(stdscr, captured.get("text", "")) - - stage_dir = Path(tempfile.mkdtemp(prefix="bot-bottle-stage.")) - try: - plan, identity = prepare_with_preflight( - spec, - stage_dir=stage_dir, - render_preflight=_render, - prompt_yes=_prompt, - backend_name=backend_name, - ) - if plan is None: - settle_state(identity) - return f"start of {picked!r} aborted at preflight" - - backend = get_bottle_backend(backend_name) - - # 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) - # 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, - # compose up). Get out of curses' way for the duration so - # the lines render cleanly; restore curses immediately after. - curses.endwin() - try: - cm = backend.launch(plan) - bottle = cm.__enter__() - except BaseException: - stdscr.refresh() - settle_state(identity) - raise - bottles[plan.slug] = (cm, bottle, identity) - - # Foreground handoff: the agent owns the terminal until exit, - # then we restore curses. - try: - agent_provider_template = getattr(plan, "agent_provider_template", "claude") - exit_code = attach_agent( - bottle, - remote_control=False, - agent_provider_template=agent_provider_template, - ) - if agent_provider_template == "claude": - capture_claude_session_state(identity, exit_code) - finally: - stdscr.refresh() - return f"[{plan.slug}] agent session ended (exit {exit_code})" - finally: - # stage_dir was the prepare scratch dir; after PRD 0018 - # chunk 2 it holds nothing the running bottle needs. Reap - # immediately regardless of which branch above ran. - shutil.rmtree(stage_dir, ignore_errors=True) - - -# --- TUI ------------------------------------------------------------------- - - -def cmd_dashboard(argv: list[str]) -> int: - parser = argparse.ArgumentParser(prog=f"{PROG} dashboard", add_help=True) - parser.add_argument( - "--once", action="store_true", - help="list pending proposals once and exit (no TUI)", - ) - args = parser.parse_args(argv) - - if args.once: - return _list_once() - try: - curses.wrapper(_main_loop) - except KeyboardInterrupt: - return 130 - except Die as e: - # die() printed the reason to stderr, but that happened while - # curses owned the terminal — the text landed on the alternate - # screen and was wiped when the terminal was restored. Re-surface - # it now that we're back on the normal screen. - if e.message: - error(e.message) - else: - error("dashboard exited on a fatal error (no detail captured).") - return e.code if isinstance(e.code, int) else 1 - except Exception as e: - # Any other crash inside the TUI. The traceback would otherwise - # vanish with the alternate screen, so persist it and tell the - # operator where to look. - log_path = _write_crash_log(e) - error(f"dashboard crashed: {type(e).__name__}: {e}") - error(f"full traceback written to {log_path}") - return 1 - return 0 - - -def _write_crash_log(exc: BaseException) -> Path: - """Persist `exc`'s traceback to a stable file under ~/.bot-bottle/ - and return its path. - - The dashboard runs under curses, so a crash's stderr/traceback is - painted onto the alternate screen and lost when the terminal is - restored — this leaves the operator a durable record of *why* it - died. Best-effort: falls back to a tempfile if the home dir can't - be written.""" - stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - body = "".join( - traceback.format_exception(type(exc), exc, exc.__traceback__) - ) - entry = f"=== dashboard crash {stamp} ===\n{body}\n" - try: - log_dir = _supervise.bot_bottle_root() / "logs" - log_dir.mkdir(parents=True, exist_ok=True) - path = log_dir / "dashboard-crash.log" - with path.open("a", encoding="utf-8") as fh: - fh.write(entry) - return path - except OSError: - fd, tmp = tempfile.mkstemp( - prefix="bot-bottle-dashboard-crash-", suffix=".log", - ) - with os.fdopen(fd, "w", encoding="utf-8") as fh: - fh.write(entry) - return Path(tmp) - - -def _list_once() -> int: - pending = discover_pending() - if not pending: - info("no pending proposals") - return 0 - for qp in pending: - sys.stdout.write( - f"{qp.proposal.arrival_timestamp} " - f"[{qp.proposal.bottle_slug}] " - f"{qp.proposal.tool} " - f"{qp.proposal.id}\n" - ) - sys.stdout.write(f" {qp.proposal.justification}\n") - return 0 - - -def _try_init_green() -> int: - """Initialise a green color pair and return its attr, or 0 if the - terminal doesn't support color. Caller ORs the returned value - into addnstr's attr argument; OR 0 is a no-op.""" - try: - curses.start_color() - curses.use_default_colors() - curses.init_pair(1, curses.COLOR_GREEN, -1) - return curses.color_pair(1) - except curses.error: - return 0 - - -def _quit_without_teardown(bottles: dict) -> None: - """Exit the dashboard process WITHOUT triggering Python's normal - cleanup of the `bottles` dict's context managers. - - The dict holds `@contextmanager`-decorated objects whose - underlying generators have implicit close-on-GC behavior: - when Python's interpreter shutdown collects them, each - generator's `finally` block runs, which invokes that bottle's - teardown (`docker compose down`). PRD 0020 explicitly DOESN'T - want that — quitting the dashboard should leave running - bottles running. `os._exit` skips all Python-level cleanup - (GC, atexit, stdio flush, etc.), so the docker compose - projects survive the dashboard exit untouched. - - The `bottles` arg is accepted for the explicit - documentation-of-intent — we're choosing not to close - these. Curses gets its terminal restored via the explicit - `endwin` below since `os._exit` doesn't run - curses.wrapper's finally.""" - del bottles # nothing to do with it; the os._exit is the point - curses.endwin() - os._exit(0) - - -def _main_loop(stdscr: "curses._CursesWindow") -> None: - curses.curs_set(0) - # Auto-refresh: getch() returns -1 after the timeout if no key - # was pressed, so the loop re-renders with any newly-arrived - # proposals every ~1s. Without this the screen only updates - # when the operator hits a key — a tool call landing while the - # operator is just watching wouldn't appear. - stdscr.timeout(_REFRESH_INTERVAL_MS) - green_attr = _try_init_green() - # Per-proposal first-seen timestamps drive the "new" highlight. - # We add entries as proposals show up and prune ones that are - # gone (approved / rejected / archived) so the dict stays small. - first_seen: dict[str, float] = {} - selected = 0 - selected_agent = 0 - # 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). - # We hold the context manager so chunk 4's `x` can call __exit__ - # 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). - manifest_cache: list[Manifest | None] = [None] - - def _get_manifest() -> Manifest: - if manifest_cache[0] is None: - manifest_cache[0] = Manifest.resolve(USER_CWD, missing_ok=True) - return manifest_cache[0] - # A malformed manifest must not take the whole dashboard down — the - # operator may just be watching running bottles. Degrade to a - # status-line warning instead. (Any non-config error propagates to - # cmd_dashboard's crash handler.) - try: - _loaded = _get_manifest() - except ManifestError as e: - status_line = f"config error: {e}" - else: - if not _loaded.bottles and not _loaded.agents: - status_line = "warning: no bot-bottle config/agents found; new-agent picker is empty" - # First-tick guard: a brand-new dashboard finds any - # pre-existing queue entries on its first poll; those - # shouldn't ring the bell as if they just arrived. - saw_first_tick = False - # The dashboard's own tmux pane id (tmux sets `$TMUX_PANE` - # per-pane). Captured at startup so a new-proposal arrival - # can `tmux select-pane` back to the dashboard from - # whatever pane the operator is currently in. - dashboard_pane_id = os.environ.get("TMUX_PANE", "") - while True: - pending = discover_pending() - if selected >= len(pending): - selected = max(0, len(pending) - 1) - - agents = discover_active_agents() - if selected_agent >= len(agents): - selected_agent = max(0, len(agents) - 1) - - now = time.monotonic() - live_ids = {qp.proposal.id for qp in pending} - # Detect proposals we've never seen before. Triggers: - # - terminal bell (`curses.beep` → tmux's monitor-bell) - # - tmux focus jump to the dashboard pane (so the - # operator notices even if they were typing at claude) - # - dashboard's internal focus flip to the proposals - # pane (so j/k navigates the queued items immediately) - newly_arrived = live_ids - first_seen.keys() - if saw_first_tick and newly_arrived: - try: - curses.beep() - except curses.error: - pass - if dashboard_pane_id and _in_tmux(): - _tmux_select_pane(dashboard_pane_id) - focus = PANE_PROPOSALS - # Land the cursor on the first new proposal so the - # operator can act immediately. Proposals are sorted - # by arrival_timestamp ascending; find the lowest - # index whose id is in `newly_arrived`. - for i, qp in enumerate(pending): - if qp.proposal.id in newly_arrived: - selected = i - break - for proposal_id in live_ids: - first_seen.setdefault(proposal_id, now) - for stale_id in list(first_seen.keys() - live_ids): - del first_seen[stale_id] - saw_first_tick = True - - _render( - stdscr, pending, selected, status_line, - 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, - ) - - try: - key = stdscr.getch() - except KeyboardInterrupt: - return - - if key == -1: - # Timeout fired — re-render with fresh queue. Status_line - # is left intact so messages from a prior keystroke stay - # readable until the operator actually does something else. - continue - - # Real keystroke: clear any stale status before dispatching - # so the next render reflects what just happened. - status_line = "" - - if key in (ord("q"), 27): # q or ESC - _quit_without_teardown(bottles) - return # unreachable; _quit_without_teardown os._exit's - if key == 9: # Tab - focus = PANE_AGENTS if focus == PANE_PROPOSALS else PANE_PROPOSALS - continue - if key == ord("n"): - # PRD 0020 chunk 2: open the picker, start + attach to - # the chosen agent, return to the dashboard with the - # bottle running. - try: - manifest = _get_manifest() - except ManifestError as e: - status_line = f"config error: {e}" - continue - except Exception as e: - status_line = f"manifest load failed: {e}" - continue - 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 - # the agents pane is focused on a real selection; - # otherwise no-op with a status hint. The pre-PRD - # discover-and-prompt scaffolding is gone. - selected_obj = _selected_agent(focus, agents, selected_agent) - if selected_obj is None: - status_line = "no agent selected; Tab into the agents pane first" - continue - if key == ord("e"): - status_line = _operator_edit_routes_flow(stdscr, selected_obj) - else: - status_line = _operator_edit_allowlist_flow(stdscr, selected_obj) - continue - - if focus == PANE_AGENTS: - # j/k/arrow navigate the agents list. Enter re-attaches - # (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")): - selected_agent = max(selected_agent - 1, 0) - elif key in (curses.KEY_ENTER, 10, 13): - target = _selected_agent(focus, agents, selected_agent) - if target is None: - status_line = "no agent selected" - 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, tmux_state=tmux_state, - ) - 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, - 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 agent 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: - continue - qp = pending[selected] - - if key in (curses.KEY_DOWN, ord("j")): - selected = min(selected + 1, len(pending) - 1) - elif key in (curses.KEY_UP, ord("k")): - selected = max(selected - 1, 0) - elif key in (curses.KEY_ENTER, 10, 13, ord("v")): - _detail_view(stdscr, qp, green_attr=green_attr) - elif key == ord("a"): - try: - approve(qp) - status_line = _approval_status(qp, "approved") - except ApplyError as e: - status_line = f"apply failed: {e}" - elif key == ord("m"): - edited = _modify(stdscr, qp) - if edited is None: - status_line = "modify aborted (no change)" - else: - try: - approve(qp, final_file=edited, notes="operator modified before approving") - status_line = _approval_status(qp, "modified+approved") - except ApplyError as e: - status_line = f"apply failed: {e}" - elif key == ord("r"): - reason = _prompt(stdscr, "reject reason: ") - if reason: - reject(qp, reason=reason) - status_line = f"rejected {qp.proposal.tool} for [{qp.proposal.bottle_slug}]" - else: - status_line = "reject aborted (empty reason)" - - -def _render( - stdscr: "curses._CursesWindow", - pending: list[QueuedProposal], - selected: int, - status_line: str, - *, - 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, -) -> None: - stdscr.erase() - h, w = stdscr.getmaxyx() - agents = agents or [] - header = ( - f"bot-bottle dashboard " - f"({len(pending)} pending, {len(agents)} active)" - ) - stdscr.addnstr(0, 0, header, w - 1, curses.A_BOLD) - stdscr.hline(1, 0, curses.ACS_HLINE, w) - - proposals_focused = focus == PANE_PROPOSALS - agents_focused = focus == PANE_AGENTS - - # ----- proposals pane (top) ----- - row = 2 - # When any proposal is in the recent-arrival window (the - # individual rows are green-highlighted by the existing logic), - # also highlight the pane label so the alert is visible at a - # glance even when the operator is focused elsewhere. - proposals_have_recent = any( - _is_recent(qp.proposal.id, first_seen, now) for qp in pending - ) - proposals_label = "proposals:" - if proposals_have_recent: - proposals_label += " (new!)" - if proposals_focused: - proposals_label += " (focused)" - label_attr = curses.A_DIM - if proposals_have_recent: - label_attr = curses.A_BOLD | green_attr - stdscr.addnstr(row, 0, proposals_label, w - 1, label_attr) - row += 1 - if not pending: - stdscr.addnstr( - row, 2, - "no pending proposals; agents will queue here when they call a " - "supervise tool", - w - 4, - ) - row += 1 - else: - for i, qp in enumerate(pending): - if row >= h - 4 - max(1, len(agents) + 2): - break - p = qp.proposal - ts_short = ( - p.arrival_timestamp.split("T", 1)[1][:8] - if "T" in p.arrival_timestamp else p.arrival_timestamp - ) - cursor = "> " if (proposals_focused and i == selected) else " " - line = ( - f"{cursor}" - f"[{p.bottle_slug}] {p.tool:<20} {ts_short} " - f"{p.justification[:60]}" - ) - attr = ( - curses.A_REVERSE - if (proposals_focused and i == selected) - else curses.A_NORMAL - ) - if _is_recent(p.id, first_seen, now): - attr |= green_attr - stdscr.addnstr(row, 0, line, w - 1, attr) - row += 1 - - # ----- agents pane (bottom) ----- - # One blank-line separator + an "active agents:" label, then - # one row per agent. Reverse-video the selected row when this - # pane has focus. Stops before the status / footer area so - # they always stay visible. - row += 1 - agents_label = "active agents:" - if agents_focused: - agents_label += " (focused)" - if row < h - 3: - stdscr.addnstr(row, 0, agents_label, w - 1, curses.A_DIM) - row += 1 - if not agents: - if row < h - 3: - stdscr.addnstr( - row, 2, - "no active bottles; ./cli.py start ", - w - 4, curses.A_DIM, - ) - else: - for i, a in enumerate(agents): - 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) - row += 1 - - footer = ( - "[n] new [Tab] switch [j/k] move " - "[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) - if status_line: - stdscr.addnstr(h - 3, 0, status_line, w - 1, curses.A_BOLD) - else: - # When idle: surface which agent is currently selected so - # the operator knows what `e` / `p` will target after chunk - # 4 wires the agent-scoped edit verbs. - sel = _selection_status(focus, agents, selected_agent) - if sel: - stdscr.addnstr(h - 3, 0, sel, w - 1, curses.A_DIM) - stdscr.refresh() - - -def _detail_view( - stdscr: "curses._CursesWindow", - qp: QueuedProposal, - *, - green_attr: int = 0, -) -> None: - """Render the full proposal: header, justification, proposed file - contents. Scrollable. Press q to return.""" - lines = _detail_lines(qp, green_attr=green_attr) - offset = 0 - while True: - stdscr.erase() - h, w = stdscr.getmaxyx() - for i, (text, attr) in enumerate(lines[offset:offset + h - 1]): - stdscr.addnstr(i, 0, text, w - 1, attr) - stdscr.addnstr( - h - 1, 0, - "[j/k] scroll [g/G] top/bottom [a] approve [m] modify [r] reject [q] back", - w - 1, curses.A_DIM, - ) - stdscr.refresh() - key = stdscr.getch() - if key in (ord("q"), 27): - return - if key in (curses.KEY_DOWN, ord("j")): - offset = min(offset + 1, max(0, len(lines) - 1)) - elif key in (curses.KEY_UP, ord("k")): - offset = max(offset - 1, 0) - elif key == ord("g"): - offset = 0 - elif key == ord("G"): - offset = max(0, len(lines) - 1) - elif key == ord("a"): - try: - approve(qp) - except ApplyError: - pass # Status surfaces back in the list view's render. - return - elif key == ord("m"): - edited = _modify(stdscr, qp) - if edited is not None: - try: - approve(qp, final_file=edited, notes="operator modified before approving") - except ApplyError: - pass - return - elif key == ord("r"): - reason = _prompt(stdscr, "reject reason: ") - if reason: - reject(qp, reason=reason) - return - - -def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None: - """Suspend curses, open $EDITOR on the proposed file, return the - edited content (or None if unchanged).""" - suffix = _suffix_for_tool(qp.proposal.tool) - curses.endwin() - try: - edited = edit_in_editor(qp.proposal.proposed_file, suffix=suffix) - finally: - stdscr.refresh() - return edited - - -def _operator_edit_routes_flow( - stdscr: "curses._CursesWindow", agent: ActiveAgent, -) -> str: - """Operator-initiated routes.yaml edit, scoped to `agent`. - PRD 0019: selection in the agents pane is the only way to - invoke this — the discover-and-prompt scaffolding is gone. - Refuses if the agent has no running egress sidecar.""" - return _operator_edit_flow( - stdscr, - agent=agent, - required_service="egress", - label="routes", - fetch=fetch_current_routes, - apply=operator_edit_routes, - suffix=".yaml", - ) - - -def _operator_edit_allowlist_flow( - stdscr: "curses._CursesWindow", agent: ActiveAgent, -) -> str: - """Operator-initiated pipelock allowlist edit, scoped to `agent`. - Pipelock is always present on an active bottle (no toggle in the - manifest) so the required-service check is belt-and-braces but - surfaces a clear error in the race-window case where compose up - is mid-flight.""" - return _operator_edit_flow( - stdscr, - agent=agent, - required_service="pipelock", - label="pipelock", - fetch=fetch_current_allowlist, - apply=operator_edit_allowlist, - suffix=".txt", - ) - - -def _operator_edit_flow( - stdscr: "curses._CursesWindow", - *, - agent: ActiveAgent, - required_service: str, - label: str, - fetch, - apply, - suffix: str, -) -> str: - """Shared scaffolding for the routes-edit + pipelock-edit verbs. - `fetch(slug)` returns the current operator-facing config; - `apply(slug, new)` does the write + restart/SIGHUP and writes - the audit entry.""" - if required_service not in agent.services: - return ( - f"[{agent.slug}] has no running {required_service} sidecar; " - f"nothing to edit" - ) - slug = agent.slug - try: - current = fetch(slug) - except ApplyError as e: - return f"fetch failed: {e}" - curses.endwin() - try: - edited = edit_in_editor(current, suffix=suffix) - finally: - stdscr.refresh() - if edited is None: - return f"{label} for [{slug}] unchanged" - try: - apply(slug, edited) - except ApplyError as e: - return f"apply failed: {e}" - return f"updated {label} for [{slug}]" - - -def _prompt(stdscr: "curses._CursesWindow", label: str) -> str: - """One-line input at the bottom of the screen.""" - curses.curs_set(1) - h, _ = stdscr.getmaxyx() - stdscr.move(h - 2, 0) - stdscr.clrtoeol() - stdscr.addstr(h - 2, 0, label) - stdscr.refresh() - curses.echo() - try: - raw = stdscr.getstr(h - 2, len(label), 200) - finally: - curses.noecho() - curses.curs_set(0) - return raw.decode("utf-8", errors="replace").strip() - - -__all__ = [ - "ACTION_OPERATOR_EDIT", # re-exported for 0014/0015 to write operator-initiated audit entries - "QueuedProposal", - "approve", - "cmd_dashboard", - "discover_pending", - "edit_in_editor", - "reject", -] diff --git a/bot_bottle/cli/dashboard_model.py b/bot_bottle/cli/dashboard_model.py deleted file mode 100644 index 0a7b252..0000000 --- a/bot_bottle/cli/dashboard_model.py +++ /dev/null @@ -1,421 +0,0 @@ -"""dashboard_model: state/model layer for the dashboard TUI. - -Data structures, discovery queries, pure state helpers, and derived -values extracted from dashboard.py so they can be tested in isolation -and navigated without wading through curses rendering code. -""" - -from __future__ import annotations - -import os -import shlex -from dataclasses import dataclass -from pathlib import Path - -from .. import supervise as _supervise -from ..agent_provider import runtime_for -from ..backend import ActiveAgent, enumerate_active_agents -from ..backend.docker.capability_apply import CapabilityApplyError -from ..backend.docker.egress_apply import EgressApplyError -from ..backend.docker.pipelock_apply import PipelockApplyError -from ..manifest import Manifest -from ..supervise import ( - TOOL_CAPABILITY_BLOCK, - TOOL_PIPELOCK_BLOCK, - Proposal, - list_pending_proposals, -) - - -# --- Constants --------------------------------------------------------------- - - -_REFRESH_INTERVAL_MS = 1000 - -_NEW_PROPOSAL_HIGHLIGHT_SEC = 5.0 - -PANE_PROPOSALS = "proposals" -PANE_AGENTS = "agents" - - -# --- Data structures --------------------------------------------------------- - - -@dataclass(frozen=True) -class QueuedProposal: - """A pending proposal plus the queue dir it was found in.""" - - proposal: Proposal - queue_dir: Path - - -ApplyError = (EgressApplyError, PipelockApplyError, CapabilityApplyError) - - -# --- Discovery --------------------------------------------------------------- - - -def discover_active_agents() -> list[ActiveAgent]: - """All currently-running agents across every backend with - their metadata + service set. Returns [] when neither - backend is reachable. Backed by the shared - `enumerate_active_agents` helper so the CLI's - `./cli.py list active` and this dashboard show the same data.""" - return enumerate_active_agents() - - -def discover_pending() -> list[QueuedProposal]: - """Walk ~/.bot-bottle/queue/* and collect pending proposals - from every bottle's queue. Sorted by arrival time across the - union — the operator works the global FIFO.""" - queue_root = _supervise.bot_bottle_root() / "queue" - if not queue_root.is_dir(): - return [] - out: list[QueuedProposal] = [] - for slug_dir in sorted(queue_root.iterdir()): - if not slug_dir.is_dir(): - continue - for proposal in list_pending_proposals(slug_dir): - out.append(QueuedProposal(proposal=proposal, queue_dir=slug_dir)) - out.sort(key=lambda q: q.proposal.arrival_timestamp) - return out - - -# --- Derived values ---------------------------------------------------------- - - -def _approval_status(qp: QueuedProposal, verb: str) -> str: - """Status-line text after a successful approval. For capability- - block, append the `resume ` hint so the operator can - bring the rebuilt bottle back up with one copy-paste.""" - base = f"{verb} {qp.proposal.tool} for [{qp.proposal.bottle_slug}]" - if qp.proposal.tool == TOOL_CAPABILITY_BLOCK: - return f"{base}; resume: ./cli.py resume {qp.proposal.bottle_slug}" - return base - - -def _is_recent( - proposal_id: str, - first_seen: dict[str, float] | None, - now: float | None, -) -> bool: - """True if `proposal_id` was first seen within the highlight - window. Both `first_seen` and `now` may be None (rendered as - not-recent) so the helper is safe in cold-start paths.""" - if first_seen is None or now is None: - return False - started = first_seen.get(proposal_id) - if started is None: - return False - return (now - started) < _NEW_PROPOSAL_HIGHLIGHT_SEC - - -def _selection_status( - focus: str, agents: list[ActiveAgent], selected_agent: int, -) -> str: - """Status-line text for the idle state. Surfaces the agents- - pane selection so the operator can tell what an agent-scoped - edit verb would target.""" - if focus != PANE_AGENTS: - return "" - if not agents: - return "[no active agents]" - if 0 <= selected_agent < len(agents): - return f"[selected: {agents[selected_agent].slug}]" - return "[no agent selected]" - - -def _selected_agent( - focus: str, agents: list[ActiveAgent], selected_agent: int, -) -> ActiveAgent | None: - """The selected agent to scope `e` / `p` to, or None if no - selection is valid (proposals pane focused, no active agents, - or selection out of bounds).""" - if focus != PANE_AGENTS: - return None - if not agents: - return None - if 0 <= selected_agent < len(agents): - return agents[selected_agent] - return None - - -# --- Picker helpers ---------------------------------------------------------- - - -def _filter_agents(query: str, names: list[str]) -> list[str]: - """Case-insensitive substring filter for the picker. Pure - function — no curses, easy to unit-test.""" - if not query: - return list(names) - q = query.lower() - return [n for n in names if q in n.lower()] - - -def _running_counts( - bottles: dict, agents_now: list[ActiveAgent], -) -> dict[str, int]: - """Per-agent running count: dashboard-owned + externally- - discovered, summed by agent_name. The picker shows this so the - operator knows whether picking an agent starts a fresh bottle - or a Nth one.""" - counts: dict[str, int] = {} - for a in agents_now: - counts[a.agent_name] = counts.get(a.agent_name, 0) + 1 - return counts - - -# --- Agent-row rendering helpers --------------------------------------------- - - -def _format_agent_row(a: ActiveAgent, maxw: int) -> str: - """One-line agent row: ` [] started - []`. The `agent` service is filtered out of - the displayed list — it's always present for an active bottle, - so listing it carries no information; the sidecars are the - differentiator. - - The `[docker]` / `[smolmachines]` prefix lets the operator tell - which backend a bottle came from (issue #77). Truncated to - `maxw` because the renderer's addnstr only enforces width if - we hand it a properly-sized string.""" - started = ( - a.started_at.split("T", 1)[1][:8] - if "T" in a.started_at else (a.started_at or "?") - ) - sidecars = tuple(s for s in a.services if s != "agent") - services = ",".join(sidecars) if sidecars else "(starting)" - backend_tag = f"[{a.backend_name}]" if a.backend_name else "" - line = ( - f" {backend_tag} {a.slug} {a.agent_name} " - f"started {started} [{services}]" - ) - if len(line) > maxw: - return line[: max(0, maxw - 1)] + "…" - return line - - -# --- Detail-view helpers ----------------------------------------------------- - - -def _detail_lines( - qp: QueuedProposal, - *, - green_attr: int = 0, -) -> list[tuple[str, int]]: - """Return the detail-view body as (text, curses-attr) tuples. - Most lines are plain (attr=0); pipelock-block proposals append - a green "→ would allow host: ..." line so the operator sees at - a glance which hostname will land in pipelock's allowlist if - they hit approve. The URL itself is shown above for context.""" - p = qp.proposal - out: list[tuple[str, int]] = [ - (f"bottle: {p.bottle_slug}", 0), - (f"tool: {p.tool}", 0), - (f"id: {p.id}", 0), - (f"arrived: {p.arrival_timestamp}", 0), - (f"queue: {qp.queue_dir}", 0), - ("", 0), - ("justification:", 0), - ] - out.extend((" " + line, 0) for line in p.justification.splitlines() or [""]) - out.extend([ - ("", 0), - (_proposed_payload_label(p.tool) + ":", 0), - ]) - out.extend((line, 0) for line in p.proposed_file.splitlines() or [""]) - if p.tool == TOOL_PIPELOCK_BLOCK: - host = _failed_url_host(p.proposed_file) - if host: - out.append(("", 0)) - out.append((host, green_attr)) - return out - - -def _failed_url_host(url: str) -> str: - """Best-effort hostname extraction from a pipelock-block proposal's - failed_url payload. Returns empty string on unparseable input — - callers handle empty as "nothing to highlight".""" - import urllib.parse - try: - return urllib.parse.urlsplit(url.strip()).hostname or "" - except ValueError: - return "" - - -def _proposed_payload_label(tool: str) -> str: - """The detail-view section heading for the proposal's payload — - `proposed_file` is what the dataclass calls it, but for - pipelock-block the payload is a single URL not a file. Render - the label per tool so the operator's eye matches.""" - if tool == TOOL_PIPELOCK_BLOCK: - return "failed URL" - return "proposed file" - - -def _suffix_for_tool(tool: str) -> str: - if tool == TOOL_CAPABILITY_BLOCK: - return ".dockerfile" - return ".txt" - - -# --- Bottle/agent resolution ------------------------------------------------- - - -def _bottle_for_slug( - slug: str, - bottles: dict, - manifest: Manifest | None, -) -> tuple["object", str]: - """Return `(bottle_handle, prompt_path_hint)` for a re-attach. - If the slug is in `bottles` (dashboard-owned), return the stored - handle directly. Otherwise synthesize a bottle from the persisted - metadata. The backend field in metadata (PRD 0040) selects Docker - or smolmachines; unknown or missing metadata defaults to Docker. - - Returns the empty string for prompt_path_hint when we omit the - flag — the caller passes None to DockerBottle in that case.""" - from ..backend.docker.bottle import DockerBottle - from ..backend.docker.bottle_state import read_metadata - from ..backend.smolmachines.bottle import SmolmachinesBottle - if slug in bottles: - _cm, bottle, _identity = bottles[slug] - return bottle, "" - instance_name = f"bot-bottle-{slug}" - prompt_path: str | None = None - metadata = read_metadata(slug) - if metadata is not None and manifest is not None: - agent = manifest.agents.get(metadata.agent_name) - if agent is not None and agent.prompt: - container_home = os.environ.get( - "BOT_BOTTLE_CONTAINER_HOME", "/home/node", - ) - prompt_path = f"{container_home}/.bot-bottle-prompt.txt" - backend = metadata.backend if metadata is not None else "" - if backend == "smolmachines": - synth: object = SmolmachinesBottle( - instance_name, - prompt_path=prompt_path, - ) - else: - synth = DockerBottle( - container=instance_name, - teardown=lambda: None, - prompt_path_in_container=prompt_path, - ) - return synth, (prompt_path or "") - - -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] - - -# --- tmux argv builders ------------------------------------------------------ - - -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 _agent_runtime_args( - *, resume: bool, remote_control: bool = False, agent_provider_template: str = "claude", -) -> list[str]: - """The argv the dashboard hands to `bottle.agent_argv` - on every attach — matches what `attach_agent` builds for the - foreground handoff so both surfaces produce the same claude - invocation.""" - runtime = runtime_for(agent_provider_template) - args = list(runtime.bypass_args) - if remote_control: - args.extend(runtime.remote_control_args) - if resume: - args.extend(runtime.resume_args) - return args - - -def _build_resume_argv_with_fallback( - bottle, *, remote_control: bool = False, agent_provider_template: str = "claude", -) -> list[str]: - """Build a backend-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 bottle, 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. - - Works across backends because `bottle.agent_argv` always - surfaces the `claude` token preceded by the backend's exec - framing (docker: `docker exec -it `; smolmachines: - `smolvm machine exec --name -- runuser -u node --`). - Splitting at `claude` keeps the framing as the prefix and - wraps just the agent tail in `sh -c`.""" - if agent_provider_template != "claude": - return bottle.agent_argv( - _agent_runtime_args( - resume=True, - remote_control=remote_control, - agent_provider_template=agent_provider_template, - ) - ) - base_args = _agent_runtime_args( - resume=False, - remote_control=remote_control, - agent_provider_template=agent_provider_template, - ) - base_exec = bottle.agent_argv(base_args) - # Split exec-framing prefix from the agent-and-args tail so - # we can compose ` --continue || ` inside - # `sh -c`. The provider command token is the marker. - command = getattr(bottle, "agent_command", runtime_for(agent_provider_template).command) - agent_idx = base_exec.index(command) - prefix = base_exec[:agent_idx] - agent_cmd = " ".join(shlex.quote(a) for a in base_exec[agent_idx:]) - resume_args = " ".join( - shlex.quote(a) for a in runtime_for(agent_provider_template).resume_args - ) - return [ - *prefix, - "sh", "-c", - f"{agent_cmd} {resume_args} || {agent_cmd}", - ] - - -def _build_split_pane_argv(agent_argv: list[str]) -> list[str]: - """Pure helper: wrap a backend-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}", - *agent_argv, - ] - - -def _build_respawn_pane_argv(pane_id: str, agent_argv: list[str]) -> list[str]: - """Pure helper: wrap a backend-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, *agent_argv] diff --git a/bot_bottle/cli/supervise.py b/bot_bottle/cli/supervise.py new file mode 100644 index 0000000..ca15403 --- /dev/null +++ b/bot_bottle/cli/supervise.py @@ -0,0 +1,620 @@ +"""supervise: list pending supervise proposals across all bottles and +act on them (approve / modify / reject). + +Curses-based TUI; modify-then-approve shells out to $EDITOR. The +approval handlers wire to the per-tool remediation engines: +PRD 0014 (egress, retargeted from cred-proxy in PRD 0017 +chunk 3) writes routes.yaml + SIGHUPs egress; PRD 0015 +(pipelock) writes the allowlist + restarts pipelock; PRD 0016 +(capability) rebuilds the bottle Dockerfile. +""" + +from __future__ import annotations + +import argparse +import curses +import os +import subprocess +import sys +import tempfile +import time +import traceback +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +from .. import supervise as _supervise +from ..backend.docker.bottle_state import read_metadata +from ..backend.docker.capability_apply import ( + CapabilityApplyError, + apply_capability_change, +) +from ..backend.docker.egress_apply import EgressApplyError, add_route +from ..backend.docker.pipelock_apply import ( + PipelockApplyError, + apply_allowlist_change, + fetch_current_allowlist, + parse_allowlist_content, + render_allowlist_content, +) +from ..log import Die, error, info +from ..supervise import ( + COMPONENT_FOR_TOOL, + AuditEntry, + Proposal, + Response, + STATUS_APPROVED, + STATUS_MODIFIED, + STATUS_REJECTED, + TOOL_CAPABILITY_BLOCK, + TOOL_EGRESS_BLOCK, + TOOL_PIPELOCK_BLOCK, + archive_proposal, + list_pending_proposals, + render_diff, + write_audit_entry, + write_response, +) +from ._common import PROG + + +_REFRESH_INTERVAL_MS = 1000 +_NEW_PROPOSAL_HIGHLIGHT_SEC = 5.0 + + +@dataclass(frozen=True) +class QueuedProposal: + """A pending proposal plus the queue dir it was found in.""" + + proposal: Proposal + queue_dir: Path + + +# Errors any remediation engine may raise. Caught by the TUI key +# handlers and surfaced in the status line so a failed apply keeps +# the proposal pending rather than crashing curses. +ApplyError = (EgressApplyError, PipelockApplyError, CapabilityApplyError) + + +def discover_pending() -> list[QueuedProposal]: + """Walk ~/.bot-bottle/queue/* and collect pending proposals.""" + queue_root = _supervise.bot_bottle_root() / "queue" + if not queue_root.is_dir(): + return [] + out: list[QueuedProposal] = [] + for slug_dir in sorted(queue_root.iterdir()): + if not slug_dir.is_dir(): + continue + for proposal in list_pending_proposals(slug_dir): + out.append(QueuedProposal(proposal=proposal, queue_dir=slug_dir)) + out.sort(key=lambda q: q.proposal.arrival_timestamp) + return out + + +def _approval_status(qp: QueuedProposal, verb: str) -> str: + """Status-line text after a successful approval.""" + base = f"{verb} {qp.proposal.tool} for [{qp.proposal.bottle_slug}]" + if qp.proposal.tool == TOOL_CAPABILITY_BLOCK: + return f"{base}; resume: ./cli.py resume {qp.proposal.bottle_slug}" + return base + + +def _is_recent( + proposal_id: str, + first_seen: dict[str, float] | None, + now: float | None, +) -> bool: + """True if `proposal_id` was first seen within the highlight window.""" + if first_seen is None or now is None: + return False + started = first_seen.get(proposal_id) + if started is None: + return False + return (now - started) < _NEW_PROPOSAL_HIGHLIGHT_SEC + + +def _detail_lines( + qp: QueuedProposal, + *, + green_attr: int = 0, +) -> list[tuple[str, int]]: + """Return the detail-view body as (text, curses-attr) tuples.""" + p = qp.proposal + out: list[tuple[str, int]] = [ + (f"bottle: {p.bottle_slug}", 0), + (f"tool: {p.tool}", 0), + (f"id: {p.id}", 0), + (f"arrived: {p.arrival_timestamp}", 0), + (f"queue: {qp.queue_dir}", 0), + ("", 0), + ("justification:", 0), + ] + out.extend((" " + line, 0) for line in p.justification.splitlines() or [""]) + out.extend([ + ("", 0), + (_proposed_payload_label(p.tool) + ":", 0), + ]) + out.extend((line, 0) for line in p.proposed_file.splitlines() or [""]) + if p.tool == TOOL_PIPELOCK_BLOCK: + host = _failed_url_host(p.proposed_file) + if host: + out.append(("", 0)) + out.append((host, green_attr)) + return out + + +def _failed_url_host(url: str) -> str: + """Best-effort hostname extraction from a pipelock-block proposal.""" + import urllib.parse + + try: + return urllib.parse.urlsplit(url.strip()).hostname or "" + except ValueError: + return "" + + +def _proposed_payload_label(tool: str) -> str: + if tool == TOOL_PIPELOCK_BLOCK: + return "failed URL" + return "proposed file" + + +def _suffix_for_tool(tool: str) -> str: + if tool == TOOL_CAPABILITY_BLOCK: + return ".dockerfile" + return ".txt" + + +# --- Operator actions ------------------------------------------------------ + + +def approve( + qp: QueuedProposal, + *, + notes: str = "", + final_file: str | None = None, +) -> None: + """Apply the proposal, write the waiting response, and audit it.""" + status = STATUS_MODIFIED if final_file is not None else STATUS_APPROVED + file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file + + diff_before, diff_after = "", "" + if qp.proposal.tool == TOOL_EGRESS_BLOCK: + diff_before, diff_after = add_route( + qp.proposal.bottle_slug, file_to_apply, + ) + elif qp.proposal.tool == TOOL_PIPELOCK_BLOCK: + diff_before, diff_after = _apply_pipelock_url( + qp.proposal.bottle_slug, file_to_apply, + ) + elif qp.proposal.tool == TOOL_CAPABILITY_BLOCK: + _meta = read_metadata(qp.proposal.bottle_slug) + if _meta is not None and not _meta.compose_project: + raise CapabilityApplyError( + "capability-block remediation is not supported for smolmachines " + "bottles. Reject this proposal or handle the capability change " + "manually, then restart the bottle." + ) + diff_before, diff_after = apply_capability_change( + qp.proposal.bottle_slug, file_to_apply, + ) + + response = Response( + proposal_id=qp.proposal.id, + status=status, + notes=notes, + final_file=final_file, + ) + write_response(qp.queue_dir, response) + _write_audit( + qp, action=status, notes=notes, + diff_before=diff_before, diff_after=diff_after, + ) + if qp.proposal.tool == TOOL_CAPABILITY_BLOCK: + archive_proposal(qp.queue_dir, qp.proposal.id) + + +def reject(qp: QueuedProposal, *, reason: str) -> None: + """Write a rejection response and an audit entry.""" + response = Response( + proposal_id=qp.proposal.id, + status=STATUS_REJECTED, + notes=reason, + final_file=None, + ) + write_response(qp.queue_dir, response) + _write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="") + + +def _apply_pipelock_url(slug: str, failed_url: str) -> tuple[str, str]: + """Merge a pipelock-block failed URL's host into the allowlist.""" + import urllib.parse + + parsed = urllib.parse.urlsplit(failed_url.strip()) + host = parsed.hostname or "" + if not host: + raise PipelockApplyError( + f"proposed failed_url has no extractable host: {failed_url!r}" + ) + current = fetch_current_allowlist(slug) + hosts = parse_allowlist_content(current) + if host not in hosts: + hosts.append(host) + return apply_allowlist_change(slug, render_allowlist_content(hosts)) + + +def _write_audit( + qp: QueuedProposal, + *, + action: str, + notes: str, + diff_before: str, + diff_after: str, +) -> None: + """Audit log for egress / pipelock tools.""" + component = COMPONENT_FOR_TOOL.get(qp.proposal.tool) + if component is None: + return + write_audit_entry(AuditEntry( + timestamp=datetime.now(timezone.utc).isoformat(), + bottle_slug=qp.proposal.bottle_slug, + component=component, + operator_action=action, + operator_notes=notes, + justification=qp.proposal.justification, + diff=render_diff(diff_before, diff_after, label=component), + )) + + +# --- $EDITOR integration -------------------------------------------------- + + +def edit_in_editor(content: str, *, suffix: str = ".tmp") -> str | None: + """Open `content` in $EDITOR and return edited content, if changed.""" + editor = os.environ.get("EDITOR", "vim") + with tempfile.NamedTemporaryFile( + mode="w", suffix=suffix, delete=False, prefix="supervise-modify.", + ) as f: + f.write(content) + path = f.name + try: + subprocess.run([editor, path], check=False) + with open(path) as f: + edited = f.read() + return edited if edited != content else None + finally: + try: + os.unlink(path) + except OSError: + pass + + +# --- TUI ------------------------------------------------------------------- + + +def cmd_supervise(argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog=f"{PROG} supervise", add_help=True) + parser.add_argument( + "--once", action="store_true", + help="list pending proposals once and exit (no TUI)", + ) + args = parser.parse_args(argv) + + if args.once: + return _list_once() + try: + curses.wrapper(_main_loop) + except KeyboardInterrupt: + return 130 + except Die as e: + if e.message: + error(e.message) + else: + error("supervise exited on a fatal error (no detail captured).") + return e.code if isinstance(e.code, int) else 1 + except Exception as e: + log_path = _write_crash_log(e) + error(f"supervise crashed: {type(e).__name__}: {e}") + error(f"full traceback written to {log_path}") + return 1 + return 0 + + +def _write_crash_log(exc: BaseException) -> Path: + """Persist `exc`'s traceback to a stable file under ~/.bot-bottle/.""" + stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + body = "".join( + traceback.format_exception(type(exc), exc, exc.__traceback__) + ) + entry = f"=== supervise crash {stamp} ===\n{body}\n" + try: + log_dir = _supervise.bot_bottle_root() / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + path = log_dir / "supervise-crash.log" + with path.open("a", encoding="utf-8") as fh: + fh.write(entry) + return path + except OSError: + fd, tmp = tempfile.mkstemp( + prefix="bot-bottle-supervise-crash-", suffix=".log", + ) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(entry) + return Path(tmp) + + +def _list_once() -> int: + pending = discover_pending() + if not pending: + info("no pending proposals") + return 0 + for qp in pending: + sys.stdout.write( + f"{qp.proposal.arrival_timestamp} " + f"[{qp.proposal.bottle_slug}] " + f"{qp.proposal.tool} " + f"{qp.proposal.id}\n" + ) + sys.stdout.write(f" {qp.proposal.justification}\n") + return 0 + + +def _try_init_green() -> int: + """Initialise a green color pair and return its attr, or 0.""" + try: + curses.start_color() + curses.use_default_colors() + curses.init_pair(1, curses.COLOR_GREEN, -1) + return curses.color_pair(1) + except curses.error: + return 0 + + +def _in_tmux() -> bool: + return bool(os.environ.get("TMUX")) + + +def _select_tmux_pane(pane_id: str) -> None: + try: + subprocess.run( + ["tmux", "select-pane", "-t", pane_id], + capture_output=True, text=True, check=False, + ) + except FileNotFoundError: + pass + + +def _main_loop(stdscr: "curses._CursesWindow") -> None: + curses.curs_set(0) + stdscr.timeout(_REFRESH_INTERVAL_MS) + green_attr = _try_init_green() + first_seen: dict[str, float] = {} + selected = 0 + status_line = "" + saw_first_tick = False + supervise_pane_id = os.environ.get("TMUX_PANE", "") + + while True: + pending = discover_pending() + if selected >= len(pending): + selected = max(0, len(pending) - 1) + + now = time.monotonic() + live_ids = {qp.proposal.id for qp in pending} + newly_arrived = live_ids - first_seen.keys() + if saw_first_tick and newly_arrived: + try: + curses.beep() + except curses.error: + pass + if supervise_pane_id and _in_tmux(): + _select_tmux_pane(supervise_pane_id) + for i, qp in enumerate(pending): + if qp.proposal.id in newly_arrived: + selected = i + break + for proposal_id in live_ids: + first_seen.setdefault(proposal_id, now) + for stale_id in list(first_seen.keys() - live_ids): + del first_seen[stale_id] + saw_first_tick = True + + _render( + stdscr, pending, selected, status_line, + first_seen=first_seen, now=now, green_attr=green_attr, + ) + + try: + key = stdscr.getch() + except KeyboardInterrupt: + return + + if key == -1: + continue + + status_line = "" + + if key in (ord("q"), 27): + return + + if not pending: + continue + qp = pending[selected] + + if key in (curses.KEY_DOWN, ord("j")): + selected = min(selected + 1, len(pending) - 1) + elif key in (curses.KEY_UP, ord("k")): + selected = max(selected - 1, 0) + elif key in (curses.KEY_ENTER, 10, 13): + _detail_view(stdscr, qp, green_attr=green_attr) + elif key == ord("a"): + try: + approve(qp) + status_line = _approval_status(qp, "approved") + except ApplyError as e: + status_line = f"apply failed: {e}" + elif key == ord("m"): + edited = _modify(stdscr, qp) + if edited is None: + status_line = "modify aborted (no change)" + else: + try: + approve(qp, final_file=edited, notes="operator modified before approving") + status_line = _approval_status(qp, "modified+approved") + except ApplyError as e: + status_line = f"apply failed: {e}" + elif key == ord("r"): + reason = _prompt(stdscr, "reject reason: ") + if reason: + reject(qp, reason=reason) + status_line = f"rejected {qp.proposal.tool} for [{qp.proposal.bottle_slug}]" + else: + status_line = "reject aborted (empty reason)" + + +def _render( + stdscr: "curses._CursesWindow", + pending: list[QueuedProposal], + selected: int, + status_line: str, + *, + first_seen: dict[str, float] | None = None, + now: float | None = None, + green_attr: int = 0, +) -> None: + stdscr.erase() + h, w = stdscr.getmaxyx() + header = f"bot-bottle supervise ({len(pending)} pending)" + stdscr.addnstr(0, 0, header, w - 1, curses.A_BOLD) + stdscr.hline(1, 0, curses.ACS_HLINE, w) + + row = 2 + if not pending: + stdscr.addnstr( + row, 2, + "no pending proposals; agents will queue here when they call a " + "supervise tool", + w - 4, + ) + else: + for i, qp in enumerate(pending): + if row >= h - 3: + break + p = qp.proposal + ts_short = ( + p.arrival_timestamp.split("T", 1)[1][:8] + if "T" in p.arrival_timestamp else p.arrival_timestamp + ) + cursor = "> " if i == selected else " " + line = ( + f"{cursor}{ts_short} " + f"[{p.bottle_slug}] {p.tool:<18} {p.id[:8]} " + f"{_proposed_payload_label(p.tool)}" + ) + attr = curses.A_REVERSE if i == selected else curses.A_NORMAL + if _is_recent(p.id, first_seen, now): + attr |= green_attr + stdscr.addnstr(row, 0, line, w - 1, attr) + row += 1 + if row >= h - 3: + break + if p.justification: + stdscr.addnstr(row, 4, p.justification[: max(0, w - 5)], w - 5) + row += 1 + + footer = "[j/k] move [Enter] view [a] approve [m] modify [r] reject [q] quit" + stdscr.hline(h - 2, 0, curses.ACS_HLINE, w) + stdscr.addnstr(h - 1, 0, footer, w - 1, curses.A_DIM) + if status_line: + stdscr.addnstr(h - 3, 0, status_line, w - 1, curses.A_BOLD) + stdscr.refresh() + + +def _detail_view( + stdscr: "curses._CursesWindow", + qp: QueuedProposal, + *, + green_attr: int = 0, +) -> None: + """Render the full proposal. Scrollable. Press q to return.""" + lines = _detail_lines(qp, green_attr=green_attr) + offset = 0 + while True: + stdscr.erase() + h, w = stdscr.getmaxyx() + for i, (text, attr) in enumerate(lines[offset:offset + h - 1]): + stdscr.addnstr(i, 0, text, w - 1, attr) + stdscr.addnstr( + h - 1, 0, + "[j/k] scroll [g/G] top/bottom [a] approve [m] modify [r] reject [q] back", + w - 1, curses.A_DIM, + ) + stdscr.refresh() + key = stdscr.getch() + if key in (ord("q"), 27): + return + if key in (curses.KEY_DOWN, ord("j")): + offset = min(offset + 1, max(0, len(lines) - 1)) + elif key in (curses.KEY_UP, ord("k")): + offset = max(offset - 1, 0) + elif key == ord("g"): + offset = 0 + elif key == ord("G"): + offset = max(0, len(lines) - 1) + elif key == ord("a"): + try: + approve(qp) + except ApplyError: + pass + return + elif key == ord("m"): + edited = _modify(stdscr, qp) + if edited is not None: + try: + approve(qp, final_file=edited, notes="operator modified before approving") + except ApplyError: + pass + return + elif key == ord("r"): + reason = _prompt(stdscr, "reject reason: ") + if reason: + reject(qp, reason=reason) + return + + +def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None: + """Suspend curses, open $EDITOR on the proposed file, return edited content.""" + suffix = _suffix_for_tool(qp.proposal.tool) + curses.endwin() + try: + edited = edit_in_editor(qp.proposal.proposed_file, suffix=suffix) + finally: + stdscr.refresh() + return edited + + +def _prompt(stdscr: "curses._CursesWindow", label: str) -> str: + """One-line input at the bottom of the screen.""" + curses.curs_set(1) + h, _ = stdscr.getmaxyx() + stdscr.move(h - 2, 0) + stdscr.clrtoeol() + stdscr.addstr(h - 2, 0, label) + stdscr.refresh() + curses.echo() + try: + raw = stdscr.getstr(h - 2, len(label), 200) + finally: + curses.noecho() + curses.curs_set(0) + return raw.decode("utf-8", errors="replace").strip() + + +__all__ = [ + "QueuedProposal", + "approve", + "cmd_supervise", + "discover_pending", + "edit_in_editor", + "reject", +] -- 2.52.0 From 41570e04c0e64b16bf730dc1f0ed2f563b047e59 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 3 Jun 2026 17:25:09 +0000 Subject: [PATCH 04/12] test(cli): update supervise triage coverage --- tests/unit/test_bottle_state.py | 46 -- tests/unit/test_dashboard_active_agents.py | 492 ------------------ tests/unit/test_dashboard_model.py | 94 ---- ...est_dashboard.py => test_supervise_cli.py} | 74 +-- ...py => test_supervise_cli_crash_logging.py} | 24 +- ....py => test_supervise_cli_detail_lines.py} | 6 +- ...ght.py => test_supervise_cli_highlight.py} | 4 +- 7 files changed, 23 insertions(+), 717 deletions(-) delete mode 100644 tests/unit/test_dashboard_active_agents.py delete mode 100644 tests/unit/test_dashboard_model.py rename tests/unit/{test_dashboard.py => test_supervise_cli.py} (88%) rename tests/unit/{test_dashboard_crash_logging.py => test_supervise_cli_crash_logging.py} (86%) rename tests/unit/{test_dashboard_detail_lines.py => test_supervise_cli_detail_lines.py} (95%) rename tests/unit/{test_dashboard_highlight.py => test_supervise_cli_highlight.py} (91%) diff --git a/tests/unit/test_bottle_state.py b/tests/unit/test_bottle_state.py index d0c032c..9714471 100644 --- a/tests/unit/test_bottle_state.py +++ b/tests/unit/test_bottle_state.py @@ -277,51 +277,5 @@ class TestBottleMetadataBackend(_FakeHomeMixin, unittest.TestCase): self.assertEqual("", loaded.backend) -class TestBottleForSlugBackend(_FakeHomeMixin, unittest.TestCase): - """PRD 0040: _bottle_for_slug constructs the right bottle type.""" - - def setUp(self): - self._setup_fake_home() - - def tearDown(self): - self._teardown_fake_home() - - def test_docker_metadata_returns_docker_bottle(self): - from bot_bottle.backend.docker.bottle import DockerBottle - from bot_bottle.cli.dashboard import _bottle_for_slug - write_metadata(BottleMetadata( - identity="dev-d1", - agent_name="dev", - cwd="", - copy_cwd=False, - started_at="2026-06-02T00:00:00+00:00", - compose_project="bot-bottle-dev-d1", - backend="docker", - )) - bottle, _ = _bottle_for_slug("dev-d1", {}, None) - self.assertIsInstance(bottle, DockerBottle) - - def test_smolmachines_metadata_returns_smolmachines_bottle(self): - from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle - from bot_bottle.cli.dashboard import _bottle_for_slug - write_metadata(BottleMetadata( - identity="dev-s1", - agent_name="dev", - cwd="", - copy_cwd=False, - started_at="2026-06-02T00:00:00+00:00", - compose_project="", - backend="smolmachines", - )) - bottle, _ = _bottle_for_slug("dev-s1", {}, None) - self.assertIsInstance(bottle, SmolmachinesBottle) - - def test_no_metadata_defaults_to_docker_bottle(self): - from bot_bottle.backend.docker.bottle import DockerBottle - from bot_bottle.cli.dashboard import _bottle_for_slug - bottle, _ = _bottle_for_slug("unknown-slug", {}, None) - self.assertIsInstance(bottle, DockerBottle) - - if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_dashboard_active_agents.py b/tests/unit/test_dashboard_active_agents.py deleted file mode 100644 index 209cb8e..0000000 --- a/tests/unit/test_dashboard_active_agents.py +++ /dev/null @@ -1,492 +0,0 @@ -"""Unit: dashboard's row-formatting + selection helpers (PRD 0019).""" - -from __future__ import annotations - -import tempfile -import unittest -from pathlib import Path -from unittest import mock - -from bot_bottle import supervise -from bot_bottle.cli import dashboard - - -class _FakeHomeMixin: - def _setup_fake_home(self) -> None: - self._tmp = tempfile.TemporaryDirectory(prefix="dashboard-aa-test.") - original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - self._restore_home = lambda: setattr(supervise, "bot_bottle_root", original) - - def _teardown_fake_home(self) -> None: - self._restore_home() - self._tmp.cleanup() - - -class TestFormatAgentRow(unittest.TestCase): - """One-line row formatting for the agents pane (PRD 0019 chunk 2).""" - - def _agent(self, **overrides) -> dashboard.ActiveAgent: - defaults = dict( - backend_name="docker", - slug="dev-abc12", - agent_name="implementer", - started_at="2026-05-26T02:55:01+00:00", - services=("egress", "git-gate", "pipelock", "supervise"), - ) - defaults.update(overrides) - return dashboard.ActiveAgent(**defaults) - - def test_renders_slug_name_time_services(self): - s = dashboard._format_agent_row(self._agent(), 200) - self.assertIn("dev-abc12", s) - self.assertIn("implementer", s) - self.assertIn("02:55:01", s) - self.assertIn("egress,git-gate,pipelock,supervise", s) - - def test_starting_label_when_no_services(self): - # Race window: compose project is up but containers haven't - # been picked up by `docker ps` yet. - s = dashboard._format_agent_row(self._agent(services=()), 200) - self.assertIn("(starting)", s) - - def test_filters_agent_service_from_display(self): - # The `agent` service is always present for an active bottle; - # listing it is noise. The row should show only the sidecars. - s = dashboard._format_agent_row( - self._agent(services=("agent", "pipelock", "supervise")), 200, - ) - self.assertIn("[pipelock,supervise]", s) - self.assertNotIn("agent,", s) - self.assertNotIn(",agent", s) - - def test_only_agent_service_shows_starting(self): - # A bottle whose only running service is `agent` (sidecars - # still warming up) renders as `(starting)`. - s = dashboard._format_agent_row(self._agent(services=("agent",)), 200) - self.assertIn("(starting)", s) - - def test_question_mark_when_no_started_at(self): - s = dashboard._format_agent_row(self._agent(started_at=""), 200) - self.assertIn("started ?", s) - - def test_truncates_to_maxw(self): - s = dashboard._format_agent_row(self._agent(), 30) - self.assertLessEqual(len(s), 30) - self.assertTrue(s.endswith("…")) - - -class TestSelectionStatus(unittest.TestCase): - """Idle-state status-line text for the agents-pane focus - (PRD 0019 chunk 3). Empty when the proposals pane is focused; - surfaces the selected agent (or a clear placeholder) when the - agents pane is focused.""" - - def _agent(self, slug: str) -> dashboard.ActiveAgent: - return dashboard.ActiveAgent( - backend_name="docker", - slug=slug, agent_name="x", started_at="", services=(), - ) - - def test_empty_when_proposals_focused(self): - s = dashboard._selection_status( - dashboard.PANE_PROPOSALS, [self._agent("a-1")], 0, - ) - self.assertEqual("", s) - - def test_no_agents_message_when_agents_pane_empty(self): - s = dashboard._selection_status(dashboard.PANE_AGENTS, [], 0) - self.assertEqual("[no active agents]", s) - - def test_shows_selected_slug(self): - agents = [self._agent("a-1"), self._agent("b-2"), self._agent("c-3")] - s = dashboard._selection_status(dashboard.PANE_AGENTS, agents, 1) - self.assertEqual("[selected: b-2]", s) - - def test_out_of_bounds_falls_back_to_no_selection(self): - agents = [self._agent("only")] - s = dashboard._selection_status(dashboard.PANE_AGENTS, agents, 99) - self.assertEqual("[no agent selected]", s) - - -class TestFilterAgents(unittest.TestCase): - """Pure-function picker filter (PRD 0020 chunk 2). Curses-free - so we can exercise the substring + case-insensitivity rules - directly.""" - - NAMES = ["implementer", "researcher", "triage-bot", "ImplDeluxe"] - - def test_empty_query_returns_all(self): - self.assertEqual(self.NAMES, dashboard._filter_agents("", self.NAMES)) - - def test_substring_match(self): - self.assertEqual( - ["implementer", "ImplDeluxe"], - dashboard._filter_agents("impl", self.NAMES), - ) - - def test_case_insensitive(self): - self.assertEqual( - ["implementer", "ImplDeluxe"], - dashboard._filter_agents("IMPL", self.NAMES), - ) - - def test_no_match_returns_empty(self): - self.assertEqual([], dashboard._filter_agents("zzz", self.NAMES)) - - def test_preserves_input_order(self): - # Filtering should never re-sort; the picker draws in the - # order the manifest exposed. - out = dashboard._filter_agents("e", ["beta", "alpha", "echo"]) - self.assertEqual(["beta", "echo"], out) - - -class TestDashboardManifestLoading(unittest.TestCase): - def test_new_agent_flow_empty_manifest_has_no_picker_entries(self): - manifest = dashboard.Manifest.from_json_obj({"bottles": {}, "agents": {}}) - with mock.patch("bot_bottle.cli.dashboard._picker_modal", return_value=None) as picker: - status = dashboard._new_agent_flow( - None, manifest, {}, [], tmux_state=None, # type: ignore[arg-type] - ) - picker.assert_called_once() - self.assertEqual([], picker.call_args.args[1]) - self.assertIn("no agents configured", status) - - -class TestRunningCounts(unittest.TestCase): - """Per-agent running-count surfaced in the picker so the - operator sees `(N running)` before picking. Counts come from - the dashboard's current `discover_active_agents` snapshot.""" - - def _agent(self, agent_name: str) -> dashboard.ActiveAgent: - return dashboard.ActiveAgent( - backend_name="docker", - slug=f"{agent_name}-abc", - agent_name=agent_name, - started_at="", - services=(), - ) - - def test_empty_when_no_active_agents(self): - self.assertEqual({}, dashboard._running_counts({}, [])) - - def test_one_per_unique_agent_name(self): - agents = [self._agent("a"), self._agent("b"), self._agent("c")] - self.assertEqual( - {"a": 1, "b": 1, "c": 1}, - dashboard._running_counts({}, agents), - ) - - def test_counts_collisions(self): - agents = [ - self._agent("implementer"), - self._agent("implementer"), - self._agent("researcher"), - ] - self.assertEqual( - {"implementer": 2, "researcher": 1}, - dashboard._running_counts({}, agents), - ) - - -class TestSelectedAgent(unittest.TestCase): - """`_selected_agent` is what chunk 4's e/p key handlers use to - decide whether to fire and which agent to target.""" - - def _agent(self, slug: str, services: tuple[str, ...] = ()) -> dashboard.ActiveAgent: - return dashboard.ActiveAgent( - backend_name="docker", - slug=slug, agent_name="x", started_at="", services=services, - ) - - def test_none_when_proposals_focused(self): - agents = [self._agent("a-1")] - self.assertIsNone( - dashboard._selected_agent(dashboard.PANE_PROPOSALS, agents, 0), - ) - - def test_none_when_no_agents(self): - self.assertIsNone( - dashboard._selected_agent(dashboard.PANE_AGENTS, [], 0), - ) - - def test_returns_indexed_agent_when_in_range(self): - agents = [self._agent("a-1"), self._agent("b-2")] - result = dashboard._selected_agent(dashboard.PANE_AGENTS, agents, 1) - self.assertIsNotNone(result) - assert result is not None # for type checker - self.assertEqual("b-2", result.slug) - - def test_none_when_index_out_of_range(self): - agents = [self._agent("only")] - self.assertIsNone( - dashboard._selected_agent(dashboard.PANE_AGENTS, agents, 99), - ) - - -class TestBottleForSlug(unittest.TestCase): - """Re-attach target resolution (PRD 0020 chunk 3). Dashboard- - owned bottles return the stored handle as-is; non-owned bottles - get a synthesized DockerBottle backed by the slug-derived - container name.""" - - def test_owned_bottle_returns_held_handle(self): - sentinel = object() - bottles = {"dev-abc": (None, sentinel, "dev-abc")} - bottle, _ = dashboard._bottle_for_slug("dev-abc", bottles, None) - self.assertIs(sentinel, bottle) - - def test_unowned_synthesizes_docker_bottle(self): - bottle, _ = dashboard._bottle_for_slug("dev-xyz", {}, None) - # The synth wraps the slug-derived container name. - self.assertEqual("bot-bottle-dev-xyz", bottle.name) - - def test_unowned_without_manifest_omits_prompt_path(self): - bottle, hint = dashboard._bottle_for_slug("dev-xyz", {}, None) - 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( - backend_name="docker", - 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 - environment-dependent; here we lock the wrapping shape so - a regression surfaces in CI without needing a real tmux.""" - - DOCKER_ARGV = [ - "docker", "exec", "-it", - "bot-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 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 bot_bottle.backend.docker.bottle import DockerBottle - return DockerBottle( - container="bot-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", "bot-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/.bot-bottle-prompt.txt") - argv = dashboard._build_resume_argv_with_fallback(bottle) - self.assertIn("--append-system-prompt-file", argv[-1]) - self.assertIn("/home/node/.bot-bottle-prompt.txt", argv[-1]) - - -class TestClaudeRuntimeArgs(unittest.TestCase): - """The argv passed to `bottle.agent_argv` on each - attach. Locked here so the tmux + foreground paths build - identical agent invocations.""" - - def test_default_skip_permissions_only(self): - self.assertEqual( - ["--dangerously-skip-permissions"], - dashboard._agent_runtime_args(resume=False), - ) - - def test_resume_appends_continue(self): - self.assertEqual( - ["--dangerously-skip-permissions", "--continue"], - dashboard._agent_runtime_args(resume=True), - ) - - def test_remote_control(self): - args = dashboard._agent_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 - 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) - - 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 - agent doesn't have the required sidecar running. The discover- - and-prompt scaffolding is gone, so the gating happens here - instead of in the key handler.""" - - def setUp(self) -> None: - self._setup_fake_home() - - def tearDown(self) -> None: - self._teardown_fake_home() - - def _agent(self, services: tuple[str, ...]) -> dashboard.ActiveAgent: - return dashboard.ActiveAgent( - backend_name="docker", - slug="dev-abc12", - agent_name="impl", - started_at="", - services=services, - ) - - def test_routes_edit_refuses_without_egress(self): - # Bottle without bottle.egress.routes → no egress sidecar. - msg = dashboard._operator_edit_flow( - stdscr=None, # type: ignore[arg-type] - agent=self._agent(("pipelock", "supervise")), - required_service="egress", - label="routes", - fetch=lambda _: "x", - apply=lambda _slug, _content: None, - suffix=".yaml", - ) - self.assertIn("no running egress sidecar", msg) - self.assertIn("dev-abc12", msg) - - def test_pipelock_edit_refuses_when_pipelock_missing(self): - # Belt-and-braces — pipelock should always be there, but - # the race window between `compose up` and `docker ps` - # update is real. - msg = dashboard._operator_edit_flow( - stdscr=None, # type: ignore[arg-type] - agent=self._agent(()), - required_service="pipelock", - label="pipelock", - fetch=lambda _: "x", - apply=lambda _slug, _content: None, - suffix=".txt", - ) - self.assertIn("no running pipelock sidecar", msg) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/test_dashboard_model.py b/tests/unit/test_dashboard_model.py deleted file mode 100644 index 3f523bd..0000000 --- a/tests/unit/test_dashboard_model.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Unit: dashboard_model — state/model layer extracted from dashboard.py. - -Tests for functions that were previously buried in the 2103-line -dashboard.py and had no coverage: _approval_status, -_proposed_payload_label, and _suffix_for_tool.""" - -import unittest -from pathlib import Path - -from bot_bottle.cli.dashboard_model import ( - QueuedProposal, - _approval_status, - _proposed_payload_label, - _suffix_for_tool, -) -from bot_bottle.supervise import ( - Proposal, - TOOL_CAPABILITY_BLOCK, - TOOL_EGRESS_BLOCK, - TOOL_PIPELOCK_BLOCK, - sha256_hex, -) -from datetime import datetime, timezone - - -def _qp(tool: str, slug: str = "dev") -> QueuedProposal: - payload = "x" - p = Proposal.new( - bottle_slug=slug, - tool=tool, - proposed_file=payload, - justification="test", - current_file_hash=sha256_hex(payload), - now=datetime(2026, 6, 1, 0, 0, 0, tzinfo=timezone.utc), - ) - return QueuedProposal(proposal=p, queue_dir=Path("/tmp/q")) - - -class TestApprovalStatus(unittest.TestCase): - def test_egress_block_base_message(self): - qp = _qp(TOOL_EGRESS_BLOCK, slug="my-bot") - msg = _approval_status(qp, "approved") - self.assertEqual("approved egress-block for [my-bot]", msg) - - def test_modified_verb(self): - qp = _qp(TOOL_PIPELOCK_BLOCK, slug="dev") - msg = _approval_status(qp, "modified+approved") - self.assertEqual("modified+approved pipelock-block for [dev]", msg) - - def test_capability_block_appends_resume_hint(self): - qp = _qp(TOOL_CAPABILITY_BLOCK, slug="alpha") - msg = _approval_status(qp, "approved") - self.assertIn("resume: ./cli.py resume alpha", msg) - self.assertIn("approved capability-block for [alpha]", msg) - - def test_egress_block_has_no_resume_hint(self): - qp = _qp(TOOL_EGRESS_BLOCK) - self.assertNotIn("resume", _approval_status(qp, "approved")) - - def test_pipelock_block_has_no_resume_hint(self): - qp = _qp(TOOL_PIPELOCK_BLOCK) - self.assertNotIn("resume", _approval_status(qp, "approved")) - - -class TestProposedPayloadLabel(unittest.TestCase): - def test_pipelock_returns_failed_url(self): - self.assertEqual("failed URL", _proposed_payload_label(TOOL_PIPELOCK_BLOCK)) - - def test_egress_returns_proposed_file(self): - self.assertEqual("proposed file", _proposed_payload_label(TOOL_EGRESS_BLOCK)) - - def test_capability_returns_proposed_file(self): - self.assertEqual("proposed file", _proposed_payload_label(TOOL_CAPABILITY_BLOCK)) - - def test_unknown_tool_returns_proposed_file(self): - self.assertEqual("proposed file", _proposed_payload_label("unknown-tool")) - - -class TestSuffixForTool(unittest.TestCase): - def test_capability_block_returns_dockerfile_suffix(self): - self.assertEqual(".dockerfile", _suffix_for_tool(TOOL_CAPABILITY_BLOCK)) - - def test_egress_block_returns_txt(self): - self.assertEqual(".txt", _suffix_for_tool(TOOL_EGRESS_BLOCK)) - - def test_pipelock_block_returns_txt(self): - self.assertEqual(".txt", _suffix_for_tool(TOOL_PIPELOCK_BLOCK)) - - def test_unknown_tool_returns_txt(self): - self.assertEqual(".txt", _suffix_for_tool("whatever")) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/test_dashboard.py b/tests/unit/test_supervise_cli.py similarity index 88% rename from tests/unit/test_dashboard.py rename to tests/unit/test_supervise_cli.py index e1e736a..be555f4 100644 --- a/tests/unit/test_dashboard.py +++ b/tests/unit/test_supervise_cli.py @@ -1,10 +1,10 @@ -"""Unit: dashboard headless paths (PRD 0013 phase 4, PRD 0014). +"""Unit: supervise headless paths (PRD 0013 phase 4, PRD 0014). The curses TUI itself isn't exercised here — these tests cover the discovery + approve/reject + audit-write paths that the TUI's key handlers call into. -apply_routes_change is stubbed at the dashboard module level so the +apply_routes_change is stubbed at the supervise module level so the tests don't need a running cred-proxy sidecar; the real docker exec/cp/SIGHUP plumbing is covered by the integration test. """ @@ -19,7 +19,7 @@ from bot_bottle import supervise from bot_bottle.backend.docker.capability_apply import CapabilityApplyError from bot_bottle.backend.docker.egress_apply import EgressApplyError from bot_bottle.backend.docker.pipelock_apply import PipelockApplyError -from bot_bottle.cli import dashboard +from bot_bottle.cli import supervise as dashboard from bot_bottle.supervise import ( Proposal, STATUS_APPROVED, @@ -61,7 +61,7 @@ class _FakeHomeMixin: """Patch supervise.bot_bottle_root to a temp dir for the test.""" def _setup_fake_home(self): - self._tmp = tempfile.TemporaryDirectory(prefix="dashboard-test.") + self._tmp = tempfile.TemporaryDirectory(prefix="supervise-test.") original = supervise.bot_bottle_root def fake_root() -> Path: @@ -306,7 +306,7 @@ class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase): class TestPipelockApplyWiring(_FakeHomeMixin, unittest.TestCase): """PRD 0015 Phase 2 + PR #25 follow-up: approve() on a - pipelock-block proposal carries the failed URL; the dashboard + pipelock-block proposal carries the failed URL; the supervise TUI extracts the host, merges it into the running allowlist, and calls apply_allowlist_change with the merged content.""" @@ -383,7 +383,7 @@ class TestPipelockApplyWiring(_FakeHomeMixin, unittest.TestCase): def test_url_without_host_raises(self): dashboard.fetch_current_allowlist = lambda slug: "" # supervise_server's validator would catch this; if a broken - # URL ever makes it through, the dashboard surfaces it too. + # URL ever makes it through, the supervise TUI surfaces it too. qp = self._enqueue_pipelock("https:///nohost") with self.assertRaises(PipelockApplyError): dashboard.approve(qp) @@ -458,68 +458,6 @@ class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase): self.assertEqual(2, len(processed)) -class TestOperatorEditRoutes(_FakeHomeMixin, unittest.TestCase): - """PRD 0014 Phase 4: operator-initiated routes edit (not gated - on a pending proposal).""" - - def setUp(self): - self._setup_fake_home() - self._original_apply = dashboard.apply_routes_change - - def tearDown(self): - dashboard.apply_routes_change = self._original_apply - self._teardown_fake_home() - - def test_writes_audit_with_operator_edit_action(self): - dashboard.apply_routes_change = lambda slug, content: ( - '{"routes": []}\n', content, - ) - dashboard.operator_edit_routes("dev", '{"routes": [{"path": "/x/"}]}\n') - entries = read_audit_entries("egress", "dev") - self.assertEqual(1, len(entries)) - self.assertEqual(supervise.ACTION_OPERATOR_EDIT, entries[0].operator_action) - self.assertEqual("", entries[0].justification) - self.assertIn("+", entries[0].diff) - - def test_failure_does_not_write_audit(self): - dashboard.apply_routes_change = lambda slug, content: (_ for _ in ()).throw( - EgressApplyError("nope") - ) - with self.assertRaises(EgressApplyError): - dashboard.operator_edit_routes("dev", '{"routes": []}\n') - self.assertEqual([], read_audit_entries("egress", "dev")) - - -class TestOperatorEditAllowlist(_FakeHomeMixin, unittest.TestCase): - """PRD 0015 Phase 3: operator-initiated pipelock allowlist edit.""" - - def setUp(self): - self._setup_fake_home() - self._original = dashboard.apply_allowlist_change - - def tearDown(self): - dashboard.apply_allowlist_change = self._original - self._teardown_fake_home() - - def test_writes_audit_with_operator_edit_action(self): - dashboard.apply_allowlist_change = lambda slug, content: ( - "old.example\n", content, - ) - dashboard.operator_edit_allowlist("dev", "old.example\nnew.example\n") - entries = read_audit_entries("pipelock", "dev") - self.assertEqual(1, len(entries)) - self.assertEqual(supervise.ACTION_OPERATOR_EDIT, entries[0].operator_action) - self.assertIn("+new.example", entries[0].diff) - - def test_failure_does_not_write_audit(self): - dashboard.apply_allowlist_change = lambda slug, content: (_ for _ in ()).throw( - PipelockApplyError("nope") - ) - with self.assertRaises(PipelockApplyError): - dashboard.operator_edit_allowlist("dev", "x.example\n") - self.assertEqual([], read_audit_entries("pipelock", "dev")) - - class TestEditInEditor(unittest.TestCase): def test_runs_editor_returns_edited_content(self): # Fake "editor" is /bin/sh -c 'cat < $1 ... EOF' diff --git a/tests/unit/test_dashboard_crash_logging.py b/tests/unit/test_supervise_cli_crash_logging.py similarity index 86% rename from tests/unit/test_dashboard_crash_logging.py rename to tests/unit/test_supervise_cli_crash_logging.py index 56dff42..d581764 100644 --- a/tests/unit/test_dashboard_crash_logging.py +++ b/tests/unit/test_supervise_cli_crash_logging.py @@ -1,4 +1,4 @@ -"""Unit: dashboard launch/crash failure logging (issue #100). +"""Unit: supervise launch/crash failure logging (issue #100). The dashboard runs under curses, so anything written to stderr while the TUI owns the terminal is wiped when the terminal is restored. These @@ -17,7 +17,7 @@ from pathlib import Path from unittest import mock from bot_bottle import supervise -from bot_bottle.cli import dashboard +from bot_bottle.cli import supervise as dashboard from bot_bottle.log import Die, die @@ -44,7 +44,7 @@ class _FakeHomeMixin: ~/.bot-bottle.""" def _setup_fake_home(self): - self._tmp = tempfile.TemporaryDirectory(prefix="dash-crash-test.") + self._tmp = tempfile.TemporaryDirectory(prefix="supervise-crash-test.") self._orig_root = supervise.bot_bottle_root self._root = Path(self._tmp.name) / ".bot-bottle" supervise.bot_bottle_root = lambda: self._root # type: ignore[assignment] @@ -54,7 +54,7 @@ class _FakeHomeMixin: self._tmp.cleanup() -class TestCmdDashboardErrorPaths(_FakeHomeMixin, unittest.TestCase): +class TestCmdSuperviseErrorPaths(_FakeHomeMixin, unittest.TestCase): def setUp(self): self._setup_fake_home() @@ -65,7 +65,7 @@ class TestCmdDashboardErrorPaths(_FakeHomeMixin, unittest.TestCase): with mock.patch.object( dashboard.curses, "wrapper", side_effect=KeyboardInterrupt ): - self.assertEqual(130, dashboard.cmd_dashboard([])) + self.assertEqual(130, dashboard.cmd_supervise([])) def test_die_resurfaces_message_after_curses(self): buf = io.StringIO() @@ -74,7 +74,7 @@ class TestCmdDashboardErrorPaths(_FakeHomeMixin, unittest.TestCase): side_effect=Die(1, "manifest parse error at line 3"), ): with contextlib.redirect_stderr(buf): - rc = dashboard.cmd_dashboard([]) + rc = dashboard.cmd_supervise([]) self.assertEqual(1, rc) self.assertIn("manifest parse error at line 3", buf.getvalue()) @@ -82,7 +82,7 @@ class TestCmdDashboardErrorPaths(_FakeHomeMixin, unittest.TestCase): buf = io.StringIO() with mock.patch.object(dashboard.curses, "wrapper", side_effect=Die(1)): with contextlib.redirect_stderr(buf): - rc = dashboard.cmd_dashboard([]) + rc = dashboard.cmd_supervise([]) self.assertEqual(1, rc) self.assertIn("fatal error", buf.getvalue()) @@ -93,12 +93,12 @@ class TestCmdDashboardErrorPaths(_FakeHomeMixin, unittest.TestCase): side_effect=ValueError("kaboom in render"), ): with contextlib.redirect_stderr(buf): - rc = dashboard.cmd_dashboard([]) + rc = dashboard.cmd_supervise([]) self.assertEqual(1, rc) out = buf.getvalue() - self.assertIn("dashboard crashed: ValueError: kaboom in render", out) + self.assertIn("supervise crashed: ValueError: kaboom in render", out) self.assertIn("full traceback written to", out) - log_path = self._root / "logs" / "dashboard-crash.log" + log_path = self._root / "logs" / "supervise-crash.log" self.assertTrue(log_path.exists()) content = log_path.read_text() self.assertIn("kaboom in render", content) @@ -117,9 +117,9 @@ class TestWriteCrashLog(_FakeHomeMixin, unittest.TestCase): raise RuntimeError("explode") except RuntimeError as e: path = dashboard._write_crash_log(e) - self.assertEqual(self._root / "logs" / "dashboard-crash.log", path) + self.assertEqual(self._root / "logs" / "supervise-crash.log", path) text = path.read_text() - self.assertIn("=== dashboard crash", text) + self.assertIn("=== supervise crash", text) self.assertIn("RuntimeError: explode", text) def test_falls_back_to_tempfile_when_home_unwritable(self): diff --git a/tests/unit/test_dashboard_detail_lines.py b/tests/unit/test_supervise_cli_detail_lines.py similarity index 95% rename from tests/unit/test_dashboard_detail_lines.py rename to tests/unit/test_supervise_cli_detail_lines.py index b1afd44..50f63bf 100644 --- a/tests/unit/test_dashboard_detail_lines.py +++ b/tests/unit/test_supervise_cli_detail_lines.py @@ -1,4 +1,4 @@ -"""Unit: dashboard's detail-view line builder. +"""Unit: supervise's detail-view line builder. _detail_lines returns (text, attr) tuples. Most are plain; for pipelock-block proposals it appends a "→ would allow host: " @@ -8,7 +8,7 @@ which hostname will land in pipelock's allowlist on approval.""" import unittest from bot_bottle import supervise -from bot_bottle.cli import dashboard +from bot_bottle.cli import supervise as dashboard from bot_bottle.supervise import ( Proposal, TOOL_CAPABILITY_BLOCK, @@ -63,7 +63,7 @@ class TestPipelockHostHighlight(unittest.TestCase): def test_skips_host_line_when_url_unparseable(self): # Shouldn't happen in production — supervise_server validates # the URL before queuing — but if a malformed payload ever - # reaches the dashboard, don't render a misleading host line. + # reaches the supervise TUI, don't render a misleading host line. lines = dashboard._detail_lines( _qp(TOOL_PIPELOCK_BLOCK, "garbage-not-a-url"), green_attr=self.GREEN, diff --git a/tests/unit/test_dashboard_highlight.py b/tests/unit/test_supervise_cli_highlight.py similarity index 91% rename from tests/unit/test_dashboard_highlight.py rename to tests/unit/test_supervise_cli_highlight.py index 79983a7..e8d691d 100644 --- a/tests/unit/test_dashboard_highlight.py +++ b/tests/unit/test_supervise_cli_highlight.py @@ -1,4 +1,4 @@ -"""Unit: dashboard's new-proposal highlight window. +"""Unit: supervise's new-proposal highlight window. The curses rendering itself is exercised manually; this isolates the pure decision `is the proposal still in its post-arrival @@ -6,7 +6,7 @@ highlight window?`""" import unittest -from bot_bottle.cli import dashboard +from bot_bottle.cli import supervise as dashboard class TestIsRecent(unittest.TestCase): -- 2.52.0 From c0e1f5fd70d6e52600e66589a9bd7dea6858a01e Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 3 Jun 2026 17:25:32 +0000 Subject: [PATCH 05/12] docs(prd): supersede dashboard agent PRDs --- docs/prds/0019-active-agents-in-dashboard.md | 2 +- docs/prds/0020-start-and-attach-from-dashboard.md | 2 +- docs/prds/0021-dashboard-tmux-split-pane.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/prds/0019-active-agents-in-dashboard.md b/docs/prds/0019-active-agents-in-dashboard.md index f84a4d8..1052c03 100644 --- a/docs/prds/0019-active-agents-in-dashboard.md +++ b/docs/prds/0019-active-agents-in-dashboard.md @@ -1,6 +1,6 @@ # PRD 0019: Active agents in the dashboard, agent-scoped edit verbs -- **Status:** Active +- **Status:** Superseded by [PRD 0049](0049-strip-dashboard-to-supervisor-tui.md) - **Author:** didericis - **Created:** 2026-05-26 diff --git a/docs/prds/0020-start-and-attach-from-dashboard.md b/docs/prds/0020-start-and-attach-from-dashboard.md index 1097359..1088088 100644 --- a/docs/prds/0020-start-and-attach-from-dashboard.md +++ b/docs/prds/0020-start-and-attach-from-dashboard.md @@ -1,6 +1,6 @@ # PRD 0020: Start and attach to agents from inside the dashboard -- **Status:** Active +- **Status:** Superseded by [PRD 0049](0049-strip-dashboard-to-supervisor-tui.md) - **Author:** didericis - **Created:** 2026-05-26 diff --git a/docs/prds/0021-dashboard-tmux-split-pane.md b/docs/prds/0021-dashboard-tmux-split-pane.md index bda1a4c..ca5fd0b 100644 --- a/docs/prds/0021-dashboard-tmux-split-pane.md +++ b/docs/prds/0021-dashboard-tmux-split-pane.md @@ -1,6 +1,6 @@ # PRD 0021: Dashboard as left tmux pane, selected agent as right pane -- **Status:** Active +- **Status:** Superseded by [PRD 0049](0049-strip-dashboard-to-supervisor-tui.md) - **Author:** didericis - **Created:** 2026-05-26 -- 2.52.0 From 63a7e63ce957c8d3666fb21bc9cf931788cb3809 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 3 Jun 2026 17:26:15 +0000 Subject: [PATCH 06/12] test(cli): clean up supervise test naming --- tests/unit/test_supervise_cli.py | 157 +++++++++--------- .../unit/test_supervise_cli_crash_logging.py | 24 +-- tests/unit/test_supervise_cli_detail_lines.py | 22 +-- tests/unit/test_supervise_cli_highlight.py | 16 +- 4 files changed, 107 insertions(+), 112 deletions(-) diff --git a/tests/unit/test_supervise_cli.py b/tests/unit/test_supervise_cli.py index be555f4..0c36ed0 100644 --- a/tests/unit/test_supervise_cli.py +++ b/tests/unit/test_supervise_cli.py @@ -4,9 +4,9 @@ The curses TUI itself isn't exercised here — these tests cover the discovery + approve/reject + audit-write paths that the TUI's key handlers call into. -apply_routes_change is stubbed at the supervise module level so the -tests don't need a running cred-proxy sidecar; the real docker -exec/cp/SIGHUP plumbing is covered by the integration test. +add_route is stubbed at the supervise CLI module level so the tests +don't need a running egress sidecar; the real docker exec/cp/SIGHUP +plumbing is covered by the integration test. """ import os @@ -19,7 +19,7 @@ from bot_bottle import supervise from bot_bottle.backend.docker.capability_apply import CapabilityApplyError from bot_bottle.backend.docker.egress_apply import EgressApplyError from bot_bottle.backend.docker.pipelock_apply import PipelockApplyError -from bot_bottle.cli import supervise as dashboard +from bot_bottle.cli import supervise as supervise_cli from bot_bottle.supervise import ( Proposal, STATUS_APPROVED, @@ -83,14 +83,14 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase): self._teardown_fake_home() def test_empty_when_no_queues(self): - self.assertEqual([], dashboard.discover_pending()) + self.assertEqual([], supervise_cli.discover_pending()) def test_walks_all_slug_subdirs(self): for slug in ("dev", "api"): qdir = supervise.queue_dir_for_slug(slug) qdir.mkdir(parents=True) supervise.write_proposal(qdir, _proposal(slug=slug)) - pending = dashboard.discover_pending() + pending = supervise_cli.discover_pending() self.assertEqual({"dev", "api"}, {qp.proposal.bottle_slug for qp in pending}) def test_sorted_by_arrival_across_bottles(self): @@ -110,7 +110,7 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase): qdir = supervise.queue_dir_for_slug(p.bottle_slug) qdir.mkdir(parents=True, exist_ok=True) supervise.write_proposal(qdir, p) - pending = dashboard.discover_pending() + pending = supervise_cli.discover_pending() self.assertEqual([early.id, late.id], [qp.proposal.id for qp in pending]) def test_excludes_already_responded(self): @@ -121,34 +121,34 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase): supervise.write_response(qdir, supervise.Response( proposal_id=p.id, status=STATUS_APPROVED, notes="", )) - self.assertEqual([], dashboard.discover_pending()) + self.assertEqual([], supervise_cli.discover_pending()) class TestApproveReject(_FakeHomeMixin, unittest.TestCase): def setUp(self): self._setup_fake_home() - self._original_add_route = dashboard.add_route - self._original_apply_allowlist = dashboard.apply_allowlist_change - self._original_fetch_allowlist = dashboard.fetch_current_allowlist - self._original_apply_capability = dashboard.apply_capability_change + self._original_add_route = supervise_cli.add_route + self._original_apply_allowlist = supervise_cli.apply_allowlist_change + self._original_fetch_allowlist = supervise_cli.fetch_current_allowlist + self._original_apply_capability = supervise_cli.apply_capability_change # Default stubs: succeed with deterministic before/after so the # audit log shows a non-empty diff. - dashboard.add_route = lambda slug, content: ( + supervise_cli.add_route = lambda slug, content: ( '{"routes": []}\n', '{"routes": [{"host": "x"}]}\n', ) - dashboard.apply_allowlist_change = lambda slug, content: ( + supervise_cli.apply_allowlist_change = lambda slug, content: ( "old.example\n", content, ) - dashboard.fetch_current_allowlist = lambda slug: "old.example\n" - dashboard.apply_capability_change = lambda slug, content: ( + supervise_cli.fetch_current_allowlist = lambda slug: "old.example\n" + supervise_cli.apply_capability_change = lambda slug, content: ( "FROM old\n", content, ) def tearDown(self): - dashboard.add_route = self._original_add_route - dashboard.apply_allowlist_change = self._original_apply_allowlist - dashboard.fetch_current_allowlist = self._original_fetch_allowlist - dashboard.apply_capability_change = self._original_apply_capability + supervise_cli.add_route = self._original_add_route + supervise_cli.apply_allowlist_change = self._original_apply_allowlist + supervise_cli.fetch_current_allowlist = self._original_fetch_allowlist + supervise_cli.apply_capability_change = self._original_apply_capability self._teardown_fake_home() def _enqueue(self, tool: str = TOOL_EGRESS_BLOCK): @@ -156,11 +156,11 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): qdir = supervise.queue_dir_for_slug("dev") qdir.mkdir(parents=True, exist_ok=True) supervise.write_proposal(qdir, p) - return dashboard.QueuedProposal(proposal=p, queue_dir=qdir) + return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir) def test_approve_writes_response_and_audit(self): qp = self._enqueue() - dashboard.approve(qp) + supervise_cli.approve(qp) resp = read_response(qp.queue_dir, qp.proposal.id) self.assertEqual(STATUS_APPROVED, resp.status) self.assertIsNone(resp.final_file) @@ -170,7 +170,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): def test_approve_with_final_file_marks_modified(self): qp = self._enqueue() - dashboard.approve(qp, final_file='{"routes": [{"path": "/x/"}]}\n', notes="tweaked") + supervise_cli.approve(qp, final_file='{"routes": [{"path": "/x/"}]}\n', notes="tweaked") resp = read_response(qp.queue_dir, qp.proposal.id) self.assertEqual(STATUS_MODIFIED, resp.status) self.assertEqual('{"routes": [{"path": "/x/"}]}\n', resp.final_file) @@ -180,7 +180,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): def test_reject_writes_rejection(self): qp = self._enqueue() - dashboard.reject(qp, reason="nope") + supervise_cli.reject(qp, reason="nope") resp = read_response(qp.queue_dir, qp.proposal.id) self.assertEqual(STATUS_REJECTED, resp.status) self.assertEqual("nope", resp.notes) @@ -190,7 +190,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): def test_capability_block_skips_audit_log(self): qp = self._enqueue(tool=TOOL_CAPABILITY_BLOCK) - dashboard.approve(qp) + supervise_cli.approve(qp) # No audit log for capability-block (per PRD 0013 / 0016). # cred-proxy and pipelock logs both empty. self.assertEqual([], read_audit_entries("egress", "dev")) @@ -198,7 +198,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): def test_pipelock_audit_distinct_from_egress(self): qp = self._enqueue(tool=TOOL_PIPELOCK_BLOCK) - dashboard.approve(qp) + supervise_cli.approve(qp) self.assertEqual(1, len(read_audit_entries("pipelock", "dev"))) self.assertEqual(0, len(read_audit_entries("egress", "dev"))) @@ -210,10 +210,10 @@ class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase): def setUp(self): self._setup_fake_home() - self._original_add_route = dashboard.add_route + self._original_add_route = supervise_cli.add_route def tearDown(self): - dashboard.add_route = self._original_add_route + supervise_cli.add_route = self._original_add_route self._teardown_fake_home() def _enqueue_egress(self, proposed: str = '{"host": "x.example"}\n'): @@ -227,17 +227,17 @@ class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase): qdir = supervise.queue_dir_for_slug("dev") qdir.mkdir(parents=True, exist_ok=True) supervise.write_proposal(qdir, p) - return dashboard.QueuedProposal(proposal=p, queue_dir=qdir) + return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir) def test_egress_block_calls_add_route_with_proposed_json(self): calls = [] - dashboard.add_route = lambda slug, content: ( + supervise_cli.add_route = lambda slug, content: ( calls.append((slug, content)) or ("before", "after") ) qp = self._enqueue_egress( proposed='{"host": "new.example", "path_allowlist": ["/x/"]}\n' ) - dashboard.approve(qp) + supervise_cli.approve(qp) self.assertEqual(1, len(calls)) slug, content = calls[0] self.assertEqual("dev", slug) @@ -250,11 +250,11 @@ class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase): def test_modify_passes_final_file_to_add_route(self): calls = [] - dashboard.add_route = lambda slug, content: ( + supervise_cli.add_route = lambda slug, content: ( calls.append(content) or ("before", "after") ) qp = self._enqueue_egress() - dashboard.approve( + supervise_cli.approve( qp, final_file='{"host": "edited.example"}\n', notes="tweaked", @@ -262,12 +262,12 @@ class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase): self.assertEqual(['{"host": "edited.example"}\n'], calls) def test_apply_failure_blocks_response_and_audit(self): - dashboard.add_route = lambda slug, content: (_ for _ in ()).throw( + supervise_cli.add_route = lambda slug, content: (_ for _ in ()).throw( EgressApplyError("docker exec failed") ) qp = self._enqueue_egress() with self.assertRaises(EgressApplyError): - dashboard.approve(qp) + supervise_cli.approve(qp) # No response file (proposal stays pending). self.assertEqual( [qp.proposal.id], @@ -277,25 +277,20 @@ class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase): self.assertEqual([], read_audit_entries("egress", "dev")) def test_real_diff_lands_in_audit(self): - dashboard.add_route = lambda slug, content: ( + supervise_cli.add_route = lambda slug, content: ( '{"routes": []}\n', # before '{"routes": [{"host": "new.example"}]}\n', # after ) qp = self._enqueue_egress(proposed='{"host": "new.example"}\n') - dashboard.approve(qp) + supervise_cli.approve(qp) entries = read_audit_entries("egress", "dev") self.assertEqual(1, len(entries)) self.assertIn('+{"routes": [{"host": "new.example"}]}', entries[0].diff) self.assertIn('-{"routes": []}', entries[0].diff) def test_reject_does_not_call_apply(self): - called = [] - dashboard.apply_routes_change = lambda slug, content: ( - called.append(True) or ("", content) - ) qp = self._enqueue_egress() - dashboard.reject(qp, reason="no thanks") - self.assertEqual([], called) + supervise_cli.reject(qp, reason="no thanks") # Reject still writes a response + audit entry with empty diff. resp = read_response(qp.queue_dir, qp.proposal.id) self.assertEqual(STATUS_REJECTED, resp.status) @@ -312,12 +307,12 @@ class TestPipelockApplyWiring(_FakeHomeMixin, unittest.TestCase): def setUp(self): self._setup_fake_home() - self._original_apply = dashboard.apply_allowlist_change - self._original_fetch = dashboard.fetch_current_allowlist + self._original_apply = supervise_cli.apply_allowlist_change + self._original_fetch = supervise_cli.fetch_current_allowlist def tearDown(self): - dashboard.apply_allowlist_change = self._original_apply - dashboard.fetch_current_allowlist = self._original_fetch + supervise_cli.apply_allowlist_change = self._original_apply + supervise_cli.fetch_current_allowlist = self._original_fetch self._teardown_fake_home() def _enqueue_pipelock(self, failed_url: str = "https://api.github.com/repos/foo/bar"): @@ -331,17 +326,17 @@ class TestPipelockApplyWiring(_FakeHomeMixin, unittest.TestCase): qdir = supervise.queue_dir_for_slug("dev") qdir.mkdir(parents=True, exist_ok=True) supervise.write_proposal(qdir, p) - return dashboard.QueuedProposal(proposal=p, queue_dir=qdir) + return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir) def test_url_host_merged_into_current_allowlist(self): - dashboard.fetch_current_allowlist = lambda slug: "existing.example\n" + supervise_cli.fetch_current_allowlist = lambda slug: "existing.example\n" applied = [] - dashboard.apply_allowlist_change = lambda slug, content: ( + supervise_cli.apply_allowlist_change = lambda slug, content: ( applied.append((slug, content)) or ("existing.example\n", content) ) qp = self._enqueue_pipelock("https://api.github.com/repos/foo/bar") - dashboard.approve(qp) + supervise_cli.approve(qp) # apply_allowlist_change was called with the merged content: # existing host + the URL's host (no path, since pipelock is # hostname-only). @@ -353,27 +348,27 @@ class TestPipelockApplyWiring(_FakeHomeMixin, unittest.TestCase): self.assertNotIn("/repos/foo/bar", content) # path stripped def test_host_already_in_allowlist_is_idempotent(self): - dashboard.fetch_current_allowlist = lambda slug: "api.github.com\n" + supervise_cli.fetch_current_allowlist = lambda slug: "api.github.com\n" applied = [] - dashboard.apply_allowlist_change = lambda slug, content: ( + supervise_cli.apply_allowlist_change = lambda slug, content: ( applied.append(content) or ("api.github.com\n", content) ) qp = self._enqueue_pipelock("https://api.github.com/some/path") - dashboard.approve(qp) + supervise_cli.approve(qp) # Still applied, but the content is unchanged from current — # before/after diff is empty. self.assertEqual(1, len(applied)) self.assertEqual("api.github.com\n", applied[0]) def test_apply_failure_blocks_response_and_audit(self): - dashboard.fetch_current_allowlist = lambda slug: "existing.example\n" - dashboard.apply_allowlist_change = lambda slug, content: (_ for _ in ()).throw( + supervise_cli.fetch_current_allowlist = lambda slug: "existing.example\n" + supervise_cli.apply_allowlist_change = lambda slug, content: (_ for _ in ()).throw( PipelockApplyError("docker exec failed") ) qp = self._enqueue_pipelock() with self.assertRaises(PipelockApplyError): - dashboard.approve(qp) + supervise_cli.approve(qp) self.assertEqual( [qp.proposal.id], [p.id for p in supervise.list_pending_proposals(qp.queue_dir)], @@ -381,12 +376,12 @@ class TestPipelockApplyWiring(_FakeHomeMixin, unittest.TestCase): self.assertEqual([], read_audit_entries("pipelock", "dev")) def test_url_without_host_raises(self): - dashboard.fetch_current_allowlist = lambda slug: "" + supervise_cli.fetch_current_allowlist = lambda slug: "" # supervise_server's validator would catch this; if a broken # URL ever makes it through, the supervise TUI surfaces it too. qp = self._enqueue_pipelock("https:///nohost") with self.assertRaises(PipelockApplyError): - dashboard.approve(qp) + supervise_cli.approve(qp) class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase): @@ -397,10 +392,10 @@ class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase): def setUp(self): self._setup_fake_home() - self._original = dashboard.apply_capability_change + self._original = supervise_cli.apply_capability_change def tearDown(self): - dashboard.apply_capability_change = self._original + supervise_cli.apply_capability_change = self._original self._teardown_fake_home() def _enqueue_capability(self, proposed: str = "FROM python:3.13\nRUN apk add ripgrep\n"): @@ -414,44 +409,44 @@ class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase): qdir = supervise.queue_dir_for_slug("dev") qdir.mkdir(parents=True, exist_ok=True) supervise.write_proposal(qdir, p) - return dashboard.QueuedProposal(proposal=p, queue_dir=qdir) + return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir) def test_capability_block_calls_apply_with_proposed_file(self): calls = [] - dashboard.apply_capability_change = lambda slug, content: ( + supervise_cli.apply_capability_change = lambda slug, content: ( calls.append((slug, content)) or ("FROM old\n", content) ) qp = self._enqueue_capability("FROM bookworm\n") - dashboard.approve(qp) + supervise_cli.approve(qp) self.assertEqual([("dev", "FROM bookworm\n")], calls) def test_apply_failure_blocks_response_and_keeps_pending(self): - dashboard.apply_capability_change = lambda slug, content: (_ for _ in ()).throw( + supervise_cli.apply_capability_change = lambda slug, content: (_ for _ in ()).throw( CapabilityApplyError("teardown failed") ) qp = self._enqueue_capability() with self.assertRaises(CapabilityApplyError): - dashboard.approve(qp) + supervise_cli.approve(qp) self.assertEqual( [qp.proposal.id], [p.id for p in supervise.list_pending_proposals(qp.queue_dir)], ) def test_no_audit_log_for_capability(self): - dashboard.apply_capability_change = lambda slug, content: ("FROM old\n", content) + supervise_cli.apply_capability_change = lambda slug, content: ("FROM old\n", content) qp = self._enqueue_capability() - dashboard.approve(qp) + supervise_cli.approve(qp) # capability-block has no audit log per PRD 0013 — its record # lives in the per-bottle Dockerfile + transcript state. self.assertEqual([], read_audit_entries("egress", "dev")) self.assertEqual([], read_audit_entries("pipelock", "dev")) def test_proposal_archived_after_apply(self): - dashboard.apply_capability_change = lambda slug, content: ("FROM old\n", content) + supervise_cli.apply_capability_change = lambda slug, content: ("FROM old\n", content) qp = self._enqueue_capability() - dashboard.approve(qp) + supervise_cli.approve(qp) # Sidecar would normally archive after delivering the response, - # but it's gone by then. The dashboard archives so + # but it's gone by then. The supervise TUI archives so # discover_pending stops surfacing the resolved proposal. self.assertEqual([], supervise.list_pending_proposals(qp.queue_dir)) processed = list((qp.queue_dir / "processed").glob("*.json")) @@ -482,7 +477,7 @@ class TestEditInEditor(unittest.TestCase): os.chmod(editor_script, 0o755) os.environ["EDITOR"] = editor_script try: - result = dashboard.edit_in_editor("original") + result = supervise_cli.edit_in_editor("original") self.assertEqual("edited", result) finally: os.unlink(editor_script) @@ -504,7 +499,7 @@ class TestEditInEditor(unittest.TestCase): os.chmod(editor_script, 0o755) os.environ["EDITOR"] = editor_script try: - result = dashboard.edit_in_editor("original") + result = supervise_cli.edit_in_editor("original") self.assertIsNone(result) finally: os.unlink(editor_script) @@ -521,19 +516,19 @@ class TestCapabilityBlockSmolmachinesGuard(_FakeHomeMixin, unittest.TestCase): def setUp(self): self._setup_fake_home() - self._original_apply_capability = dashboard.apply_capability_change - dashboard.apply_capability_change = lambda slug, content: ("", content) + self._original_apply_capability = supervise_cli.apply_capability_change + supervise_cli.apply_capability_change = lambda slug, content: ("", content) def tearDown(self): - dashboard.apply_capability_change = self._original_apply_capability + supervise_cli.apply_capability_change = self._original_apply_capability self._teardown_fake_home() - def _enqueue_capability(self, slug: str = "dev") -> "dashboard.QueuedProposal": + def _enqueue_capability(self, slug: str = "dev") -> "supervise_cli.QueuedProposal": p = _proposal(slug=slug, tool=TOOL_CAPABILITY_BLOCK) qdir = supervise.queue_dir_for_slug(slug) qdir.mkdir(parents=True, exist_ok=True) supervise.write_proposal(qdir, p) - return dashboard.QueuedProposal(proposal=p, queue_dir=qdir) + return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir) def _write_metadata(self, slug: str, compose_project: str) -> None: from bot_bottle.backend.docker.bottle_state import BottleMetadata, write_metadata @@ -550,18 +545,18 @@ class TestCapabilityBlockSmolmachinesGuard(_FakeHomeMixin, unittest.TestCase): self._write_metadata("dev", compose_project="") qp = self._enqueue_capability("dev") with self.assertRaises(CapabilityApplyError) as ctx: - dashboard.approve(qp) + supervise_cli.approve(qp) self.assertIn("smolmachines", str(ctx.exception)) def test_docker_bottle_calls_apply_capability_change(self): self._write_metadata("dev", compose_project="bot-bottle-dev") qp = self._enqueue_capability("dev") - dashboard.approve(qp) # must not raise + supervise_cli.approve(qp) # must not raise def test_no_metadata_falls_through_to_docker_path(self): # No metadata at all → assume Docker (backward-compatible). qp = self._enqueue_capability("dev") - dashboard.approve(qp) # must not raise + supervise_cli.approve(qp) # must not raise if __name__ == "__main__": diff --git a/tests/unit/test_supervise_cli_crash_logging.py b/tests/unit/test_supervise_cli_crash_logging.py index d581764..56cb6f0 100644 --- a/tests/unit/test_supervise_cli_crash_logging.py +++ b/tests/unit/test_supervise_cli_crash_logging.py @@ -1,6 +1,6 @@ """Unit: supervise launch/crash failure logging (issue #100). -The dashboard runs under curses, so anything written to stderr while the +The supervise TUI runs under curses, so anything written to stderr while the TUI owns the terminal is wiped when the terminal is restored. These tests lock the recovery paths: a config error (`Die`) is re-surfaced after the wrapper returns, and an unexpected crash is persisted to a @@ -17,7 +17,7 @@ from pathlib import Path from unittest import mock from bot_bottle import supervise -from bot_bottle.cli import supervise as dashboard +from bot_bottle.cli import supervise as supervise_cli from bot_bottle.log import Die, die @@ -63,37 +63,37 @@ class TestCmdSuperviseErrorPaths(_FakeHomeMixin, unittest.TestCase): def test_keyboard_interrupt_returns_130(self): with mock.patch.object( - dashboard.curses, "wrapper", side_effect=KeyboardInterrupt + supervise_cli.curses, "wrapper", side_effect=KeyboardInterrupt ): - self.assertEqual(130, dashboard.cmd_supervise([])) + self.assertEqual(130, supervise_cli.cmd_supervise([])) def test_die_resurfaces_message_after_curses(self): buf = io.StringIO() with mock.patch.object( - dashboard.curses, "wrapper", + supervise_cli.curses, "wrapper", side_effect=Die(1, "manifest parse error at line 3"), ): with contextlib.redirect_stderr(buf): - rc = dashboard.cmd_supervise([]) + rc = supervise_cli.cmd_supervise([]) self.assertEqual(1, rc) self.assertIn("manifest parse error at line 3", buf.getvalue()) def test_die_without_message_has_fallback(self): buf = io.StringIO() - with mock.patch.object(dashboard.curses, "wrapper", side_effect=Die(1)): + with mock.patch.object(supervise_cli.curses, "wrapper", side_effect=Die(1)): with contextlib.redirect_stderr(buf): - rc = dashboard.cmd_supervise([]) + rc = supervise_cli.cmd_supervise([]) self.assertEqual(1, rc) self.assertIn("fatal error", buf.getvalue()) def test_unexpected_exception_writes_crash_log(self): buf = io.StringIO() with mock.patch.object( - dashboard.curses, "wrapper", + supervise_cli.curses, "wrapper", side_effect=ValueError("kaboom in render"), ): with contextlib.redirect_stderr(buf): - rc = dashboard.cmd_supervise([]) + rc = supervise_cli.cmd_supervise([]) self.assertEqual(1, rc) out = buf.getvalue() self.assertIn("supervise crashed: ValueError: kaboom in render", out) @@ -116,7 +116,7 @@ class TestWriteCrashLog(_FakeHomeMixin, unittest.TestCase): try: raise RuntimeError("explode") except RuntimeError as e: - path = dashboard._write_crash_log(e) + path = supervise_cli._write_crash_log(e) self.assertEqual(self._root / "logs" / "supervise-crash.log", path) text = path.read_text() self.assertIn("=== supervise crash", text) @@ -131,7 +131,7 @@ class TestWriteCrashLog(_FakeHomeMixin, unittest.TestCase): try: raise RuntimeError("explode2") except RuntimeError as e: - path = dashboard._write_crash_log(e) + path = supervise_cli._write_crash_log(e) self.assertTrue(path.exists()) self.assertIn("explode2", path.read_text()) diff --git a/tests/unit/test_supervise_cli_detail_lines.py b/tests/unit/test_supervise_cli_detail_lines.py index 50f63bf..a535f22 100644 --- a/tests/unit/test_supervise_cli_detail_lines.py +++ b/tests/unit/test_supervise_cli_detail_lines.py @@ -8,7 +8,7 @@ which hostname will land in pipelock's allowlist on approval.""" import unittest from bot_bottle import supervise -from bot_bottle.cli import supervise as dashboard +from bot_bottle.cli import supervise as supervise_cli from bot_bottle.supervise import ( Proposal, TOOL_CAPABILITY_BLOCK, @@ -18,7 +18,7 @@ from bot_bottle.supervise import ( ) -def _qp(tool: str, payload: str) -> dashboard.QueuedProposal: +def _qp(tool: str, payload: str) -> supervise_cli.QueuedProposal: from datetime import datetime, timezone from pathlib import Path p = Proposal.new( @@ -29,14 +29,14 @@ def _qp(tool: str, payload: str) -> dashboard.QueuedProposal: current_file_hash=sha256_hex(payload), now=datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc), ) - return dashboard.QueuedProposal(proposal=p, queue_dir=Path("/tmp/q")) + return supervise_cli.QueuedProposal(proposal=p, queue_dir=Path("/tmp/q")) class TestPipelockHostHighlight(unittest.TestCase): GREEN = 0xDEADBEEF # arbitrary sentinel; _detail_lines passes through def test_appends_green_host_line_for_pipelock_block(self): - lines = dashboard._detail_lines( + lines = supervise_cli._detail_lines( _qp(TOOL_PIPELOCK_BLOCK, "https://api.github.com/repos/foo/bar"), green_attr=self.GREEN, ) @@ -47,14 +47,14 @@ class TestPipelockHostHighlight(unittest.TestCase): self.assertEqual(["api.github.com"], green_lines) def test_no_green_lines_for_egress_block(self): - lines = dashboard._detail_lines( + lines = supervise_cli._detail_lines( _qp(TOOL_EGRESS_BLOCK, '{"routes": []}'), green_attr=self.GREEN, ) self.assertEqual([], [t for t, a in lines if a == self.GREEN]) def test_no_green_lines_for_capability_block(self): - lines = dashboard._detail_lines( + lines = supervise_cli._detail_lines( _qp(TOOL_CAPABILITY_BLOCK, "FROM python:3.13\n"), green_attr=self.GREEN, ) @@ -64,7 +64,7 @@ class TestPipelockHostHighlight(unittest.TestCase): # Shouldn't happen in production — supervise_server validates # the URL before queuing — but if a malformed payload ever # reaches the supervise TUI, don't render a misleading host line. - lines = dashboard._detail_lines( + lines = supervise_cli._detail_lines( _qp(TOOL_PIPELOCK_BLOCK, "garbage-not-a-url"), green_attr=self.GREEN, ) @@ -73,7 +73,7 @@ class TestPipelockHostHighlight(unittest.TestCase): def test_no_green_attr_passed_still_renders_host(self): # Even without color support (green_attr=0), the host line # is still present — it just won't be coloured. - lines = dashboard._detail_lines( + lines = supervise_cli._detail_lines( _qp(TOOL_PIPELOCK_BLOCK, "https://api.github.com/x"), green_attr=0, ) @@ -86,14 +86,14 @@ class TestFailedUrlHost(unittest.TestCase): def test_extracts_hostname(self): self.assertEqual( "api.github.com", - dashboard._failed_url_host("https://api.github.com/repos/foo"), + supervise_cli._failed_url_host("https://api.github.com/repos/foo"), ) def test_returns_empty_for_unparseable(self): - self.assertEqual("", dashboard._failed_url_host("not a url")) + self.assertEqual("", supervise_cli._failed_url_host("not a url")) def test_returns_empty_for_url_without_host(self): - self.assertEqual("", dashboard._failed_url_host("https:///nohost")) + self.assertEqual("", supervise_cli._failed_url_host("https:///nohost")) if __name__ == "__main__": diff --git a/tests/unit/test_supervise_cli_highlight.py b/tests/unit/test_supervise_cli_highlight.py index e8d691d..84dc513 100644 --- a/tests/unit/test_supervise_cli_highlight.py +++ b/tests/unit/test_supervise_cli_highlight.py @@ -6,33 +6,33 @@ highlight window?`""" import unittest -from bot_bottle.cli import supervise as dashboard +from bot_bottle.cli import supervise as supervise_cli class TestIsRecent(unittest.TestCase): def test_just_seen_is_recent(self): - self.assertTrue(dashboard._is_recent("p1", {"p1": 100.0}, now=100.5)) + self.assertTrue(supervise_cli._is_recent("p1", {"p1": 100.0}, now=100.5)) def test_seen_within_window(self): # Default window is 5s. self.assertTrue( - dashboard._is_recent("p1", {"p1": 100.0}, now=104.9), + supervise_cli._is_recent("p1", {"p1": 100.0}, now=104.9), ) def test_seen_past_window_is_not_recent(self): self.assertFalse( - dashboard._is_recent("p1", {"p1": 100.0}, now=106.0), + supervise_cli._is_recent("p1", {"p1": 100.0}, now=106.0), ) def test_unknown_proposal_is_not_recent(self): self.assertFalse( - dashboard._is_recent("p2", {"p1": 100.0}, now=100.5), + supervise_cli._is_recent("p2", {"p1": 100.0}, now=100.5), ) def test_none_args_safe_default(self): - self.assertFalse(dashboard._is_recent("p1", None, None)) - self.assertFalse(dashboard._is_recent("p1", {"p1": 100.0}, None)) - self.assertFalse(dashboard._is_recent("p1", None, 100.5)) + self.assertFalse(supervise_cli._is_recent("p1", None, None)) + self.assertFalse(supervise_cli._is_recent("p1", {"p1": 100.0}, None)) + self.assertFalse(supervise_cli._is_recent("p1", None, 100.5)) if __name__ == "__main__": -- 2.52.0 From 4372b8a6dd7e6608549dff82d6ba5d102cf3dc4b Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 3 Jun 2026 17:27:14 +0000 Subject: [PATCH 07/12] docs(cli): update supervise code references --- bot_bottle/cli/start.py | 36 +++++++++++------------------------- bot_bottle/supervise.py | 4 ++-- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/bot_bottle/cli/start.py b/bot_bottle/cli/start.py index 8c1871b..018e5ed 100644 --- a/bot_bottle/cli/start.py +++ b/bot_bottle/cli/start.py @@ -2,10 +2,8 @@ interactive claude-code session. The container is torn down when the session ends. -The launch core is shared with `cli.py resume ` and (PRD -0020 chunk 1+) the dashboard's in-process start flow: see the -public helpers `prepare_with_preflight`, `attach_agent`, and the -private orchestrator `_launch_bottle`. +The launch core is shared with `cli.py resume ` through +the private orchestrator `_launch_bottle`. """ from __future__ import annotations @@ -71,7 +69,7 @@ def cmd_start(argv: list[str]) -> int: ) -# --- Public helpers shared with the dashboard (PRD 0020) ----------------- +# --- Launch helpers ------------------------------------------------------ def prepare_with_preflight( @@ -84,14 +82,11 @@ def prepare_with_preflight( backend_name: str | None = None, ) -> tuple[DockerBottlePlan | None, str]: """Run `backend.prepare`, render the preflight summary via the - injected callable, prompt y/N via the injected callable. The CLI - binds these to stderr/stdin; the dashboard binds them to a - curses modal. + injected callable, prompt y/N via the injected callable. `backend_name` selects which backend prepares the plan - (`None` → `$BOT_BOTTLE_BACKEND` → `docker`). Dashboard - passes the value from its new-agent backend-picker modal; the - CLI passes whatever `--backend` resolved to. + (`None` → `$BOT_BOTTLE_BACKEND` → `docker`). The CLI passes + whatever `--backend` resolved to. Returns `(plan, identity)`. `plan` is None on dry-run or operator-N, but `identity` is set as soon as `backend.prepare` @@ -122,16 +117,10 @@ def attach_agent( agent process's exit code. `resume=True` adds `--continue` so claude picks up its most - recent session non-interactively (no session-picker prompt) — - the right shape for the dashboard's Enter re-attach (PRD 0020 - chunk 3), where a bottle typically has exactly one session. - First-attach paths (`./cli.py start`, the dashboard's new-agent - flow) leave it False. + recent session non-interactively (no session-picker prompt). + First-attach paths (`./cli.py start`) leave it False. - Used as the inner step of `./cli.py start` (one-shot) and by the - dashboard, which calls it from inside a `curses.endwin → … → - stdscr.refresh()` handoff so the curses surface gets out of the - terminal's way while the agent has it.""" + Used as the inner step of `./cli.py start`.""" runtime = runtime_for(agent_provider_template) info( f"attaching interactive {agent_provider_template} session " @@ -148,8 +137,7 @@ def attach_agent( def capture_claude_session_state(identity: str, exit_code: int) -> None: """Inside the launch context, while the container is still alive: snapshot the transcript and mark for preservation if - claude crashed. Public for the dashboard's death-handling path - (PRD 0020 open question 3).""" + claude crashed.""" # FIXME: this captures Claude-specific session state. A follow-up # spike should explore freezing provider-neutral container state # instead of relying on each agent's transcript layout. @@ -162,9 +150,7 @@ def capture_claude_session_state(identity: str, exit_code: int) -> None: def settle_state(identity: str) -> None: """Post-teardown housekeeping: print the resume hint if the - state was preserved, otherwise reap the per-bottle state dir. - Public so the dashboard's explicit-stop path calls the same - settlement the CLI uses on context exit.""" + state was preserved, otherwise reap the per-bottle state dir.""" if not identity: return if is_preserved(identity): diff --git a/bot_bottle/supervise.py b/bot_bottle/supervise.py index bdf4cdb..5e5141d 100644 --- a/bot_bottle/supervise.py +++ b/bot_bottle/supervise.py @@ -12,8 +12,8 @@ agent calls when it hits a stuck-recovery category: Each tool call: the agent passes the full proposed file plus a justification text. The sidecar validates the proposal syntactically, writes it to the host's per-bottle queue dir, and holds the tool-call -connection open. The operator's TUI dashboard -(bot_bottle.cli.dashboard) sees the proposal, accepts +connection open. The operator's supervise TUI +(bot_bottle.cli.supervise) sees the proposal, accepts approve / modify / reject, and writes a response file alongside the proposal. The sidecar sees the response and returns `{status, notes}` to the agent. -- 2.52.0 From 50ec920243a491166a422a233f631754c54a16c5 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 3 Jun 2026 17:27:56 +0000 Subject: [PATCH 08/12] docs(prd): activate PRD 0049 strip dashboard to supervise --- docs/prds/0049-strip-dashboard-to-supervisor-tui.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/prds/0049-strip-dashboard-to-supervisor-tui.md b/docs/prds/0049-strip-dashboard-to-supervisor-tui.md index 8d17173..91ef8bb 100644 --- a/docs/prds/0049-strip-dashboard-to-supervisor-tui.md +++ b/docs/prds/0049-strip-dashboard-to-supervisor-tui.md @@ -1,5 +1,5 @@ -- **Status:** Draft +- **Status:** Active - **Author:** didericis - **Created:** 2026-06-03 - **Issue:** #174 @@ -343,4 +343,4 @@ The PR closes issue #174. - PRDs 0014 / 0015 / 0016 — block-remediation engines that the supervise TUI continues to drive on approve. - PRDs 0019 / 0020 / 0021 — the bolted-on capabilities this PRD - removes. \ No newline at end of file + removes. -- 2.52.0 From d3bc463295869e5e472e48c9a4f1ac93aba3fff0 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 3 Jun 2026 17:31:19 +0000 Subject: [PATCH 09/12] fix(cli): remove supervise queue highlight --- bot_bottle/cli/supervise.py | 36 +++-------------- .../0049-strip-dashboard-to-supervisor-tui.md | 17 ++++---- tests/unit/test_supervise_cli_highlight.py | 39 ------------------- 3 files changed, 13 insertions(+), 79 deletions(-) delete mode 100644 tests/unit/test_supervise_cli_highlight.py diff --git a/bot_bottle/cli/supervise.py b/bot_bottle/cli/supervise.py index ca15403..fb786d7 100644 --- a/bot_bottle/cli/supervise.py +++ b/bot_bottle/cli/supervise.py @@ -17,7 +17,6 @@ import os import subprocess import sys import tempfile -import time import traceback from dataclasses import dataclass from datetime import datetime, timezone @@ -59,7 +58,6 @@ from ._common import PROG _REFRESH_INTERVAL_MS = 1000 -_NEW_PROPOSAL_HIGHLIGHT_SEC = 5.0 @dataclass(frozen=True) @@ -99,20 +97,6 @@ def _approval_status(qp: QueuedProposal, verb: str) -> str: return base -def _is_recent( - proposal_id: str, - first_seen: dict[str, float] | None, - now: float | None, -) -> bool: - """True if `proposal_id` was first seen within the highlight window.""" - if first_seen is None or now is None: - return False - started = first_seen.get(proposal_id) - if started is None: - return False - return (now - started) < _NEW_PROPOSAL_HIGHLIGHT_SEC - - def _detail_lines( qp: QueuedProposal, *, @@ -388,21 +372,19 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: curses.curs_set(0) stdscr.timeout(_REFRESH_INTERVAL_MS) green_attr = _try_init_green() - first_seen: dict[str, float] = {} selected = 0 status_line = "" - saw_first_tick = False supervise_pane_id = os.environ.get("TMUX_PANE", "") + seen_ids: set[str] = set() while True: pending = discover_pending() if selected >= len(pending): selected = max(0, len(pending) - 1) - now = time.monotonic() live_ids = {qp.proposal.id for qp in pending} - newly_arrived = live_ids - first_seen.keys() - if saw_first_tick and newly_arrived: + newly_arrived = live_ids - seen_ids + if seen_ids and newly_arrived: try: curses.beep() except curses.error: @@ -413,15 +395,11 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: if qp.proposal.id in newly_arrived: selected = i break - for proposal_id in live_ids: - first_seen.setdefault(proposal_id, now) - for stale_id in list(first_seen.keys() - live_ids): - del first_seen[stale_id] - saw_first_tick = True + seen_ids = live_ids _render( stdscr, pending, selected, status_line, - first_seen=first_seen, now=now, green_attr=green_attr, + green_attr=green_attr, ) try: @@ -478,8 +456,6 @@ def _render( selected: int, status_line: str, *, - first_seen: dict[str, float] | None = None, - now: float | None = None, green_attr: int = 0, ) -> None: stdscr.erase() @@ -512,8 +488,6 @@ def _render( f"{_proposed_payload_label(p.tool)}" ) attr = curses.A_REVERSE if i == selected else curses.A_NORMAL - if _is_recent(p.id, first_seen, now): - attr |= green_attr stdscr.addnstr(row, 0, line, w - 1, attr) row += 1 if row >= h - 3: diff --git a/docs/prds/0049-strip-dashboard-to-supervisor-tui.md b/docs/prds/0049-strip-dashboard-to-supervisor-tui.md index 91ef8bb..5c316eb 100644 --- a/docs/prds/0049-strip-dashboard-to-supervisor-tui.md +++ b/docs/prds/0049-strip-dashboard-to-supervisor-tui.md @@ -1,5 +1,5 @@ -- **Status:** Active +- **Status:** Draft - **Author:** didericis - **Created:** 2026-06-03 - **Issue:** #174 @@ -127,8 +127,8 @@ problem is everything that got bolted onto that core after. dashboard.py` to `bot_bottle/cli/supervise.py`. The dispatcher in `bot_bottle/cli/__init__.py` and the help text both update. - **Strip the curses loop to proposal-only.** The remaining - surface is: list pending proposals (with the new-arrival bell + - green highlight from PRD 0013), Enter for detail view, + surface is: list pending proposals (with the new-arrival bell + from PRD 0013), Enter for detail view, `a`/`m`/`r` for approve / modify / reject, `q` to quit. No agents pane, no Tab, no agent picker, no `n`/`x`/`e`/`p`, no tmux dispatch, no `bottles` dict on the main loop. @@ -141,10 +141,9 @@ problem is everything that got bolted onto that core after. `operator_edit_allowlist`, and their imports come out. - **Collapse the model module.** `dashboard_model.py`'s proposal-side helpers (`QueuedProposal`, `discover_pending`, - `_approval_status`, `_is_recent`, `_detail_lines`, + `_approval_status`, `_detail_lines`, `_failed_url_host`, `_proposed_payload_label`, - `_suffix_for_tool`, `_REFRESH_INTERVAL_MS`, - `_NEW_PROPOSAL_HIGHLIGHT_SEC`) move back into + `_suffix_for_tool`, `_REFRESH_INTERVAL_MS`) move back into `supervise.py` (CLI) or into `bot_bottle/supervise.py` (the daemon-side module) — wherever they fit. The agents / picker / tmux helpers in that module (`PANE_*`, @@ -205,7 +204,7 @@ ripgrep view, same as today. - `q` / Esc quits. There are no dashboard-owned bottles, so no per-process teardown decision — `q` just exits. -- The new-arrival bell + green highlight + (if in tmux) the +- The new-arrival bell + (if in tmux) the `tmux select-pane` jump back to the supervise pane stay, because they're real wins for the operator's "I was typing at claude and a proposal landed" case. They don't require any of @@ -229,7 +228,7 @@ bot_bottle/cli/supervise.py - reject(qp, *, reason) - QueuedProposal, discover_pending - _detail_lines, _approval_status, - _is_recent, _failed_url_host, + _failed_url_host, _proposed_payload_label, _suffix_for_tool ``` @@ -262,7 +261,7 @@ a clear pointer forward. No PRD body is rewritten. Keep: - `tests/cli/test_dashboard*.py` cases that exercise `discover_pending`, `approve`, `reject`, `_detail_lines`, - `_is_recent`, `_approval_status`, `_failed_url_host`, + `_approval_status`, `_failed_url_host`, `_proposed_payload_label`, `_suffix_for_tool`, `_modify` / `edit_in_editor`. - `tests/cli/test_dashboard_once.py` (or equivalent) — the diff --git a/tests/unit/test_supervise_cli_highlight.py b/tests/unit/test_supervise_cli_highlight.py deleted file mode 100644 index 84dc513..0000000 --- a/tests/unit/test_supervise_cli_highlight.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Unit: supervise's new-proposal highlight window. - -The curses rendering itself is exercised manually; this isolates -the pure decision `is the proposal still in its post-arrival -highlight window?`""" - -import unittest - -from bot_bottle.cli import supervise as supervise_cli - - -class TestIsRecent(unittest.TestCase): - def test_just_seen_is_recent(self): - self.assertTrue(supervise_cli._is_recent("p1", {"p1": 100.0}, now=100.5)) - - def test_seen_within_window(self): - # Default window is 5s. - self.assertTrue( - supervise_cli._is_recent("p1", {"p1": 100.0}, now=104.9), - ) - - def test_seen_past_window_is_not_recent(self): - self.assertFalse( - supervise_cli._is_recent("p1", {"p1": 100.0}, now=106.0), - ) - - def test_unknown_proposal_is_not_recent(self): - self.assertFalse( - supervise_cli._is_recent("p2", {"p1": 100.0}, now=100.5), - ) - - def test_none_args_safe_default(self): - self.assertFalse(supervise_cli._is_recent("p1", None, None)) - self.assertFalse(supervise_cli._is_recent("p1", {"p1": 100.0}, None)) - self.assertFalse(supervise_cli._is_recent("p1", None, 100.5)) - - -if __name__ == "__main__": - unittest.main() -- 2.52.0 From 15b54cdff2f1922056dd7201cdcf6ef17ac5c763 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 3 Jun 2026 17:31:49 +0000 Subject: [PATCH 10/12] docs(prd): reactivate PRD 0049 without queue highlight --- docs/prds/0049-strip-dashboard-to-supervisor-tui.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/prds/0049-strip-dashboard-to-supervisor-tui.md b/docs/prds/0049-strip-dashboard-to-supervisor-tui.md index 5c316eb..61985c6 100644 --- a/docs/prds/0049-strip-dashboard-to-supervisor-tui.md +++ b/docs/prds/0049-strip-dashboard-to-supervisor-tui.md @@ -1,5 +1,5 @@ -- **Status:** Draft +- **Status:** Active - **Author:** didericis - **Created:** 2026-06-03 - **Issue:** #174 -- 2.52.0 From a593b157d63cfa46d541104acf44aec4f28881b0 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 3 Jun 2026 17:34:41 +0000 Subject: [PATCH 11/12] fix(cli): remove supervise tmux alert handling --- bot_bottle/cli/supervise.py | 17 ----------------- .../0049-strip-dashboard-to-supervisor-tui.md | 10 ++++------ 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/bot_bottle/cli/supervise.py b/bot_bottle/cli/supervise.py index fb786d7..209266f 100644 --- a/bot_bottle/cli/supervise.py +++ b/bot_bottle/cli/supervise.py @@ -354,27 +354,12 @@ def _try_init_green() -> int: return 0 -def _in_tmux() -> bool: - return bool(os.environ.get("TMUX")) - - -def _select_tmux_pane(pane_id: str) -> None: - try: - subprocess.run( - ["tmux", "select-pane", "-t", pane_id], - capture_output=True, text=True, check=False, - ) - except FileNotFoundError: - pass - - def _main_loop(stdscr: "curses._CursesWindow") -> None: curses.curs_set(0) stdscr.timeout(_REFRESH_INTERVAL_MS) green_attr = _try_init_green() selected = 0 status_line = "" - supervise_pane_id = os.environ.get("TMUX_PANE", "") seen_ids: set[str] = set() while True: @@ -389,8 +374,6 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: curses.beep() except curses.error: pass - if supervise_pane_id and _in_tmux(): - _select_tmux_pane(supervise_pane_id) for i, qp in enumerate(pending): if qp.proposal.id in newly_arrived: selected = i diff --git a/docs/prds/0049-strip-dashboard-to-supervisor-tui.md b/docs/prds/0049-strip-dashboard-to-supervisor-tui.md index 61985c6..36985f6 100644 --- a/docs/prds/0049-strip-dashboard-to-supervisor-tui.md +++ b/docs/prds/0049-strip-dashboard-to-supervisor-tui.md @@ -1,5 +1,5 @@ -- **Status:** Active +- **Status:** Draft - **Author:** didericis - **Created:** 2026-06-03 - **Issue:** #174 @@ -204,11 +204,9 @@ ripgrep view, same as today. - `q` / Esc quits. There are no dashboard-owned bottles, so no per-process teardown decision — `q` just exits. -- The new-arrival bell + (if in tmux) the - `tmux select-pane` jump back to the supervise pane stay, - because they're real wins for the operator's "I was typing at - claude and a proposal landed" case. They don't require any of - the pane-management code being removed. +- The new-arrival bell stays, because it is a real win for the + operator's "I was typing at claude and a proposal landed" case. + No tmux-specific focus management remains. ### Code organisation -- 2.52.0 From f12b0f754e665872293d325dd261bdb772e45f08 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 3 Jun 2026 17:35:10 +0000 Subject: [PATCH 12/12] docs(prd): reactivate PRD 0049 without tmux alert --- docs/prds/0049-strip-dashboard-to-supervisor-tui.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/prds/0049-strip-dashboard-to-supervisor-tui.md b/docs/prds/0049-strip-dashboard-to-supervisor-tui.md index 36985f6..4c1257a 100644 --- a/docs/prds/0049-strip-dashboard-to-supervisor-tui.md +++ b/docs/prds/0049-strip-dashboard-to-supervisor-tui.md @@ -1,5 +1,5 @@ -- **Status:** Draft +- **Status:** Active - **Author:** didericis - **Created:** 2026-06-03 - **Issue:** #174 -- 2.52.0