bmad-loop runs on Linux and macOS today, and on Windows via WSL (which is Linux — every fast path works unchanged). A native Windows host, or any other OS, is not yet shipped. This guide is the map for adding one.
The OS-specific work is quarantined behind four seams. Porting to a new OS is
new files plus one registration line per seam — no edits to the bodies of the
core .py modules or their call sites. Each seam selects its implementation by
platform from a registry, with an env-var override for tests.
| # | Seam | Contract / registry | Override env var |
|---|---|---|---|
| 1 | Terminal multiplexer | TerminalMultiplexer / register_multiplexer |
BMAD_LOOP_MUX_BACKEND (or bmad-loop mux set <name>) |
| 2 | Process lifecycle | ProcessHost / register_process_host |
BMAD_LOOP_PROCESS_HOST |
| 3 | Hook interpreter | ProcessHost.hook_interpreter() |
(rides on seam 2) |
| 4 | Validate preflight | _platform_preflight(project) (no new code — reads seams 1–2) |
— |
The one bundled caveat: a backend you ship in this repo needs its import
added to the relevant _load_builtin_* loader so it self-registers (one line). An
out-of-tree backend skips even that — it registers at its own import time.
This guide covers porting the OS axis (transport + process lifecycle). The orthogonal CLI axis — teaching bmad-loop a new coding CLI — lives in the adapter authoring guide. The deep transport contract (every method a multiplexer must implement) also lives there; this guide links to it rather than duplicating it.
Contract: TerminalMultiplexer (src/bmad_loop/adapters/multiplexer.py) — how
sessions, windows, and panes are created, observed, and torn down. Everything that
touches a session goes through get_multiplexer(); nothing else shells out to a
multiplexer directly.
Register at import time:
from bmad_loop.adapters.multiplexer import register_multiplexer
register_multiplexer("psmux", lambda platform: platform == "win32", PsmuxMultiplexer)register_multiplexer(name, matches, factory):
name— the key theBMAD_LOOP_MUX_BACKENDoverride selects by.matches(sys.platform) -> bool— decides automatic selection.factory() -> TerminalMultiplexer— builds the backend.
get_multiplexer() resolves by precedence:
BMAD_LOOP_MUX_BACKEND— forces a backend by name (per-invocation).[mux] backendin.bmad-loop/policy.toml— the persisted, machine-scoped choice (bmad-loop initgitignores policy.toml, so it never reaches teammates). Set it withbmad-loop mux set <name>.- The platform default — win32:
psmux, everywhere else:tmux— when that backend is registered andavailable(). - The first registered backend whose
matches(sys.platform)is true and whoseavailable()reports usable (registration order breaks ties). - The historical fallback: first platform match regardless of availability,
bottoming out at tmux — so a POSIX host without tmux still selects
TmuxMultiplexerandvalidatereports it unavailable.
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. 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.)
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:
[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.
- Extend
BaseTmuxBackend(adapters/tmux_base.py) for a tmux-family backend.BaseTmuxBackendholds 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_BINARYclass attribute to the binary it drives (every spawn, PATH probe, and in-source client verb follows it) and the_ENCODINGclass attribute for output decoding (e.g."utf-8"), and passes a per-callenv=where needed — overriding_run()itself only to tweak the timeout — plus the shell-dialect hooks thatnew_window/new_parked_windowcompose from (_shell_wrap,_join_argv,_parked_trailer,_source_prefix,_window_launchand the_EXIT_CAPTURE/_ECHO/_PARKfragments) — without editingtmux_base.pyor its POSIX leaftmux_backend.py(TmuxMultiplexer). The one method-body override left ispipe_pane, whose POSIXcat >>redirection is not behind a hook. - Implement
TerminalMultiplexerfresh when the host has no tmux-shaped CLI at all (e.g. a ConPTY-based window manager). You implement the full contract directly;tmux_backend.pyis the reference for what each method must produce. The reference worked example is the external herdr adapter (pbean/bmad-loop-adapter-herdr,src/bmad_loop_adapter_herdr/backend.py) — a cross-platform, agent-aware workspace manager whose object model (workspace/tab/pane) and CLI are a different binary family entirely, so it subclasses nothing and maps the whole contract onto herdr verbs: a bmad-loop session is a herdr workspace (label == session name), a window is a tab (itsroot_pane.pane_idis the native window id), and the launched command runs via a typedexec <argv>so process-exit stays tmux-identical window death. Where herdr has no analogue for a contract method — options,pipe_pane, the parked-window return hop, detach — it emulates or degrades honestly (a JSON sidecar for options, a polling tee forpipe_pane, a per-window return file for the parked trailer, a no-op detach); that degradation ledger is the module docstring, and it is the template for what "implement fresh" costs in practice. (The operator-facing view of those degradations — what a herdr user notices and does — is the adapter's operator guide.)
available() gates whether the backend is usable on the current host (e.g. its
binary is on PATH); the optional version() feeds bmad-loop mux, the diagnostic
dump, and the validate preflight (seam 4).
version() returns one bounded line. Every one of those consumers renders it
inline — a table row whose width sets every other row's, a finding message, a
scalar --json field — so a binary whose --version prints several lines (psmux
prints a tmux X.Y.Z compatibility line plus its own) folds them in the backend,
and a very long single line breaks the same surfaces a newline does. Use
fold_version() from adapters/multiplexer.py: it joins the non-blank lines with
"; " in order, caps the result at VERSION_MAX_CHARS, and returns None —
the "no version" sentinel, never "" — for an all-blank probe. Order is
load-bearing wherever something parses the string: psmux's version gate anchors at
the first segment, and the cap only ever cuts the tail. Core applies the same fold
defensively at each consumer, so breaking the promise cannot split a mux row —
but fold at the source, since only the backend knows which line identifies it.
Selection consults available(), so it must be a cheap, side-effect-free
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 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):
class PsmuxMultiplexer(BaseTmuxBackend):
_BINARY = "psmux"
def available(self) -> bool:
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)
# 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 NoneDo 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
that no tmux-family backend claims — so it is pairwise-discriminating by
construction: it can never report available on a host where only tmux is
installed, and vice versa, without any explicit tie-break. That available()
must stay a pure PATH lookup (it is called by detect_multiplexers() on every
listing) — the herdr adapter in particular never probes or starts its
background server from available(), version(), or the constructor; server
autostart is lazy, confined to the mutating operations that actually need it.
Deep contract → adapter authoring guide: the transport contract for a backend author.
Contract: ProcessHost (src/bmad_loop/process_host.py) — the four pid
operations the orchestrator needs (runs.stop_run, the TUI liveness column), plus
the hook interpreter (seam 3). On POSIX these are os.kill calls; on Windows
taskkill / psutil. WindowsProcessHost already ships (unexercised until a
Windows backend lands).
Register like the multiplexer:
from bmad_loop.process_host import register_process_host
register_process_host("windows", lambda platform: platform == "win32", WindowsProcessHost)get_process_host() selects by the same rule as get_multiplexer();
BMAD_LOOP_PROCESS_HOST forces one by name; POSIX is the default fallback.
Implement:
terminate(pid)— politely stop it (POSIXSIGTERM/ Windowstaskkill). Raise theOSErrorfamily (ProcessLookupError/PermissionError) so callers keep their "already gone / not ours" handling.force_kill(pid)— escalation whenterminateis ignored (POSIXSIGKILL/ Windowstaskkill /F /T). Only ever called once identity is confirmed.is_alive(pid)— read-only liveness probe, no signal sent.identity(pid) -> float | None— the PID-reuse guard: a value that stays constant for the life ofpidbut changes if the pid is reused (Linux reads/proc/<pid>/statstart-time; elsewhere psutil'screate_time()). ReturnNonewhere the platform can't provide one — callers then refuse to force-kill rather than risk an unrelated process that inherited the pid.hook_interpreter()— seam 3, below.
ProcessHost.hook_interpreter() is the command prefix that install / probe
interpolate into the hook registrations they write (the script path and canonical
event are appended by the caller). It exists so hook registration never branches
on sys.platform at the call site:
- POSIX returns
"python3"(the interpreter on PATH). WindowsProcessHostreturns"uv run --no-project python"— Windows ships nopython3launcher, and--no-projectresolves an interpreter without activating a project venv (hooks fire detached).
A new OS overrides this on its ProcessHost; nothing else changes.
_platform_preflight(project) (src/bmad_loop/cli.py, called from cmd_validate)
asks the selected multiplexer for its available() / version() and names the
selected process host. A new OS therefore surfaces its readiness in bmad-loop validate by registering (seams 1–2) — not by adding a win32 block to
validate. The process host is named in the output so a misselection (e.g. the
Windows host picked on Linux) is visible at a glance.
There is no new code to write for this seam — it reads seams 1 and 2.
The one sys.platform branch that does live here is not a port seam and is not a
precedent for one: the host.win32-on-wsl-path check (#332) reports that the interpreter
itself is the wrong build for the shell that launched it — a native-Windows
bmad-loop reached from a WSL prompt. No registration can express that, because
every seam is correctly selected for the interpreter that is running; what is wrong
is which interpreter the operator got. Readiness questions still register.
Plugin helper scripts (e.g. the bundled Unity plugin's unity_setup.py /
unity_teardown.py) are spawned under the orchestrator's own interpreter via
sys.executable, not a PATH-resolved python3. The practical consequence: a
bundled helper script may import bmad_loop — so for pid lifecycle it should
use the seam rather than re-implement kill/liveness behind its own
sys.platform guards:
from bmad_loop.process_host import get_process_host
host = get_process_host()
host.terminate(pid)
if host.is_alive(pid):
host.force_kill(pid)Worked example: data/plugins/unity/unity_teardown.py now delegates its
SIGTERM→SIGKILL sweep of leaked Editor / MCP-server processes to
get_process_host() instead of calling os.kill / signal.SIGKILL itself — so it
gains Windows behavior for free when a Windows host registers. (It still does its
own worktree-bound process discovery via /proc with a psutil fallback, because
discovery has no seam — see the next section.)
Out-of-tree plugin scripts distributed outside this repo can't assume
bmad_loopis importable in every install; the import path is reliable for bundled scripts spawned undersys.executable.
tests/test_portability_guard.py AST-scans src/bmad_loop and fails CI if a new
hard POSIX dependency creeps in outside an allowlist — a ["tmux", …] argv outside
the tmux backend, a bare os.kill(pid, 0) outside the liveness helpers, an
unguarded signal.SIGKILL, a hardcoded /tmp / /proc / /dev/null,
start_new_session=True, or shell=True. When you add a seam, route the OS call
through it rather than widening an allowlist; the few sanctioned exceptions
(the quarantine files, the platform-guarded discovery helpers) carry a
# portability: ack on the line.
Things without a seam still need a hand-guarded fallback behind a
sys.platform branch with that ack: cp --reflink / CoW copies, symlinks,
/proc scanning, /tmp, and start_new_session. Keep the Linux fast path
byte-identical; the new-OS branch can be best-effort until exercised.
Both get_multiplexer() and get_process_host() are lru_cached, and selection
keys off sys.platform. To exercise a not-yet-default backend on your dev box:
| To force… | Set | Then clear the cache |
|---|---|---|
| a multiplexer | BMAD_LOOP_MUX_BACKEND=psmux |
get_multiplexer.cache_clear() |
| a process host | BMAD_LOOP_PROCESS_HOST=windows |
get_process_host.cache_clear() |
The env var picks the registered backend by name; cache_clear() is required
because the first call memoizes the selection for the process. To make a
multiplexer choice stick across invocations instead, persist it with
bmad-loop mux set <name> (writes [mux] backend into the machine-local
policy.toml; the env var still outranks it, and configure_multiplexer /
register_multiplexer clear the cache themselves).
Concretely, a native-Windows port is:
PsmuxMultiplexer(BaseTmuxBackend)(or a freshTerminalMultiplexer) + itsregister_multiplexer("psmux", …).WindowsProcessHost— already shipped — needs only its registration, which is already present in_load_builtin_hosts. Itshook_interpreter()(uv run --no-project python) is in place too.- A CI runner on Windows to exercise the above.
No edits to the adapters, runs.py, tui/launch.py, probe.py, tui/data.py,
cli.py's validate, or the POSIX seam bodies. The remaining design questions
(what hosts the windows, how attach/detach and the parked exit-status window map
without a POSIX shell, the Windows-Unity cache-path follow-up) are tracked in
the roadmap.