Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,14 @@ 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 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
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("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 <name>` tie-break.

The third — **herdr** — has **shipped** end-to-end on POSIX (engine run path +
Expand Down
12 changes: 7 additions & 5 deletions docs/adapter-authoring-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 30 additions & 16 deletions docs/porting-to-a-new-os.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).)

Expand Down Expand Up @@ -100,9 +102,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) —
Expand Down Expand Up @@ -137,32 +141,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 <name>`.
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 <name>`.

A backend from a **different binary family** sidesteps this problem entirely.
The external herdr adapter probes `shutil.which("herdr")` — a distinct binary
Expand Down
76 changes: 63 additions & 13 deletions src/bmad_loop/adapters/multiplexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -292,22 +292,27 @@ 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) —
mirroring ``process_host._load_builtin_hosts``."""
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


Expand Down Expand Up @@ -390,6 +395,52 @@ 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 — 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)``.

Expand All @@ -415,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:
Expand Down
Loading