phux
protocol

L1 — Terminal substrate

Defines the required conformance tier: the wire surface scoped to a single Terminal.

wire 0.8.0stable document
Full source summary

Defines the required conformance tier: the wire surface scoped to a single Terminal. Lifecycle (spawn, attach, detach, kill, atomic group kill), the VT-bytes-on-wire state synchronization hot path, viewport and per-Terminal resize, terminal-originated events, state replay on attach, and the L1 command set. Group create and rename are not here; they decompose into L1 lifecycle plus L3 metadata. Structured agent affordances are engine-derived conveniences, not a normative structured contract.

1. Scope and the engine-delegation constraint

L1 is Terminal-scoped. Every message in this tier names exactly one TerminalId (per ADR-0016) or operates over an explicit list of them. There is no collection lifecycle tier on the wire: grouping is L3 metadata plus client logic (ADR-0030), and the conventions live in L3.md.

Both ends of the wire run the same terminal engine (libghostty). The wire therefore carries identity, lifecycle, transport framing, opaque terminal bytes, and L3 metadata — and nothing that re-encodes terminal state into a second representation. Any structured view of a Terminal (a cell grid, a command-boundary stream, a pane tree, a layout, a run-and-wait result) is a consumer-side projection computed from the engine the consumer already runs, not a wire tier. See ADR-0013 for the bytes-on-wire rationale and ADR-0030 for the delegation principle this tier follows.

Encoding is field-tagged TLV, per appendix-encoding.md, which owns the normative codec statement: each message body is a sequence of field_id || wire_type || length-delimited value fields, matched by id and skip-by-length over unknown ids. The wire bodies below list each message’s fields in field-id order (ids 1, 2, 3, … per message); the leaf primitives and nested tagged unions within a field are big-endian, length-prefixed, and positional.

2. L1 message catalog

These are the messages every conforming consumer (L1, L1+L3) speaks. They carry TerminalId and form the substrate over which L3 composes. L1 is always implemented by the server.

IDDirectionNameReferenceStatus
0x10C → SINPUT_KEYinput.mdshipped
0x11C → SINPUT_PASTEinput.mdpartial
0x12C → SINPUT_MOUSEinput.mdpartial
0x13C → SINPUT_RAWinput.mdspec-only
0x14C → SINPUT_FOCUSinput.mdpartial
0x16C → SHISTORY_REQUEST§4.5shipped
0x17C → SINPUT_TERMINAL_REPLYinput.md §6partial
0x20C → SVIEWPORT_RESIZE§9.2partial
0x22C → SSPAWN_TERMINAL§3.1partial
0x23C → STERMINAL_RESIZE§3.1partial
0x2AC → SMOVE_TERMINAL§3.1shipped
0x41C → SSUBSCRIBE_EVENTS§7partial
0x90S → CTERMINAL_OUTPUT§4.1shipped
0x91permanently retiredADR-0070retired
0x92S → CTERMINAL_RESIZED§9.2spec-only
0x93S → CBOOTSTRAP_BEGIN§4.3shipped
0x94S → CBOOTSTRAP_CHUNK§4.3shipped
0x95S → CBOOTSTRAP_READY§4.3shipped
0x96S → CHISTORY_PAGE§4.5shipped
0x97S → CBOOTSTRAP_TOMBSTONE§4.6shipped
0x98S → CHISTORY_TOMBSTONE§4.5shipped
0x99S → CHISTORY_REJECTED§4.5shipped
0xA0S → CTERMINAL_OPENED§9.1spec-only
0xA1S → CTERMINAL_CLOSED§3.1partial
0xA2S → CTERMINAL_SPAWNED§3.1partial
0xA8S → CTERMINAL_MOVED§3.1shipped
0xB0S → CBELL§3.2shipped
0xB1S → CTERMINAL_EVENT§3.3spec-only
0xB2S → CALERT§3.4spec-only
0xB3S → CEVENT§7partial

INPUT_TERMINAL_REPLY is the sole protocol-0.7 opaque client-to-server input frame. It carries only byte-exact replies generated by an attached client’s terminal emulator; it never substitutes for structured user input, INPUT_RAW, or INPUT_PASTE. It is available only when HELLO_OK.server_caps.features contains TERMINAL_REPLY = 0x80; an older 0.7 HELLO_OK without the bit does not support it. Its attachment, authorization, ordering, and 65,536-byte bound are normative in input.md §6.

The L1 command set rides on the generic COMMAND envelope (§5) and is catalogued in §5.1.

Status: spec-only. The workload grant and classifier are specified but not present in the reference server.

When a connection carries a phux-workload/v1 grant, every client-originated L1 frame and nested command is additionally governed by the total verb/selector matrix in workload-auth.md §7. That check runs before Terminal lookup, handler dispatch, or satellite forwarding. The role and input lease rules below are additional domain invariants, never substitutes for the workload grant.

3. Terminal lifecycle frames

3.1 Spawn, resize, and close

Wire bodies (field-tagged TLV, per appendix-encoding.md; fields listed in field-id order, leaf primitives and nested unions positional within each field):

SPAWN_TERMINAL   { request_id: u32,
                   group: GroupId,
                   command: optional<list<str>>,
                   cwd: optional<str>,
                   env: optional<list<(str, str)>>,
                   term: optional<str>,
                   satellite: optional<str>,
                   owner_terminal: optional<TerminalId>,
                   agent_session: optional<bytes>,
                   initial_size: optional<(u16, u16)> }
TERMINAL_SPAWNED { request_id: u32, result: SpawnResult }
TERMINAL_CLOSED  { terminal_id: TerminalId, exit_status: optional<i32> }
TERMINAL_RESIZE  { terminal_id: TerminalId, cols: u16, rows: u16 }

SpawnResult = tagged_union {
    OK  (TerminalId),  // tag 0x00
    ERR (SpawnError),  // tag 0x01
}

SpawnError = tagged_union {
    GROUP_NOT_FOUND,               // tag 0x00; empty body
    SPAWN_FAILED (str),            // tag 0x01; UTF-8 diagnostic
    UNSUPPORTED_SATELLITE_ROUTE,   // tag 0x02; empty body (§9.1)
    SATELLITE_UNREACHABLE (str),   // tag 0x03; UTF-8 diagnostic (§9.1)
    // #[non_exhaustive] — future codes (PermissionDenied,
    // ResourceExhausted, ...) MAY be added without bumping major.
}

The SpawnResult tag convention (Ok = 0x00, Err = 0x01) is established here for reuse by future Result<T, E>-shaped reply frames; it mirrors the Option tag convention (None = 0x00, Some = 0x01) so hex-dump readers do not need a second per-shape table.

SPAWN_TERMINAL is asynchronous: the server replies with TERMINAL_SPAWNED correlated by request_id. command = None means “use the server’s default shell” (the same convention as AttachTarget::CreateIfMissing.command = None, §8). cwd = None means “use the server’s default cwd” (typically the user’s $HOME; the exact policy is implementation-defined). env = None inherits the server’s environment as-is; env = Some([]) is distinct — it starts with an empty environment.

term (field id 6) is a first-class, optional per-spawn TERM override: a typed knob so a consumer can advertise a specific terminfo entry for the new Terminal without hand-rolling a TERM env pair. term = None defers to the server’s defaults.term (and ultimately its compiled-in baseline). A TERM entry inside env still wins over term, because the server applies env last; the precedence, lowest-to-highest, is compiled-in default → defaults.termterm field → env TERM entry.

satellite (field id 7) is the federation addressing knob (ADR-0007, phux-v45.6): None — the only shape a non-federated consumer ever sends — spawns on the receiving server; Some(host) asks a federation hub to route the spawn over its outbound link to the named satellite. Appended as an optional field after term’s id 6, so the field is wire-additive: a body that stops before it decodes as None on an old peer. Routing semantics — including the two SpawnError codes above, which mirror the UNSUPPORTED_SATELLITE_ROUTE / SATELLITE_UNREACHABLE error codes into the spawn’s own typed reply — are normative in §9.1. The relayed frame the satellite receives always carries satellite: None (hub-and-spoke never chains), and where the satellite places the new Terminal follows the satellite’s own placement policy, exactly as for a local non-attached spawner (the reference server hosts it in its most recently active session).

owner_terminal (field id 8) is an optional local ownership address. When present, the server MUST create the new Terminal in the exact window that owns the named existing Terminal, or return SPAWN_FAILED; it MUST NOT fall back to an attached or recently active session. The field carries no geometry or focus semantics: after TERMINAL_SPAWNED, a layout-aware caller publishes the new leaf through L3 metadata. owner_terminal = None preserves the legacy server placement policy. Ownership targeting is local-only and MUST NOT be combined with satellite.

Under phux-workload/v1, the side-effect-free resolved owner Group, not the payload group, is the CREATE subject; the owner Terminal separately requires BIND. After both checks, a payload Group unequal to the resolved Group returns SPAWN_FAILED and creates nothing. Unauthorized and absent owners remain indistinguishable at the policy guard.

agent_session (field id 9) is optional, bounded opaque provenance installed under the new local Terminal’s phux.agent-session/v1 metadata key in the same server state transaction that creates and interns the Terminal. Its semantics and 4096-byte encoded bound are specified by L3 §3.7.1; the server MUST NOT interpret provider fields. Empty or oversized values return SPAWN_FAILED. The field is local-only, MUST NOT be combined with satellite, and is wire-additive: peers that predate field 9 ignore it, after which a client MAY use ordinary L3 SET/GET confirmation as a compatibility fallback.

initial_size (field id 10) is the optional (cols, rows) — two big-endian u16s, positional within the field — that the new Terminal’s grid and PTY winsize are created at. A server advertising SPAWN_INITIAL_SIZE (proto.md §6.2) MUST create the Terminal at that grid, and MUST record it as the Terminal’s dimensions so GET_STATE and the next ATTACHED snapshot report it, before the Terminal becomes visible to any consumer. A zero on either axis MUST be read as “the sender does not know its geometry” — not as a zero-cell grid, which no terminal emulator has — and leaves the server’s default in force, consistent with §9.2’s zero-viewport no-op rule. The field is local-only: a hub MUST NOT forward it on a satellite-routed spawn, and a satellite therefore applies its own default.

The field exists because the geometry is knowable one round trip earlier than it was being applied. A layout-owning consumer computes the tile a new leaf will occupy from its own layout before it has an id for that leaf; without this field the pane bootstrapped at the server’s default, the consumer’s real tile arrived immediately afterwards as TERMINAL_RESIZE, and — because an authoritative resize invalidates the bootstrap generation (§4.3, ADR-0070) — the capture the server had just computed and published was discarded unread. Sending the tile up front makes that follow-up resize a no-op instead. A consumer with no honest answer (a headless spawner, a hub relay, an empty layout) omits the field rather than guessing.

