From ade13d0d2b11f6c98b1da7da797b49e3b7aaa54b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Davor=20Raci=C4=87?= Date: Fri, 3 Jul 2026 23:27:21 +0200 Subject: [PATCH 1/4] feat(adapters): add native-Windows psmux multiplexer backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PsmuxMultiplexer is a thin leaf over BaseTmuxBackend driving the psmux binary directly: PowerShell dialect hooks (shell source ships as pwsh -EncodedCommand base64/UTF-16LE, per-window env rides an in-source $env: prelude, a teammate-clear prefix strips the claude session vars psmux windows inherit from the server cold-starter) plus the probe-verified divergences — new-session needs PSMUX_ALLOW_NESTING=1 and a scrubbed create env, kill-session takes a plain -t target, and pipe-pane gets a pwsh Add-Content sink. The base grows a _BINARY class attribute threaded through the single spawn site, the which() guards, available()/version(), the attach argv, the parked-trailer verbs, and error prefixes, so a tmux-family leaf can name its own binary; the paired _ERRORS decode-errors knob keeps POSIX byte-identical (None default; the leaf sets backslashreplace). available() probes psmux and pwsh only — no tmux drop-in on PATH — and gates on the version reported by `psmux -V` (format `tmux X.Y.Z`) being strictly greater than 3.3.6: older releases can force-kill a recycled PID during pane teardown and leak orphaned servers, both engine-fatal and fixed upstream only after the 3.3.6 tag. The verdict is cached per instance; a forced backend name still bypasses the probe. The run bootstrap refuses an unusable backend outright instead of letting selection's last-resort fallback drive a run on one. Unit tests mock the single spawn seam, decode the EncodedCommand payloads to pin source composition, and cover the version-gate matrix and the run-bootstrap preflight; the registry default is platform-aware and the POSIX argv-shape tests pin the tmux backend explicitly. --- docs/ROADMAP.md | 9 +- docs/adapter-authoring-guide.md | 12 +- docs/porting-to-a-new-os.md | 42 +-- src/bmad_loop/adapters/multiplexer.py | 36 ++- src/bmad_loop/adapters/psmux_backend.py | 199 ++++++++++++++ src/bmad_loop/adapters/tmux_base.py | 70 ++--- src/bmad_loop/cli.py | 23 +- src/bmad_loop/tui/launch.py | 6 +- tests/test_backend_registry.py | 44 +-- tests/test_cli.py | 48 +++- tests/test_multiplexer.py | 1 + tests/test_psmux_backend.py | 339 ++++++++++++++++++++++++ tests/test_tui_launch.py | 16 +- 13 files changed, 746 insertions(+), 99 deletions(-) create mode 100644 src/bmad_loop/adapters/psmux_backend.py create mode 100644 tests/test_psmux_backend.py diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f81f4142..f866b488 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -36,11 +36,12 @@ Windows host. The remaining work is the native-Windows backend itself. Three candidates now exist, and they are **not stages of one plan**. Two are **sibling tmux-family backends** still in -flight: `psmux` (drives psmux's tmux-compatible shim via pwsh) and `tmux-windows` (#85; -drives the tmux-windows port) — both subclass `BaseTmuxBackend`, both register for `win32`, -and both invoke a binary literally named `tmux`, which is exactly why selection is +flight: `psmux` (drives psmux's tmux-compatible CLI through its own `psmux` binary, via +pwsh) and `tmux-windows` (#85; drives the tmux-windows port) — both subclass +`BaseTmuxBackend` and both register for `win32`, which is exactly why selection is availability-aware with discriminating `available()` probes (psmux → -`which("psmux") and which("tmux") and which("pwsh")`; tmux-windows → +`which("psmux") and which("pwsh")` plus a version gate excluding releases ≤ 3.3.6, whose +teardown can force-kill a recycled PID; tmux-windows → `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 + diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index ad57ee18..1e7bbc65 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -26,11 +26,13 @@ These are independent and abstracted separately: - **Transport axis** — `TerminalMultiplexer` (`adapters/multiplexer.py`): how sessions, windows, and panes are created, observed, and torn down. The generic adapter never shells out itself — it goes through `self.mux`, obtained from - `get_multiplexer()`. The one backend today is tmux: argv construction and the - single spawn primitive live in `BaseTmuxBackend` (`adapters/tmux_base.py`), with - the thin POSIX leaf `TmuxMultiplexer` (`adapters/tmux_backend.py`); together they - are the **only** files allowed to invoke `tmux` (and the only place POSIX-shell - trailers live). A future non-POSIX backend (e.g. a native-Windows "psmux") + `get_multiplexer()`. The bundled family is tmux-shaped: argv construction and + the single spawn primitive live in `BaseTmuxBackend` (`adapters/tmux_base.py`), + with the thin POSIX leaf `TmuxMultiplexer` (`adapters/tmux_backend.py`) and the + native-Windows leaf `PsmuxMultiplexer` (`adapters/psmux_backend.py`), which + points the same spawn seam at psmux's own binary via the `_BINARY` class + attribute; the base and its POSIX leaf are the **only** files allowed to invoke + `tmux` (and the only place POSIX-shell trailers live). Any other backend registers itself via `register_multiplexer(...)` and slots in behind `get_multiplexer()` with no change to the adapters. A backend author reads `multiplexer.py` for the contract and `tmux_backend.py` / `tmux_base.py` for the diff --git a/docs/porting-to-a-new-os.md b/docs/porting-to-a-new-os.md index 383f550b..41bda4a7 100644 --- a/docs/porting-to-a-new-os.md +++ b/docs/porting-to-a-new-os.md @@ -100,9 +100,11 @@ out-of-tree adapter is - **Extend `BaseTmuxBackend`** (`adapters/tmux_base.py`) for a **tmux-family** backend. `BaseTmuxBackend` holds every argv construction and routes every spawn through one primitive, `_run(argv, *, check=..., env=...)`. A native-Windows - "psmux" that speaks a tmux-like CLI sets the `_ENCODING` class attribute for - output decoding (e.g. `"utf-8"`) and passes a per-call `env=` where needed — - overriding `_run()` itself only to tweak the binary or timeout — plus the + "psmux" that speaks a tmux-like CLI sets the `_BINARY` class attribute to the + binary it drives (every spawn, PATH probe, and in-source client verb follows + it) and the `_ENCODING` class attribute for output decoding (e.g. `"utf-8"`), + and passes a per-call `env=` where needed — overriding `_run()` itself only + to tweak the timeout — plus the shell-dialect hooks that `new_window` / `new_parked_window` compose from (`_shell_wrap`, `_join_argv`, `_parked_trailer`, `_source_prefix`, `_window_launch` and the `_EXIT_CAPTURE`/`_ECHO`/`_PARK` fragments) — @@ -137,32 +139,42 @@ validate preflight (seam 4). ### Availability discriminators (same-platform backends) Selection consults `available()`, so it must be a **cheap, side-effect-free -probe** (PATH lookups — never a spawn), and `factory()` must be a plain +probe** — PATH lookups, plus at most one bounded version query when usability +genuinely depends on the installed version — and `factory()` must be a plain constructor: `detect_multiplexers()` instantiates every registered backend just to list it. When two backends claim the same platform, their `available()` probes should be **pairwise discriminating** — otherwise both report usable and only the platform default / registration order separates them in listings and -selection. The contract for the two native-Windows tmux-family backends in -flight (both drive a binary literally named `tmux`, and `tmux -V` cannot tell -them apart): +selection. The bundled psmux backend discriminates by construction: it drives +psmux's distinctly-named binary (`_BINARY = "psmux"`), so it never claims some +other tmux-family install that owns the `tmux` name. Its probe also +version-gates — psmux releases up to 3.3.6 can force-kill a recycled PID during +teardown, so an old or unidentifiable version reads as unavailable (`psmux -V` +keeps the `tmux X.Y.Z` output format deliberately): ```python -# psmux ships a distinctly-named psmux.exe alongside its tmux shim: class PsmuxMultiplexer(BaseTmuxBackend): + _BINARY = "psmux" + def available(self) -> bool: - return all(shutil.which(b) for b in ("psmux", "tmux", "pwsh")) + if not all(shutil.which(exe) for exe in ("psmux", "pwsh")): + return False + reported = re.match(r"tmux (\d+)\.(\d+)(?:\.(\d+))?", self.version() or "") + return bool(reported) and tuple(int(part or 0) for part in reported.groups()) > (3, 3, 6) -# tmux-windows has no marker binary of its own — it is "tmux without psmux": +# a sibling that owns the `tmux` name (e.g. a tmux-windows port) discriminates +# against psmux explicitly: class WindowsTmuxMultiplexer(BaseTmuxBackend): def available(self) -> bool: return shutil.which("tmux") is not None and shutil.which("psmux") is None ``` -Do **not** inherit `BaseTmuxBackend.available()` (`which("tmux")` alone) for a -same-platform sibling: selection would still break the tie via the platform -default, but `bmad-loop mux` and the validate preflight would list both as -available when only one actually drives the installed binary. A host with an -ambiguous install resolves it explicitly: `bmad-loop mux set `. +Do **not** inherit `BaseTmuxBackend.available()` (a bare `which` on `_BINARY`) +for a same-platform sibling that shares a binary name: selection would still +break the tie via the platform default, but `bmad-loop mux` and the validate +preflight would list both as available when only one actually drives the +installed binary. A host with an ambiguous install resolves it explicitly: +`bmad-loop mux set `. A backend from a **different binary family** sidesteps this problem entirely. The external herdr adapter probes `shutil.which("herdr")` — a distinct binary diff --git a/src/bmad_loop/adapters/multiplexer.py b/src/bmad_loop/adapters/multiplexer.py index 8549563e..427f8368 100644 --- a/src/bmad_loop/adapters/multiplexer.py +++ b/src/bmad_loop/adapters/multiplexer.py @@ -3,11 +3,11 @@ The coding-CLI adapter (:class:`~.base.CodingCLIAdapter`) abstracts *which CLI* to drive and how its prompts/hooks work. This module abstracts the orthogonal **transport** axis: how sessions, windows, and panes are created, observed, and -torn down. The one bundled backend is tmux -(:class:`~.tmux_backend.TmuxMultiplexer`); every other backend lives out-of-tree -(the reference is the herdr adapter, https://github.com/pbean/bmad-loop-adapter-herdr; -an eventual native-Windows "psmux" is the same shape) and slots in without the -rest of the codebase shelling out to ``tmux`` directly. +torn down. The bundled backends are tmux +(:class:`~.tmux_backend.TmuxMultiplexer`) and the native-Windows psmux +(:class:`~.psmux_backend.PsmuxMultiplexer`); every other backend lives out-of-tree +(the reference is the herdr adapter, https://github.com/pbean/bmad-loop-adapter-herdr) +and slots in without the rest of the codebase shelling out to ``tmux`` directly. ``TerminalMultiplexer`` is the contract a backend author implements. Operation names mirror today's call sites verbatim so the migration is mechanical. Backends @@ -271,9 +271,9 @@ def window_pane_pids(self, target: str) -> list[int]: _CONFIGURED: tuple[str, Path | None] | None = None # Per-platform default backend name, consulted only when that backend is both -# registered AND available on this host. Naming a backend that never registers -# is deliberate and harmless: psmux is an out-of-tree backend today, so on a -# win32 host without it the default simply doesn't apply. +# registered AND available on this host. psmux is a bundled builtin (registered +# below), so on a win32 host it applies whenever psmux reports available; if it +# isn't, selection falls through to the first platform match / fallback. _PLATFORM_DEFAULTS: dict[str, str] = {"win32": "psmux"} _DEFAULT_BACKEND = "tmux" # every platform not listed above @@ -292,9 +292,10 @@ def register_multiplexer( def _load_builtin_backends() -> None: - """Register the bundled backends — today, tmux alone (every other backend is - out-of-tree and arrives via :func:`_load_external_backends` or a manual - import). Idempotent and lazy (called from :func:`get_multiplexer`, not at + """Register the bundled backends — tmux (POSIX) and psmux (native Windows); + every other backend is out-of-tree and arrives via + :func:`_load_external_backends` or a manual import. Idempotent and lazy + (called from :func:`get_multiplexer`, not at module import) to stay cycle-safe. Registers inline rather than via tmux_backend's import side effect so the registry can be cleared and re-loaded deterministically (a re-import is a no-op once cached) — @@ -302,12 +303,16 @@ def _load_builtin_backends() -> None: global _BUILTINS_LOADED if _BUILTINS_LOADED: return + from .psmux_backend import PsmuxMultiplexer from .tmux_backend import TmuxMultiplexer # tmux is the default everywhere except native Windows (no tmux binary there); # get_multiplexer still falls back to tmux when no backend matches. Builtins # register before externals, so tmux keeps first-wins on any name collision. register_multiplexer("tmux", lambda platform: platform != "win32", TmuxMultiplexer) + # psmux speaks the tmux CLI through its own distinctly-named binary, so + # native Windows gets the tmux-family backend with a PowerShell dialect. + register_multiplexer("psmux", lambda platform: platform == "win32", PsmuxMultiplexer) _BUILTINS_LOADED = True # set only after a successful import so a transient failure retries @@ -390,6 +395,15 @@ def _usable(backend: TerminalMultiplexer) -> bool: return False +def backend_forced() -> bool: + """True when selection is pinned by the env var or the policy choice. + + A forced name bypasses ``available()`` throughout (an explicit choice is + trusted; the backend fails loudly if it can't run), so launch preflights + that refuse an unusable backend must stand down for it too.""" + return bool(os.environ.get("BMAD_LOOP_MUX_BACKEND")) or _CONFIGURED is not None + + def _select() -> tuple[TerminalMultiplexer, str, str]: """Resolve the backend by precedence; returns ``(instance, name, reason)``. diff --git a/src/bmad_loop/adapters/psmux_backend.py b/src/bmad_loop/adapters/psmux_backend.py new file mode 100644 index 00000000..6d312da8 --- /dev/null +++ b/src/bmad_loop/adapters/psmux_backend.py @@ -0,0 +1,199 @@ +"""Native-Windows psmux backend for the terminal-multiplexer seam. + +psmux (a Rust/ConPTY tmux re-implementation) speaks the tmux CLI through its +own distinctly-named ``psmux`` binary, so this leaf points the base's spawn +seam at that name, keeps every argv construction in :mod:`.tmux_base`, and +swaps only the shell dialect (PowerShell instead of POSIX sh) via the base's +hooks, plus the handful of behaviors where psmux diverges from tmux: +window-level ``-e`` is accepted but silently dropped, an attaching +``new-session`` is refused by a nesting guard when run from inside a psmux +pane, ``kill-session`` ignores the ``=name`` exact-match form, and a quoted +command string does not survive psmux's outer re-parse (so shell source +travels as ``pwsh -EncodedCommand``). ``available()`` additionally gates on +the reported version: psmux releases up to 3.3.6 kill recycled PIDs during +pane/session teardown without a process-identity check, which can take down +an unrelated long-lived process mid-run. See :mod:`.multiplexer` for the +contract. +""" + +from __future__ import annotations + +import base64 +import os +import re +import shlex +import shutil +import subprocess +from pathlib import Path + +from .tmux_base import PARKED_RETURN_DETACH, BaseTmuxBackend, TmuxError + +_ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def _pwsh_quote(value: str) -> str: + # A single-quoted PowerShell literal: no interpolation, and the only escape + # is doubling the quote itself. + return "'" + value.replace("'", "''") + "'" + + +class PsmuxMultiplexer(BaseTmuxBackend): + """psmux backend — tmux-family argv from the base, PowerShell dialect and + the documented psmux divergences here. + + Registered by :func:`~.multiplexer._load_builtin_backends` for ``win32``, + mirroring :class:`~.tmux_backend.TmuxMultiplexer`. + """ + + # psmux ships psmux/pmux/tmux binaries built from the same source; spawning + # the distinct psmux name never collides with another tmux-family install + # (e.g. a tmux-windows port owning ``tmux`` on the same PATH). + _BINARY = "psmux" + # psmux emits UTF-8; decoding with the console codepage (cp1252) garbles + # format-string output, and a stray byte must degrade visibly, not raise. + _ENCODING = "utf-8" + _ERRORS = "backslashreplace" + + # ------------------------------------------- shell dialect (PowerShell) + + # A command pwsh could not even start (not recognized) still runs the rest of + # the source but leaves $LASTEXITCODE unset — coalesce without requiring + # PowerShell 7 syntax, since availability accepts any `pwsh`. + _EXIT_CAPTURE = "$ec = if ($null -eq $LASTEXITCODE) { 1 } else { $LASTEXITCODE }" + _ECHO = "Write-Host" + _PARK = "Read-Host" + + def _join_argv(self, argv: list[str]) -> str: + # The call operator runs a quoted executable with quoted args verbatim. + # A bare `& ` is a pwsh parse error, so refuse an empty argv here rather + # than shipping a window that dies on launch. + if not argv: + raise TmuxError("empty command") + return "& " + " ".join(_pwsh_quote(arg) for arg in argv) + + def _source_prefix(self) -> str: + # psmux windows inherit the claude environment of whichever process + # cold-started the psmux server (teammate mode, session ids, SSE ports). + # Clear it so a CLI launched here starts fresh instead of impersonating + # that session. + return ( + "Get-ChildItem Env: | Where-Object { $_.Name -like 'CLAUDE_CODE_*' " + "-or $_.Name -like 'CLAUDECODE*' -or $_.Name -eq 'PSMUX_CLAUDE_TEAMMATE_MODE' } " + "| ForEach-Object { Remove-Item ('Env:' + $_.Name) }; " + ) + + def _shell_wrap(self, source: str) -> list[str]: + # psmux joins the trailing argv and re-parses it through an outer shell, + # which strips embedded quoting; -EncodedCommand (base64 of UTF-16LE) is + # the lossless transport for arbitrary shell source. + encoded = base64.b64encode(source.encode("utf-16-le")).decode("ascii") + return ["pwsh", "-NoProfile", "-EncodedCommand", encoded] + + def _parked_trailer(self, return_opt: str) -> str: + # The base's trailer re-expressed in pwsh — the tmux verbs are protocol- + # identical across the family. Errors go to $null: a client or pane that + # is already gone means the window just parks as-is. + mux = self._BINARY + return ( + f"$ret = {mux} show-options -wqv {_pwsh_quote(return_opt)} 2>$null; " + f"if ($ret -eq '{PARKED_RETURN_DETACH}') {{ {mux} detach-client 2>$null }} " + f"elseif ($ret) {{ {mux} switch-client -t $ret 2>$null; " + f"if ($LASTEXITCODE -ne 0) {{ {mux} switch-client -l 2>$null }} }}" + ) + + def _window_launch(self, env: dict[str, str], command: str) -> list[str]: + # psmux accepts `new-window -e` but silently drops it, so the env rides + # an in-source prelude instead. `command` arrives POSIX-quoted (callers + # build it with shlex.join), so split it here and re-quote for pwsh. + for key in env: + if not _ENV_NAME.fullmatch(key): + raise TmuxError(f"invalid environment variable name: {key!r}") + try: + argv = shlex.split(command) + except ValueError as exc: + raise TmuxError(f"unparseable command: {exc}") from exc + prelude = "".join(f"$env:{key} = {_pwsh_quote(value)}; " for key, value in env.items()) + source = self._source_prefix() + prelude + self._join_argv(argv) + return self._shell_wrap(source) + + # ------------------------------------------------- psmux divergences + + def new_session( + self, name: str, cwd: Path, cols: int | None = None, lines: int | None = None + ) -> None: + # psmux's nesting guard refuses new-session from inside a psmux pane + # (current builds only for an attaching one, older builds no-op'd `-d` + # too — exit 0, nothing created); the documented bypass is one env var + # on the create call, kept as a cheap belt. The create env copies the + # parent env rather than building from scratch — Windows children need + # SystemRoot etc. — but scrubs the claude session vars (the same names + # _source_prefix clears per window): this call may cold-start the psmux + # server, whose env every window then inherits. + env = { + k: v + for k, v in os.environ.items() + if not ( + k.upper().startswith(("CLAUDE_CODE_", "CLAUDECODE")) + or k.upper() == "PSMUX_CLAUDE_TEAMMATE_MODE" + ) + } + env["PSMUX_ALLOW_NESTING"] = "1" + geometry = ["-x", str(cols), "-y", str(lines)] if cols and lines else [] + try: + proc = self._run( + ["new-session", "-d", "-s", name, "-c", str(cwd), *geometry], + check=False, + env=env, + ) + except (subprocess.TimeoutExpired, OSError) as exc: + raise TmuxError(f"{self._BINARY} new-session failed: {exc}") from exc + if proc.returncode != 0: + raise TmuxError(f"{self._BINARY} new-session failed: {proc.stderr.strip()}") + + def kill_session(self, name: str) -> None: + # psmux ignores the `=name` exact-match form for kill-session; plain-name + # targeting works. Same best-effort tolerance as the base. + if not shutil.which(self._BINARY): + return + try: + self._run(["kill-session", "-t", name], check=False) + except (subprocess.SubprocessError, OSError): + pass + + def pipe_pane(self, window_id: str, log_file: Path) -> None: + # The base's POSIX `cat >>` sink assumes a POSIX host shell; psmux runs + # the pipe command on the host shell, so ship a pwsh append sink instead + # (base64 has no quoting to lose, so a plain join survives psmux's + # re-parse). Best-effort, as the base: a window that died on launch is + # not a setup failure. + sink = f"$input | Add-Content -LiteralPath {_pwsh_quote(str(log_file))}" + try: + self._tmux("pipe-pane", "-t", window_id, "-o", " ".join(self._shell_wrap(sink))) + except TmuxError: + pass + + # Releases up to this version can force-kill a recycled PID during pane + # teardown and let orphaned servers accumulate — engine-fatal, so they + # must never be selected. + _LAST_UNSUPPORTED = (3, 3, 6) + _version_ok: bool | None = None + + def available(self) -> bool: + # Every window launch needs pwsh alongside the psmux binary itself. + # The version gate fails closed: psmux prints `tmux X.Y.Z` (the tmux + # prefix is kept deliberately for tmux-version parsers), and an old or + # unidentifiable install reads as unusable. A forced backend name in + # settings still bypasses this probe. The gate verdict is cached on the + # instance so repeated availability polls don't each spawn a version + # query; an install swapped mid-process is picked up on restart. + if not all(shutil.which(exe) for exe in (self._BINARY, "pwsh")): + return False + if self._version_ok is None: + # A missing patch segment reads as 0 — psmux hardwires three-part + # Cargo semver today, but real tmux versions are two-part and the + # compat prefix invites upstream to mirror that format someday. + reported = re.match(r"tmux (\d+)\.(\d+)(?:\.(\d+))?", self.version() or "") + self._version_ok = bool(reported) and ( + tuple(int(part or 0) for part in reported.groups()) > self._LAST_UNSUPPORTED + ) + return self._version_ok diff --git a/src/bmad_loop/adapters/tmux_base.py b/src/bmad_loop/adapters/tmux_base.py index a79656c6..7c4943bd 100644 --- a/src/bmad_loop/adapters/tmux_base.py +++ b/src/bmad_loop/adapters/tmux_base.py @@ -3,18 +3,18 @@ This module is the **quarantine** for tmux/POSIX-shell knowledge: every tmux invocation and POSIX-shell trailer lives here (and in its POSIX leaf :mod:`.tmux_backend`). The point of the split is that a tmux-*family* backend — -an eventual native-Windows "psmux" — can subclass :class:`BaseTmuxBackend` and -override only the single spawn primitive :meth:`BaseTmuxBackend._run` (to tweak -the binary or timeout — output decoding is the :attr:`BaseTmuxBackend._ENCODING` -class attribute and a scrubbed per-call ``env`` is a ``_run`` parameter, neither -an override) plus the shell-dialect hooks (``_shell_wrap``, ``_join_argv``, +the native-Windows :mod:`.psmux_backend` leaf — can subclass :class:`BaseTmuxBackend` and +swap only class attributes (:attr:`BaseTmuxBackend._BINARY` for the spawned +binary, :attr:`BaseTmuxBackend._ENCODING` for output decoding — a scrubbed +per-call ``env`` is a ``_run`` parameter, and an :meth:`BaseTmuxBackend._run` +override is left for timeout tweaks) plus the shell-dialect hooks (``_shell_wrap``, ``_join_argv``, ``_parked_trailer``, ``_source_prefix``, ``_window_launch`` and the ``_EXIT_CAPTURE``/``_ECHO``/``_PARK`` fragments), **without editing** :mod:`.tmux_backend`. For :meth:`~BaseTmuxBackend.new_window` / :meth:`~BaseTmuxBackend.new_parked_window` the hooks replace method-body -overrides entirely; :meth:`~BaseTmuxBackend.pipe_pane` still hands tmux a POSIX -``cat >>`` redirection, so it remains the one contract method a non-POSIX leaf -overrides directly. +overrides entirely; :meth:`~BaseTmuxBackend.pipe_pane` still hands the +multiplexer a POSIX ``cat >>`` redirection, so a non-POSIX leaf overrides it +directly, alongside whatever divergences its multiplexer forces on it. Every method that talks to tmux funnels through :meth:`BaseTmuxBackend._run`, the one place a subprocess is spawned. See :mod:`.multiplexer` for the contract. @@ -48,9 +48,17 @@ class BaseTmuxBackend(TerminalMultiplexer): :meth:`TerminalMultiplexer.target`) coincides with tmux's exact-match target syntax, so targets pass straight through to tmux — never parsed here.""" + #: The binary every spawn, PATH probe, and in-source client verb targets. + #: A tmux-family leaf whose binary is not literally named ``tmux`` overrides + #: this one name instead of any method body. + _BINARY = "tmux" #: Output decoding for captured tmux text. ``None`` (POSIX) = locale default, #: byte-identical to a bare ``text=True``; a Windows leaf sets ``"utf-8"``. _ENCODING: str | None = None + #: Decode error handling to pair with :attr:`_ENCODING`. ``None`` (POSIX) = + #: the default strict handler; a Windows leaf sets ``"backslashreplace"`` so + #: a stray non-UTF-8 byte degrades visibly instead of raising mid-capture. + _ERRORS: str | None = None def _run( self, @@ -59,7 +67,7 @@ def _run( check: bool = True, env: dict[str, str] | None = None, ) -> subprocess.CompletedProcess[str]: - """The ONE place tmux is spawned. ``argv`` are the args after ``tmux``. + """The ONE place tmux is spawned. ``argv`` are the args after the binary. With ``check=True`` a non-zero exit raises :class:`TmuxError` (the strict form behind ``_tmux``); with ``check=False`` the completed process is @@ -74,15 +82,16 @@ def _run( vars — not from scratch (on Windows the child needs ``SystemRoot`` etc.). """ proc = subprocess.run( - ["tmux", *argv], + [self._BINARY, *argv], capture_output=True, text=True, encoding=self._ENCODING, + errors=self._ERRORS, env=env, timeout=TMUX_TIMEOUT_S, ) if check and proc.returncode != 0: - raise TmuxError(f"tmux {' '.join(argv[:2])} failed: {proc.stderr.strip()}") + raise TmuxError(f"{self._BINARY} {' '.join(argv[:2])} failed: {proc.stderr.strip()}") return proc def _tmux(self, *args: str) -> str: @@ -94,7 +103,7 @@ def _tmux(self, *args: str) -> str: try: return self._run(list(args), check=True).stdout.strip() except (subprocess.TimeoutExpired, OSError) as exc: - raise TmuxError(f"tmux {args[0] if args else ''} failed: {exc}") from exc + raise TmuxError(f"{self._BINARY} {args[0] if args else ''} failed: {exc}") from exc # ----------------------------------------------------------- sessions @@ -106,7 +115,7 @@ def has_session(self, name: str) -> bool: try: probe = self._run(["has-session", "-t", f"={name}"], check=False) except (subprocess.TimeoutExpired, OSError) as exc: - raise TmuxError(f"tmux has-session failed: {exc}") from exc + raise TmuxError(f"{self._BINARY} has-session failed: {exc}") from exc return probe.returncode == 0 def new_session( @@ -124,9 +133,9 @@ def set_session_option(self, name: str, option: str, value: str) -> None: self._tmux("set-option", "-t", name, option, value) def kill_session(self, name: str) -> None: - # Tolerant of tmux being absent / the session already gone: a best-effort - # teardown backstop, never a hard failure. - if not shutil.which("tmux"): + # Tolerant of the binary being absent / the session already gone: a + # best-effort teardown backstop, never a hard failure. + if not shutil.which(self._BINARY): return try: self._run(["kill-session", "-t", f"={name}"], check=False) @@ -134,10 +143,10 @@ def kill_session(self, name: str) -> None: pass def list_sessions(self) -> list[str]: - # [] when tmux is missing, no server is running, or the query fails — the - # absence of sessions and the absence of tmux are indistinguishable here - # and callers treat both as "nothing live". - if not shutil.which("tmux"): + # [] when the binary is missing, no server is running, or the query fails + # — the absence of sessions and the absence of the multiplexer are + # indistinguishable here and callers treat both as "nothing live". + if not shutil.which(self._BINARY): return [] try: proc = self._run(["list-sessions", "-F", "#{session_name}"], check=False) @@ -149,8 +158,8 @@ def list_sessions(self) -> list[str]: def session_options(self, option: str) -> dict[str, str]: # Map session name -> value of ``option`` ("" when unset). Same missing - # tmux / no-server tolerance as list_sessions(). - if not shutil.which("tmux"): + # binary / no-server tolerance as list_sessions(). + if not shutil.which(self._BINARY): return {} try: proc = self._run( @@ -210,11 +219,12 @@ def _parked_trailer(self, return_opt: str) -> str: # - unset/empty: nobody attached interactively -> park as-is. # The tmux verbs are protocol-identical across the family; only the # surrounding control-flow syntax is dialect-specific. + mux = self._BINARY return ( - f"ret=$(tmux show-options -wqv {shlex.quote(return_opt)} 2>/dev/null); " - f'if [ "$ret" = "{PARKED_RETURN_DETACH}" ]; then tmux detach-client 2>/dev/null; ' + f"ret=$({mux} show-options -wqv {shlex.quote(return_opt)} 2>/dev/null); " + f'if [ "$ret" = "{PARKED_RETURN_DETACH}" ]; then {mux} detach-client 2>/dev/null; ' 'elif [ -n "$ret" ]; then ' - 'tmux switch-client -t "$ret" 2>/dev/null || tmux switch-client -l 2>/dev/null; ' + f'{mux} switch-client -t "$ret" 2>/dev/null || {mux} switch-client -l 2>/dev/null; ' "fi" ) @@ -290,7 +300,7 @@ def list_window_ids(self, session: str) -> list[str]: ["list-windows", "-t", f"={session}", "-F", "#{window_id}"], check=False ) except (subprocess.TimeoutExpired, OSError) as exc: - raise TmuxError(f"tmux list-windows failed: {exc}") from exc + raise TmuxError(f"{self._BINARY} list-windows failed: {exc}") from exc if probe.returncode != 0: return [] return probe.stdout.split() @@ -381,8 +391,8 @@ def attach_target_argv(self, target: str) -> list[str]: # Inside tmux, nesting an attach is refused, so switch this client # instead (a `switch-client -l` brings it back). if os.environ.get("TMUX"): - return ["tmux", "switch-client", "-t", target] - return ["tmux", "attach", "-t", target] + return [self._BINARY, "switch-client", "-t", target] + return [self._BINARY, "attach", "-t", target] def current_pane_id(self) -> str | None: return self._display_message("#{pane_id}") @@ -429,10 +439,10 @@ def switch_client(self, target: str, last_fallback: bool = False) -> bool: return False def available(self) -> bool: - return shutil.which("tmux") is not None + return shutil.which(self._BINARY) is not None def version(self) -> str | None: - if not shutil.which("tmux"): + if not shutil.which(self._BINARY): return None try: return self._tmux("-V") diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 51f3cc5f..9706fa1e 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -100,7 +100,7 @@ def _reconcile_stale(project: Path, paths: bmadconfig.ProjectPaths, pol) -> None def _make_adapters(project: Path, run_dir: Path, policy) -> dict[str, CodingCLIAdapter]: from .adapters.generic import GenericAdapter, GenericDevAdapter - from .adapters.multiplexer import get_multiplexer + from .adapters.multiplexer import _usable, backend_forced, get_multiplexer from .adapters.profile import ProfileError, get_profile # The dev skill (bmad-dev-auto) writes no result.json: its adapter @@ -108,9 +108,7 @@ def _make_adapters(project: Path, run_dir: Path, policy) -> dict[str, CodingCLIA # find that spec — rebasing onto the active worktree's implementation- # artifacts dir under isolation, not just the main checkout's. paths = bmadconfig.load_paths(project) - # One shared terminal-multiplexer backend for every role's adapter. - mux = get_multiplexer() - + mux = None adapters: dict[str, CodingCLIAdapter] = {} by_cfg: dict = {} for role in ROLES: @@ -153,6 +151,16 @@ def _make_adapters(project: Path, run_dir: Path, policy) -> dict[str, CodingCLIA except OpencodeServerError as e: raise SystemExit(f"error: {e}") from e else: + # Resolve and probe the shared multiplexer only when a profile + # actually uses it; hookless HTTP/SSE runs need no transport. + if mux is None: + mux = get_multiplexer() + if not backend_forced() and not _usable(mux): + raise SystemExit( + f"error: multiplexer backend {type(mux).__name__} is not usable on " + "this host (its transport binary is missing or an unsupported " + "version); see `bmad-loop diagnose`" + ) common = dict( run_dir=run_dir, policy=policy, @@ -201,7 +209,8 @@ def _platform_preflight() -> tuple[list[str], list[str]]: notes.append(f"multiplexer {label} available" + (f" ({version})" if version else "")) else: problems.append( - f"multiplexer {label} unavailable — its transport binary is not on PATH; " + f"multiplexer {label} unavailable — its transport binary is missing or " + f"an unsupported version; " f"see `bmad-loop diagnose`" ) except Exception as e: # noqa: BLE001 — selection or readiness must not abort validate @@ -477,8 +486,8 @@ def _mux_set(project: Path, args: argparse.Namespace) -> int: # say so: the run will fail loudly if the binary never appears. print( f"warning: backend {args.name!r} is not available on this host (its " - "transport binary is not on PATH); persisted anyway — `bmad-loop validate` " - "will report it", + "transport binary is missing or an unsupported version); persisted anyway " + "— `bmad-loop validate` will report it", file=sys.stderr, ) if os.environ.get("BMAD_LOOP_MUX_BACKEND"): diff --git a/src/bmad_loop/tui/launch.py b/src/bmad_loop/tui/launch.py index b075c415..72256df0 100644 --- a/src/bmad_loop/tui/launch.py +++ b/src/bmad_loop/tui/launch.py @@ -18,7 +18,7 @@ from pathlib import Path from .. import runs -from ..adapters.multiplexer import MultiplexerError, get_multiplexer +from ..adapters.multiplexer import MultiplexerError, backend_forced, get_multiplexer from ..journal import Journal CTL_SESSION = "bmad-loop-ctl" @@ -275,8 +275,8 @@ def start_detached(project: Path, argv_tail: list[str], run_id: str, kind: str) unambiguously (window names collide when several kinds share a run_id). """ mux = get_multiplexer() - if not mux.available(): - raise LaunchError("multiplexer backend unavailable (binary not on PATH)") + if not backend_forced() and not mux.available(): + raise LaunchError("multiplexer backend unavailable (binary missing or unsupported version)") _ensure_ctl_session(project) try: win_id = ( diff --git a/tests/test_backend_registry.py b/tests/test_backend_registry.py index eb79e44e..3b0c5534 100644 --- a/tests/test_backend_registry.py +++ b/tests/test_backend_registry.py @@ -19,6 +19,7 @@ from bmad_loop.adapters import multiplexer as m from bmad_loop.adapters.multiplexer import MultiplexerError +from bmad_loop.adapters.psmux_backend import PsmuxMultiplexer from bmad_loop.adapters.tmux_backend import TmuxMultiplexer @@ -79,11 +80,15 @@ def fresh_registry(monkeypatch): m.get_multiplexer.cache_clear() -def test_default_is_tmux(fresh_registry, monkeypatch): - """No override, POSIX host → tmux, selected via the loop's platform match (the - builtin registers ``matches=p != 'win32'``). Pin a POSIX platform anyway: an - installed external backend may match win32 (the herdr adapter does), so only - the POSIX default is a stable claim — this test is specifically about it.""" +def test_default_matches_platform(fresh_registry, monkeypatch): + """No override → the loop's platform match picks the right builtin (tmux + registers ``p != 'win32'``, psmux ``p == 'win32'``), not just the bottom + fallback. Both legs are pinned regardless of the host OS; the lru_cache is + cleared between them so the second selection actually re-runs.""" + monkeypatch.setattr(sys, "platform", "win32") + assert isinstance(fresh_registry.get_multiplexer(), PsmuxMultiplexer) + + fresh_registry.get_multiplexer.cache_clear() monkeypatch.setattr(sys, "platform", "linux") assert isinstance(fresh_registry.get_multiplexer(), TmuxMultiplexer) @@ -358,22 +363,21 @@ def _which_only(*available: str): absent. Patches the shared stdlib module, so every backend's available() probe sees it.""" names = set(available) - return lambda name, *a, **k: (f"/usr/bin/{name}" if name in names else None) - - -def test_win32_bottoms_out_at_tmux_with_no_externals(fresh_registry, monkeypatch): - """On native Windows nothing bundled matches (tmux's `matches` is - `p != 'win32'`, psmux is out-of-tree), so with no external backend installed - `_select` bottoms out at the documented historical fallback: tmux, reported - unavailable by validate. Pins the post-extraction win32 semantics — while - herdr was bundled, its `matches=True` made it the win32 first-match/fallback - instead (that behavior now ships with the adapter, and the - `force_tmux_backend` fixture keeps guarding tmux-argv tests against any - installed external that matches win32).""" + return lambda name, *a, **k: f"/usr/bin/{name}" if name in names else None + + +def test_win32_bottoms_out_at_psmux_with_no_externals(fresh_registry, monkeypatch): + """On native Windows the bundled psmux backend matches (`p == 'win32'`), so + even with nothing available `_select` bottoms out at psmux — not the tmux + bottom fallback. psmux is the win32 platform default, but that step needs + availability; an unavailable psmux is instead reached through the historical + fallback (first platform match regardless of availability) and reported + unavailable by validate. tmux never matches win32, so it stays out of the + running here.""" monkeypatch.setattr(sys, "platform", "win32") monkeypatch.setattr(shutil, "which", _which_only()) # nothing available backend, name, reason = fresh_registry._select() - assert isinstance(backend, TmuxMultiplexer) - assert (name, reason) == ("tmux", "fallback") - # sanity: _PLATFORM_DEFAULTS still names the out-of-tree psmux for win32 + assert isinstance(backend, PsmuxMultiplexer) + assert (name, reason) == ("psmux", "fallback") + # psmux is both the win32 platform default and the sole bundled win32 match assert fresh_registry._PLATFORM_DEFAULTS.get("win32") == "psmux" diff --git a/tests/test_cli.py b/tests/test_cli.py index 5d73a666..ef579853 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,6 +9,7 @@ from bmad_loop import cli from bmad_loop import policy as policy_mod +from bmad_loop.adapters import multiplexer as mux_mod STORIES_SPEC_FOLDER = "_bmad-output/epic-1" @@ -612,12 +613,13 @@ def test_sweep_dry_run_no_ledger(project, capsys): assert "no deferred-work ledger" in capsys.readouterr().out -def test_make_adapters_review_synthesizes_from_spec(project): +def test_make_adapters_review_synthesizes_from_spec(project, monkeypatch): """Both dev AND review are bmad-dev-auto runs that write no result.json, so both roles must get the spec-synthesizing GenericDevAdapter; triage (a real result.json skill) stays a plain GenericAdapter.""" from bmad_loop.adapters.generic import GenericAdapter, GenericDevAdapter + monkeypatch.setattr(mux_mod, "_usable", lambda mux: True) install_bmad_config(project) adapters = cli._make_adapters( project.project, project.project / ".bmad-loop" / "runs" / "r", policy_mod.load(None) @@ -628,13 +630,19 @@ def test_make_adapters_review_synthesizes_from_spec(project): assert not isinstance(adapters["triage"], GenericDevAdapter) -def test_make_adapters_hookless_synthesizing_roles_get_dev_adapter(project): +def test_make_adapters_hookless_synthesizing_roles_get_dev_adapter(project, monkeypatch): """Hookless dev/review (bmad-dev-auto roles) dispatch to OpencodeDevAdapter — the _DevSynthesisMixin composed over the HTTP transport — sharing one instance via the (cfg, synthesizes) key, while triage on the same config gets a separate plain OpencodeHttpAdapter (it reads a real result.json).""" + from bmad_loop.adapters import opencode_http from bmad_loop.adapters.opencode_http import OpencodeDevAdapter, OpencodeHttpAdapter + def no_mux(): + raise AssertionError("hookless adapters must not resolve a multiplexer") + + monkeypatch.setattr(opencode_http, "_require_httpx", lambda: object()) + monkeypatch.setattr(mux_mod, "get_multiplexer", no_mux) install_bmad_config(project) _write_policy(project.project, '[adapter]\nname = "opencode"\n') pol = policy_mod.load(project.project / ".bmad-loop" / "policy.toml") @@ -649,14 +657,17 @@ def test_make_adapters_hookless_synthesizing_roles_get_dev_adapter(project): assert adapters["triage"] is not adapters["dev"] -def test_make_adapters_hookless_triage_dispatches_http_adapter(project): +def test_make_adapters_hookless_triage_dispatches_http_adapter(project, monkeypatch): """A hookless profile on a non-synthesizing role (triage) dispatches to the HTTP adapter — resolved via the `opencode` alias — while dev/review keep the shared spec-synthesizing tmux adapter. The HTTP adapter exposes `profile` (worktree provisioning keys off it) and never constructs a multiplexer.""" + from bmad_loop.adapters import opencode_http from bmad_loop.adapters.generic import GenericDevAdapter from bmad_loop.adapters.opencode_http import OpencodeHttpAdapter + monkeypatch.setattr(opencode_http, "_require_httpx", lambda: object()) + monkeypatch.setattr(mux_mod, "_usable", lambda mux: True) install_bmad_config(project) _write_policy(project.project, '[adapter.triage]\nname = "opencode"\n') pol = policy_mod.load(project.project / ".bmad-loop" / "policy.toml") @@ -669,6 +680,37 @@ def test_make_adapters_hookless_triage_dispatches_http_adapter(project): assert adapters["dev"] is adapters["review"] # (cfg, synthesizes) sharing intact +def test_make_adapters_refuses_unusable_mux(project, monkeypatch): + """Selection's fallback rung returns a platform-matched backend even when it + is unusable; the run bootstrap must refuse it so a run never drives a + missing or version-gated multiplexer.""" + install_bmad_config(project) + monkeypatch.setattr(mux_mod, "_usable", lambda mux: False) + with pytest.raises(SystemExit, match="not usable"): + cli._make_adapters( + project.project, project.project / ".bmad-loop" / "runs" / "r", policy_mod.load(None) + ) + + +def test_make_adapters_trusts_forced_backend(project, monkeypatch): + """A forced name bypasses available() in selection; the run-bootstrap + preflight must stand down for it the same way (the backend fails loudly + at first use instead).""" + from bmad_loop.adapters.multiplexer import get_multiplexer + + install_bmad_config(project) + monkeypatch.setattr(mux_mod, "_usable", lambda mux: False) + monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "tmux") + get_multiplexer.cache_clear() + try: + adapters = cli._make_adapters( + project.project, project.project / ".bmad-loop" / "runs" / "r", policy_mod.load(None) + ) + finally: + get_multiplexer.cache_clear() # don't leak the forced pick to other tests + assert set(adapters) == set(cli.ROLES) + + class _StubEngine: def __init__(self, **kwargs): pass diff --git a/tests/test_multiplexer.py b/tests/test_multiplexer.py index 7160baa8..7286c5b4 100644 --- a/tests/test_multiplexer.py +++ b/tests/test_multiplexer.py @@ -345,6 +345,7 @@ def test_run_posix_default_passes_no_encoding_and_no_env(monkeypatch): # inherit the parent env (env=None). assert rec.kwargs["text"] is True assert rec.kwargs["encoding"] is None + assert rec.kwargs["errors"] is None assert rec.kwargs["env"] is None diff --git a/tests/test_psmux_backend.py b/tests/test_psmux_backend.py new file mode 100644 index 00000000..1d4aacc7 --- /dev/null +++ b/tests/test_psmux_backend.py @@ -0,0 +1,339 @@ +"""psmux backend unit tests. + +Deterministic: the single subprocess seam (``tmux_base.subprocess.run``) is +mocked, so these run on any OS. Shell source shipped as ``-EncodedCommand`` is +decoded back (base64 → UTF-16LE) to assert its composition. +""" + +import base64 +import os +import subprocess + +import pytest + +from bmad_loop.adapters import psmux_backend, tmux_base +from bmad_loop.adapters.multiplexer import MultiplexerError, get_multiplexer +from bmad_loop.adapters.psmux_backend import PsmuxMultiplexer + + +class _RecordRun: + """Stand-in for subprocess.run that records every spawn's argv and kwargs.""" + + def __init__(self, returncode: int = 0, stderr: str = ""): + self.calls: list[tuple[list, dict]] = [] + self.returncode = returncode + self.stderr = stderr + + @property + def argv(self): + return self.calls[-1][0] + + @property + def kwargs(self): + return self.calls[-1][1] + + def __call__(self, argv, **kwargs): + self.calls.append((argv, kwargs)) + return subprocess.CompletedProcess(argv, self.returncode, stdout="", stderr=self.stderr) + + +@pytest.fixture +def rec(monkeypatch): + recorder = _RecordRun() + monkeypatch.setattr(tmux_base.subprocess, "run", recorder) + return recorder + + +def _decode(encoded: str) -> str: + return base64.b64decode(encoded).decode("utf-16-le") + + +def _pwsh_payload(argv: list) -> str: + """Assert the trailing args are a pwsh -EncodedCommand launch; return the + decoded shell source.""" + assert argv[-4:-1] == ["pwsh", "-NoProfile", "-EncodedCommand"] + return _decode(argv[-1]) + + +# ------------------------------------------------------------------ decoding + + +def test_run_decodes_utf8_with_backslashreplace(rec): + PsmuxMultiplexer()._run(["list-windows"]) + assert rec.kwargs["encoding"] == "utf-8" + assert rec.kwargs["errors"] == "backslashreplace" + + +# ---------------------------------------------------------------- new_window + + +def test_new_window_ships_env_and_command_as_encoded_pwsh(rec, tmp_path): + PsmuxMultiplexer().new_window( + "s", "n", tmp_path, {"A": "x y", "B": "it's"}, "claude -p 'hi there'" + ) + + # the tmux-family scaffolding is the base's, spawned via the psmux binary, + # with no -e flags (psmux drops them) + assert rec.argv[:12] == [ + "psmux", + "new-window", + "-t", + "=s:", + "-n", + "n", + "-c", + str(tmp_path), + "-P", + "-F", + "#{window_id}", + "pwsh", + ] + assert "-e" not in rec.argv + + source = _pwsh_payload(rec.argv) + # teammate-clear prelude, then env prelude, then the call-operator command + assert source.index("Remove-Item") < source.index("$env:A") + assert "'CLAUDE_CODE_*'" in source + assert "'CLAUDECODE*'" in source + assert "'PSMUX_CLAUDE_TEAMMATE_MODE'" in source + assert "$env:A = 'x y'; " in source + assert "$env:B = 'it''s'; " in source + assert source.endswith("& 'claude' '-p' 'hi there'") + + +def test_new_window_rejects_invalid_env_name(rec, tmp_path): + mux = PsmuxMultiplexer() + for bad in ("A-B", "1X", "A B", "", "SAFE\n"): + with pytest.raises(MultiplexerError): + mux.new_window("s", "n", tmp_path, {bad: "v"}, "cmd") + assert rec.calls == [] # rejected before any spawn + + +def test_new_window_rejects_malformed_command(rec, tmp_path): + mux = PsmuxMultiplexer() + # unbalanced quote (shlex can't split it) and an empty command (`& ` alone + # is a pwsh parse error) both fail as the seam type, before any spawn + for bad in ("claude -p 'x", "", " "): + with pytest.raises(MultiplexerError): + mux.new_window("s", "n", tmp_path, {}, bad) + assert rec.calls == [] + + +def test_new_parked_window_rejects_empty_argv(rec, tmp_path): + with pytest.raises(MultiplexerError): + PsmuxMultiplexer().new_parked_window("s", "n", tmp_path, [], "") + assert rec.calls == [] + + +def test_new_window_literalizes_shell_operators(rec, tmp_path): + # the seam's `command` is a POSIX-quoted argv join, not a shell line: pwsh + # re-quoting turns would-be operators into literal arguments + PsmuxMultiplexer().new_window("s", "n", tmp_path, {}, "a && b | c") + source = _pwsh_payload(rec.argv) + assert source.endswith("& 'a' '&&' 'b' '|' 'c'") + + +def test_new_window_env_values_stay_inert_literals(rec, tmp_path): + # Env values are attacker-shaped strings from the caller's perspective: + # pwsh must receive each one as a single-quoted literal with no room for + # interpolation, subexpression, or quote breakout. + hostile = { + "A": "it's", + "B": "line1\nline2", + "C": "$(Remove-Item x)", + "D": "`; Write-Host pwned", + "E": "", + "F": "'; Remove-Item -Recurse 'C:\\ #", + } + PsmuxMultiplexer().new_window("s", "n", tmp_path, hostile, "prog") + source = _pwsh_payload(rec.argv) + for key, value in hostile.items(): + assert f"$env:{key} = '{value.replace(chr(39), chr(39) * 2)}'; " in source + # with doubled quotes collapsed, every remaining quote must pair up — an + # odd count means some value broke out of its literal + assert source.replace("''", "").count("'") % 2 == 0 + + +# --------------------------------------------------------------- new_session + + +def test_new_session_bypasses_nesting_guard(rec, monkeypatch, tmp_path): + monkeypatch.setenv("CLAUDE_CODE_SSE_PORT", "1234") + monkeypatch.setenv("CLAUDECODE", "1") + monkeypatch.setenv("PSMUX_CLAUDE_TEAMMATE_MODE", "tmux") + monkeypatch.setenv("Claude_Code_Mixed", "mixed") + before = dict(os.environ) + PsmuxMultiplexer().new_session("s", tmp_path, cols=80, lines=24) + + assert rec.argv == [ + "psmux", + "new-session", + "-d", + "-s", + "s", + "-c", + str(tmp_path), + "-x", + "80", + "-y", + "24", + ] + assert rec.kwargs["env"]["PSMUX_ALLOW_NESTING"] == "1" + # the claude session vars are scrubbed from the create env (the psmux server + # this call may cold-start would otherwise hand them to every window) + assert "CLAUDE_CODE_SSE_PORT" not in rec.kwargs["env"] + assert "CLAUDECODE" not in rec.kwargs["env"] + assert "PSMUX_CLAUDE_TEAMMATE_MODE" not in rec.kwargs["env"] + assert "Claude_Code_Mixed" not in rec.kwargs["env"] + # the bypass var and the scrub are confined to the child spawn + assert dict(os.environ) == before + + +def test_new_session_failure_raises_multiplexer_error(monkeypatch, tmp_path): + monkeypatch.setattr(tmux_base.subprocess, "run", _RecordRun(returncode=1, stderr="boom")) + with pytest.raises(MultiplexerError): + PsmuxMultiplexer().new_session("s", tmp_path) + + def timeout(*_a, **_k): + raise subprocess.TimeoutExpired(["tmux"], 30) + + monkeypatch.setattr(tmux_base.subprocess, "run", timeout) + with pytest.raises(MultiplexerError): + PsmuxMultiplexer().new_session("s", tmp_path) + + +# --------------------------------------------------------------- kill_session + + +def test_kill_session_uses_plain_target(rec, monkeypatch): + monkeypatch.setattr(psmux_backend.shutil, "which", lambda _name: "C:\\bin\\psmux.exe") + PsmuxMultiplexer().kill_session("s") + assert rec.argv == ["psmux", "kill-session", "-t", "s"] # no `=` — psmux ignores it + + +# ------------------------------------------------------------- parked window + + +def test_new_parked_window_composes_pwsh_source(rec, tmp_path): + PsmuxMultiplexer().new_parked_window("s", "n", tmp_path, ["claude", "--resume"], "%3") + + source = _pwsh_payload(rec.argv) + prefix_end = source.index("& 'claude' '--resume'") + assert "Remove-Item" in source[:prefix_end] # teammate-clear prelude first + # A not-recognized command leaves $LASTEXITCODE unset but the source keeps + # running, so the banner needs a fallback code that also works before pwsh 7. + assert ( + "& 'claude' '--resume'; " + "$ec = if ($null -eq $LASTEXITCODE) { 1 } else { $LASTEXITCODE }; " + 'Write-Host "[bmad-loop exited $ec — press enter]"; Read-Host; ' in source + ) + # trailer: same tmux-family verbs as the POSIX one, pwsh control flow, + # issued through the psmux binary + assert "$ret = psmux show-options -wqv '%3' 2>$null; " in source + assert "if ($ret -eq 'detach') { psmux detach-client 2>$null }" in source + assert "psmux switch-client -t $ret 2>$null" in source + assert "psmux switch-client -l 2>$null" in source + + +# ------------------------------------------------------------------ pipe_pane + + +def test_pipe_pane_ships_pwsh_sink(rec, tmp_path): + log = tmp_path / "win's.log" + PsmuxMultiplexer().pipe_pane("@1", log) + + assert rec.argv[:5] == ["psmux", "pipe-pane", "-t", "@1", "-o"] + launch = rec.argv[5].split(" ") + assert launch[:3] == ["pwsh", "-NoProfile", "-EncodedCommand"] + sink = _decode(launch[3]) + assert sink == f"$input | Add-Content -LiteralPath '{str(log).replace(chr(39), chr(39) * 2)}'" + + +def test_pipe_pane_swallows_failure(monkeypatch, tmp_path): + monkeypatch.setattr(tmux_base.subprocess, "run", _RecordRun(returncode=1, stderr="gone")) + assert PsmuxMultiplexer().pipe_pane("@1", tmp_path / "log") is None + + +# ------------------------------------------------------------------ selection + + +def test_available_requires_psmux_pwsh_and_supported_version(monkeypatch): + # Only psmux + pwsh may be probed — a tmux drop-in is deliberately not + # required, so a which() stub answering for anything else must not matter. + monkeypatch.setattr( + psmux_backend.shutil, + "which", + lambda name: f"C:\\bin\\{name}.exe" if name in ("psmux", "pwsh") else None, + ) + monkeypatch.setattr(PsmuxMultiplexer, "version", lambda self: "tmux 3.3.7") + assert PsmuxMultiplexer().available() is True + + # 3.3.6 and older force-kill recycled PIDs on teardown — unusable + monkeypatch.setattr(PsmuxMultiplexer, "version", lambda self: "tmux 3.3.6") + assert PsmuxMultiplexer().available() is False + + monkeypatch.setattr(PsmuxMultiplexer, "version", lambda self: "tmux 3.4.0") + assert PsmuxMultiplexer().available() is True + + # multi-digit segments compare numerically, not lexicographically + monkeypatch.setattr(PsmuxMultiplexer, "version", lambda self: "tmux 3.10.0") + assert PsmuxMultiplexer().available() is True + monkeypatch.setattr(PsmuxMultiplexer, "version", lambda self: "tmux 10.0") + assert PsmuxMultiplexer().available() is True + + # a suffixed newer release still clears the strictly-greater gate + monkeypatch.setattr(PsmuxMultiplexer, "version", lambda self: "tmux 3.3.7-rc0") + assert PsmuxMultiplexer().available() is True + + # a two-part compat version (tmux's own format) reads as patch 0 + monkeypatch.setattr(PsmuxMultiplexer, "version", lambda self: "tmux 3.4") + assert PsmuxMultiplexer().available() is True + + monkeypatch.setattr(PsmuxMultiplexer, "version", lambda self: "tmux 3.3") + assert PsmuxMultiplexer().available() is False + + # unidentifiable version fails closed + for garbled in (None, "", "tmux next-3.4", "psmux 9.9.9"): + monkeypatch.setattr(PsmuxMultiplexer, "version", lambda self, v=garbled: v) + assert PsmuxMultiplexer().available() is False + + +def test_available_caches_version_gate_per_instance(monkeypatch): + monkeypatch.setattr( + psmux_backend.shutil, + "which", + lambda name: f"C:\\bin\\{name}.exe" if name in ("psmux", "pwsh") else None, + ) + calls = 0 + + def probe(self): + nonlocal calls + calls += 1 + return "tmux 3.3.7" + + monkeypatch.setattr(PsmuxMultiplexer, "version", probe) + mux = PsmuxMultiplexer() + assert mux.available() is True + assert mux.available() is True + assert calls == 1 # repeated polls must not respawn the version query + + +def test_available_missing_binary_short_circuits_version_probe(monkeypatch): + def no_probe(self): + raise AssertionError("version() must not spawn when a binary is missing") + + monkeypatch.setattr(PsmuxMultiplexer, "version", no_probe) + for absent in ("pwsh", "psmux"): + monkeypatch.setattr( + psmux_backend.shutil, "which", lambda name, a=absent: None if name == a else "x" + ) + assert PsmuxMultiplexer().available() is False + + +def test_registry_selects_psmux_when_forced(monkeypatch): + monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "psmux") + get_multiplexer.cache_clear() + try: + assert isinstance(get_multiplexer(), PsmuxMultiplexer) + finally: + get_multiplexer.cache_clear() # don't leak the forced pick to other tests diff --git a/tests/test_tui_launch.py b/tests/test_tui_launch.py index b3411a75..82d7a1fa 100644 --- a/tests/test_tui_launch.py +++ b/tests/test_tui_launch.py @@ -17,6 +17,7 @@ import pytest from bmad_loop.adapters import tmux_base +from bmad_loop.adapters.multiplexer import get_multiplexer from bmad_loop.tui import launch # Every test here asserts tmux-specific argv/behaviour through the multiplexer @@ -48,7 +49,12 @@ def fake_run(monkeypatch) -> FakeRun: fake = FakeRun() monkeypatch.setattr(tmux_base.subprocess, "run", fake) monkeypatch.setattr(tmux_base.shutil, "which", lambda name: f"/usr/bin/{name}") - return fake + # These tests pin the POSIX tmux argv shapes; force that backend so they + # hold on hosts where platform selection would pick another (win32 → psmux). + monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "tmux") + get_multiplexer.cache_clear() + yield fake + get_multiplexer.cache_clear() def expected_cli(*tail: str) -> str: @@ -186,12 +192,20 @@ def test_existing_ctl_session_reused(monkeypatch, tmp_path: Path): def test_launch_without_mux_raises(monkeypatch, tmp_path: Path): + monkeypatch.delenv("BMAD_LOOP_MUX_BACKEND") + get_multiplexer.cache_clear() monkeypatch.setattr(tmux_base.shutil, "which", lambda name: None) assert not launch.mux_available() with pytest.raises(launch.LaunchError, match="multiplexer backend unavailable"): launch.start_run_detached(tmp_path, "RID") +def test_forced_launch_bypasses_availability(fake_run, monkeypatch, tmp_path: Path): + monkeypatch.setattr(tmux_base.shutil, "which", lambda name: None) + launch.start_run_detached(tmp_path, "RID") + assert fake_run.by_verb("new-window") + + def test_new_window_failure_raises(monkeypatch, tmp_path: Path): def failing_run(argv, **kwargs): rc = 1 if argv[1] in ("has-session", "new-window") else 0 From a1671024dda221112cc37291d625090c3639b0a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Davor=20Raci=C4=87?= Date: Sat, 18 Jul 2026 15:04:33 +0200 Subject: [PATCH 2/4] chore: address PR review feedback The platform-default registry test previously passed even when selection fell through to the fallback rung, which returns the same class for an unavailable backend; stub availability and assert the selection reason for both platform legs instead. Mark the psmux backend as shipped in-tree on the roadmap, keeping tmux-windows as the in-flight sibling. The TUI launch test's delenv now tolerates an already-unset override. --- docs/ROADMAP.md | 10 +++++----- tests/test_backend_registry.py | 18 ++++++++++++------ tests/test_tui_launch.py | 2 +- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f866b488..f4424c18 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -34,11 +34,11 @@ held by a CI portability guard (`tests/test_portability_guard.py`). **WSL alread — it _is_ Linux, so it takes every fast path unchanged; this is purely about a future _native_ Windows host. -The remaining work is the native-Windows backend itself. Three candidates now exist, and -they are **not stages of one plan**. Two are **sibling tmux-family backends** still in -flight: `psmux` (drives psmux's tmux-compatible CLI through its own `psmux` binary, via -pwsh) and `tmux-windows` (#85; drives the tmux-windows port) — both subclass -`BaseTmuxBackend` and both register for `win32`, which is exactly why selection is +Three native-Windows candidates now exist, and they are **not stages of one plan**. +Two are **sibling tmux-family backends**: `psmux` has **shipped in-tree** (drives +psmux's tmux-compatible CLI through its own `psmux` binary, via pwsh) while +`tmux-windows` (#85; drives the tmux-windows port) is still in flight — both subclass +`BaseTmuxBackend` and both register for `win32`, which is why selection is availability-aware with discriminating `available()` probes (psmux → `which("psmux") and which("pwsh")` plus a version gate excluding releases ≤ 3.3.6, whose teardown can force-kill a recycled PID; tmux-windows → diff --git a/tests/test_backend_registry.py b/tests/test_backend_registry.py index 3b0c5534..56969a74 100644 --- a/tests/test_backend_registry.py +++ b/tests/test_backend_registry.py @@ -82,15 +82,21 @@ def fresh_registry(monkeypatch): def test_default_matches_platform(fresh_registry, monkeypatch): """No override → the loop's platform match picks the right builtin (tmux - registers ``p != 'win32'``, psmux ``p == 'win32'``), not just the bottom - fallback. Both legs are pinned regardless of the host OS; the lru_cache is - cleared between them so the second selection actually re-runs.""" + registers ``p != 'win32'``, psmux ``p == 'win32'``) through the + platform-default branch, not the bottom fallback (which returns the same + class when the backend is unavailable, so the reason must be asserted). + Both legs are pinned regardless of the host OS.""" + monkeypatch.setattr(PsmuxMultiplexer, "available", lambda self: True) monkeypatch.setattr(sys, "platform", "win32") - assert isinstance(fresh_registry.get_multiplexer(), PsmuxMultiplexer) + backend, name, reason = fresh_registry._select() + assert isinstance(backend, PsmuxMultiplexer) + assert (name, reason) == ("psmux", "platform-default") - fresh_registry.get_multiplexer.cache_clear() + monkeypatch.setattr(TmuxMultiplexer, "available", lambda self: True) monkeypatch.setattr(sys, "platform", "linux") - assert isinstance(fresh_registry.get_multiplexer(), TmuxMultiplexer) + backend, name, reason = fresh_registry._select() + assert isinstance(backend, TmuxMultiplexer) + assert (name, reason) == ("tmux", "platform-default") def test_env_override_selects_named_backend(fresh_registry, monkeypatch): diff --git a/tests/test_tui_launch.py b/tests/test_tui_launch.py index 82d7a1fa..2efd76ec 100644 --- a/tests/test_tui_launch.py +++ b/tests/test_tui_launch.py @@ -192,7 +192,7 @@ def test_existing_ctl_session_reused(monkeypatch, tmp_path: Path): def test_launch_without_mux_raises(monkeypatch, tmp_path: Path): - monkeypatch.delenv("BMAD_LOOP_MUX_BACKEND") + monkeypatch.delenv("BMAD_LOOP_MUX_BACKEND", raising=False) get_multiplexer.cache_clear() monkeypatch.setattr(tmux_base.shutil, "which", lambda name: None) assert not launch.mux_available() From 6270cb98f65dc980ed75f0eb6e00cc520e3c2499 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Davor=20Raci=C4=87?= Date: Sat, 18 Jul 2026 15:33:38 +0200 Subject: [PATCH 3/4] fix(adapters): share the forced-backend gate, byte-exact psmux log sink - one forced-aware usability gate (mux_usable) shared by the run preflight and every TUI observer (attach, ctl-window, liveness, prune); a pinned backend that probes unavailable proceeds with a one-time stderr warning instead of silently skipping the version gate - verify psmux new-session actually created the session (exit-0 no-op belt) - pipe_pane: raw-stream pwsh sink, byte-exact like the POSIX cat >> (no console decode / Add-Content re-encode / CRLF normalization); a failed pipe-pane attach now warns on stderr instead of passing silently - unusable-backend diagnostics report the version and name the pwsh prerequisite - tests: env isolation for the preflight tests; cover the policy-pinned forced leg, observer-gate parity, the real -V probe seam, the exit-0 no-op, kill-session binary guard, and geometry-less new-session --- docs/porting-to-a-new-os.md | 4 +- src/bmad_loop/adapters/multiplexer.py | 42 ++++++++++++- src/bmad_loop/adapters/psmux_backend.py | 56 +++++++++++++----- src/bmad_loop/adapters/tmux_base.py | 14 +++-- src/bmad_loop/cli.py | 38 +++++++----- src/bmad_loop/tui/data.py | 4 +- src/bmad_loop/tui/launch.py | 18 ++++-- tests/test_cli.py | 35 +++++++++-- tests/test_psmux_backend.py | 78 +++++++++++++++++++++---- tests/test_tui_launch.py | 17 +++++- 10 files changed, 245 insertions(+), 61 deletions(-) diff --git a/docs/porting-to-a-new-os.md b/docs/porting-to-a-new-os.md index 41bda4a7..82ca3313 100644 --- a/docs/porting-to-a-new-os.md +++ b/docs/porting-to-a-new-os.md @@ -67,7 +67,9 @@ register_multiplexer("psmux", lambda platform: platform == "win32", PsmuxMultipl A forced name (1–2) bypasses both the platform predicate and `available()` — an explicit choice is trusted — and fails loudly when it matches no registered -backend. `bmad-loop mux` lists every registered backend with its availability, +backend. Launch preflights and TUI observers share one forced-aware gate +(`mux_usable()`), which warns once on stderr when a forced backend probes +unavailable instead of silently proceeding or refusing. `bmad-loop mux` lists every registered backend with its availability, version, and the current selection. (The result is cached — see [Testing a port](#testing-a-port).) diff --git a/src/bmad_loop/adapters/multiplexer.py b/src/bmad_loop/adapters/multiplexer.py index 427f8368..d3756252 100644 --- a/src/bmad_loop/adapters/multiplexer.py +++ b/src/bmad_loop/adapters/multiplexer.py @@ -400,10 +400,47 @@ def backend_forced() -> bool: A forced name bypasses ``available()`` throughout (an explicit choice is trusted; the backend fails loudly if it can't run), so launch preflights - that refuse an unusable backend must stand down for it too.""" + that refuse an unusable backend must stand down for it too — via + :func:`mux_usable`, which stands down loudly.""" return bool(os.environ.get("BMAD_LOOP_MUX_BACKEND")) or _CONFIGURED is not None +_FORCED_UNUSABLE_WARNED = False + + +def mux_usable(backend: TerminalMultiplexer | None = None) -> bool: + """The one usability gate for launch preflights and TUI observers + (attach, liveness, prune): the backend probes available, or its selection + is forced. Every gate must share this rule — if launch trusts a forced + backend but observers don't, a launched run becomes invisible to the rest + of the TUI with no error anywhere. + + A forced backend that probes unavailable is still trusted, but says so + once per process on stderr: a missing binary fails loudly on first use + anyway, while a version-gated binary works right up until the gated defect + fires — proceeding must not be silent.""" + global _FORCED_UNUSABLE_WARNED + if backend is None: + backend = get_multiplexer() + if _usable(backend): + return True + if not backend_forced(): + return False + if not _FORCED_UNUSABLE_WARNED: + _FORCED_UNUSABLE_WARNED = True + try: + version = backend.version() + except Exception: # noqa: BLE001 — a broken probe must not break the warning + version = None + print( + f"warning: forced multiplexer backend {type(backend).__name__} reports " + f"unavailable (version: {version!r}); proceeding because the choice is " + "pinned — a version-gated backend can misbehave mid-run", + file=sys.stderr, + ) + return True + + def _select() -> tuple[TerminalMultiplexer, str, str]: """Resolve the backend by precedence; returns ``(instance, name, reason)``. @@ -429,8 +466,7 @@ def _select() -> tuple[TerminalMultiplexer, str, str]: factory = _factory_by_name(forced) if factory is None: raise MultiplexerError( - f"BMAD_LOOP_MUX_BACKEND={forced!r} matches no registered backend; " - f"known: {_known()}" + f"BMAD_LOOP_MUX_BACKEND={forced!r} matches no registered backend; known: {_known()}" ) return factory(), forced, "env" if _CONFIGURED is not None: diff --git a/src/bmad_loop/adapters/psmux_backend.py b/src/bmad_loop/adapters/psmux_backend.py index 6d312da8..ba437fbb 100644 --- a/src/bmad_loop/adapters/psmux_backend.py +++ b/src/bmad_loop/adapters/psmux_backend.py @@ -24,6 +24,7 @@ import shlex import shutil import subprocess +import sys from pathlib import Path from .tmux_base import PARKED_RETURN_DETACH, BaseTmuxBackend, TmuxError @@ -57,8 +58,8 @@ class PsmuxMultiplexer(BaseTmuxBackend): # ------------------------------------------- shell dialect (PowerShell) # A command pwsh could not even start (not recognized) still runs the rest of - # the source but leaves $LASTEXITCODE unset — coalesce without requiring - # PowerShell 7 syntax, since availability accepts any `pwsh`. + # the source but leaves $LASTEXITCODE unset — coalesce with a plain `if` + # (works on any PowerShell version) rather than the PS7-only `??` syntax. _EXIT_CAPTURE = "$ec = if ($null -eq $LASTEXITCODE) { 1 } else { $LASTEXITCODE }" _ECHO = "Write-Host" _PARK = "Read-Host" @@ -104,7 +105,7 @@ def _parked_trailer(self, return_opt: str) -> str: def _window_launch(self, env: dict[str, str], command: str) -> list[str]: # psmux accepts `new-window -e` but silently drops it, so the env rides # an in-source prelude instead. `command` arrives POSIX-quoted (callers - # build it with shlex.join), so split it here and re-quote for pwsh. + # shlex-quote each arg), so split it here and re-quote for pwsh. for key in env: if not _ENV_NAME.fullmatch(key): raise TmuxError(f"invalid environment variable name: {key!r}") @@ -149,6 +150,14 @@ def new_session( raise TmuxError(f"{self._BINARY} new-session failed: {exc}") from exc if proc.returncode != 0: raise TmuxError(f"{self._BINARY} new-session failed: {proc.stderr.strip()}") + # Belt for the nesting guard's historical no-op mode (exit 0, nothing + # created): verify the session exists so the failure blames session + # creation, not the next verb's "can't find session". + if not self.has_session(name): + raise TmuxError( + f"{self._BINARY} new-session exited 0 but session {name!r} was not " + "created (nesting guard no-op?)" + ) def kill_session(self, name: str) -> None: # psmux ignores the `=name` exact-match form for kill-session; plain-name @@ -162,30 +171,47 @@ def kill_session(self, name: str) -> None: def pipe_pane(self, window_id: str, log_file: Path) -> None: # The base's POSIX `cat >>` sink assumes a POSIX host shell; psmux runs - # the pipe command on the host shell, so ship a pwsh append sink instead - # (base64 has no quoting to lose, so a plain join survives psmux's - # re-parse). Best-effort, as the base: a window that died on launch is - # not a setup failure. - sink = f"$input | Add-Content -LiteralPath {_pwsh_quote(str(log_file))}" + # the pipe command on the host shell, so ship a pwsh append sink instead. + # A raw stream copy is byte-exact like `cat >>`: no console decode of the + # pane bytes, no Add-Content re-encode, no CRLF normalization. + sink = ( + f"$out = [System.IO.File]::Open({_pwsh_quote(str(log_file))}, " + "'Append', 'Write', 'Read'); " + "[System.Console]::OpenStandardInput().CopyTo($out); $out.Dispose()" + ) + wrapped = self._shell_wrap(sink) + # base64 has no quoting to lose, so a plain join survives psmux's re-parse + # — valid only while no wrapped arg contains a space. + assert all(" " not in part for part in wrapped) try: - self._tmux("pipe-pane", "-t", window_id, "-o", " ".join(self._shell_wrap(sink))) - except TmuxError: - pass + self._tmux("pipe-pane", "-t", window_id, "-o", " ".join(wrapped)) + except TmuxError as exc: + # Best-effort, as the base: a window that died on launch is not a + # setup failure — but say so, or an empty run log is unexplainable. + print( + f"warning: pipe-pane log capture failed for {window_id}: {exc}", + file=sys.stderr, + ) # Releases up to this version can force-kill a recycled PID during pane # teardown and let orphaned servers accumulate — engine-fatal, so they # must never be selected. _LAST_UNSUPPORTED = (3, 3, 6) + # Class-level default; instances shadow it on first probe. Never assign on + # the class outside tests — that would poison every future instance. _version_ok: bool | None = None def available(self) -> bool: # Every window launch needs pwsh alongside the psmux binary itself. # The version gate fails closed: psmux prints `tmux X.Y.Z` (the tmux # prefix is kept deliberately for tmux-version parsers), and an old or - # unidentifiable install reads as unusable. A forced backend name in - # settings still bypasses this probe. The gate verdict is cached on the - # instance so repeated availability polls don't each spawn a version - # query; an install swapped mid-process is picked up on restart. + # unidentifiable install reads as unusable. A forced backend name (env + # var or policy) still bypasses this probe, with a warning at the + # launch gates (see multiplexer.mux_usable). The gate verdict is cached + # on the instance so repeated availability polls don't each spawn a + # version query; the lru-cached selected instance re-probes a swapped + # install only on restart (detect_multiplexers' fresh instances + # re-probe every call). if not all(shutil.which(exe) for exe in (self._BINARY, "pwsh")): return False if self._version_ok is None: diff --git a/src/bmad_loop/adapters/tmux_base.py b/src/bmad_loop/adapters/tmux_base.py index 7c4943bd..02e1c28c 100644 --- a/src/bmad_loop/adapters/tmux_base.py +++ b/src/bmad_loop/adapters/tmux_base.py @@ -5,7 +5,8 @@ :mod:`.tmux_backend`). The point of the split is that a tmux-*family* backend — the native-Windows :mod:`.psmux_backend` leaf — can subclass :class:`BaseTmuxBackend` and swap only class attributes (:attr:`BaseTmuxBackend._BINARY` for the spawned -binary, :attr:`BaseTmuxBackend._ENCODING` for output decoding — a scrubbed +binary, :attr:`BaseTmuxBackend._ENCODING` / :attr:`BaseTmuxBackend._ERRORS` +for output decoding — a scrubbed per-call ``env`` is a ``_run`` parameter, and an :meth:`BaseTmuxBackend._run` override is left for timeout tweaks) plus the shell-dialect hooks (``_shell_wrap``, ``_join_argv``, ``_parked_trailer``, ``_source_prefix``, ``_window_launch`` and the @@ -26,6 +27,7 @@ import shlex import shutil import subprocess +import sys import time from pathlib import Path @@ -308,11 +310,15 @@ def list_window_ids(self, session: str) -> list[str]: def pipe_pane(self, window_id: str, log_file: Path) -> None: # A CLI that crashes on launch (bad args, instant auth failure) can take # its window down before pipe-pane attaches, which races as "can't find - # window". That is not a setup failure, so tolerate it instead of raising. + # window". That is not a setup failure, so tolerate it instead of raising + # — but say so, or an empty run log is unexplainable. try: self._tmux("pipe-pane", "-t", window_id, "-o", f"cat >> {shlex.quote(str(log_file))}") - except TmuxError: - pass + except TmuxError as exc: + print( + f"warning: pipe-pane log capture failed for {window_id}: {exc}", + file=sys.stderr, + ) def send_text(self, window_id: str, text: str) -> None: self._tmux("send-keys", "-t", window_id, "-l", text) diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 9706fa1e..dd740e06 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -100,7 +100,7 @@ def _reconcile_stale(project: Path, paths: bmadconfig.ProjectPaths, pol) -> None def _make_adapters(project: Path, run_dir: Path, policy) -> dict[str, CodingCLIAdapter]: from .adapters.generic import GenericAdapter, GenericDevAdapter - from .adapters.multiplexer import _usable, backend_forced, get_multiplexer + from .adapters.multiplexer import get_multiplexer, mux_usable from .adapters.profile import ProfileError, get_profile # The dev skill (bmad-dev-auto) writes no result.json: its adapter @@ -155,11 +155,16 @@ def _make_adapters(project: Path, run_dir: Path, policy) -> dict[str, CodingCLIA # actually uses it; hookless HTTP/SSE runs need no transport. if mux is None: mux = get_multiplexer() - if not backend_forced() and not _usable(mux): + if not mux_usable(mux): + try: + version = mux.version() + except Exception: # noqa: BLE001 — diagnosing must not mask the refusal + version = None raise SystemExit( f"error: multiplexer backend {type(mux).__name__} is not usable on " - "this host (its transport binary is missing or an unsupported " - "version); see `bmad-loop diagnose`" + f"this host (reported version: {version}); its transport binary is " + "missing, the version is unsupported, or a required helper is " + "absent (psmux needs `pwsh` on PATH); see `bmad-loop diagnose`" ) common = dict( run_dir=run_dir, @@ -208,10 +213,13 @@ def _platform_preflight() -> tuple[list[str], list[str]]: version = backend.version() notes.append(f"multiplexer {label} available" + (f" ({version})" if version else "")) else: + version = backend.version() problems.append( - f"multiplexer {label} unavailable — its transport binary is missing or " - f"an unsupported version; " - f"see `bmad-loop diagnose`" + f"multiplexer {label} unavailable" + + (f" (reports {version})" if version else "") + + " — its transport binary is missing, the version is unsupported, or a " + "required helper is absent (psmux needs `pwsh` on PATH); " + "see `bmad-loop diagnose`" ) except Exception as e: # noqa: BLE001 — selection or readiness must not abort validate problems.append(f"multiplexer preflight failed: {e}") @@ -338,8 +346,7 @@ def cmd_validate(args: argparse.Namespace) -> int: notes.append(f"httpx available for {profile.name}") else: problems.append( - f"{profile.name}: httpx not installed — " - f"run `pip install 'bmad-loop[opencode]'`" + f"{profile.name}: httpx not installed — run `pip install 'bmad-loop[opencode]'`" ) continue hook_config = project / profile.hooks.config_path @@ -483,16 +490,19 @@ def _mux_set(project: Path, args: argparse.Namespace) -> int: return 1 if row is not None and not row.available: # Deliberate choice = trusted (same doctrine as the env override), but - # say so: the run will fail loudly if the binary never appears. + # say so: a pinned backend bypasses the availability gate at launch + # (with a warning there too — see multiplexer.mux_usable), so a + # version-gated binary would otherwise run into its gated defect. print( - f"warning: backend {args.name!r} is not available on this host (its " - "transport binary is missing or an unsupported version); persisted anyway " - "— `bmad-loop validate` will report it", + f"warning: backend {args.name!r} is not available on this host (transport " + "binary missing, version unsupported, or a required helper like `pwsh` " + "absent); persisted anyway — launches will proceed with a warning, and " + "`bmad-loop validate` will report it", file=sys.stderr, ) if os.environ.get("BMAD_LOOP_MUX_BACKEND"): print( - "note: BMAD_LOOP_MUX_BACKEND is set in this shell and outranks the " "persisted choice", + "note: BMAD_LOOP_MUX_BACKEND is set in this shell and outranks the persisted choice", file=sys.stderr, ) policy_mod.write_mux_backend(path, args.name) # a junk name raises PolicyError → main() diff --git a/src/bmad_loop/tui/data.py b/src/bmad_loop/tui/data.py index 391e27a6..21de5ae9 100644 --- a/src/bmad_loop/tui/data.py +++ b/src/bmad_loop/tui/data.py @@ -27,7 +27,7 @@ from rich.text import Text from .. import bmadconfig, deferredwork, sprintstatus, stories -from ..adapters.multiplexer import MultiplexerError, get_multiplexer +from ..adapters.multiplexer import MultiplexerError, get_multiplexer, mux_usable from ..gates import ATTENTION_FILE from ..journal import JOURNAL_FILE, LOGS_DIR, STATE_FILE, load_state from ..model import RunState @@ -90,7 +90,7 @@ def _session_liveness(run_id: str) -> str: # An absent multiplexer / dead query proves nothing about a legacy run, so the # only positive signal is a live session; everything else is 'unknown'. mux = get_multiplexer() - if not mux.available(): + if not mux_usable(mux): # forced-aware, like every other observer gate return "unknown" try: return "alive" if mux.has_session(session_name(run_id)) else "unknown" diff --git a/src/bmad_loop/tui/launch.py b/src/bmad_loop/tui/launch.py index 72256df0..13aa1edb 100644 --- a/src/bmad_loop/tui/launch.py +++ b/src/bmad_loop/tui/launch.py @@ -18,7 +18,7 @@ from pathlib import Path from .. import runs -from ..adapters.multiplexer import MultiplexerError, backend_forced, get_multiplexer +from ..adapters.multiplexer import MultiplexerError, get_multiplexer, mux_usable from ..journal import Journal CTL_SESSION = "bmad-loop-ctl" @@ -32,7 +32,10 @@ class LaunchError(Exception): def mux_available() -> bool: - return get_multiplexer().available() + # Forced-aware (mux_usable, not raw available()): a pinned backend must look + # the same to observers (attach, ctl-window lookup, prune) as it does to the + # launch preflight, or a launched run becomes invisible to the rest of the TUI. + return mux_usable(get_multiplexer()) def session_exists(session: str) -> bool: @@ -128,7 +131,7 @@ def return_attached_client() -> bool: The option is then cleared so the post-exit return trailer doesn't fire a second time. Returns True iff a client was actually returned.""" mux = get_multiplexer() - if not mux.available(): + if not mux_usable(mux): return False win = mux.current_window_id() if win is None: @@ -198,7 +201,7 @@ def _ctl_window_candidates(project: Path) -> list[tuple[str, str]]: window is only a candidate when its run dir exists under this project. """ mux = get_multiplexer() - if not mux.available() or not session_exists(CTL_SESSION): + if not mux_usable(mux) or not session_exists(CTL_SESSION): return [] current = mux.current_window_id() rows = mux.list_windows(CTL_SESSION, ["window_id", "window_name", runs.PROJECT_OPTION]) @@ -275,8 +278,11 @@ def start_detached(project: Path, argv_tail: list[str], run_id: str, kind: str) unambiguously (window names collide when several kinds share a run_id). """ mux = get_multiplexer() - if not backend_forced() and not mux.available(): - raise LaunchError("multiplexer backend unavailable (binary missing or unsupported version)") + if not mux_usable(mux): + raise LaunchError( + "multiplexer backend unavailable (binary missing, version unsupported, " + "or a required helper absent)" + ) _ensure_ctl_session(project) try: win_id = ( diff --git a/tests/test_cli.py b/tests/test_cli.py index ef579853..9d4b0ee7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -685,6 +685,10 @@ def test_make_adapters_refuses_unusable_mux(project, monkeypatch): is unusable; the run bootstrap must refuse it so a run never drives a missing or version-gated multiplexer.""" install_bmad_config(project) + # isolate the forced legs: a developer-shell env var or a leaked policy pin + # would flip backend_forced() and let this preflight stand down + monkeypatch.delenv("BMAD_LOOP_MUX_BACKEND", raising=False) + monkeypatch.setattr(mux_mod, "_CONFIGURED", None) monkeypatch.setattr(mux_mod, "_usable", lambda mux: False) with pytest.raises(SystemExit, match="not usable"): cli._make_adapters( @@ -692,14 +696,15 @@ def test_make_adapters_refuses_unusable_mux(project, monkeypatch): ) -def test_make_adapters_trusts_forced_backend(project, monkeypatch): +def test_make_adapters_trusts_forced_backend(project, monkeypatch, capsys): """A forced name bypasses available() in selection; the run-bootstrap - preflight must stand down for it the same way (the backend fails loudly - at first use instead).""" + preflight must stand down for it the same way — but loudly (a version-gated + binary works right up until the gated defect fires).""" from bmad_loop.adapters.multiplexer import get_multiplexer install_bmad_config(project) monkeypatch.setattr(mux_mod, "_usable", lambda mux: False) + monkeypatch.setattr(mux_mod, "_FORCED_UNUSABLE_WARNED", False) monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "tmux") get_multiplexer.cache_clear() try: @@ -709,6 +714,28 @@ def test_make_adapters_trusts_forced_backend(project, monkeypatch): finally: get_multiplexer.cache_clear() # don't leak the forced pick to other tests assert set(adapters) == set(cli.ROLES) + assert "forced multiplexer backend" in capsys.readouterr().err + + +def test_make_adapters_trusts_settings_pinned_backend(project, monkeypatch, capsys): + """The policy pin (`bmad-loop mux set`) is the second forced leg: the + preflight stands down for it exactly like the env var, with the same + warning.""" + from bmad_loop.adapters.multiplexer import configure_multiplexer + + install_bmad_config(project) + monkeypatch.delenv("BMAD_LOOP_MUX_BACKEND", raising=False) + monkeypatch.setattr(mux_mod, "_usable", lambda mux: False) + monkeypatch.setattr(mux_mod, "_FORCED_UNUSABLE_WARNED", False) + configure_multiplexer("tmux") + try: + adapters = cli._make_adapters( + project.project, project.project / ".bmad-loop" / "runs" / "r", policy_mod.load(None) + ) + finally: + configure_multiplexer(None) # also clears the selection cache + assert set(adapters) == set(cli.ROLES) + assert "forced multiplexer backend" in capsys.readouterr().err class _StubEngine: @@ -1001,7 +1028,7 @@ def _write_bmad_config(project, impl="{project-root}/artifacts"): cfg = project / "_bmad" / "bmm" cfg.mkdir(parents=True, exist_ok=True) (cfg / "config.yaml").write_text( - f"implementation_artifacts: '{impl}'\n" "planning_artifacts: '{project-root}/planning'\n", + f"implementation_artifacts: '{impl}'\nplanning_artifacts: '{{project-root}}/planning'\n", encoding="utf-8", ) diff --git a/tests/test_psmux_backend.py b/tests/test_psmux_backend.py index 1d4aacc7..0cfb5926 100644 --- a/tests/test_psmux_backend.py +++ b/tests/test_psmux_backend.py @@ -19,10 +19,11 @@ class _RecordRun: """Stand-in for subprocess.run that records every spawn's argv and kwargs.""" - def __init__(self, returncode: int = 0, stderr: str = ""): + def __init__(self, returncode: int = 0, stderr: str = "", stdout: str = ""): self.calls: list[tuple[list, dict]] = [] self.returncode = returncode self.stderr = stderr + self.stdout = stdout @property def argv(self): @@ -34,7 +35,9 @@ def kwargs(self): def __call__(self, argv, **kwargs): self.calls.append((argv, kwargs)) - return subprocess.CompletedProcess(argv, self.returncode, stdout="", stderr=self.stderr) + return subprocess.CompletedProcess( + argv, self.returncode, stdout=self.stdout, stderr=self.stderr + ) @pytest.fixture @@ -165,7 +168,8 @@ def test_new_session_bypasses_nesting_guard(rec, monkeypatch, tmp_path): before = dict(os.environ) PsmuxMultiplexer().new_session("s", tmp_path, cols=80, lines=24) - assert rec.argv == [ + create_argv, create_kwargs = rec.calls[0] + assert create_argv == [ "psmux", "new-session", "-d", @@ -178,17 +182,38 @@ def test_new_session_bypasses_nesting_guard(rec, monkeypatch, tmp_path): "-y", "24", ] - assert rec.kwargs["env"]["PSMUX_ALLOW_NESTING"] == "1" + # the no-op belt: create is verified by a has-session probe afterwards + assert rec.argv == ["psmux", "has-session", "-t", "=s"] + assert create_kwargs["env"]["PSMUX_ALLOW_NESTING"] == "1" # the claude session vars are scrubbed from the create env (the psmux server # this call may cold-start would otherwise hand them to every window) - assert "CLAUDE_CODE_SSE_PORT" not in rec.kwargs["env"] - assert "CLAUDECODE" not in rec.kwargs["env"] - assert "PSMUX_CLAUDE_TEAMMATE_MODE" not in rec.kwargs["env"] - assert "Claude_Code_Mixed" not in rec.kwargs["env"] + assert "CLAUDE_CODE_SSE_PORT" not in create_kwargs["env"] + assert "CLAUDECODE" not in create_kwargs["env"] + assert "PSMUX_CLAUDE_TEAMMATE_MODE" not in create_kwargs["env"] + assert "Claude_Code_Mixed" not in create_kwargs["env"] # the bypass var and the scrub are confined to the child spawn assert dict(os.environ) == before +def test_new_session_omits_geometry_when_unset(rec, tmp_path): + PsmuxMultiplexer().new_session("s", tmp_path) + create_argv = rec.calls[0][0] + assert "-x" not in create_argv + assert "-y" not in create_argv + + +def test_new_session_exit_zero_noop_raises(monkeypatch, tmp_path): + # The nesting guard's historical failure mode: new-session exits 0 having + # created nothing. The belt verifies and blames session creation directly. + def fake(argv, **kwargs): + rc = 1 if argv[1] == "has-session" else 0 + return subprocess.CompletedProcess(argv, rc, stdout="", stderr="") + + monkeypatch.setattr(tmux_base.subprocess, "run", fake) + with pytest.raises(MultiplexerError, match="was not created"): + PsmuxMultiplexer().new_session("s", tmp_path) + + def test_new_session_failure_raises_multiplexer_error(monkeypatch, tmp_path): monkeypatch.setattr(tmux_base.subprocess, "run", _RecordRun(returncode=1, stderr="boom")) with pytest.raises(MultiplexerError): @@ -206,11 +231,23 @@ def timeout(*_a, **_k): def test_kill_session_uses_plain_target(rec, monkeypatch): - monkeypatch.setattr(psmux_backend.shutil, "which", lambda _name: "C:\\bin\\psmux.exe") + # strict which-stub: the guard must probe the psmux binary, not a + # copy-pasted "tmux" + monkeypatch.setattr( + psmux_backend.shutil, + "which", + lambda name: "C:\\bin\\psmux.exe" if name == "psmux" else None, + ) PsmuxMultiplexer().kill_session("s") assert rec.argv == ["psmux", "kill-session", "-t", "s"] # no `=` — psmux ignores it +def test_kill_session_no_binary_no_spawn(rec, monkeypatch): + monkeypatch.setattr(psmux_backend.shutil, "which", lambda _name: None) + PsmuxMultiplexer().kill_session("s") + assert rec.calls == [] + + # ------------------------------------------------------------- parked window @@ -246,12 +283,16 @@ def test_pipe_pane_ships_pwsh_sink(rec, tmp_path): launch = rec.argv[5].split(" ") assert launch[:3] == ["pwsh", "-NoProfile", "-EncodedCommand"] sink = _decode(launch[3]) - assert sink == f"$input | Add-Content -LiteralPath '{str(log).replace(chr(39), chr(39) * 2)}'" + # byte-exact raw stream copy (no console decode / re-encode / CRLF mangling) + quoted = str(log).replace(chr(39), chr(39) * 2) + assert f"[System.IO.File]::Open('{quoted}', 'Append', 'Write', 'Read')" in sink + assert "OpenStandardInput().CopyTo($out)" in sink -def test_pipe_pane_swallows_failure(monkeypatch, tmp_path): +def test_pipe_pane_swallows_failure_with_warning(monkeypatch, capsys, tmp_path): monkeypatch.setattr(tmux_base.subprocess, "run", _RecordRun(returncode=1, stderr="gone")) assert PsmuxMultiplexer().pipe_pane("@1", tmp_path / "log") is None + assert "pipe-pane log capture failed" in capsys.readouterr().err # ------------------------------------------------------------------ selection @@ -298,6 +339,21 @@ def test_available_requires_psmux_pwsh_and_supported_version(monkeypatch): assert PsmuxMultiplexer().available() is False +def test_available_composes_real_version_probe(monkeypatch): + # End-to-end through the real version() seam (no version() stub): the gate + # must survive `psmux -V` composition, including trailing-newline stripping. + monkeypatch.setattr( + psmux_backend.shutil, + "which", + lambda name: f"C:\\bin\\{name}.exe" if name in ("psmux", "pwsh") else None, + ) + monkeypatch.setattr(tmux_base.shutil, "which", lambda name: f"C:\\bin\\{name}.exe") + rec = _RecordRun(stdout="tmux 3.3.7\n") + monkeypatch.setattr(tmux_base.subprocess, "run", rec) + assert PsmuxMultiplexer().available() is True + assert rec.argv == ["psmux", "-V"] + + def test_available_caches_version_gate_per_instance(monkeypatch): monkeypatch.setattr( psmux_backend.shutil, diff --git a/tests/test_tui_launch.py b/tests/test_tui_launch.py index 2efd76ec..5841f897 100644 --- a/tests/test_tui_launch.py +++ b/tests/test_tui_launch.py @@ -200,10 +200,25 @@ def test_launch_without_mux_raises(monkeypatch, tmp_path: Path): launch.start_run_detached(tmp_path, "RID") -def test_forced_launch_bypasses_availability(fake_run, monkeypatch, tmp_path: Path): +def test_forced_launch_bypasses_availability(fake_run, monkeypatch, capsys, tmp_path: Path): + from bmad_loop.adapters import multiplexer as mux_mod + + monkeypatch.setattr(mux_mod, "_FORCED_UNUSABLE_WARNED", False) monkeypatch.setattr(tmux_base.shutil, "which", lambda name: None) launch.start_run_detached(tmp_path, "RID") assert fake_run.by_verb("new-window") + # trusted, but not silently: the bypass names itself once on stderr + assert "forced multiplexer backend" in capsys.readouterr().err + + +def test_observers_follow_forced_backend(fake_run, monkeypatch): + """The observer gates (mux_available feeds attach/ctl-window/prune) must + share the launch preflight's forced-aware rule — launch working while + attach reports "nothing to attach to" would be a silent split.""" + from bmad_loop.adapters import multiplexer as mux_mod + + monkeypatch.setattr(mux_mod, "_usable", lambda mux: False) + assert launch.mux_available() is True # fake_run's fixture forces tmux by env def test_new_window_failure_raises(monkeypatch, tmp_path: Path): From 6b5420e8314ceb3f4ec33984bb5f8d3df3c3c151 Mon Sep 17 00:00:00 2001 From: pbean Date: Sat, 18 Jul 2026 17:57:08 -0700 Subject: [PATCH 4/4] fix(tui): trip the forced-backend warning before Textual captures stderr (PR #181 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the mux_usable gate: - run_tui now calls mux_usable() once before App.run(): Textual redirects sys.stderr for the app's whole run and the forced-unusable warning is once-per-process, so a first firing inside the app (any observer gate) would consume the single emission invisibly. Selection errors from a junk forced name stay swallowed here — they fail loudly at every real mux call site. - test_return_attached_client_noop_without_tmux now drops the module-wide force_tmux_backend pin: the forced-aware gate trusts a pinned backend regardless of available(), so the "never shells out" path proceeded to current_window_id() — and inside a real tmux session (TMUX set: true on dev boxes, never in CI) display-message shelled out into the test's None-returning run stub. Mirrors the isolation a167102 gave test_launch_without_mux_raises. --- src/bmad_loop/tui/app.py | 10 ++++++- tests/test_tui_app.py | 58 ++++++++++++++++++++++++++++++++++++++++ tests/test_tui_launch.py | 6 +++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index 40d72fe9..e3d25dfe 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -22,7 +22,7 @@ from tomlkit.exceptions import ParseError from .. import bmadconfig, decisions, devcontract, policy, resolve, runs, stories, verify -from ..adapters.multiplexer import MultiplexerError +from ..adapters.multiplexer import MultiplexerError, mux_usable from ..journal import load_state from ..model import ( PAUSE_EPIC_BOUNDARY, @@ -969,5 +969,13 @@ def action_settings(self) -> None: def run_tui(project: Path) -> int: + # Trip the once-per-process forced-backend warning while stderr is still the + # real terminal: Textual captures sys.stderr for the app's whole run, so a + # first firing inside the app (any observer gate) would consume the single + # emission invisibly. Selection errors stay loud at their real call sites. + try: + mux_usable() + except MultiplexerError: + pass BmadLoopApp(project).run() return 0 diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 9bab3b11..687c1381 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -2429,3 +2429,61 @@ async def test_quit_in_resize_mode_persists_geometry(project): # Leave without Escape: shutdown unmounts the screen, which persists. saved = policy_mod.load(root / ".bmad-loop" / "policy.toml").tui assert saved.left_width == 38 # 34 + 4 + + +def test_run_tui_trips_forced_warning_before_app_capture(monkeypatch, capsys, tmp_path): + """The forced-backend usability warning is once-per-process on stderr, and + Textual captures sys.stderr for the app's whole run — so run_tui must trip + the warning (and its latch) BEFORE App.run, or a first firing inside the + app (any observer gate) consumes the single emission invisibly.""" + from bmad_loop.adapters import multiplexer as mux_mod + from bmad_loop.tui import app as tui_app + + monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "tmux") + monkeypatch.setattr(mux_mod, "_usable", lambda mux: False) + monkeypatch.setattr(mux_mod, "_FORCED_UNUSABLE_WARNED", False) + mux_mod.get_multiplexer.cache_clear() + + stderr_at_run: list[str] = [] + + class _StubApp: + def __init__(self, _project): + pass + + def run(self): + # snapshot what already reached stderr when the app takes over + stderr_at_run.append(capsys.readouterr().err) + + monkeypatch.setattr(tui_app, "BmadLoopApp", _StubApp) + try: + assert tui_app.run_tui(tmp_path) == 0 + finally: + mux_mod.get_multiplexer.cache_clear() # don't leak the forced pick + assert stderr_at_run and "forced multiplexer backend" in stderr_at_run[0] + + +def test_run_tui_survives_junk_forced_backend(monkeypatch, tmp_path): + """A junk forced name makes selection raise MultiplexerError; the preflight + must swallow it (the same junk name still fails loudly at every real mux + call site) so the TUI itself can still come up.""" + from bmad_loop.adapters import multiplexer as mux_mod + from bmad_loop.tui import app as tui_app + + monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "no-such-backend") + mux_mod.get_multiplexer.cache_clear() + + ran: list[bool] = [] + + class _StubApp: + def __init__(self, _project): + pass + + def run(self): + ran.append(True) + + monkeypatch.setattr(tui_app, "BmadLoopApp", _StubApp) + try: + assert tui_app.run_tui(tmp_path) == 0 + finally: + mux_mod.get_multiplexer.cache_clear() + assert ran == [True] diff --git a/tests/test_tui_launch.py b/tests/test_tui_launch.py index 5841f897..7ee637fe 100644 --- a/tests/test_tui_launch.py +++ b/tests/test_tui_launch.py @@ -512,6 +512,12 @@ def test_return_attached_client_noop_when_unset(monkeypatch): def test_return_attached_client_noop_without_tmux(monkeypatch): + # This is a NEGATIVE gate test: the module-wide force_tmux_backend pin makes + # mux_usable trust the backend regardless of available(), so drop the pin + # (and the pinned selection) or — inside a real tmux session, TMUX set — + # the trusted path reaches display-message and shells out after all. + monkeypatch.delenv("BMAD_LOOP_MUX_BACKEND", raising=False) + get_multiplexer.cache_clear() ran: list = [] monkeypatch.setattr(tmux_base.shutil, "which", lambda name: None) monkeypatch.setattr(tmux_base.subprocess, "run", lambda *a, **k: ran.append(a))