JSON IPC¶
roostctl and Claude hooks drive the running Roost UI through a small
newline-delimited JSON protocol over a Unix-domain stream socket. The protocol
is local-only — there is no network deployment.
The UI binary (Swift Roost.app, or the Rust/iced binary — roost as
installed on Linux, roost-iced from a dev tree) is the IPC server.
roostctl is the only first-party client; the contract here is what any
other automation should implement.
The socket path is the bundle profile's socket_path (see
paths.md):
- Mac (Swift
Roost.app):~/Library/Caches/Roost/roost.sock - Iced on Mac (
Roost-Iced.appor a dev build):~/Library/Caches/Roost-iced/roost.sock - Installed
rooston Linux (XDG):$XDG_RUNTIME_DIR/roost/roost.sock - Iced dev build on Linux (XDG):
$XDG_RUNTIME_DIR/roost-iced/roost.sock - Linux fallback:
/tmp/roost[-iced]-<uid>/roost.sock
roostctl --target mac|linux|iced selects a profile explicitly. Without an
explicit selector, roostctl probes every distinct profile socket; if more
than one is live, it reports the actual candidates and requires selection.
(The linux profile also resolves on macOS, as ~/Library/Caches/Roost-linux/,
but nothing ships or launches it there.)
A fifth socket path, Session (~/Library/Caches/RoostSession/roost.sock
on macOS, $XDG_RUNTIME_DIR/roost-session/roost.sock on Linux), is served
by the headless roost-session daemon (HS-1a, plan 035) — see
Session sockets and paths.md.
It is not a roostctl --target value and roostctl never auto-probes
it: roostctl session start|stop|status address the session profile's
socket directly (a pre-connect carve-out, since start must work when
nothing is listening yet), and any other op reaches a session only
through an explicit --socket. A UI socket answers unknown-op for
every session.* op and for tab.attach, and
not-implemented for events.subscribe, byte-identical to before
roost-session existed.
A session socket also carries a second, binary protocol on its own connections — the per-tab attach stream a client renders a remote terminal from. It shares the socket path but not the framing; see Data plane.
Wire format¶
- Framing: newline-delimited JSON. One JSON object per line.
Max line length: 16 MiB. Lines longer than that are rejected with
frame-too-large. Embedded\ninside JSON strings is the encoder's responsibility (serde_jsonandJSONEncoderboth handle this correctly). - Request envelope:
{"id": "<string>", "op": "<dotted-name>", "params": {...}}. Theidis a string-wrapped 64-bit integer, because JSON numbers lose precision past 2^53; the legacy proto schema usedint64for tab/project ids and we preserve that range. Rust uses#[serde(with = "string_int64")]; Swift'sCodableuses a custom encoder that emitsString(describing: int64). - Response envelope (success):
{"id": "<string>", "ok": true, "result": {...}}. - Response envelope (error):
{"id": "<string>", "ok": false, "error": {"code": "<kebab>", "message": "<string>"}}. - Event envelope (server-push, unsolicited):
{"event": "<dotted-name>", "data": {...}}— noid, no response expected. Pushed inside anEventBatchon a connection that ranevents.subscribe, which host-session sockets serve and UI sockets do not. The one exception is the terminalsession.stoppingcontrol envelope, which rides bare (no batch, no revision) as the last frame on the stream. Catalog: Events below. - Bytes payloads (e.g.
tab.write.data, and any future binary field): base64-encoded strings using the standard alphabet, no padding stripping. Tested for binary fidelity (0x00..0xffround-trip) in both directions. - Unknown fields: strict on the server side (rejected with
unknown-fielderror). Permissive on the client side (clients ignore unknown fields so the server can add fields without breaking older clients). Swift'sCodableis permissive by default and the client-side request encoders match that policy unchanged. On Rust,serdeis permissive by default — server-side request structs inroost-ipccarry#[serde(deny_unknown_fields)]to opt in to the strict server policy; client-side response structs do not, matching the client-side permissive policy. - Concurrency: the server is single-actor — every request is
dispatched onto the UI's main thread (Swift
@MainActor; in the iced UI the handler forwards the request onto the single engine→UI feed and awaits a oneshot reply, so it is applied fromupdateon the winit event-loop thread). Responses are delivered in completion order, which is not guaranteed to match request order. Clients correlate byid. - Schema drift mitigation:
tests/ipc-vectors/*.jsonis a directory of canonical message exemplars (one file per op/event). Bothcargo test -p roost-ipc(Rust) and Swift'sXCTesttarget load these vectors and assert decode → re-encode → byte-equal. - Errors: stable kebab-case codes. Current set:
unknown-op,unknown-field,missing-param,invalid-param,parse-error,frame-too-large,duplicate-id,not-found,not-implemented,internal,shutting-down,host-unavailable. Clients should treat unknown codes as fatal for the request and surfacemessageto the user.host-unavailableisshutting-down's mirror image — UI-socket only — and says a host-routed op could not reach the session it named: the connection is gone, died mid-op, or its queue is full. A session's own refusal crossing to a UI socket keeps its code when that code is one of the ten above that both sockets share; a session-scoped code (includingshutting-down) or one a newer session invents folds ontohost-unavailable, its own code and sentence kept inmessage, so a UI socket never answers a code this list does not name.shutting-downis session-socket only: oncesession.stoplatches, every mutating op answers it (reads still answer normally), and a secondsession.stopon the same session gets it too instead of a fresh reap report. Session sockets add a further six, all of them about the lease and the attach handshake:connect-required,already-connected,taken-over,too-many-tokens,unsupported-kind,build-mismatch— seesession.connectandtab.attach.
Shared types¶
{
"Tab": {
"id": "<string-int64>",
"project_id": "<string-int64>",
"title": "<string>",
"cwd": "<string>",
"state": "<TabState>",
"has_notification": "<bool>",
"is_active": "<bool>",
"user_titled": "<bool>",
"position": "<int32>",
"created_at": "<int64-unix-seconds>",
"last_active": "<int64-unix-seconds>",
"hook_active": "<bool>",
"shell_state": "<ShellState>",
"agent_lifecycle": "<AgentLifecycle>",
"ownership": "<Ownership, omitted when unowned>"
},
"Project": {
"id": "<string-int64>",
"name": "<string>",
"cwd": "<string>",
"position": "<int32>",
"created_at": "<int64-unix-seconds>",
"tabs": ["<Tab>"]
},
"Ownership": {
"source": "<string>",
"session_id": "<string>",
"last_event_at": "<int64-unix-seconds>",
"detail": "<string>",
"metadata": {"<string>": "<string>"}
}
}
TabState is a JSON string with values: "none", "running",
"needs_input", "idle". The legacy TAB_STATE_UNSPECIFIED is not
exposed — the server always picks a concrete state.
ShellState is a JSON string with values: "unknown", "at_prompt",
"foreground_process" — written only by OSC 133 shell marks.
AgentLifecycle is a JSON string with values: "inactive",
"working", "waiting", "finished", "failed" — written only by
agent adapters through tab.agent_report (below). The two axes are
independent: an agent can be working while the shell itself sits at
a prompt (e.g. between tool calls). Tab.ownership is present only
while some source has claimed the tab (omitted otherwise); it carries
the (source, session_id) identity pair, last_event_at (the
server's receipt time of the most recently accepted report — never
caller-supplied), a free-form detail, and an open metadata string
map for forward-compatible extension (see tab.agent_report).
tab.state / hook_active — derived, and the compatibility contract¶
Tab.state and Tab.hook_active are derived fields, computed
server-side from shell_state + agent_lifecycle + ownership, not
independently settable:
hook_active=ownershipis present with a non-emptysource("is-live").state: if ownership is live andagent_lifecycle != "inactive", the agent axis wins; otherwise the shell axis does.
| Effective lifecycle | state |
|---|---|
agent inactive, shell unknown/at_prompt |
none |
agent inactive, shell foreground_process |
running |
agent working |
running |
agent waiting |
needs_input |
agent finished |
idle |
agent failed |
needs_input |
tab.state stays a closed four-value enum — it does not gain a
fifth failed value. This is a pinned compatibility constraint, not
an oversight: both Swift decoders (IPCTabState, Workspace.TabState)
are closed String enums with no fallback case, so a fifth wire value
throws a DecodingError on the Mac client, and the Versioning
section below classifies a new enum value as a breaking protocol
change. agent_lifecycle: "failed" therefore projects onto
state: "needs_input" on the wire — the closest of the four legacy
values ("this tab wants you"). The true failed state is only
observable on agent_lifecycle, which any client that cares (the
sidebar rollup, the per-tab dot color) reads directly instead of
re-deriving it from state.
hook_active is retained on the wire for the same reason: existing
consumers (roostctl, tools/roosttest/client.py) read it as a plain
bool and don't need to change. It is derived from ownership's
liveness, and hook_active.changed continues to fire on every
ownership claim/release exactly as before.
Operations¶
Operation names use dotted lowercase. params is omitted when an op
takes no parameters, but the field is permitted as {}.
identify¶
Returns the running UI's identity and active selection.
Request:
params.client_name and params.client_version are optional and are
logged by the server for debugging. Empty/missing is permitted.
Response:
{"id": "1", "ok": true, "result": {
"socket_path": "/Users/.../Library/Caches/Roost/roost.sock",
"pid": 1234,
"active_project_id": "1",
"active_tab_id": "3",
"app_label": "Roost",
"app_id": "ai.stridelabs.Roost",
"ui_version": "0.7.0",
"protocol_version": 1
}}
tab.open¶
Open a new tab in a project. If project_id is "0" and no projects
exist, the server creates a default project and opens the tab inside
it.
Request:
{"id": "2", "op": "tab.open", "params": {
"project_id": "1",
"cwd": "",
"argv": ["/bin/zsh"],
"cols": 120,
"rows": 30,
"title": ""
}}
argv empty means [$SHELL]. cwd empty means resolve it: the
project's cwd, then $HOME, then /. title empty means derive from
the resolved cwd. There is
deliberately no opaque command string — callers wanting shell
word-splitting must pass ["sh", "-c", "..."] explicitly. This argv is
reachable from the CLI as roostctl tab open -- <cmd…> (see
cli.md).
Response: {"tab": <Tab>}.
tab.close¶
Close a tab; the PTY child is SIGHUP'd and reaped.
Request: {"params": {"tab_id": "3"}}. Response: {}.
tab.list¶
Snapshot of the workspace. Same shape as the legacy
ListTabsResponse.
Response: {"projects": [<Project>, ...]}.
On a host-session socket the response also carries
"revision": <u64> — the commit the snapshot was taken at, read under
the same lock as the projects. It is the fence a client pairs with
events.subscribe: discard every EventBatch
whose revision is <= this one, apply the rest, and the first batch
it keeps is exactly revision + 1. A UI socket omits the key entirely
(not null) — it serves no event stream, so there would be nothing to
fence against.
tab.write¶
Headless write into a tab's PTY. data is base64-encoded raw bytes.
Request:
data decodes verbatim into the PTY master fd. Binary-clean (the
test suite round-trips 0x00..0xff). Errors not-found if the tab
has no live PTY.
Response: {}.
tab.resize¶
Headless resize of a tab's PTY (issues TIOCSWINSZ, which fires
SIGWINCH to the child group).
Request: {"params": {"tab_id": "3", "cols": 100, "rows": 24}}.
Response: {}.
tab.dump¶
Read the tab's live terminal viewport as text — the determinism
backbone for automated tests (assert on exact content instead of
OCR/pixel-matching a screenshot). Both UIs walk libghostty-vt's render
state on the main thread. Viewport only for now (scrollback is a planned
follow-up, so no scrollback param is accepted yet).
Request: {"params": {"tab_id": "3"}}.
Response:
{"cols": 120, "rows": 30,
"cursor": {"row": 1, "col": 14, "visible": true},
"rows_text": ["/tmp $ echo hi", "hi", "/tmp $", ""]}
rows_text has one entry per visible row, trailing blanks trimmed (a
blank cell renders as a space so columns line up). cursor is omitted
when the cursor is off-viewport. Response is permissive, so per-cell
color / scrollback fields can be added forward-compatibly. CLI:
roostctl tab dump --tab N (plain rows) / --json (full result).
On a host-session socket this is answered from the tab's server
Terminal instead of a UI's — same request, same response shape. It
needs no lease and no attach: a session's terminal is authoritative
whether or not anybody is watching it. (Before HS-1b a session had no
terminal at all and answered internal: no UI attached.)
On a UI socket, tab_id also accepts the host-qualified
h<host>.<id> spelling (plan 037 §3.4) — "h3.7" reads tab 7 of
whichever connected host this UI process has minted connection id 3
for. It resolves to the client-side hydrated
Terminal of an attached host tab (the same one TerminalWidget
paints), not the session's own copy — so this is how a test or
roostctl observes the client half of an attach independently of the
server half above. A session socket refuses the qualified form
with invalid-param ("host-qualified tab refs are a UI-socket form;
session tab ids are bare") rather than silently narrowing it to some
unrelated numbered tab — a session's own ids are one bare id-space by
design. roostctl tab dump --tab h3.7 passes the spelling straight
through.
tab.dump_resolved¶
Companion to tab.dump — a richer read of the same viewport, but each
cell carries the post-resolver fg/bg the production paint path computes.
Ungated; useful both for debugging "why is this row gray" and as the
resolver-walk regression op for #142. (The only theme-derived input to
the resolver is the default fg/bg pair; no bold-color accent is
applied today, on either socket.)
Request: {"params": {"tab_id": "3"}}.
Response (truncated):
{"cols": 80, "rows": 24,
"cells": [
{"row": 0, "col": 0, "text": "h", "fg": "#ffffff", "bg": "#1c1c1c",
"has_explicit_bg": false, "bold": true, "italic": false, "inverse": false},
{"row": 0, "col": 1, "text": "i", "fg": "#ffffff", "bg": "#1c1c1c",
"has_explicit_bg": false, "bold": true, "italic": false, "inverse": false}
]}
fg / bg are #RRGGBB strings (lowercase). has_explicit_bg
distinguishes a default-bg cell (false) from an SGR-bg cell (true) so
a test can pin paint behavior without reasoning about the canvas
fallback. text is " " for blank cells.
Also served on a host-session socket, from the server Terminal.
Both sockets run the same resolver — the densifier and resolve_colors
live in roost-vt and the iced UI imports them — so a session's answer
and a UI's cannot drift. A session has no theme, so its default
foreground/background are the server Terminal's own (white on black
until a program changes them; see session.set_theme
for how an attached client recolors it).
On a UI socket, tab_id accepts the host-qualified h<host>.<id>
spelling too, resolving to an attached host tab's client-side Terminal
exactly as tab.dump does above; a session socket refuses
it the same way.
tab.feed_pty_bytes (test-only — gated)¶
Requires ROOST_TEST_MODE=1 set in the UI's launch environment.
Without it the server returns not-enabled. Injects raw bytes into a
tab's PTY-output drain as if the supervisor had emitted them; the OSC
scanner + libghostty + the input-reply path process them identically
to real shell output. No shadow drain — same channel the real
TabSession writes to. See
docs/development/test-automation.md §5.4.
Request:
data is base64-encoded raw bytes. Response: {}.
Ordering: the bytes are applied the moment the UI services the op.
They are not serialized against PTY output still in flight from the
shell, so an injection sent right after a tab attaches can be applied
before the shell's startup bytes — the prompt then lands on top of
(or appended to) whatever was just seeded. Harnesses must wait for the
tab to go quiet before seeding: attach is not enough, the predicate has
to observe the shell painting and then stopping (tools/roosttest's
util.wait_tab_quiet — non-empty tab.dump text, byte-identical
across consecutive polls).
On a host-session socket the op is served the same way, gated on
the same ROOST_TEST_MODE=1 (in the session's launch environment)
and routed into the tab task's pipeline — so injected bytes are
seq-assigned, teed to attached clients, and ringed exactly like real
child output. They are chunked to 4096 bytes on the way in, the
same granularity the PTY reader produces: a chunk is the unit a seq is
assigned to, and one unchunked megabyte would be a single PTY frame
past the data plane's 1 MiB frame cap. A large injection therefore
arrives as several PTY frames, and the ordering caveat above applies
unchanged. Unlike the UI path this op is a mutation on a session —
it writes into the authoritative terminal — so it answers
shutting-down once session.stop has latched.
tab.capture_pty_input (test-only — gated)¶
Requires ROOST_TEST_MODE=1 at UI launch. Returns (and by default
drains) the bytes the UI has queued onto this tab's PTY-input channel
since the last drain — keystrokes, paste payloads, OSC-reply
synthesised replies. Combined with tab.feed_pty_bytes this lets a
test exercise the full OSC reply round trip end-to-end.
Request: {"params": {"tab_id": "3", "drain": true}}. drain
defaults to false (peek). Response:
On a host-session socket it reads the same buffer one level down:
the bytes the tab task queued for the child's PTY, which is where a
session's terminal replies (DA/DSR/color queries) and any INPUT
frames from an attached data connection both land, in the order the
task produced them. drain is honored identically (consume vs. peek).
This is how the exactly-once reply rule is asserted headlessly — one
answer in the capture per query, no matter how many clients are
attached.
tab_id accepts the host-qualified h<host>.<id> spelling on a UI
socket too — same rule as tab.dump: it reads an
attached host tab's client-side terminal-reply buffer, and a session
socket refuses the qualified form.
tab.feed_ime (test-only — gated)¶
Requires ROOST_TEST_MODE=1 set in the UI's launch environment.
Without it the server returns not-enabled. Drives an IME
preedit/commit/session-boundary event through the same production
path (ime_preedit / ime_commit / ime_session_boundary) a real
input-method event takes.
Request:
action is "preedit" (update the composed-text buffer), "commit"
(finalize text and send it to the PTY), or "clear" (cancel any
in-flight composition — the session-boundary path a real IME takes
between compositions). cursor_start / cursor_end are optional byte
offsets into text marking the preedit cursor/underline span; they
must be given together (either alone is rejected with
invalid-param), and cursor_start > cursor_end is rejected the same
way. Response: {}.
The op routes by the UI's active keyboard route, not directly by
tab_id: tab_id must match the tab currently holding the route, or
the call fails invalid-param rather than silently feeding the wrong
tab. Implemented by the iced UI only; the Swift Mac app has no case for
this op and answers unknown-op (it does support IME input, via AppKit's
own interpretKeyEvents — there is just no IPC op to drive it).
project.create¶
Request: {"params": {"name": "", "cwd": "/tmp"}}. name empty means
the server picks "Untitled <n>".
Response: {"project": <Project>} — tabs is empty.
project.rename¶
Request: {"params": {"project_id": "1", "name": "Roost"}}. Response: {}.
project.delete¶
Cascades; tabs in the project are closed and their PTYs reaped before
the project is dropped. Subscribers see tab.closed for each child
tab followed by project.deleted.
Request: {"params": {"project_id": "1"}}. Response: {}.
tab.reorder¶
Request:
Order is leftmost first. Ids not belonging to project_id are rejected
with invalid-param. Tabs in the project not listed keep their
relative order after the listed ones. Ids are the canonical
spelling: a non-canonical integer ("+4", "04") is refused rather
than normalized. That holds on the iced UI socket and on a
session socket — the two that parse a ref which may carry the
h<host>.<id> form. The Swift Mac socket diverges, and it is
documented rather than promised: it decodes both reorder ops through
its plain string↔Int64 codec (@StringInt64 / @StringInt64Array),
and Swift's Int64(String) accepts a leading + and leading zeros, so
"+4" still normalizes there. Canonical traffic is byte-identical
everywhere, which is what the vectors pin; a caller that wants one
answer from both should send canonical ids.
Response: {}.
On a UI socket, both ids also accept the host-qualified
h<host>.<id> spelling (plan 044 §3.1) — {"project_id": "h3.4",
"tab_ids": ["h3.9", "h3.7"]} reorders project 4's tabs on whichever
connected host this UI process has minted connection id 3 for, by
sending the session the same op over its op queue. It is the drag
gesture's own path, as an op. The session is authoritative: the
sidebar's new order comes from the tabs.reordered event that follows,
not from this reply, and the local workspace is untouched.
project.reorder¶
Request: {"params": {"project_ids": ["2", "1", "3"]}}. Order is
topmost first. Same partial-order rules as tab.reorder. Response:
{}.
Takes the same host-qualified form: {"project_ids": ["h3.4", "h3.2"]}
reorders that host's sidebar section, and the same canonical-spelling
rule.
The reorder routing matrix¶
Both reorder ops carry a whole order in one id-space, so a request names one instance or the other and never both:
| Request | Goes to |
|---|---|
| every ref bare (an empty list included) | the local workspace, exactly as before |
a Host project with every tab Host on the same incarnation (an empty tab_ids included) |
that host's session |
every project_id Host on one incarnation |
that host's session |
two different incarnations; a Host project with a bare tab; a bare project with a qualified tab; a bare project among qualified ones |
invalid-param, naming the rule |
| any qualified ref on a session socket | invalid-param ("host-qualified … refs are a UI-socket form") |
| the host form on a socket with no UI | invalid-param ("needs a UI: host connections are client state") |
A host-routed request answers with the session's own error code when
that code is one a UI socket also speaks, so invalid-param from a
session's partial-order rules reads the same as the local engine's. A
connection that is not there (disconnected, dropped mid-op, its queue
full) is host-unavailable, and so is a refusal whose code a UI socket
does not speak — a session with a latched session.stop answers
shutting-down, which is session-socket-only, so it folds with its own
code and sentence kept in message. An incarnation this client is not
connected to is not-found.
Duplicates and unlisted rows keep whatever the answering instance
decides — the partial-order rules above apply unchanged on both sides.
roostctl tab reorder / project reorder take numeric ids and always
build the local form; the host form is reachable through the op.
tab.focus¶
Sets the active (project, tab) selection.
Request: {"params": {"tab_id": "3"}}. Response:
{"previous_project_id": "1", "previous_tab_id": "2"}.
Focusing a tab also acknowledges it: the focused tab's pending
notification is cleared, so tab.notification with
has_pending: false follows active.changed in the same batch — and
only when the tab actually carried one. The badge, the project rollup
and the notification-inbox row all derive from that bit, so they go
with it. Focusing the already-active tab acknowledges it too.
tab_id also accepts the host-qualified h<host>.<id> spelling on a
UI socket — the plan 037 §3.4 wire spelling roostctl tab focus
and the attach path both drive. Focusing a host tab is client
selection state, not a workspace mutation, so the response's two
previous_* fields are always "0": the host's own workspace owns its
active row, and this client only moved which one it is looking at. A
session socket answers invalid-param for the qualified form
("a host-qualified tab.focus needs a UI: host selection is client
state") — there is no UI there to hold a selection.
tab.set_title¶
Manual rename. Sets Tab.user_titled = true so subsequent OSC 0/1/2
sequences from the shell do not overwrite it.
Request: {"params": {"tab_id": "3", "title": "build"}}. Response: {}.
tab.set_state¶
Request: {"params": {"tab_id": "3", "state": "running"}}. Response: {}.
Internally this claims agent ownership as source: "manual" (an
empty session_id) — the same tab.agent_report machinery any agent
adapter uses — which is why setting state manually supersedes a
live agent's ownership: a real agent's own reports are dropped until
its next claim (its next session start). state: "none" additionally
releases ownership rather than claiming an inactive one, so the
tab falls through to shell-derived state — a tab with a live
foreground process now reads running under none, not
unconditionally none. See
docs/guides/notifications.md.
tab.clear_notification¶
Clears Tab.has_notification and emits the corresponding
tab.notification event with has_pending = false.
Request: {"params": {"tab_id": "3"}}. Response: {}.
tab.set_hook_active (deprecated — use tab.agent_report)¶
Kept working as an alias for backward compatibility; new integrations
should call tab.agent_report directly. active: true claims
ownership as source: "legacy" with an empty session_id
(equivalent to tab.agent_report with ownership_action: "claim"
and no lifecycle change); active: false releases it the same way a
matching release would. hook_active.changed fires exactly as
before.
Request: {"params": {"tab_id": "3", "active": true}}. Response: {}.
tab.agent_report¶
The one op every agent adapter writes through — Claude's
roostctl claude-hook today, and any future agent. A report carries
explicit patch intent rather than a full state so a stateless
adapter never has to read current state to describe an event: which
axis changes, and how, is spelled out field-by-field; anything
omitted means "unchanged."
Request:
{"id": "9", "op": "tab.agent_report", "params": {
"tab_id": "5",
"source": "claude",
"session_id": "abc123",
"ownership_action": "preserve",
"lifecycle": "waiting",
"attention": "set",
"severity": "warn",
"title": "Claude Code",
"body": "Needs your permission to run a command",
"detail": "permission_prompt",
"metadata": {"model": "claude-opus-5"}
}}
| Field | Type | Notes |
|---|---|---|
tab_id |
string-int64 | required |
source |
string | open string identifying the agent ("claude", "manual", "legacy", a third-party agent's own name…); must be non-empty (invalid-param otherwise) |
session_id |
string | opaque per-source session id; empty for sources with no session concept (manual, legacy). Ownership identity is the pair (source, session_id) — not session_id alone, since two agents could otherwise collide on an opaque id |
ownership_action |
"claim" | "preserve" | "release" |
required, no default — "take the tab" and "I already own it" have opposite failure modes, so there's no safe implicit choice |
lifecycle |
AgentLifecycle |
optional; omitted means "leave the current lifecycle unchanged." Present only on events that actually move it |
lifecycle_if |
array of AgentLifecycle |
optional; omitted means unconditional (every pre-046 client). A guard on the tab's current lifecycle — see Guarded reports below |
attention |
"set" | "clear" | "preserve" |
defaults to "preserve" |
severity |
"info" | "warn" | "error" |
defaults to "info". Carried on the model now so a later policy revision can have failed interrupt regardless of focus; v1's notification policy does not yet consult it |
title / body |
string | required when attention == "set" (invalid-param if missing), ignored otherwise |
detail |
string | free-form reason for the report ("permission_prompt", "background_tasks:2", an error name…); recorded onto the ownership record when non-empty |
metadata |
map |
open extension channel — the one field a client may extend without coordinating with the server. The params struct carries #[serde(deny_unknown_fields)] per the strict-server convention above, so a new named field on this op is a request-schema change, not an additive one: an older UI or roost-session answers unknown-field and drops the whole report, attention included. lifecycle_if was added that way in plan 046 — both server implementations changed together, and protocol_version did not move because roostctl and the UI ship as one build. Anything an adapter can express as data belongs in metadata instead |
ownership_action semantics, enforced under one lock so the check and
the mutation can't race a concurrent report:
claimalways takes ownership, replacing any existing owner unconditionally — the only path that can take a tab from a live owner (aSessionStart, or a manual override viatab.set_state).preserverequires the report's(source, session_id)to match the current owner; a mismatch is dropped (seeacceptedbelow).detail/metadatamerge onto the existing owner rather than replacing it — an empty field means "this event says nothing about it," not "clear it," because there is no delete channel in v1 and metadata is expected to accumulate across a session (e.g.modelatSessionStart, a cron count atStop).releasealso requires a match; it clears ownership and forceslifecycleto"inactive".
Guarded reports¶
lifecycle_if makes a report conditional on where the tab already
stands, so an adapter can say "this only means something if the turn was
still running" without reading state first:
{"id": "10", "op": "tab.agent_report", "params": {
"tab_id": "5",
"source": "claude",
"session_id": "abc123",
"ownership_action": "preserve",
"lifecycle": "waiting",
"lifecycle_if": ["working"],
"attention": "set",
"severity": "info",
"title": "Claude Code",
"body": "Claude is waiting for your input",
"detail": "idle_prompt"
}}
- Current lifecycle in the set — the report applies in full.
- Current lifecycle not in the set — the
lifecyclepatch and anyattention: "set"are dropped. A transition that did not happen is not news: agents nag on a timer (Claude'sidle_prompt~60s after a turn ended, cursor's repeatedstop), and an unguarded nag re-banners a turn the user already saw finish.detailandmetadatastill merge, andattention: "clear"still applies. - A
release's lifecycle is exempt: it clears ownership and forces"inactive"wherever the lifecycle stood — a guard cannot keep a departed agent's dot alive. Itsattention: "set"is not exempt and is gated like any other, so an adapter can say "announce the session ending only if the turn was still running". - An omitted
lifecycle_ifis unconditional — the behaviour every client had before plan 046.
The interaction worth knowing: the OSC 133 failsafe (A/B/D drops
the lifecycle to "inactive" while keeping ownership as a label) leaves
a tab whose agent is gone but still owned. A later report guarded on
["working"] then correctly no-ops instead of re-lighting a dead
agent's dot.
Response:
{"id": "9", "ok": true, "result": {
"accepted": true,
"tab": {"...": "the full post-report <Tab>"}
}}
accepted is false when the report lost the ownership-matching
check above — tab is then the tab unchanged. The full Tab is
always returned so an adapter never needs a follow-up tab.list to
see what its own report did.
A report vetoed by lifecycle_if is not a rejected one: accepted
stays true (the ownership check passed) and the returned tab simply
shows the unchanged agent_lifecycle. accepted answers "did this
report belong to the tab's owner", never "did the lifecycle move" —
compare the returned agent_lifecycle for that.
notification.create¶
Fire a system notification for a tab.
Request:
Response: {}.
app.screenshot¶
Render the running UI's whole window (sidebar + tab bar + active
terminal) to a PNG, in-process — the UI re-draws its own view tree
rather than capturing the screen, so it needs no screen-recording
permission and works even when the window is unfocused, occluded, or
offscreen. Backs roostctl screenshot.
Request:
scale is the pixel multiplier — 1 (default) renders at logical
window size, 2 super-samples. Values outside 1..=2 are rejected
with invalid-param.
Response:
png is the PNG bytes base64-encoded (see Bytes payloads above);
width/height are the pixel dimensions actually rendered
(== logical size × scale). The response rides the same 16 MiB frame
ceiling as every other op — a normal window PNG is well under it.
Errors: internal when there is no window to capture, the window is
minimized (Mac) or not yet realized (Linux), or PNG encoding fails;
invalid-param for an out-of-range scale.
app.window_metrics¶
Read logical application-content geometry for screenshot and pointer drivers.
Request: {"params": {}}.
{"window_width":1100.0,"window_height":700.0,"sidebar_width":220.0,
"sidebar_collapsed":false,"terminal_top":34.0,"terminal_font_family":"Berkeley Mono"}
terminal_top and terminal_font_family are optional for wire compatibility
(omitted, not null, when an adapter has nothing to report). The iced UI
reports the exact application-owned top edge of its terminal viewport (the
chrome-band height above it), always; the Mac UI reports the AppKit terminal
view's top offset, measured from the content view's top edge. Consumers that
require exact coordinates must reject a missing, non-finite, or non-positive
value instead of copying a chrome-height constant. terminal_font_family is
the resolved family the live terminal is actually rendering with
(post-fallback-chain, not a config echo). Both fields are reported by both
adapters once a terminal is live — the Mac adapter omits both until a terminal
view is mounted (fresh launch, no tabs). This operation is ungated and
read-only.
app.sidebar_dump¶
Read the sidebar's last-rendered agent rows, per project, plus the
show-sidebar-agents toggle. Both UIs keep an explicit
rendered_agents cache per project, written in the same refresh pass
that rebuilds the sidebar widgets; this op reads that cache rather than
re-deriving the rows from the workspace snapshot, so a refresh a UI
forgot to run is a wire-visible test failure instead of an invisible
one.
Request: {"params": {}}.
Response:
{ "agents_visible": true,
"projects": [ { "project_id": "1",
"agents": [ { "tab_id": "7", "name": "slauth-refactor",
"lifecycle": "waiting", "status_text": "Waiting for input",
"time_text": "2m", "is_active": false } ] } ] }
All ids are string-wrapped int64s, matching every other op. agents_visible
reflects the config/feature toggle only — nothing else. All projects
appear, in sidebar order, including projects with zero agents.
projects[].agents stays populated even when the toggle is off or a
project drag is in progress: hiding the rows and flattening the sidebar
during a drag are transient UI state, not part of this contract.
hosts — the sections below LOCAL¶
projects is this UI's own workspace. The host sections come back
beside it, in sidebar order:
{ "agents_visible": true,
"projects": [],
"hosts": [ { "id": "hs-2f1c", "label": "workbench", "state": "connected",
"projects": [ { "key": "h3.4", "name": "roost",
"tabs": [ { "key": "h3.9", "title": "zsh" } ] } ] } ] }
id is the saved host's stable id — what a host.* verb is addressed
to — and state is the section's own spelling, the same string
host.status reports. Every key is the host-qualified
h<host>.<id> form the UI socket's tab.focus, tab.reorder and
project.reorder take, so a caller that read this dump can drive those
ops without probing for the incarnation first.
The order is the authoritative one — the mirror's, as the session
last published it. The sidebar may be drawing a held drag preview for a
moment after a drop; this op never reports it. The host views are
refreshed before the read, so the answer cannot lag the mirror by an
event. A dimmed (disconnected) section is listed with the rows it
retained, still under the incarnation that published them; a host that
has never connected has an empty projects.
hosts is omitted entirely when there are none, so read it as
absent-tolerant: host sessions are iced-only, and the Swift Mac app
answers this op without the field at all. A UI with no host sections
therefore returns byte-identical bodies on both.
Ungated, read-only — always available, matching app.window_metrics.
app.render_stats (iced UI only)¶
Read the running UI's render-path counters. This is the only way to
measure the real draw path: TerminalWidget::draw needs a live
renderer, which unit tests cannot construct.
Implemented by the iced UI; the Swift Mac app has no case for this op
and answers unknown-op.
Request: {"params": {"reset": false}}. reset defaults to false,
so {"params": {}} — or no params at all — is a plain read.
Response:
{"refresh_calls": "412", "refresh_nanos": "51500000",
"rows_rebuilt": "9888", "cells_walked": "790400",
"draw_calls": "377", "draw_nanos": "94250000",
"fill_text_calls": "9048", "view_calls": "412", "view_nanos": "3300000",
"elide_calls": "0", "elide_nanos": "0"}
Every counter is a string-wrapped int64 (the same
string_int64 convention the envelope id uses, above):
the nanosecond accumulators pass 2^53 after roughly 104 days of
measured render time, and the rest ride the same convention so the
shape is uniform. All are running totals since process start, or since
the last reset: true.
refresh_calls / refresh_nanos cover the snapshot rebuild that walks
libghostty's render state; rows_rebuilt and cells_walked are what
that walk touched. draw_calls / draw_nanos / fill_text_calls
cover the widget draw pass. fill_text_calls counts glyph draws the
pass emitted — a sprite-rendered cell (box drawing, blocks) replaces
a glyph draw and counts as one. view_calls / view_nanos cover the
whole App::view() rebuild; elide_calls / elide_nanos cover
chrome::elide_to_width, the tab-pill title eliding it calls. Those
four default to 0 on decode when absent, so a client parsing a
response from an adapter that doesn't instrument them still works.
reset: true zeroes the counters after the read, so a caller can
read-reset, run a workload, then read the delta directly.
Caveat: app.screenshot re-renders the window, so taking a screenshot
inflates the three draw counters. Read before capturing, or reset
after.
Ungated — always available, matching tab.dump_resolved. Not
read-only: reset: true reads the counters and then zeroes them.
CLI: roostctl render-stats [--reset].
app.dock_badge (test-only — gated, macOS iced only)¶
Requires ROOST_TEST_MODE=1 set in the UI's launch environment.
Without it the server returns not-enabled. Reads the macOS Dock
tile's live badge label — the parity port of App.swift's
refreshDockBadge(), which mirrors the notification-inbox count onto
NSApp.dockTile.badgeLabel and writes nil at zero.
Request: {"params": {}}. Response:
label is null when the badge is cleared. The handler reads AppKit
on the main thread and deliberately does not re-derive the label
from the inbox first: recomputing would assert the count→label mapping
(which unit tests already pin) while proving nothing about whether the
write reached the Dock. Because the badge write rides the update loop
asynchronously, callers poll rather than reading once —
tools/roosttest/test_dock_badge.py is the reference use.
Implemented only by the iced UI on macOS. The iced UI on Linux answers
not-implemented, and the Swift Mac app has no case for it at all, so
its dispatcher answers unknown-op. There is no Dock off macOS, and
answering a plausible null there would read as "the badge is cleared"
and pass a test that never ran.
app.menu_dump (test-only — gated, macOS iced only)¶
Requires ROOST_TEST_MODE=1 set in the UI's launch environment.
Without it the server returns not-enabled. Reads back the macOS
iced UI's live native menu bar — walks the actual NSApp.mainMenu
AppKit holds, not a re-derivation from the keybind table, so the
e2e suite can assert table↔menu agreement rather than trusting the
menu-building code to have gotten it right.
Request: {"params": {}}. Response:
{
"menus": [
{
"title": "File",
"items": [
{
"title": "New Tab",
"key_equivalent": "t",
"modifiers": ["super"],
"enabled": true,
"state": "off",
"separator": false,
"action": "new_tab"
},
{
"title": "",
"key_equivalent": "",
"modifiers": [],
"enabled": true,
"state": "off",
"separator": true,
"action": null
}
]
}
]
}
modifiers uses the fixed vocabulary ["shift","ctrl","alt","super"],
always in that order. key_equivalent is the raw keyEquivalent
string AppKit holds (empty when the item has none, or while the
gating seam has blanked it — see sync_gating in
crates/roost-iced/src/macos/menu.rs). state is "on" or "off";
NSControlStateValueMixed never appears — nothing in this menu bar
ever sets it, and a dump that saw it would fail with internal.
action is KeybindAction::to_wire_name() for a table-bound item, a
"select_project:<id>" / "select_tab:<id>" marker for a Window-menu
row (by stable id, not position), the "quit" marker for the App
menu's Quit item, the "check_for_updates" marker for the App menu's
Sparkle item, "appkit:<selector>" for a standard AppKit item
(About, Hide, Minimize, Zoom, …), or null for an inert item (Cut,
Select All, and every separator). The App menu's title is the profile
display name (set at install time — no separate runtime substitution to
account for).
Implemented only by the iced UI on macOS, same as app.dock_badge;
the iced UI on Linux answers not-implemented.
app.menu_activate (test-only — gated, macOS iced only)¶
Requires ROOST_TEST_MODE=1 set in the UI's launch environment.
Without it the server returns not-enabled. Resolves a title path
through the live native menu bar (the same tree app.menu_dump
reads) and fires it via performActionForItemAtIndex: — the same
dispatch a real click takes, so the op exercises the full
AppKit → channel → update-loop path, not a shortcut around it.
Request:
Response: {}.
Titles carry real ellipsis characters (U+2026, e.g. "Rename Tab…"),
not three literal periods. performActionForItemAtIndex: performs no
validation of its own (Apple's docs), so the handler checks the
resolved item's isEnabled itself and errors rather than firing a
greyed-out item. Errors (invalid-param): an unknown path, an
ambiguous one (two items sharing a title at the same level — the
dynamic Window menu's project/tab rows can collide, so seed unique
names), or a disabled item. Because the fired MenuEvent rides the
same async engine-feed channel a real click does, its effect lands on
a later update turn — callers must condition-wait on the observable
result (e.g. tab.list growing), never assert synchronously on the
reply.
Implemented only by the iced UI on macOS, same as app.menu_dump.
app.update_status (test-only — gated, macOS iced only)¶
Requires ROOST_TEST_MODE=1 set in the UI's launch environment.
Without it the server returns not-enabled. Reads back the Sparkle
updater's state from the macOS iced UI's seam
(crates/roost-iced/src/macos/sparkle.rs).
Request: {"params": {}}. Response:
{
"framework_loaded": true,
"updater": "started",
"reason": null,
"check_id": 1,
"last_check": {"outcome": "found", "version": "99.0.0", "detail": null}
}
framework_loaded is whether Contents/Frameworks/Sparkle.framework/
Sparkle was found beside the executable and dlopened — false for
every bare-binary build, because the framework only ever ships inside
Roost-Iced.app. updater is "started" once -startUpdater:
succeeded and "unavailable" otherwise, with reason carrying the
why (no framework, a refused start). last_check is null until a
check completes; its outcome is "found" (a newer version is in the
appcast, version set from SUAppcastItem.displayVersionString),
"none" (the feed parsed and offered nothing newer) or "error" (no
feed, an unreachable one, a malformed appcast), with detail carrying
the reporting error's localizedDescription.
check_id increments once per completed check. Condition-wait on
it advancing rather than on last_check becoming non-null: the latter
can pass on a previous check's result.
The "Check for Updates…" menu item's enabled state mirrors
updater == "started" plus Sparkle's own canCheckForUpdates, so
app.menu_dump and this op agree by construction.
Implemented only by the iced UI on macOS, same as app.menu_dump.
app.update_check (test-only — gated, macOS iced only)¶
Requires ROOST_TEST_MODE=1 set in the UI's launch environment.
Without it the server returns not-enabled. Starts a non-interactive
-[SPUUpdater checkForUpdateInformation]: feed fetch, appcast parse
and version comparison, with no UI panel and no download. (The
menu item drives the interactive checkForUpdates instead; nothing
automated does.)
Request: {"params": {}}. Response: {}.
The reply returns as soon as the check is dispatched. Results land in
app.update_status through the updater delegate's callbacks, so
callers condition-wait on check_id advancing. Errors (internal)
when the updater is unavailable.
In test mode the seam's updater delegate overrides the feed URL from
ROOST_SPARKLE_FEED_URL, which is how tools/roosttest/test_sparkle.py
points a check at a loopback appcast. Both conditions are required
(ROOST_TEST_MODE=1 at launch and the variable): a production bundle
ignores the variable entirely.
Implemented only by the iced UI on macOS, same as app.menu_dump.
app.notification_status (test-only — gated, macOS only)¶
Requires ROOST_TEST_MODE=1 set in the UI's launch environment.
Without it the server returns not-enabled. Reads back the calling
UI's UNUserNotificationCenter backend state — the iced UI from
crates/roost-iced/src/macos/notifications.rs, the Swift app from
DesktopNotifications.status() in mac/Sources/Roost/DesktopNotifications.swift.
Request: {"params": {}}. Response:
On iced, backend is "available" once the UN delegate has
installed — a bundled launch that has reached window_opened — and
"unavailable" otherwise: every bare-binary build (no app bundle, so
UN is never touched), and a bundled app before its first window opens.
reason names why it is unavailable ("not running from an app
bundle", "window not opened yet"), or null once available. The
Swift app has no such gate — its UNUserNotificationCenter delegate is
installed at construction, and a build that could not do that would
have aborted at launch rather than produced a running process to query
— so it always reports backend: "available" with a null reason.
authorized is the user's answer to the authorization prompt on both
UIs, always false while backend is "unavailable" — CI's TCC
authorization state is unknowable, so the automated suite never asserts
this true; the real prompt/click is the morning checklist (#285).
Implemented by both UIs, macOS only — unlike app.menu_dump above and
the other macOS-gated ops around it, which are iced only and have no
Swift counterpart.
sidebar.set_width (test-only — gated)¶
Requires ROOST_TEST_MODE=1 set in the UI's launch environment.
Without it the server returns not-enabled. Sets the projects
sidebar's logical width — the programmatic twin of dragging the seam,
so the e2e suite can pin resize + persistence without a real pointer.
Request: {"params": {"width": 260.0}}. Response: {}.
The UI routes the width through the workspace, which clamps it to
[160, 400] and persists it, so an out-of-band width (90, 1000)
succeeds and lands on the nearest bound rather than erroring. Read the
applied value back with app.window_metrics.
width must be finite and positive; zero, negative, and non-finite
values are rejected with invalid-param before reaching the UI.
While the sidebar is collapsed the op still succeeds: it persists the
width and updates what expanding will reveal. app.window_metrics
reports a collapsed sidebar as sidebar_width < 1.0 until it is
expanded: the iced UI reports a literal 0.0, while the Mac UI reports
the collapsed pane's real frame width. Assert < 1.0, not == 0.0.
Two authority caveats, mirroring window.resize's "the compositor
remains authoritative" stance: the persisted value is the requested
logical width, and a window too narrow to honor it may render the
seam narrower — on macOS NSSplitView's constrainMaxCoordinate
clamps the divider to the split view's allocation, so the reported width
follows what was actually laid out.
app.window_metrics reports the live width, so assert against that.
And the op is not defined concurrent with a live pointer drag of the
seam: a drag in flight re-anchors on its press-time width and its
release wins. Harnesses drive one or the other, never both at once.
Host bootstrap test ops (app.dialog_dump / app.dialog_answer / app.keybind_dispatch) (test-only — gated)¶
Requires ROOST_TEST_MODE=1 set in the UI's launch environment.
Without it every op in this group errors. tools/roosttest/ drives a
real UI over this socket and nothing else, so without a seam onto the
host dialog family (HostDialog::{Add, ConfirmStop, ConfirmRestart,
Bootstrap} — Host sessions (development))
the consent card the SSH bootstrap flow (plan 039) gates on could not
be exercised at all. Unlike the upgrade prompt, whose button composes
ops a test can already send directly, the bootstrap job is
deliberately UI-only and has no such back door.
These are a test seam and not a production surface. Nothing but
ROOST_TEST_MODE=1 can reach them, roostctl grows no verb for any
of them, and the rule they exist to protect is the opposite of a
remote-control API: a modal must never be raised at — or answered by —
a machine.
app.dialog_dump reads which host modal is on screen and what it
says — the rendered strings, not the state behind them, so a test
asserting "the user is told the right thing" isn't re-deriving the
copy rule a second time.
Request: {"params": {}}. Response:
{
"dialog": "bootstrap",
"variant": "install",
"title": "Install roost-session on workbox?",
"body": "roost-session 0.0.19 (ghostty-abcdef0…) will be installed to ~/.local/bin/roost-session on workbox, from this Roost's own roost-session.",
"buttons": ["Cancel", "Install"],
"host": "3f9a2b7c1d4e4f5a"
}
dialog is "add" | "confirm_stop" | "confirm_restart" | "bootstrap",
or absent (with every other field defaulted/empty) when no host modal
is open. variant is present only for "bootstrap" —
"install" | "update" | "start" — and null/absent otherwise.
buttons lists every button in render order, the dismissing one
first, exactly as the card draws them. host is the saved host's
id — the opaque hex host.add minted and host.list reports, the
same value host.connect takes — not its label, even though the
rendered title and body above interpolate the label. Absent when
the dialog is not about a saved host.
app.dialog_answer presses the visible modal's primary button, or
dismisses it — through the same production handlers a real click or
Enter/Escape takes, so every guard the button itself has (re-reading
state at confirm, the mutation claim, refusing to run twice) applies
here too.
Request: {"params": {"action": "confirm"}} (or "cancel"). Response:
{}.
action outside "confirm" | "cancel" is rejected invalid-param
before anything else runs. Every other refusal is internal, carrying
a human-readable reason: no host dialog is open; "confirm" sent to a
dialog with no confirming action (a
remote host whose NeedsRestart dialog can only offer the
docs-pointer copy, RestartAction::None); or "confirm" sent to the
Add Host dialog while it's already dialing a verify. A dialog with no
primary action refuses confirm rather than silently dismissing — a
test that thinks it pressed a button that isn't there should fail
loudly, not pass by accident.
app.keybind_dispatch runs a named keybind-table action through the
same dispatcher a real key press or native menu click uses. It exists
because paste has no other IPC back door — unlike a palette row, it's
reachable only from a real key event — so the harness needed a seam to
drive it at all, including issue #376's frozen-host-frame refusal.
Request: {"params": {"action": "paste"}}. Response: {}.
Not a general keybind dispatcher. action accepts only the
literal "paste"; every other KeybindAction name ("close_tab",
"new_tab", "copy", …) is rejected — an arbitrary IPC client isn't
trustworthy with a route that can close a live terminal, mutate
workspace state, or write the system clipboard. Like
app.dialog_answer's action check, this is rejected invalid-param
before anything else runs. Widening the allowlist happens one name at
a time, alongside a concrete test need.
Command palette (palette.*)¶
Drive the command-palette overlay — open it, read its rows, filter,
activate a row, dismiss. UI-only: routed to the UI like app.screenshot,
not the workspace. A command row's id is its KeybindAction id, so
activating a row runs the same dispatch its hotkey would; activating a
sub-frame row (e.g. select_theme) drills in. Backs roostctl palette.
All five ops reply with the resulting palette state, so a driver needs no
follow-up palette.state:
{"open": true, "frame": "commands", "query": "tab", "selection": 2,
"items": [{"id": "new_tab", "title": "New Tab"},
{"id": "select_theme", "title": "Select Theme…"}]}
open is false when no palette is up (the other fields are then
empty/default). When open, frame is the current frame id — commands |
launcher | custom | themes | fonts | notifications | present |
agents (a provider drill-in sub-frame gets a generated
provider:items:<n> id instead) — and items are the filtered rows in
display order (subtitle present on rows that have one).
The agents frame (kind: "agents") lists one row per tab an agent
owns, ordered by urgency (running Claude/Codex/etc. sessions; excludes
tabs Roost itself claimed via manual/legacy ownership). Its rows carry
an additional agent object, absent on every other frame's rows:
{"id": "agent:3", "title": "Claude Code · roost · slauth-refactor",
"agent": {"effective_lifecycle": "waiting", "agent": "Claude Code",
"project": "roost", "name": "slauth-refactor",
"status_text": "Waiting for input", "time_text": "2m",
"metrics_text": "4f +86 -12"}}
agent is the display name derived from the tab's ownership source —
Claude Code / Codex / OpenCode / Grok / Cursor for the five
built-in adapters, and the raw source string verbatim for anything
else (adding a sixth adapter never requires touching this table) —
folded into title as its leading ·-separated segment.
effective_lifecycle is one of working / waiting / finished /
failed / inactive — the same value the tab pill and sidebar rollup
render, so this row can never disagree with them. metrics_text is
absent while the row's git-metrics probe is still pending and always
present once resolved — "—" for a clean repo, a non-repo cwd, or any
probe failure/timeout; otherwise "<n>f +<adds> -<dels>" (minus is
ASCII -) — so pending vs. resolved is observable on the wire. Activating
an agent:<id> row jumps to that tab (revealing the sidebar if it was
collapsed); the empty-state row ("agents:empty") is not actionable.
| Op | Request params | Notes |
|---|---|---|
palette.open |
{"kind": "commands"} |
kind: ""/commands → command palette; launcher → custom-command launcher; custom → the script-backed provider palette; agents → the agent-jump palette. Other values → invalid-param. |
palette.state |
{} |
Read the current state. |
palette.query |
{"query": "theme"} |
Set the current frame's filter (resets selection to the top match). |
palette.activate |
{"id": "new_tab"} |
Confirm the visible row with this id — runs its command or drills into its sub-frame. not-found if no palette is open or no row matches. |
palette.dismiss |
{} |
Close any open palette. |
palette.present |
{"title": "Open shed", "items": [{"id": "web", "title": "shed: web"}]} |
Open the palette on a caller-supplied list and block until the user picks a row or dismisses. Replies {"selected_id"?, "dismissed"} — selected_id is omitted on dismissal. invalid-param if items is empty. The programmatic twin of the command palette; items are {id, title, subtitle?} (the actionable flag a provider can set is not carried here — present rows are always selectable in v1). An agent object on a supplied item is ignored — including a malformed one, which decodes leniently to absent rather than erroring — so present rows always render generic, never the agent layout. v1 limitation: if the client disconnects while blocked, the palette stays open until the user dismisses it (no server-side cancellation yet). |
Selection + clipboard test ops (selection.* / clipboard.*)¶
| Op | Params | Effect |
|---|---|---|
selection.set |
{"tab_id": "1", "anchor": {"col": 3, "row": 0}, "cursor": {"col": 17, "row": 0}} |
Anchor a selection on the tab's terminal at viewport (col, row). The UI pins each endpoint with a libghostty tracked grid ref, so the selection follows its content through scrolling, scrollback eviction and reflow — same flow as mouseDown + mouseDragged. not-found if the tab has no live terminal. |
selection.clear |
{"tab_id": "1"} |
Drop the active selection (no-op if none). |
selection.dump |
{"tab_id": "1"} |
Read back the selection. Response: {"text"?: "...", "anchor_visible": bool, "cursor_visible": bool}. text carries the whole selection, including rows scrolled out of the viewport (#249). It is omitted when no selection is active, and also when an active selection currently resolves to nothing — its rows were evicted from scrollback, or it belongs to the screen (primary/alternate) that is not on display. Those cases are reported as an absent text, never as another row's text (#334); an alt-screen one starts reporting text again once its screen is active. anchor_visible / cursor_visible stay viewport-truthful on purpose — they answer "is this endpoint on screen right now", which is a different question from what text contains, and the pixel-level tests rely on it; a discarded or inactive-screen endpoint reads false. |
clipboard.dump |
{"target": "system" \| "selection"} |
Read the host pasteboard. Response: {"text"?: "..."}. system is the ⌘V / Ctrl+V target; selection is the named per-app pasteboard on Mac / X11 PRIMARY on Linux. Unknown targets → invalid-param. |
clipboard.write |
{"target": "...", "text": "..."} |
Test-only pasteboard seeding (lets a roosttest case set a known value before asserting paste behavior). Not gated: any process on the host can already write the OS clipboard. |
roostctl does not surface these yet — they exist for end-to-end test
coverage (tools/roosttest/) and as a stable surface a future scriptable
selection-driving feature (AI agent highlighting a region for the user
to confirm) could build on. Each op routes through the UI seam
(UiRequest::Selection* / UiRequest::Clipboard* on Linux, the
UiBridge protocol on Mac), not the workspace — pasteboard + selection
state live on the UI side.
Host registry (host.*)¶
Client-side saved-host bookkeeping for host sessions — a UI-only op family, like palette.*: it manages Workspace.hosts (the {id, label, target, last_connected} array persisted in the UI's own state.json, plan 037 §3.5), not a session's own workspace. host.connect / host.disconnect / host.status additionally reach into the UI's live connection set, so they answer with connection state rather than only a registry mutation — and they require a UI to be attached at all (internal: no UI attached on a headless engine embedder, the same honest failure every other UiRequest-backed op gives).
A session socket answers unknown-op for every verb here — the same "no shadow registry in the daemon" rule host.add --verify's own dial depends on. So does the Swift Mac app's socket: roostctl host * against --target mac is a documented, permanent unknown-op, not a gap to be filled — host sessions are iced-only (plan 037 §3.1), and the Swift app never grows this surface.
| Op | Request params | Notes |
|---|---|---|
host.add |
{"label": "pop-os", "target": "/home/charlie/.local/state/roost/roost-session.sock"} |
Saves a host. target is carried opaquely by this op — the workspace stores whatever string it's given, unvalidated — so classifying it (roost_ipc::ssh::classify: an SSH destination like workbox / user@host / ssh://user@host:port, only the ssh:// spelling carrying a port; a Unix socket path, containing /; or the localhost sentinel) and refusing an unclassifiable string (empty, a leading -, a bare host:port with no scheme) is each caller's job, done client-side before this op is ever sent — roostctl host add and the Add Host dialog both classify first and never call this op on a target that fails. Registry-only beyond that — this does not dial target, so a typo'd-but-classifiable target still saves cleanly (the sidebar's dot reports it at the next connect attempt). label is trimmed, must be non-empty, Unicode-case-insensitive unique, and not "local" (the reserved LOCAL band). Response: {"host": <Host>}. |
host.remove |
{"id": "3f9a2b7c1d4e4f5a"} |
Forgets a saved host — the registry entry and the dimmed rows its last connection left behind. Never touches the session itself: its shells keep running. Response: {}. |
host.list |
{} |
Response: {"hosts": [<Host>, ...]}. |
host.connect |
{"id": "3f9a2b7c1d4e4f5a"} |
Starts (or restarts) a connection. Unconditional takeover — reconnecting IS takeover on this wire — and on a localhost target it spawns the session first if nothing is listening. Answers as soon as the attempt is under way, with the state the request asked for ("connecting"), not the far end's eventual verdict — watch the sidebar or poll host.status for the settled state. Response: {"host": <Host>, "state": "connecting"}. |
host.disconnect |
{"id": "3f9a2b7c1d4e4f5a"} |
Drops the connection. Never stops the session — its shells keep running, and reconnecting picks them back up (disconnect ≠ stop). Response: {"host": <Host>, "state": "disconnected"}. |
host.status |
{} or {"id": "3f9a2b7c1d4e4f5a"} |
Every saved host's live connection state — the read-side twin of host.connect's reply, and what a script polls instead of scraping the UI log. Bare {} answers for every saved host in registry order; id narrows it to one, and an unknown id is not-found exactly as host.connect gives. Response: {"hosts": [<HostStatus>, ...]}. Not test-mode gated: this is a read of state the user can already see in the sidebar. |
Host is {"id": "<hex>", "label": "<string>", "target": "<string>", "last_connected"?: "<ISO-8601>"}. state is one of the wire's connection-state spellings: disconnected | connecting | connected | taken-over | stopped | needs-restart.
HostStatus is Host's four registry fields (so no second op is needed to correlate) plus what the sidebar's band is drawn from:
{"id": "3f9a2b7c1d4e4f5a", "label": "workbox", "target": "ssh://workbox",
"last_connected": "2026-09-01T17:40:02Z",
"generation": 3, "state": "disconnected",
"reason": "reconnecting in 8s (3/10)",
"rollup": "disconnected — reconnecting in 8s (3/10)",
"retry": {"delay_ms": 8000, "attempt": 3, "budget": 10, "armed_at": "2026-09-01T18:02:11Z",
"reason": "connecting to workbox failed: ssh: connect to host workbox port 22: Connection refused"}}
generation— which connection attempt this host is on. Bumped once per attempt started — an explicithost.connect, a launch auto-reconnect, or one rung of the ssh retry ladder —0before the first, and kept across a disconnect. This is the monotonic edge to wait on: two consecutive attempts can fail with byte-identical reasons, so "state isdisconnectedandreasonis set" cannot tell attempt N from N−1, but readinggenerationbefore a connect and waiting for it to advance can. It counts attempts rather than connections on purpose: an ssh host whose handshake never succeeds reaches no connection at all, and a ten-rung ladder that left the number flat would be no edge.state— the same spellingshost.connectanswers with, from the same classifier the band's dot reads. A host that has never connected isdisconnected.reason— the connection's own one-line reason, untruncated: the band's input. Absent when there is none.detail— the long form behindreason, when there is one the band has no room for. One thing fills it: a localhost session that could not be started, wherereasonis the ≤45-character band line ("cannot find roost-session","roost-session failed to start") anddetailis what actually happened — the launch ladder's three rungs verbatim, the exec error, or the daemon's own start verdict. Such a host carries noretry: no retry could find a binary, so it settles once and waits for ↻ Reconnect.rollup— the band's output, verbatim from the sidebar's reducer, capped at 60 characters with an ellipsis. For a connected host this is the agent count ("3 agents"), not state text; absent when the band shows no rollup at all. It is what the next frame draws — nothing here asserts a frame was painted.retry— aRetrySchedule, absent unless an auto-reconnect is armed.delay_msis the delay the timer was armed with (not what is left) andarmed_atis when, so a caller can compute the remainder.attempt(1-based, the3in the band's(3/10)) andbudgetcome with the ssh ladder only: a localhost retry is the connection task's own backoff whose counter never leaves the task, so it reportsdelay_msalone.retry.reason— why this rung is armed: the classified failure's own copy, in the same words the give-up line uses for it. It is a separate field from thereasonabove because that one is the band's input androllupis derived from it — while a rung is armed the band has to readreconnecting in 8s (3/10), so the family needs its own slot or it is unreadable until the attempt settles. ssh-only, likeattempt/budget. The rule for a caller is simply: read it when it is present. Do not gate onattempt— the number is not a proxy in either direction. Absence is ordinary rather than a fault: the drop that starts an outage is usually the live connection dying, a bare bridge EOF with nothing to classify, and the classified copy arrives with the next dial's failure; a later rung armed by another connection coming up and dying reads absent again for the same reason. And presence is not confined to later rungs — a suspend/wake resets the ladder toattempt: 1while deliberately carrying the family it already had, because it is still the same outage.
Every optional field is omitted rather than null, so a host that has never connected is {"id", "label", "target", "generation": 0, "state": "disconnected"} and nothing else.
host.add / host.remove are UiRequest-style when a UI is attached (a roostctl host add is visible in the sidebar immediately, no restart needed) and fall back to a direct Workspace mutation for a headless embedder (the engine's own tests) — both paths mint the same WorkspaceError wire codes, so a caller cannot tell which one answered. host.connect / host.disconnect / host.status have no headless form: connection state is the app's alone, and a field that spelled "unknown" and "disconnected" the same way would be exactly the lie the op exists to remove.
events.subscribe¶
Turn this connection into a one-way event stream. Served by a
host-session socket only, and lease-gated: the caller presents
the lease session.connect handed it.
Request: {"params": {"lease": "9f2c…6b83", "tab_id_filter": "0"}}.
Response (the last request/response frame on the connection):
A missing or unknown lease is {"code": "connect-required"}; the lease
of a client that was taken over is {"code": "taken-over"}. The two
instruct differently on purpose — go get a lease, versus stop, somebody
else drives this session now. Subscribing registers the connection
under the lease, which is what lets a later takeover close this
stream rather than leaving two clients both believing they drive the
session.
After the ack every frame is an EventBatch — one per workspace
commit, {"revision": <u64>, "events": [<EventEnvelope>, ...]}, one
per newline-delimited frame — except the single terminal control
envelope that ends the stream:
reason is "stop" (the session is shutting down) or "taken-over"
(another client took the lease). It carries no revision and is
exempt from the gap check below: it is not a commit, it is the stream
saying why it is over, and it is always the last frame before the
close. The catalog of batch envelopes is Events below.
The envelope is best-effort. A peer that stopped reading has already made the write impossible once its socket buffer filled, so a plain EOF remains the fallback signal and a client must treat an unlabeled close exactly as it treated one before: reconnect and resync.
Three properties make this lossless without a replay buffer:
- The ack is a fence.
revisionis the commit the subscription starts from, and the first batch is exactlyrevision + 1. Pair it withtab.list's ownrevision: snapshot, discard every batch<=it, apply the rest. - No gaps. Every commit is a batch, including a commit that
produced no events — that arrives as
{"revision": N, "events": []}. A skipped number therefore always means loss, never a quiet commit. - The server closes rather than thins. If a subscriber stops
reading, falls behind the workspace broadcast, or the connection
stalls, the server closes the connection instead of dropping events
out of the stream. A close is the resync signal: reconnect,
re-subscribe, re-pull
tab.list, and fence again — thesession.stoppingenvelope, where it arrives, only tells the client why it is resyncing.
After the flip the connection answers nothing. Frames a client writes
on it are read and discarded (so the server still notices a peer that
goes away), never dispatched and never replied to. That read is also
why a client must keep its write half open: half-closing it is how
a peer says it is gone, and the server ends the stream. session.stop
labels and closes every subscriber before it drains its in-flight work,
so a client watching a session sees the stream end — with a reason — as
the session goes down.
A non-zero tab_id_filter is rejected with invalid-param rather than
ignored — HS-2 scope. Silently serving an unfiltered stream to a client
that asked for one tab would make it mis-attribute every other tab's
events.
Breaking change, HS-1b (plan 036). HS-1a served this op with no
lease at all. A client written against that form still decodes — the
lease field defaults rather than being required — and gets
connect-required, which names the step it skipped instead of an
envelope-shaped invalid-param that names nothing.
SESSION_PROTOCOL_VERSION bumped 1 → 2 for exactly this.
On a UI socket the op is still unimplemented: it answers
{"ok": false, "error": {"code": "not-implemented", "message":
"events.subscribe is not yet implemented"}} rather than a false ACK,
because a UI process pushes nothing. Callers there poll tab.list /
tab.dump instead. A UI-side stream lands with its first consumer.
Session ops¶
Served only by a host session (roost-session), never by a UI
socket. A UI socket answers unknown-op for every one of them —
including tab.attach, which is a tab.* name but a
session-only op — which is how a client tells the two kinds of socket
apart.
The order a client runs them in is session.identify →
session.connect → session.set_theme / session.set_focus →
events.subscribe / tab.attach, with session.set_agent_hooks
queued behind them. Only the lease part of that is
enforced: identify is a stateless read and nothing requires it first,
but the lease is required, and the ops that need it say so by name.
The three set_* ops are placed where they are because each states
something the session would otherwise guess wrong — its palette, whose
window is looking at it, and whether the user wants agent hooks on this
machine — and each is re-stated whenever the client's own answer
changes. set_agent_hooks is last and off the critical path on purpose:
it is the only one that touches the filesystem, so a client queues it
rather than waiting on it.
Session sockets¶
roost-session is a headless daemon: it owns a workspace + PTY
supervisor exactly like a UI does, but with no window and no renderer
attached. It resolves the Session bundle profile
(paths.md) — same ~/Library/Caches/
RoostSession/roost.sock (macOS) / $XDG_RUNTIME_DIR/roost-session/
roost.sock (Linux) socket path identify.socket_path names above,
RoostSessionDev / roost-session-dev in debug builds. Startup installs
umask 0077 before creating anything, so the state dir, log dir, and
socket directory it creates itself land at 0700 and state.json at
0600; the socket file gets the same 0600 IpcServer::bind chmods
every profile's socket to. On top of that file-mode posture, a session
socket is the one IPC server in this codebase that also checks the
peer's UID at accept time (IpcServer::require_same_uid) and drops
any connection from a different user — a UI socket does not enforce
this, relying on the 0700/0600 directory posture alone. Before the
locks, validate_runtime_dir rejects (rather than repairs) a socket
directory some other mode or owner already created, so a session never
silently inherits a loosened directory. On shutdown the session unlinks
its socket only if the path still resolves to the (dev, ino) it bound
— a guard against removing a different, later session's live socket at
the same path.
The wire is byte-identical over SSH. A host session reached over an SSH target (host-sessions HS-3) is not a distinct protocol — the client's local bridge socket and the far side's roost-session client-bridge are a pure byte pump between this socket and the client's control/events/data connections, so every op and frame on this page crosses SSH exactly as written here. See Host sessions (development) → Transport: SSH hosts for the transport itself (per-connection ssh exec over a shared ControlMaster, the classified-failure surface); the classifier deciding whether a saved host's target is an SSH destination, a socket path, or localhost is host.add's concern, not this socket's.
roostctl session start|stop|status address this socket directly; they
are a pre-connect carve-out (session start has to work when nothing is
listening at all) and are deliberately not reachable through
--target / ROOST_BUNDLE_PROFILE / auto-detect. Any other op reaches a
session only via an explicit --socket <path>. See cli.md
for the verb-level contract (exit codes, ROOST_SESSION_BIN).
A session's default tab size is 120x40 (DEFAULT_TAB_COLS /
DEFAULT_TAB_ROWS) for both restored and freshly-opened tabs, since
there is no window to measure a size from — a UI socket keeps its usual
80x24 default. On start, a session hydrates its saved
state.json layout headlessly: it re-opens each tab's saved {title,
cwd} as a fresh shell (same "layout, not live state" contract as a UI —
DL-7)
and attaches a per-tab OSC drain to each one so title/cwd/notification
facts keep flowing with no terminal or renderer present. SIGTERM and
SIGINT converge on the same shutdown path as an IPC-driven
session.stop: the signal handler dials the session's own socket and
drives the identical latch → drain → flush → reap sequence described
below. Only if that self-dial fails outright (broken socket) or exceeds
its budget does the daemon fall back to a direct finalization — flush,
socket/lock cleanup, exit — without the mutation barrier or a reap
report; the children then die with the process.
HS-1a shipped with three documented deviations from
discovery/host-sessions-architecture.md;
HS-1b (plan 036) resolved all three, and each is now described where
it belongs: events.subscribe is
lease-gated, session.stop and takeover
label what they close, and terminal-generated queries
are answered by the tab's own server Terminal (below).
Every tab a session spawns now has an authoritative server Terminal behind it — a real libghostty terminal fed synchronously by a per-tab task, with 2000 lines of scrollback and VT continuation tracking on from construction. Three consequences a client can observe:
- Queries are answered, detached. A program that asks the terminal who it is (DA/DA2), where the cursor is (DSR), or what a palette entry is set to gets its reply on any tab, whether or not anybody is attached. The server answers, exactly once; a client that is attached and runs its own terminal over the same bytes must discard its own reply buffer, or the child sees the answer twice.
- The terminal is readable.
tab.dumpandtab.dump_resolvedare served from it. - The terminal is streamable.
tab.attachplus a data connection hand a client the whole terminal as a snapshot and then keep it live.
Flow control is the shape of the pipeline, not a policy on top of it:
the PTY reader feeds the tab task over a bounded channel, so a tab that
falls behind stalls its own reader and the child blocks on write. The
authoritative terminal never loses bytes; a runaway child pays for it
in backpressure rather than the session paying for it in memory.
session.identify¶
Params: {}. Response:
{
"app_version": "0.0.18",
"session_protocol": 2,
"payload_kinds": ["ghostty-snapshot", "vt"],
"libghostty_build": "ghostty-3f6b1c9a4d2e5f80+snapshot.v1",
"session_id": "01K3S8TQ4F0Q9YB2K6WZ5D7XN",
"started_at": "2026-08-27T14:03:11Z"
}
The handshake a client runs before anything binary exists, so every
incompatibility is caught on stable JSON. session_protocol is
SESSION_PROTOCOL_VERSION — deliberately separate from the
request/response protocol_version in identify, because
the two version different things and move independently. It is 2
as of HS-1b: the bump is breaking because
events.subscribe and tab.attach
now require a lease, and a client written against 1 subscribed with
none. The attach handshake carries the same number and
refuses a mismatch before it even looks at the token.
payload_kinds names what this session can encode a tab's attach
payload as, in no particular order; it is an open list of strings,
not a closed enum — a client preserves values it does not recognize and
negotiates on the ones it does. The sample above shows a host offering
two kinds, which is what a client's decode has to tolerate; the shipped
roost-session advertises ["ghostty-snapshot"] alone (vt, the
replay-a-byte-stream fallback, is a named kind with no implementation
behind it — architecture §4.4's escape hatch, not built).
libghostty_build is this session's pinned Ghostty build identity,
ghostty-<first 16 hex of the pinned SHA>+snapshot.v<format version>.
It is the negotiation for ghostty-snapshot: two libghostty builds
that disagree cannot exchange a snapshot, so tab.attach requires an
exact string match and refuses a mismatch by name
(build-mismatch) rather than letting it surface later as a corrupt
screen. Both fields were empty in HS-1a, when there was nothing to
attach to.
Test seam: with ROOST_TEST_MODE=1 set, a session additionally
reads ROOST_SESSION_FAKE_BUILD and reports that string as
libghostty_build instead of its real one — and uses it for
tab.attach's check #4 too, so the two stay consistent. Reproducing a
build/protocol mismatch otherwise needs a second binary built against a
second Ghostty pin, which no CI lane can produce; this makes it a
one-line fixture (plan 037 §3.7). Ignored entirely outside test mode,
so a production daemon can never be made to lie about its own build.
session.connect¶
Claim the session's interactive lease — the authority to drive tabs, not merely to read them.
Request:
Response:
takeover defaults to false. lease is 32 lowercase hex characters
of OS entropy — a bearer credential: never log it, never print it
in a failure dump, never echo it in an error. revision is the
workspace commit the lease was minted at, read under the same lock as
the snapshot, so a client can fence its first
tab.list against the event stream without a second round
trip.
The lease is the interactive-authority boundary, and a self-declared
client id would not be one: possession of the token is what proves a
client is the driver. It gates events.subscribe
and tab.attach. Administrative ops — tab.open,
tab.list, tab.write, project.*, tab.agent_report, the dumps —
stay lease-free: they are same-UID control-plane use (roostctl, a
Claude hook), not interactive ownership.
The lease outlives the connection it was minted on. Dropping every socket releases nothing; a client that reconnects is a new client as far as the session is concerned. That is deliberate — a half-crashed client still holding a data connection must not be able to keep typing into tabs a replacement believes it owns, and the session cannot tell a crash from a slow network.
So reconnecting is always a takeover:
| Situation | takeover: false |
takeover: true |
|---|---|---|
| No lease held | mints a lease | mints a lease |
| Someone else holds it | already-connected |
takes it |
| You hold it, on this very connection | already-connected |
takes it (fresh token) |
The third row is not an oversight. A client that lost track of its own lease is exactly the one that has to re-establish it deliberately.
A takeover, under one lock, atomically: invalidates the old lease, closes every connection registered under it except the requesting one, purges its outstanding attach tokens, and mints the new lease. What "closes" means depends on what the connection was doing:
- a plain control connection just closes — there is no stream its peer is waiting on, only a reply it never asked for;
- an events connection gets the terminal
{"event": "session.stopping", "data": {"reason": "taken-over"}}envelope, then closes; - a data connection gets an
ERRORframe with codetaken-over, then closes.
Both labels are best-effort under a 2 s deadline: a peer that stopped reading made the write impossible when its socket buffer filled, and EOF is then the only signal it gets.
Purging the displaced lease's attach tokens matters for a reason that
is easy to miss: they would be refused at the handshake's lease
re-check anyway, but leaving them would let a dead client's 16
outstanding tokens hold the whole quota against the new holder for a
full TTL. A ticket purged this way answers invalid-token — the
session no longer recognizes it at all — while an op that presents the
lease answers taken-over.
Exactly one tombstone. The session remembers the most recently
displaced lease so its holder is told taken-over (someone else has
it; stop) rather than connect-required (you never connected;
reconnect). Only the most recent: after a second takeover the
first-displaced client's lease is forgotten and it falls back to
connect-required. It has already been told, and an unbounded graveyard
of dead leases is not a thing a process meant to run for weeks should
keep.
session.connect is a mutating op for stop-latch purposes — it hands
out authority — so it answers shutting-down once
session.stop has latched.
session.set_theme¶
Seed every tab's server Terminal with the connected client's palette — closes architecture §13's reseed gap (plan 037 §3.6). Lease-gated: only the interactive-lease holder may recolor a session.
Request:
{"id": "8", "op": "session.set_theme", "params": {
"lease": "9f2c1d7a4b6e08315c0d9a72e4f16b83",
"osc_colors": {
"foreground": "#ffffff", "background": "#1c1c1c", "cursor": "#98989d",
"palette": ["#000000", "... 256 entries total ..."]
}
}}
Response: {"tabs": 3} — the number of live tabs whose server Terminal was reseeded. 0 is a success: a session with no tabs yet still remembers the theme for the ones it opens next.
palette must carry exactly 256 #rrggbb entries (lowercase, the same spelling tab.dump_resolved uses); a short or long array is invalid-param rather than a partial application.
A client sends this immediately after session.connect and before its first tab.attach — attaching before the theme lands would paint the session's factory colors for one frame — and again whenever its own theme changes thereafter. Concurrent callers are last-writer-wins by design: the theme store mints a generation on every apply, so a set_theme racing a tab spawn is caught up at promotion rather than silently lost, and interleaved fan-outs converge on the newest theme instead of whichever send landed last.
Like session.connect, this answers shutting-down once session.stop has latched.
session.set_focus¶
Tell the session which of its tabs the attached client is actually looking at. Lease-gated: focus is a property of the client driving the session, so a client that does not drive it does not get to state one.
Request:
{"id": "9", "op": "session.set_focus", "params": {
"lease": "9f2c1d7a4b6e08315c0d9a72e4f16b83",
"focused_tab_id": "5"
}}
Response: {} — nothing to report beyond "applied".
Why it exists. A session is headless: it has no window, so its workspace defaults to focused, and its active tab is whatever its restored layout selected. The focus-suppression rule — suppress := the window is focused AND this is the active tab — is therefore permanently satisfied for one tab per session, and a suppressed raise emits nothing at all (no pending bit, no notification.fired, no badge). Without this op, that one tab's agent can never reach an attached client. With it, the client that does have a window states the truth and the session suppresses the tab the user is really looking at.
focused_tab_id is required, and nullable. null means "nothing on this session is being looked at" — the client's window lost focus, or its selection moved to another host or to a local tab. An omitted field is not the same statement and is refused with missing-param: a client that forgot to say is exactly the one that must not be guessed for, since guessing "focused" re-creates the mute this op exists to fix.
Validation order, for the same reason tab.attach pins one — each failure names a different thing to fix:
missing-param/invalid-param— the field is absent, or is neither a decimal-string tab id nor null. Decode comes first of necessity: the lease itself rides inside the params, so an envelope that does not decode cannot present one.connect-required/taken-over— the lease gate.not-found— a tab id this session does not have.
The apply is one workspace transaction and not-found leaves nothing applied: a client naming a tab that just closed must not flip the session to "focused" against whatever tab happened to be active. On success with an id, the session both marks itself focused and moves its own active selection (and its persisted selection) onto that tab, as tab.focus would; it does not acknowledge the tab's notification — the client sends tab.clear_notification for that. Re-stating a focus that is already current emits no active.changed, so a reconnecting client's re-assert costs other clients no re-render. null moves the flag alone and leaves the selection where it is, so a reconnect restores the same tab.
A focus does not outlive the client that reported it. The lease deliberately outlives its connections, but the focus does not: the session reverts to "nobody is looking" (the flag only — the selection stays) when
- a new lease is minted,
session.connecttakeover included; - the connection that sent the
session.set_focuscloses; and - the last connection registered under the live lease closes.
The middle one is what keeps the reset independent of ordering: a client re-dialing on the same lease can register before the departed client's close is noticed, and counting live connections alone would then leave a gone client's focus standing.
A client therefore re-states its focus right after session.connect, and again whenever its window focus or selection moves — including when the session's own active.changed reports a move away from the stated focus (a lease-free tab.focus from a script would otherwise park the suppressed slot on a tab nobody is watching until the client's next natural edge). A session one release older answers unknown-op, which is a refusal like any other: the connection is unaffected and the client keeps HS-2's behavior (the attached tab suppresses its own notifications). The reverse pairing — a session with this op driven by an older client that never sends it — errs the loud way: the connect-time reset leaves the session unfocused, so nothing is suppressed and the attached tab's notifications fire rather than vanish.
Like session.connect, this answers shutting-down once session.stop has latched — the latch is checked before the lease gate, so a stopping session says so rather than sending a client off to reconnect.
session.set_agent_hooks¶
Bring the host's agent hook entries in line with the connected client's agent-hooks configuration. Lease-gated: this one writes files under the session user's $HOME, so only the client driving the session may send it.
Request:
{"id": "11", "op": "session.set_agent_hooks", "params": {
"lease": "9f2c1d7a4b6e08315c0d9a72e4f16b83",
"mode": "auto",
"skip": ["cursor"],
"client": "charlie-mbp"
}}
Response:
{"wired": ["claude", "codex"], "refreshed": [], "removed": [],
"skipped": [{"agent": "cursor", "reason": "skip-list"},
{"agent": "grok", "reason": "not installed"}],
"errors": []}
mode is auto or off — the same two values agent-hooks takes in config.md; any other spelling is invalid-param rather than a silent default in either direction. skip is the client's agent-hooks-skip list verbatim: the host resolves the names and reports back any it does not recognise as a skip with reason no agent named that (…), because only the host can tell a typo from an agent a newer client knows about, and neither is a reason to refuse the run. skip may be omitted (an empty list); client may not — it is recorded as by in the host's state record, which is what makes two clients of one host tellable apart.
off removes, it does not abstain. On the client's own machine agent-hooks = off means "wire nothing", and the UI never opens an agent's config file. Here it means "unwire": a host has no config.conf of its own to consult, so the client is the only authority that can tell it to come clean, and an off that did nothing remotely would leave a host's entries in place with no way to remove them short of an ssh session. Off is off everywhere.
wired is the toast list, not this call's writes. It names the agents this host has wired and has never announced to any client — the session flips its record's noticed for exactly what it reports here, in the same locked write that recorded the wiring, so the sentence "Roost wired agent hooks on ‹host›" appears at most once per agent per host even when two clients connect at the same moment, and including for a wiring done by roostctl agent ensure on the host itself. A reconnect, or a second client, gets an empty wired. refreshed and removed are this call's writes.
A per-agent failure is reported, never raised. A config.toml the host could not parse, a read-only file, a file that changed underneath the plan: each is an entry in errors beside a successful reply, because the wiring is not what the client dialed in for and must not cost it the session it just attached to. Only a whole-run failure — no $HOME, an unwritable state record, an install lock another writer held past its deadline — is an error frame (internal).
The lease is checked again at the point of effect, and a displaced client hears taken-over. The install engine holds one advisory lock per home across plan and apply, so this op can wait behind another writer; neither dropping the client's connection nor the client's own 15 s timeout cancels a run already under way on the host. A request admitted under a lease that has since been taken over would otherwise finish afterwards and rewrite the files — and the state record — against the policy of whoever displaced it. So the session re-asks whose lease is live once it owns the lock and before it plans anything: still current, the run proceeds; taken over, nothing is written and the reply is taken-over like any other lease-gated op. Two clients that each hold the lease in turn are still last-writer-wins (below); one acting after it lost the lease is not. The wait for that lock is itself bounded — a lock nobody releases is a whole-run internal failure rather than a request that never answers, because this op holds the mutation barrier session.stop waits on.
A client sends this after every session.connect, with its own config values, because the op is idempotent and a config edit made since the last connect has no other way to reach the host. It is queued rather than chained into the connect: an error in the chain fails the whole attempt, and an ensure on a network-mounted $HOME would hold hydration up behind file I/O nothing is waiting on. A session that predates the op answers unknown-op, which is a refusal like any other — the connection is unaffected, and the client logs one line, once, for as long as it keeps dialling that host. It is deliberately not one line per connection: the op is re-sent on every connect and a dropped localhost session reconnects on a 250 ms ladder, so the latch outlives the connection, exactly like the fact it records.
Two clients that disagree flip the files on every reconnect. Last writer wins, by design: the record stores by and wired_at, the session logs each run, and roostctl agent status on the host shows who did what. Reconciling them is future work.
No new authority: a client that holds this session's lease can already tab.open an arbitrary command on the host.
Served only by a session built with an install backend. A session without one answers not-supported rather than reporting an empty success; a UI socket answers unknown-op like every other session.* op.
Like session.connect, this answers shutting-down once session.stop has latched — and it is in the latched set deliberately, because entries wired after a stop would point at a roostctl reporting to a socket the session is about to unlink.
tab.attach¶
Negotiate a payload kind for one tab and get a single-use ticket for
one data connection. Session sockets only; a UI socket
answers unknown-op.
Request:
{"id": "4", "op": "tab.attach", "params": {
"lease": "9f2c1d7a4b6e08315c0d9a72e4f16b83",
"tab_id": "5",
"kinds": ["ghostty-snapshot"],
"cols": 120,
"rows": 40,
"cell_w_px": 9,
"cell_h_px": 18,
"libghostty_build": "ghostty-3f6b1c9a4d2e5f80+snapshot.v1"
}}
Response:
{"id": "4", "ok": true, "result": {
"attach_token": "1a0be5c37d924f68b1c05e3a7f2d8496",
"kind": "ghostty-snapshot",
"server_epoch": 6032428321756423947,
"tab_generation": 3
}}
kinds is the client's preference order and the server serves the
first entry it supports; a list mixing kinds this build has never heard
of with one it serves is fine.
Validation order is part of the contract, because each failure tells the client to fix a different thing and an earlier one must not be masked by a later one:
| # | Check | Error |
|---|---|---|
| 1 | lease is live | connect-required (unknown/absent) or taken-over (tombstoned) |
| 2 | tab exists with a live terminal | not-found |
| 3 | kinds contains something servable |
unsupported-kind (message names both lists) |
| 4 | libghostty_build matches exactly |
build-mismatch (message names both strings) |
| 5 | cols and rows both non-zero |
invalid-param |
| 6 | the tab accepts the geometry | invalid-param |
| 7 | token quota not exhausted | too-many-tokens |
Zero cell_w_px / cell_h_px are legal — a headless client has no
cell metrics to report — but a zero-sized grid is not a grid.
Attach is when the server resizes. Between checks 5 and 7 the
session resizes the tab (server terminal and TIOCSWINSZ) to the
requested geometry and waits for that to land, so the snapshot the
data connection is about to encode is already at client size and needs
no post-READY resize. Detach never resizes back — the PTY keeps the
last attached size, so a TUI agent does not get a SIGWINCH because
somebody closed a laptop. This does not contradict that rule: attach is
exactly when an in-process Roost resizes too.
attach_token is 32 hex characters, the same bearer credential the
lease is and under the same no-logging rule. It is:
- single-use — consumed under the registry lock, so two connections presenting one token admit exactly one;
- short-lived — 60 s TTL (
ATTACH_TOKEN_TTL), a protocol constant that is not scaled byROOST_TEST_TIMEOUT_SCALE. A session started withROOST_TEST_MODE=1honorsROOST_SESSION_ATTACH_TTL_MSto shorten it, which is how the expiry case is tested in seconds; a production daemon ignores that variable entirely; - quota-bounded — at most 16 unconsumed tokens
(
MAX_OUTSTANDING_TOKENS) exist at once. Past that, minting is refused withtoo-many-tokensrather than evicting a token some other connection is about to present. Reaching it means a client minted 16 tickets inside one TTL and dialed none of them; a healthy attach consumes its ticket within a round trip; - lease-bound — re-checked at the handshake, so a takeover between
this reply and the dial refuses the ticket with
taken-over; - pipeline-bound — stamped with the
tab_generationbelow, so a respawn in the same window is a cleannot-foundrather than a stream from a different terminal under the old identity.
server_epoch and tab_generation are the resume identity. The
epoch is a random value minted once per session process; the generation
counts tab pipelines within it. A client that later wants to resume a
stream hands both back, and the randomness is what makes a restarted
session's streams unresumable by construction rather than by luck — a
monotonic counter would collide across a restart and silently accept a
stale stream. Both ride as bare JSON numbers, not the
string-wrapped int64 ids use: they are counters, not ids. Neither can
exceed i64::MAX — the epoch is deliberately 63 random bits, not 64,
because a top-bit-set value round-trips imprecisely through a decoder
that falls back to Double, and the whole point of the field is an
exact match.
Like session.connect, tab.attach answers shutting-down once
session.stop has latched.
session.stop¶
Params: {}. Response: the reap report,
Stops the session. In order: the session latches stopping (every
mutating op from that point answers {"code": "shutting-down"}, reads
keep answering, and a second session.stop gets shutting-down too);
it labels and closes every connection the lease holder owns — an
events connection gets the terminal
{"event": "session.stopping", "data": {"reason": "stop"}} envelope, a
data connection gets an ERROR frame with code shutting-down, a
plain control connection just closes; it waits out the mutating
requests already in flight, so a tab.open that got past the latch
completes and its tab is included below; it flushes the workspace
layout; then it hangs every PTY up, escalating to SIGKILL after a
soft deadline.
The labeling comes before the relays are torn down, on purpose: cut the relay first and the peer gets a bare EOF it cannot tell from a crash. It is still best-effort under a 2 s deadline — a peer that stopped reading gets EOF, which remains a valid signal.
The three id lists partition the tabs that were live when the stop
began — each id appears in exactly one, and each is sorted. reaped
died on the hangup; killed was still live at the deadline and was
SIGKILLed; abandoned was still unreaped after the post-kill tail and
the session stopped waiting for it. Ids are string-encoded like every
other id on this wire.
The reply is written before the process-level shutdown tail runs, so a client always gets its report even though the session is on its way out.
Data plane¶
One connection per attached tab, carrying one tab's terminal: a snapshot of what is on screen now, then everything that happens next. It shares the session's socket path with the JSON control plane but not its framing — after a one-line handshake the wire turns binary and stays that way.
Why a second connection at all: the control plane is serial
request→response, so keystrokes would queue behind their own acks and a
slow op like tab.dump would head-of-line-block typing.
The data connection is unacknowledged and bidirectional, which is what
keeps input latency flat under control-plane load.
The handshake¶
The first line a connection writes decides what it is. A JSON
object carrying attach and no op is a data handshake; anything
else — an op-carrying envelope (even one that also has attach), a
non-object, malformed JSON — stays a request connection and behaves
exactly as it did before the data plane existed. The test applies to
the first line only, so a request stream can never be diverted
mid-flight by a payload that happens to look like a handshake.
{"attach": "1a0be5c37d924f68b1c05e3a7f2d8496", "protocol_version": 2,
"resume_from_seq": 8814, "server_epoch": 6032428321756423947,
"tab_generation": 3}
attach and protocol_version are required; the resume triple is
optional and all-or-nothing in practice (see Resume below).
Decode is permissive — a newer client may carry fields this build
has never heard of, and refusing the whole handshake over one would
turn an additive change into a hard incompatibility.
Scope: this sniff exists on Rust-served sockets only. The Mac UI's
Swift IPC server has no data plane and is untouched — a handshake line
there gets the parse-error it always got. A Rust UI socket
recognizes the shape and answers not-supported, which is a different
and more useful thing to tell a client than "your JSON is bad".
The reply is one JSON line. Accepted:
{"ok": true, "kind": "ghostty-snapshot", "mode": "snapshot",
"seq": 8813, "server_epoch": 6032428321756423947, "tab_generation": 3}
Rejected — then the connection closes, and nothing binary is ever written, so a client that got a refusal never has to guess whether the bytes after it are frames:
| Code | Meaning |
|---|---|
protocol-mismatch |
wrong protocol_version. Checked before the token: the two ends disagree about what a token even is, and invalid-token would send the client hunting for the wrong bug. |
invalid-token |
unknown, expired, already-used, or purged by a takeover. |
taken-over |
the lease the token was minted under is no longer current. |
not-found |
the tab has no live terminal, or was respawned between tab.attach and this handshake. |
snapshot-failed |
the terminal could not be encoded right now. Re-attach is the recovery — it is about this instant, not about the client. |
shutting-down |
session.stop has latched. |
parse-error |
the handshake line did not decode. |
not-supported |
this socket serves no data connections. |
mode is "snapshot" or "resume", and seq is the fence: the
client has everything up to and including it, and the first PTY frame
carries seq + 1. In snapshot mode the fence is the snapshot's own
encode point; in resume mode it is resume_from_seq - 1.
Preamble and frames¶
After an accepted reply the server writes the 8-byte magic
ROOSTDP2 — a client that reads anything else has negotiated with a
host it cannot talk to and must not try to parse what follows — and
then frames flow both ways:
| Type | Dir | Payload |
|---|---|---|
0x01 SNAP |
S→C | the next bytes of the encoded snapshot stream |
0x02 PTY |
S→C | u64-LE seq | raw PTY bytes |
0x03 EXIT |
S→C | u64-LE final_seq | i32-LE exit code |
0x0F ERROR |
S→C | JSON {code, message}; the connection closes after it |
0x11 INPUT |
C→S | raw encoded key/paste bytes, ordered and unacknowledged |
0x12 RESIZE |
C→S | u16-LE cols | rows | cell_w_px | cell_h_px |
Rules, all fatal (best-effort ERROR, then close). Only the first is
the framer's — it has to be, because it bounds the allocation made
before anyone sees the frame; the rest belong to the endpoint that
knows the protocol state, which is why a client validates the widths of
what the server sends and vice versa:
- 1 MiB per payload (
MAX_DATA_FRAME_BYTES), both directions, reader and writer. A client with a bigger paste splits it acrossINPUTframes — this is the client's job, not something the server will do for it. The server splits an oversized snapshot record acrossSNAPframes for the same reason. - Fixed-width payloads are exactly that width:
PTY≥ 9 bytes,EXIT== 12,RESIZE== 8. - A zero-length payload is meaningful only for
SNAP. An emptyINPUTis aprotocol-error. - An unknown type byte is a
protocol-errornaming the byte. The framer hands it up rather than failing the decode, precisely so the endpoint can name it.
EXIT is always the last frame on a connection that sees one, and
final_seq == last PTY seq + 1 — the exit consumes an ordinal of its
own, so a client that has applied PTY frame final_seq - 1 knows it
missed nothing. Pixel dimensions on RESIZE are load-bearing, not
decoration: the server terminal's resize and mode-2048 size reports
both need them.
Ordering, once the stream is running:
- The whole snapshot prefix through Ghostty's READY record goes
out at full speed, and live
PTYframes are absorbed but held until it has. A client has no terminal to apply aPTYframe to before READY, and making it buffer them would move this queue into every client. The window is tiny — that prefix is just the active screen. - After READY,
PTYleads: a keystroke's echo must not wait behind a scrollback page. - But the snapshot cannot be starved. At least one
SNAPframe goes out after 256 KiB ofPTYpayload or 50 ms since the last one, whichever comes first — ayes-style producer would otherwise hold FINISH off for as long as it kept running, and a slow-but-endless one would never trip a byte floor at all. EXITgoes out only once everything before it has.
The snapshot's own record structure (GHOSTSNP: envelope, READY,
history pages, FINISH) rides inside SNAP frames as an opaque byte
stream — no record alignment, since Ghostty designed the format to be
embedded and the client's decoder buffers to record boundaries itself.
Ghostty's READY is the only READY in this design; the transport adds no
second marker.
ERROR frames carry a stable code:
| Code | Meaning |
|---|---|
desync |
the stream cannot be trusted: a gap or duplicate seq, a lagged tee, a snapshot that blew the attach budgets. Re-attach. |
overflow |
the peer is not reading — 8 MiB queued for it, or a single write past its deadline. |
superseded |
a newer data connection took this tab. |
taken-over |
another client took the session lease. |
shutting-down |
session.stop latched. |
protocol-error |
the client sent something the framing forbids. |
A gap is fatal rather than papered over because the client's terminal
would silently diverge and could never tell — and re-attach already
rebuilds from a fresh snapshot, so there is nothing to gain by
continuing. Every ERROR is best-effort: a peer that stopped reading
made the write impossible, and EOF is the accepted fallback everywhere
a label is promised.
Resume¶
A client that already holds a tab's stream up to some seq can ask for
the rest instead of a whole new snapshot: send resume_from_seq
together with the server_epoch and tab_generation the original
tab.attach returned. On a hit the reply says mode: "resume", no
SNAP frames are sent at all, and the tab's replay ring (2 MiB,
oldest evicted) plays back as ordinary PTY frames ahead of the live
ones — through the same contiguity walk, so a hole in the ring is as
fatal as a hole in the live stream.
Resume is honored only when all of these hold:
server_epochmatches this session process exactly. A restarted session mints a fresh random epoch, so a pre-restart stream cannot match — by construction, not by luck.tab_generationmatches the tab's current pipeline, so a respawned tab's seq space is never streamed under the old one's identity.ring_front <= resume_from_seq <= last_assigned + 1. The upper bound is inclusive:last_assigned + 1is a valid empty-slice resume — the client missed nothing and simply carries on.
Every miss — resume_from_seq of 0, a seq past the end, an evicted
range, an identity mismatch, a tab task that went away — falls back to
mode: "snapshot" and a full attach in the same reply. A resume
failure is never an error, and a client never has to handle one: it
reads mode and does what it says.
Supersede, and one connection per tab¶
A tab has at most one live data connection. A second admitted
handshake for the same tab supersedes the first, which closes with
ERROR superseded — two forwarders racing one tee is not a state worth
supporting, and the client that just attached is by definition the
current one. Admission (token consume, lease re-check, registration,
supersede) happens as a single step under one lock, so a takeover
either wholly precedes an attach or wholly follows it.
Client disconnect at any point simply aborts the forwarder; the tab keeps running and no partial state survives. That is the difference between detaching and stopping: dropping the socket leaves the session exactly as it was.
Budgets¶
The budgets are behavior a client can hit, not tuning knobs:
| Budget | Value | On breach |
|---|---|---|
| Snapshot half of an attach, in time | 60 s from the fence | ERROR desync |
| Snapshot half of an attach, in bytes | 512 MiB (snapshot + live PTY written alongside it) | ERROR desync |
| Queued-but-unwritten PTY bytes | 8 MiB | ERROR overflow |
| A single stalled write | the push write deadline | ERROR overflow |
A slow consumer therefore gets cut off deterministically instead of growing the session's memory, and re-attaching immediately afterwards works — there is no thrash loop and no cooldown.
One ordering caveat, documented rather than solved: control-plane
tab.write and data-plane INPUT frames have no
cross-channel ordering guarantee. They arrive at the tab task in
whatever order they reach it. tab.write is administrative,
low-frequency scripting; interleaving it with live typing is not a
supported pattern.
Events¶
Server-push only, delivered on a host-session socket after
events.subscribe. Each envelope is a
{"event": "<name>", "data": {...}} object inside an EventBatch;
several envelopes can share one batch, which is what makes a commit
atomic on the wire. The set below is exhaustive — the serializer
(crates/roost-engine/src/event_push.rs) is a total match over the
workspace's event enum, so a new event cannot ship without a name
here.
session.stopping is deliberately not in this set: it is not a
workspace event, it carries no revision, and it never rides inside a
batch. It is the connection's own terminal control envelope — see
events.subscribe.
tab.opened—{"tab": <Tab>}.tab.closed—{"tab_id": "<id>"}.tab.state_changed—{"tab_id": "<id>", "state": "<TabState>"}.tab.title_changed—{"tab_id": "<id>", "title": "<string>"}.tab.cwd_changed—{"tab_id": "<id>", "cwd": "<string>"}. Note: when an OSC 7 (ortab.set_cwd-equivalent) lands on a tab whoseuser_titledis false, the workspace also re-derives the title from the basename of the new cwd. Subscribers will see atab.cwd_changedimmediately followed by atab.title_changed(in that order, cause-then-effect) for that single op — treat them as a pair, not as one-event-per-op. On shells with the shipped integration, a furthertab.title_changedarrives a prompt cycle later (OSC 0 → tilde-abbreviated full path).tab.notification—{"tab_id": "<id>", "has_pending": <bool>}.project.created—{"project": <Project>}(tabs empty).project.renamed—{"project_id": "<id>", "name": "<string>"}.project.deleted—{"project_id": "<id>"}.active.changed—{"project_id": "<id>", "tab_id": "<id>"}(either may be"0").tabs.reordered—{"project_id": "<id>", "tab_ids": ["<id>", ...]}. The full post-reorder display order for that project, not a diff.projects.reordered—{"project_ids": ["<id>", ...]}. The full post-reorder sidebar order.hook_active.changed—{"tab_id": "<id>", "active": <bool>}.notification.fired—{"tab_id": "<id>", "title": "<string>", "body": "<string>"}. Mirrors the legacy proto'sNotificationEvent; useful for tools that mirror notifications elsewhere.agent_report.changed—{"tab_id": "<id>", "shell_state": "<ShellState>", "agent_lifecycle": "<AgentLifecycle>", "ownership": "<Ownership, omitted when unowned>", "state": "<TabState>", "hook_active": <bool>}. Fires whenever an acceptedtab.agent_reportor an OSC 133 shell mark changes the agent record.tab.state_changedandhook_active.changedstill fire for their (derived) slices, so existing subscribers keep working unmodified — this event carries what those two projections lose: which lifecycle, whose session, and the shell axis underneath.stateis included pre-derived so a subscriber never has to re-run the projection itself.tab.effect—{"tab_id": "<id>", "effect": "bell" | "clipboard-write", "data"?: "<base64>", "target"?: "system" | "selection"}(plan 037 §3.6). One client-directed side effect the tab's OSC scan produced, for whichever client is attached (§3.4's lease-holder-only contract) to apply — a bell BEL byte outside any escape sequence, or an OSC 52 clipboard write.dataandtargetare present only forclipboard-write:datais the decoded payload, base64-encoded like every other bytes field on this wire and capped at 256 KiB decoded (CLIPBOARD_EFFECT_MAX_BYTES— an oversized write is dropped and debug-logged by size, never by content);targetdistinguishes OSC 52's primary-selection form (p/s) from the system clipboard (cor no selector), defaulting tosystemwhen absent. HS-2 ships exactly these two effects — every other client-local OSC effect (pointer shape, today) stays dropped + debug-logged in the tab task rather than added to this envelope.
Dropped vs. the legacy proto¶
These RPCs/messages were intentionally dropped — the new architecture makes them unnecessary:
StreamPty(PtyClientMessage,PtyServerMessage, all variants). The UI owns the PTY; nothing crosses the wire.ReportOsc. OSC sequences are parsed in the UI; the UI updates its own state directly. There is nobody to round-trip to.WatchEvents(legacy event stream RPC) is replaced by theevents.subscribeop + push envelopes on the same connection; seeevents.subscribe. Served by a host session today, stillnot-implementedon a UI socket.
Schema-only fields that survive but rename:
- Proto
TabStateenum → JSON string. Mapping:TAB_STATE_NONE → "none",TAB_STATE_RUNNING → "running",TAB_STATE_NEEDS_INPUT → "needs_input",TAB_STATE_IDLE → "idle".TAB_STATE_UNSPECIFIEDis omitted; the server never returns it.
Versioning¶
identify.protocol_version is the integer schema version. M0 ships
version 1. Additive changes (new optional fields, new ops, new
events) do not bump the version. Breaking changes coordinate a major
version bump and updated clients.