A server that predates the field skips it by length and spawns at its default, which is exactly the pre-field behavior, so a consumer that sends it unadvertised degrades rather than breaks. Consumers SHOULD still gate on the advertised bit, since it is what tells them whether their follow-up resize is redundant.

group is GroupId, a documented opaque grouping key, not a lifecycle tier. The reference server exposes a single default value at GroupId(1); other ids MAY surface as SpawnError::GroupNotFound. GroupId is retained because some SPAWN_TERMINAL and L3 scoping paths still carry it; this is settled, not a remnant awaiting removal (bead phux-0bmc closed as resolved-by-rename). It SHALL NOT be read as a grouping lifecycle tier — there is none.

TERMINAL_CLOSED.exit_status is Some(n) when the process called _exit(n) and None for signal kills and unknown-cause exits — a compact subset of §9.1’s ExitStatus tagged union. The wider ExitStatus shape MAY grow in a later wire bump; the Option<i32> shape is sufficient for the spawn / kill use cases.

TERMINAL_CLOSED is the single L1 lifecycle event for a Terminal ceasing to exist, whether it died from PTY EOF or process exit, a KILL_TERMINAL command, or a KILL_TERMINALS group teardown (§5.1). The server MUST emit it to every client subscribed to the Terminal and MUST NOT additionally infer a client DETACHED from a Terminal’s death: the server reports lifecycle facts and does not interpret them (ADR-0015). Whether the death of a Terminal should detach a client is a consumer policy. A client still detaches explicitly via DETACH (proto.md §7.3), and the server still sends DETACHED for server-initiated teardown (shutdown, takeover); EOF is not one of those cases.

“Subscribed to the Terminal” spans both subscription paths and is not limited to the session-scoped one: a client subscribed by ATTACH (§9.1) and a client subscribed by ATTACH_TERMINAL (§5.1, which explicitly requires no ATTACH) are equally subscribers, and a consumer that only ever sent ATTACH_TERMINAL MUST receive TERMINAL_CLOSED. This is not a nicety: the content stream ends silently on a Terminal’s death, so without the lifecycle frame a dead Terminal is indistinguishable from a quiet one, and the consumer most exposed to that is exactly the one watching a single Terminal — an agent observing one pane, or a federation hub’s proxy subscription, neither of which attaches to a session. A client subscribed by both paths at once MUST receive the frame exactly once, not once per path.

TERMINAL_RESIZE is sent in addition to (not in place of) VIEWPORT_RESIZE: the outer-viewport frame conveys the client’s smallest-common-bounding-box; TERMINAL_RESIZE conveys the resolved per-Terminal dimensions after the client’s layout walk. The server’s PTY layer drives ioctl(TIOCSWINSZ) from this. Implementations SHOULD treat cols or rows of zero as a no-op rather than a kernel error; the wire codec round-trips zero faithfully.

TERMINAL_RESIZE is the C→S resize frame. The S→C TERMINAL_RESIZED discriminant at 0x92 is spec-only for now; it lands when multi-client per-Terminal resize fan-out (§9.2) is needed for non-attaching observers.

Cross-window move (ADR-0056):

MOVE_TERMINAL  { request_id: u32,
                 terminal: TerminalId,
                 owner_terminal: TerminalId }
TERMINAL_MOVED { request_id: u32, result: MoveResult }

MoveResult = tagged_union {
    OK  (TerminalId),  // tag 0x00; the moved Terminal's unchanged id
    ERR (MoveError),   // tag 0x01
}

MoveError = tagged_union {
    MOVE_FAILED (str),             // tag 0x00; UTF-8 diagnostic
    UNSUPPORTED_SATELLITE_ROUTE,   // tag 0x01; empty body
    // #[non_exhaustive] — future codes MAY be added without bumping major.
}

MOVE_TERMINAL re-parents the live Terminal terminal into the window that currently owns owner_terminal — possibly in a different session — without touching its process, PTY, scrollback, metadata, or agent record. owner_terminal is an ownership address exactly as in SPAWN_TERMINAL.owner_terminal: it conveys no split direction, ratio, or focus, and after a successful TERMINAL_MOVED a layout-aware caller publishes geometry through L3 metadata — two envelope writes, one per session, with the caller issuing the inverse MOVE_TERMINAL if the destination write fails (ADR-0056). The server MUST perform the re-parent atomically under its state lock, and MUST reap a source window the move emptied by the same rules as pane death. The moved Terminal’s id is stable across the move: subscriptions, per-Terminal metadata, and outstanding waits survive. If reaping removes the source session, the server MUST detach session-attached clients observing that now-nonexistent session; independent per-Terminal subscriptions survive. When the moved Terminal was the source window’s only pane, no owner_terminal remains there for an inverse move, so a destination-publication failure is the documented best-effort rollback case: the caller reports that ownership could not be restored, tells the user to inspect the Terminal’s current location, and names insert-pane as recovery.

The operation is local-only: a satellite-tagged id on either end is refused with UNSUPPORTED_SATELLITE_ROUTE. A missing Terminal on either end, or an owner_terminal with no owning window, is MOVE_FAILED with a diagnostic.

MOVE_TERMINAL is gated on the MOVE_TERMINAL server feature bit (proto.md §6.2): a client MUST NOT send it to a server that does not advertise the bit — an older server treats the discriminant as an unknown frame and drops it, so an ungated sender would wait forever.

3.2 BELL

BELL { terminal_id: TerminalId }

The Terminal received a bell character. The server MUST NOT translate this into VT output; clients decide policy.

3.3 TERMINAL_EVENT

Status: spec-only. The frame has no codec entry, so nothing below is observable on the wire today. A consumer that needs OSC-derived facts reads them from its own engine over the TERMINAL_OUTPUT bytes, which carry the same sequences. The TerminalEvent type in phux-client is that consumer-side projection, not this frame.

A channel for terminal-originated events the server has parsed (via libghostty-vt’s OSC parser) and chooses to surface to clients. It is how an L1-only consumer (agent, recorder, CI orchestrator) answers questions such as “did the command finish, what was the exit code, what directory am I in?”

TERMINAL_EVENT {
    terminal_id: TerminalId,
    event: TerminalEventBody,
}

TerminalEventBody = tagged_union {
    TITLE             { title: str },                              // OSC 0/1/2
    CHANGE_WINDOW_ICON,                                            // OSC 1 (icon-only)
    CURRENT_DIR       { uri: str },                                // OSC 7
    HYPERLINK_START   { id: u32, uri: str, params: str },          // OSC 8 begin
    HYPERLINK_END     { id: u32 },                                 // OSC 8 end
    USER_NOTIFICATION { body: str, tag: optional<str> },           // OSC 9 / iTerm2 / OSC 777
    SEMANTIC_PROMPT   { kind: PromptMarkKind, info: optional<str> }, // OSC 133
    CLIPBOARD         { selection: ClipboardSelection, data: bytes }, // OSC 52
    MOUSE_SHAPE       { shape: str },                              // OSC 22
    PROGRESS_REPORT   { state: ProgressState, value: optional<u8> }, // ConEmu OSC 9;4
    EXIT_CODE         { code: i32 },                               // synthesized at PTY exit
    CUSTOM            { kind: u32, payload: bytes },               // pass-through escape hatch
}

PromptMarkKind = enum {
    PROMPT_START   = 1,  // OSC 133;A
    COMMAND_START  = 2,  // OSC 133;B
    COMMAND_END    = 3,  // OSC 133;C
    PROMPT_END     = 4,  // OSC 133;D (optional exit code in `info`)
}

ProgressState = enum {
    REMOVE      = 0,
    DEFAULT     = 1,
    ERROR       = 2,
    INDETERMINATE = 3,
    WARNING     = 4,
    PAUSED      = 5,
}

ClipboardSelection = enum {
    SYSTEM     = 0,
    PRIMARY    = 1,
    SECONDARY  = 2,
}

The union carries synthesized non-OSC events (EXIT_CODE) alongside parsed OSC sequences. The server does not forward every OSC type libghostty recognises. Color operations, kitty color protocol commands, and kitty text-sizing are purely terminal-state concerns; they are applied to the Terminal’s libghostty_vt::Terminal and clients see their effect through normal cell diffs. The variants above are those that affect client UX (chrome, notifications, clipboard, status-bar widgets) or that an L1-only consumer needs for command-boundary detection.

3.4 ALERT

Status: spec-only. No codec entry and no server-side activity or silence tracking. BELL (§3.2) is the only one of these three signals a consumer receives today.

Server-internal notifications about a Terminal:

ALERT { terminal_id: TerminalId, kind: AlertKind }

AlertKind = enum {
    ACTIVITY  = 0,  // Terminal wrote output while consumer was inactive
    SILENCE   = 1,  // Terminal has been quiet for the configured threshold
    BELL      = 2,  // duplicate of §3.2 for clients that prefer one channel
}

4. Terminal state synchronization

ADR-0070 defines two wire identities in addition to TerminalId:

StreamId    = nonzero<u64>  // one logical subscription on one connection
BootstrapId = nonzero<u64>  // one replaceable replica generation in that stream

Zero is malformed. Every bootstrap, history, live output, and StateSync ACK frame carries all three identities. A generation is immutable once announced; replacement always allocates a new BootstrapId.

4.1 Live frame model

TERMINAL_OUTPUT {                 // 0x90, field-id order
    terminal_id: TerminalId,      // 1
    seq: u64,                     // 2
    bytes: bytes,                 // 3
    stream_id: StreamId,          // 4
    bootstrap_id: BootstrapId,    // 5
}

The Terminal actor stamps checked, non-wrapping sequence values before broadcast. Within (terminal, stream, bootstrap), output is contiguous. A bootstrap’s inclusive actor cut covers seq <= base_seq; the first live frame after READY is exactly base_seq + 1. Receivers reject duplicate, decreasing, gapped, wrong-generation, or post-tombstone frames and request a new cut.

For NativeState, bytes are exact PTY bytes. The server, hub, transport, recorder, and client MUST NOT color-downsample, strip images/hyperlinks, scan, or rewrite them. For the two synthesized profiles the compatibility emitter semantics in proto.md §6.2 apply.

FRAME_ACK is valid only for SynthesizedVtStateSync, is cumulative after application, and includes terminal=1, seq=2, stream=3, bootstrap=4. Raw profiles do not ACK; neither bootstrap publication nor raw release waits an RTT.

