Both wlr-family backends accepted the topology axis, the management API reported it as the session's effective topology, and the backend logged a warning and did nothing — sweep 13.18 shipped the warning, never the behaviour. And because resolve_topology sends auto (the default) to Exclusive on any host without a PUNKTFUNK_COMPOSITOR pin, and both compositors are auto-detected, the default policy on every such box was an Exclusive that behaved as Extend.
What landed
exclusive now disables the operator's heads for the session and restores them when the display group's last member is torn down, through the same take_topology_restore hand-off KWin uses. That buys the two ordering properties for free: the registry runs the restore before the last output is reclaimed (so the compositor never sees zero enabled outputs), and a sibling session never has the desk re-enabled under it.
The disable filter is group-aware (design §6.1) — enabled, not ours, not managed:
managed predicate
also spares
Hyprland
PF-<pid>-<n>
a second host's outputs, not just our own
sway
HEADLESS- prefix
a headless sway's own bootstrap output
That sway prefix is deliberately blunt: sway names its own headless outputs exactly as we do. The failure it buys is the harmless one (a bootstrap head stays lit on a box with no physical screen) rather than blacking out a live sibling.
primary stays treated as extend, which is the honest answer rather than a gap: Wayland has no primary-output concept and these compositors have only a focused output — which the streamed head already holds since #283. It now says so distinctly instead of sharing a warning with exclusive.
🛑 The Hyprland restore is hyprctl reload, and that is measured, not chosen
Re-applying the head's own mode/position/scale — what design §5.2 and the issue both assume — does not undo a disable. It answers ok and leaves disabled: true.
Probed 2026-08-18 against 0.56.2 (hyprlang) and 0.55.4 (Lua). Every one of these was accepted and changed nothing:
keyword monitor <n>,<W>x<H>@<Hz>,<x>x<y>,<scale> — the exact pre-disable rule
keyword monitor <n>,preferred,auto,1
keyword monitor <n>,enable — not a verb; answers invalid resolution
A runtime monitor rule is additive and the disable keeps winning; only re-reading the config clears runtime rules. So the restore is the operator's own config, re-applied — which for a config-driven compositor is what "put it back how it was" means.
Two things worth knowing, both documented at the call site and in the docs:
⭐The headless output survives the reload, so the issue's worry about losing our own monitor rule doesn't hold — and it is removed moments later by the same teardown anyway.
⚠️ A reload drops other runtime keyword/eval overrides, and a hyprlang config re-runs its exec = lines (exec-once does not; a Lua config's hl.on("hyprland.start", …) does not re-fire). It runs only when a session actually disabled something.
Both config eras, decided by read-back
Disable is spelled per era and confirmed by reading the state back, mirroring set_monitor_rule:
hyprlang — keyword monitor "<n>,disable"
Lua — eval 'hl.monitor{ output = "<n>", disabled = true }' (the field is disabled; disable = true → "unknown field", mode = "disable" → "error applying field 'mode'")
Each era rejects the other's form at exit 0, so the read-back — not the exit status, not the ok — is what decides.
That also surfaced a marker gap fixed here: hyprctl keyword under the Lua config manager answers "keyword can't work with non-legacy parsers. Use eval.", and hyprctl_dispatch matched couldn't but notcan't — so the wrong-era form read as success. set_monitor_rule was covered by its own mode verification; nothing else was.
⚠️ Reviewer notes
This changes the default.Auto → Exclusive on any unpinned host means every existing Hyprland/sway box now actually goes dark on connect. It is exactly what the console and /display/state already claimed, but it is a visible change — worth a release note, and easy to revisit if the default should move instead.
The sway half is not exercised on a live sway. No box in the fleet runs one — the same gap #283's focus output shipped with, and what display-management.md records as "wlroots exclusive (needs a Sway box)". The argv is sway's documented surface; both shapes are pinned by tests, because this file legitimately uses output <name> <verb>andfocus output <name> and getting one backwards is the live risk. The read-back turns a wrong guess into a warning naming the outputs rather than a screen that silently stays dark.
Separate bug, found not fixed: PR #283's focus fix is a no-op on a Lua-configured Hyprland — hyprctl dispatch focusmonitor <n> is parsed as Lua and rejected, and hl.dsp.focusmonitor does not exist. Documented on focus_output; it belongs to the #283 follow-up, not here.
Verification
6 new unit tests (group-aware filter on both backends, headless no-op, both disable spellings, the marker set); 246 pf-vdisplay Linux tests pass.
On-glass: every hyprctl command this change issues was measured on Hyprland 0.56.2 and 0.55.4, both config eras, in throwaway headless instances.
Closes #284.
Both wlr-family backends accepted the `topology` axis, the management API reported it as the session's effective topology, and the backend logged a warning and did nothing — sweep **13.18** shipped the warning, never the behaviour. And because `resolve_topology` sends `auto` (the default) to `Exclusive` on any host without a `PUNKTFUNK_COMPOSITOR` pin, and both compositors are auto-detected, the default policy on every such box was an **Exclusive that behaved as Extend**.
## What landed
`exclusive` now disables the operator's heads for the session and restores them when the display **group's** last member is torn down, through the same `take_topology_restore` hand-off KWin uses. That buys the two ordering properties for free: the registry runs the restore *before* the last output is reclaimed (so the compositor never sees zero enabled outputs), and a sibling session never has the desk re-enabled under it.
The disable filter is group-aware (design §6.1) — enabled, not ours, not managed:
| | managed predicate | also spares |
|---|---|---|
| Hyprland | `PF-<pid>-<n>` | a **second host's** outputs, not just our own |
| sway | `HEADLESS-` prefix | a headless sway's own bootstrap output |
That sway prefix is deliberately blunt: sway names its own headless outputs exactly as we do. The failure it buys is the harmless one (a bootstrap head stays lit on a box with no physical screen) rather than blacking out a live sibling.
`primary` stays treated as extend, which is the honest answer rather than a gap: Wayland has no primary-output concept and these compositors have only a *focused* output — which the streamed head already holds since #283. It now says so distinctly instead of sharing a warning with `exclusive`.
## 🛑 The Hyprland restore is `hyprctl reload`, and that is measured, not chosen
Re-applying the head's own mode/position/scale — what design §5.2 **and the issue** both assume — **does not undo a disable**. It answers `ok` and leaves `disabled: true`.
Probed 2026-08-18 against **0.56.2 (hyprlang)** and **0.55.4 (Lua)**. Every one of these was accepted and changed nothing:
- `keyword monitor <n>,<W>x<H>@<Hz>,<x>x<y>,<scale>` — the exact pre-disable rule
- `keyword monitor <n>,preferred,auto,1`
- `keyword monitor <n>,enable` — not a verb; answers `invalid resolution`
- `keyword monitorv2 output=<n>,…,disabled=false`
- `keyword unset monitor`
- `eval 'hl.monitor{ output = "<n>", disabled = false, … }'` — the Lua twin
- `dispatch dpms on <n>` — DPMS is a different axis
- `dispatch forcerendererreload`
A runtime `monitor` rule is additive and the `disable` keeps winning; only re-reading the config clears runtime rules. So the restore is the operator's own config, re-applied — which for a config-driven compositor is what "put it back how it was" means.
Two things worth knowing, both documented at the call site and in the docs:
- ⭐ **The headless output survives the reload**, so the issue's worry about losing our own monitor rule doesn't hold — and it is removed moments later by the same teardown anyway.
- ⚠️ A reload drops other runtime `keyword`/`eval` overrides, and a **hyprlang** config re-runs its `exec =` lines (`exec-once` does not; a Lua config's `hl.on("hyprland.start", …)` does not re-fire). It runs **only** when a session actually disabled something.
## Both config eras, decided by read-back
Disable is spelled per era and confirmed by reading the state back, mirroring `set_monitor_rule`:
- hyprlang — `keyword monitor "<n>,disable"`
- Lua — `eval 'hl.monitor{ output = "<n>", disabled = true }'` (the field is `disabled`; `disable = true` → "unknown field", `mode = "disable"` → "error applying field 'mode'")
Each era rejects the other's form **at exit 0**, so the read-back — not the exit status, not the `ok` — is what decides.
That also surfaced a marker gap fixed here: `hyprctl keyword` under the Lua config manager answers *"keyword can't work with non-legacy parsers. Use eval."*, and `hyprctl_dispatch` matched `couldn't` but **not** `can't` — so the wrong-era form read as success. `set_monitor_rule` was covered by its own mode verification; nothing else was.
## ⚠️ Reviewer notes
- **This changes the default.** `Auto` → `Exclusive` on any unpinned host means every existing Hyprland/sway box now actually goes dark on connect. It is exactly what the console and `/display/state` already claimed, but it is a visible change — worth a release note, and easy to revisit if the default should move instead.
- **The sway half is not exercised on a live sway.** No box in the fleet runs one — the same gap #283's `focus output` shipped with, and what `display-management.md` records as "wlroots `exclusive` (needs a Sway box)". The argv is sway's documented surface; both shapes are pinned by tests, because this file legitimately uses `output <name> <verb>` **and** `focus output <name>` and getting one backwards is the live risk. The read-back turns a wrong guess into a warning naming the outputs rather than a screen that silently stays dark.
- **Separate bug, found not fixed:** PR #283's focus fix is a **no-op on a Lua-configured Hyprland** — `hyprctl dispatch focusmonitor <n>` is parsed as Lua and rejected, and `hl.dsp.focusmonitor` does not exist. Documented on `focus_output`; it belongs to the #283 follow-up, not here.
## Verification
- 6 new unit tests (group-aware filter on both backends, headless no-op, both disable spellings, the marker set); **246 pf-vdisplay Linux tests pass**.
- `cargo fmt --all --check` clean, `cargo clippy --all-targets -- -D warnings` clean.
- On-glass: every hyprctl command this change issues was measured on Hyprland 0.56.2 and 0.55.4, both config eras, in throwaway headless instances.
Both wlr-family backends accepted the topology axis, the management API reported
it as the session's effective topology, and the backend logged a warning and did
nothing (sweep 13.18 shipped the warning, never the behaviour). Because
`resolve_topology` sends `auto` — the default — to `Exclusive` on any host without
a `PUNKTFUNK_COMPOSITOR` pin, and both compositors are auto-detected, the default
policy on every such box was an Exclusive that behaved as Extend. Closes#284.
`exclusive` now disables the operator's heads for the session and restores them
when the display GROUP's last member is torn down, via the same
`take_topology_restore` hand-off KWin uses — so the registry runs the restore
before the last output is reclaimed and the compositor never sees zero enabled
outputs, and a sibling session never has the desk re-enabled under it.
The disable filter is group-aware (design §6.1): enabled, not ours, and not
managed. On Hyprland `managed` is `PF-<pid>-<n>`, which covers a second host's
outputs too; on sway it is the `HEADLESS-` prefix, which also spares a headless
sway's own bootstrap output — the harmless failure, versus blacking out a live
sibling.
`primary` stays treated as extend, which is the honest answer rather than a gap:
Wayland has no primary-output concept and these compositors have only a focused
output, which the streamed head already holds since #283. It now says so
distinctly instead of sharing a warning with `exclusive`.
🛑 The Hyprland restore is `hyprctl reload`, and that is measured, not chosen.
Re-applying the head's own mode/position/scale — what design §5.2 and the issue
both assume — does NOT undo a disable: it answers `ok` and leaves `disabled:
true`. Probed 2026-08-18 against 0.56.2 (hyprlang) and 0.55.4 (Lua); every
targeted form was accepted and changed nothing, including `,enable` (answers
`invalid resolution`), `preferred,auto,1`, `monitorv2 disabled=false`, `keyword
unset monitor`, the Lua `disabled = false`, `dispatch dpms on` and
`forcerendererreload`. A runtime monitor rule is additive and the `disable` keeps
winning; only re-reading the config clears it. The headless output survives the
reload, so the issue's worry about losing it does not hold. Side effects are
documented at the call site and in the docs: other runtime `keyword` overrides are
dropped, and a hyprlang config re-runs its `exec =` lines. It runs only when a
session actually disabled something.
Disable is spelled per config era and confirmed by read-back, mirroring
`set_monitor_rule`: `keyword monitor <n>,disable` under hyprlang, `hl.monitor{
output = "<n>", disabled = true }` under Lua. Both eras reject the other's form at
exit 0, so the read-back — not the exit status, not the `ok` — is what decides.
Also fixes a marker gap that made one of those rejections read as success:
`hyprctl keyword` under the Lua config manager answers "keyword can't work with
non-legacy parsers. Use eval.", and `hyprctl_dispatch` matched "couldn't" but not
"can't". `set_monitor_rule` was covered by its own mode verification; nothing else
was.
⚠ The sway half is NOT exercised on a live sway — no box in the fleet runs one,
the same gap #283's `focus output` shipped with. The argv is sway's documented
surface, both shapes are pinned by tests (this file uses `output <name> <verb>`
AND `focus output <name>`, so getting one backwards is the live risk), and the
read-back turns a wrong guess into a warning naming the outputs rather than a
screen that silently stays dark.
Six new unit tests cover the group-aware filter on both backends, the headless
no-op case, both disable spellings and the marker set. 246 pass on Linux.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #284.
Both wlr-family backends accepted the
topologyaxis, the management API reported it as the session's effective topology, and the backend logged a warning and did nothing — sweep 13.18 shipped the warning, never the behaviour. And becauseresolve_topologysendsauto(the default) toExclusiveon any host without aPUNKTFUNK_COMPOSITORpin, and both compositors are auto-detected, the default policy on every such box was an Exclusive that behaved as Extend.What landed
exclusivenow disables the operator's heads for the session and restores them when the display group's last member is torn down, through the sametake_topology_restorehand-off KWin uses. That buys the two ordering properties for free: the registry runs the restore before the last output is reclaimed (so the compositor never sees zero enabled outputs), and a sibling session never has the desk re-enabled under it.The disable filter is group-aware (design §6.1) — enabled, not ours, not managed:
PF-<pid>-<n>HEADLESS-prefixThat sway prefix is deliberately blunt: sway names its own headless outputs exactly as we do. The failure it buys is the harmless one (a bootstrap head stays lit on a box with no physical screen) rather than blacking out a live sibling.
primarystays treated as extend, which is the honest answer rather than a gap: Wayland has no primary-output concept and these compositors have only a focused output — which the streamed head already holds since #283. It now says so distinctly instead of sharing a warning withexclusive.🛑 The Hyprland restore is
hyprctl reload, and that is measured, not chosenRe-applying the head's own mode/position/scale — what design §5.2 and the issue both assume — does not undo a disable. It answers
okand leavesdisabled: true.Probed 2026-08-18 against 0.56.2 (hyprlang) and 0.55.4 (Lua). Every one of these was accepted and changed nothing:
keyword monitor <n>,<W>x<H>@<Hz>,<x>x<y>,<scale>— the exact pre-disable rulekeyword monitor <n>,preferred,auto,1keyword monitor <n>,enable— not a verb; answersinvalid resolutionkeyword monitorv2 output=<n>,…,disabled=falsekeyword unset monitoreval 'hl.monitor{ output = "<n>", disabled = false, … }'— the Lua twindispatch dpms on <n>— DPMS is a different axisdispatch forcerendererreloadA runtime
monitorrule is additive and thedisablekeeps winning; only re-reading the config clears runtime rules. So the restore is the operator's own config, re-applied — which for a config-driven compositor is what "put it back how it was" means.Two things worth knowing, both documented at the call site and in the docs:
keyword/evaloverrides, and a hyprlang config re-runs itsexec =lines (exec-oncedoes not; a Lua config'shl.on("hyprland.start", …)does not re-fire). It runs only when a session actually disabled something.Both config eras, decided by read-back
Disable is spelled per era and confirmed by reading the state back, mirroring
set_monitor_rule:keyword monitor "<n>,disable"eval 'hl.monitor{ output = "<n>", disabled = true }'(the field isdisabled;disable = true→ "unknown field",mode = "disable"→ "error applying field 'mode'")Each era rejects the other's form at exit 0, so the read-back — not the exit status, not the
ok— is what decides.That also surfaced a marker gap fixed here:
hyprctl keywordunder the Lua config manager answers "keyword can't work with non-legacy parsers. Use eval.", andhyprctl_dispatchmatchedcouldn'tbut notcan't— so the wrong-era form read as success.set_monitor_rulewas covered by its own mode verification; nothing else was.⚠️ Reviewer notes
Auto→Exclusiveon any unpinned host means every existing Hyprland/sway box now actually goes dark on connect. It is exactly what the console and/display/statealready claimed, but it is a visible change — worth a release note, and easy to revisit if the default should move instead.focus outputshipped with, and whatdisplay-management.mdrecords as "wlrootsexclusive(needs a Sway box)". The argv is sway's documented surface; both shapes are pinned by tests, because this file legitimately usesoutput <name> <verb>andfocus output <name>and getting one backwards is the live risk. The read-back turns a wrong guess into a warning naming the outputs rather than a screen that silently stays dark.hyprctl dispatch focusmonitor <n>is parsed as Lua and rejected, andhl.dsp.focusmonitordoes not exist. Documented onfocus_output; it belongs to the #283 follow-up, not here.Verification
cargo fmt --all --checkclean,cargo clippy --all-targets -- -D warningsclean.topology: exclusivewas echoed back by the API and dropped on Hyprland and sway