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.
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.
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 cwdSo the script wasn't the problem.
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.logResult 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 rpcfails withBroken 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.
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.)
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):
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.restartSocketListener→AppDelegate.restartSocketListener→restartSocketListenerIfEnabled)
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.
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.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.
⌘⇧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
- AppleScript
System Eventscan read a front window'sposition/sizebut gives no display ID and is awkward with multi-display negative origins. CGWindowListCopyWindowInfocaptures frames but cannot move windows.- Only the AX API (
AXUIElementSetAttributeValuewithkAXPositionAttribute/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 CoreGraphicsIt 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.
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).
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.
{
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 aloneThe 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.logSynthetic 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.
- 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.
- 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". - Read the server's source.
Broken pipewas meaningless untilisDescendant()explained why the peer closed the connection; and "stored mode = automation but still denied" only made sense oncereloadConfig()was seen to skip the listener. - Karabiner specifics:
shell_command=execv("/bin/sh",{"/bin/sh","-c",cmd})(single wrap, whole string as one arg), no user PATH, stdout/stderr logged toconsole_user_server.log. Use absolute paths everywhere. - cmux specifics: the socket defaults to
cmuxOnly(descendants only). For external automation useautomationmode (same-user, no password,0600) — and rememberreload-configdoesn't re-bind the listener; use ⌘⇧P → "Restart CLI Listener" or relaunch. - 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.
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.)
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.
{ "$schema": "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux.schema.json", "schemaVersion": 1, "automation": { "socketControlMode": "automation" } }