Skip to content

Instantly share code, notes, and snippets.

@kkbae-com
Created September 9, 2026 21:57
Show Gist options
  • Select an option

  • Save kkbae-com/a4aa518eac7bab582945b57f2e3be7d9 to your computer and use it in GitHub Desktop.

Select an option

Save kkbae-com/a4aa518eac7bab582945b57f2e3be7d9 to your computer and use it in GitHub Desktop.
OmniWM: auto-fix workspace-monitor split when Sidecar connects [tags: omniwm, macos, tiling-window-manager, sidecar, multi-monitor, launchd]
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.omniwm-sidecar-watch</string>
<key>ProgramArguments</key>
<array>
<string>/bin/zsh</string>
<string>-lc</string>
<string>exec /Applications/OmniWM.app/Contents/MacOS/omniwmctl watch display-changed --reconnect --exec "$HOME/.config/omniwm/sidecar-workspace-fixup.sh"</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/omniwm-sidecar-watch.out.log</string>
<key>StandardErrorPath</key>
<string>/tmp/omniwm-sidecar-watch.err.log</string>
</dict>
</plist>

OmniWM: auto-fix workspace-monitor split when a Sidecar iPad connects

Tags: omniwm, macos, tiling-window-manager, sidecar, multi-monitor, launchd, ipc, dotfiles

OmniWM (a Niri/Hyprland-inspired tiling window manager for Apple Silicon Macs) lets you assign each workspace a "home" monitor, but only as one of three static choices: main, secondary (whichever external display happens to be connected), or a pin to one specific physical display. There's no way to say "secondary, unless the secondary is my iPad (Sidecar), in which case main instead" — so a workspace split that's correct with one external monitor plugged in becomes backwards the moment you swap it for a different one.

This is a script + LaunchAgent that fixes that: it watches OmniWM's own display-changed IPC event and explicitly re-homes a block of workspaces onto the right display depending on which external monitor is actually connected right now, computing left/right direction from live display geometry each time (rather than hardcoding it) since your two external monitors may not sit on the same side of your main display.

Requirements

  • macOS, Apple Silicon
  • OmniWM installed (brew install --cask omniwm) and running
  • ipcEnabled = true under [general] in ~/.config/omniwm/settings.toml, so omniwmctl can talk to the running app
  • Python 3 (ships with macOS / Xcode Command Line Tools)

Files

  • sidecar-workspace-fixup.sh — the actual fixup. Queries omniwmctl query displays to find the main display and whichever external is connected, decides where workspaces 1-5 and 6-9 belong based on whether that external's name contains "Sidecar", then queries omniwmctl query workspaces to see where each workspace currently lives and issues omniwmctl workspace move-to-monitor <workspace> <left|right> --force only for the ones that need to move. Direction is derived from comparing frame.x between the current and target display, not hardcoded, since which side your Sidecar/second monitor sits on isn't necessarily the same for every external you use.
  • com.example.omniwm-sidecar-watch.plist — a LaunchAgent that runs omniwmctl watch display-changed --reconnect --exec sidecar-workspace-fixup.sh, so the fixup re-runs automatically every time your display setup changes, and keeps re-establishing the IPC connection (--reconnect) if OmniWM restarts. Wraps the command in zsh -lc '...' so $HOME expands to whichever user runs it — plain launchd ProgramArguments are literal strings with no variable expansion, so a hardcoded /Users/<you>/... path would only work on the machine you wrote it on.

Install

mkdir -p ~/.config/omniwm ~/Library/LaunchAgents
cp sidecar-workspace-fixup.sh ~/.config/omniwm/
chmod +x ~/.config/omniwm/sidecar-workspace-fixup.sh
cp com.example.omniwm-sidecar-watch.plist ~/Library/LaunchAgents/com.example.omniwm-sidecar-watch.plist

Edit ~/config/omniwm/settings.toml's workspace assignments to the layout that's correct for your primary external monitor (the one you use most), using plain main/secondary — that's the config the script falls back to whenever your Sidecar/alternate monitor isn't connected. Then enable IPC:

[general]
ipcEnabled = true

