feat(adapters): add native-Windows psmux multiplexer backend#181
Conversation
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.
|
Important Review skippedNo new commits to review since the last review. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
WalkthroughThis change adds a native Windows ChangesNative Windows psmux transport
Availability gates and registry selection
Lazy resolution and forced backend handling
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
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
tests/test_tui_launch.py (1)
195-195: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAdd
raising=Falsetodelenv.If
BMAD_LOOP_MUX_BACKENDhappens to be unset in the testing environment,monkeypatch.delenvwill raise aKeyErrorand fail the test. Addingraising=Falsesafely 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
📒 Files selected for processing (13)
docs/ROADMAP.mddocs/adapter-authoring-guide.mddocs/porting-to-a-new-os.mdsrc/bmad_loop/adapters/multiplexer.pysrc/bmad_loop/adapters/psmux_backend.pysrc/bmad_loop/adapters/tmux_base.pysrc/bmad_loop/cli.pysrc/bmad_loop/tui/launch.pytests/test_backend_registry.pytests/test_cli.pytests/test_multiplexer.pytests/test_psmux_backend.pytests/test_tui_launch.py
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.
|
Re the nitpick in the review summary (tests/test_tui_launch.py:195, |
- 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
|
Ran a multi-agent review pass over this branch (correctness, test coverage, comments, silent failures, type design). Findings fixed in 6270cb9:
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
left a comment
There was a problem hiding this comment.
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
_BINARYseam is necessary, not creep — a_run-only override (the previously documented route) can't reach theshutil.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=Nonepinned by the new assert intest_run_posix_default_passes_no_encoding_and_no_env).- Lazy mux in
_make_adaptersis 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.killdrives everything through the ProcessHost seam (win32 = taskkill + psutil identity checks), and the inheritedwindow_pane_pidsdegrades 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
psmuxbinary instead of thetmuxshim, 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
- The forced-backend warning could vanish entirely in the TUI. Textual captures
sys.stderrfor the app's whole run, andmux_usable's warning is once-per-process — so if the first trigger was a TUI observer gate (e.g._session_livenesson the poll worker), the latch consumed the single emission invisibly.run_tuinow trips the gate once beforeApp.run(), while stderr is still the real terminal. test_return_attached_client_noop_without_tmuxfails when the suite runs inside a real tmux session. The module-wideforce_tmux_backendpytestmark setsBMAD_LOOP_MUX_BACKEND=tmux, which the new forced-aware gate now trusts regardless ofavailable()— so the "never shells out" test proceeds tocurrent_window_id(), and withTMUXset (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-returningrunstub →AttributeError. The test now drops the pin, mirroring the isolation a167102 added totest_launch_without_mux_raises. Worth remembering as a class: under the forced-aware gate,force_tmux_backendchanges 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 newnew_sessionbelt 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 #58doesn't orphan its item 6: #185.
|
@CodeRabbit review |
✅ Action performedReview finished.
|
Summary
Adds
PsmuxMultiplexer, a native-Windows transport backend driving psmux (Rust/ConPTY tmux re-implementation) — no WSL, no POSIX sh. The leaf drives thepsmuxbinary exclusively (never thetmux.exedrop-in), so it cannot collide with another tmux-family port owning thetmuxname on the same PATH (cf. #85)._BINARYseam (base change)BaseTmuxBackendgains a_BINARY = "tmux"class attribute threaded through the single spawn site (_run), thewhich()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 namedtmuxoverrides this one name instead of any method body. POSIX tmux behavior is byte-identical (default unchanged, existing tmux suite untouched).A paired
_ERRORSdecode-errors attribute (defaultNone= strict, POSIX-identical) lets the leaf setbackslashreplace, so a stray non-UTF-8 ConPTY byte degrades one visible character instead of raisingUnicodeDecodeErrorout of every capture.PowerShell dialect (via the existing hooks)
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.$env:K = 'v'prelude (names validated, values single-quote-escaped) because psmux acceptsnew-window -ebut silently drops it.CLAUDE_CODE_*/CLAUDECODE*/PSMUX_CLAUDE_TEAMMATE_MODEvars — psmux windows inherit the full env of whichever process cold-started the server.new_sessionspawns withPSMUX_ALLOW_NESTING=1(the documented nesting-guard bypass) on a scrubbed copy of the parent env;kill_sessionuses a plain-t nametarget (psmux ignores the=nameexact-match form);pipe_paneships a pwshAdd-Contentsink.Version gate
available()requirespsmux+pwshon PATH and the version reported bypsmux -V(formattmux X.Y[.Z]— psmux keeps thetmuxprefix deliberately for libtmux-style parsers; a missing patch segment reads as 0) to be strictly greater than 3.3.6:taskkillrecycled 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).BMAD_LOOP_MUX_BACKEND=psmuxor[mux] backend = "psmux") bypasses the probe — the documented escape hatch for from-main dev builds, which still reporttmux 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 diagnosepointer 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, anddocs/ROADMAP.mdrewritten around the_BINARYseam and version-gated psmux-binary detection.Testing
Important
Live testing MUST run against psmux built from
mainor 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 andbmad-loop diagnoseshows the gated version; to force the backend anyway (e.g. a from-main dev build, which still reportstmux 3.3.6), setBMAD_LOOP_MUX_BACKEND=psmuxor[mux] backend = "psmux".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: missingopencodeextra / known skill-sync drift, identical on a clean tree).-EncodedCommandpayloads to pin source composition:3.3.7-rc0accepted; garbled or missing output fails closed; missingpsmux/pwshshort-circuits without spawning; repeated polls spawnpsmux -Vonce (cached verdict);', 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;trunk check(ruff, black, isort, bandit, markdownlint, prettier) clean on all changed files.attach/switch-clienttargeting, 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
psmuxterminal multiplexer backend (PowerShell-based), with version-gated availability and platform-aware default selection.Bug Fixes
Documentation