docs/08-ipc.md

08 — IPC Schema (signaltty/1)

Transport: Unix domain socket at $XDG_RUNTIME_DIR/signaltty/signaltty.sock (mode 0700 dir, 0600 socket, owned by user). Framing: newline-delimited JSON (JSONL), one request per line, one response per line on the same connection; server→client events are JSONL messages with type: "event". Binary PTY data inside events is base64. Max line 16 MiB (PTY bursts are chunked).

Envelope

Request:

json
1{"protocol":"signaltty/1","id":"c1","method":"pane.spawn","params":{…}}

Response (ok):

json
1{"protocol":"signaltty/1","id":"c1","ok":true,"result":{…}}

Response (error):

json
1{"protocol":"signaltty/1","id":"c1","ok":false,"error":{"code":"NO_SUCH_PANE","message":"…","details":{}}}

Event:

json
1{"protocol":"signaltty/1","type":"event","event":"agent.done","seq":412,"payload":{"pane_id":"pane_…"}}

Rules: protocol mismatch → BAD_PROTOCOL error, connection stays open. Unknown method → UNKNOWN_METHOD. Unknown fields ignored (forward compat); present-but-mistyped fields → BAD_PARAMS (missing and explicit null still mean "absent" for optionals). Every mutating result returns opaque ids. seq is a monotonic server counter; clients can subscribe {from_seq} to replay.

Methods

