Skip to content

fix(adapters): give psmux a per-window option channel#315

Merged
pbean merged 5 commits into
bmad-code-org:mainfrom
dracic:fix/psmux-window-option-channel-310
Jul 26, 2026
Merged

fix(adapters): give psmux a per-window option channel#315
pbean merged 5 commits into
bmad-code-org:mainfrom
dracic:fix/psmux-window-option-channel-310

Conversation

@dracic

@dracic dracic commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Closes #310

What

psmux keeps every user option in one per-server map and short-circuits every -w read of an @-prefixed name to "" before that map is consulted. Both mechanisms riding per-window options were therefore broken: the ctl-window PROJECT_OPTION prune guard read the same bled value for every row (a prune in one project could kill-window another project's window), and the RETURN_OPTION parked-return mechanism was fully inert.

  • The three window-option verbs are overridden onto psmux's one working channel: a session-scoped user option keyed by the full window id (@bmad_project_@3 for window @3), every out-of-pane write and read routed with an explicit -t <session>. Builtin (non-@) options fall through to the base -w path. No call-site changes.
  • list_windows drops @-prefixed fields from the psmux format string (the #{@name} bleed) and fills those columns per row from one batched show-options -q listing. A failed read poisons the column instead of reading as "untagged" — "" is the kill-eligible direction.
  • Values that cannot survive psmux's lossy CLI→server hop verbatim are refused loudly (_transportable). That includes standalone ;/\; tokens: the one-shot CLI path splits them without quote-awareness and hands the remainder to the server as a command — filed upstream as set-option: ";" inside a double-quoted value splits the one-shot command line (value truncated, remainder executed) psmux/psmux#499 with an execution proof.
  • The parked trailer self-resolves its own window id in-pane (-t $env:TMUX_PANE) and reads/unsets its id-suffixed key. The switch-client leg composes the correct verb but stays inert until switch-client -t: window/pane-scoped targets accepted (exit 0) but silently ignored (3.3.7) psmux/psmux#483 lands upstream; the detach leg works today.
  • Key lifecycle: kill_window sweeps that window's _@N session keys before the kill, and a launch-time orphan reconcile covers the Enter-dismiss exit that kill_window never sees (snapshot-before-listing ordering so a freshly minted window can never read as an orphan).
  • tmux untouched: its -w argv is byte-identical to before (divergence pinned by test).

Every psmux behavior relied on here was read from the psmux source at tag v3.3.7 (the installed build) and the transport edge cases were smoke-tested live against a scratch session on stock 3.3.7.

Verification

  • pytest tests/test_psmux_backend.py tests/test_multiplexer.py -q → 134 passed (incl. ablation-checked degrade/refusal gates and the tmux divergence pins)
  • full suite: no new failures vs. the local baseline
  • trunk check clean, pyright@1.1.411 clean on the touched modules

Summary by CodeRabbit

  • Bug Fixes

    • Fixed psmux window targeting and per-window option handling.
    • Restored returning detached clients to their originating windows.
    • Prevented option values from being corrupted during transport; unsupported values are now rejected.
    • Added cleanup for stale window options and improved handling when windows are removed or parked.
  • Documentation

    • Clarified psmux’s session-scoped window model, option behavior, and transport limitations.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@pbean, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 5 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5ad83d6c-0387-44bc-8cea-1ca332707e06

📥 Commits

Reviewing files that changed from the base of the PR and between 38ee3f8 and 99385fe.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • docs/multiplexer-backends.md
  • src/bmad_loop/adapters/multiplexer.py
  • src/bmad_loop/adapters/psmux_backend.py
  • tests/test_psmux_backend.py

Walkthrough

psmux per-window options now use session-scoped keys qualified by window id. The backend updates option reads, writes, listing, cleanup, parked-window returns, transport validation, documentation, changelog entries, and comprehensive tests.

Changes

psmux window option channel

Layer / File(s) Summary
Option routing and contracts
src/bmad_loop/adapters/multiplexer.py, src/bmad_loop/adapters/psmux_backend.py, tests/test_psmux_backend.py
@... options use session-scoped keys suffixed with window ids, transport-invalid values are rejected, and reads and writes return warnings or markers for failures.
Window listing and option cleanup
src/bmad_loop/adapters/psmux_backend.py, tests/test_psmux_backend.py
list_windows fills option columns from one session listing, while orphaned and killed-window option keys are cleaned up without blocking window termination.
Parked-window return integration
src/bmad_loop/adapters/psmux_backend.py, tests/test_psmux_backend.py, docs/multiplexer-backends.md, CHANGELOG.md
Parked trailers probe qualified ids for return options, free consumed keys, and reconcile orphaned keys before minting; documentation and changelog entries describe the updated psmux behavior.

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

Possibly related issues

Possibly related PRs

Suggested reviewers: pbean

Poem

A bunny found keys in a session-wide nest,
Each window-id tag kept its option best.
Orphans were swept, and returns came true,
Safe values hopped through the control line too.
“No cross-window mix-ups!” the rabbit sang bright.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main change: adding a per-window option channel for psmux.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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: 1

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

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

Assertion is tautological in shape.

Filtering on c[1] == "set-option" and then collecting c[1] compares the verb against itself; it passes for the right reason but reads as a mistake. assert not [c for c in calls if c[1] == "set-option"] (or collecting the key, as the sibling sweep test does) states the intent directly.

🤖 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_psmux_backend.py` around lines 1152 - 1154, Update the assertion
in the PsmuxMultiplexer().new_parked_window test so it directly verifies that
calls with the "set-option" verb are absent, rather than filtering and comparing
that same verb to itself; preserve the existing calls capture and test behavior.
🤖 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 `@src/bmad_loop/adapters/psmux_backend.py`:
- Around line 467-473: Update the non-transportable-value branch in the setter
around _transportable to remove or unset the existing option value before
returning, ensuring a rejected write cannot preserve stale state. Keep the
warning behavior and stderr output unchanged, and preserve normal writes for
transportable values.

---

Nitpick comments:
In `@tests/test_psmux_backend.py`:
- Around line 1152-1154: Update the assertion in the
PsmuxMultiplexer().new_parked_window test so it directly verifies that calls
with the "set-option" verb are absent, rather than filtering and comparing that
same verb to itself; preserve the existing calls capture and test behavior.
🪄 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 Plus

Run ID: 73557e5b-3c90-473a-9df1-31549e39a93c

📥 Commits

Reviewing files that changed from the base of the PR and between ec476c1 and 38ee3f8.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • docs/multiplexer-backends.md
  • src/bmad_loop/adapters/multiplexer.py
  • src/bmad_loop/adapters/psmux_backend.py
  • tests/test_psmux_backend.py

Comment thread src/bmad_loop/adapters/psmux_backend.py
@greptile-apps

greptile-apps Bot commented Jul 26, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces a broken psmux per-window option path with a working substitute channel. Because psmux has one user-option scope per server and short-circuits every -w @-name read to "", both the ctl-window project tag (used for prune scoping) and the parked-return mechanism were silently non-functional.

  • Session-scoped option channel: set_window_option, unset_window_option, and show_window_option now key @-prefixed options as {option}_@{digits} on the session scope, routed with an explicit -t <session>. Non-@ builtin options keep the original -w path unchanged.
  • list_windows fix: @-prefixed columns are replaced with a window_id probe in the format string, then filled from one batched show-options listing; a failed listing degrades to "" (unset) with a warning rather than silently reading a bleed value.
  • Parked-trailer fix: The trailer now probes its own window id in-pane via display-message -t $env:TMUX_PANE and reads/frees the id-keyed session option, restoring the detach-return path.
  • Key lifecycle: kill_window sweeps the window's _@N-suffixed keys before the kill; new_parked_window runs an orphan-key reconcile to catch Enter-dismissed windows that bypass kill_window. The _transportable guard refuses values that cannot survive psmux's CLI→server hop verbatim.

Confidence Score: 5/5

Safe to merge. The change adds an entirely new code path for a psmux-specific workaround; the tmux path is untouched and divergence is pinned by a test.

The implementation is carefully guarded at every layer: unroutable targets decline loudly rather than misroute, untransportable values are refused and any stale prior value is freed, key lifecycle is covered by both proactive cleanup (kill_window) and reconciliation (orphan sweep at launch), and the orphan sweep can never fail the mint. All edge cases are exercised by 134 passing tests including ablation checks, degrade gates, and an end-to-end prune discrimination test. The tmux backend is untouched and pinned by a divergence test. No correctness issues were found.

Files Needing Attention: No files require special attention.

Important Files Changed

Filename Overview
src/bmad_loop/adapters/psmux_backend.py Core of the PR: adds the per-window option channel, key lifecycle management, transport validation, and parked-trailer fix. Logic is carefully guarded, each edge case is commented with psmux source references and live-verification notes. No issues found.
tests/test_psmux_backend.py Adds 464 lines of new tests covering every new method: channel read/write/unset, unroutable/untransportable refusal gates, list_windows column fill, kill_window sweep, orphan-key reconcile, and an end-to-end prune discrimination test. Existing tests updated to account for the orphan sweep's extra subprocess calls.
src/bmad_loop/adapters/multiplexer.py Minor docstring update to set_window_option clarifying that backends may use alternate storage; no logic changes.
docs/multiplexer-backends.md Documents the psmux option channel model and the counter-intuitive transportability rules (apostrophe in spaceless vs. spaced path).
CHANGELOG.md Release note added for the per-window option channel fix. Accurate and concise.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant PsmuxMux as PsmuxMultiplexer
    participant psmux as psmux binary
    participant PsmuxServer as psmux server (session-scoped options)

    Note over Caller,PsmuxServer: set_window_option("ctl:@3", "@bmad_project", "C:/p")
    Caller->>PsmuxMux: set_window_option
    PsmuxMux->>PsmuxMux: _option_scope → ("ctl", "3")
    PsmuxMux->>PsmuxMux: _transportable("C:/p") → True
    PsmuxMux->>psmux: "set-option -t ctl @bmad_project_@3 C:/p"
    psmux->>PsmuxServer: "store @bmad_project_@3"

    Note over Caller,PsmuxServer: list_windows("ctl", ["window_id", "@bmad_project"])
    Caller->>PsmuxMux: list_windows
    PsmuxMux->>psmux: "list-windows -F #{window_id} #{window_id} -t ctl"
    psmux-->>PsmuxMux: "@1 @1 / @3 @3"
    PsmuxMux->>psmux: show-options -q -t ctl
    psmux-->>PsmuxMux: "@bmad_project_@3 "C:/p""
    PsmuxMux-->>Caller: "[("ctl:@1",""), ("ctl:@3","C:/p")]"

    Note over Caller,PsmuxServer: kill_window("ctl:@3")
    Caller->>PsmuxMux: kill_window
    PsmuxMux->>psmux: show-options -q -t ctl
    psmux-->>PsmuxMux: "@bmad_project_@3 "C:/p""
    PsmuxMux->>psmux: "set-option -u -t ctl @bmad_project_@3"
    PsmuxMux->>psmux: "kill-window -t ctl:@3"

    Note over Caller,PsmuxServer: Parked trailer (in-pane, routes via $TMUX)
    PsmuxMux->>psmux: "display-message -p -t $env:TMUX_PANE #{window_id}"
    psmux-->>PsmuxMux: "@3"
    PsmuxMux->>psmux: "show-options -qv @bmad_return_pane_@3"
    psmux-->>PsmuxMux: detach
    PsmuxMux->>psmux: detach-client
    PsmuxMux->>psmux: "set-option -u @bmad_return_pane_@3"
Loading

Reviews (3): Last reviewed commit: "docs(adapters): pin the spaceless-apostr..." | Re-trigger Greptile

Comment thread src/bmad_loop/adapters/psmux_backend.py
dracic added a commit to dracic/bmad-auto that referenced this pull request Jul 26, 2026
A refused rewrite left the prior value live — a stale parked return
target could replay later. The refusal now unsets the key so the option
reads as unset, matching the reworded warning (bmad-code-org#315 review).

Also: pin (source + round-trip test) that a spaceless UNC path rides the
unquoted hop verbatim — the tokenizer's backslash collapse fires only
inside double quotes — and de-tautologize the empty-live-list sweep
assertion.
@dracic

dracic commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Heads-up for whoever merges this, from the #227 / PR #316 review — a constraint this PR inherits rather than causes.

While the option channel is inert, tui.launch.return_attached_client reads an empty RETURN_OPTION on psmux and answers False before it ever reaches a switch-client or detach-client call. This PR removes that shield. Once the channel works, the parked-return path reaches those verbs — and on psmux both report execution, not effect:

  • at v3.3.7 the whole switch-client arm is send_control(cmd)?; return Ok(()) (no reply for any flag, -t included), and detach-client exits nonzero only when the session server is unreachable, so it succeeds with zero clients attached;
  • current main adds a response path for switch-client -t alone, leaving -l and detach-client exit-0-regardless.

PR #316 just made return_attached_client trust those booleans to decide whether to clear the return option. A vacuous True therefore clears it and reports a hand-back that never happened — #227's exact symptom, on psmux, reachable only after this lands.

Filed as #317 with the source evidence and three candidate directions, so nothing here needs to change: this is a note that the option channel's first live consumer arrives with a known-untrustworthy signal underneath it. Worth adding to the #313 safety-core checklist rather than treating as a blocker for this merge.

dracic and others added 5 commits July 26, 2026 14:19
A refused rewrite left the prior value live — a stale parked return
target could replay later. The refusal now unsets the key so the option
reads as unset, matching the reworded warning (bmad-code-org#315 review).

Also: pin (source + round-trip test) that a spaceless UNC path rides the
unquoted hop verbatim — the tokenizer's backslash collapse fires only
inside double quotes — and de-tautologize the empty-live-list sweep
assertion.
… review)

The read-failure poison marker guarded a hazard the caller's untagged
fallback already covers: the ctl prune claims an untagged window only when
its run dir exists under this project, and run ids are unique by
construction, so "" is ownership-safe in both directions. It was also
inconsistent with the channel's own documented hole - a psmux server
handler can answer the option listing empty-with-success while keys exist,
and that path filled "" anyway. Degrade to "unset" everywhere, keep the
stderr warning, and drop the cross-backend rule that an option column may
carry a backend-private opaque marker.
A refused value re-entered unset_window_option to free the stale key, so an
unroutable target warned twice - once about the transport, once about a
`set-option -u` the caller never issued. Resolve the scope first (an
unroutable target has nothing to write and nothing to free), then gate the
value, and free the key through the resolved scope. The two mutating verbs
now share one spawn-and-warn body.
The docs cited only a UNC-and-spaced path, but the psmux client quotes a
value only when it holds an ASCII space and `'` is literal inside those
quotes - so `C:\Users\O'Brien\dev` is refused while its spaced sibling is
accepted. Name the inversion and pin it with a parametrize case. Also point
the module docstring at the channel block instead of restating the model,
and drop a stray word from the kill_window cost note.
@pbean

pbean commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Reviewed against #310, the design doctrine in AGENTS.md/CONTRIBUTING, and the POSIX path. The approach is the one to take and the scope is right — I pushed four small corrections onto the branch rather than sending it back, and rebased onto main.

What I checked, and what held

What I changed (3 commits)

1. fix(adapters): degrade psmux option columns to unset (#310 review) — the one substantive finding.

_OPT_READ_FAILED guarded a hazard the caller's untagged fallback already covers. _ctl_window_candidates claims an untagged window only when its run dir exists under this project, and a run id is %Y%m%d-%H%M%S-<4 hex> — so "" is safe in both directions: ours → correctly pruned, theirs → no run dir here → skipped anyway. The poison's only real effect was to skip pruning our own dead window on a transient failure.

It was also inconsistent with the channel's own documented hole: _scoped_options' docstring records that a psmux handler can answer the listing empty-with-success while keys exist, and list_windows filled "" on that path regardless — so the exact hazard the sentinel existed to prevent was reachable, silently, through a path the PR documents. Both halves now degrade the same way, and the cross-backend rule ("an @ column may carry a backend-private opaque marker") comes back out of the ABC. I kept the set_window_option docstring paragraph — "the contract is the keying, not the storage" is the seam rule this fix genuinely relies on.

I also anchored the surviving warning the way #291 taught us: it is visible on the CLI prune (including the --dry-run your comment cites) but not under the TUI, which captures sys.stderr for the app's whole run — same deliberate ceiling as the select_window note.

2. fix(adapters): resolve psmux option scope before the transport gate — a refused value re-entered unset_window_option to free the stale key, so an unroutable target warned twice: once about the transport, once about a set-option -u the caller never issued. Scope resolves first (an unroutable target has nothing to write and nothing to free), then the value is gated, and the key is freed through the already-resolved scope. The two mutating verbs now share one spawn-and-warn body.

3. docs(adapters): pin the spaceless-apostrophe transport refusal — the docs cited only "UNC and spaced", but since the client quotes only on an ASCII space and ' is literal inside those quotes, C:\Users\O'Brien\dev is refused while C:\Users\O'Brien Files\dev is accepted. The inversion is correct per your transport model, but an apostrophe in a Windows home is far more common than a UNC+spaced project path, so it is now named in the docs and pinned by a parametrize case. Also pointed the module docstring at the channel block instead of restating the model, and replaced a stray word in the kill_window cost note.

Left for you

Local: full suite green apart from two pre-existing test_module_skills_sync failures that also fail on main (gitignored seed drift, unrelated to this branch); trunk check clean; pyright@1.1.411 clean.

@pbean

pbean commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Live verification — psmux 3.3.7 (05cc5d4), native Windows

Ran the whole thing on a Windows VM against 99385fe, then repeated the prune half against d3c6f5e for the before/after. Zero LLM tokens; every check is raw psmux or the backend driven directly. All five areas pass, and two assumptions the code rests on are now probed rather than assumed.

The TMUX_PANE claim holds — this was the load-bearing one

TMUX=[/tmp/psmux-7556/default,56590,0]
TMUX_PANE=[%1]
psmux display-message -p -t $env:TMUX_PANE '#{window_id}'  ->  @1
psmux display-message -p '#{window_id}'                    ->  @1

Also confirmed separately: the trailer's untargeted in-pane read reaches the session's map, so $TMUX routing is real and the missing -t <session> in _parked_trailer is correct. Replayed by hand in a pane against a key set from outside — ret=[detach], matching the targeted read.

The per-window channel discriminates

ids: psmux-probe:@2 psmux-probe:@3
raw listing: {'@bmad_project_@3': 'C:\\proj\\beta', '@bmad_project_@2': 'C:\\proj\\alpha'}
row: ('psmux-probe:@1', 'pwsh',  '')
row: ('psmux-probe:@2', 'alpha', 'C:\\proj\\alpha')
row: ('psmux-probe:@3', 'beta',  'C:\\proj\\beta')
keys after kill_window(@2): {'@bmad_project_@3': 'C:\\proj\\beta'}

Rows carry distinct values, and kill_window freed that window's key and only that one.

_transportable is exactly calibrated — no corruption, no false refusals

Round-tripped ten values through a real write + read. Every one is either byte-identical or refused; nothing stored corrupted:

value result
C:\proj\normal OK
C:\Users\Some User\p OK
C:\Users\O'Brien Files\proj OK
a; b OK
two spaces OK
\\srv\share OK
C:\Users\O'Brien\dev refused
a ; b refused
\\srv\share My Proj refused
C:\dir with space\ refused

The accept/refuse split matches your model exactly, including the counter-intuitive pair — spaced O'Brien Files accepted, spaceless O'Brien\dev refused.

The parked return actually returns now

Real new_parked_window, attach, select-window to it, Enter at the park prompt:

window: parkprobe:@2
psmux show-options -qv -t parkprobe "@bmad_return_pane_@2"  ->  detach
psmux attach -t parkprobe        # ... Enter at the banner -> dropped to the host prompt
psmux has-session -t parkprobe; echo "rc=$LASTEXITCODE"     ->  rc=0
psmux show-options -q -t parkprobe | Select-String bmad     ->  (nothing)

Client detached, session survived, key freed. The has-session line matters: a read against a dead session doesn't fail on psmux, it falls through the most-recent-session routing and answers from another server — so an option read is only evidence while the session is provably alive.

The switch-client leg is untested and stays inert per psmux/psmux#483, as the PR says.

Cross-project prune — and the "before" is worse than #310 documented

On 99385fe, two projects sharing bmad-loop-ctl:

@bmad_project_@2 "C:\tmp\projA"
@bmad_project_@3 "C:\tmp\projB"

cleanup --dry-run --project C:\tmp\projA  ->  would close ctl window run-20260101-000000-aaaa
cleanup --dry-run --project C:\tmp\projB  ->  would close ctl window run-20260102-000000-bbbb

Same scenario on d3c6f5e (has #291, not #310) — the map collapses to one key, last writer wins:

@bmad_project "C:\tmp\projB"

cleanup --dry-run --project C:\tmp\projA  ->  nothing to clean up
cleanup --dry-run --project C:\tmp\projB  ->  would close ctl window run-20260101-000000-aaaa
                                              would close ctl window run-20260102-000000-bbbb

Both failure directions, live: a project cannot prune its own window, and the other project would kill it. The issue only documented the second.

The pre-fix return is reproducible too — on d3c6f5e, Enter at a parked window leaves the client attached and drops it to window 0, because the -wqv read yields '' and neither branch fires.

Two assumptions, now probed rather than assumed

Window ids are not recycled — the sweep's correctness comment holds. If they were, a new window would inherit a dead one's stale key:

@1  ->  new-window a  ->  @1 @2  ->  kill @2  ->  new-window b  ->  @1 @3

new-session on an existing session errors (duplicate session: dup, rc 1), so new_session's post-create has_session belt is meaningful rather than papering over a silent no-op.

Two cosmetic notes, neither blocking and neither yours

  • psmux list-windows renders run-20260101-000000-aaaa- with a trailing - — that is psmux's default format appending window flags, not a corrupted name. bmad-loop passes its own -F #{window_name}, and cleanup printed the clean name, so nothing is affected.
  • bmad-loop mux renders a broken table on a psmux host: psmux -V prints two lines and cli.py:407 drops r.version straight into a ljust column. The tmux row also reports psmux's version, since psmux ships a tmux-named binary — harmless for selection today because tmux's platform gate excludes win32. Pre-existing and unrelated to this PR; I'll file it separately.

That closes the verification gap I flagged. From my side this is ready to merge.

@pbean
pbean merged commit f5402e4 into bmad-code-org:main Jul 26, 2026
11 checks passed
pbean added a commit to dracic/bmad-auto that referenced this pull request Jul 27, 2026
…ode (bmad-code-org#317)

The parked-return path reads an empty option on psmux only while the per-window
option channel is inert. bmad-code-org#310 revived it, so the path now reaches
switch_client/detach_client on Windows - and psmux answers both with an exit
code that means nothing. At v3.3.7 every arm ends in `send_control(cmd)?;
return Ok(())`: the only nonzero exit is an unreachable session server, so a
detach succeeds with zero clients attached, and no form of switch-client - `-t`
included - carries a server reply. Later builds narrow that only for `-t`.
Taking those codes as the seam's booleans is the rc-0 no-op (bmad-code-org#228) reached
through Python, and it hands `return_attached_client` a True that clears the
return option and reports a hand-back that never happened: bmad-code-org#227's exact
symptom, on the platform the psmux backend exists for.

So discard the exit code and measure: count the clients attached to this
session before and after the verb, answer on the drop. Never on an absolute
count - a `-t` read against a session whose server is gone answers from
whichever server the fallback picks, so "zero attached" alone proves nothing
(bmad-code-org#315); a drop needs two successful reads of the same session, the first
nonzero. Unobservable answers False, never a vacuous True: the probe is
self-detecting, since a build without #{session_attached} cannot echo a
plausible integer. The switch leg reads as no effect today - it is inert at
3.3.7 (psmux/psmux#483) - and starts reporting True on its own when upstream
lands the fix, which is why this measures rather than hardcoding.

Also state the rule at the seam it belongs to: detach_client/switch_client owe
effect, not dispatch, and the authoring guide now shows both ways to pay for it.
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.

psmux: per-window user options are session-scoped - the ctl-window project guard cannot discriminate and the parked-return mechanism is inert

2 participants