Skip to content

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.app or a dev build): ~/Library/Caches/Roost-iced/roost.sock
  • Installed roost on 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 UI profile explicitly. Without an explicit selector, roostctl probes every distinct UI 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. A session is reachable only explicitly: roostctl --target session (or --socket pointed at its path) — never by auto-detect, and never by ROOST_BUNDLE_PROFILE, which still refuses session like any other unrecognized value (#475). roostctl session start|stop|status keep their own carve-out ahead of that ladder: they address the session profile's socket directly, since start must work when nothing is listening yet. An op a session does not serve fails with the server's own error for that op, not a session-specific one: unknown-op for host.* and agent.set_hooks (client-side UI state a session doesn't keep), internal: no UI attached for app.* window/UI ops (except app.activate, which answers {} and does nothing). identify (on a UI socket) and session.identify (on a session) each list what that socket serves right now in ops. A UI socket, symmetrically, answers unknown-op for every session.* op, and serves events.subscribe only while it runs its tabs in-process, and only live — see On a UI socket.

The Swift Mac app (Roost.app) does not do three things this page describes for agent tooling: it answers unknown-op to project.ensure, its identify reply has no ops and no instance_id, and it answers not-implemented to events.subscribe.

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 \n inside JSON strings is the encoder's responsibility (serde_json and JSONEncoder both handle this correctly).
  • Request envelope: {"id": "<string>", "op": "<dotted-name>", "params": {...}}. The id is a string-wrapped 64-bit integer, because JSON numbers lose precision past 2^53; the legacy proto schema used int64 for tab/project ids and we preserve that range. Rust uses #[serde(with = "string_int64")]; Swift's Codable uses a custom encoder that emits String(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": {...}} — no id, no response expected. Pushed inside an EventBatch on a connection that ran events.subscribe, which host-session sockets serve and an in-process UI socket serves live. The one exception is the terminal control envelope — session.stopping from a session, stream.ended from a UI socket — 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..0xff round-trip) in both directions.
  • Unknown fields: strict on the server side (rejected with unknown-field error). Permissive on the client side (clients ignore unknown fields so the server can add fields without breaking older clients). Swift's Codable is permissive by default and the client-side request encoders match that policy unchanged. On Rust, serde is permissive by default — server-side request structs in roost-ipc carry #[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 from update on the winit event-loop thread). Responses are delivered in completion order, which is not guaranteed to match request order. Clients correlate by id.
  • Schema drift mitigation: tests/ipc-vectors/*.json is a directory of canonical message exemplars (one file per op/event). Both cargo test -p roost-ipc (Rust) and Swift's XCTest target 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, too-large, store-full, shutting-down, host-unavailable, not-enabled, busy. Clients should treat unknown codes as fatal for the request and surface message to the user. too-large and store-full arrived with session.put_file and are both about one file: it is over the per-upload cap, or the host's file store has no room left for it. Two codes rather than one because the remedy differs — send something smaller, or restart the session to clear the store — and a status line has to say which. host-unavailable is shutting-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 those above that both sockets share (every one but shutting-down, host-unavailable and not-enabled); a session-scoped code (including shutting-down) or one a newer session invents folds onto host-unavailable, its own code and sentence kept in message, so a UI socket never answers a code this list does not name. too-large and store-full are deliberately on the shared side of that line (CODES_A_UI_SOCKET_ALSO_SPEAKS, app/servicing.rs): folding them would tell a caller the host is gone when the host is right there and one file did not fit. So is busy, the one refusal a caller retries. not-enabled answers a test seam called on a server launched without ROOST_TEST_MODE=1. busy says a local-backend switch is in flight and the request did nothing: poll identify until local_backend_switch is absent, then re-read any ids before retrying, since a switch replays the tabs under new ones. A server older than busy refused the same request host-unavailable, so on host-unavailable re-read identify too: a present local_backend_switch is that older spelling of busy, and an absent one means the session really is down. shutting-down is session-socket only: once session.stop latches, every mutating op answers it (reads still answer normally), and a second session.stop on the same session gets it too instead of a fresh reap report. Session sockets add a further six: unsupported-kind, build-mismatch and too-many-attaches from the attach handshake's negotiation, and replay-expired, revision-ahead, session-mismatch from events.subscribe's resume. Every same-UID connection is otherwise symmetric — there is no per-connection authority code, because there is no per-connection authority.

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>",
    "password_input": "<bool, omitted while false>",
    "dropped_reports": "<DroppedReports, omitted until a report is dropped>"
  },
  "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.password_input is true while the tab's PTY is at a password prompt: its line discipline is in canonical (line) mode with echo off (ICANON && !ECHO), Ghostty's heuristic. The process that owns the PTY samples it off the master every 200 ms — the UI for an in-process tab, the session for a host-session tab, so a sudo prompt on a remote host is seen too — and tab.password_input reports each change. It is live state, never persisted, and it clears when the tab's process exits. A program that turns echo off at an interactive bash or zsh prompt does not set it: their line editors run the terminal raw, outside canonical mode. The key is omitted while false, so a server that predates the field, and a tab that is not at a prompt, look the same — both read as false, with no protocol bump.

Tab.dropped_reports counts the tab.agent_reports this tab's server refused (accepted: false) and keeps the latest:

{"count": 9, "last": {"source": "codex", "session_id": "01a12677…",
  "ownership_action": "release", "reason": "not_owner",
  "tab_had_owner": true, "at": 1791648366}}

reason is "not_owner" (a preserve or release from anyone but the owner, including any report to a tab nobody owns) or "nested_claim". tab_had_owner says whether any agent owned the tab when the report was dropped. An agent whose reports land as not_owner with tab_had_owner: false has lost the tab: nothing it sends shows until it claims again, and roostctl doctor points it out. The record is history, never cleared, so check last.at against when the agent last ran. A client that can't read the record (a newer server's new reason, say) treats it as absent rather than failing the tab. The field is live diagnostic state on the server that holds the tab, never persisted and carried by no event, so read it from that server rather than from a window mirroring a remote host. The key is omitted until something is dropped, so an older server reads the same as a tab with nothing dropped.

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 = ownership is present with a non-empty source ("is-live").
  • state: if ownership is live and agent_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:

{"id": "1", "op": "identify",
 "params": {"client_name": "roostctl", "client_version": "0.6.0"}}

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,
  "local_backend": "in-process",
  "local_session_socket": null,
  "local_backend_switch": null,
  "ops": ["identify", "tab.open", "tab.list", "project.ensure", "…"],
  "instance_id": "5d0c7e21a9f3b846"
}}

ops lists the ops this socket would dispatch right now — worked out per request, so it follows the live local_backend and test mode. An op is left out when this socket would answer it with unknown-op (session.* here; host.* and agent.set_hooks on a session socket's identify), with not-implemented (the six macOS-only test ops off macOS), with not-enabled (a gated test op without ROOST_TEST_MODE=1), or with no UI attached (an op that needs a window, on a socket with none behind it). Under local-backend = session the Unsupported ops in the table below are left out too, and the Forward and Rewrite ops are listed: they reach the slot. A UI launched with ROOST_TEST_MODE=1 lists its test seams — tab.feed_pty_bytes, app.dialog_answer and the rest — which are harness drivers, not a surface an agent should use. Read the list for membership: a name that is absent means this server does not serve that op, and a reply with no ops field at all is an older server or the Swift Mac app, which a client treats the same way. The contents are not a compatibility promise.

instance_id names this UI process: 63 random bits as 16 lowercase hex digits, minted once at launch, so a restarted UI has a new one. A session socket's identify omits it — a session's identity is session.identify.session_id — and so does the Swift Mac app.

persist_error is present only when the last attempt to write state.json failed — the message of that write, and absent otherwise. It is the standing value; an in-process subscriber also sees workspace.durability_changed live, and that event's entry says what the value means.

local_backend (plan 063 §D1) is "in-process" or "session" — which backend this UI's own local tabs run on, the local-backend config key's live value. Absent from a Swift Mac reply, which decodes to "in-process": Swift always runs its tabs in-process and never reads the key. local_session_socket is the local roost-session's own socket path, present only under session — a client that wants the event stream this socket does not serve under session (events.subscribe answers not-implemented there) dials that socket instead. Under session, active_project_id and active_tab_id name the slot's own UI-selected pair rather than this socket's own (empty) workspace, which is what lets roostctl tab write / send / state with no --tab still have something to act on. local_backend_switch names the phase of a local-backend switch in progress (plan 063 §D8a) — one of "preparing", "replaying", "committing", "cleaning-up" — and is absent whenever the UI is idle; while it is present, every op that mutates the workspace on a UI socket under in-process answers busy, and one already under way when the switch began finishes before the switch copies anything. Reads keep answering, and so do the UI's own seams (the palette, the window, the clipboard, the selection), which act on the window rather than on the workspace the switch moves. A mutation refused busy can read this to see which phase is holding it up, and a test can tell "before the commit point" from "after" it, since local_backend itself flips at exactly one point inside the sequence.

A UI socket under local-backend = session

Everything below assumes local-backend = in-process, where a bare project/tab id on a UI socket has always meant a row in this UI's own workspace. Under local-backend = session (plan 063 §D1) that workspace is empty and invisible — every local tab actually lives on the slot, the local roost-session identify.local_session_socket names — so a bare id has to mean the slot's id instead, on every op that would otherwise silently read or write nothing. Plan 063 §D10 is the full design; this is the wire-level summary.

Every op is classified exactly one of five ways, exhaustively — a small test in roost-ipc parses messages.rs's ops:: constants and fails by name if one is ever added without a row:

  • Forward. The whole request is sent to the slot as-is and its reply is returned unchanged (including its error code and message). A bare id therefore means the slot's id. Every workspace mutation is here — tab.open, tab.close, tab.list, tab.write, tab.resize, project.create, project.ensure, project.rename, project.delete, tab.set_title, tab.set_state, tab.clear_notification, tab.set_hook_active, tab.agent_report, notification.create. (tab.open's project_id: "0" special case forwards verbatim too, so it is the slot that mints a default project when there is none — not this socket.)
  • Rewrite. The UI answers it here, after rewriting any bare id in the request to the slot's h<n>.<id> form first; a ref that already arrived host-qualified is left exactly as it was, so an explicit h<n>.<id> request is routed by its own parser as if this mode did not exist. This is everything that is really client state rather than a workspace row — a terminal this client has hydrated (tab.dump, tab.dump_resolved, tab.capture_pty_input), this client's own selection (tab.focus, selection.set / selection.clear / selection.dump), a file this client is reading off its own filesystem (tab.send_file), the order this client's sidebar paints (tab.reorder, project.reorder), and the low-level input ops (tab.feed_pty_bytes, tab.feed_ime, tab.expand_selection_at, tab.dispatch_mouse_event).
  • UI-owned. The mode changes nothing — the op carries no local workspace id to mean anything by. identify, palette.*, host.*, app.screenshot, app.window_metrics, app.sidebar_dump, app.render_stats, window.resize, clipboard.*, and the rest of the app.* surface all stay exactly as documented elsewhere on this page.
  • Unsupported. Refused under this mode, though the same socket serves it in-process: events.subscribe (not-implemented, its message ending dial identify.local_session_socket). The workspace this socket would stream is the hidden one.
  • Session-only / event. session.* ops and every tab.*/project.* event name answer unknown-op on a UI socket exactly as they always have; the mode plays no part.

A forwarded tab.open is also selected in this UI's window, as it is under in-process, unless it carried activate: false. The reply is the session's, written the moment the session answers; the window selects the new tab when its copy of the slot's workspace lists it, which can be a moment later. For that gap, every tab a forwarded tab.open opened — with or without activate — is tracked until it is listed: a tab.focus naming it waits for the listing instead of answering not-found, and answers not-found only if the tab closes first, the slot's connection drops, or 10 seconds pass. So roostctl tab open --focus and roostctl open --focus land on the new tab. This is the UI socket's behavior only: a client on the session's own socket changes the session's active tab (the reply's is_active) and never the window's. roostctl run inside a session tab, whose ROOST_SOCKET names the session, dials the window that owns the session instead unless told otherwise, so it gets the UI socket's behavior (cli.md).

A forwarded request that cannot reach the slot — nothing is connected yet, or the connection dropped mid-op — answers host-unavailable with the message local session is not connected (the same code and shape a host-routed tab.reorder/project.reorder already uses for an unreachable host: the slot is a host). During a local-backend switch (plan 063 §D8a), a Rewrite or Forward op instead answers busy with the message a local-backend switch is in progress, and identify.local_backend_switch names the phase holding it up. A Forward op's own error code and message cross back unchanged — only this socket's own refusals (host-unavailable, busy) are this socket's.

tab.list's revision field is stripped at this boundary before the reply reaches the caller: revision is the fence a client pairs with events.subscribe, and under this mode the UI socket serves no event stream to fence — a caller that wants both legs dials identify.local_session_socket and asks the session directly.

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 /. A cwd that is not a directory on the server's machine — a typo, or a directory since deleted — is treated as empty (#541): the tab starts in the project's cwd if that is a directory, else at $HOME, and the result's tab.cwd says which. A directory that exists but cannot be entered still fails the open. 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).

activate (optional, #503). false opens the tab without selecting it: the tab is appended at the end of its project, and the active project, the active tab and each project's remembered tab stay where they were, so tab.opened arrives with no active.changed after it. With project_id: "0" the default project is found or created without being selected either. Omitted or true, the new tab is selected, as it always has been; an omitted field is not sent, so those requests are the bytes they were before the field existed. A server that predates the field — Roost.app through v0.0.20 among them — answers unknown-field. From the CLI: --no-activate on tab open and open. On a UI socket under local-backend = session the request is forwarded to the local session, and the window selects the new tab once it lists it — unless activate is false.

cwd_from_tab (optional, plan 070). Names a tab whose working directory the new tab starts in — the way ⌘T / Alt+T follows the active tab (cwd tracking). It is a string-wrapped id like every other ("cwd_from_tab": "5"); null means unset, and an unset field is not sent, so a request without it is the bytes it always was. The server resolves the tab's cwd natively first — read from the leader of the tab's foreground process group, so a cd inside a nested interactive shell is seen (inside tmux it is not: the job is the tmux client, which stays where it started); then from the tab's direct PTY child, the process the tab spawned — and falls back to the tab's tracked (OSC 7) cwd. A candidate counts only if it is an existing directory on the server's own machine, so a directory since removed, or an OSC 7 path from across an ssh hop that does not exist here, falls through. A resolved cwd replaces cwd, including for project_id: "0", where it is resolved before the default project is found or created. When nothing resolves — no such tab, or neither candidate is a directory — it is not an error: cwd is used as sent, and one that is empty or not a directory resolves through the usual chain above. The result's tab.cwd carries the cwd the tab started in. Served by sessions, Roost-Iced's UI socket and the Swift Mac app's socket. A server that predates the field — Roost.app through v0.0.20 among them — answers unknown-field; see the compatibility matrix. From the CLI: --cwd-from-tab <id> on tab open (plan 072, #536), or --here for $ROOST_TAB_ID — accepted only with no explicit --socket/--target naming another target, since $ROOST_TAB_ID is otherwise not necessarily this call's own id space; 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: {}.

Closing the tab that was selected moves the selection, by the one rule every surface follows — see Where the selection lands. Closing any other tab leaves it alone.

tab.list

Snapshot of the workspace. Same shape as the legacy ListTabsResponse.

Response: {"projects": [<Project>, ...]}.

On a host-session socket, and on a UI socket running its tabs in-process, 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 on the same socket: discard every EventBatch whose revision is <= this one, apply the rest, and the first batch it keeps is exactly revision + 1. A UI socket under local-backend = session omits the key entirely (not null), and so does the Swift Mac app: neither serves an 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:

{"id": "4", "op": "tab.write", "params": {
  "tab_id": "3",
  "data": "bHMK"
}}

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.

Raw input is open to every same-UID client, on every socket. The socket's UID check is the whole boundary; there is no per-connection write fence. TabWriteParams carries no authority field at all — deny_unknown_fields, so a request that still sends one is refused with unknown-field rather than silently ignored.

Response: {}.

tab.send_file

Send local files to a tab. On a host tab each one is uploaded to that host with session.put_file and the host paths are pasted; on a local tab nothing crosses a boundary and the escaped local paths are pasted, which is what a file drop has always done. UI socket only, ungated. The same function the native drop handler calls — the op is the drop, minus the window event.

Served by the iced UI only. The Swift Roost.app answers unknown-op: it has no host sessions, so there is nothing to upload to, and this op's whole point is the crossing. (The protocol integer that moved for this work is session_protocol, which governs session sockets; the UI socket's own protocol_version is unchanged, so a Mac UI is not claiming to serve this.) The Mac fold-in is described in the plan's §3.7 and waits on Mac host sessions.

Request:

{"id": "13", "op": "tab.send_file", "params": {
  "tab": "h2.7",
  "paths": ["/Users/charlie/Desktop/shot.png", "/Users/charlie/build"]
}}

Response:

{"pasted": "/home/charlie/.cache/roost-session/files/4b9d1e7f0a3c5e21/shot.png",
 "uploads": [{"source": "/Users/charlie/Desktop/shot.png",
              "name": "shot.png",
              "path": "/home/charlie/.cache/roost-session/files/4b9d1e7f0a3c5e21/shot.png",
              "bytes": 482113}],
 "skipped": [{"path": "/Users/charlie/build", "reason": "directory"}]}

tab takes the same spelling tab.focus does — a bare engine id, or h<host>.<id> for a connected host's tab (DL-20 routing). paths must be non-empty and every entry absolute: a relative path is invalid-param, because the only working directory this process could resolve one against is the app's, which is not the one the caller typed it in. Duplicates are dropped first-seen. The paths are read in the UI process's filesystem namespace.

skipped is not a failure. Each entry carries the path and one of five stable reason strings — directory, missing, unreadable, not-regular, over-cap — and the rest of the batch still goes. The reasons are the client planner's SkipReason serialized (roost_ui_model::file_transfer); they are a String on the wire so a result a client cannot decode at all is never the price of a reason it has no name for. not-regular is what a FIFO, a device node or a procfs entry gets: is_file() is the only kind that uploads, because everything else has a len() that lies.

The reply lands when the paste has been queued client-side — what tab.capture_pty_input sees. Not host receipt, and not agent attachment: the data plane has no acknowledgement and none was added for this. uploads is empty for a local tab. A gesture is all-or-nothing for the paste: if one upload fails nothing is pasted, and the files that already crossed stay on the host until it is swept.

Error precedence, in order:

  1. not-found — the tab ref resolves to no live terminal.
  2. host-unavailable — the host is stopped, not connected, or it disconnected or reconnected mid-gesture; also a tab closed under the gesture and an app shutting down. An upload is a same-UID connection op like any other — every connected client may send one, so this code names only a dead or dying connection, never another client. The message names the file where there is one.
  3. invalid-param — an empty or relative paths, or a request where every path was skipped, in which case the message lists each path with its reason (nothing to send: /tmp/build (directory)).
  4. too-large / store-full — one file the host would not take.

Be precise about too-large: the client preflights against the same MAX_PUT_FILE_BYTES the session enforces, so an ordinary over-cap file never reaches the wire — it is skipped as over-cap, and a request of nothing but such files answers invalid-param, not too-large. The host's own too-large is reachable only if a file grows between inspection and upload, which the upload lane catches by reading through take(cap + 1). too-large is also the client's own answer to a batch whose regular files sum past the 256 MiB per-drop limit (That drop is 540 MiB, over the 256 MiB per-drop limit) — the same judgement the host makes per file, made once for the gesture. store-full is reachable normally, and means what it says on session.put_file.

Every terminal path answers. The handler replies through a oneshot the gesture queue owns rather than blocking the winit thread, so the UI socket keeps serving other frames while a long upload runs, and a roostctl caller can never hang: a refusal, an upload timeout, a disconnect, a frozen frame, a closed tab and app shutdown all answer. A caller that disconnects first drops its receiver and the gesture completes anyway.

Note the shape of that concurrency. Frames on one connection are served serially — roost-ipc's server awaits each handler inline per connection — so a caller that wants to do something else while a send-file is in flight opens a second connection. What the deferred reply buys is that the UI itself never stalls and other connections stay served.

No new security boundary, stated the way clipboard.write states its own: the UI now reads a local file on behalf of a socket caller, which is new — but the socket is 0600 inside a 0700 directory and reachable only by the same user, and tab.write / tab.open already let that same caller type or run anything.

See cli.md for roostctl tab send-file, and the host-sessions guide for what the user sees.

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: {}.

A tab is sized by the last geometry-bearing interaction with it (plan 057, R15) — the rule that lets two clients at different sizes share one tab without either permanently shrinking the other. Four things carry geometry, and each one sizes the tab:

  • this op;
  • an attach handshake with focus: true, which resizes during negotiation;
  • a data-plane INPUT frame, which applies its connection's declared geometry ahead of the bytes when it differs — typing is how a client says which viewport it is looking at;
  • a data-plane RESIZE frame, which applies and becomes that connection's declared geometry.

Nothing else does. tab.dump, an events.subscribe stream, a tab.write (a control-plane write has no viewport behind it) and an idle client that attached with focus: false all leave the size exactly where it was.

Geometry is the four numbers (cols, rows, cell_w_px, cell_h_px) compared together, not just the grid: libghostty's mode-2048 in-band size reports quote the pixel dimensions, so the same grid at different cell metrics is a different viewport. Unchanged geometry is not re-applied, so two clients typing at the same size cost nothing. This op states the grid only and leaves the cell metrics where the last geometry-bearing client left them; before any client has declared them they are unset, and the size report quotes 0x0 pixels.

Simultaneous interactions linearize in the order the tab receives them, not in wall-clock order: every one of them is a command on the tab's single channel. The consequence is the rule working as intended — two clients alternating keystrokes at different sizes flip the PTY's size each time. The tab tells nobody it was resized under them (see Data plane); a client at another size sees wrapping until it interacts again.

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) — plus however many rows of history above it the request asks for. Both UIs walk libghostty-vt's render state on the main thread.

Request: {"params": {"tab_id": "3"}}, or with history: {"params": {"tab_id": "3", "scrollback": 50}}. Response:

