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:
| 1 | {"protocol":"signaltty/1","id":"c1","method":"pane.spawn","params":{…}} |
Response (ok):
| 1 | {"protocol":"signaltty/1","id":"c1","ok":true,"result":{…}} |
Response (error):
| 1 | {"protocol":"signaltty/1","id":"c1","ok":false,"error":{"code":"NO_SUCH_PANE","message":"…","details":{}}} |
Event:
| 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
| Method | Params | Result |
|---|---|---|
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)
| 1 | workspace.created workspace.updated workspace.closed |
| 2 | worktree.changed |
| 3 | tab.created tab.updated tab.closed |
| 4 | pane.created pane.updated pane.exited pane.closed |
| 5 | pane.resized pty.data pty.snapshot |
| 6 | agent.working agent.blocked agent.done agent.failed |
| 7 | agent.idle agent.unknown agent.exited |
| 8 | attention.created attention.updated attention.cleared |
| 9 | decision.created decision.answered decision.cleared |
| 10 | notification.created |
| 11 | git.branch_changed |
| 12 | server.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_PANEcontext fornotify/hook-eventwhen run inside a pane (env set by the server at spawn).