Skip to content

Add MultiWM - #5190

Open
Meteony wants to merge 23 commits into
ramensoftware:mainfrom
Meteony:MultiWM
Open

Add MultiWM#5190
Meteony wants to merge 23 commits into
ramensoftware:mainfrom
Meteony:MultiWM

Conversation

@Meteony

@Meteony Meteony commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Changelog

MultiWM

MultiWM is a continuation of the original Tiling Helper by U2X1. Explicit approval from the original author has been obtained, as they no longer intend on maintaining the original Tiling Helper. Overlap concerns should be treated as settled.

A lightweight, low-cortisol window manager for Windows 11 with true per-virtual-desktop layouts - including floating - and simple, predictable controls.

GIF

Windows 10 support is currently untested.

Features

  • First-class floating - use Floating as a whole-workspace layout
    or float individual windows while retaining useful geometry.
  • Automatic or Manual management - let MultiWM continuously discover
    and migrate windows, or keep explicitly tiled groups under your control.
  • True virtual-desktop layouts - uses Windows' existing virtual desktops
    instead of implementing a separate desktop system.
  • Event-driven - changes are signaled through native events rather than
    continuous polling.

Layouts

  • Floating
  • Master + Stack (vertical or horizontal)
  • Binary Space Partitioning (BSP)
  • Columns / Rows
  • Monocle

Getting Started

Move a tiled window to float it or swap it with the window underneath.

Hotkeys are available for tiling, changing layouts, moving windows through the logical
order, floating a window, and switching between Automatic and Manual management.

A small tray indicator shows the active workspace layout and provides quick access to layouts and management mode.

Mod authorship

If this pull request introduces a new mod, please complete the section below.

This mod was created by:

    • The submitter, without AI assistance
    • The submitter, with AI assistance
    • Claude
    • ChatGPT
    • Gemini
    • Another AI (please specify):
    • Other (please specify):

Please select the options that best apply. Your selection does not affect the acceptance criteria, but it helps reviewers understand the context of the code and provide relevant feedback.

@windhawk-reviewer

Copy link
Copy Markdown

Thanks for the pull request! This repository uses a two-stage review: an AI review that you run yourself, followed by a human review.

To get started, comment /ai-review. Once you're happy with the result, comment /ready-for-reviewer to hand it over to a human reviewer.

See the pull request review process for the full details.

@windhawk-reviewer windhawk-reviewer Bot added the waiting-for-author The author's turn: request an AI review, or respond to one that was posted. label Aug 23, 2026
@Meteony

Meteony commented Aug 23, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 23, 2026
@m417z

m417z commented Aug 23, 2026

Copy link
Copy Markdown
Member

Congratulations on being the first to submit a mod with an id shorter than 8 characters. Let me think about it, maybe we can lift this limitation.

@Meteony

Meteony commented Aug 23, 2026

Copy link
Copy Markdown
Contributor Author

Congratulations on being the first to submit a mod with an id shorter than 8 characters. Let me think about it, maybe we can lift this limitation.

I appreciate it.

@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


The architecture is genuinely good - the serialized STA actor, the "OS events are dirty signals, the model is authoritative" split, and the tool-mod packaging are all the right calls, and the settings block is fully in sync with the code. The findings below are mostly about defaults, one window-resolution heuristic, and scope.

1. The default hotkeys take over Alt+letter system-wide and break menu mnemonics.

RegisterConfiguredHotkeys registers eight global hotkeys with the default Alt modifier: Alt+T, Alt+L, Alt+M, Alt+F, Alt+R, Alt+I, Alt+, and Alt+.. A hotkey registered with RegisterHotKey is consumed by the system before it ever reaches the focused window, so with default settings Alt+F no longer opens the File menu, Alt+T no longer opens Tools, Alt+I no longer opens Insert, and so on - in every application. The original Tiling Helper only claimed three such combos; claiming eight makes this much harder to miss.

Please default TilingModifier to a combination that doesn't collide with menu mnemonics (ctrl+alt or alt+shift), and add MOD_NOREPEAT so holding a hotkey doesn't repeat the command (holding Alt+L currently cycles layouts as fast as the key repeats):

if (!RegisterHotKey(nullptr, id, modifiers | MOD_NOREPEAT, key)) {

2. ResolveEquivalentWindow can apply a command to the wrong window.

ResolveToTiledWindow falls back to ResolveEquivalentWindow, which returns the first tiled candidate that merely shares a PID and window class with the target. Two windows of the same app are indistinguishable to that heuristic, so the command lands on a sibling:

  • Two Chrome/Explorer windows on the same workspace, one tiled and one already floating. Focus the floating one and press the Float hotkey - Commands::FloatFocusedWindow resolves it to the tiled sibling and floats that one instead.
  • Same for SwapMaster, PromoteFocusedWindow/DemoteFocusedWindow, and Commands::ApplyUserMoveSize (dragging an untracked window of the same app can float or swap a tracked sibling). In Manual mode, where most windows are deliberately not members, this is easy to hit.

The function's own comment says it exists to bridge "apps that replace one top-level HWND with another", but it starts with if (!hwnd || !IsWindow(hwnd)) return nullptr; - so it never runs for a replaced/destroyed HWND, only for live ones, which is exactly the case where the guess is wrong. I'd drop the PID+class fallback entirely and keep only the exact/GA_ROOTOWNER/GW_OWNER resolution, letting the commands no-op when the focused window isn't a member.

3. Verify the tool process's DPI awareness before trusting any of the geometry.

GetMonitorEffectiveDpi has this fallback:

// GetScaleFactorForMonitor also guards against hosts
// whose DPI-awareness context makes GetDpiForMonitor report 96.

GetDpiForMonitor(MDT_EFFECTIVE_DPI) returns 96 precisely when the calling process is DPI-unaware, which suggests the WM thread is running unaware or system-aware. If so, the workaround only fixes the DPI number - every GetWindowRect/DwmGetWindowAttribute/SetWindowPos/MonitorFromRect/GetMonitorInfo call the mod makes is still going through DPI virtualization, so the tile rectangles will be wrong on mixed-DPI multi-monitor setups (which the mod explicitly advertises support for via per-monitor scaled gaps/insets).

The root-cause fix is one line at the top of HotkeyThreadProc, before any geometry work:

SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);

See mods/classic-min-max-animations.wh.cpp#L962 for the same pattern on a worker thread. Once that's in place the GetScaleFactorForMonitor fallback can go away too.

4. Manual mode silently admits every window when a layout command creates a workspace.

CycleCurrentWorkspaceLayout and SetCurrentWorkspaceLayout fall back to EnsureWorkspaceFromSnapshot(monitor, ...) when no workspace exists yet, and that always calls AdmitUntrackedSnapshot(workspace, snapshot, /*allowAdmission=*/true, false) regardless of management mode - then arranges. So a Manual-mode user who presses the layout hotkey (or picks a layout from the tray menu) on a monitor they never tiled gets all eligible windows pulled into the group and tiled at once. That directly contradicts the documented contract ("Manual admits only through explicit commands... new windows are not admitted"). Either pass IsAutomaticMode() through as allowAdmission on this path, or have the layout commands create an empty workspace with the chosen layout instead of seeding it from a snapshot.

5. Hotkey keys only parse as a single character, but the settings documentation advertises words.

