Skip to content
Draft
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to `bmad-loop` are documented here. The format is based on
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the project is pre-1.0,
breaking changes may land in a minor release.

## [Unreleased]

### Added

- **Native Windows can now use `tmux-windows` through a dedicated transport backend.**
`WindowsTmuxMultiplexer` handles tmux-windows cwd semantics, passes inherited Windows
environment into agent panes so CLI binaries resolve, and uses PowerShell syntax for parked
windows. Pane pipe logging is a deliberate no-op on this backend because `tmux-windows`
can terminate the server when `pipe-pane` spawns a Windows command. POSIX tmux behavior
stays on the existing `TmuxMultiplexer`.

## [0.8.1] — 2026-07-05

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Inspired by the original [bmad-automator](https://github.com/bmad-code-org/bmad-
## Requirements

- **Python 3.11+**, **tmux**, and a supported coding CLI — `claude` by default; `codex` and `gemini` via [profiles](#other-coding-clis).
- **Linux or macOS** (or **Windows via WSL**, which _is_ Linux — it runs as-is). tmux is the one terminal-multiplexer backend today, but it now sits behind a pluggable **registry** of OS seams (transport, process lifecycle, hook interpreter), so a native-Windows backend slots in as new files + a registration line each, with no engine edits — see [Porting bmad-loop to a new OS](docs/porting-to-a-new-os.md). Native Windows is not yet shipped.
- **Linux or macOS**, **Windows via WSL**, or **native Windows with [`tmux-windows`](https://github.com/arndawg/tmux-windows)**. On native Windows, install tmux first (for example, `winget install --id arndawg.tmux-windows -e`) so `tmux.exe` is on PATH. The transport still sits behind a pluggable **registry** of OS seams (transport, process lifecycle, hook interpreter), so a future non-tmux native backend can slot in as new files + a registration line each, with no engine edits — see [Porting bmad-loop to a new OS](docs/porting-to-a-new-os.md).
- A **BMAD v6 project** (`_bmad/bmm/config.yaml`, a `sprint-status.yaml` from `bmad-sprint-planning`) with the upstream `bmad-dev-auto` skill and the bmad-loop skill module from this repo installed (`bmad-loop-resolve`, `bmad-loop-sweep` — see [Installing the skill module](#installing-the-skill-module)). Standard BMAD skills stay untouched.

## Quick start
Expand Down
13 changes: 7 additions & 6 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ Status legend: **planned** (agreed, not started) · **exploring** (shape still o

---

## Native Windows multiplexer backend
## Native Windows non-tmux multiplexer backend

**Status:** planned · **Foundation:** the full platform-seam series landed (multiplexer registry + `BaseTmuxBackend` + `ProcessHost` + hook interpreter + validate preflight, v0.7.6; original seam v0.7.0)
**Status:** planned · **Foundation:** the full platform-seam series landed (multiplexer registry + `BaseTmuxBackend` + `ProcessHost` + hook interpreter + validate preflight, v0.7.6; original seam v0.7.0); native Windows can currently run through the `WindowsTmuxMultiplexer` backend with `tmux-windows`.

The orchestrator no longer fuses tmux into the engine. All session/window/pane operations
go through a single `TerminalMultiplexer` ABC (`src/bmad_loop/adapters/multiplexer.py`),
Expand All @@ -28,12 +28,13 @@ host — so a new OS surfaces in preflight by registering, not by a `validate` e
plugin's `/proc`/`/tmp`/`cp -a`/symlink primitives degrade off Linux (with `psutil` from the
optional `non-linux` extra) and its pid lifecycle now delegates to `ProcessHost`; everything is
held by a CI portability guard (`tests/test_portability_guard.py`). **WSL already works today**
— it _is_ Linux, so it takes every fast path unchanged; this is purely about a future _native_
Windows host.
— it _is_ Linux, so it takes every fast path unchanged. Native Windows now has an interim
tmux-family backend for `tmux-windows`; this roadmap item is about a future native host that
does not depend on tmux.

The remaining work is a real non-tmux backend (a "psmux"-style multiplexer) that implements
the `TerminalMultiplexer` contract on native Windows and registers itself for `win32`. The
seams are designed so this slots in as **new files + one registration line each, with no
the `TerminalMultiplexer` contract on native Windows and can replace `windows-tmux` for `win32`
hosts. The seams are designed so this slots in as **new files + one registration line each, with no
change to the adapters, `runs.py`, `tui/launch.py`, `probe.py`, `tui/data.py`, or
`cli.py`'s `validate`** (`WindowsProcessHost` and its hook interpreter are already in place
and registered). The end-to-end port path — both build options, the test-override env vars,
Expand Down
21 changes: 11 additions & 10 deletions docs/adapter-authoring-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,15 @@ 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")
registers itself via `register_multiplexer(...)` and slots in behind
`get_multiplexer()`. The tmux-family backends share argv construction and the
single spawn primitive in `BaseTmuxBackend` (`adapters/tmux_base.py`), with thin
leaves for POSIX tmux (`adapters/tmux_backend.py`) and tmux-windows
(`adapters/tmux_windows_backend.py`). Together they are the **only** files
allowed to invoke `tmux`. A future non-tmux backend (e.g. a native-Windows
"psmux") 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
reference implementation. Transport is one of **four OS seams** — the others
`multiplexer.py` for the contract and the tmux-family files for reference.
Transport is one of **four OS seams** — the others
(process lifecycle, hook interpreter, validate preflight) are mapped in
[Porting bmad-loop to a new OS](porting-to-a-new-os.md).

Expand All @@ -45,8 +45,9 @@ through `get_multiplexer()` — not just the generic adapter but also `runs.py`
(session listing/tagging, kill, attach argv), `tui/launch.py` (the control
session and its parked orchestrator windows), `probe.py` (the throwaway probe
session), and `tui/data.py` (legacy-run liveness). A grep for `"tmux"` outside the
tmux backend (`adapters/tmux_base.py` + `adapters/tmux_backend.py`) should turn up
only `shutil.which("tmux")` presence checks, never an invocation.
tmux backends (`adapters/tmux_base.py`, `adapters/tmux_backend.py`, and
`adapters/tmux_windows_backend.py`) should turn up only `shutil.which("tmux")`
presence checks, never an invocation.

To add a backend, build a `TerminalMultiplexer` (`adapters/multiplexer.py`) and
**register** it — `register_multiplexer(name, matches, factory)`, where
Expand Down
17 changes: 7 additions & 10 deletions docs/porting-to-a-new-os.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,13 @@ fallback, so POSIX behavior is unchanged. (The result is cached — see

- **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
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) —
**without editing** `tmux_base.py` or its POSIX leaf `tmux_backend.py`
(`TmuxMultiplexer`). The one method-body override left is `pipe_pane`, whose
POSIX `cat >>` redirection is not behind a hook.
through one primitive, `_run(argv, *, check=..., env=...)`. The
`WindowsTmuxMultiplexer` leaf is the reference for a tmux-family non-POSIX
backend: it rewrites tmux-windows cwd arguments, injects inherited Windows env
into windows, uses PowerShell parked-window fragments, and deliberately no-ops
`pipe_pane` because tmux-windows can terminate the server when a pipe command
is spawned. A different tmux-like host can follow the same pattern without
editing `tmux_base.py` or the POSIX leaf `tmux_backend.py` (`TmuxMultiplexer`).
- **Implement `TerminalMultiplexer` fresh** 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.py` is the reference for what each method must produce.
Expand Down
11 changes: 8 additions & 3 deletions src/bmad_loop/adapters/multiplexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,14 @@ def _load_builtin_backends() -> None:
if _BUILTINS_LOADED:
return
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.
from .tmux_windows_backend import WindowsTmuxMultiplexer

# tmux-windows needs a small Windows leaf; POSIX tmux remains the default
# elsewhere. get_multiplexer still falls back to POSIX tmux when no backend
# matches, preserving the old safe fallback.
register_multiplexer(
"windows-tmux", lambda platform: platform == "win32", WindowsTmuxMultiplexer
)
register_multiplexer("tmux", lambda platform: platform != "win32", TmuxMultiplexer)
_BUILTINS_LOADED = True # set only after a successful import so a transient failure retries

Expand Down
176 changes: 176 additions & 0 deletions src/bmad_loop/adapters/tmux_windows_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""Windows tmux backend for the terminal-multiplexer seam.

This is an interim native-Windows transport for the tmux-windows port. It keeps
the generic adapter on the existing tmux contract while quarantining the
Windows-specific behavior that differs from POSIX tmux:

* tmux-windows rejects drive-qualified ``-c C:\\...`` paths, so tmux is spawned
from the requested cwd and receives ``-c .``.
* tmux-windows panes do not reliably inherit the parent PATH, so agent windows
explicitly receive the Windows environment needed to resolve CLI binaries.
* parked windows use PowerShell syntax instead of POSIX sh.
* pane logging is disabled because tmux-windows can terminate the server when
``pipe-pane`` spawns a Windows command.
"""

from __future__ import annotations

import os
import subprocess
from pathlib import Path, PureWindowsPath

from .tmux_base import TMUX_TIMEOUT_S, BaseTmuxBackend, TmuxError

_WINDOWS_ENV_NAMES = {
"APPDATA",
"COMSPEC",
"HOME",
"HOMEDRIVE",
"HOMEPATH",
"LOCALAPPDATA",
"PATH",
"PATHEXT",
"SYSTEMROOT",
"TEMP",
"TMP",
"USERPROFILE",
}


def _ps_quote(value: str) -> str:
return "'" + value.replace("'", "''") + "'"


def _powershell_exe() -> str:
system_root = os.environ.get("SystemRoot") or os.environ.get("SYSTEMROOT") or r"C:\Windows"
return str(Path(system_root) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe")


def _is_absolute_path(value: str) -> bool:
return Path(value).is_absolute() or PureWindowsPath(value).is_absolute()


def _merge_env(base: dict[str, str], override: dict[str, str]) -> dict[str, str]:
merged: dict[str, str] = {}
actual_key_by_upper: dict[str, str] = {}

for source in (base, override):
for key, value in source.items():
if not value:
continue
upper = key.upper()
old_key = actual_key_by_upper.get(upper)
if old_key is not None and old_key != key:
merged.pop(old_key, None)
merged[key] = value
actual_key_by_upper[upper] = key
return merged


class WindowsTmuxMultiplexer(BaseTmuxBackend):
"""tmux-windows backend.

The tmux argv contract remains inherited from :class:`BaseTmuxBackend`; this
leaf only adapts the spawn cwd, environment injection, and shell dialect.
"""

_ENCODING = "utf-8"

def _inherited_env(self) -> dict[str, str]:
inherited = {
key: value
for key, value in os.environ.items()
if key.upper() in _WINDOWS_ENV_NAMES and value
}
if not any(key.upper() == "SYSTEMROOT" for key in inherited):
inherited["SystemRoot"] = r"C:\Windows"
if not any(key.upper() == "COMSPEC" for key in inherited):
system_root = next(
value for key, value in inherited.items() if key.upper() == "SYSTEMROOT"
)
inherited["ComSpec"] = str(Path(system_root) / "System32" / "cmd.exe")
return inherited

def _normalize_tmux_argv(self, argv: list[str]) -> tuple[list[str], Path | None]:
rewritten = list(argv)
run_cwd: Path | None = None
for index, value in enumerate(rewritten[:-1]):
if value != "-c":
continue
raw_cwd = rewritten[index + 1]
if _is_absolute_path(raw_cwd):
run_cwd = Path(raw_cwd)
rewritten[index + 1] = "."
return rewritten, run_cwd

def _run(
self,
argv: list[str],
*,
check: bool = True,
env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
run_argv, run_cwd = self._normalize_tmux_argv(argv)
proc = subprocess.run(
["tmux", *run_argv],
capture_output=True,
text=True,
encoding=self._ENCODING,
env=env,
cwd=str(run_cwd) if run_cwd is not None else None,
timeout=TMUX_TIMEOUT_S,
)
if check and proc.returncode != 0:
raise TmuxError(f"tmux {' '.join(argv[:2])} failed: {proc.stderr.strip()}")
return proc

def _join_argv(self, argv: list[str]) -> str:
return subprocess.list2cmdline(argv)

def _source_prefix(self) -> str:
assignments = [
f"$env:{key} = {_ps_quote(value)}; "
for key, value in self._inherited_env().items()
]
return "".join(assignments)

_EXIT_CAPTURE = (
"$ec = if ($global:LASTEXITCODE -ne $null) { $global:LASTEXITCODE } "
"else { if ($?) { 0 } else { 1 } }"
)
_ECHO = "Write-Host"
_PARK = "[void][Console]::ReadLine()"

def _shell_wrap(self, source: str) -> list[str]:
return [_powershell_exe(), "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", source]

def _parked_trailer(self, return_opt: str) -> str:
quoted_return_opt = _ps_quote(return_opt)
detach = _ps_quote("detach")
return (
f"$ret = (& tmux show-options -wqv {quoted_return_opt} 2>$null); "
f"if ($ret -eq {detach}) {{ & tmux detach-client 2>$null }} "
"elseif ($ret) { "
"& tmux switch-client -t $ret 2>$null; "
"if ($global:LASTEXITCODE -ne 0) { & tmux switch-client -l 2>$null } "
"}"
)

def _window_launch(self, env: dict[str, str], command: str) -> list[str]:
launch_env = _merge_env(self._inherited_env(), env)
env_args: list[str] = []
for key, value in launch_env.items():
env_args += ["-e", f"{key}={value}"]
return [*env_args, command]

def pipe_pane(self, window_id: str, log_file: Path) -> None:
# tmux-windows 3.6a can exit the server when pipe-pane spawns either a
# PowerShell or cmd.exe pipe command. A missing pane log is less damaging
# than taking down the live agent session; hooks/window liveness still
# drive completion.
return None

def attach_target_argv(self, target: str) -> list[str]:
if os.environ.get("TMUX"):
return ["tmux", "switch-client", "-t", target]
return ["tmux", "attach", "-t", target]
11 changes: 10 additions & 1 deletion tests/test_backend_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from bmad_loop.adapters import multiplexer as m
from bmad_loop.adapters.multiplexer import MultiplexerError
from bmad_loop.adapters.tmux_backend import TmuxMultiplexer
from bmad_loop.adapters.tmux_windows_backend import WindowsTmuxMultiplexer


@pytest.fixture
Expand All @@ -34,12 +35,20 @@ def fresh_registry(monkeypatch):
m.get_multiplexer.cache_clear()


def test_default_is_tmux(fresh_registry):
def test_default_on_posix_is_tmux(fresh_registry, monkeypatch):
"""No override, POSIX host → tmux, selected via the loop's platform match (the
builtin registers ``matches=p != 'win32'``), not just the bottom fallback."""
monkeypatch.setattr(sys, "platform", "linux")
assert isinstance(fresh_registry.get_multiplexer(), TmuxMultiplexer)


def test_default_on_windows_is_windows_tmux(fresh_registry, monkeypatch):
"""Native Windows selects the tmux-windows backend instead of falling through
to the POSIX tmux backend."""
monkeypatch.setattr(sys, "platform", "win32")
assert isinstance(fresh_registry.get_multiplexer(), WindowsTmuxMultiplexer)


def test_env_override_selects_named_backend(fresh_registry, monkeypatch):
"""``BMAD_LOOP_MUX_BACKEND`` resolves a backend by name without monkeypatching
sys.platform. ``matches`` returns False here, so only the name path can pick it."""
Expand Down
Loading