4.2 Cells, cursor, modes, and engine ownership

Cells, parser continuation, graphics, active screen, history pages, cursor, modes, selections, and semantic anchors are engine-owned, not phux wire structures. A native consumer hands opaque payload bytes directly to the exact selected libghostty codec. phux never scans checkpoint magic/records, READY, FINISH, Page layout, pointers, padding, allocator, or mappings and never synthesizes a native record.

4.3 Bootstrap frames and exact field order

BOOTSTRAP_BEGIN {                 // 0x93
    terminal_id: TerminalId,      // field 1
    stream_id: StreamId,          // field 2
    bootstrap_id: BootstrapId,    // field 3
    codec: BootstrapCodec,        // field 4
    cols: u16,                    // field 5, nonzero
    rows: u16,                    // field 6, nonzero
    output_mode: OutputMode,      // field 7
    base_seq: u64,                // field 8, inclusive cut
}

BootstrapCodec = tagged_union {
    SynthesizedVtV1,              // tag 0
    Native { engine_version: u8 } // tag 1; v2 = 2
}

BOOTSTRAP_CHUNK {                 // 0x94
    terminal_id: TerminalId,      // 1
    stream_id: StreamId,          // 2
    bootstrap_id: BootstrapId,    // 3
    chunk_seq: u32,               // 4, starts at zero and is contiguous
    payload: bytes,               // 5, opaque
}

BOOTSTRAP_READY {                 // 0x95
    terminal_id: TerminalId,      // 1
    stream_id: StreamId,          // 2
    bootstrap_id: BootstrapId,    // 3
    history_cursor: optional<bytes>, // 4, absent = no retained history
}

The BEGIN codec/output_mode pair admits exactly: native-v2 + Raw, synthesized-v1 + Raw, or synthesized-v1 + StateSync. Native + StateSync is malformed. It MUST also match the connection’s HELLO_OK profile.

Chunks may split engine records arbitrarily; chunk_seq orders fragments, not records. Native payload integrity and grammar validation belong exclusively to libghostty. BOOTSTRAP_READY is emitted only after all bytes through the selected codec’s READY boundary. The client decodes into invisible staging and atomically publishes only when its engine has reached READY and it consumes the matching protocol READY. Reliable ordering permits the first raw live frame immediately afterward; there is no client bootstrap ACK.

4.4 Inclusive actor cut, pane order, and fairness

A producer subscribes/drains first, then asks the Terminal actor for one inclusive cut in the same actor turn. The actor applies all bytes through base_seq, captures authoritative geometry, increments the generation, and returns a bounded immutable lease/COW view. The coordinator discards subscribed duplicates <= base_seq and queues only contiguous output > base_seq, bounded by bytes and age. The PTY is never paused for network delivery.

For a multi-pane ATTACH, send ATTACHED, then one BEGIN per pane in stable snapshot traversal order. Emit bounded chunks round-robin across panes. Each pane’s READY immediately releases that pane’s live queue; a large checkpoint cannot block another pane’s first render. ATTACH_READY follows once all panes are READY or closed. History never blocks live writes or aggregate attach readiness.

4.5 Native client-pull history

These frames are legal only under the negotiated NativeState profile:

HISTORY_REQUEST {                 // 0x16
    terminal_id: TerminalId,      // 1
    stream_id: StreamId,          // 2
    bootstrap_id: BootstrapId,    // 3
    cursor: bytes,                // 4, opaque stable lease capability
    max_bytes: u32,               // 5, requested byte budget
    max_rows: u32,                // 6, requested row budget
}

HISTORY_PAGE {                    // 0x96
    terminal_id: TerminalId,      // 1
    stream_id: StreamId,          // 2
    bootstrap_id: BootstrapId,    // 3
    cursor: bytes,                // 4, echoes request cursor
    next_cursor: optional<bytes>, // 5, next older lease capability
    payload: bytes,               // 6, opaque selected-codec records
    page_seq: u64,                // 7, nonzero
    rows: u32,                    // 8, authenticated history row count
}

HISTORY_TOMBSTONE {               // 0x98
    terminal_id: TerminalId,      // 1
    stream_id: StreamId,          // 2
    bootstrap_id: BootstrapId,    // 3
    cursor: bytes,                // 4, invalidated lease capability
    reason: HistoryTombstoneReason, // 5
}

HistoryTombstoneReason = enum {
    Stale = 0, Pruned = 1, Reset = 2, Resize = 3,
    Expired = 4, Released = 5, Limit = 6, CodecFailure = 7,
}

HISTORY_REJECTED {                // 0x99
    terminal_id: TerminalId,      // 1
    stream_id: StreamId,          // 2
    bootstrap_id: BootstrapId,    // 3
    cursor: bytes,                // 4, not advanced
    reason: HistoryRejectionReason, // 5
    required_bytes: u32,          // 6, nonzero retry minimum
    required_rows: u32,           // 7, nonzero retry minimum
}

HistoryRejectionReason = enum {
    ZeroLimit = 0, TooSmall = 1, Busy = 2,
}

History is newest-to-oldest, at most one request outstanding per logical stream, and lower priority than live/READY-prefix work. Explicit scroll demand outranks prefetch. cursor is a stable engine-owned lease capability, not a page identifier. It is echoed byte-identically by every response in its lineage and is capped at 4 KiB.

page_seq starts at 1 for each (terminal_id, stream_id, bootstrap_id, cursor) and advances with checked addition. A repeated sequence with byte-identical payload, rows, and next_cursor is idempotent. The same sequence with different content, a gap, or sequence exhaustion invalidates that history cursor and requires HISTORY_TOMBSTONE plus a fresh history lease/request; it does not invalidate the live generation.

Clients request nonzero max_bytes and max_rows. Before engine work, the server clamps both budgets to the request, the negotiated byte bound, the 4,096-row protocol bound, and its engine/resource limits. A zero budget, next record too large for either effective budget, or temporary capture contention returns HISTORY_REJECTED without advancing the cursor. Ghostty OutOfSpace maps to TooSmall with exact nonzero required budgets, never to a tombstone. required_bytes cannot exceed the negotiated page bound and required_rows cannot exceed 4,096; a client may clamp and retry the same cursor.

Every response is validated against both effective request budgets before client import. rows is the engine-authenticated number of history rows in the opaque payload and may be zero only when that payload contains exclusively non-row HISTORY_BEGIN/FINISH records. next_cursor = None is legal only on the final page whose payload engine-authenticates FINISH and reaches the beginning of retained history.

Pages are generation-bound and independently engine-validated; applying them MUST NOT change active screen, parser continuation, cursor, modes, raw live sequence, or input. HISTORY_TOMBSTONE invalidates only the exact progressive history cursor and its derived cache. It never retires the BootstrapId or affects live/raw/input state. Stale, pruned, reset, resized, expired, released, bounded-limit, and history-codec failures use its typed reasons. A nearby range is never substituted.

4.6 Tombstone and resume

BOOTSTRAP_TOMBSTONE {             // 0x97
    terminal_id: TerminalId,      // 1
    stream_id: StreamId,          // 2
    bootstrap_id: BootstrapId,    // 3
    reason: TombstoneReason,       // 4
    last_valid_seq: u64,           // 5
}

TombstoneReason = enum {
    RawReplayOverflow = 0,
    OutboundGap       = 1,
    Resize            = 2,
    RelayReconnect    = 3,
    ExplicitReattach  = 4,
    CodecFailure      = 5,
    Other             = 6,
}

The server sends a bootstrap tombstone before any replacement BEGIN. After it, no chunk, READY, history page/status, output, or ACK carrying the old BootstrapId is legal; an already-in-flight history response is fenced rather than emitted after retirement. The client keeps its last published terminal visible until a replacement reaches READY.

Reconnect resumes only when authenticated server incarnation, TerminalId, selected profile/codec/features, stream mapping, BootstrapId, and last contiguous sequence all prove continuity. Otherwise it takes a fresh cut. Sequence gap/wrap, bounded raw/outbound overflow, live engine-integrity failure, authoritative resize, relay reconnect, stale generation, or explicit reattach require a bootstrap tombstone and replacement. Cursor-only history failures use §4.5 statuses and MUST NOT reach this path.

4.7 Resource bounds

HELLO_OK’s max_chunk_bytes and max_history_page_bytes are nonzero, per-axis minima capped at 8 MiB; the hard outer frame cap remains 16 MiB. History row requests/responses are capped at 4,096. Reference advertisements are 256 KiB chunks and 1 MiB pages. Implementations validate response limits before allocation/import and clamp request budgets before engine work. Capture leases, post-cut queues by bytes and age, per-client outbound queues, history jobs, and local caches remain independently bounded. Raw/live overflow uses a bootstrap tombstone; progressive history exhaustion uses HISTORY_REJECTED or HISTORY_TOMBSTONE, never silent loss or unbounded retention.


5. L1 commands

Commands are typed messages, not strings. They are sent over the same connection and correlated via request_id. Commands are partitioned by tier; the server MUST reject (with ERROR { code: INVALID_COMMAND }) any command outside the negotiated tier set (proto.md §6.1).

COMMAND { request_id: u32, cmd: Command }
COMMAND_RESULT { request_id: u32, result: CommandResult }

CommandResult = tagged_union {
    OK,
    OK_WITH(CommandValue),
    ERROR(ErrorCode, str),
}

CommandValue = tagged_union {
    TERMINAL_ID(TerminalId),
    GROUP_ID(GroupId),            // opaque grouping key, not a tier
    STATE(StateSnapshot),
    JSON(str),                    // for structured returns
    BYTES(bytes),                 // for L3 metadata values
    FILE_UPLOAD { next_offset: u64, path: optional<str> }, // tag 0x05 (ADR-0059)
}

A COMMAND is asynchronous: the server MAY emit other messages (including events relevant to the command’s effect) before COMMAND_RESULT. Clients MUST tolerate that ordering.

5.1 L1 command catalog (Terminal substrate)

