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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ breaking changes may land in a minor release.

### Added

- **Out-of-tree multiplexer backends (`bmad_loop.mux_backends` entry points).** A backend
package installed next to bmad-loop (e.g. `uv tool install bmad-loop --with <adapter>`) now
registers itself with no config step: before every selection, core imports each module
advertised under the `bmad_loop.mux_backends` entry-point group, whose import-time
`register_multiplexer(...)` call makes the backend selectable exactly like a bundled one
(builtins load first, so default selection is unchanged by installing an adapter). A package
that fails to import can never break selection — the failure is recorded and surfaced as a
`warning:` line by `bmad-loop mux` and a note in the `validate` preflight
(`external_backend_errors()`).

- **Unity modal-dialog guards (`[plugins.unity]`).** A chronically-dirty Unity scene raises modal
Editor dialogs ("scene changed on disk", "save changes before closing") that freeze the MCP
dispatch loop and stall the whole run. The bundled Unity plugin now defends in depth: it seeds an
Expand Down
7 changes: 6 additions & 1 deletion docs/adapter-authoring-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,12 @@ gitignored) → the platform default when registered and `available()` → the
first available platform match → the historical tmux fallback. `bmad-loop mux`
lists every registered backend and the selection; same-platform backends need
discriminating `available()` probes (see the
[porting guide](porting-to-a-new-os.md#availability-discriminators-same-platform-backends)). There are two build paths: extend `BaseTmuxBackend` (`adapters/tmux_base.py`)
[porting guide](porting-to-a-new-os.md#availability-discriminators-same-platform-backends)).
An out-of-tree backend package makes its registration run by advertising the
module under the `bmad_loop.mux_backends` entry-point group — core imports it
before every selection, so co-installing the package is the whole setup (see
[the porting guide](porting-to-a-new-os.md#shipping-out-of-tree-the-bmad_loopmux_backends-entry-point);
a broken package degrades to a `bmad-loop mux` warning, never a selection failure). There are two build paths: extend `BaseTmuxBackend` (`adapters/tmux_base.py`)
for a tmux-family backend — overriding only its single spawn primitive `_run()`
plus the shell-dialect hooks (`_shell_wrap`, `_join_argv`, `_parked_trailer`,
`_source_prefix`, `_window_launch` and the `_EXIT_CAPTURE`/`_ECHO`/`_PARK`
Expand Down
24 changes: 24 additions & 0 deletions docs/porting-to-a-new-os.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,30 @@ backend. `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).)

### Shipping out-of-tree: the `bmad_loop.mux_backends` entry point

How does the registration snippet above ever _run_ when the backend lives in its
own package? Advertise the module in the package's `pyproject.toml`:

```toml
[project.entry-points."bmad_loop.mux_backends"]
psmux = "my_package.backend"
```

Before every selection, core scans that entry-point group and imports each
advertised module (builtins first, so tmux keeps first registration and the
precedence above is unchanged); the module's top-level `register_multiplexer(...)`
call does the rest. Installing the package into bmad-loop's environment — e.g.
`uv tool install bmad-loop --with <your-adapter>` — is the entire setup; no core
edit, no config step. The entry-point _value_ is a bare module path (core only
imports it; the name is just a diagnostic label).

A package that fails to import can never break selection: the failure is
recorded and reported by `bmad-loop mux` (a `warning:` line under the table) and
the `validate` preflight, and selection proceeds without it. The reference
out-of-tree adapter is
[bmad-loop-adapter-herdr](https://github.com/pbean/bmad-loop-adapter-herdr).

### Two build paths

- **Extend `BaseTmuxBackend`** (`adapters/tmux_base.py`) for a **tmux-family**
Expand Down
51 changes: 49 additions & 2 deletions src/bmad_loop/adapters/multiplexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
``TerminalMultiplexer`` is the contract a backend author implements. Operation
names mirror today's call sites verbatim so the migration is mechanical. Backends
register themselves through :func:`register_multiplexer` (bundled ones from
:func:`_load_builtin_backends`, out-of-tree ones at import time); the process-wide
backend is selected by registry and returned by :func:`get_multiplexer`.
:func:`_load_builtin_backends`; out-of-tree ones at import time, triggered by the
``bmad_loop.mux_backends`` entry-point scan in :func:`_load_external_backends` —
so a pip/uv co-installed adapter package is selectable with no config step); the
process-wide backend is selected by registry and returned by :func:`get_multiplexer`.

Selection precedence (issue #87): the ``BMAD_LOOP_MUX_BACKEND`` env var, then the
policy ``[mux] backend`` choice (installed once per CLI invocation via
Expand All @@ -25,6 +27,7 @@
from __future__ import annotations

import functools
import importlib.metadata
import os
import sys
from abc import ABC, abstractmethod
Expand Down Expand Up @@ -303,6 +306,48 @@ def _load_builtin_backends() -> None:
_BUILTINS_LOADED = True # set only after a successful import so a transient failure retries


# The entry-point group an out-of-tree backend package advertises its module
# under; importing the module runs its register_multiplexer call. Loader state:
# scanned-once flag + per-entry-point failure reasons for mux/validate to show.
MUX_BACKENDS_GROUP = "bmad_loop.mux_backends"
_EXTERNALS_LOADED = False
_EXTERNAL_ERRORS: dict[str, str] = {}


def _load_external_backends() -> None:
"""Import every ``bmad_loop.mux_backends`` entry point; each module
self-registers via :func:`register_multiplexer` at import time. Called after
:func:`_load_builtin_backends`, so builtins keep first registration (tmux
stays first-wins on a name collision) and selection precedence is unchanged.

A broken third-party distribution must never break backend selection:
failures are recorded in ``_EXTERNAL_ERRORS`` (surfaced by ``bmad-loop mux``
and the ``validate`` preflight via :func:`external_backend_errors`), not
raised. Unlike ``_BUILTINS_LOADED``, the loaded-flag is set up front: a
third-party import failure is not transient, and retrying on every
selection would re-import (and re-fail) each time."""
global _EXTERNALS_LOADED
if _EXTERNALS_LOADED:
return
_EXTERNALS_LOADED = True
try:
eps = importlib.metadata.entry_points(group=MUX_BACKENDS_GROUP)
except Exception as exc: # noqa: BLE001 — diagnostics path, never crash selection
_EXTERNAL_ERRORS["<entry-point scan>"] = f"{type(exc).__name__}: {exc}"
return
for ep in eps:
try:
ep.load() # module import runs register_multiplexer(...)
except Exception as exc: # noqa: BLE001 — one bad package must not hide the rest
_EXTERNAL_ERRORS[ep.name] = f"{type(exc).__name__}: {exc}"


def external_backend_errors() -> dict[str, str]:
"""Entry-point name -> failure reason for every external backend that failed
to load this process (empty when all loaded). For diagnostics surfaces."""
return dict(_EXTERNAL_ERRORS)


def configure_multiplexer(name: str | None, *, origin: Path | None = None) -> None:
"""Install the policy ``[mux] backend`` choice (``None``/``""`` = auto).

Expand Down Expand Up @@ -359,6 +404,7 @@ def _select() -> tuple[TerminalMultiplexer, str, str]:
can't run. A forced name matching nothing is a misconfiguration; never
silently fall back to tmux (wrong/unsafe on a non-POSIX host)."""
_load_builtin_backends()
_load_external_backends()
forced = os.environ.get("BMAD_LOOP_MUX_BACKEND")
if forced:
factory = _factory_by_name(forced)
Expand Down Expand Up @@ -442,6 +488,7 @@ def detect_multiplexers() -> list[MuxBackendInfo]:
every registered backend, so factories must stay cheap, side-effect-free
constructors (true of the tmux family)."""
_load_builtin_backends()
_load_external_backends()
try:
_, selected_name, reason = _select()
except MultiplexerError:
Expand Down
23 changes: 21 additions & 2 deletions src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,11 @@ def _platform_preflight() -> tuple[list[str], list[str]]:
``sys.platform`` branch to validate. The process host is named so a
misselection (e.g. the Windows host picked on Linux) is visible at a glance.
"""
from .adapters.multiplexer import detect_multiplexers, get_multiplexer
from .adapters.multiplexer import (
detect_multiplexers,
external_backend_errors,
get_multiplexer,
)
from .process_host import get_process_host

notes: list[str] = []
Expand Down Expand Up @@ -196,6 +200,12 @@ def _platform_preflight() -> tuple[list[str], list[str]]:
if chosen and chosen.reason in ("env", "policy"):
notes.append(f"multiplexer selection {_mux_reason_label(chosen.reason)}")

# Advisory, not a problem: selection already degraded past the broken
# package (a failed external can never be the selected backend), so the
# preflight outcome above is authoritative — this line explains the absence.
for ep_name, reason in sorted(external_backend_errors().items()):
notes.append(f"external mux backend '{ep_name}' failed to load: {reason}")

try:
notes.append(f"process host: {type(get_process_host()).__name__}")
except Exception as e: # noqa: BLE001 — a bad BMAD_LOOP_PROCESS_HOST must report, not crash
Expand Down Expand Up @@ -323,7 +333,12 @@ def cmd_mux(args: argparse.Namespace) -> int:
"""List registered terminal-multiplexer backends and the selection, or
persist a machine-scoped choice (`mux set <name>` / `mux set --clear`) into
.bmad-loop/policy.toml. Never prompts — runs are unattended."""
from .adapters.multiplexer import MultiplexerError, detect_multiplexers, get_multiplexer
from .adapters.multiplexer import (
MultiplexerError,
detect_multiplexers,
external_backend_errors,
get_multiplexer,
)

project = _project(args)
if args.action == "set":
Expand All @@ -347,6 +362,10 @@ def cmd_mux(args: argparse.Namespace) -> int:
widths = [max(len(h), *(len(row[i]) for row in table), 0) for i, h in enumerate(header)]
for row in (header, *table):
print(" ".join(cell.ljust(w) for cell, w in zip(row, widths)).rstrip())
# A failed external package is invisible in the table (it never registered),
# so name it here — the one place an operator looks when a backend is missing.
for ep_name, reason in sorted(external_backend_errors().items()):
print(f"warning: external backend '{ep_name}' failed to load: {reason}", file=sys.stderr)
print(
"override: BMAD_LOOP_MUX_BACKEND env var, or `bmad-loop mux set <name>` "
f"(persists to {POLICY_FILE})"
Expand Down
11 changes: 11 additions & 0 deletions tests/test_backend_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,25 @@ def fresh_registry(monkeypatch):
saved_backends = list(m._BACKENDS)
saved_loaded = m._BUILTINS_LOADED
saved_configured = m._CONFIGURED
saved_ext_loaded = m._EXTERNALS_LOADED
saved_ext_errors = dict(m._EXTERNAL_ERRORS)
m._BACKENDS.clear()
m._BUILTINS_LOADED = False
m._CONFIGURED = None
# Externals stay OFF by default (the flag reads "already scanned"): these
# tests pin builtin selection, and a real entry-point scan here would let
# whatever adapters happen to be installed on the dev box leak in. The
# discovery tests opt back in by resetting the flag themselves.
m._EXTERNALS_LOADED = True
m._EXTERNAL_ERRORS.clear()
m.get_multiplexer.cache_clear()
yield m
m._BACKENDS[:] = saved_backends
m._BUILTINS_LOADED = saved_loaded
m._CONFIGURED = saved_configured
m._EXTERNALS_LOADED = saved_ext_loaded
m._EXTERNAL_ERRORS.clear()
m._EXTERNAL_ERRORS.update(saved_ext_errors)
m.get_multiplexer.cache_clear()


Expand Down
Loading
Loading