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, scope?} | {workspace_id, scope, branch?, files[{path, added, removed, untracked, binary}], dirs[{dir, added, removed}], added, removed, turn?} (scope head (default): worktree-vs-HEAD git diff --numstat as data; turn: latest turn baseline vs the checkout now, with turn {pane_id, started_at} or null before any turn (ADR-0029); non-repo → BAD_PARAMS) |
workspace.file_diff | {workspace_id, path, scope?} | {workspace_id, path, untracked, content} (one literal root-relative filename; typed text hunks or binary/unchanged/unavailable state; current worktree against HEAD, or against the latest turn baseline with scope: "turn", BAD_PARAMS before any turn) |
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, signal?} | {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?, parent_pane_id?, label?, relationship?, task_id?} | {pane, integration?} |
pane.split | {pane_id, direction: "right"|"down", argv?, cwd?} | {pane, integration?} (new sibling) |
pane.rename | {pane_id, name?} | {pane}; name absent or null clears it |
pane.swap | {pane_id, target_pane_id} | {tabs} (touched tabs; each pane takes the other's slot) |
pane.move | {pane_id, target_pane_id, direction?: "right"|"down"} | {tabs} (touched tabs; re-split next to the target) |
pane.get | {pane_id} | {pane, wait_baseline} |
pane.input | {pane_id, data_b64} | {written}; five-second input budget, stalled input → TIMEOUT, closed bound PTY → PANE_EXITED; write failures carry details.written_bytes (kernel-accepted prefix, null if a worker failure makes it unknown); no automatic retry |
pane.resize | {pane_id, cols, rows} | {pane} |
pane.signal | {pane_id, signal, group?} | {sent} (INT TERM KILL HUP QUIT WINCH USR1 USR2, case-insensitive, optional SIG; any other name, here or in a *.close signal, → BAD_PARAMS) |
pane.read | {pane_id, mode: "screen"|"tail"|"rendered", lines?, strip_ansi?, after_seq?} | screen/tail → {text, truncated}; rendered → {text, seq, next_seq, dropped, truncated} |
pane.wait_for_output | pane.read params + {match, regex?, timeout_s?} | the matching read's result + matched_line, or TIMEOUT / PANE_EXITED; invalid regex → BAD_PARAMS |
pane.clear | {pane_id} | {}; blanks screen and scrollback in every view |
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) |
pane.explain | {pane_id} | {pane_id, kind, process, live, hooked, source: "user"|"bundled"|"none", classifies, matched: {id, state, priority, index}?, rules: [{id, state, priority, region, matched}]} (read-only screen-rule trace against the current title and visible screen; process is the program generic rules are scoped to; classifies = live, unhooked and has rules; index is the winner's position in rules, since ids may repeat across manifests) |
pane.submit | {pane_id, text, submit_delay_ms?, stall_timeout_s?} | {submitted, outcome, transition_seq, lifecycle, attention} (bracketed paste + delayed Enter; embedded ESC[200~ / ESC[201~ is BAD_PARAMS before any write, not stripped; gate checks lifecycle, resumes input_required to working) |
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?, resume_argv?} | {pane} (resume_argv replaces the adapter's resume command: ≤64 args, ≤8 KiB, no control characters, first arg a plain command name; else BAD_PARAMS) |
subscribe | {events?: ["agent.*","attention.*",…], from_seq?} | {subscribed, seq, replay?} then complete replay/live stream |
wait | {pane_id, until: string|string[], after?: wait_baseline, timeout_s?} | {satisfied, outcome, transition_seq, lifecycle, attention} or TIMEOUT / IDENTITY_CHANGED |
focus.next_unread | — | {pane_id?} (severity→recency order) |
task.start | {repo, contract: {objective, constraints?, output_format?, acceptance_criteria?}, agent?, label?, parent_pane_id?, context_id?, client_request_id?, base_ref?, fetch_first?, branch?, path?, ready_timeout_s?, stall_timeout_s?, submit_delay_ms?, argv?} | {task, pane} (starts worker at pending; background ready-wait and prompt submit transitions to working; objective over 32 KiB, or a composed prompt over 32 KiB plus the fixed preamble, is BAD_PARAMS and creates no worktree; the same client_request_id returns the existing task and pane instead of creating another; repo and path must be absolute, and the task exists before its worker starts, so an immediate report finds it) |
task.get | {task_id} | {task} |
task.list | {context_id?, state?, limit?} | {tasks: [...]} |
task.wait | {task_id?, context_id?, until?: string|string[], timeout_s?} | {satisfied, tasks: [...]} (default until: settled) |
task.cancel | {task_id} | {task} |
task.report | {task_id?, pane_id?, status: completed|failed|rejected, summary, artifacts?, evidence?} (task resolved via explicit task_id, else the task owning pane_id; one of the two is required) | {task} (stores the result, emits task.result + task.updated; report on a terminal task → BAD_PARAMS) |
task.diff | {task_id} | {task_id, base_sha, branch, files[...], dirs[...], added, removed, merge_preview} (merge_preview: {target, clean, conflicted[]} previews merging the committed branch tip into the recorded target, null when git cannot tell; worktree-vs-recorded-base, tracked + untracked; no index mutation; untracked reads are capped, O_NOFOLLOW, and off the runtime thread — symlinks, fifos, and files over 512 KiB are binary with zero counts) |
task.file_diff | {task_id, path} | {task_id, path, untracked, content} (same content shapes as workspace.file_diff) |
task.finish | {task_id, mode: merge|discard, target_ref?, delete_branch?, ignore_dirty?} | {task, merge?: {target, sha}, cleanup_error?} (the outcome is task.disposition.outcome (merged/discarded), while task.state keeps its A2A value; a merge deletes the task branch unless delete_branch: false, discard keeps it unless delete_branch: true, and a pre-existing branch is never deleted; merge needs a completed task and a clean target; a failed git status is IO_ERROR, never "clean"; conflicts abort leaving the target clean → MERGE_CONFLICT, or IO_ERROR with details.target_dirty when abort fails; the merge runs in its own process group under a deadline (default 120 s, SIGNALTTY_MERGE_TIMEOUT_MS) and expiry kills it, restores the target and returns TIMEOUT, or IO_ERROR when it cannot; a merge git refuses without conflicts (hook, unrelated histories) is IO_ERROR carrying git's message; both modes kill and reap the worker's process group first, and once the worktree is gone the workspaces rooted at it close; an explicit path on task.start must be absolute) |
task.pr_open | {task_id, title?, body?, draft?} | {task} (pushes task branch to origin, opens GitHub PR via gh pr create, records task.pr: {number, url, state: open, checks: none, review: none}; requires completed task with no disposition or PR; each git/gh call runs in its own process group with prompts disabled under a 60 s deadline → TIMEOUT, missing gh → SPAWN_FAILED, non-zero exit → IO_ERROR with details.stderr; nothing is recorded on failure) |
task.pr_refresh | {task_id?} | {tasks: [...]} (refreshes PR state, reviewDecision, and CI statusCheckRollup via gh pr view for the given task (errors as task.pr_open) or all open PRs, where a failing PR is skipped and keeps its stored state; returns refreshed tasks) |
attention.pending | {limit?} (default 50, max 500) | {panes: [{pane_id, workspace_id, tab_id, label?, task_id?, lifecycle, attention, last_message?, attention_since?}]} ranked by severity then recency; panes with attention: none excluded |
plugin.list | — | {dir, plugins[], failures[]} (hooks with runs/errors/last_error; see 13) |
plugin.reload | — | same as plugin.list after re-scan (stats reset) |
agents.list | — | {dir, manifests: [{file, kind, binaries, screen_rules}], failures: [{file, error}]} (loaded agent manifests; malformed files are skipped and listed) |
agents.reload | — | same as agents.list after re-reading the dir; the whole set is swapped atomically and live panes are not touched; a dir that cannot be listed is IO_ERROR and keeps the active set (a missing dir is an empty set) |
until accepts any lifecycle or attention state, plus seen and
attention_cleared; a nonempty array matches any alternative in caller order.
Without after, an already matching current state succeeds immediately.
task.start requires agent or argv (else BAD_PARAMS) and runs cap →
base → worktree → spawn synchronously: over-cap starts are refused with
RATE_LIMITED before creating anything. Validation and base/worktree failures
before the checkout exists return synchronous errors and create no task.
Once the checkout exists, workspace/tab/parent/spawn failures persist a
failed task and leave its worktree available for inspection or discard.
The error includes details: {task_id, stage}. Background ready-wait and
prompt-write failures also persist failed tasks with
{stage: ready_timeout|submit_refused, …} evidence (ready_timeout adds
screen_tail, the pane's last screen lines). The background step
waits for the worker pane to reach idle/done (default
ready_timeout_s: 30), writes the composed prompt with submit_delay_ms
(default 300, the same paste/Enter delay as pane.submit), and runs the
same activity gate: a newer working or blocked transition within
stall_timeout_s (default 5) moves the task to working; a stall (after one Enter retry halfway through the gate) parks it
at input_required with {reason: submit_unconfirmed, stage: activity_gate},
and the worker turning working later resumes it. Closing a worker from
task.finish/task.cancel clears its attention; any pane exit drops
input_required/permission_required to unread. A repeated client_request_id returns the
existing task and pane instead of creating another; without it retries
create new tasks. The merge target defaults to the recorded
target_branch; when the start ran on a detached HEAD an explicit
target_ref is required at finish, and the resolved target must be the
branch currently checked out in the source repo (BAD_PARAMS with
details: {expected, actual} otherwise).
pane.submit rechecks decisions immediately before its delayed Enter. If a
permission request or pending decision arrived after paste, it returns
AGENT_BUSY with details: {stage: "delayed_enter", paste_delivered: true}
without writing Enter. The pasted text is already in the worker input box;
inspect and resolve the decision before sending more input. Automatic Enter
retries hold the same store read guard for the decision check and Enter write.
For a task's background first submit, a refusal after paste uses recoverable
input_required rather than failing the task. A current decision is recorded
as decision_required; when no decision remains, existing submit-unconfirmed
recovery applies. The pane's decision remains unanswered. If a decision arrives
after Enter but before submit confirmation,
the task commit preserves input_required and its decision_required evidence,
for both the background first prompt and a follow-up.
task.wait takes exactly one of task_id / context_id (both or neither
→ BAD_PARAMS); until accepts task states plus the pseudo-states
terminal (any of completed/failed/canceled/rejected) and settled
(terminal OR input_required, the default). A context wait ends when every
task in it matches (an empty context matches immediately); unknown tasks →
NO_SUCH_TASK, expiry → TIMEOUT (default timeout 3600 s). Unknown until
strings and an empty until array return BAD_PARAMS. Like pane wait,
task.wait is connection-bound and single-flight: client disconnect or server
shutdown cancels the waiter. Task state persists independently of the waiter.
task.finish records disposition through a store transition that emits
task.updated with a top-level task_id. Conflict file names and a cleanup
failure are stored on task.finish_error. A second finish is BAD_PARAMS
once the recorded worktree is gone; while that path still exists the second
finish retries removal and does not merge again. A foreign live pane blocks
finish only when its canonical cwd is the worktree or a directory inside it
(PANES_ALIVE); comparison is on whole path components, so a sibling such as
…/proj-other does not match and a symlink to the worktree does.
subscribe accepts an optional task_ids[] server-side filter alongside
events/from_seq: only events whose payload carries a matching task_id
are delivered (other events still pass the glob match).
pane.read modes: screen returns the current grid as plain text (no
scrollback). tail (default, lines default 200, max 5000) returns the
last content lines from the rendered ring — cursor-correct (CR overwrites,
repaints and alternate-screen grids resolved), same {text, truncated}
shape as before. rendered adds the incremental cursor: pass after_seq
(default 0 = oldest retained) and lines (default 200, max 5000); the
reply's seq is the oldest retained line in the reply (or the head when
empty), next_seq is the current head (pass back as the next after_seq),
dropped means after_seq is older than retained scrollback, ahead of the
head, or from before a restart (cursors are runtime-only, like
output_offset — re-read from scratch), and truncated means the new
range exceeded lines (the newest lines are returned, still
cursor-continuous). Unknown pane → NO_SUCH_PANE. Reads are passive:
never refused on agent state, never move the viewport. While a full-screen
TUI owns the alternate grid, reads return the current alt grid; repaints
bump the touched rows' sequences (poll again with next_seq for deltas)
and nothing is appended to history. See docs/05 (rendered ring).
pane.wait_for_output re-reads the pane every 100 ms, with the same mode,
lines and strip_ansi as pane.read, until one line contains match (or
matches it as a regex when regex is true; timeout_s defaults to 3600). In
screen/tail mode a line already on screen matches at once. In rendered
mode only lines newer than after_seq match and the cursor advances between
polls: capture next_seq before submitting work, then wait from it. Each
rendered poll reads up to the 5000-line cap whatever lines says, so a burst
cannot step the cursor past a match; only output that outruns the whole
retained ring between two polls (more than 5000 lines in 100 ms) can be missed. A pane
whose process has exited is read once more and then fails with PANE_EXITED.
pane.clear writes ESC[H ESC[2J ESC[3J into the pane's output stream, in
order with the program's output, so the server grid and every attached view
clear at one output_offset; the server also drops its scrolled-off history,
and rendered cursors from before the clear read as dropped. The program
is not told: a full-screen TUI stays blank until it repaints. ED clears the
active screen only, so while a TUI holds the alternate screen, the main
screen's text comes back when it exits.
pane.get adds wait_baseline {pane_id, process_instance, agent_session_id, session_generation, lifecycle_seq, attention_seq}. Capture it before submitting
new work and pass the whole object as wait.after. A matching outcome requires a
newer transition on its own axis; attention changes cannot validate an old Done.
Fast work completed before wait arrival still matches, including brief outcomes
that have already moved on. outcome/transition_seq identify the matched transition;
lifecycle/attention report the current pane state. Repeated same-state hooks
do not create a new transition. A baseline does not identify individual concurrent
input writers; serialize submissions when turn attribution matters.
Process replacement (resume or server restart) and known session replacement return
IDENTITY_CHANGED before considering state. Exit retains process identity so
until:exited can succeed. First session discovery is allowed; subsequent replacement,
even replacement back to the original value, invalidates the baseline. Missing panes
remain NO_SUCH_PANE; malformed/ahead baselines and empty outcomes are BAD_PARAMS.
Wait uses a dedicated single-flight connection: EOF, another request on that connection
or shutdown cancels it immediately. Runtime schema advertises these capabilities.
See ADR-0018.
Glob subscriptions: *, agent.*, pane.*, workspace.*, tab.*,
attention.*, notification.*, decision.*, worktree.*, git.*, pty.*,
task.*.
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 state event appends to $XDG_STATE_HOME/signaltty/audit.jsonl
({seq, event, payload, at}; rotation keeps one .1 predecessor, 8 MiB threshold
each plus the crossing record). The audit record is synced before publication.
event-sequence.json reserves blocks of 4096 numbers using synced temporary write,
rename and parent-directory sync before issuance. Restart skips unused reservations;
PTY output and filtering also leave valid numeric holes. Journal/reservation write
failure stops the server; corrupt reservations fail startup. Snapshot durability is
separate. A legacy audit seeds the first reservation from its maximum retained number;
uncertain legacy/corrupt history requires recovery.
subscribe {from_seq} captures file/ring history, coverage and the handoff fence in
one Store read. The in-memory ring retains 1024 state events. Replay filters before
its 4096-event cap, deduplicates by seq, and returns in ascending order before live
events. The reply adds replay {status, requested_after, retained_after, through, returned, recovery?}. complete proves all matching state events in
(requested_after, through]; numeric adjacency is not evidence of completeness.
history_lost, truncated, unavailable and cursor_ahead return
subscribed:false, zero replay frames and recovery:snapshot_then_resubscribe, then
close the connection. Retention metadata is persisted before discarding a generation and records expected
file lengths before publication. Missing or truncated generations invalidate proof;
uncertainty is checked before rotation and survives restart. Legacy journals remain
unavailable for disk completeness; the new runtime ring proves recent intervals.
On incomplete replay, open a fresh subscription before fetching current workspace state and attaching panes. Queued state events through the handoff fence are suppressed. Lag or out-of-order live delivery closes the stream; a disconnected client must refresh state and reattach rather than assume no changes. GUI recovery keeps terminal widgets, preserves PTY size and restores the current visible screen. It does not promise offline scrollback. Mutating requests are never automatically resubmitted.
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.
pane.rename gives a pane a unique name: a lowercase slug
([a-z][a-z0-9_-]*, at most 32 bytes, herdr's agent-name rule). An invalid
name, or one another pane holds, is BAD_PARAMS. Every request's pane_id,
target_pane_id and parent_pane_id, and the leaves of a tab.set_layout
tree, accept the name in place of the id. A
real pane id always wins over a name. The name persists across restarts and
emits pane.updated.
pane.swap and pane.move rearrange live panes without touching their
processes. Both panes must be distinct, in one workspace and in their tab
layouts, else BAD_PARAMS. Swap keeps the tree shape and ratios; across tabs
each pane changes tab and the tab's focus stays on its slot. Move removes the
pane (collapsing its parent; a tab left without panes becomes empty, as after
pane.close) and splits the target, evening out the run like pane.split.
Each touched tab emits tab.updated; a pane that changed tab emits
pane.updated. Zoom stays a per-window GUI view, so there is no pane.zoom.
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 | workspace.turn_started |
| 3 | worktree.changed |
| 4 | tab.created tab.updated tab.closed |
| 5 | pane.created pane.updated pane.exited pane.closed |
| 6 | pane.resized pty.data pty.snapshot |
| 7 | agent.working agent.blocked agent.done agent.failed |
| 8 | agent.idle agent.unknown agent.exited |
| 9 | attention.created attention.updated attention.cleared |
| 10 | decision.created decision.answered decision.cleared |
| 11 | notification.created |
| 12 | task.created task.updated task.result |
| 13 | git.branch_changed |
| 14 | server.will_shutdown |
workspace.turn_started {workspace_id, pane_id, started_at} follows an
agent.working that does not come from blocked, once the server has recorded
the workspace checkout as the latest turn baseline (ADR-0029). It is also emitted
when that capture failed: turn-scoped reads then report the error.
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. A forward byte
gap requires reattach/reconnect before feeding more bytes. 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_TASK, NO_SUCH_DECISION, AGENT_BUSY, AGENT_NOT_READY, MERGE_CONFLICT, PANE_EXITED, PANES_ALIVE, SPAWN_FAILED, IO_ERROR, TIMEOUT, IDENTITY_CHANGED, 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 | native_cancelled). A pending decision
clears on answer, when an agent transition clears its required attention or leaves blocked, or when the child exits. A still-unanswered decision at turn end (Stop/SessionEnd) is kept, never dropped. Clearing a decision never resumes a worker task: only an explicit answer or an accepted follow-up submit moves an input_required task back to working; timeout, disconnect, and turn-end drops park it in input_required. 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).
Task orchestration CLI: signaltty task start --repo … --objective … … -- <argv…> → task.start (the worker command is trailing args, not a
flag; returns {task, pane} at pending); signaltty task get/list/wait diff/file-diff/finish/cancel → the matching task.* method (wait
defaults to --until settled; finish needs --merge or --discard);
signaltty report --status … --summary … [--task …] [--pane …] →
task.report (falls back to $SIGNALTTY_TASK / $SIGNALTTY_PANE);
signaltty pane submit … --text … → pane.submit;
signaltty pane read … --mode rendered [--after-seq N] → pane.read;
signaltty pane wait-output <id> <pattern> [--regex] [--mode …] [--after-seq N] [--timeout S] [--raw] → pane.wait_for_output; signaltty pane clear <id> → pane.clear;
signaltty pane rename <id> [name] → pane.rename;
signaltty pane swap <id> <target> → pane.swap; signaltty pane move <id> <target> [--direction right|down] → pane.move;
signaltty pane spawn … [--parent-pane …] [--label …] [--relationship …] →
pane.spawn; signaltty attention [--limit N] → attention.pending.
signaltty schema prints the live contract (same constants the router
dispatches on); a sync test proves every listed method dispatches.
pane.resume retains a directly selected path-qualified executable when its
basename matches the adapter's bare resume command, including older snapshots.
Explicit manifest resume paths and the retained configuration environment remain
authoritative. Launch options are not copied into resume arguments.
Relative executable paths supplied to pane.spawn or pane.split are anchored
to their launch directory before spawning and stored as absolute paths. Later
process directory changes therefore cannot redirect a session resume.
Legacy snapshots with a relative original executable keep the adapter command: they do not persist a reliable initial directory to anchor that relative path.