Skip to content

Instantly share code, notes, and snippets.

@johnlindquist
Last active May 28, 2026 21:07
Show Gist options
  • Select an option

  • Save johnlindquist/32d43632ae3f91fe69197ae11dfe4eef to your computer and use it in GitHub Desktop.

Select an option

Save johnlindquist/32d43632ae3f91fe69197ae11dfe4eef to your computer and use it in GitHub Desktop.
Debugging cmux ⌘⇧Z → Open focused workspace in Zed via Karabiner (root cause: cmux socket cmuxOnly descendant check; fix: allowAll + password)

Debugging ⌘⇧Z → "Open focused cmux workspace in Zed" via Karabiner

A full debugging walkthrough: from "the shortcut does nothing" to a verified end-to-end fix, including the non-obvious root cause inside cmux's socket server.

Goal

Press ⌘⇧Z anywhere → open the currently focused cmux workspace's directory in Zed.

Approach: a Karabiner-Elements rule whose shell_command asks cmux for the focused workspace's cwd, then launches Zed there.

zed "$(cmux rpc workspace.current | jq -r '.workspace.current_directory')"

This worked when run from a terminal inside cmux but did nothing when bound to a Karabiner hotkey. Here's why, and the fix.


The investigation (evidence-driven)

1. Confirm the shell logic is sound

Karabiner runs shell_command as execv("/bin/sh", {"/bin/sh","-c", command}) — confirmed from the source src/core/console_user_server/include/console_user_server/shell_command_handler.hpp in pqrs-org/Karabiner-Elements. It also logs stdout/stderr to:

~/.local/share/karabiner/log/console_user_server.log

Running the exact generated command under a Karabiner-like stripped env passed:

env -i HOME="$HOME" PATH="/usr/bin:/bin" /bin/sh -c "$SHELL_CMD"   # exit 0, resolved cwd

So the script wasn't the problem.

2. Instrument the rule to prove whether it even fires

Add an unconditional log write at the very top of the command, rebuild, press the key once:

echo "[$(/bin/date +%H:%M:%S)] FIRED" >> /tmp/cmux-zed-karabiner.log
CWD=$(... rpc workspace.current 2>>/tmp/cmux-zed-karabiner.log | jq -r ...)
echo "  CWD=[$CWD]" >> /tmp/cmux-zed-karabiner.log

Result after one real keypress:

[14:20:54] FIRED bin=/Applications/cmux.app/Contents/Resources/bin/cmux
Error: Failed to write to socket (Broken pipe, errno 32)
  CWD=[]
  EMPTY CWD -> notification

Key findings:

  • The rule fires correctly (ordering, escaping, Karabiner reload — all fine).
  • cmux rpc fails with Broken pipe, errno 32 → empty CWD → fallback notification.

Note: a synthetic osascript "keystroke z using {command down, shift down}" will not test this — Karabiner intercepts at the IOKit/HID layer, before OS-level synthetic events exist. You need a real hardware press.

3. Why "Broken pipe" only under Karabiner?

The same cmux rpc worked from a terminal inside cmux but failed from Karabiner. The difference is process lineage: a cmux terminal's shell is a descendant of cmux.app; Karabiner's console_user_server is spawned by launchd, outside cmux's process tree.

Root cause, from cmux source Sources/TerminalController.swift:

if accessMode == .cmuxOnly {
    let pid = peerPid ?? getPeerPid(socket)
    if let pid {
        guard isDescendant(pid) else {
            _ = writeSocketResponse(
                "ERROR: Access denied — only processes started inside cmux can connect",
                to: socket)
            return   // ← closes socket → client sees "Broken pipe, errno 32"
        }
    }
}

cmux's socket defaults to cmuxOnly mode: it walks the connecting PID's parent chain and rejects any process that isn't a descendant of cmux.app. Karabiner is denied by design — that's the broken pipe.

(Socket choice was a red herring: both cmux.sock and cmux-501.sock respond; neither was stale.)


The fix: automation socket mode (no password)

cmux supports five socket modes (Sources/SocketControlSettings.swift):

Mode Ancestry check Password Socket perms Who can connect
off nobody (socket disabled)
cmuxOnly (default) yes no 0600 only cmux descendants
automation no no 0600 any process of this macOS user
password no yes 0600 anyone with the password
allowAll no no 0666 any local user/process (unsafe)

automation is the right choice for a Karabiner hotkey: the 0600 socket file means only your own macOS user can open it, the ancestry check is skipped (so Karabiner — same user, not a descendant — is allowed), and there's no password to manage or store in plaintext. allowAll (which I tried first) is overkill: 0666 exposes the socket to every local user.