Edit the two 1 2 3 4 5 / 6 7 8 9 workspace-number lists near the bottom of sidecar-workspace-fixup.sh to match your own workspace numbering and which group should live on which display, and change the "Sidecar" substring match to whatever your actual secondary/tertiary display reports as its name (omniwmctl query displays --format json will show you).

Load the LaunchAgent (only needs doing once — launchd loads it automatically on every future login since it's a file in ~/Library/LaunchAgents/):

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.omniwm-sidecar-watch.plist

To stop it: launchctl bootout gui/$(id -u)/com.example.omniwm-sidecar-watch. Logs land in /tmp/omniwm-sidecar-watch.{out,err}.log.

Notes / limitations

  • omniwmctl workspace move-to-monitor sets a temporary runtime override, not a permanent change to a workspace's configured home — it doesn't clear itself just because your display setup changed back. That's why the script explicitly computes and applies the correct target for every workspace on every run, for both "Sidecar connected" and "Sidecar not connected" cases, rather than only acting in one direction and assuming the other reverts on its own. An earlier version of this script only handled the "connected" case and left workspaces stuck on the wrong monitor after disconnecting.
  • Only handles a two-external-monitor scenario (main + one other at a time). Extending it to three-way setups would need a bit more than a single left/right nudge per workspace.
  • Assumes exactly one "main" (isMain: true) display is always present, which is true for any Mac's built-in or currently-designated main display.
#!/bin/bash
# Runs on every OmniWM display-changed event (see the LaunchAgent in
# Library/LaunchAgents/). OmniWM's static config can only say a workspace's
# home is "main", "secondary" (whichever external is connected), or one
# specific physical display - it can't say "secondary, unless that's the
# Sidecar, in which case main". settings.toml keeps the HP Z27-correct
# layout (1-5 main / 6-9 secondary) as the durable config; this script
# explicitly pushes workspaces 1-5 onto the Sidecar and 6-9 onto the
# built-in display while the Sidecar is connected, and explicitly pushes
# them back (1-5 built-in, 6-9 whatever external is connected, if any)
# when it's not - using the same temporary runtime override as the in-app
# "Move Workspace to Monitor" action. It must handle BOTH directions
# explicitly: that override does not clear itself just because the
# topology changed back, so a script that only acted in one direction
# would leave workspaces stuck on the wrong monitor after switching away
# from Sidecar. Direction (left/right) is computed from live display
# geometry each run rather than hardcoded, since Sidecar sits left of the
# built-in display in this setup while the HP Z27 sits to its right.
OMNIWMCTL=/Applications/OmniWM.app/Contents/MacOS/omniwmctl
"$OMNIWMCTL" query displays --format json 2>/dev/null | python3 -c '
import json, subprocess, sys
OMNIWMCTL = "'"$OMNIWMCTL"'"
try:
data = json.load(sys.stdin)
except ValueError:
sys.exit(0)
displays = data.get("result", {}).get("payload", {}).get("displays", [])
built_in = next((d for d in displays if d.get("isMain")), None)
external = next((d for d in displays if not d.get("isMain")), None)
if built_in is None or external is None:
sys.exit(0)
sidecar_connected = "Sidecar" in external.get("name", "")
target_1_5 = external if sidecar_connected else built_in
target_6_9 = built_in if sidecar_connected else external
def direction(from_display, to_display):
return "right" if to_display["frame"]["x"] > from_display["frame"]["x"] else "left"
workspaces_result = subprocess.run(
[OMNIWMCTL, "query", "workspaces", "--format", "json", "--fields", "raw-name,display"],
capture_output=True, text=True,
)
try:
workspaces = json.loads(workspaces_result.stdout)["result"]["payload"]["workspaces"]
except (ValueError, KeyError):
sys.exit(0)
current_display_by_ws = {w["rawName"]: w["display"]["id"] for w in workspaces}
def nudge(ws_names, target):
for ws in ws_names:
current_id = current_display_by_ws.get(ws)
if current_id == target["id"]:
continue
current = built_in if current_id == built_in["id"] else external
subprocess.run(
[OMNIWMCTL, "workspace", "move-to-monitor", ws, direction(current, target), "--force"],
capture_output=True,
)
nudge([str(n) for n in range(1, 6)], target_1_5)
nudge([str(n) for n in range(6, 10)], target_6_9)
'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment