diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ff2b2c..d0612c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 57f7066..5470fd3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index af81abb..30dea0c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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`), @@ -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, diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index ba17db2..97238ed 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -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). @@ -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 diff --git a/docs/porting-to-a-new-os.md b/docs/porting-to-a-new-os.md index 4a33271..6f1bfe8 100644 --- a/docs/porting-to-a-new-os.md +++ b/docs/porting-to-a-new-os.md @@ -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. diff --git a/src/bmad_loop/adapters/multiplexer.py b/src/bmad_loop/adapters/multiplexer.py index dc05c98..17492ee 100644 --- a/src/bmad_loop/adapters/multiplexer.py +++ b/src/bmad_loop/adapters/multiplexer.py @@ -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 diff --git a/src/bmad_loop/adapters/tmux_windows_backend.py b/src/bmad_loop/adapters/tmux_windows_backend.py new file mode 100644 index 0000000..7b87ff7 --- /dev/null +++ b/src/bmad_loop/adapters/tmux_windows_backend.py @@ -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] diff --git a/tests/test_backend_registry.py b/tests/test_backend_registry.py index 25e4654..1fe92d7 100644 --- a/tests/test_backend_registry.py +++ b/tests/test_backend_registry.py @@ -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 @@ -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.""" diff --git a/tests/test_multiplexer.py b/tests/test_multiplexer.py index a67062e..93c2a31 100644 --- a/tests/test_multiplexer.py +++ b/tests/test_multiplexer.py @@ -11,15 +11,17 @@ import os import shlex import subprocess +from pathlib import Path import pytest -from bmad_loop.adapters import tmux_base +from bmad_loop.adapters import tmux_base, tmux_windows_backend from bmad_loop.adapters.base import SessionSpec from bmad_loop.adapters.generic import GenericAdapter from bmad_loop.adapters.multiplexer import MultiplexerError, TerminalMultiplexer from bmad_loop.adapters.profile import get_profile from bmad_loop.adapters.tmux_backend import TmuxMultiplexer +from bmad_loop.adapters.tmux_windows_backend import WindowsTmuxMultiplexer from bmad_loop.policy import LimitsPolicy, Policy @@ -335,6 +337,79 @@ def test_run_custom_env_is_forwarded_without_leaking(monkeypatch): assert dict(os.environ) == before +def test_windows_tmux_rewrites_absolute_cwd_to_dot_and_subprocess_cwd(monkeypatch): + rec = _RecordRun() + monkeypatch.setattr(tmux_windows_backend.subprocess, "run", rec) + + cwd = Path(r"C:\DevProjects\zerg") + WindowsTmuxMultiplexer().new_session("s", cwd, 120, 40) + + assert rec.argv == [ + "tmux", + "new-session", + "-d", + "-s", + "s", + "-c", + ".", + "-x", + "120", + "-y", + "40", + ] + assert rec.kwargs["cwd"] == str(cwd) + + +def test_windows_tmux_new_window_injects_parent_path(monkeypatch): + rec = _RecordRun() + monkeypatch.setattr(tmux_windows_backend.subprocess, "run", rec) + monkeypatch.setenv("PATH", r"C:\tools") + monkeypatch.setenv("PATHEXT", ".COM;.EXE;.BAT;.CMD") + + WindowsTmuxMultiplexer().new_window( + "s", + "n", + Path(r"C:\DevProjects\zerg"), + {"BMAD_LOOP_RUN_DIR": "run-dir", "PATH": r"C:\override"}, + "codex --version", + ) + + env_flags = [rec.argv[index + 1] for index, arg in enumerate(rec.argv) if arg == "-e"] + assert "PATH=C:\\override" in env_flags + assert "BMAD_LOOP_RUN_DIR=run-dir" in env_flags + assert any(flag.upper().startswith("PATHEXT=") for flag in env_flags) + assert rec.argv[-1] == "codex --version" + + +def test_windows_tmux_parked_window_uses_powershell_dialect(monkeypatch): + rec = _RecordRun() + monkeypatch.setattr(tmux_windows_backend.subprocess, "run", rec) + monkeypatch.setenv("PATH", r"C:\tools") + + WindowsTmuxMultiplexer().new_parked_window( + "s", "n", Path(r"C:\DevProjects\zerg"), ["bmad-loop", "validate"], "%3" + ) + + assert "sh" not in rec.argv + assert "-Command" in rec.argv + source = rec.argv[-1] + assert "$env:PATH" in source + assert "bmad-loop validate" in source + assert "[void][Console]::ReadLine()" in source + + +def test_windows_tmux_pipe_pane_is_noop_to_avoid_tmux_windows_server_crash( + monkeypatch, tmp_path +): + rec = _RecordRun() + monkeypatch.setattr(tmux_windows_backend.subprocess, "run", rec) + log_file = tmp_path / "pane.log" + + WindowsTmuxMultiplexer().pipe_pane("@1", log_file) + + assert rec.argv == [] + + # ------------------------------------------------------- shell-dialect seam # # new_window / new_parked_window keep the tmux argv construction and the diff --git a/tests/test_portability_guard.py b/tests/test_portability_guard.py index ee03640..d9a8ed6 100644 --- a/tests/test_portability_guard.py +++ b/tests/test_portability_guard.py @@ -29,7 +29,11 @@ # tmux/POSIX-shell knowledge, split across the shared base (where the spawn # primitive + argv live) and its POSIX leaf. No per-line ack needed: these files # *are* the sanctioned spot (their module docstrings say so). -TMUX_BACKENDS = {"adapters/tmux_base.py", "adapters/tmux_backend.py"} +TMUX_BACKENDS = { + "adapters/tmux_base.py", + "adapters/tmux_backend.py", + "adapters/tmux_windows_backend.py", +} # Platform-guarded files that may name a bare POSIX path, each on a line carrying # a `# portability:` ack (and guarded by a sys.platform branch). process_host.py's diff --git a/tests/test_tui_launch.py b/tests/test_tui_launch.py index 27f797f..a9aeb2c 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 @@ -40,9 +41,12 @@ def by_verb(self, verb: str) -> list[list[str]]: @pytest.fixture def fake_run(monkeypatch) -> FakeRun: fake = FakeRun() + monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "tmux") + get_multiplexer.cache_clear() monkeypatch.setattr(tmux_base.subprocess, "run", fake) monkeypatch.setattr(tmux_base.shutil, "which", lambda name: f"/usr/bin/{name}") - return fake + yield fake + get_multiplexer.cache_clear() def expected_cli(*tail: str) -> str: