Control socket¶
SpacialShell listens on a unix socket, ~/Library/Application Support/SpacialShell/spacialshell.sock
(or /tmp/spacialshell-<uid>.sock when that path is too long), mode 0600. Framing is
newline-delimited JSON: one request object per line, {"id":1,"cmd":"state","args":{}}, and one
{"id":1,"v":1,"ok":true,"data":…} reply per request. spacialctl is the reference client;
spacialctl state prints the capabilities a running shell supports.
One shell per socket (#131)¶
At launch the shell connects to the socket before touching it. If another SpacialShell answers,
the new one prints "SpacialShell is already running" and exits 1 before it moves a window. A socket
file nobody answers on (the last shell crashed) is stale: it is removed and replaced. Quit the
running shell first with spacialctl quit or the rail menu's Quit, or restart it in place with
spacialctl reload (below).
Verbs¶
capabilities (in state) lists every verb below and the state payload features
(window-rows, layouts, problems), and nothing else. A client checks it before using a verb a
shell may be too old for. The list only grows: v stays 1 and a new verb adds a capability string.
cmd |
args |
Does |
|---|---|---|
version |
— | data.version |
state |
— | the model: screens, workspaces, windows, layouts, problems, capabilities |
run |
command |
a bound command by name, exactly like its hotkey (spacialctl run focus-workspace-2) |
window-preview |
window |
#199: writes the window's thumbnail (taken now if missing) to ~/Library/Caches/sh.emu.SpacialShell/previews/<id>.png; data.path is the file. Needs Screen Recording |
set-layout |
layout, workspace? |
changes a workspace's layout; the focused one when workspace is absent. spacialctl change-layout <id> sends it (spacialctl set-layout still works) |
quit |
— | quits through the same path as the rail menu's Quit: every window is put back first. The reply comes before the shell stops; its process exits a few seconds later. |
reload |
— | quit, then the same SpacialShell.app starts again (below). The reply comes before the quit. |
subscribe |
— | the event stream (below) |
Id-addressed verbs. These are the shell's own clicks and drags with wire names. Each names its
target outright, so it works on any display without moving focus there first, and on any row, not
only the first ten. A workspace is its id from state. A window is {"id": <window id>, "pid":
<pid>}, as in state and the events.
cmd |
args |
Same as |
|---|---|---|
focus-workspace |
workspace |
a rail click: that workspace becomes active on its display, and that display gets focus |
focus-window |
window, or app? title? index? first? |
a tab click; a minimized or hidden window is brought back. #199: without window, the window is found by app (name substring, bundle id or its last part, pid) and/or title (case-insensitive substring); several matches are refused with a numbered list (display, row, tab order) unless index (1-based) or first (the focused, else the shown one) picks |
close-window |
window |
a tab's close button |
toggle-float |
window |
the tab menu's Float / Tile |
recover-window |
window |
the rail tray: bring back a window no tab reaches |
move-window |
window, workspace, follow? |
a tab dropped on a rail row. follow (default true) switches to it as the keyboard does; false only re-files it, like a drop |
move-window-before |
window, before? |
a tab dragged within the bar: before before, or at the row's end when it is absent or null |
move-app |
window, workspace |
⌥-drop: all of that window's app to the workspace, without following |
swap-window |
window, onto |
a window dropped on a tile: same row, they swap; another row, it takes that slot |
move-workspace |
workspace, index |
a rail tile dragged within its display; index is 0-based, as activeIndex in state |
remove-workspace |
workspace |
the workspace menu's Remove |
set-symbol |
workspace, symbol |
the workspace menu's icon (an SF Symbol name) |
set-category |
workspace, category |
the workspace menu's category: web, coding, terminal, communication, media, design, productivity, utilities, or null to clear. The argument is required, so leaving it out cannot clear a category |
set-split-columns |
workspace, columns |
the layout popover's split −/+ |
The reply is the run reply: data.outcome is ok, noop (with reason), or failed. An id
that names nothing (a workspace or window that is gone, or a string that is not an id at all) is
ok: false, data.error unknown-workspace or unknown-window, and spacialctl exits 1. A
missing or ill-typed argument is ok: false with a message that names it.
The shell checks every id when it runs the command, so a keystroke landing between your state
and your request can at worst turn it into unknown-…, never move the wrong thing.
ws=$(spacialctl state | jq -r '.screens[1].workspaces[0].id')
spacialctl focus-workspace "$ws" # a row on the second display
spacialctl change-layout split # the focused workspace's layout (#198)
spacialctl focus-window --app brave --title "pull requests" # by app and title (#199)
spacialctl focus-window --app brave --index 2 # several match: the second as listed
spacialctl call move-window "{\"window\":{\"id\":4242,\"pid\":501},\"workspace\":\"$ws\",\"follow\":false}"
spacialctl quit
spacialctl call <cmd> [<json-object>] sends any verb with its args as written.
Event subscription (subscribe, #117)¶
{"id":1,"cmd":"subscribe"} turns the connection into an event stream. The shell replies
{"id":1,"v":1,"ok":true}, then sends one line per event, each {"v":1,"event":<name>,"data":…}:
- One
shellevent first:datais the full currentShellSnapshot(screens, workspaces, windows, focus). This is the baseline; it is not repeated. - Only changes after that, in this order within one update:
event |
data |
|---|---|
workspace-activated |
display, workspace (uuid), name, symbol, previous (uuid, absent the first time) |
workspaces-changed |
display, workspaces: that display's full workspace rows (names, layout, isActive, windowCount, …) |
window-adopted |
display, window: the full window row (window {id,pid}, workspaceId, title, appName, bundleID, …) |
window-moved |
window {id,pid}, from, to (workspace uuids, absent for a visitor), fromDisplay, display |
window-title-changed |
window {id,pid}, title |
window-closed |
window {id,pid}, pid |
focus-changed |
display, and when a window has focus: window, workspaceId, title, appName, bundleID |
The stream is additive to the wire: v stays 1, new event names and new fields may appear, and a
client ignores what it does not know. The requests the connection sent before subscribe are
still answered.
spacialctl subscribe | jq -c 'select(.event == "workspace-activated") | .data.name'
spacialctl subscribe flushes after every line. | head -1 kills it with SIGPIPE (exit 141);
that is normal for a streaming CLI.
Slow clients are dropped. The shell never waits on a subscriber: each has a fixed kernel
buffer (1 MiB), and a write that would not fit closes the connection. Deltas cannot be skipped
safely, so dropping is the only honest option. A client that sees EOF reconnects and starts from a
fresh shell baseline. spacialctl subscribe exits 3 with "connection closed" when that happens
or when the shell quits.
Privacy. Events carry window titles and app names, the same ones the tabs show (#110). The
socket is local and readable only by your user, so nothing leaves the machine through it. Treat a
captured stream like a screenshot of your tab bar. Telemetry is separate: spans record only the
request verb (ipc.cmd = "subscribe"), never a title or any event payload.
Karabiner-Elements rules (karabiner-rules, #196)¶
{"id":1,"cmd":"karabiner-rules"} replies with the hotkeys as a Karabiner-Elements
complex-modifications file in data; spacialctl karabiner-rules prints it. With
"args":{"write":true} (spacialctl karabiner-rules --write) the shell writes it to
~/.config/karabiner/assets/complex_modifications/spacialshell.json and data.message says so. A
failed write is ok: false with the reason. See
keybindings: secure input and Karabiner-Elements.
Resetting saved state (reset-state, #139)¶
{"id":1,"cmd":"reset-state"} deletes state.json and stops the shell writing it until it next
launches, which then starts fresh. The windows stay where they are for the rest of the session.
The reply's data.message says what happened; spacialctl reset-state prints it. It is the same
action as the settings window's Reset saved state… button; see
config: resetting saved state.
Reloading (reload, #194)¶
{"id":1,"cmd":"reload"} quits the way quit does, so every parked window is put back on screen
first, then starts the same bundle again: a reload of /Applications/SpacialShell.app reopens that
one, a reload of a dev bundle reopens the dev bundle. Just before it exits, the shell starts a
small /bin/sh helper that waits for its pid to be gone (at most 12 s) and then opens the bundle,
asking again for 5 s while LaunchServices refuses (-600), as Scripts/install.sh --relaunch-only
does. The new shell restores state.json (when saving is on) and re-tiles. A quit that arrives while a
reload is under way wins: the shell stays quit.
The reply comes before the quit, so spacialctl reload exits 0 at once. The same action is
Reload SpacialShell in Settings → General, in the rail's app menu next to Quit, and in the
Raycast extension. The bare Scripts/dev.sh binary has no bundle to reopen, so there it only quits.
Not to be confused with the rail menu's Reload config, which re-reads config.toml in place.