Command_L1 = tagged_union {
    SPAWN            { cwd: optional<str>, command: optional<list<str>>,
                       initial_size: optional<{cols: u16, rows: u16}>,
                       group: optional<GroupId> },                  // opaque grouping key
    ATTACH_TERMINAL  { terminal_id: TerminalId,                     // tag 0x01
                       role_policy: RolePolicy },                   //   (role_policy not yet encoded; §8.1 default applies)
    DETACH_TERMINAL  { terminal_id: TerminalId },                   // tag 0x02
    KILL_TERMINAL    { terminal_id: TerminalId },
    GET_SCREEN       { terminal_id: TerminalId,                     // tag 0x07
                       request_scrollback: optional<u32>, cells: bool },
    ROUTE_INPUT      { terminal_id: TerminalId, event: InputEvent },// tag 0x08
    KILL_TERMINALS   { ids: list<TerminalId> },                     // tag 0x09
    RESIZE_TERMINAL  { terminal_id: TerminalId, cols: u16, rows: u16 },
    GET_STATE        { scope: StateScope },
    RUN_HOOK         { name: str, args: list<str> },
    GET_TERMINAL_STATE        { terminal_id: TerminalId },          // tag 0x0c
    SUBSCRIBE_TERMINAL_EVENTS { terminal_id: TerminalId },          // tag 0x0d
    UPGRADE,                                                        // tag 0x0e; no payload (ADR-0032)
    ACQUIRE_INPUT    { terminal_id: TerminalId, mode: InputMode,    // tag 0x0f (ADR-0033)
                       ttl_ms: u32 },
    RELEASE_INPUT    { terminal_id: TerminalId },                   // tag 0x10 (ADR-0033)
    SIGNAL_TERMINAL  { terminal_id: TerminalId,                     // tag 0x11 (ADR-0033)
                       signal: TerminalSignal },
    REPORT_ASKED     { terminal_id: TerminalId,                     // tag 0x12 (ADR-0036)
                       id: str, question: str,
                       suggestions: list<str>,
                       elapsed_seconds: optional<u64> },
    APPLY_INPUT      { operation_id: bytes16,                       // tag 0x14 (ADR-0053)
                       terminal_id: TerminalId,
                       events: list<InputEvent> },
    PUT_FILE         { upload_id: bytes16,                          // tag 0x15 (ADR-0059)
                       terminal_id: TerminalId, extension: str,
                       offset: u64, data: bytes, final_chunk: bool,
                       sha256: optional<bytes32> },
    SHUTDOWN,                                                       // tag 0x16; no payload
    REPORT_AGENT_STATE { terminal_id: TerminalId,                   // tag 0x17 (ADR-0085)
                         state: ReportedAgentState },
    GET_PERF         { reset: bool },                               // tag 0x18 (ADR-0096)
    TRANSCRIBE       { upload_id: bytes16, terminal_id: TerminalId }, // tag 0x19
}

InputMode = enum (u8) {            // ACQUIRE_INPUT acquisition mode
    COOPERATIVE = 0,               // grant only if the lease is free
    SEIZE       = 1,               // preempt the current holder
}

TerminalSignal = enum (u8) {       // SIGNAL_TERMINAL signal selector
    INTERRUPT = 0,  FREEZE = 1,  RESUME = 2,  TERMINATE = 3,  KILL = 4,
}

ReportedAgentState = enum (u8) {
    WORKING = 0, BLOCKED = 1, DONE = 2,
}

InputEvent = tagged_union {
    KEY(KeyEvent),       // INPUT_KEY atom (input.md §2)
    MOUSE(MouseEvent),   // INPUT_MOUSE atom (input.md §3)
    FOCUS(FocusEvent),   // INPUT_FOCUS atom (input.md §4)
    PASTE(PasteEvent),   // INPUT_PASTE atom (input.md §5)
}

SPAWN returns a TerminalId and asynchronously emits TERMINAL_OPENED (§9.1). collection is the opaque grouping key from §3.1, not a parent in a lifecycle tier. SPAWN is not role-gated; the creating client becomes PRIMARY for the newly spawned Terminal unless a future revision adds an explicit spawn role.

ATTACH_TERMINAL and DETACH_TERMINAL are per-consumer subscription operations; they do not affect the Terminal’s existence. A Terminal MAY be attached by multiple clients simultaneously; the server multicasts. ATTACH_TERMINAL wires the calling client to receive TERMINAL_OUTPUT with the role and takeover semantics of §8.1.

ATTACH_TERMINAL (tag 0x01) and DETACH_TERMINAL (tag 0x02) shipped in v0.5.0-draft.15 (phux-v45.7) with the following reference semantics. ATTACH_TERMINAL registers the caller as an output subscriber, allocates a nonzero StreamId and fresh BootstrapId, sends the selected profile’s BEGIN/CHUNK/READY sequence, then streams generation-bound TERMINAL_OUTPUT. The subscription also opens attach-scoped INPUT_*; it opens FRAME_ACK only for SynthesizedVtStateSync. A session-scoped ATTACH is not required. Re-attach tombstones any current generation and takes a fresh cut without duplicating the logical stream. It does not resize the Terminal; callers follow with TERMINAL_RESIZE. The role_policy field is not yet encoded, so the existing default remains { requested_role: PRIMARY, takeover: NEVER }. DETACH_TERMINAL drops the caller’s per-Terminal output subscription and its per-Terminal SUBSCRIBE_EVENTS scope; it is idempotent (Ok for unknown Terminals and never-attached callers, so a detach can never race a natural close into an error). This pair is the unit the federation hub relays for two-hop attach (§9.1).

KILL_TERMINAL terminates one Terminal’s underlying PTY and asynchronously emits TERMINAL_CLOSED. It is the single-Terminal destroy verb.

SHUTDOWN (tag 0x16) stops the server process, ending every Terminal it holds. It is the only command whose subject is the server rather than a Terminal or a collection, and it carries no payload.

A server MUST reply COMMAND_RESULT { Ok } before it begins tearing down, and MUST then terminate as if it had been stopped by any other means — every Terminal reaped through the ordinary close path, so consumers observe the lifecycle they already handle rather than a bare transport drop. The connection closes as part of that teardown, so a consumer MUST treat a disconnect following its request as success, not as a failure to stop.

SHUTDOWN is gated on the SHUTDOWN server feature (§6.2 of proto.md); a client MUST NOT send it unless the bit is advertised, because a server that predates the tag drops it silently and “nothing happened” is indistinguishable from “the server declined”.

SHUTDOWN requires both SIGNAL on the Global selector and an owner-authenticated UDS transport under phux-workload/v1 (workload-auth.md §7). The owner UDS receives the operator grant in local policy; a paired UDS needs the same scope explicitly. A remote paired grant cannot stop the server, regardless of its scope bits.

REPORT_AGENT_STATE feeds high-priority lifecycle evidence into the target pane’s detector. It does not write phux.agent/v1; the detector publishes via the ordinary L3 arbiter and the next screen derivation may supersede it. The server retains a report that narrowly precedes its first identity poll for at most 1.5 seconds, then publishes it only if agent identity resolves. idle is not reportable. Clients MUST observe the REPORT_AGENT_STATE feature bit before sending the command.

GET_PERF { reset } returns the server’s in-process performance telemetry as COMMAND_RESULT { OkWith(Json(report)) }. The JSON is a phux_perf::PerfReport carrying its own schema_version, the reporting process id and uptime, a getrusage(2) section, and one entry per metric: a latency or size histogram (non-empty log-linear buckets plus count, sum, min, max), a monotone counter, or a gauge. Metric names are diagnostic and are not part of this contract; a consumer MUST tolerate names appearing and disappearing between servers and MUST NOT fail on an unknown kind. reset = true zeroes every metric after the snapshot is taken, so consecutive resetting requests read as intervals. The command carries no session or Terminal state and is allowed for viewers; a server MAY restrict it by transport. Clients MUST observe the GET_PERF feature bit before sending the command. See ADR-0096.

TRANSCRIBE { upload_id, terminal_id } turns a completed PUT_FILE upload (its final chunk acknowledged, §6.2.2) into text with the server’s configured transcriber and pastes that text into terminal_id as one acknowledged APPLY_INPUT batch under a server-minted operation id, so a reconnect between the reply and the paste cannot double it. The reply is COMMAND_RESULT { OkWith(Json(result)) } where result carries its own schema_version, the text the transcriber returned, pasted (false when the transcriber heard nothing, in which case nothing was written), and timing fields; the JSON field set beyond schema_version/text/pasted is diagnostic. The paste INSERTS and never submits: a consumer shows text and lets the user press Enter. A server with no transcriber configured MUST refuse with INVALID_COMMAND and a remedy naming [voice] transcriber; an unknown or unfinished upload id is refused with INVALID_COMMAND; a transcriber that exceeds its deadline is refused with RESOURCE_EXHAUSTED; a satellite-routed Terminal is refused with UNSUPPORTED_SATELLITE_ROUTE. The transcript is capped at 64 KiB. Clients MUST observe the TRANSCRIBE feature bit before sending the command.

KILL_TERMINALS { ids } (§5.2) is the atomic group-teardown verb.

RESIZE_TERMINAL resizes one Terminal. KILL_TERMINAL, KILL_TERMINALS, and RESIZE_TERMINAL require the caller to be PRIMARY for each target Terminal. A server receiving any of them from a viewer MUST return COMMAND_RESULT { result: ERROR(PERMISSION_DENIED, ...) } and MUST NOT perform the operation. DETACH_TERMINAL and GET_STATE are allowed for viewers.

Status: spec-only. Every role gate in this section — here, on ROUTE_INPUT (§6.2), and on KILL_TERMINALS (§5.2) — depends on the subscription roles of §8.1, which no server implements. The trust boundary that is actually enforced today is the transport’s: one server per user, authenticated peer (proto.md §10).

Three entries in the catalog above are also unallocated:

Status: spec-only. SPAWN, RESIZE_TERMINAL, and RUN_HOOK have no command tag and decode as UnknownEnumValue. Spawning and resizing ride the dedicated SPAWN_TERMINAL / TERMINAL_RESIZE frames (§3.1) instead; hooks are a server-side config surface (docs/consumers/tui.md §9), not a wire verb.

GET_SCREEN, ROUTE_INPUT, APPLY_INPUT, PUT_FILE, GET_TERMINAL_STATE, and SUBSCRIBE_TERMINAL_EVENTS are the live agent affordances; §6 frames them.

ACQUIRE_INPUT, RELEASE_INPUT, and SIGNAL_TERMINAL are the supervisory verbs (ADR-0033, “take the wheel + kill”). ACQUIRE_INPUT asserts an exclusive input lease over a Terminal: while held, only the holder’s input (INPUT_* frames, ROUTE_INPUT, and APPLY_INPUT) reaches the PTY. Blocked fire-and-forget input is dropped; blocked APPLY_INPUT is refused with ERROR(INPUT_LEASE_HELD) before any write. mode = COOPERATIVE grants only if the Terminal is unheld and otherwise replies ERROR(INPUT_LEASE_HELD); mode = SEIZE preempts the current holder. ttl_ms is advisory; the reference server holds the lease until the holder releases it (RELEASE_INPUT) or its connection drops. SIGNAL_TERMINAL delivers a POSIX signal to the Terminal’s process group — distinct from KILL_TERMINAL, which removes the pane; SIGNAL_TERMINAL signals the process and leaves the pane addressable. FREEZE (SIGSTOP) / RESUME (SIGCONT) is the reversible brake. Each lease change and signal broadcasts a terminal_control event (§6.5) to every subscriber.

5.2 KILL_TERMINALS — atomic group teardown

KILL_TERMINALS { ids: list<TerminalId> }   // tag 0x09

Wire body: a u16 count followed by that many tagged TerminalIds (field-tagged TLV, per appendix-encoding.md).

KILL_TERMINALS is the one irreducible group operation. A consumer-side projection cannot tear down N Terminals atomically: killing them one at a time exposes intermediate states and races a concurrent observer. The server applies the whole list under its single std::sync Mutex<ServerState> lock in one acquisition — all-or-nothing for a local server (cross-host atomicity is out of scope). No observer sees a partial teardown.

The reply is COMMAND_RESULT { OK }, issued once the teardown is committed under the lock; the per-Terminal TERMINAL_CLOSED notifications follow asynchronously as each PTY reaps (a COMMAND MAY interleave other frames before its result, §5). The caller MUST be PRIMARY for every Terminal in ids; if any target is not owned or not found, the server rejects the whole command with ERROR { PERMISSION_DENIED } or ERROR { TERMINAL_NOT_FOUND } and kills nothing.

This verb replaces the former collection-teardown command. “Kill this group” is a client-assembled id list, not a named server-side entity. The CLI verb phux kill SESSION resolves the session’s member ids from L3 metadata (L3.md) and issues one KILL_TERMINALS.

5.3 Group create and rename are not L1

There is no CREATE_SESSION, KILL_COLLECTION, or RENAME_SESSION command. Group create and rename are not wire lifecycle; they decompose:

  • Create a group: SPAWN_TERMINAL (one or more) plus an L3 metadata write recording membership and a name. The conventions — the well-known keys (phux.session.create/v1, read back via phux.session.created/v1) and the name key (phux.session.name/v1) — are owned by L3.md.
  • Rename a group: an L3 metadata SET on the name key. See L3.md.
  • Kill a group: KILL_TERMINALS (§5.2).

User-facing UX is unchanged: the CLI, MCP adapter, and TUI present create / rename / kill as before; the work moves from a wire verb to client logic plus L3 metadata. The structured agent verb contracts are owned by ../consumers/agents.md.

5.4 What is deliberately absent

The protocol exposes no string-based command DSL, no expression evaluator, no formatting language. Commands are an enum. Strings appear only as user-supplied names, paths, and arguments. This is documented in ADR-0013 (which supersedes ADR-0002) and CONTRIBUTING.md.


6. Live agent affordances (engine convenience)

GET_SCREEN, ROUTE_INPUT, GET_TERMINAL_STATE, SUBSCRIBE_TERMINAL_EVENTS, and the EVENT push frame let a consumer read and drive a Terminal by id without attaching, subscribing for output, or resizing. They are engine-derived conveniences over the shared engine, not a normative structured contract (ADR-0030).

These commands return engine-derived snapshots a consumer could also compute locally if it ran its own engine. They exist so a consumer that has not yet adopted the carry-your-own-engine pattern can still read structured state, not to make structured terminal state a normative wire type system. New structured surfaces SHALL NOT be added to the wire; they belong in the projection. The authoritative, versioned structured agent contract is a local projection exposed through the CLI and its JSON schema (ScreenState v3, RunResult, WaitOutcome), owned by ../consumers/agents.md. The reference projection consumer is phux-web (ADR-0025): it runs ghostty-vt.wasm, speaks this exact wire codec over WebSocket, and projects locally. An agent SDK should copy that shape — carry your own engine — rather than treat these affordances as the contract.

6.1 GET_SCREEN (0x07)

GET_SCREEN reads a Terminal’s current viewport as structured data with no side effects: the server walks its own emulator grid and replies COMMAND_RESULT { OK_WITH(JSON(..)) } carrying a ScreenState ({ schema_version, pane, cols, rows, cursor?, lines[], scrollback[], cells? }). Unlike ATTACH_TERMINAL, it neither subscribes the caller nor resizes the Terminal, so it is safe to poll against a pane other clients are using. It is the read floor of the agent surface (ADR-0022). Allowed for viewers.

request_scrollback selects history above the viewport: absent (None) reads the viewport only; Some(0) reads all retained history rows; Some(n) reads the most-recent n history rows (those nearest the viewport). Requested history lands in ScreenState.scrollback[] (oldest first, right-trimmed); the viewport lines[] are unchanged. Walking history is side-effect-free — the server reads history cells in place and does not scroll the live viewport.

The cells flag (default false, wire-additive) requests the per-cell projection. When true, the reply’s ScreenState carries the additive cells[] array: one entry per viewport cell that has a non-default style or an OSC-133 semantic mark, in row-major order, skipping wide-cell tails — a sparse list, so a mostly-blank grid emits little. Each entry is { col, row, semantic?, style: { bold, faint, italic, underline, blink, inverse, invisible, strikethrough, overline, fg, bg } }. semantic is present only for shell-integration input / prompt cells (OSC-133 ;B / ;A); command output and unmarked cells omit it. fg / bg are tagged { kind: "default" | "palette" | "rgb", ... }, distinguishing the terminal default from an explicit palette index or truecolor triple. When cells is false the field is absent (None). ScreenState.schema_version is 3 once cells[] is part of the contract; both scrollback[] and cells are serde-default, so an older consumer reading a v3 reply ignores the extra keys.

6.2 ROUTE_INPUT (0x08)

ROUTE_INPUT delivers an already-built InputEvent (the same key / mouse / focus / paste atom carried by the INPUT_* frames, input.md) to terminal_id without an ATTACH_TERMINAL, subscription, or resize. It is the write counterpart to GET_SCREEN: the server feeds the event straight into the Terminal’s input pipeline, so — unlike the attach-then-INPUT_KEY path, which advertises a viewport and transiently resizes the Terminal — the live session keeps its dimensions.

The reply is COMMAND_RESULT { OK }, ERROR { TERMINAL_NOT_FOUND } for an unknown id, or ERROR { PERMISSION_DENIED } when the caller is not PRIMARY for the target Terminal (input.md §8). Input is fire-and-forget (input.md §8): if the Terminal’s input mailbox is full the event is dropped, but the command still acks OK (the event was accepted for delivery). Allowed for primaries; the read-only GET_SCREEN remains the viewer-safe surface.

6.2.1 APPLY_INPUT (0x14)

APPLY_INPUT is the acknowledged, reconnect-safe counterpart to ROUTE_INPUT. It is available only when HELLO_OK.server_caps.features contains ACKNOWLEDGED_INPUT (proto.md §6.2). A client MUST NOT probe an older server by sending the unknown command.

Its nested positional command body is:

u8       command_tag = 0x14
bytes16  operation_id
TerminalId terminal_id
u16-BE   event_count
InputEvent[event_count]

operation_id MUST be a non-zero 128-bit value generated from a CSPRNG and MUST identify one immutable { terminal_id, events } payload. A batch MUST contain 1..=256 events. The complete nested command body and the resulting encoded PTY byte vector MUST each be at most 65,536 bytes. A server MUST reject an over-limit body before allocating its event list or handing input to a pane.

The command is local-terminal only in this version. A SATELLITE target MUST return ERROR(UNSUPPORTED_SATELLITE_ROUTE) without forwarding any event.

For a new operation, the server MUST resolve input authority, capture one terminal-mode snapshot, and validate and encode every event before handing off any bytes. It MUST reject an unsafe paste, failed event encoding, or authority failure for the whole batch. It then submits the combined bytes as one ordered PTY-writer job through a bounded queue. No other input job may interleave inside that byte vector. If the pane has no PTY, or the bounded writer queue cannot accept the job (full or already closed), no writer thread ever observes the job and the result is INPUT_NOT_WRITTEN (phux-w7z2.60): the server MUST make this determination without ever invoking the platform write call, and the queue MUST NOT grow without bound.

COMMAND_RESULT { OK } means write_all and flush completed on the PTY master and the server retained the operation’s dedupe record. It does not mean the foreground program consumed the bytes or a shell command completed. ERROR(INPUT_DELIVERY_UNKNOWN) means a writer thread attempted the write and it failed, its completion was lost, or the bounded completion wait expired after handoff to a writer that received the job; some or all bytes MAY have reached the PTY. A client MUST NOT present that result as delivered, and MUST NOT resubmit the operation under any id.

ERROR(CANONICAL_LIMIT_EXCEEDED) (phux-mjmc) means the write was refused with zero bytes written: at write time the pane’s line discipline was in canonical (ICANON) mode and the batch’s combined encoded PTY bytes contained a line — a run between two terminators, or from the last terminator to the end of the bytes — longer than the pane’s canonical-line byte limit, named in the error message. Writing such a payload would silently truncate at the kernel’s canonical-queue boundary instead of delivering it, and if the payload’s own terminator falls past the truncation point, the terminator is dropped too, permanently wedging the line. Unlike INPUT_DELIVERY_UNKNOWN, delivery here is known, not merely unconfirmed: the server never called write_all. A client MUST NOT retry the identical operation expecting a different outcome — the payload itself must change (split into newline-terminated lines, sized under the limit, or sent after the pane leaves canonical mode) — but MAY reuse ROUTE_INPUT/INPUT_* framing for line-oriented input, which this guard does not reject as long as each line stays under the limit.

ERROR(INPUT_NOT_WRITTEN) (phux-w7z2.60) means zero bytes were written and the server can prove it: the pane has no PTY, the bounded writer queue was full or its channel already closed, the pane’s own actor was already gone before the batch reached its mailbox, or the operation was refused, or its registration abandoned, at any other point strictly before a writer thread took the job. Unlike INPUT_DELIVERY_UNKNOWN, no writer ever attempted the write for this result. A client MAY resubmit the identical operation under the same id (idempotent, per the caching rule below) or under a fresh id (safe because nothing already written exists to duplicate) — the two ERROR codes exist precisely so a client does not have to guess which recovery an opaque INTERNAL_ERROR would have meant.

The server retains the canonical payload digest and final OK, INPUT_DELIVERY_UNKNOWN, CANONICAL_LIMIT_EXCEEDED, or INPUT_NOT_WRITTEN result by operation id for ten minutes, bounded to 65,536 entries. A same-id, same-payload retry inside that horizon MUST return the cached result without writing again. Same-id, different-payload reuse MUST return ERROR(INVALID_COMMAND). A pre-handoff refusal retains the id-to-digest binding but not the refusal result: the unchanged operation MAY be evaluated again after its cause is repaired, while changed input requires a new id. INPUT_NOT_WRITTEN spans both cases — a full or closed writer queue and a pane with no PTY are decided at handoff and so are cached final results like OK; every other INPUT_NOT_WRITTEN cause (a full or closed input lane, the pane’s actor gone, or its completion tracking unavailable) is decided before handoff and so is not — but a client’s recovery is identical either way: resubmitting is always safe, under the same id or a fresh one.

The reference server admits at most one unresolved acknowledged operation per Terminal. A concurrent operation against the same Terminal, or a full input lane, is refused immediately with ERROR(RESOURCE_EXHAUSTED); the server MUST NOT retain additional caller payloads outside its bounded lane while waiting for PTY completion. Operations against distinct Terminals proceed concurrently, and one Terminal’s unresolved write MUST NOT delay input to another Terminal. The completion wait is bounded at five seconds, so a caller’s own deadline for the submit leg must exceed that plus lane-queue time, and a backoff schedule for RESOURCE_EXHAUSTED must tolerate a same-Terminal retry interval that long.

If a connection closes before the matching result, the client MAY reconnect and resend the unchanged operation with a new connection-local request_id only when HELLO_OK.server_id is unchanged and the ten-minute horizon has not elapsed. If the server incarnation changed or the horizon elapsed, delivery is unknown and the client MUST NOT replay automatically. This bounds idempotency to one server incarnation while making loss of volatile dedupe state explicit (ADR-0053).

6.2.2 PUT_FILE (0x15)

PUT_FILE transfers a local artifact to the host that owns terminal_id without routing bytes through the PTY. It is available only when HELLO_OK.server_caps.features contains FILE_UPLOAD (proto.md §6.2). A client MUST NOT probe an older server by sending the unknown command.

Its nested positional command body is:

u8         command_tag = 0x15
bytes16    upload_id
TerminalId terminal_id
str        extension
u64-BE     offset
bytes      data
u8         final_chunk
u8         sha256_present
bytes32?   sha256

upload_id MUST be a non-zero 128-bit value generated from a CSPRNG and MUST identify one immutable file. extension MUST contain 1..=16 ASCII alphanumeric bytes without a dot. data MUST be at most 8 MiB and offset + len(data) MUST be at most 64 MiB. A non-final chunk MUST contain at least one byte and no digest. A final chunk MUST carry the expected SHA-256 of the complete file; an empty final chunk is valid when the previous chunk ended on a boundary.

terminal_id selects the destination host and MUST resolve to a live Terminal. For SATELLITE { host, id }, a federation hub rewrites the id to LOCAL and relays the command; the satellite performs all storage and digest work. The client supplies no destination path. The terminal-owning server writes only under its mode-0700 upload sandbox and chooses phux-upload-<upload_id>.<extension> as a mode-0600 final name.

The server MUST reject an offset gap. When an offset overlaps retained bytes, the overlap MUST match exactly; the server appends only the unseen suffix. This makes a same-id/same-offset retry idempotent across reconnect and server restart. Same-id/different-byte or changed-extension reuse is ERROR(INVALID_COMMAND).

Every retained chunk replies:

COMMAND_RESULT {
    OK_WITH(FILE_UPLOAD {
        next_offset: u64,
        path: optional<str>,
    })
}

next_offset is the complete contiguous byte count the server retained. path MUST be absent until a final digest matches. Before returning a path the server MUST flush and sync the partial file, atomically rename it to the final name, and return that absolute path. A digest mismatch is ERROR(INVALID_COMMAND) and leaves no final path visible. I/O failure is ERROR(INTERNAL_ERROR); a size limit is ERROR(RESOURCE_EXHAUSTED).

Completed files remain until explicit user cleanup. A server MUST NOT silently delete a completed path on a timer while a terminal program may still consume it. Payload bytes and completed paths MUST NOT appear in routine telemetry (ADR-0059).

6.3 GET_TERMINAL_STATE (0x0c)

GET_TERMINAL_STATE returns an engine-derived snapshot of a single Terminal’s non-grid state (the server’s view of its libghostty_vt::Terminal metadata) with no side effects: it does not attach, subscribe, or resize. Like GET_SCREEN, the returned structure is a convenience snapshot, not a normative wire type; a carry-your-own-engine consumer reads the same facts from its local engine. The reply is COMMAND_RESULT { OK_WITH(JSON(..)) } or ERROR { TERMINAL_NOT_FOUND }. Allowed for viewers.

6.4 SUBSCRIBE_TERMINAL_EVENTS (0x0d)

SUBSCRIBE_TERMINAL_EVENTS registers the calling client for pushed EVENT frames (§7) scoped to one Terminal, without attaching, subscribing for output, or sending a snapshot. It is a pure push registration, so an agent can watch a Terminal without disturbing the live session. The server-scoped subscription variant is SUBSCRIBE_EVENTS (§7).


7. Agent event stream

The push half of the live agent affordances. A client subscribes to a stream of extensible tagged lifecycle / activity events, and the server pushes EVENT frames (0xB3) as those events occur. This is an additive accelerator of the CLI-side poll-floor wait (which ships over GET_SCREEN, §6.1): match conditions stay evaluated client-side, but an event wakes the waiter immediately instead of on the next poll tick, cutting latency without changing correctness. A consumer that ignores the stream entirely still converges via polling. Per §6, this is a convenience accelerator, not a normative structured contract.

Wire bodies (field-tagged TLV, per appendix-encoding.md; fields listed in field-id order, leaf primitives and nested unions positional within each field):

SUBSCRIBE_EVENTS { terminal: optional<TerminalId> }   // 0x41
EVENT            { terminal: optional<TerminalId>, event: AgentEvent }  // 0xB3

SUBSCRIBE_EVENTS.terminal scopes the subscription: Some(id) delivers only that Terminal’s events (equivalent to SUBSCRIBE_TERMINAL_EVENTS, §6.4); None delivers every event the server emits for any Terminal the client may observe (server-scoped), including pane_spawned / pane_closed across the attached group. Subscription is idempotent (re-subscribing the same scope is a no-op) and is implicitly torn down on detach, matching SUBSCRIBE_METADATA (an explicit UNSUBSCRIBE_EVENTS is future work). Subscribing does not attach, resize, or send a snapshot.

EVENT.terminal identifies the Terminal the event concerns, or None for a server-scoped event with no single owning Terminal.

7.1 AgentEvent taxonomy

AgentEvent is an extensible tagged union, encoded TLV: a tag: u8 followed by a length-prefixed body: bytes. The length prefix is the forward-compatibility lever — a decoder that does not recognise tag reads (and skips) the declared body length and surfaces the event as an opaque Unknown { tag, body }, so a later minor version MAY add event kinds and an older client skips them cleanly rather than failing the frame parse. Tags are allocated sequentially:

TagVariantBody
0x00command_started(empty)
0x01command_finishedexit_code: optional<i32>
0x02title_changedtitle: str
0x03bell(empty)
0x04pane_spawned(empty; id on envelope)
0x05pane_closedexit_status: optional<i32>
0x06dirty(empty)
0x07idle(empty)
0x08terminal_controllifecycle: TerminalLifecycle, exit_status: optional<i32>, input_holder: optional<ClientId>, action: ControlAction, actor: optional<ClientId>
0x09askedfield-tagged TLV (below)
0x0acwd_changedcwd: str

terminal_control (ADR-0033) is the supervisory broadcast: emitted on every input-lease change and process-lifecycle transition. lifecycle is RUNNING = 0 \| FROZEN = 1 \| EXITED = 2; action names what happened (acquired = 0, seized = 1, released = 2, interrupted = 3, frozen = 4, resumed = 5, terminated = 6, killed = 7, exited = 8); input_holder is the client that now holds the wheel (or absent for Open); actor is the client that performed the action (absent for server-driven transitions). Unlike the grid-activity events, it bypasses the SUBSCRIBE_TERMINAL_EVENTS type filter — every subscriber receives it, since “who has the wheel” and “frozen” are not grid activity.

Unlike the other bodies (positional), the asked (0x09) body is itself field-tagged TLV — each field is field_id: varint || wire_type: u8 || length-delimited value, read by id and skipped-by-length when unrecognised — so its suggestion list and optional elapsed counter are additive:

FieldIdTypeNotes
id1strstable id the answer correlates against
question2strthe question text presented to the human
suggestion3strone suggested answer; repeated, in order; absent for none
elapsed_seconds4u64optional; absent field = 0 / unknown

asked (phux-2sl6) is the control-plane carrier for an agent’s pending human-answerable question — emitted when an agent blocks for input so a projection consumer can render the waiting prompt without re-deriving it from the grid. It mirrors the consumer-side question model one-for-one. REPORT_ASKED is the explicit hook source selected by ADR-0036; the phux-ask title sentinel remains the low-friction OSC source. Both converge on the same asked event payload.

Event sourcing (reference server):

  • pane_spawned / pane_closed — sourced from the Terminal lifecycle. pane_spawned is emitted for every Terminal the server creates, not only those a client asked for by name: a SPAWN_TERMINAL adding a pane to an existing session, and equally the seed pane of a newly created session — whether that session came from ATTACH { CreateIfMissing } (§3.1) or from the headless phux.session.create/v1 L3 write (L3.md §3.1), which creates without attaching. A server-scoped subscriber therefore observes session creation, not merely the creations it happened to request. pane_closed is sourced from the PTY-EOF / KILL_TERMINAL path that emits TERMINAL_CLOSED (§3.1), and its exit_status carries the same value as TERMINAL_CLOSED.exit_status.
  • title_changed — sourced from libghostty’s OSC 0 / OSC 2 title tracking; the server polls the Terminal title after each PTY chunk and emits on change.
  • bell — sourced from a BEL (0x07) in the PTY byte stream; the control-plane counterpart to the BELL frame (0xB0).
  • dirty / idle — sourced from the per-pane state-sync tick’s dirty flag. The server coalesces: at most one dirty per active output burst, then one idle when the grid has settled across an idle window.
  • asked — sourced from explicit REPORT_ASKED hook commands or the phux-ask OSC title sentinel. Both are coalesced by consumers as the same pending human-answerable question payload.
  • command_started / command_finished — sourced from OSC-133 semantic prompt marks, which the reference server scans directly out of the raw PTY byte stream (a small stateful scanner that survives marks split across read chunks; the grid cell-semantic projection does not retain them). command_started is emitted from the C (command-executed) mark; command_finished from the D (command-end) mark, with command_finished.exit_code = Some(n) when the shell integration reported one (OSC 133 ; D ; n ST) and None otherwise. Both require the pane’s shell to emit OSC-133 marks at all — a bare shell produces neither.
  • cwd_changed — sourced from the kernel cwd of the PTY child process (the same query defaults.cwd-inheritance uses: /proc/<pid>/cwd on Linux, proc_pidinfo(PROC_PIDVNODEPATHINFO) on macOS), polled at OSC-133 D prompt boundaries and when an output burst settles (idle), and coalesced: emitted only when the directory differs from the last observation. A consumer seeds its view from the ATTACHED snapshot’s TerminalInfo.cwd (the spawn cwd) and refines from this stream; a pane that never produces output after a cd keeps the seed until its next prompt boundary.