ParseSingleCharKey looks at str[0] only. The TileKey description says Examples: T, D, Space, \, -andLayoutKeysaysExamples: L, Tab, =, ], /- but typingSpacebindsSandTabbindsT`, silently, with no log line. Either accept the documented names:

if (_wcsicmp(str, L"Space") == 0) return VK_SPACE;
if (_wcsicmp(str, L"Tab") == 0) return VK_TAB;
// ... Enter, Esc, F1-F12, arrows

or reject multi-character input (if (str[1]) return 0; plus a Wh_Log) and fix the descriptions. Adding F1-F12 would also give users an easy way out of the Alt+letter collisions in item 1.

6. The diagnostics subsystem is a large amount of surface for what it delivers.

Roughly 1,700 of the mod's 10,359 lines are the diagnostics report: ~90 lifetime counters, a ReportBuilder, CPU self-cost accounting, a telemetry-consistency audit, recursive directory creation, a UTF-8 file writer with BOM, and ExpandDiagnosticPath. It costs a user-facing settings entry, one of the eight global hotkeys, and a file-writing side effect in Documents - and Windhawk already gives every mod a log window via Wh_Log.

I'd cut it down hard: keep the state dump if you find it useful in the field, but emit it through Wh_Log and drop the file writer, the path expansion, the CPU/self-cost accounting, the counter table and the DiagnosticDumpKey/DiagnosticsOutputPath settings. That removes a hotkey collision, a filesystem side effect and a large maintenance burden in one go. If you keep the disk report, at minimum make the hotkey default to blank (disabled) so an accidental Alt+I doesn't write files.

7. Catalog overlap with Tiling Helper.

You state the original author approved the continuation, and I'll take that at face value - but Tiling Helper stays in the catalog after this merges, so users browsing windhawk.net will see two tiling mods with no indication of the relationship. Worth coordinating with the maintainer and u2x1 on a follow-up PR that adds a "superseded by MultiWM" note to Tiling Helper's README (or deprecates it), so this doesn't just fragment the catalog.

Optional improvements

Minor polish - none of this affects users, so it's your call.

  • Leftover "Tiling Helper" naming. The mod is called MultiWM, but users still see Tiling Helper in the tray tooltip (SetTooltip), and the code carries it in the window class names (WindhawkTilingHelperTrayWindow, WindhawkTilingHelperStatusFlyout), the report title/filename (TilingHelper-Diagnostics-...), the DiagnosticsOutputPath default, and log lines (Tiling Helper mod initializing...). Related: SettingsState::diagnosticsOutputPath defaults to MultiWMDiagnostics but LoadSettings overwrites it with TilingHelperDiagnostics, so the struct default is dead. Some comments also still say "Alt+D group" where the current default is Alt+T.

  • Dead code. AreSameWindows, IsCurrentlyManagedWindow, Layout::ComputeWeightedSizesWithFixed and WorkspaceRepository::ClearAfterWmStopped are defined but never called.

  • LoadLibraryW(L"Shcore.dll") in GetMonitorEffectiveDpi. Bare-name loads use the default search order, which includes the executable's directory. It's a non-issue here (the tool process runs from the Windhawk install dir), but the hardened form costs nothing: LoadLibraryExW(L"Shcore.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32).

  • Use WindhawkUtils::StringSetting consistently. LoadSettings, ReadHotkeySetting and ReadModifierSetting still use raw Wh_GetStringSetting + Wh_FreeStringSetting, while TrayUi::LoadSettings and the array loops already use the RAII wrapper. Also, Wh_GetStringSetting never returns NULL (it returns L""), so the !str / str && checks around it can go.

  • The SRW locks are probably redundant. WINEVENT_OUTOFCONTEXT hooks deliver their callbacks on the thread that called SetWinEventHook, i.e. WinEventProc runs on the WM thread inside its own message loop. If that's right, g_moveSize.lock and g_conformanceLeases.lock never see contention, and the "this callback is outside the model mutation boundary" comments describe a threading model that doesn't exist. Worth confirming and simplifying - AssertWmThread would already catch a violation.

  • Repeated OS queries per operation. MonitorId::Resolve() runs a full EnumDisplayMonitors pass and calls EnumDisplayDevicesW(..., EDD_GET_DEVICE_INTERFACE_NAME) per monitor, and it's called from ArrangeWorkspace, RepairFloatingGeometry, GetCurrentAuthoritativeTiledRect and MonitorsOnDesktop. IsWorkspaceOnActiveDesktop makes a cross-process COM call on every ArrangeWorkspace, and GetWorkspaceGapPixels/GetWorkspaceInsetsPixels each re-query the monitor DPI. A small cache invalidated by NotifyDisplayTopologyChanged (which you already have) would cut most of this.

  • wchar_t path[32768] in GetWindowProcessName is a 64 KB stack allocation, taken per window per enumeration when process exclusions are configured. DiagnosticReadWindowIdentity already heap-allocates the same buffer - do the same here, or just use MAX_PATH-ish sizing with a retry.

  • Unused dependencies. -loleaut32 doesn't appear to be needed (no BSTR/VARIANT use), and #include <objectarray.h> is unused.

  • Keep the tool-mod boilerplate verbatim. The snippet at the end is functionally identical to the wiki version (I diffed it), but it's been reformatted to the mod's 2-space style and its header comment points at PR 1916 rather than the wiki page. Pasting it unchanged makes future updates to the snippet diffable.

  • Consider adding @license. The mod has no license tag, and the virtual-desktop notification sink is closely modelled on Taskbar Desktop Indicator (MIT), which asks for attribution. A compatible @license plus a credit line in the README would tidy that up.

  • List the default hotkeys in the README. It currently says hotkeys exist but not what they are; users have to open the settings to find out.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • The global EVENT_OBJECT_LOCATIONCHANGE hook is the expensive one. It fires for every window movement system-wide, and the mod keeps it installed for the whole session. The filtering in WinEventProc is good, but you already know exactly when you need it: while a conformance lease is active, while a suspended-maximized member exists, or while monitor-ownership drift is possible. Installing/removing g_hooks.locationChange on demand would cut the idle cost of the mod noticeably. No objection if you'd rather keep it simple - just noting the tradeoff.

  • The conformance lease can fight an application for 3 seconds. kConformanceRepairMinIntervalMs = 75 allows ~13 SetWindowPos calls/second against a window that keeps restoring its own geometry, for the full ConformanceLeaseMs window. For an app that genuinely refuses (Electron apps restoring session bounds, some Java/Qt apps), the user sees a visible 3-second fight before the window is finally floated. Consider backing off exponentially rather than at a fixed cadence, or floating after N consecutive rejected reinforcements rather than waiting for the deadline.

  • SnapshotLooksTiled thresholds. Accepting an aggregate coverage of 85-115% (or 85-105% for 3+ windows) as "this looks tiled" will occasionally misfire on a maximized-ish floating window or a group of large overlapping windows, and then AdoptGeometry learns arbitrary geometry as authoritative weights. Not obviously wrong, just worth knowing it's a heuristic that can adopt a layout the user didn't intend.

  • ClassifyMoveSizeIntent's moveScore > resizeScore * 2 rule classifies a drag that also nudges the size (common with snap-assist or DWM edge rounding) as a resize, which then feeds LearnMasterStackResize/LearnGridResize with a bogus divider delta. The TransferPairResizeDelta clamping limits the damage, but a slow drift in the master ratio after repeated drags would be the symptom.

  • Virtual-desktop ABI gap. SelectVirtualDesktopAbiProfile deliberately returns false for Explorer builds 22483-22620, which disables the mod entirely on those builds (no workspace key can be produced). That's a defensible choice, but it's silent apart from a log line - worth a note in the README next to the "Windows 10 support is currently untested" line.

  • Enabling the mod immediately rearranges every window on every monitor of the current desktop, since DefaultWindowManagementMode is automatic. That's the point of the mod, but a one-line heads-up in the README ("on first enable, Automatic mode will tile your existing windows") would set expectations.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 23, 2026
@m417z

m417z commented Aug 23, 2026

Copy link
Copy Markdown
Member

Updated limit to 6.

@Meteony

Meteony commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author
  1. The default hotkeys are based on the ones I use personally. Most users rarely use the default Alt+Modifier bindings that came with their system. For the frequency at which the mod-defined hotkeys are expected to be hit, Alt seems to be the more ergonomic choice for most users. Alt+T for tiling is also easier to remember than other binding - Alt+D for example. I consider that a fair trade-off. If a user does end up finding the collision inconvenient, it's just a one-line configuration change anyway.
    The next part of the finding is valid. I have added KEY_NOREPEAT to hotkeys other than window promotion and demotion, since those do benefit from rapid repeat events.

  2. Valid. Dropped the fallback path.

  3. The dedicated tool process is windhawk.exe, whose manifest is already PerMonitorV2, and newly created threads inherit that process DPI context, so the WM thread is not currently DPI-unaware/system-aware. That said, explicitly setting PMv2 at the start of the WM thread makes sense.

  4. That was inherited from the original Tiling Helper, which had the exact same semantics. That said, making it so that layout commands create an empty space in manual mode is the more internally consistent model. The last commit made it exact.

  5. Addressed.

  6. The diagnostics system has turned out quite useful during development, so I won't scrap it just yet. Dumping a plain .txt file also offers better formatting and readability over plain logging. That said, I have changed it to be disabled by default.

  7. Noted. The README change will happen after this merges.

@Meteony

Meteony commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 24, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


The previous round's findings are all addressed correctly: the PID+class resolution fallback is gone, the WM thread sets PMv2 explicitly, Manual mode no longer mass-admits windows when a layout command creates a workspace, ParseHotkeyKey accepts the documented key names (and rejects the rest with a log line), MOD_NOREPEAT is applied everywhere except promote/demote, and the naming/license/attribution cleanup is done. The lock removal is also sound - WINEVENT_OUTOFCONTEXT callbacks really are delivered on the thread that installed the hook, so AssertWmThread is the right guard and the actor is genuinely single-threaded. What's left is one efficiency item plus two carry-overs.

1. Every window movement on the system costs a DWM round trip plus a display-topology enumeration.

WinEventProc's EVENT_OBJECT_LOCATIONCHANGE path ends in HasTrackedMonitorOwnershipMismatch(hwnd) for every tracked window that isn't suspended-maximized and has no active lease - which is the common case, since EVENT_SYSTEM_MOVESIZESTART cancels the lease before a drag begins. That helper calls GetWindowPhysicalMonitor (DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS), a DWM round trip) and then MonitorId::FromHMonitor, which is GetMonitorInfoW + EnumDisplayDevicesW(..., EDD_GET_DEVICE_INTERFACE_NAME). LOCATIONCHANGE fires per moved frame, so dragging or animating one managed window runs that whole sequence ~60-100 times per second, in a background process, for the whole session.

The queued side repeats the same work: MonitorId::Resolve() is a full EnumDisplayMonitors pass that calls FromHMonitor (i.e. EnumDisplayDevicesW) for each connected monitor, and it runs from ArrangeWorkspace, GetCurrentAuthoritativeTiledRect, RepairFloatingGeometry and MonitorsOnDesktop; GetWorkspaceGapPixels and GetWorkspaceInsetsPixels each re-query GetMonitorEffectiveDpi on top of that. So a single lease reinforcement performs at least one full topology sweep, and an arrange performs several.

This one has a clean fix and you already have the invalidation point:

  • Cache HMONITOR -> {MonitorId, dpi} (and the reverse lookup used by Resolve()), populate it lazily, and clear it from RuntimeLifecycle::NotifyDisplayTopologyChanged(), which already covers WM_DISPLAYCHANGE / WM_DPICHANGED / SPI_SETWORKAREA. Keep IsLiveMonitorHandle() as the cheap validity check on the fast path.
  • Now that HotkeyThreadProc sets DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, the GetScaleFactorForMonitor fallback in GetMonitorEffectiveDpi is dead weight - and it's called on every invocation, not only when GetDpiForMonitor fails. At minimum make it conditional on !haveDpi; better, drop it.

With the cache in place the remaining per-event cost is just the DWM query, which is reasonable. (The related note from last round still stands: gating g_hooks.locationChange on "some lease is active or some member is suspended-maximized" would take the idle cost to zero, but it's optional once the identity work is cached.)

2. Tiling Helper still ships alongside MultiWM.

Carried over from last round and still open. I take the author approval at face value - the concern is only that after this merges, windhawk.net lists two tiling mods with no indication of the relationship. Worth agreeing with the maintainer (and u2x1) on a follow-up that adds a "superseded by MultiWM" note to Tiling Helper's README, or deprecates it.

3. Default Alt+letter hotkeys.

You've already responded to this and I won't re-argue it - recording it only so the human reviewer sees it's a deliberate choice rather than an oversight. The concrete part (MOD_NOREPEAT) is done.

Optional improvements

Minor polish - none of this affects users, so it's your call.

  • assert() in AssertWmThread aborts the tool process. Windhawk builds mods without NDEBUG (see .vscode/c_cpp_properties.json), so assert(currentThread == expectedThread) is live in release: if it ever trips in the field, the CRT calls abort() and the dedicated process dies, taking the window manager with it. The Wh_Log line directly above already records everything useful - drop the assert (and <cassert>) and let the mod continue. No other merged mod uses assert in shipped code.

  • RegisterClass failure isn't tracked, so the class can leak across a settings reload. TrayUi::Initialize continues when RegisterClassW fails with ERROR_CLASS_ALREADY_EXISTS, but leaves g_trayWindowClass == 0, and Shutdown only unregisters when that ATOM is non-zero - so the surviving registration is never cleaned up afterwards. It's benign here (dedicated process, same module image, so the WndProc can't dangle), but calling UnregisterClassW unconditionally in Shutdown closes the loop.

  • Consider declaring @architecture x86-64. VirtualDesktopNotification_NoOp() (no parameters) and VirtualDesktopNotification_CurrentChanged(self) are installed in vtable slots the caller invokes with more arguments. That's harmless under the x64 convention, but under x86 __stdcall the callee-side stack cleanup wouldn't match. It's currently unreachable on x86 (the sink is only built for build >= 22000), so this is about making the assumption explicit and matching taskbar-desktop-indicator, which the VD integration is modelled on.

  • Add a way to turn the status flyout off. FlyoutPosition offers top/bottom only, and ShowDesktopSwitchFlyouts puts an OSD on every monitor on every desktop switch. An off option (or a separate boolean) would be an easy win for users who don't want the OSD.

  • Unused dependencies, still. -loleaut32 doesn't appear to be needed (no BSTR/VARIANT use) and #include <objectarray.h> is unused.

  • wchar_t path[32768] in GetWindowProcessName is still a 64 KB stack allocation taken per window per enumeration when process exclusions are configured; DiagnosticReadWindowIdentity already heap-allocates the same buffer.

  • Duplicated block in DumpPlatformHealth. The "Explorer shell identity" and "Explorer shell binding" lines re-query GetShellWindow()/GetWindowThreadProcessId and print the same three values twice.

  • Keep the tool-mod boilerplate verbatim. I diffed it again: functionally identical to the wiki snippet, but still re-wrapped to the mod's style, and the header comment points at PR 1916 rather than the wiki page. Pasting it unchanged keeps future updates diffable.

  • README typo: "switching between Automatic and Manual management (Alt R)" - missing +.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • A managed window that goes fullscreen keeps its tile. GetPhysicalSuspensionReason only recognizes minimized / hidden / maximized, and WindowCanBeManaged deliberately tolerates a tracked window losing WS_SIZEBOX - so a tracked window that enters fullscreen (F11 in a browser, a game switching from windowed to borderless) stays an active tiled member. Nothing re-tiles it immediately (its LOCATIONCHANGE is filtered out because the monitor didn't change), but the next arrange from any unrelated cause - a new window, a minimize, a desktop switch - yanks it back into its slot mid-video. Most tiling WMs treat "frame covers the whole monitor" as a fullscreen state; adding a SuspensionReason::Fullscreen alongside Maximized would reuse the existing suspend/restore slot machinery.

  • Keyboard snapping is handled inconsistently. Win+arrow doesn't emit EVENT_SYSTEM_MOVESIZESTART/END, so it never reaches ApplyUserMoveSize. Inside the 3s conformance lease the mod fights the snap and restores the tile; after the lease expires the snapped window simply stays off-tile until the next arrange. A mouse drag of the same window would have floated it. Not wrong, just two different outcomes for what the user experiences as the same action.

  • Earlier functionality notes still stand and were not addressed (no objection, just noting they're still accurate): the conformance lease can visibly fight an app for up to 3s at ~13 reinforcements/second; SnapshotLooksTiled's 85-115% coverage window is a heuristic that can adopt geometry the user didn't intend; ClassifyMoveSizeIntent's moveScore > resizeScore * 2 rule can feed a bogus divider delta to LearnMasterStackResize after a snap-assisted drag; and SelectVirtualDesktopAbiProfile silently disables the mod on Explorer builds 22483-22620, which is worth a README line next to the "Windows 10 support is currently untested" note.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 24, 2026
@Meteony

Meteony commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author
  1. Caching monitor identity is the right call. DPI on the other hand is a mutable identity and should be re-queried from fresh. I'll keep the cache scoped to monitors instead of introducing DPI.

@Meteony

Meteony commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

/ready-for-reviewer

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-reviewer Ready for a human reviewer, and in the queue for one. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 24, 2026
@u2x1

u2x1 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

cool!

@m417z

m417z commented Aug 28, 2026

Copy link
Copy Markdown
Member

Regarding:

  • Windhawk 2.0 (or higher) on a 64-bit system is recommended for the best experience; for x86 builds, virtual desktop switch events are inferred at best effort due to the lack of documented ABIs.

It will take time until 1.7.3 is phased out. For the time being, given that making it work for a 32-bit process requires more effort, perhaps it's better to just have a tool mod targeting explorer.exe. Just changing the target process from windhawk.exe to explorer.exe should be enough. Let me know what you think.

@m417z m417z added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-reviewer Ready for a human reviewer, and in the queue for one. labels Aug 28, 2026
@Meteony

Meteony commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

Regarding:

  • Windhawk 2.0 (or higher) on a 64-bit system is recommended for the best experience; for x86 builds, virtual desktop switch events are inferred at best effort due to the lack of documented ABIs.

It will take time until 1.7.3 is phased out. For the time being, given that making it work for a 32-bit process requires more effort, perhaps it's better to just have a tool mod targeting explorer.exe. Just changing the target process from windhawk.exe to explorer.exe should be enough. Let me know what you think.

That might work. Thanks.

@Meteony

Meteony commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-ai-review An AI review was requested and is being prepared. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 29, 2026
@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


The host-process switch does what it was meant to: explorer.exe is 64-bit on 64-bit Windows, so _WIN64 is defined and the native virtual-desktop notification sink is available even on Windhawk 1.7.3, where windhawk.exe was 32-bit. The floating-placement refactor is also correct - moving the override lookup into RepairFloatingGeometry means a record with no remembered rectangle now gets the same rule treatment as a newly centered window, needsFallbackSize gates it to exactly those two cases, and the $description was updated to match. Two items this round, both consequences of the new host.

1. The dedicated process is now a real explorer.exe, so every other explorer.exe mod is loaded into it.

Windhawk selects mods by the target process's image, and the tool process's image is C:\Windows\explorer.exe. So explorer.exe -tool-mod "multiwm" gets the Windhawk engine plus every enabled mod whose @include matches explorer.exe - around 270 mods in the catalog target it. That has a few consequences worth making explicit:

  • Those mods run Wh_ModInit/Wh_ModAfterInit in a process whose entry point is hooked to ExitThread(0), so no shell code ever runs there. Purely hook-based mods no-op harmlessly (GetModuleHandle(L"taskbar.dll") is null, LoadLibraryExW hooks never fire), but a mod that does unconditional startup work - resolving symbols against explorer.exe's own PDB, creating windows/threads/timers, adding a tray icon - does it a second time here.
  • A crash or a hang in any of those unrelated mods now takes down the window manager with the process, and nothing restarts it until the next explorer.exe launch.
  • In the other direction, Wh_ModUninit ends with ExitProcess(0), so disabling or updating MultiWM tears the process down under those mods without their Wh_ModUninit running.
  • Users also see a second explorer.exe in Task Manager, and taskkill /im explorer.exe (the usual "restart Explorer" recipe) takes it along with the shell - that is the state loss the README note describes.

There is precedent - simple-window-switcher ships @include windhawk.exe plus @include explorer.exe with the same boilerplate - and this may well be exactly the trade m417z had in mind. The ask is just to make it a stated decision rather than a side effect: extend the README note to say that the mod runs in its own dedicated explorer.exe process and why that is the default (a 64-bit host on Windhawk 1.7.x), so the extra process and the Explorer-restart coupling aren't a surprise. The alternative m417z originally suggested - one VirtualDesktopNotification_NoOp per prototype, keeping windhawk.exe as the host - is still available if the coupling turns out to matter.

2. Default hotkeys claim five Alt+letter combinations system-wide.

Recorded once more only so the human reviewer sees it, not to re-argue it: RegisterConfiguredHotkeys still defaults to MOD_ALT with T, L, M, R, F, ,, ., and RegisterHotKey consumes those before the foreground window sees them, so Alt+F (File), Alt+T (Tools), etc. stop working in every application while the mod is enabled. You've explained that this is deliberate and that the modifier is a one-line change for anyone it bothers; MOD_NOREPEAT is applied everywhere except promote/demote, which is right. The only cheap thing left would be a sentence in the TilingModifier $description warning that Alt+<key> shadows menu mnemonics.

Optional improvements

Minor polish - none of this affects users, so it's your call. Most carry over from the previous rounds and still apply.

  • SysWOW64\explorer.exe can become the host. @include explorer.exe matches by file name, so the 32-bit C:\Windows\SysWOW64\explorer.exe is a target too, and Wh_ModAfterInit uses GetModuleFileName(nullptr) - it would spawn a 32-bit SysWOW64\explorer.exe -tool-mod "multiwm". If it wins the mutex (e.g. the 64-bit worker died first), the WM runs in the exact 32-bit host the change was meant to avoid, with the notification sink compiled out. It's rare - that binary is only launched when something targets it explicitly - but a IsWow64Process(GetCurrentProcess(), &wow64) bail-out in the launcher branch would close it for one line.
  • Every explorer.exe launch spawns a throwaway tool process. Wh_ModAfterInit runs in every explorer.exe instance, including the transient ones a folder open / "Open file location" / explorer.exe <path> creates, so each of those starts another explorer.exe -tool-mod "multiwm" that loads the mod, sees ERROR_ALREADY_EXISTS and ExitProcess(1)s. Harmless, but with "Launch folder windows in a separate process" enabled it happens per Explorer window.
  • AssertWmThread can abort() the tool process. Still open: after logging the violation it calls assert(currentThread == expectedThread), and mods are built without NDEBUG, so that's a live abort() in the shipped binary. The Wh_Log above it already carries the diagnostic value; no merged mod uses runtime assert.
  • WM_APP_TRAY_REFRESH is dead. It's declared and handled in HandleWmThreadMessage, but nothing posts it - WM_APP_FOREGROUND_CHANGED covers that path now.
  • GdiFlush() before reading the DIB bits in CreateTextIcon. DrawTextW goes through the per-thread GDI batch, so the pixels[i] = ... loop can read the DIB section before GDI has finished drawing into it; the documented requirement on CreateDIBSection is to GdiFlush() first. Symptom would be an occasional blank or partially-drawn tray glyph. One line after the DrawTextW/SelectObject block, same as desktop-live-overlay.wh.cpp#L2005.
  • LayoutGridWeighted is missing the weight-count guard its sibling has. LayoutMasterStackWeighted defends itself (if (weights.size() != stackCount) weights = DefaultWeights(stackCount);), but the grid path indexes sizes[i] for i < windowCount while ComputeWeightedSizes returns weights.size() entries. EnsureWeights() guarantees they match today, so it can't trigger - two lines would keep a future invariant slip a logged validation failure instead of an out-of-bounds read.
  • Flyout windows for removed monitors are never reclaimed. FindFlyoutForMonitor keys on HMONITOR, so after a topology change the old instances never match again and a fresh window is created per monitor, leaving stale ones alive until Shutdown. RuntimeLifecycle::NotifyDisplayTopologyChanged already clears the monitor identity cache and is the natural place to drop instances whose monitor no longer passes IsLiveMonitorHandle.
  • TrayUi::Initialize still treats ERROR_CLASS_ALREADY_EXISTS as success. Shutdown destroys every window of both classes before unregistering them, so the class should never pre-exist; the tolerance can only mask a failed teardown, and it leaves g_trayWindowClass/g_flyoutWindowClass at 0 so the next Shutdown skips UnregisterClass entirely. Harmless here because unload goes straight to ExitProcess, but treating it as a hard failure would be more honest about the invariant.
  • StartHotkeyThread waits INFINITE. The wait set includes the thread handle, so a thread that dies is covered - but a thread that starts and wedges leaves a stuck explorer.exe -tool-mod with no tray icon and no way out but Task Manager, and this runs before the host's entry point. tiling-helper and virtual-desktop-helper both use a 5000 ms bound.
  • A blank window rule in the middle of the list silently drops every rule after it. LoadSettings breaks out of the Rules[%d] loop when process, class and title are all empty. There is a Wh_Log line, but logging is off by default; continue costs 64 cheap setting reads and removes the footgun.
  • Unused dependencies. #include <objectarray.h> is unused (nothing references IObjectArray; the monitor-collection parameter is taken as void*), and -loleaut32 is dead - there is no BSTR, VARIANT or SAFEARRAY anywhere in the file. -ladvapi32 is already in mingw-w64's default link set, so it's redundant for the single RegGetValueW.
  • GetWindowProcessName puts a 64 KB wchar_t path[32768] on the stack and re-queries per call. With any process rule configured, FindMatchingWindowRule runs OpenProcess + QueryFullProcessImageNameW for every candidate on every EnumWindows pass - and the placement-override lookup now also reaches it from RepairFloatingGeometry. WindowRecord already stores pid; caching the resolved image name next to it, or at least matching DiagnosticReadWindowIdentity's heap std::vector<wchar_t>, would avoid both.
  • GetScaleFactorForMonitor still runs on every GetMonitorEffectiveDpi. The WM thread is PMv2, so GetDpiForMonitor is authoritative and the second Shcore call is dead weight on the happy path; apis.getScale && (!haveDpi || ...) would keep the fallback while skipping it.
  • DumpPlatformHealth prints the Explorer shell identity twice. The "Explorer shell identity" and "Explorer shell binding" lines re-run GetShellWindow + GetWindowThreadProcessId and report the same three values in a different order.
  • Tool-mod boilerplate header comment. The snippet itself matches the wiki version, but its header comment still points at PR 1916 rather than the wiki page, and now also says "The mod will load and run in a dedicated windhawk.exe process", which is no longer true.
  • FlyoutPosition still has no off value for users who don't want the desktop-switch OSD on every monitor.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • The README lost the platform caveats. The old note ("Windows 10 support is currently untested" / "for x86 builds, virtual desktop switch events are inferred at best effort") is gone, but the behavior it described is unchanged: SelectVirtualDesktopAbiProfile only fills profile.notification for build >= 22000 and _WIN64, so every Windows 10 build - 64-bit included - falls back to settled cloak/uncloak inference, and Explorer builds 22483-22620 are rejected outright, which leaves the mod completely inert there (no ABI -> no GetCurrentDesktopId -> no workspace key), with nothing but a log line to say why. Worth a line in the new Notes section.
  • Which windows end up permanently floated is still undocumented. kPlacementTolerancePx = 4 decides whether an application "accepted" its tile, so apps that quantize their own size, are aspect-locked, or whose minimum size exceeds the slot land outside it every time and get floated at the conformance deadline with no indication of why. A README line ("an app that refuses its assigned rectangle is floated automatically") would save users from thinking the mod is broken.
  • Per-candidate cost of the eligibility query. GetCurrentManagedWindowMonitor ends in IsWindowCloaked (a DWM call) and IsWindowOnCurrentDesktopSafe (a cross-process COM call to Explorer) for every window that survives the cheap checks, so each EnumWindows pass costs roughly one DWM plus one COM round trip per real user window. The cheap predicates are correctly ordered first and the comments explain why the cloak bit alone isn't trusted, so there's no obvious better answer - just noting the shape of the cost.
  • PlaceWindowChecked is synchronous by design, so a slow app stalls the actor. The IsHungAppWindow preflight covers the already-wedged case, but a window whose thread is merely slow still makes SetWindowPos wait, and hotkeys, tray commands and queued WinEvents wait behind it. SWP_ASYNCWINDOWPOS would avoid the block but destroy the read-back-and-verify contract the conformance model is built on, so there's no clean alternative - just be aware of it.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-author The author's turn: request an AI review, or respond to one that was posted. and removed waiting-for-ai-review An AI review was requested and is being prepared. labels Aug 29, 2026
@Meteony

Meteony commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

/ready-for-reviewer

@windhawk-reviewer windhawk-reviewer Bot added waiting-for-reviewer Ready for a human reviewer, and in the queue for one. and removed waiting-for-author The author's turn: request an AI review, or respond to one that was posted. labels Aug 29, 2026
@windhawk-reviewer

Copy link
Copy Markdown

New commits were pushed, so this pull request left the human review queue and is back to waiting-for-author.

Comment /ai-review to get an AI review of the updated code, then /ready-for-reviewer to hand it over to a human reviewer again. See the pull request review process for details.

@Meteony

Meteony commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


Since the last round: the host-process change is settled with the maintainer and the README documents it, the TilingModifier $description now warns about Alt mnemonics, and the native-fullscreen fallback in GetPhysicalSuspensionReason is threaded consistently through every physical-state check (ArrangeWorkspace, GetCurrentManagedWindowMonitor, the conformance paths, MakeWindowRecord), with re-checks at the mutation boundaries so a window that enters fullscreen mid-operation can't be converted to Floating by a stale verdict. Every declared setting is still read with a matching type, and the tool-mod boilerplate is a verbatim copy of the wiki snippet. Two items this round; the first is new, the second is a carry-over I think is worth promoting.

1. A single per-window virtual-desktop query failure tears down and rebuilds the whole VD COM stack, and can latch into a ~1 s rebuild loop.

IsWindowOnCurrentDesktopSafe and GetWindowDesktopIdSafe treat any FAILED(hr) as evidence that the proxy is dead:

HRESULT hr = g_vd.desktopManager->IsWindowOnCurrentVirtualDesktop(hwnd, onCurrent);
if (FAILED(hr) && ReinitializeVirtualDesktopAPI() && g_vd.desktopManager) {
  hr = g_vd.desktopManager->IsWindowOnCurrentVirtualDesktop(hwnd, onCurrent);
}
if (FAILED(hr)) RuntimeLifecycle::RequestMaintenance(false);

But these are per-window queries, and a per-window HRESULT is routine: the HWND can die between EnumWindows handing it out and the call (ERROR_INVALID_WINDOW_HANDLE), and IVirtualDesktopManager also refuses some windows outright. The reaction is disproportionate on two levels:

  • ReinitializeVirtualDesktopAPI() runs CleanupVirtualDesktopAPI() - Unregister on Explorer's notification service plus Release on all three proxies, i.e. blocking cross-process calls - then two CoCreateInstance calls and a QueryService, and finally RequestMaintenance(true), which re-registers the sink. All of that happens inside the EnumWindows callback (GetCurrentManagedWindowMonitorIsWindowOnCurrentDesktopSafe; likewise CollectWorkspaceWindowsForInitialization and GetTileWindowsAfterDesktopSwitch via GetWindowDesktopIdSafe). One enumeration containing N failing HWNDs performs N full teardown/rebuild cycles mid-sweep.

  • It doesn't settle. RequestMaintenance always sets g_wm.lifecycleRetryAfterPlatformRecovery = true and arms the 1 s timer. RunMaintenanceNow then succeeds (the core was just rebuilt), sees recoveredDeferredWork, and does:

    g_wm.forceMonitorReconcile = true;
    Reconcile::ScheduleLifecycleReconcile(nullptr);

    which runs a full settled reconcile - EnumWindows over the desktop again - which hits the same window, which calls RequestMaintenance again. Any window that fails persistently keeps this cycling roughly once a second for the rest of the session: a COM teardown/rebuild, an Unregister/Register round trip against Explorer, a full window enumeration and a forced arrange of every current workspace, forever, for one unclassifiable HWND.

The distinction the code needs is "the platform is broken" vs. "I couldn't classify this one window". Only the former justifies reinitialization or a maintenance request:

// Only a dead/disconnected proxy justifies rebuilding the VD stack. A per-window
// HRESULT (e.g. the HWND died between EnumWindows and this call) is just an
// unknown answer for that window.
static bool IsDeadVirtualDesktopProxy(HRESULT hr) {
  switch (hr) {
    case RPC_E_DISCONNECTED:
    case RPC_E_SERVERFAULT:
    case CO_E_OBJNOTCONNECTED:
    case HRESULT_FROM_WIN32(RPC_S_SERVER_UNAVAILABLE):
    case HRESULT_FROM_WIN32(RPC_S_CALL_FAILED):
      return true;
    default:
      return false;
  }
}

HRESULT hr = g_vd.desktopManager->IsWindowOnCurrentVirtualDesktop(hwnd, onCurrent);
if (FAILED(hr) && IsDeadVirtualDesktopProxy(hr) &&
    ReinitializeVirtualDesktopAPI() && g_vd.desktopManager) {
  hr = g_vd.desktopManager->IsWindowOnCurrentVirtualDesktop(hwnd, onCurrent);
}
if (FAILED(hr) && IsDeadVirtualDesktopProxy(hr)) {
  RuntimeLifecycle::RequestMaintenance(false);
}
return SUCCEEDED(hr);

GetCurrentDesktopId is the one place where a failure really is a global signal, so it can keep the current behavior. (A diagnostic counter for "per-window VD query failures" would also make this visible in the report, where VD API reinitializations currently just climbs with no indication of which window caused it.)

2. LOCATIONCHANGE during a drag still probes the monitor and can migrate the window the user is holding.

Raised in the previous two rounds as optional; promoting it because the visible half is a real artifact, not just a cost. In ProcessWindowLifecycleEvent:

case EVENT_OBJECT_LOCATIONCHANGE:
  if (!Platform::WindowEvents::HasTrackedMonitorOwnershipMismatch(hwnd) &&
      HandleTiledWindowLocationChange(hwnd)) {
    break;
  }
  ReconcileManagedWindowStateNow(
      hwnd, ReconcileScope::Participation | ReconcileScope::Monitor);
  ScheduleLifecycleReconcile(hwnd);
  break;

HandleTiledWindowLocationChange's IsMoveSizeGestureInProgress bail-out is behind the mismatch probe, so during a drag of a tiled window you still pay GetWindowPhysicalMonitorDwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS) per frame (~60-100/s - the mod's only sustained per-frame cross-process call), and the moment the frame crosses a monitor boundary the mismatch branch runs for real: ReconcileWindowOwnershipEnsureUniqueWindowOwnershipMigrateManagedWindow, then ArrangeWorkspace on both workspaces. If the destination workspace is in the Floating layout, MigrateManagedWindow also calls RepairFloatingGeometry - and because PeekMoveSizeGesture has no end rect yet, the hint is PassiveRestore, so it issues a SetWindowPos that re-centres the window the user is still dragging. The move loop overrides it on the next mouse message, so the net effect is the window fighting the cursor while crossing monitors.

HandleMoveSizeEndMessage already runs ReconcileScope::Monitor for this HWND authoritatively at MOVESIZEEND, so nothing is lost by skipping the mid-drag work entirely:

case EVENT_OBJECT_LOCATIONCHANGE:
  // A drag is authoritative until MOVESIZEEND, which reconciles monitor
  // ownership for this HWND. Don't probe or migrate underneath the user.
  if (IsMoveSizeGestureInProgress(hwnd)) break;
  if (!Platform::WindowEvents::HasTrackedMonitorOwnershipMismatch(hwnd) && ...
Optional improvements

Minor polish - none of this affects users, so it's your call. Most of these carry over from earlier rounds and still apply.

  • assert() in AssertWmThread can abort() the tool process. Confirmed against the repo's build defines: .vscode/c_cpp_properties.json defines UNICODE/WH_MOD/NOMINMAX and friends but not NDEBUG, so assert(currentThread == expectedThread) is live in the shipped binary - a thread-affinity slip would take the window manager down instead of leaving a log line. The Wh_Log immediately above already carries the diagnostic value. (assert(valid) in DebugValidateMutation is fine - it's behind MULTIWM_DEBUG_VALIDATE.)
  • WM_APP_TRAY_REFRESH is dead. Declared at the top and handled in HandleWmThreadMessage, but nothing posts it - WM_APP_FOREGROUND_CHANGED covers that path now.
  • GdiFlush() before reading the DIB bits in CreateTextIcon. DrawTextW goes through the per-thread GDI batch, so the pixels[i] = ... loop can read the DIB section before GDI has finished drawing into it; the documented requirement on CreateDIBSection is to GdiFlush() first. Symptom would be an occasional blank or partially-drawn tray glyph. One line, same as desktop-live-overlay.wh.cpp#L2005.
  • LayoutGridWeighted is missing the weight-count guard its sibling has. LayoutMasterStackWeighted defends itself (if (weights.size() != stackCount) weights = DefaultWeights(stackCount);), but the grid path indexes sizes[i] for i < windowCount while ComputeWeightedSizes returns weights.size() entries. EnsureWeights() guarantees they match today, so it can't trigger - two lines would keep a future invariant slip a logged validation failure instead of an out-of-bounds read.
  • StartHotkeyThread waits INFINITE. The wait set includes the thread handle, so a thread that dies is covered - but a thread that starts and wedges leaves a stuck explorer.exe -tool-mod with no tray icon, and this runs before the host's entry-point hook. tiling-helper and virtual-desktop-helper both bound it at 5000 ms.
  • Flyout windows for removed monitors are never reclaimed. FindFlyoutForMonitor keys on HMONITOR, so after a topology change the old instances never match again and a fresh window is created per monitor. RuntimeLifecycle::NotifyDisplayTopologyChanged already clears the monitor identity cache and is the natural place to drop instances whose monitor no longer passes IsLiveMonitorHandle.
  • A blank window rule in the middle of the list silently drops every rule after it. LoadSettings breaks out of the Rules[%d] loop when process, class and title are all empty. There's a Wh_Log line, but logging is off by default; continue costs 64 cheap setting reads and removes the footgun.
  • TrayUi::Initialize still treats ERROR_CLASS_ALREADY_EXISTS as success, leaving the ATOM at 0 so the next Shutdown skips UnregisterClassW entirely. Harmless in a dedicated process that ExitProcesses on unload, but the tolerance can only mask a failed teardown.
  • SysWOW64\explorer.exe can become the host. @include explorer.exe matches by file name, and Wh_ModAfterInit uses GetModuleFileName(nullptr), so a 32-bit SysWOW64\explorer.exe launcher would spawn a 32-bit tool process - the exact host the change to explorer.exe was meant to avoid, with the notification sink compiled out. An IsWow64Process bail-out in the launcher branch closes it in one line.
  • ReconcileKnownWindow's HWND-reuse pre-check deep-copies a whole Workspace per owner to read one DWORD. A targeted repository accessor - same shape as WorkspaceRepository::IsTiled / IsSuspendedMaximized - would read record->pid under the shared lock with no allocation.
  • GetWindowProcessName puts a zero-initialised 64 KB wchar_t path[32768] on the stack and re-queries per call. With any process rule configured it runs OpenProcess + QueryFullProcessImageNameW per candidate per EnumWindows pass; DiagnosticReadWindowIdentity already heap-allocates the same buffer, and WindowRecord already stores pid if you want to cache the name next to it.
  • GetScaleFactorForMonitor still runs on every GetMonitorEffectiveDpi. The WM thread is PMv2, so GetDpiForMonitor is authoritative; apis.getScale && (!haveDpi || ...) keeps the fallback while skipping it on the happy path.
  • Unused dependencies. #include <objectarray.h> is unused (nothing references IObjectArray; the monitor-collection parameter is a void*), and -loleaut32 is dead - no BSTR, VARIANT or SAFEARRAY anywhere in the file. -ladvapi32 is already in mingw-w64's default link set for the single RegGetValueW.
  • DumpPlatformHealth prints the Explorer shell identity twice - the "Explorer shell identity" and "Explorer shell binding" lines re-run GetShellWindow + GetWindowThreadProcessId and report the same three values in a different order.
  • Tool-mod boilerplate header comment. The snippet matches the wiki version, but its comment still points at PR 1916 rather than the wiki page, and the surrounding comment still says "The mod will load and run in a dedicated windhawk.exe process", which is no longer true for this mod.
  • FlyoutPosition still has no off value for users who don't want the desktop-switch OSD on every monitor.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • The new fullscreen fallback has its "this is the window's own tile" exemption on only one path. GetPhysicalSuspensionReason now reports Maximized for any restored window whose GetWindowRect matches rcMonitor within 2 px, and ReconcileWindowParticipation correctly exempts a tiled member that already sits on its authoritative rectangle - but GetCurrentManagedWindowMonitor and ArrangeWorkspace don't, so a window that is legitimately supposed to fill the monitor drops out of CollectTileWindows and is skipped by the placement loop. It takes a specific setup to reach (insets ≤ 2 DIP, a monitor whose rcWork == rcMonitor - e.g. a secondary display with no taskbar - and a window whose DWM extended frame equals its window rect, so PlaceWindowChecked's border compensation doesn't push it outside the monitor), and the usual invisible resize border keeps normal WS_SIZEBOX windows clear of it. But if it does happen the state latches: the window is never re-placed by an arrange, and once a second window appears it gets suspended and left overlapping the layout. Factoring the exemption into a shared helper that all three call sites use would close it.
  • The README lost the platform caveats. SelectVirtualDesktopAbiProfile fills profile.notification only for build >= 22000 and _WIN64, so every Windows 10 build - 64-bit included - falls back to settled cloak/uncloak inference, not just x86. Explorer builds 22483-22620 are rejected outright, which leaves the mod completely inert there (no ABI → no GetCurrentDesktopId → no workspace key) with nothing but a log line to say why. Both are worth a line in the new Notes section.
  • Which windows end up permanently floated is still undocumented. kPlacementTolerancePx = 4 decides whether an application "accepted" its tile, so apps that quantize their own size, are aspect-locked, or whose minimum size exceeds the slot land outside it every time and get floated at the conformance deadline with no indication of why. A README line ("an app that refuses its assigned rectangle is floated automatically") would save users from thinking the mod is broken.
  • Per-candidate cost of the eligibility query. GetCurrentManagedWindowMonitor ends in IsWindowCloaked (a DWM call) and IsWindowOnCurrentDesktopSafe (a cross-process COM call to Explorer) for every window that survives the cheap checks, and the fullscreen fallback added GetWindowRect + MonitorFromRect + GetMonitorInfoW on top. The cheap predicates are correctly ordered first and the comments explain why the cloak bit alone isn't trusted, so there's no obvious better answer - just noting the shape of the cost, since settled lifecycle bursts can run this several times a second.
  • PlaceWindowChecked is synchronous by design, so a slow app stalls the actor. The IsHungAppWindow preflight covers the already-wedged case, but a window whose thread is merely slow still makes SetWindowPos wait, and hotkeys, tray commands and queued WinEvents wait behind it. SWP_ASYNCWINDOWPOS would avoid the block but destroy the read-back-and-verify contract the conformance model is built on, so there's no clean alternative here.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@Meteony

Meteony commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

Noted. I wouldn't consider those release-blocking issues.

Leaving them unaddressed for now, and I'll assess those separately - I can do a follow up patch soon afterwards if they are genuine issues.

@Meteony

Meteony commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

/ready-for-reviewer

@m417z

m417z commented Aug 29, 2026

Copy link
Copy Markdown
Member

That's correct, and generally it'd probably be best to limit the mod to x86-64 now:

  • SysWOW64\explorer.exe can become the host. @include explorer.exe matches by file name, and Wh_ModAfterInit uses GetModuleFileName(nullptr), so a 32-bit SysWOW64\explorer.exe launcher would spawn a 32-bit tool process - the exact host the change to explorer.exe was meant to avoid, with the notification sink compiled out. An IsWow64Process bail-out in the launcher branch closes it in one line.

@Meteony

Meteony commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

That's correct, and generally it'd probably be best to limit the mod to x86-64 now:

  • SysWOW64\explorer.exe can become the host. @include explorer.exe matches by file name, and Wh_ModAfterInit uses GetModuleFileName(nullptr), so a 32-bit SysWOW64\explorer.exe launcher would spawn a 32-bit tool process - the exact host the change to explorer.exe was meant to avoid, with the notification sink compiled out. An IsWow64Process bail-out in the launcher branch closes it in one line.

Agreed.

@Meteony

Meteony commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

/ai-review

@windhawk-reviewer

Copy link
Copy Markdown

Submission review

Note: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding.

Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it.

Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them.


@architecture x86-64 closes the SysWOW64\explorer.exe host hole cleanly, and it's the right value rather than amd64: explorer.exe is a predefined shell process, so on ARM64 the mod is loaded natively and the hand-assembled notification sink stays ABI-correct there too (AAPCS64 is caller-cleanup, like x64). The arity assumption behind VirtualDesktopNotification_NoOp() is now guaranteed by the metadata rather than by a preprocessor guard, which is a better place for it. Everything else in the file is unchanged since the last round, so the two items below are the same ones you deferred - recording them once more, concretely, so the human reviewer can weigh them rather than having to reconstruct them from the thread.

1. A single per-window virtual-desktop query failure tears down and rebuilds the whole VD COM stack, and can latch into a ~1 s rebuild loop.

IsWindowOnCurrentDesktopSafe and GetWindowDesktopIdSafe treat any FAILED(hr) as proof that the proxy is dead:

HRESULT hr = g_vd.desktopManager->IsWindowOnCurrentVirtualDesktop(hwnd, onCurrent);
if (FAILED(hr) && ReinitializeVirtualDesktopAPI() && g_vd.desktopManager) {
  hr = g_vd.desktopManager->IsWindowOnCurrentVirtualDesktop(hwnd, onCurrent);
}
if (FAILED(hr)) RuntimeLifecycle::RequestMaintenance(false);

These are per-window queries, and a per-window HRESULT is routine - the HWND can die between EnumWindows handing it out and the call, and IVirtualDesktopManager refuses some windows outright. Two problems follow:

  • Both call sites are reached from inside EnumWindows callbacks (GetCurrentManagedWindowMonitorIsWindowOnCurrentDesktopSafe; CollectWorkspaceWindowsForInitialization and GetTileWindowsAfterDesktopSwitchGetWindowDesktopIdSafe). ReinitializeVirtualDesktopAPI() runs CleanupVirtualDesktopAPI() - Unregister on Explorer's notification service plus Release on three proxies, i.e. blocking cross-process calls - then ReadExplorerVersion() (which reads explorer.exe's version resource off disk), two CoCreateInstance calls and a QueryService. One enumeration containing N failing HWNDs performs N of those teardown/rebuild cycles mid-sweep.
  • It doesn't settle. RequestMaintenance(true) (called unconditionally at the end of ReinitializeVirtualDesktopAPI) resets the attempt budget and arms the 1 s timer; RunMaintenanceNow then succeeds, sees recoveredDeferredWork, and does g_wm.forceMonitorReconcile = true; Reconcile::ScheduleLifecycleReconcile(nullptr); - a full settled reconcile, i.e. EnumWindows over the desktop again, which hits the same window again. Any window that fails persistently keeps this cycling roughly once a second for the rest of the session: COM teardown/rebuild, an Unregister/Register round trip against Explorer, a full window enumeration and a forced arrange of every current workspace - all for one unclassifiable HWND.

The distinction the code needs is "the platform is broken" vs. "I couldn't classify this one window"; only the former justifies reinitialization or a maintenance request:

// Only a dead/disconnected proxy justifies rebuilding the VD stack. A per-window
// HRESULT (e.g. the HWND died between EnumWindows and this call) is just an
// unknown answer for that window.
static bool IsDeadVirtualDesktopProxy(HRESULT hr) {
  switch (hr) {
    case RPC_E_DISCONNECTED:
    case RPC_E_SERVERFAULT:
    case CO_E_OBJNOTCONNECTED:
    case HRESULT_FROM_WIN32(RPC_S_SERVER_UNAVAILABLE):
    case HRESULT_FROM_WIN32(RPC_S_CALL_FAILED):
      return true;
    default:
      return false;
  }
}

HRESULT hr = g_vd.desktopManager->IsWindowOnCurrentVirtualDesktop(hwnd, onCurrent);
if (FAILED(hr) && IsDeadVirtualDesktopProxy(hr) &&
    ReinitializeVirtualDesktopAPI() && g_vd.desktopManager) {
  hr = g_vd.desktopManager->IsWindowOnCurrentVirtualDesktop(hwnd, onCurrent);
}
if (FAILED(hr) && IsDeadVirtualDesktopProxy(hr)) {
  RuntimeLifecycle::RequestMaintenance(false);
}
return SUCCEEDED(hr);

GetCurrentDesktopId is the one place where a failure really is a global signal, so it can keep the current behavior. A diagnostic counter for "per-window VD query failures" would also make this visible in the report, where VD API reinitializations currently just climbs with no indication of which window caused it.

2. LOCATIONCHANGE during a drag still probes the monitor and can migrate the window the user is holding.

In ProcessWindowLifecycleEvent:

case EVENT_OBJECT_LOCATIONCHANGE:
  if (!Platform::WindowEvents::HasTrackedMonitorOwnershipMismatch(hwnd) &&
      HandleTiledWindowLocationChange(hwnd)) {
    break;
  }
  ReconcileManagedWindowStateNow(
      hwnd, ReconcileScope::Participation | ReconcileScope::Monitor);
  ScheduleLifecycleReconcile(hwnd);
  break;

HandleTiledWindowLocationChange's IsMoveSizeGestureInProgress bail-out sits behind the mismatch probe, so during a drag of a tracked window you pay GetWindowPhysicalMonitorDwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS) per frame (~60-100/s - the mod's only sustained per-frame cross-process call), and the moment the frame crosses a monitor boundary the mismatch branch runs for real: ReconcileWindowOwnershipEnsureUniqueWindowOwnershipMigrateManagedWindow, then ArrangeWorkspace on both workspaces. ArrangeWorkspace has no gesture check, so it issues SetWindowPos on the window that is still under the cursor; if the destination workspace is in the Floating layout, MigrateManagedWindow also calls RepairFloatingGeometry with a PassiveRestore hint (PeekMoveSizeGesture has no end rect yet) and re-centres it. The move loop overrides that on the next mouse message, so the visible effect is the window fighting the cursor while it straddles a monitor boundary - and because MonitorFromRect flips back and forth while the frame spans both displays, it can repeat several times per crossing.

HandleMoveSizeEndMessage already runs ReconcileScope::Monitor for this HWND authoritatively at MOVESIZEEND, so nothing is lost by skipping the mid-drag work entirely:

case EVENT_OBJECT_LOCATIONCHANGE:
  // A drag is authoritative until MOVESIZEEND, which reconciles monitor
  // ownership for this HWND. Don't probe or migrate underneath the user.
  if (IsMoveSizeGestureInProgress(hwnd)) break;
  if (!Platform::WindowEvents::HasTrackedMonitorOwnershipMismatch(hwnd) && ...
Optional improvements

Minor polish - none of this affects users, so it's your call. These all carry over from earlier rounds and still apply to the current source.

  • The #if defined(_WIN64) guard and the x86 commentary are now dead. With @architecture x86-64, _WIN64 is always defined, so the guard in SelectVirtualDesktopAbiProfile never excludes anything, and the comments there ("x86 keeps the core desktop API but uses settled cloak/uncloak reconciliation") and in RegisterVirtualDesktopNotifications ("x86 also deliberately uses settled cloak/uncloak reconciliation") describe a build that no longer exists. Either drop the guard and reword the comments around what the architecture tag guarantees, or leave the guard as belt-and-braces with a comment saying so - right now a reader has to reconcile two different stories about which builds get native notifications.
  • assert() in AssertWmThread can abort() the tool process. .vscode/c_cpp_properties.json defines UNICODE/WH_MOD/NOMINMAX but not NDEBUG, so assert(currentThread == expectedThread) is live in the shipped binary - a thread-affinity slip would take the window manager down instead of leaving a log line. The Wh_Log immediately above already carries the diagnostic value. (assert(valid) in DebugValidateMutation is fine - it's behind MULTIWM_DEBUG_VALIDATE.)
  • WM_APP_TRAY_REFRESH is dead. Declared at the top and handled in HandleWmThreadMessage, but nothing posts it - WM_APP_FOREGROUND_CHANGED covers that path now.
  • GdiFlush() before reading the DIB bits in CreateTextIcon. DrawTextW goes through the per-thread GDI batch, so the pixels[i] = ... loop can read the DIB section before GDI has finished drawing into it; the documented requirement on CreateDIBSection is to GdiFlush() first. Symptom would be an occasional blank or partially-drawn tray glyph. One line, same as desktop-live-overlay.wh.cpp#L2005.
  • LayoutGridWeighted is missing the weight-count guard its sibling has. LayoutMasterStackWeighted defends itself (if (weights.size() != stackCount) weights = DefaultWeights(stackCount);), but the grid path indexes sizes[i] for i < windowCount while ComputeWeightedSizes returns weights.size() entries. EnsureWeights() guarantees they match today, so it can't trigger - two lines would keep a future invariant slip a logged validation failure instead of an out-of-bounds read.
  • StartHotkeyThread waits INFINITE. The wait set includes the thread handle, so a thread that dies is covered - but a thread that starts and wedges leaves a stuck explorer.exe -tool-mod with no tray icon, and this runs before the host's entry-point hook. tiling-helper and virtual-desktop-helper both bound it at 5000 ms.
  • Flyout windows for removed monitors are never reclaimed. FindFlyoutForMonitor keys on HMONITOR, so after a topology change the old instances never match again and a fresh window is created per monitor. RuntimeLifecycle::NotifyDisplayTopologyChanged already clears the monitor identity cache and is the natural place to drop instances whose monitor no longer passes IsLiveMonitorHandle.
  • TrayUi::Initialize still treats ERROR_CLASS_ALREADY_EXISTS as success, leaving the ATOM at 0, so the next Shutdown skips UnregisterClassW entirely and the state is self-perpetuating across settings reloads. Harmless here because the module stays mapped for the life of the tool process, but the tolerance can only ever mask a failed teardown - Shutdown destroys every window of both classes before unregistering them, so the class should never pre-exist.
  • A blank window rule in the middle of the list silently drops every rule after it. LoadSettings breaks out of the Rules[%d] loop when process, class and title are all empty. There's a Wh_Log line, but logging is off by default; continue costs 64 cheap setting reads and removes the footgun.
  • GetWindowProcessName puts a zero-initialised 64 KB wchar_t path[32768] on the stack and re-queries per call. With any process rule configured, FindMatchingWindowRule runs OpenProcess + QueryFullProcessImageNameW per candidate per EnumWindows pass. DiagnosticReadWindowIdentity already heap-allocates the same buffer, and WindowRecord already stores pid if you'd rather cache the resolved name next to it.
  • ReconcileKnownWindow's HWND-reuse pre-check deep-copies a whole Workspace per owner to read one DWORD. A targeted repository accessor - same shape as WorkspaceRepository::IsTiled / IsSuspendedMaximized - would read record->pid under the shared lock with no allocation.
  • GetScaleFactorForMonitor still runs on every GetMonitorEffectiveDpi. The WM thread is PMv2, so GetDpiForMonitor is authoritative; apis.getScale && (!haveDpi || ...) keeps the fallback while skipping it on the happy path.
  • Unused dependencies. #include <objectarray.h> is unused (nothing references IObjectArray; the monitor-collection parameter is a void*), and -loleaut32 is dead - no BSTR, VARIANT or SAFEARRAY anywhere in the file. -ladvapi32 is already in mingw-w64's default link set for the single RegGetValueW.
  • DumpPlatformHealth prints the Explorer shell identity twice - the "Explorer shell identity" and "Explorer shell binding" lines re-run GetShellWindow + GetWindowThreadProcessId and report the same three values in a different order.
  • Tool-mod boilerplate header comment. The snippet matches the wiki version, but its comment still points at PR 1916 rather than the wiki page, and still says "The mod will load and run in a dedicated windhawk.exe process", which is no longer true for this mod.
  • FlyoutPosition still has no off value for users who don't want the desktop-switch OSD on every monitor.

Functionality notes

Non-critical observations and ideas about the feature behavior itself.

  • The README doesn't say the mod runs in its own dedicated explorer.exe. The Notes section hints at it ("change the target process"), but a user who opens Task Manager will see a second explorer.exe with no explanation, and taskkill /im explorer.exe - the usual "restart Explorer" recipe - takes the window manager with it. One sentence stating that MultiWM runs in a dedicated explorer.exe -tool-mod process, and why (a 64-bit host on Windhawk 1.7.x), would set expectations.
  • The platform caveats are still missing from the README. SelectVirtualDesktopAbiProfile fills profile.notification only for build >= 22000, so every Windows 10 build falls back to settled cloak/uncloak inference; and Explorer builds 22483-22620 are rejected outright, which leaves the mod completely inert there (no ABI → no GetCurrentDesktopId → no workspace key) with nothing but a log line to say why. Both deserve a line in Notes - the second especially, since "the mod does nothing at all" is otherwise indistinguishable from a bug.
  • The fullscreen fallback's "this is the window's own tile" exemption is on only one path. GetPhysicalSuspensionReason reports Maximized for any restored window whose GetWindowRect matches rcMonitor within 2 px. ReconcileWindowParticipation correctly exempts a tiled member already sitting on its authoritative rectangle, but GetCurrentManagedWindowMonitor and ArrangeWorkspace don't - so a window that legitimately fills the monitor drops out of CollectTileWindows and is skipped by the placement loop, and once a second window appears it is suspended and left overlapping the layout. It takes a specific setup to reach (insets ≤ 2 DIP, a monitor whose rcWork == rcMonitor, and a window whose DWM extended frame equals its window rect, so the usual invisible resize border doesn't keep it clear), but the state latches once entered. Factoring the exemption into a shared helper used by all three call sites would close it.
  • Which windows end up permanently floated is still undocumented. kPlacementTolerancePx = 4 decides whether an application "accepted" its tile, so apps that quantize their own size, are aspect-locked, or whose minimum size exceeds the slot land outside it every time and get floated at the conformance deadline with no indication of why. A README line ("an app that refuses its assigned rectangle is floated automatically") would save users from thinking the mod is broken.
  • Per-candidate cost of the eligibility query. GetCurrentManagedWindowMonitor ends in IsWindowCloaked (a DWM call) and IsWindowOnCurrentDesktopSafe (a cross-process COM call to Explorer) for every window that survives the cheap checks, and the fullscreen fallback added GetWindowRect + MonitorFromRect + GetMonitorInfoW on top. The cheap predicates are correctly ordered first and the comments explain why the cloak bit alone isn't trusted, so there's no obvious better answer - just noting the shape of the cost, since settled lifecycle bursts can run this several times a second.
  • The lifecycle debounce has no upper bound. ScheduleLifecycleReconcile kills and re-arms the settle timer on every qualifying event, so a sustained stream faster than ReconcileDelayMs (50 ms by default) postpones ReconcileDeferredLifecycle indefinitely. In practice the immediate ReconcileManagedWindowStateNow calls cover the important cases and real bursts are short, so this is an FYI rather than a request - but a "reconcile at least every N ms regardless" floor would make the worst case bounded.
  • PlaceWindowChecked is synchronous by design, so a slow app stalls the actor. The IsHungAppWindow preflight covers the already-wedged case, but a window whose thread is merely slow still makes SetWindowPos wait, and hotkeys, tray commands and queued WinEvents wait behind it. SWP_ASYNCWINDOWPOS would avoid the block but destroy the read-back-and-verify contract the conformance model is built on, so there's no clean alternative here.


Next steps:

  • /ai-review - after pushing fixes, to get a review of the updated code. You can repeat this as many times as you need, but each review is thorough and usually there's no need for more than 2-3 iterations.
  • /ready-for-reviewer - once you're satisfied with the state of the pull request, to hand it over to a human reviewer. If some findings above are left unaddressed, add a short note explaining why.

See the review process for details.

@Meteony

Meteony commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

/ready-for-reviewer

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

Labels

waiting-for-reviewer Ready for a human reviewer, and in the queue for one.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants