L1 — Terminal substrate

Protocol reference

L1 — Terminal substrate

TL;DR. 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
0x20C → SVIEWPORT_RESIZE§9.2partial
0x22C → SSPAWN_TERMINAL§3.1partial
0x23C → STERMINAL_RESIZE§3.1partial
0x41C → SSUBSCRIBE_EVENTS§7partial
0x90S → CTERMINAL_OUTPUT§4shipped
0x91S → CTERMINAL_SNAPSHOT§4.4shipped
0x92S → CTERMINAL_RESIZED§9.2spec-only
0xA0S → CTERMINAL_OPENED§9.1spec-only
0xA1S → CTERMINAL_CLOSED§3.1partial
0xA2S → CTERMINAL_SPAWNED§3.1partial
0xB0S → CBELL§3.2shipped
0xB1S → CTERMINAL_EVENT§3.3spec-only
0xB2S → CALERT§3.4spec-only
0xB3S → CEVENT§7partial

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

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

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; its full removal is tracked as future work (bead phux-0bmc). 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.

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.

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

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

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 — the hot path

The server’s libghostty_vt::Terminal is the canonical owner of each Terminal’s grid, scrollback, cursor, and modes. Each client runs its own local libghostty_vt::Terminal as a rendering mirror. Terminal content flows between them as a stream of VT bytes, not as structured diffs (ADR-0013).

4.1 The frame model

A Terminal’s content on the wire is a sequence of TERMINAL_OUTPUT frames:

TERMINAL_OUTPUT {
    terminal_id: TerminalId,
    seq: u64,        // monotonic per-Terminal sequence id, for ack /
                     //   predictive-echo correlation (see proto.md §8)
    bytes: bytes,    // VT bytes from the PTY (canonicalised by the
                     //   server's libghostty Terminal and possibly
                     //   downsampled for this client's caps per
                     //   proto.md §6.2)
}

The flow is:

  1. The Terminal’s PTY emits VT bytes.
  2. The server feeds those bytes to the Terminal’s canonical libghostty_vt::Terminal, which becomes the authoritative parse (grid, scrollback, cursor, modes).
  3. The server forwards bytes to each attached client, having applied per-client capability downsampling (proto.md §6.2) — for example rewriting truecolor SGR sequences to 256-color or 16-color forms, or stripping unsupported image escape sequences.
  4. The client feeds the received bytes into its own libghostty_vt::Terminal. Both ends now hold equivalent (post-downsampling) grid state.

Coalescing is a server concern: the server SHOULD batch bytes between transport writes, and MAY rate-limit the per-Terminal output stream (default cap 60 Hz of TERMINAL_OUTPUT emissions; configurable), but the emissions carry raw PTY bytes — no structured frame boundaries, no frame_id / base_frame_id relationship, no per-emission cursor/modes block. Frame identity is replaced by seq, used solely for acknowledgement (proto.md §8) and predictive-echo correlation; seq carries no structural meaning.

DEC synchronized output (CSI ? 2026 hCSI ? 2026 l) may span any number of TERMINAL_OUTPUT messages and transport reads. A rendering client MUST continue applying those bytes to its local Terminal but MUST NOT publish intermediate grid paints while mode 2026 is set; it paints the accumulated state when the mode clears. An implementation MAY use a bounded watchdog to publish the latest state if a malformed producer never clears the mode. Transport/message boundaries MUST NOT implicitly end a synchronized-output transaction.

A TERMINAL_SNAPSHOT (§4.4) is a self-contained replay: a synthesized VT byte sequence that, when applied to a fresh Terminal of the matching dimensions, reproduces the current grid (and optionally the scrollback).

4.2 Cells

Cells are not wire-level concepts. Each end’s libghostty_vt::Terminal owns its own grid representation. Clients that need rendered cell data (for layout, copy/paste, search) use libghostty’s Terminal::grid_ref() and related APIs to query their local Terminal; they do not reconstruct cells from wire frames. Cell attribute encoding on the wire is whatever the PTY’s byte stream produces (SGR sequences, OSC 8 hyperlinks, and so on), as canonicalised by the server’s Terminal and downsampled per the client’s capabilities.

4.3 Diff operations

Diff operations are not present on the wire (ADR-0013). The PTY’s own byte stream is the canonical delta from one observed state to the next; libghostty’s VT parser applies it deterministically and identically on the server (canonical) and on each client (mirror).

Local rendering optimisation — skipping unchanged rows on redraw — is a client-local concern using libghostty’s RenderState per-row dirty tracking. It is invisible to the wire format. There is no notion of CELL_RUN, REPEAT, CLEAR, ERASE_LINE, SCROLL_UP, or SCROLL_DOWN as wire operations; those concepts live inside libghostty’s parser.

Hyperlinks (OSC 8) and image escape sequences (sixel, kitty graphics, iTerm2) flow as bytes within the same TERMINAL_OUTPUT stream, subject to the capability gating in proto.md §6.2.

4.4 Snapshots

