Skip to content

feat(adapters): add native-Windows psmux multiplexer backend#181

Merged
pbean merged 4 commits into
bmad-code-org:mainfrom
dracic:feat/psmux-native-backend
Jul 19, 2026
Merged

feat(adapters): add native-Windows psmux multiplexer backend#181
pbean merged 4 commits into
bmad-code-org:mainfrom
dracic:feat/psmux-native-backend

Conversation

@dracic

@dracic dracic commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds PsmuxMultiplexer, a native-Windows transport backend driving psmux (Rust/ConPTY tmux re-implementation) — no WSL, no POSIX sh. The leaf drives the psmux binary exclusively (never the tmux.exe drop-in), so it cannot collide with another tmux-family port owning the tmux name on the same PATH (cf. #85).

_BINARY seam (base change)

BaseTmuxBackend gains a _BINARY = "tmux" class attribute threaded through the single spawn site (_run), the which() guards, available()/version(), the attach argv, the parked-trailer in-source verbs, and error-message prefixes. A tmux-family leaf whose binary is not literally named tmux overrides this one name instead of any method body. POSIX tmux behavior is byte-identical (default unchanged, existing tmux suite untouched).

A paired _ERRORS decode-errors attribute (default None = strict, POSIX-identical) lets the leaf set backslashreplace, so a stray non-UTF-8 ConPTY byte degrades one visible character instead of raising UnicodeDecodeError out of every capture.

PowerShell dialect (via the existing hooks)

  • Shell source ships as pwsh -NoProfile -EncodedCommand <base64 UTF-16LE> — psmux joins trailing argv and re-parses it through an outer shell, so embedded quoting does not survive; base64 has nothing to lose.
  • Per-window env rides an in-source $env:K = 'v' prelude (names validated, values single-quote-escaped) because psmux accepts new-window -e but silently drops it.
  • A teammate-clear prefix scrubs inherited CLAUDE_CODE_*/CLAUDECODE*/PSMUX_CLAUDE_TEAMMATE_MODE vars — psmux windows inherit the full env of whichever process cold-started the server.
  • new_session spawns with PSMUX_ALLOW_NESTING=1 (the documented nesting-guard bypass) on a scrubbed copy of the parent env; kill_session uses a plain -t name target (psmux ignores the =name exact-match form); pipe_pane ships a pwsh Add-Content sink.

Version gate

available() requires psmux + pwsh on PATH and the version reported by psmux -V (format tmux X.Y[.Z] — psmux keeps the tmux prefix deliberately for libtmux-style parsers; a missing patch segment reads as 0) to be strictly greater than 3.3.6:

  • psmux releases up to and including v3.3.6 taskkill recycled PIDs during pane/session teardown without a process-identity check and never reap orphaned servers. When a stale pane PID has been reused by the engine, psmux kills the engine mid-run; accumulated orphans exhaust the Windows desktop heap. Both are engine-fatal and were fixed upstream only after the v3.3.6 tag (psmux #446, #447, #448). The pipe-pane output tee was also fixed after v3.3.6 (psmux #440).
  • The gate fails closed (missing binary, raising probe, or unparseable output → unavailable) and the verdict is cached per instance.
  • A forced backend name (BMAD_LOOP_MUX_BACKEND=psmux or [mux] backend = "psmux") bypasses the probe — the documented escape hatch for from-main dev builds, which still report tmux 3.3.6.

Run preflight

Selection's last-resort fallback rung returns a platform-matched backend even when unusable (so diagnostics can name it). The run bootstrap now refuses an unusable backend outright with a bmad-loop diagnose pointer instead of driving a run on it; hookless HTTP/SSE-only configurations skip the multiplexer entirely; detached TUI launches honor the forced-name escape hatch the same way.

Docs

docs/porting-to-a-new-os.md, docs/adapter-authoring-guide.md, and docs/ROADMAP.md rewritten around the _BINARY seam and version-gated psmux-binary detection.

Testing

Important

Live testing MUST run against psmux built from main or the first release tagged after v3.3.6. The version gate deliberately rejects v3.3.6 (the current latest tag): on that release the backend reports unavailable by design, because 3.3.6's PID-reuse false-kill can terminate the engine mid-run. On a 3.3.6-only host the unit suite still passes and bmad-loop diagnose shows the gated version; to force the backend anyway (e.g. a from-main dev build, which still reports tmux 3.3.6), set BMAD_LOOP_MUX_BACKEND=psmux or [mux] backend = "psmux".

  • Windows 11, Python 3.13: PYTHONUTF8=1 pytest tests/test_psmux_backend.py tests/test_multiplexer.py tests/test_backend_registry.py tests/test_tui_launch.py tests/test_cli.py — green (the only failures on this host are pre-existing and unrelated: missing opencode extra / known skill-sync drift, identical on a clean tree).
  • Unit tests mock the single spawn seam and decode the -EncodedCommand payloads to pin source composition:
    • version-gate matrix: 3.3.6 and older rejected; 3.3.7 / 3.4 / 3.4.0 / 3.10.0 / 10.0 / suffixed 3.3.7-rc0 accepted; garbled or missing output fails closed; missing psmux/pwsh short-circuits without spawning; repeated polls spawn psmux -V once (cached verdict);
    • hostile env values (', newlines, $(...), backtick injection, quote breakout, empty, unicode) stay inert single-quoted literals with balanced quoting in the decoded payload; invalid env names are rejected before any spawn;
    • run-bootstrap preflight: unusable backend → refusal; forced name → trusted; hookless-only → multiplexer never resolved;
    • platform-default registry selection pinned for both win32 and POSIX legs independent of the host OS.
  • POSIX: existing tmux backend suite unchanged and green.
  • Lint: trunk check (ruff, black, isort, bandit, markdownlint, prettier) clean on all changed files.
  • A real-terminal smoke (parked trailer return, attach/switch-client targeting, pipe-pane tee) is deliberately deferred until a post-3.3.6 psmux tag exists to run it against, and is tracked as follow-up work.

Closes #58

Summary by CodeRabbit

  • New Features

    • Added a native Windows psmux terminal multiplexer backend (PowerShell-based), with version-gated availability and platform-aware default selection.
    • Forced backend selection now works reliably, including a “forced backend” warning surfaced during launch/TUI startup.
  • Bug Fixes

    • Improved multiplexer probing/usability gating (side-effect-free checks), clearer error messaging, and better handling when forced/availability states differ.
    • Updated tmux-family invocations to use the configured backend binary consistently.
  • Documentation

    • Refreshed Windows backend selection wording and expanded adapter/porting guidance to match new forced/probing behavior.

PsmuxMultiplexer is a thin leaf over BaseTmuxBackend driving the psmux
binary directly: PowerShell dialect hooks (shell source ships as
pwsh -EncodedCommand base64/UTF-16LE, per-window env rides an in-source
$env: prelude, a teammate-clear prefix strips the claude session vars
psmux windows inherit from the server cold-starter) plus the
probe-verified divergences — new-session needs PSMUX_ALLOW_NESTING=1
and a scrubbed create env, kill-session takes a plain -t target, and
pipe-pane gets a pwsh Add-Content sink.

The base grows a _BINARY class attribute threaded through the single
spawn site, the which() guards, available()/version(), the attach argv,
the parked-trailer verbs, and error prefixes, so a tmux-family leaf can
name its own binary; the paired _ERRORS decode-errors knob keeps POSIX
byte-identical (None default; the leaf sets backslashreplace).

available() probes psmux and pwsh only — no tmux drop-in on PATH — and
gates on the version reported by `psmux -V` (format `tmux X.Y.Z`) being
strictly greater than 3.3.6: older releases can force-kill a recycled
PID during pane teardown and leak orphaned servers, both engine-fatal
and fixed upstream only after the 3.3.6 tag. The verdict is cached per
instance; a forced backend name still bypasses the probe. The run
bootstrap refuses an unusable backend outright instead of letting
selection's last-resort fallback drive a run on one.

Unit tests mock the single spawn seam, decode the EncodedCommand
payloads to pin source composition, and cover the version-gate matrix
and the run-bootstrap preflight; the registry default is platform-aware
and the POSIX argv-shape tests pin the tmux backend explicitly.
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4a3807fc-e429-44b2-af69-8b23c43cb7cf

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

This change adds a native Windows PsmuxMultiplexer, generalizes tmux-family transport execution, introduces psmux availability/version gating, registers Windows backend selection, and updates CLI/TUI handling for lazy and forced multiplexer resolution.

Changes

Native Windows psmux transport

Layer / File(s) Summary
Transport abstraction and psmux execution
src/bmad_loop/adapters/tmux_base.py, src/bmad_loop/adapters/psmux_backend.py, tests/test_psmux_backend.py, tests/test_multiplexer.py
The shared tmux transport uses configurable binaries and decoding errors, while PsmuxMultiplexer adds PowerShell encoding, lifecycle operations, environment handling, piping, and focused tests.

Availability gates and registry selection

Layer / File(s) Summary
Availability gates and registry selection
src/bmad_loop/adapters/multiplexer.py, src/bmad_loop/adapters/psmux_backend.py, docs/ROADMAP.md, docs/porting-to-a-new-os.md, tests/test_backend_registry.py, tests/test_psmux_backend.py
Windows registers psmux as a builtin candidate, probes psmux and pwsh, rejects versions through 3.3.6, and tests platform defaults, fallback behavior, caching, and forced selection.

Lazy resolution and forced backend handling

Layer / File(s) Summary
Lazy resolution and forced backend handling
src/bmad_loop/cli.py, src/bmad_loop/tui/launch.py, src/bmad_loop/tui/app.py, src/bmad_loop/tui/data.py, src/bmad_loop/adapters/multiplexer.py, tests/test_cli.py, tests/test_tui_launch.py, tests/test_tui_app.py
CLI and TUI paths defer multiplexer construction until required, use mux_usable(), reject unusable unforced backends, and allow explicitly forced backends to bypass availability checks.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLIorTUI
  participant MultiplexerRegistry
  participant PsmuxMultiplexer
  participant pwsh
  participant psmux
  CLIorTUI->>MultiplexerRegistry: Resolve backend when multiplexer access is required
  MultiplexerRegistry->>PsmuxMultiplexer: Select Windows psmux candidate
  PsmuxMultiplexer->>psmux: Probe binary and version
  PsmuxMultiplexer->>pwsh: Encode PowerShell command
  PsmuxMultiplexer->>psmux: Execute tmux-style operation
  psmux-->>CLIorTUI: Return operation result
Loading

Possibly related PRs

Suggested reviewers: pbean

Poem

I’m a rabbit with PowerShell paws,
Hopping through tmux-shaped laws.
Psmux sessions bloom,
Safe versions clear room,
While forced paths leap past flaws.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 24.72% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding the native-Windows psmux multiplexer backend.
Linked Issues check ✅ Passed The PR implements the native-Windows psmux backend, version gating, registry changes, and test coverage described in #58.
Out of Scope Changes check ✅ Passed The changes stay focused on the psmux backend, supporting docs, and test coverage; no unrelated scope stands out.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/test_tui_launch.py (1)

195-195: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add raising=False to delenv.

If BMAD_LOOP_MUX_BACKEND happens to be unset in the testing environment, monkeypatch.delenv will raise a KeyError and fail the test. Adding raising=False safely ensures the variable is cleared without throwing an error if it doesn't exist.

♻️ Proposed fix
-    monkeypatch.delenv("BMAD_LOOP_MUX_BACKEND")
+    monkeypatch.delenv("BMAD_LOOP_MUX_BACKEND", raising=False)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_tui_launch.py` at line 195, Update the monkeypatch.delenv call for
BMAD_LOOP_MUX_BACKEND to pass raising=False, allowing the test setup to clear
the variable whether or not it is already defined.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/ROADMAP.md`:
- Around line 39-45: Update the roadmap entry to mark psmux as shipped/complete
while keeping tmux-windows identified as still in flight; preserve the existing
backend, availability, and selection details.

In `@tests/test_backend_registry.py`:
- Around line 83-93: Update test_default_matches_platform to stub
PsmuxMultiplexer and TmuxMultiplexer as available, then assert get_multiplexer()
selects each backend through the platform-default branch by checking the
selection reason rather than only the returned class. Preserve the separate
win32 and linux cases and clear the cached result between them.

---

Nitpick comments:
In `@tests/test_tui_launch.py`:
- Line 195: Update the monkeypatch.delenv call for BMAD_LOOP_MUX_BACKEND to pass
raising=False, allowing the test setup to clear the variable whether or not it
is already defined.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0e7ac277-a008-450d-81b6-340f576172b3

📥 Commits

Reviewing files that changed from the base of the PR and between db3e610 and ade13d0.

📒 Files selected for processing (13)
  • docs/ROADMAP.md
  • docs/adapter-authoring-guide.md
  • docs/porting-to-a-new-os.md
  • src/bmad_loop/adapters/multiplexer.py
  • src/bmad_loop/adapters/psmux_backend.py
  • src/bmad_loop/adapters/tmux_base.py
  • src/bmad_loop/cli.py
  • src/bmad_loop/tui/launch.py
  • tests/test_backend_registry.py
  • tests/test_cli.py
  • tests/test_multiplexer.py
  • tests/test_psmux_backend.py
  • tests/test_tui_launch.py

Comment thread docs/ROADMAP.md Outdated
Comment thread tests/test_backend_registry.py Outdated
The platform-default registry test previously passed even when selection
fell through to the fallback rung, which returns the same class for an
unavailable backend; stub availability and assert the selection reason
for both platform legs instead. Mark the psmux backend as shipped
in-tree on the roadmap, keeping tmux-windows as the in-flight sibling.
The TUI launch test's delenv now tolerates an already-unset override.
@dracic

dracic commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

Re the nitpick in the review summary (tests/test_tui_launch.py:195, delenv without raising=False): also fixed in a167102 — the override deletion now tolerates an environment where BMAD_LOOP_MUX_BACKEND is unset.

- one forced-aware usability gate (mux_usable) shared by the run preflight
  and every TUI observer (attach, ctl-window, liveness, prune); a pinned
  backend that probes unavailable proceeds with a one-time stderr warning
  instead of silently skipping the version gate
- verify psmux new-session actually created the session (exit-0 no-op belt)
- pipe_pane: raw-stream pwsh sink, byte-exact like the POSIX cat >> (no
  console decode / Add-Content re-encode / CRLF normalization); a failed
  pipe-pane attach now warns on stderr instead of passing silently
- unusable-backend diagnostics report the version and name the pwsh
  prerequisite
- tests: env isolation for the preflight tests; cover the policy-pinned
  forced leg, observer-gate parity, the real -V probe seam, the exit-0
  no-op, kill-session binary guard, and geometry-less new-session
@dracic

dracic commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

Ran a multi-agent review pass over this branch (correctness, test coverage, comments, silent failures, type design). Findings fixed in 6270cb9:

  • Forced/pinned backend silently bypassed the safety gates. bmad-loop mux set psmux (or the env var) skipped the version gate with no signal — and only 2 of the 6 availability gates honored the bypass, so a forced backend could launch runs the TUI then couldn't attach to, observe, or prune. There is now a single forced-aware gate (mux_usable()) shared by the run preflight and all TUI observers; a pinned-but-unavailable backend proceeds with a one-time stderr warning.
  • psmux pipe_pane sink wasn't byte-faithful. $input | Add-Content decoded pane bytes via the console code page and re-encoded on write — reintroducing exactly the mojibake the UTF-8 capture seam avoids. Replaced with a raw stream copy (byte-exact, like the POSIX cat >>). A failed pipe-pane attach also warns now instead of passing silently, so an empty run log is explainable.
  • new_session exit-0 no-op belt. psmux's nesting guard historically no-op'd -d with exit 0; the session is now verified after create so the failure blames session creation instead of the next verb's "can't find session".
  • Unusable-backend diagnostics now include the reported version and name the pwsh prerequisite (previously "binary missing or unsupported version" was wrong on both counts for a pwsh-less host).
  • Tests: env isolation for the preflight tests, plus coverage for the policy-pinned forced leg, observer-gate parity, the real -V probe seam, the exit-0 no-op, and the kill-session binary guard.

The live smoke on a real psmux > 3.3.6 build is still owed; pipe-pane actually feeding pane output to the sink's stdin is the first thing it should verify.

…err (PR bmad-code-org#181 review)

Two review findings on the mux_usable gate:

- run_tui now calls mux_usable() once before App.run(): Textual redirects
  sys.stderr for the app's whole run and the forced-unusable warning is
  once-per-process, so a first firing inside the app (any observer gate)
  would consume the single emission invisibly. Selection errors from a
  junk forced name stay swallowed here — they fail loudly at every real
  mux call site.

- test_return_attached_client_noop_without_tmux now drops the module-wide
  force_tmux_backend pin: the forced-aware gate trusts a pinned backend
  regardless of available(), so the "never shells out" path proceeded to
  current_window_id() — and inside a real tmux session (TMUX set: true on
  dev boxes, never in CI) display-message shelled out into the test's
  None-returning run stub. Mirrors the isolation a167102 gave
  test_launch_without_mux_raises.

@pbean pbean left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Full review + validation pass (diff, base interplay, upstream claims). Approving — the design is sound, the injection surface is tight, and the base changes are the minimum the leaf actually needs. Two defects found and fixed on the branch (commit pushed here), the rest is non-blocking notes.

Validated

  • _BINARY seam is necessary, not creep — a _run-only override (the previously documented route) can't reach the shutil.which("tmux") guards, attach_target_argv, or the in-source trailer verbs; threading the one attribute is the smaller diff. POSIX stays byte-identical (errors=None pinned by the new assert in test_run_posix_default_passes_no_encoding_and_no_env).
  • Lazy mux in _make_adapters is required, not optional: a hookless HTTP/SSE-only config on a psmux-less Windows host would otherwise be refused for a transport it never uses.
  • Teardown interplay (#157/#183/#184): safe by construction — generic.kill drives everything through the ProcessHost seam (win32 = taskkill + psutil identity checks), and the inherited window_pane_pids degrades to [] (escalation skipped) if psmux can't report #{pane_pid}.
  • Issue #58 fidelity: matches the live-probed 3.3.6 divergences; the deviations from #58's plan (drive the psmux binary instead of the tmux shim, add the version gate) are improvements and documented.
  • Upstream premise checked: v3.3.6 confirmed the latest tag; pipe-pane stub (psmux#440) confirmed fixed post-tag. One correction: psmux #446/#447/#448 are still open — fix commits landed on master after the tag (e.g. psmux/psmux@3cf9a07), but "were fixed upstream" overstates it; open issues mean possibly-partial fixes. The gate is right for every current release either way; the follow-up issue (see below) re-verifies completeness when the first post-3.3.6 tag lands.

Fixed on the branch

  1. The forced-backend warning could vanish entirely in the TUI. Textual captures sys.stderr for the app's whole run, and mux_usable's warning is once-per-process — so if the first trigger was a TUI observer gate (e.g. _session_liveness on the poll worker), the latch consumed the single emission invisibly. run_tui now trips the gate once before App.run(), while stderr is still the real terminal.
  2. test_return_attached_client_noop_without_tmux fails when the suite runs inside a real tmux session. The module-wide force_tmux_backend pytestmark sets BMAD_LOOP_MUX_BACKEND=tmux, which the new forced-aware gate now trusts regardless of available() — so the "never shells out" test proceeds to current_window_id(), and with TMUX set (any dev running pytest from inside tmux; never true in CI, which is why every leg is green) it shells out into the test's None-returning run stub → AttributeError. The test now drops the pin, mirroring the isolation a167102 added to test_launch_without_mux_raises. Worth remembering as a class: under the forced-aware gate, force_tmux_backend changes what negative tests exercise — any new "backend unavailable" test in a pinned module needs the same delenv.

Non-blocking notes

  • pipe_pane's POSIX base now warns on the benign died-on-launch race the comment itself describes as expected — accepted (an unexplainable empty run log is worse), just noting the tradeoff was seen.
  • The five remaining =-prefixed target sites (has-session — which the new new_session belt depends on — new-window, list-windows) ride on #58's live probes only having flagged kill-session; they're on the smoke checklist.
  • Follow-up issue filed with the full deferred-smoke checklist so Closes #58 doesn't orphan its item 6: #185.

@pbean

pbean commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Native-Windows psmux multiplexer backend: re-land as a thin leaf over the dialect seams

2 participants