{"cols": 120, "rows": 30,
 "cursor": {"row": 1, "col": 14, "visible": true},
 "scrollback_rows": 812,
 "scrollback_text": ["…", "make: nothing to be done", "/tmp $ echo hi"],
 "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 fields can be added forward-compatibly. CLI: roostctl tab dump --tab N (plain rows) / --json (full result).

Scrollback (plan 053, #421). scrollback is an optional row count, 0 (the default) meaning none. It is omitted from the request when unset, so a viewport-only ask stays byte-identical to what clients have always sent — which matters because TabDumpParams is strict: a server predating the key refuses the whole request with unknown-field rather than ignoring the field. Ask for more than MAX_DUMP_SCROLLBACK (10 000) and the count is clamped, never refused, so a client can ask for "everything" without knowing the tab's retention; a maximum exists at all because the rows are formatted synchronously on the thread that owns the terminal.

Two response fields answer it, both serde(default) so a client built against this shape still decodes a pre-053 server's response:

  • scrollback_rows — how many history rows sit above the current viewport, always present. This is the anchor for the whole contract: it counts rows above what rows_text is showing, not above the live bottom, so the two halves stay adjacent however far a UI tab is scrolled up. A session socket never scrolls, so there it equals the whole history; on the alternate screen it is 0.
  • scrollback_text — the last min(scrollback, scrollback_rows) of those rows, top to bottom, omitted when empty. Its final entry is always the row immediately above rows_text[0], at the bottom, scrolled to the middle, or scrolled to the top. Rows are trimmed of trailing spaces the same way rows_text and a copy of the same selection are (libghostty's own trim is deliberately not used — it eats a space carrying a combining mark), and blank rows are preserved as "" rather than dropped, so the array is exactly as long as the count of rows returned and its numbering never silently shifts.

Both halves describe one instant: the iced path refreshes its render snapshot before reading either, rather than straddling a PTY chunk. CLI: roostctl tab dump --tab N --scrollback 50.

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 requires no attach: a session's terminal is authoritative whether or not anybody is watching it.

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.

Under local-backend = session, a slot tab this UI holds no client-side Terminal for (one the window has not shown) is dumped by the local session instead: the UI socket forwards the request under the session's bare id and returns the session's reply unchanged, errors included. That holds for a bare id and for the slot's own h<n>.<id> alike. A tab the window has shown is still read off its client-side Terminal, and an h<n>.<id> naming any other host stays not-found.

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. (A UI socket resolves with its theme: bold text with no color of its own takes the theme's bold-color. A session socket has no theme, so its bold text keeps the default foreground.) Viewport only — it takes no scrollback param, and its params are strict, so passing one is unknown-field.

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:

{"params": {"tab_id": "3", "data": "G10xMTtyZ2I6MDAvMTEvMjIH"}}

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.

On a session the reply means the bytes are queued on the tab task, not processed. Whatever they cause — a bell or OSC 52 tab.effect, a title or cwd change — commits afterwards on the tab task and shows up only on the event stream. A caller that needs one of those commits must wait for it there: it cannot assume the commit precedes the reply, or a later op issued right after — even a synchronous one such as tab.set_title.

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:

{"data": "G10xMTtyZ2I6MDAwMC8xMTExLzIyMjIH"}

A UI socket's errors. Under local-backend = session a bare tab_id is routed first, as for every op, so while the local session is not connected it answers host-unavailable before anything below is checked. Otherwise, in the order they are checked:

  1. not-enabled — the UI was launched without ROOST_TEST_MODE=1, whatever tab_id names.
  2. not-found — tab_id names no tab (tab 7 not found), or a tab this UI lists but holds no terminal for (tab 7 has no live terminal): a host tab the window has not shown, or a local tab whose PTY is still being spawned. Same code, two messages.

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:

{"params": {"tab_id": "3", "action": "preedit", "text": "こ",
            "cursor_start": 0, "cursor_end": 3}}

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).

tab.expand_selection_at (test-only — gated)

Requires ROOST_TEST_MODE=1 set in the UI's launch environment. Without it the server returns not-enabled. Drives the same double-/ triple-click word/line expansion the production handle_click_count / handleClickCount path runs from a real mouse press, then commits the resulting span as the tab's selection — so the e2e suite can pin word/line expansion without a synthetic mouse sequence. Same gate as tab.feed_pty_bytes.

Request:

{"params": {"tab_id": "3", "col": 12, "row": 4, "click_count": 2}}

click_count is 2 for a double-click (word) or 3+ for a triple-click (line); a value below 2 is invalid-param, checked before the gate. Response:

{"col0": 8, "col1": 15, "text": "example"}

col0 / col1 are the expanded span's inclusive start/end columns on row; text is the selected text, or null for a span that resolved to nothing (never an error by itself).

Errors: not-found — either the tab has no live terminal, or (col, row) has no word/line span (whitespace under a double-click, or a row out of range). The iced UI reports these as two distinct messages under the same code; the Swift app reports one combined message.

tab_id is a bare string_int64, not the h<host>.<id> wire spelling tab.dump accepts — under local-backend = session the UI resolves it against the slot itself rather than needing a host-qualified id; see A UI socket under local-backend = session.

Implemented by both UIs.

tab.dispatch_mouse_event (test-only — gated)

Requires ROOST_TEST_MODE=1 set in the UI's launch environment. Without it the server returns not-enabled. Drives a synthetic mouse event into the UI's mouse-routing handler at cell-grid coordinates — the same path a real NSEvent / iced GestureClick takes, including gating on the negotiated mouse-tracking mode, encoder choice, and the SGR/X10/pixel report formats, so the op exercises exactly what production does rather than a shortcut around it. Same gate as tab.feed_pty_bytes.

Request:

{"params": {"tab_id": "3", "kind": "press", "button": "left",
            "cell_x": 10, "cell_y": 4, "mods": 0}}

kind is "press" | "release" | "motion"; button is "left" | "right" | "middle" | "wheel_up" | "wheel_down" | "none" ("none" for a plain hover motion). cell_x / cell_y are 0-based terminal cell coordinates. mods defaults to 0 and matches the key encoder's Mods bit layout: shift(0), ctrl(1), alt(2), cmd/super(3).

overshoot (iced only, optional) puts the pointer that many rows past the grid: negative above row 0, positive below the last row, with cell_x / cell_y the cell the pointer clamps to, as a real drag past the edge reports it. A left-button motion with a non-zero overshoot during a selection drag starts the selection auto-scroll: every 50 ms the viewport scrolls one to five rows toward that edge and the selection grows to the row it brought on screen. A release, a motion back over the grid, a resize, or anything that cancels the gesture stops it, and it never scrolls on the alternate screen or under mouse tracking. Omit the key rather than sending 0: the params reject unknown keys, so a server that predates it refuses any request carrying it.

{"params": {"tab_id": "3", "kind": "motion", "button": "left",
            "cell_x": 0, "cell_y": 0, "mods": 0, "overshoot": -3}}

On iced, a press starts a new gesture and a button's motion or release belongs to the tab's latest press, as the terminal widget's do. Once a pointer cancel has sent that press's release (the window switching away from the tab or losing focus, a context menu or the delete confirmation opening, a font-size change), its later motion and release are dropped. A press while a context menu is open is dropped too.

Response: {}. Errors: invalid-param for an unrecognized kind or button.

On the iced UI, mods also decides whether the link modifier is held, so a left press on a hyperlink opens it through the UI's own launcher exactly as a real click does — a Linux-only effect in practice, since the Mac UI drives the mouse-tracking encoder alone and does not run this path (TerminalView.emitMouseTracking).

tab_id is a bare string_int64; under local-backend = session it resolves against the slot the same way tab.expand_selection_at above does.

Implemented by both UIs.

project.create

Request: {"params": {"name": "", "cwd": "/tmp"}}. name empty means the server picks "Untitled <n>".

Response: {"project": <Project>} — tabs is empty.

project.ensure

Find the project with this exact name, or create it. Served by the iced UI and by a host session; the Swift Mac app has no case for this op and answers unknown-op.

Request:

{"id": "41", "op": "project.ensure",
 "params": {"name": "review", "cwd": "/home/u/review"}}

Response: {"project": <Project>, "created": <bool>}.

The match is on the exact name (no trimming, case-sensitive), and the find and the create are one step: callers racing on a name get one project between them, and at most one of them sees created: true. Project names are not unique, so when several share the name the one with the lowest position (the first in display order) is returned. That pick is stable but arbitrary, and created: false says nothing about duplicates. Key on project.id from then on, not on the name.

  • cwd is read only on the create path. It may be omitted when the project exists, and a found project's cwd is never updated.
  • A found project comes back as tab.list shows it, tabs included. A created one has empty tabs.
  • Neither path changes the active selection (like project.create).
  • project.created fires only when the call creates. A find commits nothing and emits nothing.

Errors: invalid-param for a name that is empty or only whitespace, and for an empty or whitespace-only (or omitted) cwd when no project has the name. A missing name is missing-param, and an unknown key is unknown-field.

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: {}.

Deleting the project that held the selected tab moves the selection — see Where the selection lands.

Where the selection lands

When the selected tab goes away — tab.close, project.delete, or the shell simply exiting — the selection moves by one rule, and only when it pointed at what went away:

  • the tab's project survives → the nearest surviving tab to the right in tab order, else the nearest to the left;
  • the project is gone → the nearest surviving project above it in sidebar order, else the nearest below, skipping any project with no tabs; within that project, its remembered tab, else its first;
  • nothing survives → no selection, and the UI's own empty-workspace rule decides what that means.

The asymmetry is deliberate: a closed tab hands the strip forward, the way a browser slides the next tab under the cursor, while a closed project hands the sidebar to the row above it.

An active.changed carries the result. It is not an acknowledgement: a tab the user was moved onto keeps whatever notification it was holding, because nobody focused it.

A host session publishes its own active.changed on the same rule, and that is what a client reads when it needs a project's tab. It is the session's selection, though, shared by every client attached to it — which tab a given client is showing is that client's alone, and tab.focus on a host tab deliberately does not tell the session.

tab.reorder

Request:

{"params": {"project_id": "1", "tab_ids": ["3", "2", "1"]}}

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, on the iced UI socket, a session socket, and the Swift Mac socket alike.

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.

Under local-backend = session a bare tab_id on a UI socket is rewritten to the slot's h<n>.<id> and answered the same way. A tab a forwarded tab.open just opened may not be listed by the window yet; a focus on it waits for the listing, and answers not-found only if the tab closes first, the slot's connection drops, or 10 seconds pass. A tab.focus on a session's own socket moves only the session's active tab; roostctl tab focus run inside a session tab reaches the window instead (cli.md).

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: {"cleared": true} — whether this call is what took a pending notification down. false for a tab with nothing pending, and for a superseded acknowledgement (below); both are ordinary answers, not errors. An unknown tab_id is not-found, so a client can still tell "gone" from "somebody got there first".

generation (optional) makes the clear an acknowledgement. Supply the notification.fired generation you are answering and the session clears only if that raise is still the current one:

{"id": "9", "op": "tab.clear_notification", "params": {
  "tab_id": "3", "generation": 7
}}

Omit it — as a person clicking the tab does, and as roostctl tab clear-notification does — and the clear is unconditional: whatever is pending comes down.

The check exists because a client that clears automatically, because it is already reading the tab (Notifications fan out), is answering one specific raise over a round trip. Without it: A fires, the viewing client acknowledges A, B fires, and A's acknowledgement lands afterwards and erases a notification nobody has seen.

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. Adapter-stamped keys already in use, and the <product>. prefix rule for a key owned by one product, are documented in the agents guide (agents.md#gx)

ownership_action semantics, enforced under one lock so the check and the mutation can't race a concurrent report:

  • claim takes ownership, replacing any existing owner — the only path that can take a tab from a live owner (a SessionStart, or a manual override via tab.set_state). One exception: a nested claim is dropped. When the owner is mid-turn (agent_lifecycle working or waiting) under a foreground process (shell_state: "foreground_process"), a claim from a different agent source comes from an agent the owner itself is running, such as a codex exec review. That agent inherited the tab's ROOST_TAB_ID, and honoring it would evict the owner and then release the tab out from under it. The same source under a new session_id still supersedes, since that is the agent switching sessions itself. manual and legacy claims always supersede. A tab whose shell has no integration (shell_state: "unknown") never treats a claim as nested.
  • preserve requires the report's (source, session_id) to match the current owner; a mismatch is dropped (see accepted below). detail/metadata merge 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. model at SessionStart, a cron count at Stop).
  • release also requires a match; it clears ownership and forces lifecycle to "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 lifecycle patch and any attention: "set" are dropped. A transition that did not happen is not news: agents nag on a timer (Claude's idle_prompt ~60s after a turn ended, cursor's repeated stop), and an unguarded nag re-banners a turn the user already saw finish. detail and metadata still merge, and attention: "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. Its attention: "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_if is 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, or was a nested claim. tab is then the tab unchanged apart from its dropped_reports count. 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:

{"params": {"tab_id": "3", "title": "Build", "body": "passed"}}

Response: {}.

app.activate

Raise + focus the running UI window. Sent by a second launch that loses the single-instance flock (#6) — the running Roost's window comes forward instead of a second instance starting — so a user who double-clicks the Dock icon or reruns the binary while Roost is already up gets the existing window rather than a launch failure. Nothing else sends this op today; roostctl has no verb for it.

Request: {"params": {}}. Response: {}.

Takes no params — the envelope is declared empty and strict (deny_unknown_fields), so an unexpected key is unknown-field rather than silently ignored.

Unlike every other app.* op, the handler does not go through the usual ui_call (which answers internal: no UI attached with nothing wired up). It checks the engine's UI channel directly and, if there is none — a headless embedder, or a session with no UI attached — sends nothing and still answers {}: a fire-and-forget no-op rather than an error.

Implemented only by the iced UI (crates/roost-iced/src/main.rs's second-launch path is the only sender); the Swift Mac app has no case for this op and answers unknown-op — its own single-instance path does not dial the socket.

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:

{"params": {"scale": 1}}

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": "<base64-png>", "width": 1100, "height": 700, "scale": 1}

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_left":220.0,"terminal_padding":0.0,"cell_width":8.0,"cell_height":16.0,
 "window_focused":true,"native_focus_losses":0}

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.

terminal_left, terminal_padding, cell_width and cell_height are the cell grid's exact geometry, in the same logical points and equally optional (omitted when an adapter does not report them; the iced UI always does, the Mac UI does not). terminal_left is the terminal viewport's left edge in the content, terminal_padding the grid's inset inside the viewport on every side, and the cell size is the live grid's: the active tab's applied metrics, or the UI's current ones before a tab has any. A cell's top-left in the content is (terminal_left + terminal_padding + col × cell_width, terminal_top + terminal_padding + row × cell_height). The Mac real-input harness (tools/input/mac/coords.py) turns that into a screen point with the window's Accessibility frame, so a real click lands on a known cell without measuring pixels.

window_focused and native_focus_losses are optional in the same way: the iced UI always reports them and the Mac UI does not. window_focused is the focus the UI acts on: the last native focus event, or a later app.set_window_focus. A selection auto-scroll runs only while it is true. native_focus_losses counts the times the OS has taken focus from the window since launch, and app.set_window_focus never adds to it. A test that forces focus can therefore tell a native focus loss that cancelled its gesture from its own.

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.

sections — the section strip, presence-derived (plan 063 §D2)

hosts lists only the saved hosts and their rows. sections is a different cut: every band the sidebar's section strip actually draws, in sidebar order, including the local one — which is what makes "which local backend is on screen, and is it up?" answerable from the wire without inferring it from identify.local_backend plus a guess about what the sidebar chose to render.

{ "agents_visible": true,
  "projects": [],
  "hosts": [ ... ],
  "sections": [
    { "role": "session", "label": "PROJECTS", "state": "connected",
      "dot": "connected", "saved_id": "hs-2f1c", "reconnect_row": false },
    { "role": "host", "label": "WORKBENCH", "state": "disconnected",
      "dot": "offline", "saved_id": "hs-91aa", "reconnect_row": true,
      "fidelity": "restart" }
  ] }

Each entry:

  • role — "local" (the in-process band), "session" (the local band under local-backend = session — the slot's own band wearing the local label, or a placeholder while no localhost host is saved yet), or "host" (an ordinary saved host below it).
  • label — the band's header text exactly as drawn: LOCAL, PROJECTS, or the saved label uppercased.
  • state / dot — state is the same wire spelling host.status reports; dot is the three-way summary the band's own dot paints — "connected", "pending", or "offline".
  • saved_id — the saved host this band renders, and the only pairing between a band and a host: under session the leading band is itself a host, so position says nothing. Absent for the in-process band and for the session placeholder (no localhost host saved yet).
  • reconnect_row — whether the band offers the inline ↻ Reconnect row, addressed to saved_id.
  • fidelity — "update" | "restart" | "manual", the reduced-fidelity pill's action (see The upgrade / restart flow), omitted when the band draws no pill. It is what pressing the pill does: "manual" (not pressable) whenever neither Install Update nor Restart Session is offered — a socket host, or a session newer than this Roost or of an unordered build.
  • update_pill — "needs update" when the session refused this Roost and has to be updated (host.status update.state required), or "update Roost" when it refused it for being too old (session-newer with blocked); omitted otherwise.

sections is empty, and so omitted, whenever the sidebar draws its classic single sticky PROJECTS header instead of a strip: in-process with no saved hosts, which is also every Swift Mac reply — the same absent-tolerant contract hosts uses, for the same reason.

agent_panel — the Agents panel (plan 080)

Roost-Iced's sidebar lists its agents in one panel below the projects: every agent on every host the sidebar draws, in the order the panel's sort puts them. agent_panel is that list, derived from the same row cache the panel paints from at the moment of the read — so a sort change shows in the very next dump, with no event in between.

{ "agents_visible": true,
  "projects": [ ... ],
  "agent_panel": {
    "visible": true, "sort": "grouped", "fraction_permille": 370,
    "entries": [
      { "kind": "group", "label": "workbench › craze", "count": 1 },
      { "kind": "agent", "tab": "h3.9", "name": "e2e flake hunt",
        "agent": "Claude Code", "lifecycle": "failed", "location_text": "",
        "status_text": "Failed", "time_text": "14m",
        "is_active": false, "dimmed": true } ] } }
  • visible — the show-sidebar-agents toggle, the same value as agents_visible. entries stays populated while the panel is hidden.
  • sort — "grouped" | "priority" | "recent", the agent-panel-sort key.
  • fraction_permille — the panel's share of the sidebar below the project list, in thousandths: the dragged value while the divider is being dragged, else the remembered one, which sidebar.set_agents_fraction sets — 370 until a drag or the op first sets one. While the panel is shown its divider is drawn at that share whether or not any agent is running: an empty panel says "No agents running" under its header and keeps its height. The panel draws at least its header and room for one whole row (under grouped, a group head too), agents or not, and the project list at least a band and two rows, unless the sidebar is too short for both: then the project list keeps its floor and the panel gets what is left, down to its header. Those floors clamp only how a share is drawn: a remembered or op-set value is kept as given. A drag publishes the share drawn at each of its moves, and its release commits the last one.
  • entries — tagged by kind:
    • "group" — a grouped head for one host's project: label as drawn (local › roost, workbench › craze — the host's label as saved, local for this machine — or the bare project name while the sidebar draws a single band), and count, the agent entries that follow it.
    • "agent" — one agent-owned tab. tab is the key the tab ops take ("7" in this UI's workspace, "h3.9" on a host); agent is the agent's display name as the agents palette shows it; location_text is the row's [host › ]project line, "" under grouped, where the head says it; is_active is whether it is the active tab; dimmed marks a disconnected host's last-known agent, listed but not pressable.

Every text field is the full string, never the width-elided one the panel draws. Under local-backend = session the in-process workspace is not drawn, so its agents are not listed. The Swift Mac app has no panel and omits agent_panel, so read it as absent-tolerant.

Ungated, read-only — always available, matching app.window_metrics.

app.notice_dump

What the window is telling the user right now: the notice in the terminal area, and the bottom-right line. Like app.dialog_dump, these are the rendered strings — the dump reads what the widgets read, so a test asserting "the user is told the right thing" does not re-derive the copy rule a second time. Which surface carries which kind of message is User messaging.

Request: {"params": {}}. Response:

{ "generation": 3,
  "terminal": { "kind": "session_ended", "subject": "hs-2f1c", "severity": "warning",
                "placement": "over_frame", "message": "The session on workbench ended.",
                "detail": null,
                "actions": [ { "id": "start", "label": "Start a new session",
                               "primary": true } ] },
  "bottom_line": { "text": "the local session is not connected", "severity": "error",
                   "source": "status" } }
  • terminal — the notice drawn in the terminal area, or null.
    • kind says what it is about. Read it as an open set — a newer build may add kinds.
      • "session_ended": a host whose session ended under the frame the window is still showing (placement: "over_frame").
      • "local_session_cannot_start": under local-backend = session, the local session's launch settled without a session and nothing is retrying it, and the terminal area has nothing selected to show (placement: "empty_area", severity: "error"). detail is the band's reason and the path of the UI's log. Not shown while a local-backend switch is in flight.
    • subject is the saved host's id, the value host.* takes — not its label, which message interpolates.
    • severity is "info", "warning" or "error".
    • placement is "over_frame" (over a kept frame, under a scrim) or "empty_area" (at the top of a terminal area with nothing in it).
    • detail is the second line, or null when the notice draws none.
    • actions is every button in render order; its id is what app.notice_answer presses.
  • bottom_line — the bottom-right line, or null. source is "status" for the toast that follows something you just did, or "durability" for a standing failure to save the workspace, which a toast covers while it is up. severity is its tone. The toast is gone after five seconds, so read it right after the action that raised it.
  • generation — which showing of terminal this is. It is bumped every time the notice on screen changes, including when it goes away and when the same notice comes back, so two dumps with the same kind and subject under different generations are two different showings.

Ungated and read-only. The Swift Mac app answers unknown-op.

app.notice_answer (test-only — gated)

Requires ROOST_TEST_MODE=1 set in the UI's launch environment. Without it the server returns not-enabled. Presses one action of the terminal notice app.notice_dump returned, through the same handler its button's click reaches. A test seam, not a surface: roostctl has no verb for it.

Request: {"params": {"kind": "session_ended", "subject": "hs-2f1c", "generation": 3, "action": "start"}}. Response: {}.

kind, subject and generation are the ones the dump returned, and action one of its actions[].id. If the notice on screen is not that showing — another notice, none at all, or the same notice gone and back under a newer generation — or it no longer offers that action, the answer is not-found and nothing is pressed. An empty kind, subject or action is rejected invalid-param before anything else runs.

kind action What it does
session_ended start Starts a new session on subject, the Connect the sidebar's ↻ runs.
local_session_cannot_start reconnect Tries the local session again, the Connect the sidebar's ↻ runs.
local_session_cannot_start use_in_process Raises the Use in-process local tabs? confirm card, as the palette's row does. Nothing switches until the card is confirmed.

The Swift Mac app answers unknown-op.

app.key_event (test-only — gated)

Requires ROOST_TEST_MODE=1 set in the UI's launch environment. Without it the server returns not-enabled. Delivers one keystroke — the press, then its release — to the window through the same handler a real one reaches: a configured shortcut runs first (Alt+T opens a tab on Linux, as ⌘T does on macOS), and otherwise the key goes to whatever owns the keyboard — the terminal on screen, the tab a new-tab shortcut is still opening, or a dialog or the palette, which answer Enter, Escape and the arrows here but take their text fields' typing from the toolkit, not from this handler. A test seam, not a surface: roostctl has no verb for it.

Request: {"params": {"key": "t", "modifiers": ["alt"]}}. Response: {}.

  • key is one character ("t", "M", "$", " ") or a named key: Enter, Tab, Space, Backspace, Escape, Delete, Insert, Home, End, PageUp, PageDown, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, F1–F12.
  • modifiers is any of "shift", "ctrl", "alt" and "super"; omitted, none are held.
  • text is what the press types. Omitted, the UI derives it as a US keyboard would: the character itself, its control byte under ctrl, nothing under super.

An empty key or an unknown modifier is rejected invalid-param before anything else runs, and so is a named key the UI doesn't know. The release follows the press at once, so the key is never left held and never repeats. The keystroke cannot drive an input method's composition; that path is tab.feed_ime's.

The Swift Mac app answers unknown-op.

app.set_window_focus (test-only — gated)

Requires ROOST_TEST_MODE=1 set in the UI's launch environment. Without it the server returns not-enabled. Drives the focus-tracking emit path without moving real OS focus — the whole production window-focus route, not just the emit half: on the iced UI this runs window_opened first and the focus change second, the same re-entry a real focus change takes, because the once-per-process latches that hang off window_opened (the agent-hooks startup ensure, plan 064's consent card) only exist because a focus change re-enters it. An op that skipped that half could not fail when a latch was removed, which is the same as not testing it.

Request: {"params": {"focus": true}}. Response: {}.

When mode 1004 (focus reporting) is negotiated, the UI writes \x1b[I (focused) / \x1b[O (unfocused) onto the active tab's PTY-input channel; tests pick it up via tab.capture_pty_input.

The two UIs disagree with no active tab. The iced UI still applies every other side effect of a focus change (workspace state, drag/IME/ rename teardown) and answers {} regardless; the Swift app answers not-found ("no active tab to drive focus on"). A harness driving both UIs should open a tab before calling this op.

Implemented by both UIs.

app.cursor_shape

Ungated read of the active tab's current W3C cursor name — the latest OSC 22 payload the terminal program requested, or "default" if none has landed yet (and "default" for OSC 22's empty-string reset form too, so callers can always assert against a non-empty name). A transient UI-owned link hover can override the last-requested shape while the pointer sits over a hyperlink.

Request: {"params": {}}. Response:

{"shape": "pointer"}

Reads the active tab implicitly — there is no tab_id param, and the params envelope is empty and strict. internal: no UI attached with no UI behind the socket. Used by the e2e suite to assert an OSC 22 request actually applied.

Implemented by both UIs.

app.active_terminal_focused

Ungated read of whether the active tab's terminal currently owns the UI's logical keyboard route. Deliberately independent of native toplevel or compositor focus: an in-app overlay (the palette, a modal) can hold real OS focus while this reads false, because the overlay owns the keyboard route instead.

Request: {"params": {}}. Response:

{"focused": true}

internal: no UI attached with no UI behind the socket.

Implemented only by the iced UI; the Swift Mac app has no case for this op and answers unknown-op.

app.selected_tab_id

Ungated read of the active project's on-screen selected tab id — UI truth, for asserting the core and the displayed tab agree. "0" when nothing is selected.

Request: {"params": {}}. Response:

{"tab_id": "7"}

Under local-backend = session this reads the slot's own on-screen selected tab — the same UI-owned reading identify.active_tab_id, the title bar, and the tab strip take (plan 063 §D10); see A UI socket under local-backend = session. internal: no UI attached with no UI behind the socket.

Implemented only by the iced UI; the Swift Mac app has no case for this op and answers unknown-op.

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": "3"}

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:

{"params": {"path": ["File", "New Tab"]}}

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:

{"backend": "available", "reason": null, "authorized": false}

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.

app.secure_input (test-only — gated)

Requires ROOST_TEST_MODE=1 set in the UI's launch environment. Without it the server returns not-enabled. Reads Secure Keyboard Entry as the iced UI's owner holds it (see macos-secure-keyboard-entry): the inputs it last applied, what they ask for, what it holds, and whether the tab band draws the lock. A test seam, not a surface: roostctl has no verb for it.

Request: {"params": {}}. Response:

{
  "desired": true,
  "owned": true,
  "indicator": true,
  "manual": false,
  "auto": true,
  "app_active": true,
  "password_input": true
}
  • manual is macos-secure-keyboard-entry, auto is macos-auto-secure-input, and password_input is the active tab's password_input — whatever owns the keyboard, so a palette or rename editor in front of a prompt does not count it out.
  • app_active is NSApp.isActive as read the last time the owner applied. A bare binary on a CI runner may never be active, so assert the formula against the reported inputs rather than an outcome: desired == (app_active && (manual || (auto && password_input))).
  • owned is whether Roost holds an EnableSecureEventInput that succeeded: desired, unless the call failed.
  • indicator is owned while macos-secure-input-indication is on, and false otherwise.

The values are the owner's as they stand — the handler never pushes inputs or applies first, so an input change the UI failed to apply reads as stale here.

Answered by the iced UI on every OS. Off macOS every field is false: Secure Keyboard Entry does not exist there, and the toggle_secure_input action does nothing. The Swift Mac app answers unknown-op.

Notification raise test ops (app.notification_activate / app.last_activation) (test-only — gated)

Requires ROOST_TEST_MODE=1 set in the UI's launch environment. Without it both ops return not-enabled. A click on a desktop banner focuses its tab and raises the window. On Wayland the raise spends the xdg-activation token the notification server sends with the click (the spec 1.2 ActivationToken signal) as xdg_activation_v1.activate, because a plain focus request never reaches the compositor there. These two ops click a banner and read back what the raise came to. They are a test seam with no roostctl verb.

app.notification_activate clicks tab_id's banner, carrying token as the server's ActivationToken would. Omit token for a server that sends none. tab_id takes tab.dump's ref: a bare id for one of this window's local tabs, or h<host>.<id> for a host's. Request: {"params": {"tab_id": "7", "token": "t-1"}}. Response: {}, as soon as the click is queued where a real one lands, so wait on its effect. A tab that is gone is a click on a stale banner: nothing moves. A bare id while the local session is not connected is host-unavailable.

app.last_activation reads the last click whose raise has settled. Request: {"params": {}}. Response:

{"outcome": "no-global", "token": "t-1", "activation_global": false}

Every field is null until a click has settled. outcome is one of:

  • activated: xdg_activation_v1.activate(token, surface) was sent. Whether the compositor then raises the window is its decision.
  • no-global: a Wayland window, on a compositor that offers no xdg_activation_v1.
  • not-wayland: an X11 window, or macOS, which has no tokens.
  • no-token: a click on Linux that carried no token.
  • failed: the compositor could not be asked. Either its registry did not answer within 250 ms (the next click resumes the wait), or the connection failed.

token is the click's token. activation_global says whether the compositor's registry lists xdg_activation_v1. It is read off the registry rather than off outcome, so a test can work out which outcome it should have seen, and it is null when the raise never asked a Wayland compositor (not-wayland, no-token) or the registry never answered.

The focus request still follows in every case, and is what raises an X11 or macOS window.

Implemented by the iced UI only; the Swift app answers unknown-op.

window.resize (test-only — gated)

Requires ROOST_TEST_MODE=1 set in the UI's launch environment. Without it the server returns not-enabled. Programmatically set the running UI window's logical size — the harness driver behind the sidebar-layout regression suite (tools/roosttest), so a resize can be pinned without a real pointer or window-manager event.

Request: {"params": {"width": 1100.0, "height": 700.0}}. Response: {}.

width / height are the window's logical content size (matches iced's set_inner_size; the Mac handler converts to the outer frame so both UIs land on the same content rect for the same call). Both must be finite and > 0 — invalid-param otherwise, checked before the gate, so a malformed size is rejected the same way whether or not test mode is on.

Some Wayland compositors retain authority over toplevel size and may ignore the request outright; the iced UI applies the requested geometry to its own state immediately regardless, and a compositor Resized event afterward remains authoritative if one arrives.

Under local-backend = session this stays UI-owned — the window belongs to this process, not the slot; see A UI socket under local-backend = session.

Implemented by both UIs.

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.

sidebar.set_agents_fraction (test-only — gated)

Requires ROOST_TEST_MODE=1 set in the UI's launch environment. Without it the server returns not-enabled. Sets the share of the sidebar the Agents panel takes below its divider — the programmatic twin of dragging that divider, as sidebar.set_width is of dragging the seam.

Request: {"params": {"fraction": 0.6}}. Response: {}.

The UI routes the fraction through the workspace, which clamps it to [0.15, 0.85] and persists it (agents_panel_fraction in state.json), so an out-of-band fraction succeeds and lands on the nearest bound. Read it back as app.sidebar_dump's agent_panel.fraction_permille. fraction must be finite and positive; anything else is rejected with invalid-param before reaching the UI.

The fraction is kept as given within that band: the floors listed under fraction_permille clamp only how it is drawn, so a short window shows less than a stored 0.85. The op ends a divider drag in flight, committing one that has moved first, so the op's fraction stands until the next press.

Implemented by the iced UI only; the Swift app answers unknown-op.

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 — and the agent-hooks consent card (plan 064) — 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" | "confirm_switch" | "bootstrap" | "agent_hooks", or absent (with every other field defaulted/empty) when no 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.

Two fields belong to the agent-hooks consent card (plan 064) and are absent/empty for every other dialog. mode is "first_run" | "preferences" — which card this is, not what it would do, which is why it isn't folded into variant. rows is one entry per agent, in the install engine's ALL_AGENTS order (the order the card draws them):

{
  "dialog": "agent_hooks",
  "mode": "preferences",
  "title": "Agent hooks",
  "body": "Roost adds a hook to each agent you switch on so its tabs show status and send notifications. …",
  "buttons": ["Cancel", "Apply"],
  "rows": [
    {
      "agent": "claude",
      "on": true,
      "found": true,
      "status": "wired v3",
      "files": ["/home/u/.claude/settings.json"]
    },
    {
      "agent": "codex",
      "on": false,
      "found": true,
      "status": "found, not wired",
      "files": ["/home/u/.codex/hooks.json", "/home/u/.codex/config.toml"]
    }
  ]
}

agent is the canonical name the agent-hooks key uses. on is what Apply would write — prefilled from the key, not from what is wired, so an agent whose entries are on disk but which the key doesn't name starts off. found says whether that agent is installed on this machine. status is the row's status line — "wired v3", "wired v2, out of date", "found, not wired", "wired, not allowed", "not found" — and is null on the first-run card, which doesn't render one. files are the files that agent's install owns or merges into (two for codex).

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: {}.

"toggle:<agent>" is the third action, and only the agent-hooks card takes it: it flips that row's switch, the same route a click on the switch or Space on the focused one takes. toggle:claude, toggle:codex, toggle:grok, toggle:cursor, toggle:opencode. The switches are that card's answer, so a harness that can only confirm could never apply anything but the defaults.

action outside "confirm" | "cancel" | "toggle:<agent>" 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); "confirm" sent to the Add Host dialog while it's already dialing a verify; or a toggle: for an agent the card has no row for. 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.

Context menu test ops (app.context_menu_dump / app.context_menu_activate / app.context_menu_open) (test-only — gated)

Requires ROOST_TEST_MODE=1 set in the UI's launch environment. Without it every op in this group returns not-enabled. They read and press the right-click menu on a tab, a project row or a host band — the same menu, built by the same code, that a right-click shows — and are a test seam with no roostctl verb, under the same rule as app.dialog_answer.

Every op takes a target, one of:

  • {"tab_id": "<ref>"} — a tab, by tab.dump's ref: a bare id for one of this window's local tabs, or h<host>.<id> for a host's;
  • {"project_id": "<ref>"} — a project row, by the same two spellings;
  • {"host": "<saved id>"} — a host band, by the saved host's id (host.list).

A ref whose row is gone, or whose h<host> names a connection that has since reconnected (every connect mints a new one), is refused invalid-param.

app.context_menu_dump lists the menu the row would show now, in order. Request: {"params": {"target": {"tab_id": "7"}}}. Response:

{
  "entries": [
    {"action": "rename_tab", "label": "Rename…", "enabled": true},
    {"action": "new_tab_here", "label": "New Tab Here", "enabled": true},
    {"action": "copy_tab_path", "label": "Copy Path", "enabled": true},
    {"separator": true},
    {"action": "close_tab", "label": "Close Tab", "enabled": true}
  ]
}

A project row lists new_tab, rename_project, copy_project_path, open_project_folder (labelled "Open in Finder" on macOS and "Open in File Manager" on Linux, and only for a path on this machine), a separator and close_project. A project on a host adds a separator and the host's own items; a host band, and a dimmed or offline host's rows, list only those. The host items are the command palette's host verbs for the same host, under short labels that never name the host: Connect (host_connect), Install Update… (host_install_update), Update roost-session… (host_update_session), Restart Session… (host_restart_session), then a separator, Disconnect (host_disconnect) and Stop Session… (host_stop_session), each only when it applies. The menu also lists the plain maintenance Restart Session…, which the palette leaves out unless a newer build is staged or the connection is at reduced fidelity. A refused session lists the fix in Connect's place: Update roost-session… over ssh, Restart Session… on this machine, and nothing for a session too new for this Roost. Remove Host is never on the menu.

A host's items can be introduced by a header, {"header": "<text>"}: text only, never activated, and skipped by the arrow keys. A host band's menu opens with the session line once the session's build is known — Session 0.0.21 · 0.0.22 available, 0.0.22 installed · restart to use it, Session 0.0.22 · up to date, Session 0.0.23 · newer than this Roost, Session 0.0.19 · 0.0.22 needed to connect, Session 0.0.23 · update this Roost to connect, Session 0.0.22 dev a1b2c3d (with · this Roost dev f00ba12 when this Roost is a dev build), plain Session 0.0.21 for a socket host, or Session version unknown — followed by · and the reason when no restart target is usable (its roost-session is gone, can't read its roost-session, its roost-session is older, its roost-session can't talk to this Roost). A disconnected host has no header. A project's or tab's host block always opens with one, naming the host first: {"header": "workbox · Session 0.0.21 · 0.0.22 available"}, or just {"header": "workbox"} while its session is not known. Read entries by their keys: an entry with header has no action or label.

app.context_menu_activate runs one item through the dispatcher a click reaches. Request: {"params": {"target": {"tab_id": "7"}, "action": "close_tab"}}. Response: {}, as soon as the item has been dispatched — wait on its effect. An item that asks first raises its card, as a click does: close_project the project's close confirmation, host_stop_session the Stop Session card (app.dialog_dump's confirm_stop), and the rename items the inline editor.

The menu is rebuilt before the item runs, and each refusal is invalid-param with its own message:

  • the row is gone, or its connection was replaced;
  • action is not on that row's menu now (or is not an action at all);
  • the item is on the menu but disabled — Copy Path with no known path;
  • a modal, the palette, a rename editor or an input-method composition owns input.

New Tab, New Tab Here, Close Tab and Close Project answer busy while a local-backend switch is in flight, as their palette rows do.

app.context_menu_open shows the row's menu on screen, as a right-click on the row does, with its corner 40 points right of and below the window content area's top-left. Request: {"params": {"target": {"tab_id": "7"}}}. Response: {}. While the menu is up it owns the keyboard: through app.key_event, ArrowUp, ArrowDown, Home and End move its highlight past separators, headers and disabled items, Enter runs the highlighted item, Escape closes it, and every other key is swallowed — none reaches a shortcut or the terminal. A row that is gone, a row whose menu has no items, and a modal, the palette, a rename editor or an input-method composition owning input are each refused invalid-param. On macOS the menu is the native popup, which no test op opens, so the op answers not-supported there.

The Swift Mac app answers unknown-op to all three.

agent.set_hooks

Set this machine's own agent-hooks key — the consent dialog's Apply and roostctl agent set's wire path — and raise every connected non-localhost host to at least the same allow-list in the same call (plan 064 §3.4). UI-socket, like app.*: a session socket answers unknown-op. Unlike session.set_agent_hooks this op can lower the key as well as raise it — it is the path behind a user directly at this machine's own keyboard, where session.set_agent_hooks's raise-only rule exists only because nobody is at the far end's.

Request:

{"id": "21", "op": "agent.set_hooks", "params": {"agents": ["claude", "codex"]}}
or, to switch every agent off:
{"id": "22", "op": "agent.set_hooks", "params": {"agents": "off"}}

Response:

{
  "config_path": "/home/charlie/.config/roost/config.conf",
  "local": {"wired": ["claude", "codex"], "refreshed": [], "removed": [],
            "skipped": [{"agent": "cursor", "reason": "not allowed"}],
            "errors": []},
  "hosts": [
    {"host": "mac-mini", "result": {"wired": ["claude"], "refreshed": [],
                                     "removed": [], "skipped": [], "errors": []}},
    {"host": "build-box", "error": "connection refused"}
  ]
}

agents is either a JSON array of agent names or the literal string "off" — nothing else decodes. An empty array, and an array with a blank element, are both invalid-param. A name this build has no adapter for is skipped, not refused: it comes back in local.skipped with reason "unknown" and the rest of the list is applied, and an array naming only such agents answers ok having written nothing at all — no key, no dotfile, no host raised. local is this machine's own install outcome, the same shape session.set_agent_hooks answers with; removed can be non-empty here (unlike the session op's), because this is an explicit local set, not a raise. hosts carries one entry per connected, non-localhost saved host, in stable connection order — each either the host's own result (its raise's outcome) or an error string for a host that could not be asked (not connected, refused the raise, timed out).

config_path names where this machine's agent-hooks key now lives, for the confirmation surface to show.

Test-mode fenced like the install engine's other write paths: with ROOST_TEST_MODE=1 set and no ROOST_AGENT_HOOKS_FORCE=1 override, the op is refused before the config write — the same fence roost-session's agent-hooks install applies, so a test harness cannot accidentally wire a real dotfile.

A localhost host is never sent the raise, which is why hosts excludes it: on this machine the UI has already written the key and reconciled the files directly, and the session behind a localhost host reads that same config.conf. Raising it would be the UI raising itself — and since a raise can only ever widen, a local "switch codex off" would come straight back on that session's next connect.

Served by the UI socket only, by both UIs. The Linux (iced) UI links the install engine and does the work in process. The Mac app has no Swift binding for it, so it spawns roostctl agent set --local <list|off> --json — the same binary, the same config.conf, the same state record — and answers from that JSON, with this app's own HOME and ROOST_CONFIG handed to the child so it cannot resolve a different config file. hosts is always [] there: the Mac app holds no host connections and answers unknown-op to every host.* op. One further consequence of spawning the CLI: its wired is this run's writes rather than the record's never-announced list, because that is what the CLI can honestly report.

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 (Roost-Iced leaves a collapsed sidebar collapsed; the Swift app reveals it); 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": "..."} or {"target": "system", "image_png": "<base64>"} Test-only pasteboard seeding (lets a roosttest case set a known value before asserting paste behavior). The text form is not gated: any process on the host can already write the OS clipboard. The image form is — see below.
clipboard.write_files {"paths": ["/abs/a.txt", "/abs/b c.pdf"]} (Test-only — gated.) Put copied files on the system clipboard, the way a file manager's copy leaves them — see below.

clipboard.write takes exactly one of text / image_png. text was required until the image form existed and is optional now; both at once is invalid-param (silently preferring text would drop an image the caller believed it had written) and neither is missing-param. image_png exists for one reason: the host-file-paste end-to-end lanes need a real image on the clipboard, and an IPC-only harness has no other way to put one there. It is therefore gated on ROOST_TEST_MODE=1 at UI launch — outside test mode it answers not-supported, the same "this seam is not here" answer a lane skips on. target must be "system": PRIMARY carries text by convention and the paste path never probes it for an image, so a selection image write would seed a clipboard nothing reads (invalid-param). The PNG is decoded before it is written, so bytes that are not a PNG, that do not reduce to 8-bit RGBA, or that exceed the paste path's own 40 MP pixel cap are invalid-param too.

The iced UI writes the image through arboard, holding one clipboard handle for the life of the process — X11 has no clipboard content, only an owning window, and a handle dropped at the end of the call takes the image with it. Under Wayland the write is attempted and only its failure is classified: a compositor that implements wlr-data-control (COSMIC does) works, and one that does not — the headless compositors the lanes run under — fails with not-supported naming #302, which is a lane's cue to skip rather than to report a paste bug.

Mac is text only. Roost.app's handler names target and text and nothing else, so an image_png key is unknown-field there, and a request carrying neither field is invalid-param (a decode failure, not missing-param).

clipboard.write_files exists so the end-to-end lanes can paste copied files: a paste that finds files on the system clipboard does what dropping them on the tab does (the escaped paths in a local tab, an upload in a host tab). It is its own op rather than a field on clipboard.write, is gated on ROOST_TEST_MODE=1 at UI launch (not-enabled otherwise), and is served by the iced UI only — Roost.app answers unknown-op. Every path must be absolute (invalid-param otherwise). On macOS each file becomes one pasteboard item carrying its file URL and, as the string flavor, only its name — what a Finder copy leaves, and why a paste there reads files before text. On Linux the iced UI writes text/uri-list through arboard, which canonicalizes each path and drops one that does not exist, and holds the clipboard the way the image form does; under Wayland a refused write is not-supported, as it is there. An empty paths writes nothing and leaves the clipboard as it was. The reply comes once a paste could read the files.

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 — a new, symmetric connection alongside any this client or another already has open, never a claim on one — 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. While a host.update / host.restart (or the same action from a menu) holds the host, in any phase, it is refused busy with a restart/update is in progress on <label> rather than cancelling or racing it; host.disconnect is the way to give up on the action. 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.update {"id": "3f9a2b7c1d4e4f5a", "confirm": true} Install Update (plan 076): install this client's roost-session on an ssh host and leave the running session — and this client's stream to it — alone; a host.restart puts the new build to use. Response: {"accepted": true} as soon as the action starts; its outcome is host.status's update.action. See Install and restart for every refusal.
host.restart {"id": "3f9a2b7c1d4e4f5a", "confirm": true} Restart Session (plan 076): stop the host's session and start it again on the newest usable build Roost can find — never one older than the running session, never one this client cannot connect to. Every tab reopens as a fresh shell in its directory; running programs end. Response and outcome as host.update.

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 | 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"}}

A live host additionally carries connect and tabs:

{"id": "3f9a2b7c1d4e4f5a", "label": "workbox", "target": "ssh://workbox",
 "generation": 4, "state": "connected",
 "connect": {"session_id": "a1b2c3d4", "reduced_fidelity": true,
             "resumed": true, "from_revision": 4312},
 "tabs": 5}
  • generation — which connection attempt this host is on. Bumped once per attempt started — an explicit host.connect, a launch auto-reconnect, or one rung of the ssh retry ladder — 0 before 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 is disconnected and reason is set" cannot tell attempt N from N−1, but reading generation before 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 spellings host.connect answers with, from the same classifier the band's dot reads. A host that has never connected is disconnected.
  • reason — the connection's own one-line reason, untruncated: the band's input. Absent when there is none.
  • detail — the long form behind reason, when there is one the band has no room for. One thing fills it: a localhost session that could not be started, where reason is the ≤45-character band line ("cannot find roost-session", "roost-session failed to start", "roost-session exited early (status N)" or "(signal N)") and detail is what actually happened — the launch ladder's three rungs verbatim, the exec error, the daemon's own start verdict, or the exit status of a roost-session that exited before it was ready and the last 4 KiB it wrote to stderr. Such a host carries no retry: 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 — a RetrySchedule, absent unless an auto-reconnect is armed. delay_ms is the delay the timer was armed with (not what is left) and armed_at is when, so a caller can compute the remainder. attempt (1-based, the 3 in the band's (3/10)) and budget come with the ssh ladder only: a localhost retry is the connection task's own backoff whose counter never leaves the task, so it reports delay_ms alone.
  • payload_kind — what the last attach this client accepted on this host is being decoded as, one of payload_kinds' spellings. "vt" is the fallback a libghostty build skew lands on; such a host connects normally, the dot deliberately stays green, at that payload's documented fidelity. Absent until a tab has actually attached over the live connection — it reports what is being decoded, never what could be — and it goes when that connection does, so a reconnect to a matching daemon cannot keep claiming a fallback it is no longer on.
  • connect — a HostConnectStatus, present only while the connection is live, naming what the prologue established before any tab attaches — this is what the sidebar's reduced fidelity indicator, the update/restart offers, and the CLI's fidelity line all key on, rather than payload_kind, which is lazy until an attach. session_id is the session's own id from session.identify. reduced_fidelity is true when this client and the session pin different libghostty builds and the connection fell back to vt — the same condition payload_kind will read "vt" for once something attaches. resumed says whether this connection's prologue replayed missed events (events.subscribe {from_revision, session_id}, R11/#442) instead of taking a fresh tab.list; from_revision is the revision it resumed from, as the session's subscribe ack attested it, present only when resumed is true. None of this bumped SESSION_PROTOCOL_VERSION — both fields are additive on the UI socket only.
  • tabs — how many tab rows this host's sidebar section is listing right now, always present (0 included). The "5 tabs" a person reads, and the observable a poller uses to confirm a reconnect never blanked the section (R11: the client keeps drawing the carried mirror through Connecting rather than purging it before the fresh one lands).
  • 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 the reason above because that one is the band's input and rollup is derived from it — while a rung is armed the band has to read reconnecting in 8s (3/10), so the family needs its own slot or it is unreadable until the attempt settles. ssh-only, like attempt/budget. The rule for a caller is simply: read it when it is present. Do not gate on attempt — 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 to attempt: 1 while deliberately carrying the family it already had, because it is still the same outage.
  • update — a HostUpdateStatus (plan 076), present whenever the session's identity is known: while connected, and while refused at the gate (needs-restart). It says where the running session stands against this client and what Restart Session would run:

    {"state": "staged",
     "session": {"version": "0.0.21"},
     "client": {"version": "0.0.22", "dev": true, "sha": "a1b2c3d"},
     "restart": {"offered": true,
                 "target": {"version": "0.0.22", "source": "bundled"}}}
    
    • state — up-to-date, available (an ssh host's session is older than this client; an install would fix it), staged (a newer build is already in place — on localhost, the binary shipped with this client — or, on localhost, the session runs at reduced fidelity; whether a restart can actually run anything is restart.offered, with restart.why when it is not, so check that rather than state alone), required (the gate refused the session; nothing connects until it is updated), session-newer (the session is newer than this client), unordered (the two builds cannot be ordered: dev builds of one version with different or missing shas, a dev build against a release of the same version, or a prerelease) or unknown (the session reported no version). The first matching rule in that precedence wins: a refusal first, then a newer session, then a staged build.
    • blocked — present only for session-newer: true when the session refused this client, which then has to be updated to connect at all.
    • session / client — {version, dev?, sha?}. dev marks a build the release workflow did not make; sha is its 7-character commit when the build could read one. A session started before plan 076 reports neither and reads as a release build.
    • staged — {version, dev?, sha?}, the newer build already installed on an ssh host (this client's own install, or one a probe found), whether or not a restart can run it. Omitted when nothing newer is installed, and always on localhost, where the candidate a restart would run is restart.target.
    • restart.offered — whether Restart Session is offered. Never on a socket-path host, a session-newer host with blocked, or a refused ssh host (its fix is the update). restart.why says why not when no binary a restart could run is usable: missing, unreadable (it would not identify itself), older (than the running session — a restart never downgrades), incompatible (it speaks a different session protocol than this client) or override (ROOST_SESSION_BIN is set and cannot be run; it is then the only candidate). restart.target is the binary it would run, {version, dev?, sha?, source} with source one of bundled (shipped beside this client), running (the running session's own executable), override (ROOST_SESSION_BIN) or installed (the host's own search path); absent until it has been identified.
    • On localhost the candidates are identified in the background once per session, after the connection lands. An ssh host is never probed in the background: what is known about its installed binary comes from probes a person started, so an ssh host reads available until one has looked.
    • action — the latest install, restart or update started on this host, from any surface, kept across reconnects: {"kind": "install" | "restart" | "update", "phase": "running" | "done" | "failed", "message"?}. message is the status line it ended with — "Installed roost-session 0.0.22 on mini3", "mini3 restarted on roost-session 0.0.22", or why it failed. While the host is down, update still appears beside the action, carrying the facts last learned about the host.

Install and restart

host.update and host.restart run the same paths as the menu, palette and band, minus the card: an IPC caller is never shown one. Both answer immediately and report through host.status.update.action. Both refuse:

  • without "confirm": true — invalid-param (host.update installs software; pass confirm: true);
  • on a socket-path host — not-supported: nothing here can reach that session's binary;
  • when the action is not offered in the host's current state — invalid-param, naming the reason (host.update is offered only for an ssh host whose session is available; host.restart wherever update.restart.offered is true);
  • while another install, restart or update is under way for the host, or for the same session through another saved host — busy.

host.update also refuses, after looking at the host, when this client is older than the running session, when the binary the host would run is already this build or newer (restart to use it), and when the session speaks another protocol (that session needs the update that also restarts it). Nothing is installed in any of those cases.

Before anything is stopped or written, both re-identify the running session and the binary they were planned against; a different session or a changed binary fails the action with the session changed; check again, and nothing changes. A restart's new session has to be a different session running exactly the build it was aimed at, or the action fails with restarted, but the session is still …. On localhost the restart starts that binary and nothing else: if it is gone by then the action fails and nothing is started.

A host.disconnect while one runs ends it as failed. Work already under way on the far side may still finish, and until it has reported the host stays busy — host.connect included, with a restart/update on <label> is being cancelled — but nothing follows from it: no reconnect, and no change to what host.status reports.

Every optional field is omitted rather than null, so a host that has never connected is {"id", "label", "target", "generation": 0, "state": "disconnected", "tabs": 0} and nothing else — tabs is the one field that is always present (0 included) rather than omitted, since a caller polling it across a reconnect needs a number every time, not a key that comes and goes.

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 / host.update / host.restart 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, and — live only, with the differences On a UI socket lists — by a UI socket running its tabs in-process. Every subscriber receives every event — there is no per-connection classification of the stream, and there never needs to be: reading is not authority, so a client that only wants to watch and one that also types get the same feed.

Request: {"params": {"tab_id_filter": "0"}}. Two more fields, both optional and, per the wire's usual rule, omitted rather than sent as null when unset:

  • from_revision — resume instead of starting fresh: the client already has everything at or below this revision, the same sentence the response's own revision already means. Served out of a bounded replay ring behind the fence (bounds below); absent means a fresh subscribe at the current revision. 0 means "I have nothing, replay from 1", valid exactly while the whole history is still retained; a value equal to the current revision is a valid resume with an empty replay.
  • session_id — the incarnation the client fenced against (session.identify.session_id). A resume must name it: revisions restart at 0 in every process, so a fence carried across a session restart would otherwise be served a different history that still passes the client's own gap check. Accepted without from_revision too — a client may always state who it thinks it is talking to.

Response (the last request/response frame on the connection):

{"id": "7", "ok": true, "result": {"revision": 42, "session_id": "8ba08e0d1c4f4a9e"}}

On a resume the ack echoes from_revision back as revision, so "the first batch is revision + 1" holds unchanged: the replayed batches run consecutively into the live ones, with a fresh subscribe's revision (the current one) as the case with nothing to replay.

Four refusals answer on the ack, before anything is spawned or registered — validation runs ahead of the replay cut, the relay task and the stream registration, so a refusal leaves the connection exactly the request/response connection it was, and a client may simply subscribe again on it:

  • invalid-param — from_revision without session_id: "a resume names the session it fenced against: from_revision requires session_id, which session.identify reports".
  • session-mismatch — session_id names an incarnation that is not this one's: "this is session <id>, not <named>: revisions restart with the process, so a fence from another incarnation cannot be resumed; snapshot with tab.list and subscribe afresh".
  • replay-expired — from_revision is older than the ring still holds: "revision N is outside the replay window (oldest resumable: M, current: C); snapshot with tab.list and subscribe afresh".
  • revision-ahead — from_revision is past the current revision, a fence from another incarnation or a client bug: "this session never produced revision N (current: C): a different incarnation, or a client bug; compare session.identify.session_id, then snapshot".

Past validation, the stream is live: every workspace batch, plus notification.fired and tab.effect (bells, OSC 52 clipboard writes) alike — the same feed for every subscriber, resumed or fresh. Nothing about this op classifies the connection; a script that only ever calls events.subscribe sees exactly what the attached UI does. What each client then does with a tab.effect is its own decision, not the wire's — a bell is worth marking on any tab that owns one, while a clipboard write should land only on whatever tab that client is actually looking at, so a background watcher's own clipboard is never overwritten by a tab it merely subscribes to. See the attach handshake and the guide's Several windows on one session for that policy from the client side; the wire itself fans every effect out to every subscriber unfiltered.

Subscribing registers the stream in the session's connection set, which is what lets session.stop find and close every one of them; nothing about a stream's registration depends on any other connection this client holds.

After the ack every frame is an EventBatch — one per workspace commit, {"revision": <u64>, "events": [<EventEnvelope>, ...]}, one per newline-delimited frame — except one envelope that rides outside the batch discipline entirely, and is always the last frame on the stream:

{"event": "session.stopping", "data": {"reason": "stop"}}

reason is always "stop" — the session is shutting down, the only way a session ever ends a stream deliberately. 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. A UI socket's counterpart is stream.ended.

The catalog of batch envelopes is Events below.

Replay window. A subscribe that supplies from_revision is served out of a bounded ring of committed batches the session keeps behind the fence: at most REPLAY_WINDOW commits (1024) and REPLAY_BUDGET_BYTES bytes (4 MiB) of their serialized JSON, whichever binds first — stated as a maximum, never as a guaranteed window. A busy session can exhaust either one (an agent's tab churn burning the count, or a single 1 MiB notification.fired body eating the whole budget at once — which clears the ring rather than leave a hole in the middle of it, so every fence below it also expires); the answer either way is replay-expired, which is the designed fallback, not a failure — the client snapshots with tab.list and subscribes afresh.

Moments are live-only. Three events are stripped before a batch ever enters the ring, so no resumed stream ever receives one from its gap, for any subscriber:

  • tab.effect (bells, OSC 52 clipboard writes — DL-18), because replaying a clipboard write from thirty seconds ago is wrong regardless of who resumes into it;
  • workspace.durability_changed, because the standing value is what a client resyncs against and replaying the moment it changed would raise a failure the next write already fixed;
  • notification.fired, because a banner is a moment and the acknowledgement that answers it may be sitting in the very same gap — a resumed stream that replayed one would flash a banner and then retire it.

What each of them leaves behind is ordinary state and is recovered the ordinary way: the pending bit off tab.list, the durability error off session.identify.persist_error, and a bell off nothing at all, because a bell leaves nothing behind. A commit whose only events were live-only still occupies a revision and replays as the same empty {"revision": N, "events": []} batch the gap rule already requires.

Three properties made this lossless before the replay ring existed, and still do — the ring just means fewer clients ever need the third one:

  • The ack is a fence. revision is the commit the subscription starts from, and the first batch is exactly revision + 1. Pair it with tab.list's own revision: snapshot, discard every batch <= it, apply the rest.
  • No gaps. Every commit is a batch, including a commit that produced no events, or whose only events a replay's effect-stripping filtered out — both arrive 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 still the resync signal: reconnect, and either re-subscribe fresh and re-pull tab.list, or — with a fence and a session_id in hand — resume, and let the replay window catch up what the gap cost instead of re-snapshotting. Exactly three things ask for the close on this wire — session.stopping, a UI socket's stream.ended, and an EOF — and the two envelopes only say why the stream that is already ending ended.

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.

At SESSION_PROTOCOL_VERSION 6 this op gates on nothing but the socket's own same-UID check, and there is no other classification left to describe — every subscriber gets every event. That is a change, not a constant: generations 2 through 4 required the lease here, and a leaseless subscribe opened an observer stream with tab.effect stripped (see the history below). The history of how this op's shape moved across earlier generations is in Versioning and CHANGELOG.

On a UI socket

A UI socket running its tabs in-process serves the same live stream over its own workspace — the same batches, the same gap rule, the same catalog — with these differences:

  • The ack's session_id is the UI's identify.instance_id, since a UI process has no session. A client compares the two to know its stream and its snapshot came from the same process: a UI that restarted between a client's identify and its subscribe has a new instance_id, and revisions restart with it.
  • Live only. A UI keeps no replay ring, so from_revision is refused invalid-param: "resume is a session-socket feature: subscribe live and snapshot with tab.list". A session_id that is not this UI's instance_id is refused invalid-param too, and a non-zero tab_id_filter gets the same invalid-param a session gives.
  • The snapshot fence takes a second connection. Subscribe first, then call tab.list — whose revision an in-process UI socket carries — on another connection, because a connection that flipped to push answers nothing more. Discard every batch with revision <= that tab.list.revision and apply the rest.
  • A slow subscriber cannot hold the UI up. The bounds above are the same: a subscriber that stops reading is closed once its queue or its socket write stays stuck past the stall budget, and one that falls behind the workspace broadcast is closed at once — the UI never waits on a subscriber.
  • How it ends. Lag, a stall, or an internal resync closes it with a bare EOF — the resync signal: subscribe again and re-pull tab.list. A local-backend switch ends every in-process stream deliberately, with one last envelope and then the close:
{"event": "stream.ended", "data": {"reason": "backend-switch"}}

reason is always "backend-switch": the workspace this stream reads is about to stop being the one on screen. Like session.stopping it carries no revision, is exempt from the gap check, never enters a replay, and is always the last frame. * Refusals around a switch. While a switch is in flight a subscribe is refused busy with a local-backend switch is in progress — the same answer a mutation gets then — rather than handed a stream nothing would end. Under local-backend = session the op answers not-implemented, the message ending dial identify.local_session_socket: that session serves the stream, with resume, and its own tab.list fences it.

Waiting on a condition

How a raw-socket client waits for a tab to change without polling — and what roostctl wait does:

  1. Pick the socket. Call identify on the UI socket. If local_session_socket is present, use that socket for everything below; else, if ops names events.subscribe, use the UI socket; else the server has no stream (the Swift Mac app, an older Roost): poll tab.list.
  2. Subscribe first, on connection A. Keep the ack's revision and session_id. A subscribe refused busy met a local-backend switch: poll identify until local_backend_switch is absent, then start again from step 1.
  3. Then, on connection B, check the process: identify.instance_id on a UI socket, session.identify.session_id on a session socket, must equal the ack's session_id. If not, the server restarted between the two — revisions restarted with it — so close both and start again. Then take the snapshot: tab.list, whose revision is at or past the ack's.
  4. Evaluate the snapshot, then the stream. Discard every batch whose revision is <= the snapshot's; apply the rest in order (tab.state_changed, tab.closed, …) and re-evaluate after each. Nothing on the stream carries a tab's output, so a condition on the viewport re-reads tab.dump on connection B.

The order is the whole trick: every commit after the subscribe is on the stream, so a snapshot taken after it can only be newer. Snapshot first and a commit landing between the two legs is in neither. A bare EOF or a revision gap means start again from step 1, and so does stream.ended — but a tab id means something only to the process that minted it. A local-backend switch replays the tabs onto its destination under new ids, and a restart mints new ones, so if the fresh ack's session_id is not the one the wait began with, the tab being waited on has to be found again (by title, cwd, or whatever the client knew it by) before anything is read off the new snapshot — a tab missing there is not a tab that closed. session.stopping means the session is going away.

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, which is how a client tells the two kinds of socket apart.

There is no connect step, and no order the server enforces: a client dials, calls session.identify to run the compatibility gate (below), and from there every other op is available immediately, to every same-UID connection, in any order. What Roost's own client actually sends is session.identify → session.set_theme → events.subscribe, dialing a data connection per tab it shows, with session.set_agent_hooks queued behind them — not because the wire requires that shape, but because each set_* op states something the session would otherwise guess wrong (its palette, whether the user wants agent hooks on this machine), and each is re-stated whenever the client's own answer changes. A client that only wants to watch runs a shorter sequence — session.identify → events.subscribe → tab.list — and never calls the set_* ops or attaches at all; nothing distinguishes that client from any other at the wire level, it simply chose to send less. 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.

One session per bundle profile per machine. Precisely: one per resolved socket namespace (XDG_RUNTIME_DIR, or its fallback) and state namespace (ROOST_STATE_DIR) — the two are pointed elsewhere on purpose whenever a test, or a second profile, needs an isolated session of its own. Uniqueness is enforced by two flocks, taken in a load-bearing order: the socket lock beside the socket first, then the state lock beside state.json (crates/roost-engine/src/single_instance.rs). A second roost-session start against the same two namespaces finds the socket lock already held, reports already-running, and exits 0 without touching the state a live session owns; roostctl session start does not take that verdict on faith — it polls session.identify on the socket afterward and only reports success once a session actually answers.

There is deliberately no session.list / session.create / session.kill: one profile is one session, so there is nothing to enumerate or select among. The seam for more than one session on a single host — a named session such as workbox:agents (HS-4e) — is one more path component in the profile resolver (BundleProfile → socket dir + state dir) plus a --name flag on session start / stop / status; it is not a registry op, and nothing on this wire needs to change to support it. session.identify.session_id distinguishes process incarnations of one session (it changes across a restart) — it names a run, not a session.

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 stay deliberately not reachable through --target / ROOST_BUNDLE_PROFILE / auto-detect. Any other op reaches a session explicitly — roostctl --target session or --socket <path> (#475) — never through ROOST_BUNDLE_PROFILE (session is refused there like any other unrecognized value) or auto-detect (a session is never a candidate). 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 was made lease-gated (plan 049, R1 later re-cut this — subscribing is leaseless again, and the lease classifies the stream instead), session.stop and takeover labeled what they closed (R15 later left a takeover closing nothing at all), 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.dump and tab.dump_resolved are served from it — and since plan 053 tab.dump reaches into those 2000 lines of history, not just the viewport.
  • The terminal is streamable. One data connection hands a client the whole terminal as a snapshot and then keeps 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": 6,
  "payload_kinds": ["ghostty-snapshot", "vt"],
  "libghostty_build": "ghostty-3f6b1c9a4d2e5f80+snapshot.v1",
  "session_id": "01K3S8TQ4F0Q9YB2K6WZ5D7XN",
  "started_at": "2026-08-27T14:03:11Z",
  "ops": ["identify", "tab.open", "events.subscribe", "session.stop", "…"]
}

ops is identify.ops for this socket: the ops this session would dispatch right now. A headless session never lists an op that needs a window, nor host.* and agent.set_hooks (unknown-op here), and lists tab.feed_pty_bytes and tab.capture_pty_input only when it was started with ROOST_TEST_MODE=1. Absent from an older session. It is discovery, not negotiation — the generation check below still comes first — and its contents are not a compatibility promise.

Three more optional fields describe the build, all additive and absent from a session that predates them (plan 076):

  • dev — true when the release workflow did not produce this build: any local build, debug or release profile, and every prerelease tag. Omitted for a release build.
  • git_sha — the commit the build came from, 7 hex characters, when the build could read it.
  • exe_path — the daemon's own executable, canonicalised once when it started, with no fallback; omitted when that did not resolve. It is a path to run, supplied by a peer, so a client uses it only for a session on its own machine.

roost-session identify, the offline identity a binary prints before it has ever run, carries dev and git_sha the same way.

An optional persist_error rides beside them, present only when the last attempt to write state.json failed, carrying that write's message. It is the standing value behind the live-only workspace.durability_changed event, which is why a client re-reads it on connect and again after a resync. Its absence is not a capability signal — a session that is writing fine omits it.

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 6. There is no features field: at 6 the integer is the whole negotiation, and there is no other capability a client needs to detect. The compatibility rule is one comparison: a conforming client checks session_protocol for exact equality against its own constant before anything else, and refuses to proceed on a mismatch — the attach handshake carries the same integer and enforces it server-side too, before it reads any other term. Every op on this page assumes that equality already holds; what came before 6 — the interactive lease, session.connect, per-connection classification, the features list that once carried intra-generation capabilities, and session.set_agent_hooks's own mode/skip shape — is history, not this build's contract, and lives in CHANGELOG rather than here.

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 shipped roost-session advertises both kinds the sample shows, as of plan 053: ghostty-snapshot, the full-fidelity binary format, and vt, the replay-a-byte-stream fallback that architecture §4.4 named as the escape hatch and that is now built (see Payload kinds for what each carries). A pre-053 session advertises ["ghostty-snapshot"] alone, which is what makes the list, not the build string, the thing a client's compatibility gate reads first.

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 and for that kind only: two libghostty builds that disagree cannot exchange a binary snapshot, so the attach handshake requires an exact string match before it will serve one and refuses a mismatch by name (build-mismatch) rather than letting it surface later as a corrupt screen. vt has no such requirement — it is bytes any VT parser replays — so a session advertising it can still be attached to across a skew, at that payload's documented fidelity. 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 the handshake's eligibility check 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.

A second variable under the same double gate, ROOST_SESSION_LEGACY_KINDS=1, makes the session advertise ["ghostty-snapshot"] alone — the pre-053 shape. It exists because the vt fallback removes the state the restart flow hangs off: with vt on offer a skewed client connects instead of refusing, so without this knob the only end-to-end test of the "needs restart" path would have had nothing left to reach.

A third test-mode seam is a file, not a variable: a JSON object at <the binary's canonical path>.test-identity with any of app_version, dev, git_sha (a string, or null to clear it) and session_protocol (a number) replaces those fields in both session.identify and roost-session identify. Copies of one binary in different directories can then answer as different builds, which an update test needs and one process-wide variable cannot give. An unknown key, or a sidecar that does not parse, fails roost-session identify outright; a starting session logs it and ignores the file. Ignored entirely outside test mode.

History: the interactive lease

Protocol generations 1 through 4 required a client to claim an interactive lease before it could drive a session: a session.connect op minted a bearer token that decided whose events.subscribe stream received tab.effect, whose reported focus was the one a session suppressed notifications against (through a session.set_focus op, itself retired at generation 6 — see Notifications fan out), and which connection the session.set_theme / session.set_agent_hooks / session.put_file ops would accept. Reconnecting was always a takeover — the same op, with takeover: true, displaced whoever held the lease — and what that did to the displaced client's connections changed within the generation. Through protocol 2 and 3 a takeover closed every connection the previous holder had, each told why: a terminal session.stopping envelope on the events connection, an ERROR frame on the data connections. R15, late in generation 4, left them open instead and demoted only the displaced client's event stream to a session.driver_changed-notified observer.

Protocol 5 retired all of it. There is no session.connect op, no lease field on any op, and no session.driver_changed event; every same-UID connection is symmetric, as session.identify states and every section below describes. See CHANGELOG for the protocol-5 migration entry and Versioning for the generation history.

Notifications fan out

A session suppresses nothing. An agent's attention on any tab raises Tab.has_notification and pushes both tab.notification and notification.fired to every subscriber, always. A session is headless — it has no window and no screen — so the only thing that can say "somebody is already looking at this tab" is a client, about itself.

Each client decides for its own window. On notification.fired for tab T, a client that is showing T and has window focus raises no banner and instead sends tab.clear_notification with that event's generation — the automatic form of the acknowledgement that selecting a tab already performs. That clear takes the dot down on every client rather than muting one. Every other client banners as it always did.

Until protocol 6 this was a session.set_focus op: each connection told the session what it was looking at and the session muted a tab while any connection claimed it. That op is gone — it answers unknown-op — because a union mutes the wrong people: a phone left open on a tab silenced the laptop, with no dot, no banner and no event (#474).

What that costs, stated plainly, because it is the design and not a defect:

  • Other clients may show the dot for one round trip, until the acknowledging client's clear lands. The client that is looking never paints it: it filters its own viewed tab out of both attention surfaces.
  • Two focused viewers both acknowledge the same raise. The second is answered cleared: false.
  • Regaining window focus does not clear. Only selecting the tab does, which is parity with a local window: focus alone has never acknowledged anything.
  • Another client's clear retires an inbox row, not a banner already shown. A desktop banner that has been put in front of a person is theirs to dismiss.

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). Same-UID, last-writer-wins: no per-op gate, any connection may send it.

Request:

{"id": "8", "op": "session.set_theme", "params": {
  "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; a short or long array is invalid-param rather than a partial application. The parser takes either case, and roost writes lowercase — the same spelling tab.dump_resolved uses — so the vectors have one spelling to agree about. Only the long form is accepted: #abc would be a second spelling of one color.

A client sends this right after connecting and before its first data connection — 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 — including between two different clients' palettes.

Answers shutting-down once session.stop has latched.

session.set_agent_hooks

Raise the host's agent-hooks key to (at least) the connected client's own allow-list. Same-UID, and every connection is additive: this one writes files under the session user's $HOME, and any connected client may send it — the socket's UID check is the only gate.

Request:

{"id": "11", "op": "session.set_agent_hooks", "params": {
  "agents": ["claude", "codex"],
  "client": "charlie-mbp"
}}

Response:

{"wired": ["claude", "codex"], "refreshed": [], "removed": [],
 "skipped": [{"agent": "cursor", "reason": "not allowed"},
             {"agent": "grok", "reason": "not installed"}],
 "errors": []}

This op only ever raises the key, never lowers it (plan 064 §3.3). agents is unioned into whatever agent-hooks already says on the host — off, unanswered, or a narrower list — and the union is what gets wired. There is no wire spelling of "off" or "narrow this" here: a client whose own agent-hooks is off, or unconfigured, has no allow-list to raise a host with, so it sends this op not at all. Taking a host's entries back out stays a deliberate, local act — roostctl agent ensure/uninstall, run by hand on the host itself. removed is therefore always empty from this op; the field stays on the reply shape because agent.set_hooks — an explicit local set, not a raise — can populate it for the local machine.

agents must be non-empty and carry no blank element; both are invalid-param. A name the host does not recognise is not — it comes back as {"agent": "gemini", "reason": "unknown"} in skipped, and the names the host does know are wired. Protocol equality pins one wire generation, not one agent set: a newer Roost can ship an adapter inside the same generation, so refusing the whole raise on an unfamiliar name is what would keep a newly supported agent switched off forever. A list with no recognised name at all is a successful, empty run — everything skipped, wired empty, nothing written, including the key. skipped[].reason remains free text for the client to show or log verbatim. client is required — it is recorded as by in the host's state record, which is what makes two clients of one host tellable apart.

A name the host cannot wire still survives in its key. The raise is a read-modify-write of agent-hooks, so an older host asked to widen a key a newer Roost wrote re-emits every token it could not resolve, verbatim, after the names it could: claude, gemini raised with ["codex"] reads back claude, codex, gemini. Only an explicit local set — agent.set_hooks, roostctl agent set — replaces the key outright and drops them, because there the user is looking at the list they just chose. roostctl agent status on the host lists such a name as unknown to this build.

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 raising an already-covered list, gets an empty wired. refreshed is this call's writes; two clients raising different allow-lists both win, additively.

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 install engine holds one advisory lock per home across plan and apply, so this op can wait behind another writer's run; neither dropping the client's connection nor the client's own 15 s timeout cancels a run already under way on the host. 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 right after connecting, 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 connection sequence: an error there fails the whole attempt, and a raise on a network-mounted $HOME would hold hydration up behind file I/O nothing is waiting on.

No new authority: any same-UID client 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.

Answers shutting-down once session.stop has latched — 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.

session.put_file

Land one client-supplied file on the host and answer with a path a shell on that host can be told to read. This is the leg underneath tab.send_file: the client uploads each file over a connection of its own, then pastes the returned paths into the tab. Available to every same-UID connection — uploads land in separate private directories keyed by random entropy (below), so two clients uploading at once never collide.

Request:

{"id": "12", "op": "session.put_file", "params": {
  "name": "roost-image-1757083567-8f3a1d0e5b7c42c2.png",
  "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB…"
}}

Response:

{"path": "/home/charlie/.cache/roost-session/files/4b9d1e7f0a3c5e21/roost-image-1757083567-8f3a1d0e5b7c42c2.png",
 "bytes": 69}

data is base64 like every other bytes field. One frame per file, no chunking: the raw cap is 10 MiB (roost_ipc::MAX_PUT_FILE_BYTES), which base64-encodes to ~13.4 MiB and still fits the 16 MiB frame with the envelope around it — that headroom is the whole reason the op needs no chunked form. Over the cap is too-large, checked twice: the encoded string's length before the typed decode (the frame and its serde_json::Value already exist by then, so what the early check spares is the decoded Vec<u8>), and the decoded length again after. The early check is deliberately coarse — base64 carries three bytes per four characters, so a file one or two bytes over encodes to exactly the cap's own length and is caught by the exact check instead. Both answer the same code.

name is the client's, and is never repaired. It must be a bare basename: 1–128 bytes of [A-Za-z0-9._-], not . or .., not starting with -. Anything else is invalid-param — not sanitized, not escaped, not quoted. The reason is the whole point of the op: the path this answers with is about to be pasted bare into an agent's composer, and bare is the one spelling every agent unquotes identically. A name that would need quoting is not a name this store can land, so the client sanitizes a dropped file's basename to that charset before it asks (roost_ui_model::file_transfer::sanitize_name), and a clipboard image keeps the roost-image-<nanos>-<16hex>.png shape the local paste already uses.

The whole returned path is paste-safe, and the client re-checks it. The file lands at <files_dir>/<16 hex chars>/<name> (eight bytes of OS entropy, one directory per file); the random directory is what keeps two drops of one name from colliding while the basename survives, because the agents show that basename in their chips. At start the session checks its entire resolved files_dir path against ^/[A-Za-z0-9._/-]+$ and falls back to a /tmp root of its own if a $HOME or $XDG_CACHE_HOME put a space, a unicode character or a non-UTF-8 byte anywhere in it — see paths.md. So the op never returns a path an agent cannot parse. The client still refuses to paste a reply that is not absolute, not in that grammar, whose final component is not exactly the name it sent, or whose bytes is not what it sent: a malformed or hostile reply must never become typed input.

The store admits, and never evicts. One serialized FileStore per session holds a 512 MiB admission cap (roost_engine::ipc::FILE_STORE_CAP_BYTES); accounting and directory allocation both happen under one lock, so two connections can never each be told their file fits the same remaining room. When the next file does not fit, the op answers store-full and deletes nothing — a path this op has handed back may sit unsubmitted in an agent's composer for an hour, and Roost removing it under the agent is exactly the bug eviction would have introduced. Clearing a full store means stopping or restarting the session. The write itself (private temp file, rename, 0600 under 0700 directories) runs on the blocking pool, because a session's cache can sit on a network mount and a stalled write must not take the tokio worker serving other connections with it; a write that fails removes the whole upload directory, so no partial file is ever visible under a path this op never returned.

Lifetime. A returned path stays valid until the session stops cleanly or starts again. roost-session sweeps files_dir entirely at start — a crash or a SIGKILL leaves it behind, and the next start is where that gets cleaned up — and again on the clean wire-stop path, after the reap. The SIGTERM direct-finalize fallback does not sweep: its children may still be alive, and the next start sweeps anyway.

session.put_file is in the session's mutating set, so a slow write holds the mutation barrier and a racing session.stop waits for it. That is the accepted price of never handing back a path the sweep has already removed. Once a stop has latched, the op answers shutting-down like every other mutating op.

Served only by a session built with a file store. One that could not open its root answers not-supported rather than refusing to start — the session still serves everything else. A UI socket answers unknown-op, like every other session.* op.

session.stop

Params: {}. Response: the reap report,

{"reaped": ["3", "5"], "killed": ["8"], "abandoned": ["9"]}

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 session ever admitted — every control connection, every data connection on every tab, and every event stream, regardless of which client opened it — 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": "7", "protocol_version": 6,
 "session_id": "01K3S8TQ4F0Q9YB2K6WZ5D7XN",
 "kinds": ["ghostty-snapshot", "vt"], "cols": 100, "rows": 30,
 "cell_w_px": 8, "cell_h_px": 16,
 "libghostty_build": "ghostty-1a2b3c4d5e6f7a8b+snapshot.v1",
 "focus": true,
 "resume_from_seq": 8814, "server_epoch": 6032428321756423947,
 "tab_generation": 3}

This line is the whole of an attach. There is no control-plane half and no ticket: attach names the tab as a string_int64 and the terms ride the same line, so a client opens one connection and is either streaming or refused. session_id is the value session.identify reported and is required — it is what stops a dial prepared for one session from landing on a replacement listening at the same socket path, and a mismatch is session-mismatch before anything is registered. The cell metrics may be omitted (a headless client has none); every other term is required, and a missing one is a parse-error that names it. 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. Every session params struct is strict; this handshake is the one permissive request on the wire, which is exactly what makes a future handshake term additive instead of a generation.

protocol_version is checked on the raw line, ahead of the typed decode, so a version-skewed client hears protocol-mismatch rather than a complaint about terms the two ends no longer agree on the meaning of.

An attach takes no authority beyond the socket's own UID check. The handshake carries no lease or authority term at all: attaching is reading plus raw input, and both are open to every same-UID client, as many at a time as it likes — bounded only by MAX_DATA_CONNS_PER_SESSION (see Many connections per tab).

kinds is the client's preference order and the server serves the first entry that is both servable and eligible; a list mixing kinds this build has never heard of with one it serves is fine. Servable is what session.identify advertised in payload_kinds — the advertisement is the contract the client negotiated against, so a kind absent from it is never served even when the code could produce it. Eligible is the kind's own requirement, and only ghostty-snapshot has one: an exact libghostty_build match. vt requires nothing, so a client offering ["ghostty-snapshot", "vt"] across a build skew lands on vt rather than on a refusal — which is the whole point of the fallback, decided server-side so a third-party client need not compare build strings itself. Reversing that preference order gets vt on a matching build too; nothing forces the binary format on a client that would rather replay bytes.

The two are separate walks over the offered list rather than one predicate, because a single pass cannot tell "nothing servable" from "nothing eligible" and those instruct the client differently: unsupported-kind means offer something else, while build-mismatch means the one kind we could have served needs the same libghostty on both ends — the answer a pre-vt client's whole restart flow hangs off. Since plan 053 the second is reachable only when the client offers no kind but ghostty-snapshot, or is talking to a session too old to advertise vt.

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. That holds for an unfocused attach too: cols/rows are this connection's declared geometry either way, and its first INPUT or RESIZE frame applies them.

focus says whether this attach claims the tab's geometry. A focused attach is when the server resizes: once the connection is admitted and before anything is encoded, the session resizes the tab (server terminal and TIOCSWINSZ) to the requested geometry and waits for that to land, so the snapshot about to be encoded is already at client size and needs no post-READY resize. A geometry either half refuses comes back as invalid-param. 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.

focus: false resizes nothing — the point of it is a client that wants to watch a tab without shrinking the one that is typing (a phone glancing at a desktop's session). Its geometry still counts the moment it interacts; see the sizing rule beside tab.resize.

focus is required and always spelled out on the wire — no default, no omit-when-true shim. An attach that leaves it out is a malformed request, not an unfocused one.

Refusal 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: protocol, then the typed decode, then session_id, then the stop latch, then the tab, then a servable kind, then an eligible one, then the grid, then the session's connection bound, and only then the focused resize.

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,
 "snapshot_cols": 100, "snapshot_rows": 30}

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:

{"ok": false, "error": {"code": "session-mismatch", "message": "..."}}
Code Meaning
protocol-mismatch wrong protocol_version. Checked first, on the raw line: the two ends disagree about what every other term means, and naming one of those would send the client hunting for the wrong bug.
session-mismatch session_id is not this session's.
not-found the tab has no live terminal, or was respawned around the hand-off this attach was admitted for — the tab task reports which generation actually served it, so a stream from a replaced terminal is refused rather than painted under the old tab's identity.
unsupported-kind no kinds entry is one this session advertises.
build-mismatch the only servable kind needs the same libghostty on both ends.
invalid-param attach is not a tab id, the grid is zero-sized, or a focused attach asked for a geometry the tab refused.
too-many-attaches this session already serves MAX_DATA_CONNS_PER_SESSION (32) data connections. One buggy same-UID client can hold all of them; that is accepted under the same-UID boundary this socket already draws.
snapshot-failed the terminal could not be encoded right now. Re-attach is the recovery — it is about this instant, not about the client. For vt this also covers a terminal whose VT parser sits mid-sequence with no retained continuation for the whole attach budget: the encode is parked and retried after each further chunk rather than emitting a payload that would desync the client, and the budget is what bounds that wait.
shutting-down session.stop has latched. Read before the tab is looked up, so a stop that already reaped the tab still answers this and not not-found: what went away is the session, not one tab, and a client that removed the tab from its UI over a not-found would be acting on the wrong news.
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.

kind is the kind the handshake settled on out of the kinds the client offered — this reply is the authoritative one, and it is what a client selects its decoder from.

server_epoch and tab_generation are the resume identity, and the accepted reply is the only place a client learns them. 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.

snapshot_cols and snapshot_rows name the size the bytes that follow were written for — the snapshot's own encode geometry in "snapshot" mode, the tab's grid at the handoff in "resume" mode — and are present on every accepted reply, focus: true included, because raw input is open and any other client may resize the tab between this connection's own resize and the encode. A vt client builds its terminal at that size before replaying, then resizes it to its own; a resuming client replays the ring into the terminal it kept, and an unfocused one may not be at the tab's grid (a served resume never straddles a grid change — see Resume). An accepted reply that states no geometry is a truncated reply, and a client refuses it.

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:

frame := u32-LE payload length | u8 type | payload
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 across INPUT frames — this is the client's job, not something the server will do for it. The server splits an oversized snapshot record across SNAP frames for the same reason. A vt payload is split at a smaller 64 KiB (VT_SNAP_FRAME_BYTES), which is a hold-window fix rather than a wire-limit change — see Payload kinds.
  • Fixed-width payloads are exactly that width: PTY ≥ 9 bytes, EXIT == 12, RESIZE == 8.
  • A zero-length payload is meaningful only for SNAP, where it is the vt payload's terminator (below). An empty INPUT is a protocol-error.
  • An unknown type byte is a protocol-error naming 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, and they are part of the geometry the tab compares against (see tab.resize). A RESIZE naming zero cols or zero rows is ignored, not fatal and not applied — the handshake refuses a zero-sized grid and the two state the same client's geometry, so they have to agree about what a grid is.

Both INPUT and RESIZE size the tab: a RESIZE applies and becomes the connection's declared geometry, and an INPUT applies that geometry ahead of its bytes when the tab is at another one. Neither is answered, and the server never tells an attached client that somebody else resized the tab under it — a client at another size sees wrapping until it next interacts.

Ordering, once the stream is running:

  1. The whole ready prefix goes out at full speed, and live PTY frames are absorbed but held until it has. A client has no terminal to apply a PTY frame to before that point, and making it buffer them would move this queue into every client. For ghostty-snapshot the prefix ends at Ghostty's own READY record and the window is tiny — that prefix is just the active screen. For vt the prefix is the whole payload: a replayed byte stream has no partial-render marker, so the hold lasts as long as the payload does at the client's link speed.
  2. After the ready prefix, PTY leads: a keystroke's echo must not wait behind a scrollback page.
  3. But the snapshot cannot be starved. At least one SNAP frame goes out after 256 KiB of PTY payload or 50 ms since the last one, whichever comes first — a yes-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.
  4. EXIT goes out only once everything before it has — the vt terminator included, even for a tab that exits mid-payload.

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.
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.

Payload kinds

Two, negotiated on the handshake and stated back on its accepted reply. They differ in fidelity and in how a client knows the payload has ended.

ghostty-snapshot is libghostty's own binary format. Its 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, and FINISH is how the client knows it has the whole thing. Both ends must be the same libghostty_build, which is what makes it a full-fidelity mirror and also what makes it refusable.

vt is a plain VT byte stream: escape sequences and text that any conforming parser replays into a fresh terminal of the attach geometry to reproduce the server's active screen — its scrollback, viewport, cursor, pen, tabstops, scrolling region and an allowlist of modes, plus the parser's unfinished input so the first live PTY frame completes a sequence the fence cut exactly as it does on the server. It carries no build requirement, so it is what a client whose libghostty disagrees with the session's attaches with instead of being refused.

Three transport consequences follow from vt having no internal framing of its own:

  • One zero-length SNAP frame terminates it. A byte stream has no end marker, so the transport supplies one. It is written in the same step as the final payload frame — never a later pass, because a later pass could flush held PTY or write EXIT first, and both "no PTY precedes the terminator" and "EXIT is last" have to hold. It is sent unconditionally. A non-empty SNAP after it is a protocol-error; re-attach. ghostty-snapshot sends no such frame, and a client under that kind already treats an empty SNAP as a no-op.
  • Frames are capped at 64 KiB (VT_SNAP_FRAME_BYTES), not the wire's 1 MiB. The pump awaits a whole frame's write inline and drains the tab's output tee only between writes, so holding PTY for a 1 MiB frame lets a busy child lag the tee and trip desync — the unrecoverable code — where the smaller frame raises the child-to-link ratio the tee tolerates 16× and pushes slower producers into the bounded, recoverable overflow path instead.
  • First paint waits for the whole payload, since rule 1's prefix is all of it. On a slow link that trails what ghostty-snapshot would have painted at READY.

What a vt attach does not carry. Stated here because a client on this kind is showing a real screen with real gaps in it, not a degraded-but-equivalent one:

  • the inactive screen — a tab attached while the alternate screen is up gets an empty primary when the program exits alt mode (ghostty-snapshot carries both);
  • soft-wrap flags — a wrapped row replays as a hard row, so copy and reflow-on-resize differ from the server's;
  • per-cell hyperlinks — VT content emits OSC 8 only for HTML, so only the pen's link survives and link hover and click stop working on such a tab. Roost injects FORCE_HYPERLINK=1 into every tab, so this is a visible loss, not a theoretical one;
  • text-free background-only rows in history — a row erased under a background color with no text in it. Viewport rows of that shape are refilled explicitly; history rows cannot be, there being no cursor addressing into history;
  • the saved cursor (DECSC) and the kitty-keyboard stack — only the live cursor and the current kitty flags are carried;
  • modes outside the carried allowlist, deliberately: DECCOLM would resize the client away from the attach geometry, ?1048 moves its cursor, mode 2048 and the visibility report write a reply to the PTY on enable, and ?2026 can latch synchronized output and freeze rendering mid-frame. A program that set two members of one mouse-tracking or mouse-format family replays as the last of them, since libghostty collapses each family onto a single field;
  • Kitty images — same as ghostty-snapshot;
  • pending-wrap under origin mode — restoring DECOM homes the cursor in libghostty, so the repositioning that follows clears the flag. This is the one cursor state vt cannot reproduce;
  • a program color override that happens to equal the server's own default is indistinguishable from "unset" and is not carried. Only the program's overrides ride the payload at all — the client keeps its own theme.

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 accepted reply stated. 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_epoch matches 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_generation matches 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 + 1 is a valid empty-slice resume — the client missed nothing and simply carries on.
  • The tab's grid has not changed since resume_from_seq (#564) — whoever changed it, this attach's own focus: true resize included. The ring holds records written for the grid the tab had, and replayed into a terminal the client kept at another width they land in the wrong cells. The rule is conservative: a resize with no output after it refuses every resume point before it, since the seqs cannot say whether the client saw it.

Every miss — resume_from_seq of 0, a seq past the end, an evicted range, an identity mismatch, a grid change inside the range, a tab task that went away — falls back to mode: "snapshot" and a full attach in the same reply. A session older than the grid rule can still answer resume across a grid change, which is why Roost's own client also stops asking: it forgets a detached tab's resume point when it re-grids that tab's terminal, gives up the point of an attach that sent a resize while live, and discards a resume answered for a grid its terminal has left while the handshake was in flight. A resume failure is never an error, and a client never has to handle one: it reads mode and does what it says.

Many connections per tab

A tab admits as many data connections as clients dial. Each gets its own receiver on the tab's output tee, its own fence, its own snapshot and its own budgets; nothing is shared, so a peer that stops reading is cut on its own lag and takes none of the others with it. Every admitted connection receives the same PTY bytes and every one of them may send INPUT. There is no supersede: a second attach never closes the first, and connecting from anywhere else never closes anything that was already open.

The tab is sized by whichever of them interacted last, per the rule beside tab.resize: a client that only watches (attached with focus: false, never typing) never changes the size out from under the one that is working.

What bounds the count is not a per-tab limit: it is MAX_DATA_CONNS_PER_SESSION (32 live data connections session-wide, answered too-many-attaches past it) and the session's 4 concurrent snapshot encodes. Within that the number of connections one tab admits is open, which is the honest statement — the per-attach machinery is what keeps that affordable.

Client disconnect at any point simply aborts that one forwarder; the tab keeps running, the other connections keep streaming, 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.

The 60 s budget now also covers the encode, not just the streaming after it: the fence awaits the encoder under that timeout, so a terminal that never reaches an encodable state fails the attach with snapshot-failed instead of hanging it. That is what makes the wait a vt encode can take (parking mid-sequence, above) bounded — and it fixed the same hole for ghostty-snapshot, where a wedged encode previously awaited forever.

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 or an in-process UI 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 and stream.ended are deliberately not in this set: neither is a workspace event, neither carries a revision, and neither ever rides inside a batch or enters a replay — each is the connection's own terminal control envelope, delivered outside the batch discipline, the last frame before the stream closes. See events.subscribe for session.stopping and On a UI socket for stream.ended.

  • 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 (or tab.set_cwd-equivalent) lands on a tab whose user_titled is false, the workspace also re-derives the title from the basename of the new cwd. Subscribers will see a tab.cwd_changed immediately followed by a tab.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 further tab.title_changed arrives 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>}.
  • tab.password_input — {"tab_id": "<id>", "password_input": <bool>}. The tab's PTY entered or left a password prompt (Tab.password_input); fires once per change, never per sample. Workspace state like any other tab field, so it is replayed into a resume, and a client that reconnects reads the standing value off tab.list. An older client that does not know the name skips it.
  • notification.fired — {"tab_id": "<id>", "title": "<string>", "body": "<string>", "generation": <int>}. Mirrors the legacy proto's NotificationEvent; useful for tools that mirror notifications elsewhere. generation numbers the raises on that tab, counting from one; a client acknowledging this raise sends it back on tab.clear_notification — see Notifications fan out. Live-only on a session's event stream: never replayed into a resume.
  • 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 accepted tab.agent_report or an OSC 133 shell mark changes the agent record. tab.state_changed and hook_active.changed still 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. state is 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"}. One side effect the tab's OSC scan produced, fanned out to every subscriber unfiltered — a bell BEL byte outside any escape sequence, or an OSC 52 clipboard write. What a client does with it is its own policy: a bell is worth marking on any tab that owns one, while a clipboard write should be applied only to the tab that client is actually viewing (see events.subscribe and the guide's Several windows on one session). data and target are present only for clipboard-write: data is 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); target distinguishes OSC 52's primary-selection form (p/s) from the system clipboard (c or no selector), defaulting to system when 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.
  • workspace.durability_changed — {"error": "<string>" | null}. The session stopped — or started — writing state.json. error is the write's message while it is failing and null the moment one lands again; the key is always present, because recovery is the news half the time. Emitted on a change of value only, so a session on a full disk says it once rather than once per mutation, and ops keep succeeding throughout: a workspace nobody can save is still a workspace, and refusing tab.open on a full disk would be worse than losing the layout. The event is live-only — the standing value is session.identify.persist_error, which a client re-reads after a resync.

Versioning

Two integers version this wire independently. identify.protocol_version is the UI socket's schema version — currently 1 (roost_ipc::PROTOCOL_VERSION); it is reported by identify but nothing compares it, so the UI socket has no handshake gate. session.identify.session_protocol is the session sockets' — currently 6 (roost_ipc::messages::SESSION_PROTOCOL_VERSION), covering both the session JSON ops and the binary data plane; conforming clients check it for equality before anything else and the attach handshake refuses a mismatch.

The rule, stated once: an addition bumps the session integer only when a pre-bump peer could not refuse it meaningfully; everything else is a plain additive change (a new optional field, a new op, a new event name an old client can ignore) and moves nothing. There is no intra-generation capability channel today — the integer is the whole negotiation. The generation-by-generation history of what each bump changed, including the lease's own retirement at 5, is in CHANGELOG, not here: this page describes the current contract, and a bump's rationale belongs with the change that made it, once.

The consumption and compatibility policy lives in ipc-compatibility.md — what is additive in which direction, which enums are closed, when either integer bumps, and how an external project depends on roost-ipc.

Machine-readable schema

docs/reference/api/roost-ipc.schema.json is a JSON Schema (draft 2020-12) bundle generated from the same serde types this page describes by hand, so a wire-shape change shows up in that file's diff — it cannot describe a shape the types this crate actually serializes don't accept. The bundle is one document:

{"$schema": "https://json-schema.org/draft/2020-12/schema",
 "schema_version": 1, "protocol_version": 1, "session_protocol_version": 6,
 "envelopes": {"request": …, "response": …, "error": …, "event_batch": …,
               "session_stopping": …, "stream_ended": …},
 "ops": {"tab.open": {"params": …, "result": …}, …},
 "events": {"tab.opened": …, …},
 "$defs": {…}}

envelopes are the six frame shapes every op or event rides inside; ops and events name every ops::/EVENT_* constant's params/result or data type, each a $ref into $defs. Which socket serves an op is not encoded here — that is identify.ops's job, not the schema's: a session socket and a UI socket can both appear in ops even though only one serves a given op at runtime.

roostctl schema prints the file verbatim, with no running Roost and no --json distinction (the file already is the payload) — see schema in the CLI reference. The file is generated from crates/roost-ipc/src/schema.rs's bundle()/bundle_json() and pinned byte-for-byte by crates/roost-ipc/tests/schema_pin_test.rs; coverage and fidelity tests (the latter validating every file in tests/ipc-vectors/ against the bundle) are in the same crate — see What enforces this.

No consumer validates against this schema yet — it exists so one can, this release ships the generator, the pin, and the file, not a client-side validator.

See also

docs/archive/ipc-legacy-proto.md has the mapping from the pre-JSON protobuf protocol to this one, for anyone still holding a client written against it.