handleClient only runs the descendant guard when accessMode == .cmuxOnly; for automation it's skipped entirely. requiresPasswordAuth is true only for password.

Set it declaratively in ~/.config/cmux/cmux.json (parsed in Sources/KeyboardShortcutSettingsFileStore.swift):

{
  "$schema": "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux.schema.json",
  "schemaVersion": 1,

  "automation": {
    "socketControlMode": "automation"
  }
}

⚠️ The non-obvious part: reload-config does NOT re-bind the socket

This is what cost the most time. After editing cmux.json:

cmux reload-config
defaults read com.cmuxterm.app socketControlMode   # → automation  ✅ (stored value updated)

…yet the live listener was still cmuxOnly and Karabiner kept getting "Access denied". Reason, from source: TerminalController.reloadConfig() only calls GhosttyApp.shared.reloadConfiguration() — it never restarts the socket listener. The listener's accessMode is bound once, at TerminalController.start(accessMode:), via AppDelegate.startSocketListenerIfEnabled / restartSocketListenerIfEnabled.

To apply the mode live without restarting cmux (which would kill every terminal/session): run the Command Palette command that re-binds the listener:

⌘⇧P → "Restart CLI Listener" (palette.restartSocketListenerAppDelegate.restartSocketListenerrestartSocketListenerIfEnabled)

A full cmux relaunch also re-applies it (it reads the stored mode on launch), so once it's in cmux.json it's durable across restarts.

Verifying the live mode — beware false positives

You cannot faithfully test this from a terminal inside cmux: every process you spawn there (even env -i … or launchctl asuser …) still has cmux in its parent chain, so isDescendant passes and the call succeeds regardless of mode. That masked the bug for several rounds. The only faithful test is a process whose parent chain does not include cmux — i.e. Karabiner itself, or the instrumented log below. Confirm launchctl asuser parentage before trusting it:

launchctl asuser $(id -u) /bin/sh -c 'p=$PPID; while [ "$p" -gt 1 ]; do ps -p $p -o pid=,ppid=,comm=; p=$(ps -p $p -o ppid= | tr -d " "); done'
# If cmux.app appears in the chain, this is NOT a valid non-descendant test.

The window-match upgrade: put Zed where cmux was

Second goal: the Zed window for that cwd should land on the same display and frame as the cmux window that was frontmost when ⌘⇧Z was pressed — visually swapping cmux for Zed in place.

cmux's window.list RPC exposes key/visible but no frame, so we don't ask cmux for geometry. Instead we read the frontmost window's frame at press time (cmux is frontmost then) and drive everything through a tiny compiled Swift helper using the Accessibility (AX) API + CGWindowList.

Architecture

⌘⇧Z (Karabiner)
  └─ /bin/sh -lc '~/.local/bin/cmux-zed-in-place'      # orchestrator, absolute paths
       1. cmux-zed-ax-helper capture-frontmost          # AX/CGWindowList → {frame, displayID, bundleId, title}
       2. cmux rpc workspace.current                     # resolve focused cwd (needs automation mode)
       3. zed "$cwd"                                     # open/focus the project window
       4. cmux-zed-ax-helper move-zed --cwd "$cwd"       # find Zed window by basename, set AXPosition/AXSize

Why a compiled Swift helper (not AppleScript/JXA)

  • AppleScript System Events can read a front window's position/size but gives no display ID and is awkward with multi-display negative origins.
  • CGWindowListCopyWindowInfo captures frames but cannot move windows.
  • Only the AX API (AXUIElementSetAttributeValue with kAXPositionAttribute/kAXSizeAttribute) can move another app's window.

One Swift binary does capture and move, with proper NSScreen display matching. Compile once:

swiftc ~/.config/scripts/cmux-zed-ax-helper.swift -o ~/.local/bin/cmux-zed-ax-helper \
  -framework AppKit -framework ApplicationServices -framework CoreGraphics

It exposes three subcommands: capture-frontmost (prints JSON {frame,displayID,bundleIdentifier,title,…}), move-zed --cwd <dir> (reads CMUX_ZED_FRAME_JSON env, finds the Zed window whose title contains the cwd basename, sets its frame), and trust-check.

Coordinate space (the one gotcha)

AX and CGWindowList use top-left origin, y-down, global across displays (negative origins for displays above/left of main). NSScreen.frame uses Cocoa bottom-left origin, y-up. The helper converts only when matching a captured rect to a display:

let mainMaxY = NSScreen.screens.first?.frame.maxY ?? 0
func axRectFromNSScreenRect(_ r: CGRect) -> CGRect {
    CGRect(x: r.minX, y: mainMaxY - r.maxY, width: r.width, height: r.height)
}

It does not convert the captured window rect before setting Zed — capture and set are both AX/top-left, so pass them through unchanged. Use logical points, never pixels (don't multiply by backing scale).

Accessibility (TCC)

The helper calls AX, so its binary needs Accessibility permission. AX trust is attributed to the responsible process, which differs by launch context — so approve it once under the Karabiner path. The first real ⌘⇧Z triggers the prompt (AXIsProcessTrustedWithOptions(prompt:true)); approve cmux-zed-ax-helper in System Settings → Privacy & Security → Accessibility, then press again.

Karabiner rule

{
  description: "cmux: Cmd+Shift+Z -> Open Zed at CWD",
  manipulators: [
    {
      type: "basic",
      from: { key_code: "z", modifiers: { mandatory: ["command", "shift"], optional: ["any"] } },
      to: [{ shell_command: `/bin/sh -lc '"$HOME/.local/bin/cmux-zed-in-place"'` }],
      conditions: [
        { type: "frontmost_application_if", bundle_identifiers: ["^com\\.cmuxterm\\.app$"] },
      ],
    },
  ],
}
kar build -c ~/.config/kar-migration/config.ts   # writes only the `kar` profile; leaves Goku's Default profile alone

Verifying without guesswork

The orchestrator logs every stage to ~/Library/Logs/cmux-zed-in-place.log. A successful real keypress looks like:

frontmost=com.cmuxterm.app frame={...,"x":1589,"y":-1033,"width":1512,"height":949,"displayID":4,...}
cwd=/Users/johnlindquist/dev/story-generator
{"finalFrame":{"x":1589,"y":-1033,"width":1512,"height":949},"moved":true,"requestedFrame":{...},"title":"story-generator"}
done

Tactics that pinpointed each failure:

# 1. Did the rule fire at all? (Karabiner logs shell_command stdout/stderr)
grep "shell_command" ~/.local/share/karabiner/log/console_user_server.log | tail

# 2. What did the orchestrator see? (frontmost app, resolved cwd, final Zed frame)
cat ~/Library/Logs/cmux-zed-in-place.log

# 3. Watch for a keypress live, then read the result
:> ~/Library/Logs/cmux-zed-in-place.log
while [ ! -s ~/Library/Logs/cmux-zed-in-place.log ]; do sleep 1; done; cat ~/Library/Logs/cmux-zed-in-place.log

Synthetic keypresses (osascript 'keystroke "z" using {command down, shift down}') cannot test this — Karabiner intercepts at the IOKit/HID layer, before OS-level synthetic events exist. Only a hardware press fires the rule.


Lessons

  1. Reproduce the exact runtime. "Works in my terminal" ≠ "works under Karabiner" — process lineage and environment differ. Inside cmux, every spawn is a descendant, so a descendant-gated socket can't be tested there at all.
  2. Instrument, don't guess. One unconditional log line turned the mystery into a one-line root cause: rpc_exit=1 … "Access denied — only processes started inside cmux can connect".
  3. Read the server's source. Broken pipe was meaningless until isDescendant() explained why the peer closed the connection; and "stored mode = automation but still denied" only made sense once reloadConfig() was seen to skip the listener.
  4. Karabiner specifics: shell_command = execv("/bin/sh",{"/bin/sh","-c",cmd}) (single wrap, whole string as one arg), no user PATH, stdout/stderr logged to console_user_server.log. Use absolute paths everywhere.
  5. cmux specifics: the socket defaults to cmuxOnly (descendants only). For external automation use automation mode (same-user, no password, 0600) — and remember reload-config doesn't re-bind the listener; use ⌘⇧P → "Restart CLI Listener" or relaunch.
  6. Window geometry: capture the OS frontmost window's frame at press time and move the target app's window with the AX API. Don't reach for app-specific geometry RPCs.

Security note

automation mode keeps the socket file at 0600, so only your macOS user can connect — no password needed, nothing secret stored. (allowAll would set 0666 and let any local user in; avoid it unless you truly need cross-user access.)

Known minor cosmetic

When Zed opens a brand-new window (project wasn't already open), macOS may clamp its top ~65px below the menu bar because Zed has a standard titlebar while cmux is borderless (hiddenTitleBar) and can sit higher. Already-open Zed windows match the cmux frame exactly. Optional polish: clamp the target rect to the display's visibleFrame.

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