TERMINAL_SNAPSHOT {
    terminal_id: TerminalId,
    cols: u16,
    rows: u16,
    vt_replay_bytes: bytes,
    scrollback_bytes: optional<bytes>,
}

vt_replay_bytes is a self-contained VT byte sequence synthesized by the server from its canonical Terminal’s current grid state. When the client writes the bytes to a fresh libghostty_vt::Terminal of the declared cols × rows, the result MUST reproduce the server’s grid state at the moment of snapshot emission. The byte sequence is Mosh-style and opaque to the client: the client MUST NOT attempt to parse or rewrite it beyond feeding it to its Terminal.

Servers SHOULD produce vt_replay_bytes whose effect is independent of any prior Terminal state. A typical implementation begins with cursor-home + erase-display, emits per-row SGR and cell text, ends with a final cursor-position move and the appropriate DECSET/DECRST pairs to re-establish modes, and avoids escape sequences whose meaning depends on prior parser state. The exact construction is implementation-defined; only the end result (client Terminal grid == server Terminal grid at snapshot time) is normative.

scrollback_bytes is present iff the attaching client requested scrollback replay (ATTACH.request_scrollback = true, §8), bounded by ATTACH.scrollback_limit_lines. It is also an opaque VT byte sequence; when applied to a fresh Terminal before vt_replay_bytes (or under whatever construction the server chooses), it reproduces the requested scrollback history.

Servers emit TERMINAL_SNAPSHOT when:

  1. A client first attaches (§8).
  2. Backpressure forced the server to compact pending output and resume from a known state (proto.md §8).
  3. The grid resized in a way that requires full retransmission (§9.2).
  4. The protocol requires it for correctness in any future case.

After a TERMINAL_SNAPSHOT, the next TERMINAL_OUTPUT for the same Terminal continues the live byte stream. The client’s local Terminal is in sync after applying the snapshot bytes and before consuming the next TERMINAL_OUTPUT.

4.5 Cursor and modes

Cursor state (position, visibility, shape, blink) and Terminal modes (altscreen, bracketed paste, app cursor keys, mouse protocol, focus reporting, origin mode, and so on) live entirely inside each end’s libghostty_vt::Terminal. They are not separate wire concepts.

Clients that need cursor or mode state — to render a local cursor overlay, to decide whether to forward mouse events, or to enable bracketed paste in the outer terminal — MUST query their local Terminal via libghostty’s API (Terminal::screen(), Terminal::modes(), and related). They MUST NOT expect a CursorState or TerminalModes block in TERMINAL_OUTPUT or TERMINAL_SNAPSHOT.


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

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

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, primes it with one authoritative TERMINAL_SNAPSHOT, and streams TERMINAL_OUTPUT deltas from then on; the snapshot MUST precede the first delta for that consumer. The subscription also opens the attach-scoped INPUT_* / FRAME_ACK gates — a session-scoped ATTACH is NOT required. It is idempotent: a re-attach re-sends a fresh snapshot without duplicating the stream. It does NOT resize the Terminal (no viewport rides the command; callers follow with TERMINAL_RESIZE), and the role_policy field is not yet encoded — an absent policy means { requested_role: PRIMARY, takeover: NEVER } per §8.1, and the field lands additively. 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.

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.

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 §7). Input is fire-and-forget (input.md §7): 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 actor accepted the operation but the bounded writer queue cannot accept it, the completion signal is lost and the result is INPUT_DELIVERY_UNKNOWN; 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 the write failed, its completion was lost, or the bounded completion wait expired after handoff; some or all bytes MAY have reached the PTY. A client MUST NOT present that result as delivered.

The server retains the canonical payload digest and final OK or INPUT_DELIVERY_UNKNOWN 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.

The reference server admits at most one unresolved acknowledged operation at a time. A concurrent operation 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.

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 (SPAWN_TERMINAL and the PTY-EOF / KILL_TERMINAL path that emits TERMINAL_CLOSED, §3.1). pane_closed.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 replay on attach

When a client sends ATTACH, the server’s response sequence is:

  1. ATTACHED { snapshot, initial_client_id } — a metadata-only snapshot of the consumer’s tier-visible state: the set of Terminals (L1) the client is wired to receive, and, if L3 is in the negotiated tier set, the relevant metadata-key inventory. This step carries no Terminal content.
  2. For each Terminal the client is attached to, one TERMINAL_SNAPSHOT { terminal_id, cols, rows, vt_replay_bytes, scrollback_bytes? } per §4.4. The client applies scrollback_bytes (if present) and then vt_replay_bytes to a fresh libghostty_vt::Terminal of the declared dimensions; the client’s local Terminal is then in sync with the server’s canonical Terminal for that Terminal.
  3. Subsequent TERMINAL_OUTPUT { terminal_id, seq, bytes } messages flow live, continuing the per-Terminal VT byte stream from where the snapshot left off.

The per-Terminal seq numbering used by TERMINAL_OUTPUT resumes from the server’s chosen base after snapshot emission; clients MUST treat the first TERMINAL_OUTPUT after a TERMINAL_SNAPSHOT as authoritative for the sequence base and MUST NOT assume seq continuity across the snapshot boundary (ADR-0013).

ATTACH {
    target: AttachTarget,
    viewport: { cols: u16, rows: u16, pixel_w: optional<u16>, pixel_h: optional<u16> },
    request_scrollback: bool,
    scrollback_limit_lines: u32,
    role_policy: RolePolicy,
}

AttachTarget = tagged_union {
    LAST,                            // most-recently-used target
    BY_NAME(str),                    // resolved via L3 group-name metadata (L3.md)
    BY_GROUP_ID(GroupId),            // opaque grouping key
    BY_TERMINAL_ID(TerminalId),      // attach to one Terminal directly
    CREATE_IF_MISSING { name: str, command: optional<list<str>>, cwd: optional<str> },
}

ATTACHED {
    snapshot: SubstrateSnapshot,
    initial_client_id: ClientId,
}

// Tier-conditional contents:
//   - L1-only client: `terminals` populated; no metadata.
//   - L1+L3:          `terminals` populated; metadata-key inventory populated
//                     (listing only — values fetched on demand via GET_METADATA).
SubstrateSnapshot {
    terminals:     list<TerminalInfo>,
    metadata_keys: list<MetadataKey>,          // empty if L3 not negotiated
}

A client reconnecting after hours of detached work receives the full state of every Terminal it is wired to, including scrollback up to the configured limit.

8.1 Terminal roles and takeover policy

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

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), the fire-and-forget INPUT_*, FRAME_ACK, and TERMINAL_RESIZE frames, 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, TERMINAL_OUTPUT, TERMINAL_SNAPSHOT, TERMINAL_CLOSED, BELL) are re-tagged LOCAL { id }SATELLITE { host, id } before reaching the consumer, so a consumer only ever observes the hub-scoped address it used. A frame arriving from a satellite already carrying a SATELLITE tag is dropped: the topology is hub-and-spoke, satellites are unaware of each other, and 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 (phux-v45.7). Interactive attach to a satellite Terminal rides ATTACH_TERMINAL / DETACH_TERMINAL (§5.1): the hub relays a consumer’s ATTACH_TERMINAL like any per-terminal command and registers the consumer as a proxy subscriber atomically with the relayed command — a satellite error rolls the registration back, so a consumer whose attach failed is never fanned a stream it was told it does not have. The satellite is the byte authority: its authoritative TERMINAL_SNAPSHOT crosses the link verbatim, is re-tagged, and MUST reach the consumer before any relayed TERMINAL_OUTPUT delta for that Terminal (the link is one ordered stream and fan-out preserves arrival order, so the satellite-side snapshot-first guarantee survives the extra hop). A hub MUST hold that ordering even when a consumer’s outbound mailbox is briefly full at attach: it retains the un-deliverable snapshot for that consumer and suppresses the consumer’s later deltas until the snapshot lands (phux-v45.12), rather than dropping the snapshot while a subsequent delta slips through — the scope of the reliability is the one attaching consumer, never a link-wide stall (a sustained-slow consumer may still lag on content, the ordinary backpressure condition, but never observes a delta before its snapshot). The gate MUST hold on the upgrade path too (phux-v45.15): a consumer that already holds an event-only subscription — or an already-attached, snapshot-landed stream — to that Terminal and then re-issues ATTACH_TERMINAL re-enters the gate, so the new attach’s deltas are suppressed until its own fresh snapshot lands, not delivered ahead of it because the consumer was already streaming (and a satellite error on the upgrade restores the pre-existing stream rather than stranding it behind a snapshot that never comes). TERMINAL_CLOSED and BELL are the two exceptions to the gate: both are snapshot-independent (a lifecycle signal and an ephemeral notification the TERMINAL_SNAPSHOT does not capture), so the hub delivers them best-effort past the gate to a still-gated consumer — gating them would drop them permanently, whereas a gated TERMINAL_OUTPUT delta is safely superseded by the snapshot (freshest full grid wins). The attach opens the satellite’s INPUT_* / FRAME_ACK gates for the link consumer, so attach-scoped input and acks relayed by the hub flow end to end; FRAME_ACK stays a loss-tolerant hint (ADR-0018, ADR-0043) — hub consumers of one satellite Terminal share a single link-side ack stream, which can only delay (never deadlock) eviction. On the forwarded leg, whose fan-out can drop whole TERMINAL_OUTPUT frames under consumer backpressure, a StateSync origin server MAY advance a consumer’s reference on FRAME_ACK rather than on emit (ADR-0043), so a dropped frame re-diffs against the last-acked reference and self-heals rather than diverging the mirror; this is a server-side emission strategy with no wire-byte change. The hub resolves a consumer’s DETACH_TERMINAL locally — it withdraws that consumer’s proxy subscription and relays a satellite-side DETACH_TERMINAL only when the last proxy subscriber for that Terminal is gone (relaying every detach verbatim would tear the link’s shared stream down under the other consumers still watching it). The same last-subscriber rule runs on consumer disconnect, so a satellite never streams a Terminal no hub consumer observes.
  • 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 }

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

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.