8. State publication on attach

An ATTACH carries field-tagged TLV in this order:

ATTACH {
    target: AttachTarget,             // field 1
    viewport: ViewportInfo,           // field 2
    request_scrollback: bool,         // field 3
    scrollback_limit_lines: u32,      // field 4
    attach_id: u32,                   // field 5, client correlation
}

ATTACHED {
    snapshot: SubstrateSnapshot,      // field 1; metadata/graph only
    initial_client_id: ClientId,      // field 2
    attach_id: u32,                   // field 3, echoes ATTACH
}

ATTACH_READY { attach_id: u32 }       // 0x83, field 1

AttachTarget::Last is server-resolved. A live most-recently-touched session MUST win. When the server has no touch history, but was started with a configured seed session, Last MUST resolve to that seed while it remains live. The configured identity is the server’s effective seed name (after any server-side or launch-time template resolution), not a name reconstructed by the attaching client. If there is touch history but no touched session remains live, or if an untouched server has no live configured seed, the server MUST return ERROR { code: SESSION_NOT_FOUND }.

Last is lookup-only. A server MUST NOT create or recreate a session while resolving it, and a client MUST NOT reinterpret SESSION_NOT_FOUND as permission to retry with CreateIfMissing. Creation remains an explicit caller choice expressed by AttachTarget::CreateIfMissing.

AttachTarget and SubstrateSnapshot retain their existing tagged/positional layouts. ATTACHED carries no terminal content. For every Terminal in stable snapshot traversal order the server allocates a nonzero StreamId and fresh BootstrapId, then emits §4 BEGIN/CHUNK/READY. Chunk turns are round-robin. Per-pane live output begins immediately after that pane’s READY. Once every pane has reached READY or closed, the server sends matching ATTACH_READY. History pages are pulled after READY and never delay ATTACH_READY.

The client stages each pane invisibly and publishes it at matching READY. It publishes the aggregate attachment only at ATTACH_READY, while panes already published remain eligible for live updates. request_scrollback permits the server to include a READY cursor and the client to issue bounded page requests; it does not make history part of attach readiness. scrollback_limit_lines is a client retention preference, not permission to parse native history.

Sequence continuity is explicit rather than inferred: BEGIN declares the inclusive base_seq and the first live output is exactly base_seq + 1. Reconnect uses §4.6 proof or a fresh generation; no snapshot-era implicit base or stale output survives.

8.1 Terminal roles and takeover policy

Status: spec-only. No part of this section is implemented. phux-server has no role state, RolePolicy is not encoded on either ATTACH or ATTACH_TERMINAL, and every subscription behaves as an unconstrained primary — the PERMISSION_DENIED refusals named in §5.1 and input.md §8 never fire. Read the MUSTs below as the contract role enforcement will have to satisfy, not as behavior to test against a live server. The default this section specifies, { PRIMARY, NEVER }, is also the shape that would make every existing ATTACH_TERMINAL caller — including the observer the recorder uses (ADR-0060) — start failing against an attached TUI the day it lands, so landing it means giving observers an explicit VIEWER request first.

Every client-to-Terminal subscription has a TerminalRole chosen by RolePolicy on ATTACH and ATTACH_TERMINAL. Roles are per Terminal, not per transport and not per group. A client attached to multiple Terminals MAY be PRIMARY for one and VIEWER for another.

RolePolicy {
    requested_role: TerminalRole,
    takeover: TakeoverPolicy,
}

TerminalRole = enum {
    PRIMARY = 0,
    VIEWER  = 1,
}

TakeoverPolicy = enum {
    NEVER      = 0,  // fail rather than displace an existing primary
    DELIBERATE = 1,  // explicitly displace an existing primary
}

The server MUST maintain at most one PRIMARY subscription per Terminal at a time. Any number of VIEWER subscriptions MAY coexist. Both roles receive the Terminal’s output, snapshots, and terminal-originated events, subject to the usual tier and subscription rules. Only PRIMARY may send Terminal input or terminal-mutating commands (input.md, §5.1 above).

When requested_role = VIEWER, takeover MUST be NEVER; non-NEVER takeover on a viewer attach is invalid and MUST be rejected with ERROR { code: MALFORMED_MESSAGE } for ATTACH or COMMAND_RESULT { result: ERROR(INVALID_COMMAND, ...) } for ATTACH_TERMINAL.

When requested_role = PRIMARY and no primary exists for a target Terminal, the server grants PRIMARY. When a primary already exists:

  • If takeover = NEVER, the server MUST reject the request with ERROR { code: ALREADY_ATTACHED } for ATTACH or COMMAND_RESULT { result: ERROR(ALREADY_ATTACHED, ...) } for ATTACH_TERMINAL. No subscription role changes.
  • If takeover = DELIBERATE, the server MUST transfer primary status to the requester. The displaced client remains attached as VIEWER unless server policy requires exclusive-primary eviction; in that case the server MUST send DETACHED { reason: REPLACED } before closing that client’s transport.

Takeover is explicit: servers MUST NOT infer it from a second PRIMARY attach, repeated ATTACH_TERMINAL, terminal focus, or transport reconnect. Clients implementing a watch-only UI SHOULD request VIEWER; clients implementing an interactive handoff SHOULD request PRIMARY with DELIBERATE only in response to a user or operator action.

For ATTACH targets that resolve to multiple Terminals (for example a named group), the same RolePolicy applies independently to each Terminal. The server MUST apply the policy atomically for the attach: if any target Terminal would reject the requested role, the whole ATTACH fails and no Terminal role changes. ATTACH_TERMINAL is scoped to a single Terminal and fails or succeeds independently.

RolePolicy is encoded as an additive field on both ATTACH and ATTACH_TERMINAL. If absent, decoders MUST behave as if RolePolicy { requested_role: PRIMARY, takeover: NEVER } had been sent. This preserves the default that an interactive attach is the input-capable client, while making watch-only and deliberate-takeover semantics explicit for clients that need them.


9. Terminal lifecycle event frames and viewport

9.1 Terminal lifecycle event frames

Status: spec-only. TERMINAL_OPENED has no codec entry; a consumer learns about a new Terminal from the TERMINAL_SPAWNED reply to its own SPAWN_TERMINAL (§3.1), which is why nothing observes Terminals it did not create. The wide ExitStatus union below is likewise unbuilt: TERMINAL_CLOSED ships the compact exit_status: optional<i32> of §3.1, so a signal kill is indistinguishable from an unknown cause.

TERMINAL_OPENED {
    terminal_id: TerminalId,
    initial_size: { cols: u16, rows: u16 },
    cwd: str,
    command: list<str>,
}

TERMINAL_CLOSED {
    terminal_id: TerminalId,
    exit_status: optional<ExitStatus>,
}

ExitStatus = tagged_union {
    EXITED(u8),     // process called _exit(n)
    SIGNALED(u8),   // killed by signal n
    UNKNOWN,
}

TerminalId is a tagged union per ADR-0016, federation-routable like every other identity in the protocol:

TerminalId = tagged_union {
    LOCAL     { id: u32 },              // tag = 0
    SATELLITE { host: str, id: u32 },   // tag = 1; federation routing (ADR-0007)
}

A non-federated server only ever constructs LOCAL. Decoders MUST accept the SATELLITE tag; a server that is not configured as a federation hub (or a hub whose satellite registry has no entry for host) MUST respond with an ERROR { code: UnsupportedSatelliteRoute } (proto.md §9) rather than failing the frame.

