diff --git a/CHANGELOG.md b/CHANGELOG.md index 6008955..00dc9a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,15 @@ breaking changes may land in a minor release. forced selection. A tmux-less POSIX host still selects `TmuxMultiplexer` and reports it unavailable, exactly as before. +- **Seam-canonical window targets.** The `=session[:window]` target grammar is now owned by the + `TerminalMultiplexer` seam instead of living as hand-assembled tmux syntax in core: a new + concrete `target(session, window=None)` encoder (overridable per backend, tmux inherits the + default and passes it straight through) and a module-level `parse_target()` decoder that + native-id backends reuse instead of re-deriving the grammar (the herdr backend's + `_parse_target` now delegates to it). `runs.py`/`tui/launch.py`/`tui/app.py` format every + target via `target()` (new `runs.session_target` / `launch.ctl_target` helpers) — output is + byte-identical, so no backend or operator behavior changes; the contract is documented in the + adapter authoring guide's new "Window targets" section. - **Herdr TUI-launch surface.** The herdr backend now covers everything `tui/launch.py` drives: parked orchestrator windows (a typed `exec sh -c '; banner; read; trailer'` recipe, tmux-identical from the operator's seat, with the return-to-origin target mirrored into a @@ -79,7 +88,8 @@ breaking changes may land in a minor release. `platform_util.file_lock`), lazy server autostart, and a protocol-version guard (fail-below / warn-above). Landed as one registration line plus one sanctioned `probe.py` gate fix — zero portability-guard allowlist changes. The TUI-launch surface landed as a second PR - (previous entry); the remaining degradations are in the backend module docstring's ledger. + (previous entry); the remaining degradations are in the backend module docstring's ledger and, + operator-facing, in `docs/multiplexer-backends.md`. - **Stories mode — a second planning pipeline that drives the loop off a typed `stories.yaml` (folder+id dispatch) instead of `sprint-status.yaml`.** Opt in with `[stories] source = "stories"` + `spec_folder`, or per run with `bmad-loop run --spec ` (overrides policy); `--story` then filters by story id. Each entry dispatches by folder + id — the dev skill creates-or-resumes the story spec at `/stories/-.md` and the orchestrator reads that id-keyed path back deterministically (no shared board to line-edit, no result-artifact mtime-scan). Strictly linear schedule (list order, no `depends_on`); `bmad-loop run --dry-run --spec ` and `bmad-loop status` print the board (id · live disk state · checkpoint markers · title). Sprint mode is unchanged and remains the default. Requires a `bmad-dev-auto` new enough for folder+id dispatch — the run preflight checks and remediates. - **Per-story human checkpoints (stories mode).** Independent `spec_checkpoint` (pause before code to review the plan — dev halts at `ready-for-dev`; approve to implement, or request a replan that resets the spec to `draft`) and `done_checkpoint` (pause after the story commits, skipped when it is the last story); both additive to `gates.mode`. A blocked story escalates + resolves as in sprint mode, with a pre-planning-halt sentinel auto-deleted (a copy preserved under the run dir) on re-arm. - **TUI human-in-the-loop surface for stories mode.** The sprint tree is replaced by a stories board (id · live disk state · spec/done checkpoint markers · title) when a stories-mode run is selected; paused runs carry a per-run pause-kind badge and the run list shows a global _⚑ N need attention_ count; `p` opens the stage-appropriate viewer — plan-checkpoint spec review (Approve & resume / Request replan), story-checkpoint summary card (Continue / Stop), escalation with story context (Resolve / Re-arm & resume), and a gate spec viewer that the existing spec-approval/epic pauses reuse. The start-run modal gains a source select + spec-folder field with a live schedule preview. Every TUI action calls the same code paths as the CLI. @@ -102,6 +112,23 @@ breaking changes may land in a minor release. ### Changed +- **Backend-neutral naming for the seam-backed helpers and operator messages.** The multiplexer + seam has non-tmux backends now, so the helpers that wrap it drop their legacy tmux names — + `launch.tmux_available` → `mux_available`, `app._tmux_missing` → `_mux_missing`, + `runs.tmux_sessions` → `mux_sessions` (internal, no deprecation aliases) — and the operator-facing + strings stop naming tmux when they mean the selected backend: launch errors say + `multiplexer new-session/new-window failed` and `multiplexer backend unavailable (binary not on +PATH)`, the TUI notifies `multiplexer backend unavailable — launch/attach disabled` and + `launched (control session bmad-loop-ctl)`, and the "attach to … bmad-loop-ctl" hints say + _control session_. The TUI-guide troubleshooting table matches. Behavior is unchanged. +- **Docs: operator guide for the herdr backend (`docs/multiplexer-backends.md`).** The user-facing + docs no longer claim tmux is the only multiplexer backend. The new page covers backend selection + (`bmad-loop mux` / `mux set`) and what changes from the operator's seat on herdr — chiefly that + herdr has no CLI detach, so the automatic hand-back after answering an attached sweep decision + becomes a manual `ctrl+b q`; the rest (polled session logs, the `~/.bmad-loop/herdr-state.json` + sidecar, advisory geometry, lazy server autostart, no last-client fallback) is emulated to parity + or cosmetic. README, setup guide, TUI guide, and FEATURES now name both backends and link the + page. - **Docs: `followup_review_recommended` is now scored upstream.** BMAD-METHOD#2580 replaced the skill's convergence-prone significance judgment with a severity-weighted score over patched findings and added a fourth default review layer (Intent Alignment Auditor, #2560). README, diff --git a/README.md b/README.md index ff8c1c3..8457122 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Inspired by the original [bmad-automator](https://github.com/bmad-code-org/bmad- ## Requirements - **Python 3.11+**, **tmux**, and a supported coding CLI — `claude` by default; `codex`, `gemini`, `copilot`, and `antigravity` (`agy`) via [profiles](#other-coding-clis). -- **Linux or macOS** (or **Windows via WSL**, which _is_ Linux — it runs as-is). tmux is the one bundled terminal-multiplexer backend today, but it sits behind a pluggable **registry** of OS seams (transport, process lifecycle, hook interpreter) with availability-aware selection — env var → persisted `[mux] backend` choice (`bmad-loop mux set `) → platform default (`psmux` on Windows, `tmux` elsewhere) → first available platform match — so a native-Windows backend slots in as new files + a registration line each, with no engine edits — see [Porting bmad-loop to a new OS](docs/porting-to-a-new-os.md). Native Windows is not yet shipped. +- **Linux or macOS** (or **Windows via WSL**, which _is_ Linux — it runs as-is). tmux is the default of the two bundled terminal-multiplexer backends (the opt-in [herdr](docs/multiplexer-backends.md) is the other), and both sit behind a pluggable **registry** of OS seams (transport, process lifecycle, hook interpreter) with availability-aware selection — env var → persisted `[mux] backend` choice (`bmad-loop mux set `) → platform default (`psmux` on Windows, `tmux` elsewhere) → first available platform match — so a native-Windows backend slots in as new files + a registration line each, with no engine edits — see [Porting bmad-loop to a new OS](docs/porting-to-a-new-os.md). Native Windows is not yet shipped. - A **BMAD v6 project** (`_bmad/bmm/config.yaml`, a `sprint-status.yaml` from `bmad-sprint-planning`) with the upstream `bmad-dev-auto` skill (and the three review-hunter skills its step-04 invokes inline: `bmad-review-adversarial-general`, `bmad-review-edge-case-hunter`, `bmad-review-verification-gap`) and the bmad-loop skill module from this repo installed (`bmad-loop-resolve`, `bmad-loop-sweep` — see [Installing the skill module](#installing-the-skill-module)). Standard BMAD skills stay untouched. ## Quick start @@ -493,7 +493,7 @@ Each run drives its agents inside a dedicated tmux session, `bmad-loop-` ## Other coding CLIs -One generic driver (`adapters/generic.py`) runs any coding CLI that fits the injection + hook-signal transport; everything CLI-specific lives in a declarative **profile** (`adapters/profile.py`), and the terminal transport itself sits behind a pluggable `TerminalMultiplexer` seam (tmux is the only backend today). Built-in profiles ship as TOML in `bmad_loop/data/profiles/`: +One generic driver (`adapters/generic.py`) runs any coding CLI that fits the injection + hook-signal transport; everything CLI-specific lives in a declarative **profile** (`adapters/profile.py`), and the terminal transport itself sits behind a pluggable `TerminalMultiplexer` seam (tmux by default, herdr opt-in — see [Terminal multiplexer backends](docs/multiplexer-backends.md)). Built-in profiles ship as TOML in `bmad_loop/data/profiles/`: | Profile | Status | Notes | | ------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 12f642b..df74665 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -8,23 +8,23 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se ## Capability matrix (feature → problem addressed) -| Capability | What it does | Problem it addresses | -| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| Deterministic control loop | Story selection, retries, gates, completion checks run in plain Python | LLM-as-orchestrator is nondeterministic, hard to debug, and costs tokens for control flow | -| Dual planning pipelines | Same loop from either `sprint-status.yaml` (sprint mode, default) or a typed `stories.yaml` dispatched by folder+id (stories mode, opt-in) | Sprint boards need `bmad-sprint-planning`; a `bmad-spec` Story Breakdown has no board | -| Per-story human checkpoints | Stories-mode `spec_checkpoint` pauses to review the plan before code; `done_checkpoint` pauses after the commit; both independent, both surfaced in the TUI | Coarse run-global gates can't ask for a plan review on _this_ story only | -| Trust-nothing verification | Checks on-disk artifacts (spec status, baseline-commit match, non-empty diff, sprint sync) + runs your test/lint commands before commit | Agents claim success without working code; broken builds slip through | -| Fresh-context adversarial review | Dev and review are separate sessions; review uses 4 parallel layers (Blind Hunter / Edge Case Hunter / Verification Gap / Intent Alignment) | Self-review anchoring bias; implementer marks own work correct | -| Hook-based transport | Coding-agent hooks write structured event files; skills write `result.json` | Brittle terminal pane-scraping | -| Resumable state machine | Every run is on-disk state, resumable after gate/escalation/crash | Long unattended runs lost to interruptions | -| Plateau-defer | Stuck stories are skipped, stashed, and the run continues | One unconvergeable story blocking a whole sprint | -| Typed escalations + resolve workflow | CRITICAL pauses + notifies; interactive resolve agent re-arms the story | Ambiguous specs silently producing wrong code | -| Deferred-work sweeps | Triages an append-only ledger against real code, bundles + executes | Split-off goals and review findings get lost | -| Multi-CLI adapter + profiles | Generic driver runs claude/codex/gemini/copilot/antigravity; per-stage overrides; TOML profiles; transport + process-lifecycle + hook-interpreter behind a pluggable OS-seam registry (tmux/POSIX today) | Vendor lock-in; no way to mix models per stage; future non-tmux/Windows transport | -| Cost-weighted token budgets | Per-story budget counts cache reads at ~0.1x; raw totals displayed | Naive token caps misjudge real cost (cache reads dominate) | -| Non-invasive skill forks | Drives its own `bmad-loop-*` skill forks; reads `sprint-status.yaml` only | Modifying a user's standard BMAD install | -| Read-only TUI + launcher | Live dashboard over run-dir artifacts; launches detached runs | No visibility into what an unattended run is doing | -| Git worktree isolation (opt-in) | Each unit runs in its own worktree/branch (seeded with the adapters' gitignored MCP/CLI configs), merging back into the target locally; failed units kept for inspection | A long unattended run mutating the working tree you're actively using | +| Capability | What it does | Problem it addresses | +| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Deterministic control loop | Story selection, retries, gates, completion checks run in plain Python | LLM-as-orchestrator is nondeterministic, hard to debug, and costs tokens for control flow | +| Dual planning pipelines | Same loop from either `sprint-status.yaml` (sprint mode, default) or a typed `stories.yaml` dispatched by folder+id (stories mode, opt-in) | Sprint boards need `bmad-sprint-planning`; a `bmad-spec` Story Breakdown has no board | +| Per-story human checkpoints | Stories-mode `spec_checkpoint` pauses to review the plan before code; `done_checkpoint` pauses after the commit; both independent, both surfaced in the TUI | Coarse run-global gates can't ask for a plan review on _this_ story only | +| Trust-nothing verification | Checks on-disk artifacts (spec status, baseline-commit match, non-empty diff, sprint sync) + runs your test/lint commands before commit | Agents claim success without working code; broken builds slip through | +| Fresh-context adversarial review | Dev and review are separate sessions; review uses 4 parallel layers (Blind Hunter / Edge Case Hunter / Verification Gap / Intent Alignment) | Self-review anchoring bias; implementer marks own work correct | +| Hook-based transport | Coding-agent hooks write structured event files; skills write `result.json` | Brittle terminal pane-scraping | +| Resumable state machine | Every run is on-disk state, resumable after gate/escalation/crash | Long unattended runs lost to interruptions | +| Plateau-defer | Stuck stories are skipped, stashed, and the run continues | One unconvergeable story blocking a whole sprint | +| Typed escalations + resolve workflow | CRITICAL pauses + notifies; interactive resolve agent re-arms the story | Ambiguous specs silently producing wrong code | +| Deferred-work sweeps | Triages an append-only ledger against real code, bundles + executes | Split-off goals and review findings get lost | +| Multi-CLI adapter + profiles | Generic driver runs claude/codex/gemini/copilot/antigravity; per-stage overrides; TOML profiles; transport + process-lifecycle + hook-interpreter behind a pluggable OS-seam registry (tmux default + opt-in herdr; POSIX today) | Vendor lock-in; no way to mix models per stage; future non-tmux/Windows transport | +| Cost-weighted token budgets | Per-story budget counts cache reads at ~0.1x; raw totals displayed | Naive token caps misjudge real cost (cache reads dominate) | +| Non-invasive skill forks | Drives its own `bmad-loop-*` skill forks; reads `sprint-status.yaml` only | Modifying a user's standard BMAD install | +| Read-only TUI + launcher | Live dashboard over run-dir artifacts; launches detached runs | No visibility into what an unattended run is doing | +| Git worktree isolation (opt-in) | Each unit runs in its own worktree/branch (seeded with the adapters' gitignored MCP/CLI configs), merging back into the target locally; failed units kept for inspection | A long unattended run mutating the working tree you're actively using | --- @@ -125,7 +125,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se ### Multi-CLI / multi-agent support -- Generic adapter drives any CLI fitting the injection + hook-signal transport; CLI specifics live in declarative TOML profiles. Two independent axes: the **CLI** (`CodingCLIAdapter` + profile) and the **terminal transport** (`TerminalMultiplexer`) — tmux is the only backend today, behind a pluggable seam so a native-Windows backend can be added without touching the engine (see the [adapter authoring guide](adapter-authoring-guide.md#two-axes-cli-vs-transport)). +- Generic adapter drives any CLI fitting the injection + hook-signal transport; CLI specifics live in declarative TOML profiles. Two independent axes: the **CLI** (`CodingCLIAdapter` + profile) and the **terminal transport** (`TerminalMultiplexer`) — two backends ship, tmux (default) and the opt-in herdr ([what to expect](multiplexer-backends.md)), behind a pluggable seam so a native-Windows backend can be added without touching the engine (see the [adapter authoring guide](adapter-authoring-guide.md#two-axes-cli-vs-transport)). - The OS is abstracted by a **registry of seams**, each selecting an implementation by platform (with a test-override env var) and extended by a single registration line: the terminal multiplexer (`register_multiplexer`, with availability-aware selection: env var → persisted `[mux] backend` via `bmad-loop mux set` → platform default → first available platform match), the process-lifecycle `ProcessHost` (`register_process_host` — `terminate`/`force_kill`/`is_alive`/`identity`), and the hook interpreter (`ProcessHost.hook_interpreter()`); `bmad-loop validate` runs a platform preflight over them. Porting to a new OS is new files + registrations, no core edits — see [Porting bmad-loop to a new OS](porting-to-a-new-os.md). - Supported, E2E-verified: `claude` (reference), `codex` (≥ 0.139), `gemini` (≥ 0.46), `copilot` (GitHub Copilot CLI ≥ 2026-02 — the `copilot` binary, not the VS Code extension; `agentStop` turn-end, `-i` interactive launch, `--allow-all-tools`; pin a capable model — the free GPT-5 mini default is unreliable for multi-step skills). - Experimental, probe-required: `antigravity` (Google's `agy` ≥ 1.0.16) — `-i` interactive launch, `Stop` turn-end hook (flat handler in `.agents/hooks.json`, no SessionStart/SessionEnd), `--dangerously-skip-permissions` for unattended runs; `usage_parser = "none"` until a transcript parser is written. Verify against your `agy` build with `probe-adapter antigravity`. @@ -174,7 +174,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - `bmad-loop init` — install skills, hooks, policy, gitignore. - `bmad-loop validate` — preflight all prerequisites. -- `bmad-loop mux` — list registered terminal-multiplexer backends (platform · availability · version · which is selected and why); `mux set ` persists a machine-scoped choice into policy.toml (`--clear` reverts to auto, `--force` allows a name only registered on the target machine). +- `bmad-loop mux` — list registered terminal-multiplexer backends (platform · availability · version · which is selected and why); `mux set ` persists a machine-scoped choice into policy.toml (`--clear` reverts to auto, `--force` allows a name only registered on the target machine). Shipped backends: `tmux` (default) and `herdr` (opt-in — see [Terminal multiplexer backends](multiplexer-backends.md)). - `bmad-loop run` — drive the dev → review → verify → commit loop. - `bmad-loop sweep` — triage + execute open deferred-work entries. - `bmad-loop resume ` — continue a paused/interrupted run. diff --git a/docs/README.md b/docs/README.md index 024b992..6231562 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ guides below go deeper, roughly in the order you'll need them. - **[Setup guide](setup-guide.md)** — install the tools, pick a CLI, initialize a project, pass preflight, and uninstall. - **[TUI guide](tui-guide.md)** — the dashboard: layout, key bindings, the settings editor, and troubleshooting. - **[Features & functionality](FEATURES.md)** — the full capability matrix and policy reference. +- **[Terminal multiplexer backends](multiplexer-backends.md)** — which backend drives your agent sessions (tmux by default, herdr opt-in), how selection works, and what changes from the operator's seat on herdr. ## Extending bmad-loop diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f2a42a0..54fe0ad 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -44,7 +44,8 @@ availability-aware with discriminating `available()` probes (psmux → `which("tmux") and not which("psmux")`) and an explicit `bmad-loop mux set ` tie-break. The third — **herdr** — has **shipped** end-to-end on POSIX (engine run path + -TUI-launch surface) as a +TUI-launch surface; operator guide: +[Terminal multiplexer backends](multiplexer-backends.md)) as a different-binary-family backend: it implements `TerminalMultiplexer` fresh over herdr's workspace/tab/pane model rather than subclassing `BaseTmuxBackend`, and probes `which("herdr")`, so it is pairwise-discriminating against the tmux family by construction diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index f0a332b..7dc3e4c 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -84,6 +84,22 @@ seams of a full OS port are in `detach_client`, `switch_client` (with an optional last-client fallback), `available` (is this backend usable on the current host). +**Window targets.** The target-taking methods (`kill_window`, `select_window`, +the window-option trio, `attach_target_argv`, `switch_client`) receive one of two +families: the **seam-canonical target token** `=session[:window]` — formatted by +the concrete `TerminalMultiplexer.target(session, window=None)`, decoded by the +module-level `parse_target()` — or the backend's own **native id** (whatever your +`new_window` returned). Core never hand-assembles the grammar; it calls +`target()`. tmux consumes the token natively (it coincides with tmux exact-match +syntax), so `BaseTmuxBackend` passes it straight through. A native-id backend +calls `parse_target()` first — `None` means "already a native id, use as-is", +otherwise resolve `(session, window)` yourself; `herdr_backend._parse_target` is +the worked example (workspace-by-label → tab-by-name → root pane, resolved lazily +at use time). You MAY override `target()` to emit native ids, but the token must +stay a stable _by-name_ reference: core formats targets ahead of use (a parked +window's return target, for one), so eager resolution to a live id goes stale — +inheriting the default and resolving lazily is almost always right. + Operations that can race a window dying (`pipe_pane`) or a session already being gone (`kill_session`) must tolerate it rather than raise; everything else raises a `MultiplexerError` subclass on failure, which call sites catch at the seam (e.g. @@ -113,7 +129,9 @@ a tmux tee would (`generic._log_activity_key`'s stall re-arm and `probe`'s marke discovery). Its module docstring is a **degradation ledger** of every such divergence (sidecar options, poller `pipe_pane`, no-op `detach_client`, the attach argv, the advisory geometry, the protocol-version policy) — the reference for what -"implement fresh" costs when the host has no tmux-shaped CLI. +"implement fresh" costs when the host has no tmux-shaped CLI. The operator-facing +view — what a herdr _user_ notices and does — is +[Terminal multiplexer backends](multiplexer-backends.md). The hard part of a new profile isn't the TOML — it's the **facts that live in no doc**: the CLI's exact hook payload shape (field names and casing, whether diff --git a/docs/multiplexer-backends.md b/docs/multiplexer-backends.md new file mode 100644 index 0000000..cad739a --- /dev/null +++ b/docs/multiplexer-backends.md @@ -0,0 +1,88 @@ +# Terminal multiplexer backends + +The orchestrator drives every agent session — and the TUI's launch/attach keys — through +a terminal multiplexer behind a pluggable seam (`TerminalMultiplexer`). Two backends ship +today: **tmux**, the default everywhere except native Windows, and **herdr**, opt-in. +This page is for operators: which backend is running, how to switch, and what changes +from your seat on herdr. Contributors porting a new backend should start with +[Porting bmad-loop to a new OS](porting-to-a-new-os.md). + +## Which backend is running, and how to switch + +`bmad-loop mux` lists the registered backends (platform · availability · version) and +shows which one is selected and why. Selection precedence, highest first: + +1. `BMAD_LOOP_MUX_BACKEND=` — forces a backend for one invocation. +2. `bmad-loop mux set ` — persists the choice into the gitignored, machine-scoped + `[mux] backend` key in `.bmad-loop/policy.toml` (`mux set --clear` reverts to auto). +3. The platform default — tmux, everywhere except native Windows — when installed. +4. The first registered backend that matches the platform and is available. + +The choice applies to the next invocation — switch between runs, not while one is live: +`attach`, `cleanup`, and the TUI all look for sessions in the currently selected backend. +After switching, `bmad-loop validate` reports the selected backend's availability and +version as part of the preflight. + +## tmux (the default) + +tmux is the reference backend: everything else in these docs — the `bmad-loop-` +and `bmad-loop-ctl` session names, the `ctrl-b d` detach chord — describes tmux behavior. +It is required for launching, attaching, and driving runs; pure TUI observation works +without it. + +## herdr (opt-in) + +[herdr](https://herdr.dev) is a cross-platform, agent-aware terminal workspace manager — +a background server plus a CLI — whose agent-status sidebar is a natural fit for watching +runs. The backend is opt-in: tmux stays the default on POSIX, and herdr is selected only +when you ask for it. + +```bash +bmad-loop mux set herdr # persist for this machine +BMAD_LOOP_MUX_BACKEND=herdr bmad-loop run # …or force a single invocation +``` + +A bmad-loop **session** becomes a herdr **workspace** (same name) and each **window** a +**tab**. From your seat, runs behave as on tmux: windows close when the agent's process +exits, parked `[bmad-loop exited — press enter]` windows wait for Enter, and the +TUI's Log tab, stall detection, and completion probes all work. One difference is +functional and needs a keypress from you; the rest are cosmetic or invisible. + +### Detach is manual: press `ctrl+b q` + +herdr has no CLI command to detach an attached terminal — detach exists only as herdr's +own keybinding, by default `ctrl+b q` (`ctrl+b ctrl+b` sends a literal `ctrl+b`). So the +one moment where bmad-loop would detach your terminal **for** you becomes manual: + +- **When it bites** — you attached from a plain terminal (or the TUI suspended itself to + attach you) to answer a sweep decision. On tmux, answering prints + `✓ decisions recorded — sweep continues in the background` and your shell or dashboard + comes back by itself. On herdr you see the same message, but your terminal **stays + attached** to the sweep's window. +- **What to do** — press `ctrl+b q`. Nothing is lost or waiting on you: the answer is + already recorded and the sweep runs unattended; the keypress only hands your terminal + back. A suspended TUI resumes the moment the attach exits. +- **What still returns automatically** — everything that rides a window _closing_: when a + parked window's command finishes and you press Enter, the pane closes and the blocking + attach exits on its own, exactly as on tmux. Attaching from **inside** herdr is a tab + switch and behaves like tmux's `switch-client`. + +### Differences you may notice + +| Where | On tmux | On herdr | What to do | +| ------------------------------ | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| Session logs (TUI Log tab) | output streams continuously (a tmux tee) | the pane is snapshotted about once a second, and only when its content changes — a static screen doesn't grow the log | nothing — stall detection and completion markers read the same log and work unchanged | +| Backend state | lives in the tmux server | lives in a lock-guarded JSON sidecar, `~/.bmad-loop/herdr-state.json` (override: `BMAD_LOOP_HERDR_STATE`) | leave it alone while runs are live; entries for gone workspaces are pruned automatically | +| Switching back after an attach | falls back to your most recent client | herdr has no "last client" — if the pane you came from is gone, focus stays put | switch tabs yourself | +| Detached window size | honors the requested geometry headless | advisory — a detached pane takes the size of whichever client attaches | nothing; the first attach may briefly reflow | +| Server lifecycle | any tmux command starts the server | bmad-loop starts the herdr server lazily, and only for operations that create or change something | nothing — `bmad-loop mux`, `validate`, and listings never resurrect a stopped server | + +### Current limits + +- **POSIX launches only for now.** The herdr backend launches windows through a POSIX + shell recipe, so it runs on Linux, macOS, and WSL today; the native-Windows launch path + is tracked in [the roadmap](ROADMAP.md#native-windows-multiplexer-backend) (#140). +- **Don't hand-create `bmad-loop-*` workspaces.** herdr allows duplicate workspace labels + (tmux session names are unique); bmad-loop refuses to create a duplicate itself and + always resolves the first match, but a hand-made duplicate can shadow a run's real + workspace. diff --git a/docs/porting-to-a-new-os.md b/docs/porting-to-a-new-os.md index ee96640..8b10080 100644 --- a/docs/porting-to-a-new-os.md +++ b/docs/porting-to-a-new-os.md @@ -100,7 +100,9 @@ version, and the current selection. (The result is cached — see JSON sidecar for options, a polling tee for `pipe_pane`, a per-window return file for the parked trailer, a no-op detach); that **degradation ledger** is the module docstring, and it is the template for what "implement fresh" costs - in practice. + in practice. (The operator-facing view of those degradations — what a herdr + _user_ notices and does — is + [Terminal multiplexer backends](multiplexer-backends.md).) `available()` gates whether the backend is usable on the current host (e.g. its binary is on PATH); the optional `version()` feeds the diagnostic dump and the diff --git a/docs/setup-guide.md b/docs/setup-guide.md index b5d014d..226cac7 100644 --- a/docs/setup-guide.md +++ b/docs/setup-guide.md @@ -20,15 +20,18 @@ of the README. ## Platform prerequisites - **Python 3.11+** and a supported coding CLI (`claude` by default). -- **tmux** — the orchestrator drives agent sessions through a terminal multiplexer, and - tmux is the only backend shipped today. It is required for launching, attaching, and - driving runs (pure TUI observation works without it). The multiplexer sits behind a - pluggable seam (`TerminalMultiplexer`), so a native-Windows backend can be added later - without changing the engine — contributors should start with +- **A terminal multiplexer** — the orchestrator drives agent sessions through a terminal + multiplexer: **tmux** is the default backend, and **herdr** ships as an opt-in + alternative (`bmad-loop mux set herdr` — see + [Terminal multiplexer backends](multiplexer-backends.md) for what changes from the + operator's seat, chiefly a manual `ctrl+b q` detach). A backend is required for + launching, attaching, and driving runs (pure TUI observation works without one). The + multiplexer sits behind a pluggable seam (`TerminalMultiplexer`), so a native-Windows + backend can be added later without changing the engine — contributors should start with [Porting bmad-loop to a new OS](porting-to-a-new-os.md). `bmad-loop mux` lists the - registered backends and shows which is selected (and why); once more than one is - available, `bmad-loop mux set ` — or the `[mux] backend` policy key, or the - `BMAD_LOOP_MUX_BACKEND` env var — forces the choice per machine. + registered backends and shows which is selected (and why); `bmad-loop mux set ` — + or the `[mux] backend` policy key, or the `BMAD_LOOP_MUX_BACKEND` env var — forces the + choice per machine. - **OS** — Linux or macOS. **Windows is supported via WSL**, which _is_ Linux: tmux and every POSIX path work unchanged there, so no special setup is needed. **Native Windows is not yet shipped** — it awaits a non-tmux multiplexer backend (tracked in diff --git a/docs/tui-guide.md b/docs/tui-guide.md index b4f57aa..62f5e19 100644 --- a/docs/tui-guide.md +++ b/docs/tui-guide.md @@ -14,12 +14,13 @@ cd /path/to/your/bmad/project bmad-loop tui # or: bmad-loop tui --project /path/to/project ``` -`--project` defaults to the current directory. tmux — the orchestrator's only -registered terminal-multiplexer backend today — must be on PATH for the launch/attach keys -(`r` `s` `e` `a`); pure observation works without it. (WSL counts as Linux, so -tmux works there unchanged; native Windows awaits a non-tmux backend.) Once a second -backend registers, `bmad-loop mux` shows which is selected and why, and the Settings -editor's `mux.backend` (or the `BMAD_LOOP_MUX_BACKEND` env var) forces the choice per machine. +`--project` defaults to the current directory. The selected terminal-multiplexer +backend — tmux by default, herdr opt-in; see +[Terminal multiplexer backends](multiplexer-backends.md) — must be on PATH for the +launch/attach keys (`r` `s` `e` `a`); pure observation works without it. (WSL counts as +Linux, so tmux works there unchanged; native Windows awaits a Windows-capable backend.) +`bmad-loop mux` shows which backend is selected and why, and the Settings editor's +`mux.backend` (or the `BMAD_LOOP_MUX_BACKEND` env var) forces the choice per machine. Over a slow or high-latency link (SSH, Tailscale), a 60fps update stream can't drain in time and partial frames paint over old ones. Launch with @@ -369,7 +370,7 @@ disambiguate the spec. When it has recorded a resolution, the same window prompt `re-arm and resume run ? [y/N]`; answer `y` and it re-arms the story (`escalated → pending`, spec status reset to `ready-for-dev`) and resumes the run in place — a clean rebuild against the corrected spec, then on through the rest -of the sprint. Detach (`Ctrl-b d`) to return to the dashboard, which observes the +of the sprint. Detach (`Ctrl-b d`; herdr: `ctrl+b q`) to return to the dashboard, which observes the resumed run like any other. Exiting the agent without recording a resolution leaves the story escalated and the run paused — the safe default. @@ -421,7 +422,10 @@ the run header name the stage so you know which viewer `p` will open. If the TUI itself is running inside tmux, attach uses `switch-client` — the TUI keeps running and you switch back with your usual tmux client commands. Outside tmux, the TUI suspends, runs `tmux attach`, and resumes when you -detach (`ctrl-b d`). +detach (`ctrl-b d`). On the herdr backend the same two paths apply (a tab switch +inside herdr, a suspended blocking attach outside); the detach chord is `ctrl+b q`, +and one hand-back is manual — see +[Terminal multiplexer backends](multiplexer-backends.md#detach-is-manual-press-ctrlb-q). ### Answering a sweep decision @@ -434,7 +438,9 @@ toast. Then: orchestrator window, where the prompt is waiting. 2. Answer the prompt (build / close / keep-open, with the triage recommendation shown). -3. Detach with `ctrl-b d`. +3. Detach with `ctrl-b d`. (On tmux this step is belt-and-suspenders — answering + already hands your terminal back. On herdr the hand-back cannot be automatic: + press `ctrl+b q`.) The banner clears on the next poll after the sweep journals anything further (the answer is recorded as a `decision:` line in `deferred-work.md`). Sweeps @@ -560,10 +566,10 @@ buttons and block the save. The write itself is atomic (temp file + | Message | Cause / fix | | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `tmux not found on PATH — launch/attach disabled` | install tmux; the dashboard still works read-only | +| `multiplexer backend unavailable — launch/attach disabled` | install the selected backend's binary (tmux by default — see `bmad-loop mux`); the dashboard still works read-only | | `git worktree is not clean — commit or stash first` | the launch guard; commit/stash and retry. `.bmad-loop/policy.toml` is exempt, so saving in the settings editor never blocks a launch | | `another run is live: ` | a second engine on the same project may conflict — confirm only if you know they won't touch the same stories | -| `launch may have failed — attach to tmux session bmad-loop-ctl` | no `state.json` within 10 s of launch; attach to the ctl window to read the error (the window stays open with the exit code) | +| `launch may have failed — attach to control session bmad-loop-ctl` | no `state.json` within 10 s of launch; attach to the ctl window to read the error (the window stays open with the exit code) | | `no run selected` | `e` / `a` need a selected run — the project has no runs yet | | `state for run is unreadable` | corrupt/missing `state.json`; inspect the run dir | | `run already finished` | finished runs can't be resumed | diff --git a/src/bmad_loop/adapters/herdr_backend.py b/src/bmad_loop/adapters/herdr_backend.py index 768d824..2933a7b 100644 --- a/src/bmad_loop/adapters/herdr_backend.py +++ b/src/bmad_loop/adapters/herdr_backend.py @@ -42,8 +42,9 @@ ``herdr tab focus``). POSIX-only, like the ``exec`` launch (pwsh dialect is the Windows follow-up). - ``attach_target_argv`` accepts both target families (native pane id and the - tmux-style ``=session[:window]`` specs ``tui/launch.py`` builds — see - ``_parse_target``): outside herdr it returns ``["herdr", "terminal", + seam-canonical ``=session[:window]`` tokens core formats via + ``TerminalMultiplexer.target`` — see ``_parse_target``): outside herdr it + returns ``["herdr", "terminal", "attach", ]`` (which blocks, and exits when the pane closes); inside a herdr pane it returns the fire-and-forget ``["herdr", "tab", "focus", ]`` — the switch-client move, mirroring tmux's in-``TMUX`` @@ -88,7 +89,7 @@ from pathlib import Path from .. import platform_util -from .multiplexer import MultiplexerError, TerminalMultiplexer +from .multiplexer import MultiplexerError, TerminalMultiplexer, parse_target HERDR_TIMEOUT_S = 30 # The herdr server wire protocol this backend was written against (0.7.3). Read @@ -665,19 +666,20 @@ def _tab_root_pane(self, tab_id: str, *, strict: bool) -> str | None: def _parse_target(self, target: str, *, strict: bool) -> str | None: """Resolve any window target to a native pane id. - Callers hand this two families: a tmux-style ``=session[:window]`` spec - (``tui/launch.py`` builds these) and our own native pane id. The ``=`` - prefix is the discriminator — native ids contain ``:`` (``w1:p1``) but - never lead with ``=``. A native id passes through untouched, with no - server round-trip. For ``=…`` targets: session → workspace (first-match - label), a window name → the tab with that label, no window → the - session-level tab (see :func:`_session_level_tab`), then that tab's - pane. ``strict=True`` raises :class:`HerdrError` when the target cannot - be resolved; ``strict=False`` returns None (the sentinel callers' quiet - failure).""" - if not target.startswith("="): + Callers hand this two families: the seam-canonical ``=session[:window]`` + token (decoded by :func:`multiplexer.parse_target`; core formats these + via ``TerminalMultiplexer.target``) and our own native pane id — native + ids contain ``:`` (``w1:p1``) but never lead with ``=``. A native id + passes through untouched, with no server round-trip. For ``=…`` targets: + session → workspace (first-match label), a window name → the tab with + that label, no window → the session-level tab (see + :func:`_session_level_tab`), then that tab's pane. ``strict=True`` + raises :class:`HerdrError` when the target cannot be resolved; + ``strict=False`` returns None (the sentinel callers' quiet failure).""" + parsed = parse_target(target) + if parsed is None: return target - session, _, window = target[1:].partition(":") + session, window = parsed row = self._workspace_row(session, strict=strict) if row is None: if strict: diff --git a/src/bmad_loop/adapters/multiplexer.py b/src/bmad_loop/adapters/multiplexer.py index 11959fd..e5addbe 100644 --- a/src/bmad_loop/adapters/multiplexer.py +++ b/src/bmad_loop/adapters/multiplexer.py @@ -39,6 +39,24 @@ class MultiplexerError(Exception): without importing a backend.""" +def parse_target(target: str) -> tuple[str, str | None] | None: + """Decode a seam-canonical window target (see :meth:`TerminalMultiplexer.target`). + + Returns ``(session, window)`` for a canonical ``=session[:window]`` token — + ``window`` is None when absent *or* empty, so ``"=s"`` and ``"=s:"`` both + decode to ``("s", None)`` — or None when ``target`` does not start with + ``=``: a backend-native id (``"@1"``, ``"%3"``, ``"w1:p1"``, ...) the caller + resolves itself. The window part is everything after the *first* ``:``; + that split is safe because bmad-loop mints window names + (``-``) that never contain ``:``. Provided so a backend whose + native addressing differs decodes the grammar with one tested helper + instead of re-deriving it (see the herdr backend's ``_parse_target``).""" + if not target.startswith("="): + return None + session, _, window = target[1:].partition(":") + return (session, window or None) + + class TerminalMultiplexer(ABC): """Transport backend for agent sessions: sessions, windows, and clients. @@ -49,6 +67,21 @@ class TerminalMultiplexer(ABC): ``tui/launch.py``, ``probe.py``, and ``tui/data.py`` migrate onto it. """ + # ------------------------------------------------------------ targets + + def target(self, session: str, window: str | None = None) -> str: + """Format the seam-canonical target token for ``session`` (optionally + one of its windows, *by name*). The default grammar is + ``=session[:window]`` — historically tmux's exact-match syntax, now + owned by the seam: every target-taking method below accepts both this + token and the backend's native ids, and :func:`parse_target` is the + matching decoder. Backends MAY override to emit native ids, but the + result must stay a stable *by-name* reference: callers format targets + ahead of use (e.g. a parked window's return target), so eager + resolution to a live native id can go stale — keeping the token + symbolic and resolving lazily at use time is the recommended default.""" + return f"={session}:{window}" if window else f"={session}" + # ----------------------------------------------------------- sessions @abstractmethod @@ -132,28 +165,33 @@ def window_alive(self, session: str, window_id: str) -> bool: @abstractmethod def kill_window(self, target: str) -> None: """Kill the targeted window (tolerant of it already being gone, and a - no-op on a transport failure).""" + no-op on a transport failure). ``target`` is a :meth:`target` token or + a backend-native window id.""" @abstractmethod def select_window(self, target: str) -> None: """Make ``target`` the current window of its session (best-effort: a no-op - on a transport failure).""" + on a transport failure). ``target`` is a :meth:`target` token or a + backend-native window id.""" @abstractmethod def set_window_option(self, target: str, option: str, value: str) -> None: """Set a user option on the targeted window (best-effort: a no-op on a - transport failure).""" + transport failure). ``target`` is a :meth:`target` token or a + backend-native window id.""" @abstractmethod def unset_window_option(self, target: str, option: str) -> None: """Remove a user option from the targeted window (so a later read sees it as unset, not as an empty value). Best-effort: a no-op on a transport - failure.""" + failure. ``target`` is a :meth:`target` token or a backend-native + window id.""" @abstractmethod def show_window_option(self, target: str, option: str) -> str: """Value of a user option on the targeted window ('' if unset, and '' on a - transport failure).""" + transport failure). ``target`` is a :meth:`target` token or a + backend-native window id.""" @abstractmethod def pipe_pane(self, window_id: str, log_file: Path) -> None: @@ -168,7 +206,9 @@ def send_text(self, window_id: str, text: str) -> None: @abstractmethod def attach_target_argv(self, target: str) -> list[str]: - """argv that attaches the caller's terminal to ``target``.""" + """argv that attaches the caller's terminal to ``target`` (a + :meth:`target` token — session-only or session+window — or a + backend-native id).""" @abstractmethod def current_pane_id(self) -> str | None: @@ -194,7 +234,8 @@ def detach_client(self) -> None: def switch_client(self, target: str, last_fallback: bool = False) -> bool: """Switch the current client to ``target`` (optionally falling back to the last client on failure). Returns True iff a switch happened — so a - transport failure returns False.""" + transport failure returns False. ``target`` is a :meth:`target` token + or a backend-native id.""" @abstractmethod def available(self) -> bool: diff --git a/src/bmad_loop/adapters/tmux_base.py b/src/bmad_loop/adapters/tmux_base.py index 3893781..9173042 100644 --- a/src/bmad_loop/adapters/tmux_base.py +++ b/src/bmad_loop/adapters/tmux_base.py @@ -43,7 +43,10 @@ class TmuxError(MultiplexerError): class BaseTmuxBackend(TerminalMultiplexer): """tmux-family backend: all argv construction and every contract method, with - one overridable subprocess primitive (:meth:`_run`) every call funnels through.""" + one overridable subprocess primitive (:meth:`_run`) every call funnels through. + The seam-canonical target grammar (``=session[:window]``, see + :meth:`TerminalMultiplexer.target`) coincides with tmux's exact-match target + syntax, so targets pass straight through to tmux — never parsed here.""" #: Output decoding for captured tmux text. ``None`` (POSIX) = locale default, #: byte-identical to a bare ``text=True``; a Windows leaf sets ``"utf-8"``. diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 6ca5270..ba4e534 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -359,6 +359,8 @@ def cmd_mux(args: argparse.Namespace) -> int: return 1 chosen = next((r for r in rows if r.selected), None) reason = _mux_reason_label(chosen.reason) if chosen else "fallback" + # no selected row means _select bottomed out at its documented historical + # fallback, which is tmux by contract — not a stale hardcoding name = chosen.name if chosen else "tmux" print(f"selection: {name} ({type(backend).__name__}) — {reason}") return 0 diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 52f438f..fc51d89 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -120,8 +120,14 @@ def attach_target_argv(target: str) -> list[str]: return get_multiplexer().attach_target_argv(target) +def session_target(run_id: str) -> str: + """Seam-canonical target token for the run's agent session (see + :meth:`TerminalMultiplexer.target`).""" + return get_multiplexer().target(session_name(run_id)) + + def attach_argv(run_id: str) -> list[str]: - return attach_target_argv(f"={session_name(run_id)}") + return attach_target_argv(session_target(run_id)) # ---------------------------------------------------- run resolution / liveness @@ -283,7 +289,7 @@ def project_tag(project: Path) -> str: return str(project.resolve()) -def tmux_sessions() -> list[str]: +def mux_sessions() -> list[str]: """All live session names, or [] when the multiplexer is missing, no server is running, or the query fails.""" return get_multiplexer().list_sessions() @@ -291,7 +297,7 @@ def tmux_sessions() -> list[str]: def session_project_tags() -> dict[str, str]: """Map each live session name to its PROJECT_OPTION value ("" when unset). - Same missing-multiplexer/no-server guards as tmux_sessions().""" + Same missing-multiplexer/no-server guards as mux_sessions().""" return get_multiplexer().session_options(PROJECT_OPTION) @@ -317,7 +323,7 @@ def prunable_sessions(project: Path) -> tuple[list[str], list[str], set[str]]: prunable: list[str] = [] live: list[str] = [] unknown: set[str] = set() - for name in tmux_sessions(): + for name in mux_sessions(): if name == CTL_SESSION or not name.startswith(_SESSION_PREFIX): continue run_id = name[len(_SESSION_PREFIX) :] diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index daab212..d465a45 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -148,10 +148,10 @@ def action_toggle_dark(self) -> None: # ------------------------------------------------------------ run control - def _tmux_missing(self) -> bool: - if launch.tmux_available(): + def _mux_missing(self) -> bool: + if launch.mux_available(): return False - self.notify("tmux not found on PATH — launch/attach disabled", severity="error") + self.notify("multiplexer backend unavailable — launch/attach disabled", severity="error") return True def _guarded(self, go: Callable[[], None]) -> None: @@ -184,7 +184,7 @@ def _guarded(self, go: Callable[[], None]) -> None: go() def action_start_run(self) -> None: - if self._tmux_missing(): + if self._mux_missing(): return source, spec_folder = self._stories_defaults() self.push_screen( @@ -236,13 +236,13 @@ def go() -> None: except launch.LaunchError as e: self.notify(str(e), severity="error") return - self.notify(f"run {run_id} launched (tmux session {launch.CTL_SESSION})") + self.notify(f"run {run_id} launched (control session {launch.CTL_SESSION})") self._dashboard.expect_run(run_id) self._guarded(go) def action_start_sweep(self) -> None: - if self._tmux_missing(): + if self._mux_missing(): return self.push_screen(StartSweepModal(), self._start_sweep_result) @@ -269,7 +269,7 @@ def go() -> None: except launch.LaunchError as e: self.notify(str(e), severity="error") return - self.notify(f"sweep {run_id} launched (tmux session {launch.CTL_SESSION})") + self.notify(f"sweep {run_id} launched (control session {launch.CTL_SESSION})") self._dashboard.expect_run(run_id) self._guarded(go) @@ -315,7 +315,7 @@ def _record_decision(self, decision: object, option: object) -> bool: return True def action_resume_run(self) -> None: - if self._tmux_missing(): + if self._mux_missing(): return run_id = self._dashboard.selected_run_id if run_id is None: @@ -340,12 +340,12 @@ def done(ok: bool | None) -> None: except launch.LaunchError as e: self.notify(str(e), severity="error") return - self.notify(f"resume of {run_id} launched (tmux session {launch.CTL_SESSION})") + self.notify(f"resume of {run_id} launched (control session {launch.CTL_SESSION})") self.push_screen(ConfirmResumeModal(run_id, state, engine_alive), done) def action_attach(self) -> None: - if self._tmux_missing(): + if self._mux_missing(): return run_id = self._dashboard.selected_run_id if run_id is None: @@ -360,12 +360,12 @@ def action_attach(self) -> None: if window is not None and (self._dashboard.decision_pending is not None or not agent_live): launch.select_ctl_window(window) self._attach_to_target( - f"={launch.CTL_SESSION}", - return_window=f"={launch.CTL_SESSION}:{window}", + launch.ctl_target(), + return_window=launch.ctl_target(window), ) return elif agent_live: - target = f"={session}" + target = runs.session_target(run_id) else: self.notify( f"nothing to attach: no live agent session ({session}) and no " @@ -413,7 +413,7 @@ def _attach_to_target(self, target: str, return_window: str | None = None) -> No ) def action_resolve_run(self) -> None: - if self._tmux_missing(): + if self._mux_missing(): return run_id = self._dashboard.selected_run_id if run_id is None: @@ -459,7 +459,7 @@ def _launch_resolve(self, run_id: str) -> None: self.notify("resolve launched but its window id was not captured", severity="error") return launch.select_ctl_window_id(win_id) - self._attach_to_target(f"={launch.CTL_SESSION}", return_window=win_id) + self._attach_to_target(launch.ctl_target(), return_window=win_id) # -------------------------------------------------------- HITL pause review @@ -608,7 +608,7 @@ def _review_escalation(self, run_id: str, run_dir: Path, state: RunState) -> Non def done(verb: str | None) -> None: if verb == "resolve": - if self._tmux_missing() or self._resolve_blocked_by_liveness(run_id, run_dir): + if self._mux_missing() or self._resolve_blocked_by_liveness(run_id, run_dir): return self._launch_resolve(run_id) elif verb == "rearm": @@ -637,7 +637,7 @@ def _do_resume(self, run_id: str) -> None: """Resume a paused run — the `bmad-loop resume` / `e` path, minus the confirm modal (the viewer was the confirmation). Guards tmux + a possibly-live engine so an approve/continue can't double-drive.""" - if self._tmux_missing(): + if self._mux_missing(): return run_dir = self.project / RUNS_DIR / run_id if _engine_possibly_live(run_dir): @@ -648,7 +648,7 @@ def _do_resume(self, run_id: str) -> None: except launch.LaunchError as e: self.notify(str(e), severity="error") return - self.notify(f"resume of {run_id} launched (tmux session {launch.CTL_SESSION})") + self.notify(f"resume of {run_id} launched (control session {launch.CTL_SESSION})") def _do_replan(self, run_id: str, spec_path: Path) -> None: """Request-replan: reset the planned spec to draft + strip its Auto Run @@ -781,7 +781,7 @@ def _selected_run_dir(self) -> tuple[str, Path] | None: return run_id, self.project / RUNS_DIR / run_id def action_stop_run(self) -> None: - if self._tmux_missing(): + if self._mux_missing(): return selected = self._selected_run_dir() if selected is None: @@ -886,7 +886,7 @@ def _archive_run_worker(self, run_id: str, run_dir: Path) -> None: self.call_from_thread(self.notify, f"run {run_id} archived to {dest}") def action_cleanup_sessions(self) -> None: - if self._tmux_missing(): + if self._mux_missing(): return def done(ok: bool | None) -> None: diff --git a/src/bmad_loop/tui/launch.py b/src/bmad_loop/tui/launch.py index a79af20..efc7b34 100644 --- a/src/bmad_loop/tui/launch.py +++ b/src/bmad_loop/tui/launch.py @@ -31,7 +31,7 @@ class LaunchError(Exception): pass -def tmux_available() -> bool: +def mux_available() -> bool: return get_multiplexer().available() @@ -43,7 +43,7 @@ def ctl_window(run_id: str) -> str | None: """Name of the control-session window hosting this run's orchestrator process (start_detached names windows -), or None when the run was not launched from the TUI or the session is gone.""" - if not tmux_available(): + if not mux_available(): return None for (name,) in get_multiplexer().list_windows(CTL_SESSION, ["window_name"]): if name.endswith(f"-{run_id}"): @@ -51,10 +51,16 @@ def ctl_window(run_id: str) -> str | None: return None +def ctl_target(window: str | None = None) -> str: + """Seam-canonical target token for the control session (optionally one of + its windows, by name); see :meth:`TerminalMultiplexer.target`.""" + return get_multiplexer().target(CTL_SESSION, window) + + def select_ctl_window(window: str) -> None: """Make `window` the control session's current window, so a plain attach to the session lands on it (attach-session itself takes no window).""" - get_multiplexer().select_window(f"={CTL_SESSION}:{window}") + get_multiplexer().select_window(ctl_target(window)) def select_ctl_window_id(window_id: str) -> None: @@ -162,9 +168,9 @@ def attach_plan(project: Path, run_id: str) -> tuple[list[str], str | None] | No decision_pending(runs.run_dir_for(project, run_id)) or not agent_live ): select_ctl_window(window) - return runs.attach_target_argv(f"={CTL_SESSION}"), f"={CTL_SESSION}:{window}" + return runs.attach_target_argv(ctl_target()), ctl_target(window) if agent_live: - return runs.attach_target_argv(f"={session}"), None + return runs.attach_target_argv(runs.session_target(run_id)), None return None @@ -173,7 +179,7 @@ def kill_ctl_window(run_id: str) -> None: if any. A no-op when the run was not launched from the TUI or tmux is gone.""" window = ctl_window(run_id) if window is not None: - get_multiplexer().kill_window(f"={CTL_SESSION}:{window}") + get_multiplexer().kill_window(ctl_target(window)) def _ctl_window_candidates(project: Path) -> list[tuple[str, str]]: @@ -244,7 +250,7 @@ def _ensure_ctl_session(project: Path) -> None: try: mux.new_session(CTL_SESSION, project) except MultiplexerError as e: - raise LaunchError(f"tmux new-session failed: {e}") from e + raise LaunchError(f"multiplexer new-session failed: {e}") from e def cli_argv(*tail: str) -> list[str]: @@ -266,7 +272,7 @@ def start_detached(project: Path, argv_tail: list[str], run_id: str, kind: str) """ mux = get_multiplexer() if not mux.available(): - raise LaunchError("tmux not found on PATH") + raise LaunchError("multiplexer backend unavailable (binary not on PATH)") _ensure_ctl_session(project) try: win_id = ( @@ -280,7 +286,7 @@ def start_detached(project: Path, argv_tail: list[str], run_id: str, kind: str) or None ) except MultiplexerError as e: - raise LaunchError(f"tmux new-window failed: {e}") from e + raise LaunchError(f"multiplexer new-window failed: {e}") from e if win_id: # Tag the window with its project so a cleanup in another project never # closes it (the ctl session is shared across projects). diff --git a/src/bmad_loop/tui/screens/dashboard.py b/src/bmad_loop/tui/screens/dashboard.py index ff580a0..127035c 100644 --- a/src/bmad_loop/tui/screens/dashboard.py +++ b/src/bmad_loop/tui/screens/dashboard.py @@ -682,7 +682,7 @@ def _tick(self, force_rescan: bool | None = None) -> None: if self._pending_run is not None and time.monotonic() > self._pending_deadline: self._pending_run = None self.notify( - "launch may have failed — attach to tmux session bmad-loop-ctl", + "launch may have failed — attach to control session bmad-loop-ctl", severity="error", timeout=15, ) diff --git a/src/bmad_loop/tui/widgets.py b/src/bmad_loop/tui/widgets.py index 564dcf5..2682865 100644 --- a/src/bmad_loop/tui/widgets.py +++ b/src/bmad_loop/tui/widgets.py @@ -112,7 +112,7 @@ def show_starting(self, run_id: str) -> None: text.append(" ⧗ starting…", style="yellow") text.append( "\nwaiting for the engine to write state.json" - " — if nothing appears, attach to tmux session bmad-loop-ctl", + " — if nothing appears, attach to control session bmad-loop-ctl", style="dim", ) self.update(text) diff --git a/tests/test_multiplexer.py b/tests/test_multiplexer.py index a67062e..74c81ec 100644 --- a/tests/test_multiplexer.py +++ b/tests/test_multiplexer.py @@ -17,7 +17,8 @@ from bmad_loop.adapters import tmux_base from bmad_loop.adapters.base import SessionSpec from bmad_loop.adapters.generic import GenericAdapter -from bmad_loop.adapters.multiplexer import MultiplexerError, TerminalMultiplexer +from bmad_loop.adapters.herdr_backend import HerdrMultiplexer +from bmad_loop.adapters.multiplexer import MultiplexerError, TerminalMultiplexer, parse_target from bmad_loop.adapters.profile import get_profile from bmad_loop.adapters.tmux_backend import TmuxMultiplexer from bmad_loop.policy import LimitsPolicy, Policy @@ -506,3 +507,54 @@ def test_dialect_leaf_new_window_routes_launch_through_hook(monkeypatch, tmp_pat "wrapped:cmd", ] assert "-e" not in rec.argv # env strategy fully delegated to the hook + + +# ------------------------------------------------------------ target contract +# +# target() is the seam-canonical encoder core uses instead of hand-assembling +# "=session[:window]" strings; parse_target is the matching decoder a native-id +# backend reuses instead of re-deriving the grammar. Pure string work: no +# subprocess, no env sensitivity, safe on every CI leg. Both backends are +# constructed directly (their constructors are documented side-effect-free). + + +def test_target_default_grammar(): + mux = TmuxMultiplexer() + assert mux.target("s") == "=s" + assert mux.target("s", "w") == "=s:w" + # falsy window collapses to the session-only form, mirroring parse_target's + # "=s:" -> ("s", None) decode + assert mux.target("s", None) == "=s" + assert mux.target("s", "") == "=s" + + +def test_herdr_inherits_the_default_encoder(): + # herdr resolves targets lazily at use time (_parse_target), so it must NOT + # override target() to eagerly emit native ids — a token formatted ahead of + # use (e.g. attach_plan's return_window) would go stale. + assert "target" not in HerdrMultiplexer.__dict__ + assert HerdrMultiplexer().target("s", "w") == "=s:w" + + +@pytest.mark.parametrize( + ("session", "window"), + [("s", None), ("s", "w"), ("bmad-loop-ctl", "run-20260714-abc")], +) +def test_parse_target_round_trips_the_encoder(session, window): + mux = TmuxMultiplexer() + assert parse_target(mux.target(session, window)) == (session, window) + + +def test_parse_target_edges(): + # empty window part decodes like the session-only form + assert parse_target("=s:") == ("s", None) + # window is everything after the FIRST colon (minted names carry no colon, + # but the split rule is pinned regardless) + assert parse_target("=s:a:b") == ("s", "a:b") + + +@pytest.mark.parametrize("native", ["@1", "%3", "w1:p1"]) +def test_parse_target_passes_native_ids_through(native): + # non-"=" targets are backend-native ids: the decoder answers None and the + # backend resolves them itself + assert parse_target(native) is None diff --git a/tests/test_runs.py b/tests/test_runs.py index 94eebd7..e6a877b 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -525,20 +525,20 @@ def test_stop_run_clean_stop_on_pre_stop_pid_reuse(tmp_path, monkeypatch): # ---------------------------------------------------------------- prune sessions -def test_tmux_sessions_no_tmux(monkeypatch): - # tmux_sessions now delegates to the multiplexer backend; patch its seam. +def test_mux_sessions_no_tmux(monkeypatch): + # mux_sessions now delegates to the multiplexer backend; patch its seam. monkeypatch.setattr(tmux_base.shutil, "which", lambda _name: None) - assert runs.tmux_sessions() == [] + assert runs.mux_sessions() == [] -def test_tmux_sessions_no_server(monkeypatch): +def test_mux_sessions_no_server(monkeypatch): monkeypatch.setattr(tmux_base.shutil, "which", lambda _name: "/usr/bin/tmux") monkeypatch.setattr( tmux_base.subprocess, "run", lambda *a, **k: subprocess.CompletedProcess(a, 1, stdout="", stderr="no server"), ) - assert runs.tmux_sessions() == [] + assert runs.mux_sessions() == [] def test_prunable_sessions_partitions(tmp_path, monkeypatch): @@ -564,7 +564,7 @@ def test_prunable_sessions_partitions(tmp_path, monkeypatch): "bmad-loop-ctl", # control session: never a candidate "unrelated", # not ours ] - monkeypatch.setattr(runs, "tmux_sessions", lambda: sessions) + monkeypatch.setattr(runs, "mux_sessions", lambda: sessions) monkeypatch.setattr( runs, "session_project_tags", @@ -593,7 +593,7 @@ def test_prunable_sessions_skips_invalid_run_ids(tmp_path, monkeypatch): (good / "engine.pid").write_text(str(_dead_pid())) sessions = ["bmad-loop-fin-1", "bmad-loop-../../x", "bmad-loop-a.b", "bmad-loop-"] - monkeypatch.setattr(runs, "tmux_sessions", lambda: sessions) + monkeypatch.setattr(runs, "mux_sessions", lambda: sessions) monkeypatch.setattr(runs, "session_project_tags", lambda: dict.fromkeys(sessions, mine)) prunable, live, unknown = runs.prunable_sessions(tmp_path) @@ -607,7 +607,7 @@ def test_prunable_sessions_flags_unknown(tmp_path, monkeypatch): mine = runs.project_tag(tmp_path) odd = _make_state_run(tmp_path, "odd-1") (odd / "engine.pid").write_text("4242 123.0") - monkeypatch.setattr(runs, "tmux_sessions", lambda: ["bmad-loop-odd-1"]) + monkeypatch.setattr(runs, "mux_sessions", lambda: ["bmad-loop-odd-1"]) monkeypatch.setattr(runs, "session_project_tags", lambda: {"bmad-loop-odd-1": mine}) monkeypatch.setattr(runs, "get_process_host", lambda: _FakeHost(alive=True, identity=None)) prunable, live, unknown = runs.prunable_sessions(tmp_path) @@ -621,7 +621,7 @@ def test_prune_sessions_dry_run_kills_nothing(tmp_path, monkeypatch): (finished / "engine.pid").write_text(str(_dead_pid())) killed: list[str] = [] monkeypatch.setattr(runs, "kill_session", lambda rid: killed.append(rid)) - monkeypatch.setattr(runs, "tmux_sessions", lambda: ["bmad-loop-fin-1"]) + monkeypatch.setattr(runs, "mux_sessions", lambda: ["bmad-loop-fin-1"]) monkeypatch.setattr( runs, "session_project_tags", lambda: {"bmad-loop-fin-1": runs.project_tag(tmp_path)} ) @@ -639,7 +639,7 @@ def test_prune_sessions_returns_unknown_from_same_sample(tmp_path, monkeypatch): (odd / "engine.pid").write_text("4242 123.0") killed: list[str] = [] monkeypatch.setattr(runs, "kill_session", lambda rid: killed.append(rid)) - monkeypatch.setattr(runs, "tmux_sessions", lambda: ["bmad-loop-odd-1"]) + monkeypatch.setattr(runs, "mux_sessions", lambda: ["bmad-loop-odd-1"]) monkeypatch.setattr(runs, "session_project_tags", lambda: {"bmad-loop-odd-1": mine}) monkeypatch.setattr(runs, "get_process_host", lambda: _FakeHost(alive=True, identity=None)) assert runs.prune_sessions(tmp_path) == (["odd-1"], [], {"odd-1"}) diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 1311cbf..451f900 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -911,7 +911,7 @@ async def test_settings_binding_opens_editor(project): async def test_start_run_modal_escape_cancels(project, monkeypatch): calls = [] - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(launch, "start_run_detached", lambda *a, **kw: calls.append(a)) app = BmadLoopApp(project.project) async with app.run_test() as pilot: @@ -925,7 +925,7 @@ async def test_start_run_modal_escape_cancels(project, monkeypatch): async def test_start_run_modal_launches(project, monkeypatch): calls = {} - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) def fake_start(proj, run_id, *, spec=None, epic, story, max_stories): calls.update( @@ -959,7 +959,7 @@ def fake_start(proj, run_id, *, spec=None, epic, story, max_stories): async def test_dirty_worktree_blocks_launch(project, monkeypatch): calls = [] - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(launch, "start_run_detached", lambda *a, **kw: calls.append(a)) (project.project / "src.txt").write_text("dirty\n") app = BmadLoopApp(project.project) @@ -974,7 +974,7 @@ async def test_dirty_worktree_blocks_launch(project, monkeypatch): async def test_live_run_asks_for_confirmation(project, monkeypatch): calls = [] - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(launch, "start_run_detached", lambda *a, **kw: calls.append(a)) make_run(project.project, "20260611-100000-aaaa", alive=True) # our pid: running app = BmadLoopApp(project.project) @@ -996,7 +996,7 @@ async def test_live_run_asks_for_confirmation(project, monkeypatch): async def test_unknown_pid_run_asks_for_confirmation(project, monkeypatch): calls = [] - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(launch, "start_run_detached", lambda *a, **kw: calls.append(a)) monkeypatch.setattr(data, "liveness", lambda run_dir: "unknown") run_dir = make_run(project.project, "20260611-100000-aaaa") @@ -1023,7 +1023,7 @@ async def test_legacy_pidless_but_live_run_asks_for_confirmation(project, monkey # (liveness == "alive"). The launch guard must still catch it — the pid gate # alone would skip a running engine and allow a conflicting launch. calls = [] - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(launch, "start_run_detached", lambda *a, **kw: calls.append(a)) monkeypatch.setattr(data, "liveness", lambda run_dir: "alive") make_run(project.project, "20260611-100000-aaaa") # no engine.pid: legacy run @@ -1045,7 +1045,7 @@ async def test_legacy_pidless_but_live_run_asks_for_confirmation(project, monkey async def test_start_sweep_modal_launches(project, monkeypatch): calls = {} - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) def fake_sweep(proj, run_id, *, no_prompt, decisions_only, max_bundles): calls.update( @@ -1073,7 +1073,7 @@ def fake_sweep(proj, run_id, *, no_prompt, decisions_only, max_bundles): async def test_dry_run_shows_captured_output(project, monkeypatch): seen = {} - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) def fake_captured(tail): seen["tail"] = tail @@ -1109,7 +1109,7 @@ async def test_validate_shows_output_modal(project, monkeypatch): async def test_resume_confirm_launches(project, monkeypatch): calls = [] - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") make_run( @@ -1129,7 +1129,7 @@ async def test_resume_confirm_launches(project, monkeypatch): async def test_resume_unknown_pid_warns(project, monkeypatch): - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(data, "liveness", lambda run_dir: "unknown") run_dir = make_run( project.project, @@ -1169,7 +1169,7 @@ async def test_cleanup_unknown_sessions_notifies(project, monkeypatch): # must say so instead of silently killing a possibly-live engine's session. from bmad_loop import runs - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(runs, "prune_sessions", lambda _p: (["odd-1"], [], {"odd-1"})) monkeypatch.setattr(launch, "prune_ctl_windows", lambda _p: []) make_run(project.project, "20260611-100000-aaaa") @@ -1184,7 +1184,7 @@ async def test_cleanup_unknown_sessions_notifies(project, monkeypatch): async def test_resume_finished_run_refused(project, monkeypatch): - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) make_run(project.project, "20260611-100000-aaaa", finished=True) app = BmadLoopApp(project.project) async with app.run_test() as pilot: @@ -1195,18 +1195,21 @@ async def test_resume_finished_run_refused(project, monkeypatch): assert isinstance(app.screen, DashboardScreen) -async def test_attach_without_tmux_notifies(project, monkeypatch): - monkeypatch.setattr(launch, "tmux_available", lambda: False) +async def test_attach_without_mux_notifies(project, monkeypatch): + monkeypatch.setattr(launch, "mux_available", lambda: False) make_run(project.project, "20260611-100000-aaaa") app = BmadLoopApp(project.project) async with app.run_test() as pilot: await until(pilot, lambda: isinstance(app.screen, DashboardScreen)) await pilot.press("a") - await until(pilot, lambda: any("tmux not found" in m for m in notifications(app))) + await until( + pilot, + lambda: any("multiplexer backend unavailable" in m for m in notifications(app)), + ) async def test_attach_without_agent_session_notifies(project, monkeypatch): - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(launch, "session_exists", lambda session: False) monkeypatch.setattr(launch, "ctl_window", lambda run_id: None) make_run(project.project, "20260611-100000-aaaa") @@ -1223,7 +1226,7 @@ async def test_attach_multiplexer_error_notifies(project, monkeypatch): # raise after the availability/session pre-gates pass (server died or the # workspace was torn down in between); the TUI must surface the error as a # toast, not crash the app. - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(launch, "session_exists", lambda session: True) monkeypatch.setattr(launch, "ctl_window", lambda run_id: None) @@ -1314,7 +1317,7 @@ async def test_attach_targets_ctl_window_when_decision_pending(project, monkeypa run_dir = make_run(project.project, "20260611-100000-aaaa", run_type="sweep", alive=True) Journal(run_dir).append("decision-pending", dw_id="DW-7", question="q?") selected: list[str] = [] - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(launch, "session_exists", lambda session: True) # agent up too monkeypatch.setattr(launch, "ctl_window", lambda run_id: f"sweep-{run_id}") monkeypatch.setattr(launch, "select_ctl_window", lambda w: selected.append(w)) @@ -1340,7 +1343,7 @@ async def test_attach_outside_tmux_stamps_detach(project, monkeypatch): Journal(run_dir).append("decision-pending", dw_id="DW-7", question="q?") monkeypatch.delenv("TMUX", raising=False) stamps: list[tuple[str, str]] = [] - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(launch, "session_exists", lambda session: True) monkeypatch.setattr(launch, "ctl_window", lambda run_id: f"sweep-{run_id}") monkeypatch.setattr(launch, "select_ctl_window", lambda w: None) @@ -1357,7 +1360,7 @@ async def test_attach_outside_tmux_stamps_detach(project, monkeypatch): @pytest.mark.usefixtures("force_tmux_backend") # win32: pin tmux; default mux is herdr there async def test_attach_prefers_agent_session_without_decision(project, monkeypatch): make_run(project.project, "20260611-100000-aaaa", alive=True) - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(launch, "session_exists", lambda session: True) monkeypatch.setattr(launch, "ctl_window", lambda run_id: f"run-{run_id}") calls, stamps = _patch_attach_exec(monkeypatch) @@ -1376,7 +1379,7 @@ async def test_attach_prefers_agent_session_without_decision(project, monkeypatc async def test_attach_falls_back_to_ctl_window(project, monkeypatch): make_run(project.project, "20260611-100000-aaaa", alive=True) selected: list[str] = [] - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(launch, "session_exists", lambda session: False) monkeypatch.setattr(launch, "ctl_window", lambda run_id: f"run-{run_id}") monkeypatch.setattr(launch, "select_ctl_window", lambda w: selected.append(w)) @@ -1396,7 +1399,7 @@ async def test_attach_falls_back_to_ctl_window(project, monkeypatch): async def test_resolve_escalation_launches_and_attaches(project, monkeypatch): launched: list[str] = [] selected: list[str] = [] - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") def fake_start_resolve(proj, rid): @@ -1429,7 +1432,7 @@ def fake_start_resolve(proj, rid): async def test_resolve_unknown_pid_refused(project, monkeypatch): launched: list[str] = [] - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(data, "liveness", lambda run_dir: "unknown") monkeypatch.setattr(launch, "start_resolve_detached", lambda proj, rid: launched.append(rid)) run_dir = make_run( @@ -1450,7 +1453,7 @@ async def test_resolve_unknown_pid_refused(project, monkeypatch): async def test_resolve_refused_when_not_escalation(project, monkeypatch): launched: list[str] = [] - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") monkeypatch.setattr(launch, "start_resolve_detached", lambda proj, rid: launched.append(rid)) make_run( @@ -1622,7 +1625,7 @@ async def _open_review(app, pilot, modal_type): async def test_plan_checkpoint_approve_resumes(project, monkeypatch): calls: list[str] = [] - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") _stories_paused_run(project.project, stage="plan-checkpoint") @@ -1639,7 +1642,7 @@ async def test_plan_checkpoint_replan_resets_and_resumes(project, monkeypatch): calls: list[str] = [] resets: list[tuple] = [] strips: list[Path] = [] - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") monkeypatch.setattr( @@ -1658,7 +1661,7 @@ async def test_plan_checkpoint_replan_resets_and_resumes(project, monkeypatch): async def test_story_checkpoint_continue_resumes(project, monkeypatch): calls: list[str] = [] - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") _stories_paused_run( @@ -1680,7 +1683,7 @@ async def test_story_checkpoint_stop_marks_stopped(project, monkeypatch): from bmad_loop import runs stops: list[Path] = [] - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") monkeypatch.setattr(runs, "stop_run", lambda rd: stops.append(rd) or True) monkeypatch.setattr(launch, "kill_ctl_window", lambda rid: None) @@ -1709,7 +1712,7 @@ def test_checkpoint_gate_line_pluralization(): async def test_story_checkpoint_card_surfaces_real_review_cycles(project, monkeypatch): # audit item 13: the card's gate line must reflect the task's real # review_cycle, never the old blanket "verification passed" string. - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") _stories_paused_run( project.project, @@ -1734,7 +1737,7 @@ async def test_escalation_rearm_resumes_when_resolution_ready(project, monkeypat calls: list[str] = [] rearms: list[str] = [] - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") monkeypatch.setattr( @@ -1787,7 +1790,7 @@ async def test_escalation_rearm_warns_when_restore_recorded(project, monkeypatch calls: list[str] = [] rearms: list[str] = [] notes: list[str] = [] - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") monkeypatch.setattr( @@ -1836,7 +1839,7 @@ async def test_escalation_rearm_disabled_without_resolution(project, monkeypatch async def test_gate_pause_resume(project, monkeypatch): calls: list[str] = [] - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") spec = project.project / "spec-1-1-a.md" @@ -1860,7 +1863,7 @@ async def test_gate_pause_resume(project, monkeypatch): async def test_start_run_modal_stories_source_launches(project, monkeypatch): calls: dict = {} - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) def fake_start(proj, run_id, *, spec=None, epic, story, max_stories): calls.update(spec=spec, epic=epic, story=story) @@ -1881,10 +1884,10 @@ def fake_start(proj, run_id, *, spec=None, epic, story, max_stories): async def test_start_run_modal_stories_preview_validates(project, monkeypatch): - # action_start_run bails on _tmux_missing() before it can push the modal, and + # action_start_run bails on _mux_missing() before it can push the modal, and # the Windows CI matrix has no tmux on PATH — every StartRunModal test stubs # this out so the modal opens (its absence here was the all-Windows timeout). - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) _write_stories_fixture(project.project) # epic-1 with two stories, 1 done app = BmadLoopApp(project.project) async with app.run_test() as pilot: @@ -1922,7 +1925,7 @@ async def test_start_run_modal_stories_preview_validates(project, monkeypatch): async def test_start_run_modal_stories_source_blank_folder_errors(project, monkeypatch): calls: list = [] - monkeypatch.setattr(launch, "tmux_available", lambda: True) + monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(launch, "start_run_detached", lambda *a, **kw: calls.append(a)) app = BmadLoopApp(project.project) async with app.run_test() as pilot: diff --git a/tests/test_tui_launch.py b/tests/test_tui_launch.py index 4e862cc..a091489 100644 --- a/tests/test_tui_launch.py +++ b/tests/test_tui_launch.py @@ -185,10 +185,10 @@ def test_existing_ctl_session_reused(monkeypatch, tmp_path: Path): assert [c[1] for c in fake.calls] == ["has-session", "new-window", "set-option"] -def test_launch_without_tmux_raises(monkeypatch, tmp_path: Path): +def test_launch_without_mux_raises(monkeypatch, tmp_path: Path): monkeypatch.setattr(tmux_base.shutil, "which", lambda name: None) - assert not launch.tmux_available() - with pytest.raises(launch.LaunchError, match="tmux not found"): + assert not launch.mux_available() + with pytest.raises(launch.LaunchError, match="multiplexer backend unavailable"): launch.start_run_detached(tmp_path, "RID")