MethodParamsResult
server.status—{version, protocol, uptime_s, workspaces, tabs, panes, live_panes}
server.shutdown{force?}{stopped} (refuses with PANES_ALIVE unless force)
server.schema—{protocol, version, methods[], events[], codes[]} (self-printing contract, same constants the router matches on)
workspace.create{name?, cwd?}{workspace}
workspace.list—{workspaces:[…]}
workspace.get{workspace_id}{workspace, tabs:[…]}
workspace.rename{workspace_id, name}{workspace}
workspace.close{workspace_id, signal?}{closed}
workspace.refresh_git{workspace_id}{workspace}
workspace.diff{workspace_id}{workspace_id, branch?, files[{path, added, removed, untracked, binary}], dirs[{dir, added, removed}], added, removed} (worktree-vs-HEAD git diff --numstat as data; non-repo → BAD_PARAMS)
workspace.file_diff{workspace_id, path}{workspace_id, path, untracked, content} (one literal root-relative filename; typed text hunks or binary/unchanged/unavailable state; current worktree against HEAD)
worktree.list{workspace_id}{worktrees:[{path, branch?, head?, main, bare, locked, prunable, workspace_id?}]} (actual Git registrations and open workspace association)
worktree.create{workspace_id, path, branch, name?}{workspace, path, reused:false} (absolute new checkout path, new branch at source HEAD; checkout retained if subsequent workspace binding fails)
worktree.open{workspace_id, path, name?}{workspace, path, reused} (absolute registered checkout path, canonical-cwd workspace reuse)
worktree.remove{workspace_id, path}{removed:true, path} (explicit non-force removal; main, dirty, locked and open-referenced checkouts refused; branch retained)
tab.create{workspace_id, title?}{tab}
tab.close{tab_id}{closed}
tab.set_layout{tab_id, layout}{tab}
tab.set_ratio{tab_id, path, ratio}{tab}
pane.spawn{workspace_id, tab_id?, cwd?, argv, env?, cols?, rows?, agent_hint?}{pane, integration?}
pane.split{pane_id, direction: "right"|"down", argv?, cwd?}{pane, integration?} (new sibling)
pane.get{pane_id}{pane}
pane.input{pane_id, data_b64}{written}
pane.resize{pane_id, cols, rows}{pane}
pane.signal{pane_id, signal, group?}{sent}
pane.read{pane_id, mode: "screen"|"tail", lines?, strip_ansi?}{text, truncated}
pane.attach{pane_id, cols?, rows?, mark_seen?}{snapshot_b64, output_offset, size, live, ...} then pty.data stream; the snapshot is replayable VT state (contents, colours, cursor, modes) (mark_seen default true; GUIs pass false and acknowledge on focus)
pane.detach{pane_id}{detached} (also implicit on disconnect)
pane.close{pane_id, signal?}{closed}
pane.resume{pane_id}{pane, integration?} (spawns adapter resume argv; errors unless restored+resumable)
pane.mark_seen{pane_id}{pane} (records reading; clears ordinary attention while preserving unanswered decisions and their required attention)
decision.answer{pane_id, decision_id, option_id}{answered, lifecycle?, attention?} (delivers through a live native permission waiter or the pane adapter's channel and consumes the id; stale/consumed ids → NO_SUCH_DECISION, unknown option or channelless adapter → BAD_PARAMS)
notify{pane_id?, title, body?, severity?}{notification}
hook-event{agent, event, pane_id?, client_pid?, payload?, message?, title?, severity?, decision?}{accepted, agent, event, pane_id, lifecycle?, attention?} (adapter classification; pane by explicit id or client_pid ancestry; decision: {id, prompt, options[{id, label}]} sets/supersedes the pane's pending decision, captured answerable iff the adapter has a channel)
report-session{pane_id, agent_session_id, agent?}{pane}
subscribe{events?: ["agent.*","attention.*",…], from_seq?}{subscribed, seq} then event stream
wait{pane_id, until, timeout_s?}{satisfied, state} or TIMEOUT
focus.next_unread—{pane_id?} (severity→recency order)
plugin.list—{dir, plugins[], failures[]} (hooks with runs/errors/last_error; see 13)
plugin.reload—same as plugin.list after re-scan (stats reset)

until: blocked | done | idle | failed | exited | seen | attention_cleared. Glob subscriptions: *, agent.*, pane.*, workspace.*, tab.*, attention.*, notification.*, decision.*, worktree.*, git.*, pty.*.

Worktree methods accept a source workspace ID or handle. Git owns registrations; these methods do not fetch a remote or delete a branch. Close the worktree workspace before removal. Live pane references return PANES_ALIVE; invalid, dirty, main, locked, or otherwise open targets return BAD_PARAMS. A path reservation prevents concurrent workspace creation or pane launch into a checkout being removed. Git operations have a bounded execution deadline.

hook-event also accepts wait_for_answer?:bool and wait_timeout_s?:1..120. The waiting mode requires a supported native PermissionRequest payload with session_id, tool_name and object tool_input, and no explicit decision. It registers a live answer route, publishes generated Allow once/Deny choices and returns {accepted:true, native_verdict} when answered. Cancellation returns native_verdict:null and a reason. The default timeout is 120 seconds. This mode uses a dedicated single-flight connection; EOF, another request on that connection, or server shutdown cancels it. Ordinary reporting remains an immediate call. A timeout or cancelled reporter never means Allow.

Every workspace_id param accepts a workspace id or its handle: ids match first, then handles (namespaces are disjoint — handles never contain _). Handles are immutable slugs derived at create (My API!! → my-api, collisions → my-api-2); renames change only the name.

Every broadcast event except pty.data appends to $XDG_STATE_HOME/signaltty/audit.jsonl ({seq, event, payload, at}; rotation keeps one .1 predecessor, 8 MiB cap each). subscribe {from_seq} backfills from the file when the in-memory ring (1024) has rotated or the server restarted — replays merge file + ring deduped by seq (cap 4096).

pane.spawn, pane.split and pane.resume include an optional integration {agent, status, changed, file?, notice?} for direct supported-agent launches. Status is configured, disabled, or error. Configuration precedes process execution; a setup error does not prevent launch. Clients show notice when present. configured is not proof of provider trust or event delivery. Direct interactive local Codex execution adds native --no-daemon when a bounded version probe confirms support; persisted argv remains unchanged. Unsupported clients and explicit remote endpoints retain their command and return a disabled integration notice (ADR-0014). Ordinary shell/arbitrary launches have no integration result; manually typed Codex needs --no-daemon.

pane.spawn rejects an explicit tab from another workspace with BAD_PARAMS. A failed process launch leaves no automatic tab or pane. Ownership validation, process launch and pane publication complete before a concurrent close can remove the target tab or workspace.

tab.set_layout requires every pane owned by the tab exactly once. Missing, foreign or repeated pane IDs return BAD_PARAMS without mutation. Split ratios must be finite and clamp to 0.05–0.95. Layout replacement arranges existing panes; pane.close deletes one.

tab.set_ratio moves one divider: path holds 0 (first) / 1 (second) choices from the tab root ([] = root) and must resolve to a Split, else BAD_PARAMS. ratio is clamped to 0.05–0.95 so a client can never collapse a pane. The echo is tab.updated, like tab.set_layout. GUIs persist dragged dividers through this method rather than tab.set_layout: a targeted update cannot overwrite a layout another client changed concurrently.

workspace.file_diff reads one literal UTF-8 filename relative to the checkout root. It accepts workspace IDs or handles. Absolute, empty, NUL, ./.. and Git-private paths return BAD_PARAMS; wildcard-looking names remain literal. Known tracked files, deletions and unignored untracked files are supported. The comparison includes staged and unstaged edits against HEAD, or an empty tree before the first commit. It uses three context lines, no rename detection, and no external diff or textconv. It emits no event and issues no Git mutation command. The application issues no edit/stage operation. As with the existing summary, tracked reads retain canonical clean/process conversions from trusted local Git configuration; those helpers can have their own effects. The Git process-group deadline bounds them. Inherited GIT_DIFF_OPTS cannot override the context.

content is tagged by kind. Text contains hunks, truncated and optional notice. Each hunk has old_start, old_count, new_start, new_count, heading and lines. A line has literal text, optional old_line/new_line numbers and kind context, added, removed or no_newline. Other content kinds are binary, unchanged and unavailable with a literal reason. Metadata-only changes and empty new files can have text with no hunks and a notice. Untracked text is all additions and does not alter summary counts.

Preview bytes are capped at 512 KiB and lines at 10,000; stderr is bounded. Truncated text retains complete lines and declares its incompleteness. Unsupported content has an explicit unavailable state. The total server read deadline is eight seconds (TIMEOUT); process/read failures use IO_ERROR. File acquisition does not follow symlinks to read external content. See ADR-0016 and the selected-file contract.

Events (all carry seq)

text
1workspace.created workspace.updated workspace.closed
2worktree.changed
3tab.created tab.updated tab.closed
4pane.created pane.updated pane.exited pane.closed
5pane.resized pty.data pty.snapshot
6agent.working agent.blocked agent.done agent.failed
7agent.idle agent.unknown agent.exited
8attention.created attention.updated attention.cleared
9decision.created decision.answered decision.cleared
10notification.created
11git.branch_changed
12server.will_shutdown

worktree.changed {operation, path, workspace_id} records checkout creation, new workspace association or removal; operation is create, open or remove, and workspace_id identifies the source workspace. Creation remains observable if later convenience workspace binding fails and the checkout is retained. Reopening the same associated workspace is a no-op.

pty.data {pane_id, data_b64, output_offset} streams only to connections that ran pane.attach for that pane. output_offset is the cumulative end byte offset of this output chunk. The attach reply carries the offset covered by its snapshot, acquired atomically with terminal state. Streaming registration precedes the final snapshot, and its response precedes subsequent stream events. Clients discard or trim output already covered by that snapshot. Offsets are per-pane for the lifetime of the server and are not persisted. Repeated attach on one connection registers one viewer; detach removes it and preserves the process. Viewer registration survives a child exit and pane.resume, so existing connections receive resumed output without attaching again. All other events go to subscribers by glob match.

Error codes

BAD_PROTOCOL, UNKNOWN_METHOD, BAD_PARAMS, NO_SUCH_{SERVER,WORKSPACE,TAB,PANE}, NO_SUCH_DECISION, PANE_EXITED, PANES_ALIVE, SPAWN_FAILED, IO_ERROR, TIMEOUT, RATE_LIMITED, FORBIDDEN, INTERNAL.

decision.created {pane_id, decision, prev?} fires on set (a supersede folds into one emit carrying the previous id). decision.answered {pane_id, decision_id, option_id} fires on delivery. decision.cleared {pane_id, decision_id, reason} fires when the bar drops unanswered (reason: attention_cleared | moved_on | pane_exited). A pending decision clears on answer, when an agent transition clears its required attention or leaves blocked, or when the child exits. Reading, focusing, pane.mark_seen, and default attach preserve the decision and gate.

CLI mapping

Every CLI command is a thin wrapper over one method call with --json passthrough of result. Examples: signaltty new → workspace.create

  • pane.spawn; signaltty pane read → pane.read; signaltty wait → wait; signaltty notify → notify. The CLI injects $SIGNALTTY_PANE context for notify/hook-event when run inside a pane (env set by the server at spawn).