Satellite routing on a federation hub (ADR-0007 §4). A server started as a hub (phux server --hub) relays frames whose TerminalId carries the SATELLITE tag over the hub’s outbound link to the named satellite:

  • Outbound leg. The hub rewrites SATELLITE { host, id } to LOCAL { id } and forwards the frame verbatim over host’s link — the hub never re-encodes VT bytes or input payloads (opaque relay). This covers the per-terminal COMMAND catalog (GET_SCREEN, ROUTE_INPUT, KILL_TERMINAL, GET_TERMINAL_STATE, SUBSCRIBE_TERMINAL_EVENTS, ACQUIRE_INPUT, RELEASE_INPUT, SIGNAL_TERMINAL, REPORT_ASKED; the SATELLITE-tagged ids inside a KILL_TERMINALS batch are partitioned per host and relayed as per-satellite batches), INPUT_*, HISTORY_REQUEST, StateSync-only FRAME_ACK, TERMINAL_RESIZE, and terminal-scoped SUBSCRIBE_EVENTS. COMMAND.request_id never crosses the link: the hub allocates its own link-side id space and correlates the reply back to the consumer’s original request_id.

  • Return leg. Responses and subscribed stream frames arriving from the satellite (COMMAND_RESULT, correlated ERROR, EVENT, BEGIN/CHUNK/READY, HISTORY_PAGE, HISTORY_TOMBSTONE, HISTORY_REJECTED, BOOTSTRAP_TOMBSTONE, TERMINAL_OUTPUT, TERMINAL_CLOSED, BELL) are re-tagged LOCAL { id } to SATELLITE { host, id }. Checkpoint/history/cursor/live payload bytes remain byte-identical. A satellite-originated SATELLITE tag is dropped: routes never chain.

  • Reachability. A relayable frame for a satellite whose link is down, still dialing, or refused fail-closed (ADR-0038) fails fast with ERROR { code: SatelliteUnreachable } — never an indefinite wait. When a satellite link drops, the hub fails every in-flight relayed command with SatelliteUnreachable and pushes one un-correlated ERROR { code: SatelliteUnreachable } to each consumer holding a proxy subscription to that satellite’s terminals, then clears those subscriptions; consumers re-subscribe after the link recovers. The bound holds even when the link looks up: every relayed COMMAND carries a hub-side deadline, so a silently partitioned satellite (no FIN/RST) or one that accepts frames but never answers still resolves as SatelliteUnreachable rather than waiting forever, and the hub MUST enforce a keepalive / idle-timeout contract on every link transport (QUIC’s transport-level keep_alive / max_idle_timeout; hub-originated pings plus an inbound-idle limit on WebSocket) so a silent partition is detected and torn down like an ordinary disconnect.

  • Two-hop attach. The hub relays ATTACH_TERMINAL atomically with proxy registration; an error rolls registration back. The satellite remains codec authority. The hub owns only connection-local StreamId/BootstrapId mapping, generation/watermark tracking, bounds, and lifecycle: it re-tags TerminalId, maps each downstream tuple to one upstream tuple, and forwards opaque BEGIN/CHUNK/READY/history/live payloads without decoding or rewriting them. The READY fence and contiguous base_seq + 1 live release survive the hop. On mailbox overflow, sequence gap, or link loss the hub sends a downstream tombstone before any replacement; it never lets later live bytes bypass a missing prefix. A relay reconnect always takes a fresh upstream cut and maps a fresh downstream BootstrapId unless the complete §4.6 resume proof holds. Each consumer is isolated: one slow proxy cannot stall the satellite link or another consumer. TERMINAL_CLOSED and BELL may pass a generation gate because they are independent lifecycle/ephemeral signals.

    Attach opens satellite INPUT_*; it opens FRAME_ACK only for negotiated SynthesizedVtStateSync. Native and synthesized-raw ACKs are rejected. The hub resolves DETACH_TERMINAL locally and tears down the shared upstream subscription only when its last proxy subscriber leaves, including on consumer disconnect.

  • Input-lease aliasing (phux-v45.7). Every hub consumer reaches a satellite through the link’s single client identity, so the satellite’s ADR-0033 lease map cannot distinguish them. The hub MUST therefore enforce lease exclusion between its own consumers itself: it keeps a per-(host, terminal) ledger of which hub consumer holds the relayed lease, refuses a cooperative ACQUIRE_INPUT (and ROUTE_INPUT / INPUT_*) from a non-holder without touching the link, and treats a non-holder’s RELEASE_INPUT as the idempotent no-op Ok without forwarding it — forwarding would release the real holder’s satellite-side lease. A SEIZE takeover (ACQUIRE_INPUT { mode: SEIZE }) from a different hub consumer preempts the prior holder in the ledger, and the hub MUST notify that evicted holder — a re-tagged TerminalControl { action: SEIZED, input_holder: <new holder> } event (§9.1) delivered to it directly, mirroring the local takeover’s broadcast (phux-v45.13). The satellite cannot raise this notice itself: the relayed SEIZE arrives under the shared link identity, so its own lease change reads as a same-identity re-acquire and names the link, not the evicted hub consumer. Without the hub-issued notice the prior holder would keep believing it holds the wheel while its relayed INPUT_* is silently dropped at the ledger gate. The relayed lease (held by the link identity) keeps excluding the satellite’s own local clients; the holder’s disconnect relays a RELEASE_INPUT so both sides converge. Known limitation, accepted deliberately: the satellite-side lease is connection-scoped, so a link drop/redial releases it on the satellite while the hub-side ledger persists — exclusion among hub consumers is therefore preserved across link churn, but a satellite-local client can acquire the freed satellite-side lease in that window. Splitting hub consumers into per-consumer sub-identities over the link would close that window and is deferred with the rest of the link-identity work (ADR-0038).

  • Aggregated LIST (GET_STATE). On a hub, GET_STATE { scope: SERVER } returns the hub’s local snapshot plus every dialed satellite’s terminals: the hub relays GET_STATE { scope: SERVER } over each link (links queried concurrently, each bounded by the per-command relay deadline above) and appends the returned panes re-tagged LOCAL { id }SATELLITE { host, id }. Only terminals aggregate: session and window identities are not federation-routable (ADR-0016 makes TerminalId the wire primary), so the satellite’s sessions / windows lists and focus fields are discarded — their u32 ids would collide with the hub’s own. A satellite pane’s window_id is therefore passed through verbatim: it is satellite-local, resolvable only on the satellite, and has no entry in the merged snapshot’s windows list; consumers MUST NOT join it against the hub’s windows and SHOULD group satellite terminals by the host in their TerminalId. cols / rows / title / cwd are relayed verbatim from the satellite’s snapshot (the hub synthesizes nothing). Per-satellite degradation: a satellite that is unreachable (or answers with an error) contributes an empty set and MUST NOT fail the aggregate; the hub instead pushes one un-correlated ERROR { code: SatelliteUnreachable } naming the host to the requesting consumer before the COMMAND_RESULT — the same observable-degradation shape as the proxy-subscription teardown notification — and the merged snapshot simply lacks that host’s terminals. A SATELLITE-tagged id in a satellite’s own list is dropped (no chaining, as above).

  • Satellite-targeted spawn (SPAWN_TERMINAL.satellite, §3.1). A hub receiving SPAWN_TERMINAL { satellite: Some(host), .. } relays the spawn over host’s link with satellite stripped to None (satellites spawn locally; no chaining) and its own link-side request_id, correlating the satellite’s TERMINAL_SPAWNED back to the consumer with the freshly allocated id re-tagged LOCAL { id }SATELLITE { host, id } — the returned id is immediately routable through the hub by every relayed verb above. Failures stay typed inside the spawn’s own reply (TERMINAL_SPAWNED { result: ERR(..) }, §3.1): a server that is not a hub, or a hub whose registry lacks host, MUST reply SpawnError::UNSUPPORTED_SATELLITE_ROUTE; a hub whose link to host is down, dialing, refused fail-closed, or unanswering within the relay deadline MUST reply SpawnError::SATELLITE_UNREACHABLE — fast, never an indefinite wait. A SATELLITE-tagged id in the satellite’s own spawn reply is out of topology and resolves as SPAWN_FAILED rather than chaining.

  • Scope. Predictive-echo tuning across the extra hop is defined separately. ROUTE_INPUT remains the attach-free input path through a hub (lease-gated as above).

TerminalIds are stable for the life of the server and are not reused after close (the counter is monotonic for the server’s lifetime).

9.2 Viewport resize

The client’s outer terminal size and cell geometry are signalled with VIEWPORT_RESIZE:

VIEWPORT_RESIZE {
    cols: u16,                          // outer terminal width in cells
    rows: u16,                          // outer terminal height in cells
    pixel_w: optional<u16>,             // outer terminal width in pixels
    pixel_h: optional<u16>,             // outer terminal height in pixels
    cell_w: optional<u16>,              // single-cell width in pixels
    cell_h: optional<u16>,              // single-cell height in pixels
    padding_top: optional<u16>,         // chrome padding around the cell grid
    padding_bottom: optional<u16>,
    padding_left: optional<u16>,
    padding_right: optional<u16>,
}

cell_w / cell_h / padding_* are required for accurate mouse encoding in pixel-format mouse protocols (SgrPixels). Cell-quantized clients (TUIs without real pixel metrics) MAY pass cell_w = 1, cell_h = 1, padding_* = 0 — the server’s encoder produces correct output in cell-format protocols regardless. Pixel-precise clients (GUIs) SHOULD provide real metrics.

The server recomputes per-Terminal sizes against the new viewport. Per-Terminal resize events are then emitted as TERMINAL_RESIZED:

TERMINAL_RESIZED { terminal_id: TerminalId, cols: u16, rows: u16 }

Status: spec-only. The frame has no codec entry. A resized Terminal is observed the way any other grid change is: through the TERMINAL_OUTPUT bytes and the consumer’s own engine. The defaults.window-size aggregation below is implemented; the notification frame is not.

When multiple clients subscribe to the same Terminal with different viewport sizes, the server resolves the one authoritative PTY geometry by applying the defaults.window-size policy (phux-nk07, ADR-0027) across every subscriber’s viewport — NOT last-writer-wins, which let differently-sized clients thrash the shared grid. The policy vocabulary mirrors tmux:

  • smallest (default) — the per-axis minimum across subscribers; nothing is ever cropped, and larger views letterbox. Avoids surprising shrink-and-grow for the smallest viewer.
  • largest — the per-axis maximum; smaller views clamp (content may be cut).
  • latest — the most recently resized client’s viewport.
  • manual — geometry is fixed externally; view sizes are ignored (the server leaves the PTY size unchanged on attach and resize).

A viewport report that leaves authoritative PTY geometry unchanged does not replace any bootstrap generation. If policy resolution changes authoritative geometry, the Terminal actor orders the resize with its live sequence, every affected stream receives BOOTSTRAP_TOMBSTONE { reason: Resize }, and each desired subscription takes a fresh post-resize cut. No pre-resize chunk, history page, or live byte is relabeled into the new generation. Degenerate zero-dimension viewports are ignored in the min/max so a transient resize-to-zero cannot collapse the grid. Per-Terminal resize events are then emitted as TERMINAL_RESIZED. How Terminals are laid out within an attached client’s viewport is a consumer concern: TUIs paint borders and chrome; agents may not paint anything; layout-tree state is L3 metadata (see L3.md), not a wire concept.

9.2.1 Pixel geometry

pixel_w / pixel_h (on the ATTACH viewport and VIEWPORT_RESIZE) are the client’s outer text area in pixels, coherent with the same report’s cols / rows. The server derives a per-cell pixel size from them (pixel / cells, floored) and applies cells x cell size — for the authoritative grid the window-size policy resolved, which may match no single client’s viewport — to everything that advertises pixel geometry to the Terminal’s child process: the PTY winsize pixel fields (ws_xpixel / ws_ypixel), XTWINOPS size replies (CSI 14/16/18 t), and mode-2048 in-band size reports. This keeps the kernel-reported geometry self-consistent: ws_xpixel / ws_col is exactly the cell width, the division pixel-aware programs (kitten icat-class preflights, sixel sizers) perform.

The donor report is the most recent usable pixel report among the Terminal’s current subscribers — recency, not the window-size policy, because cell pixel size is a property of one physical display and a min/max across mixed-DPI viewports would synthesize a cell belonging to no real screen. Reports without pixel metrics (or with degenerate, sub-pixel cells) never displace an established cell size; until any subscriber supplies a usable report the server advertises zero pixel dimensions, matching a terminal that does not know its pixel geometry.

View exact source