L1 — Terminal substrate

L1 — Terminal substrate

TL;DR. The REQUIRED conformance tier: Terminal lifecycle, the hot-path VT-bytes-on-wire state synchronization, viewport resize, structured terminal-originated events, L1 commands, and state replay on attach. Every L1 consumer (agent, recorder, GUI, TUI) implements this tier; it is the substrate on top of which L2 and L3 compose.


1. L1 message catalog

These are the messages every conforming consumer (L1, L1+L3, L1+L2+L3) speaks. They carry TerminalId per ADR-0016 and form the substrate over which higher tiers compose. L1 is always implemented by the server.

IDDirectionNameReferenceStatus
0x10C → SINPUT_KEYinput.md §INPUT_KEYshipped
0x11C → SINPUT_PASTEinput.md §INPUT_PASTEpartial
0x12C → SINPUT_MOUSEinput.md §INPUT_MOUSEpartial
0x13C → SINPUT_RAWinput.md §INPUT_RAWspec-only
0x14C → SINPUT_FOCUSinput.md §INPUT_FOCUSpartial
0x20C → SVIEWPORT_RESIZE§6.2partial
0x22C → SSPAWN_TERMINAL§1.1partial
0x23C → STERMINAL_RESIZE§1.1partial
0x90S → CTERMINAL_OUTPUT§2shipped
0x91S → CTERMINAL_SNAPSHOT§2.4shipped
0x92S → CTERMINAL_RESIZED§6.2spec-only
0xA0S → CTERMINAL_OPENED§6.1spec-only
0xA1S → CTERMINAL_CLOSED§1.1partial
0xA2S → CTERMINAL_SPAWNED§1.1partial
0xB0S → CBELL§1.2shipped
0xB1S → CTERMINAL_EVENT§1.3spec-only
0xB2S → CALERT§1.4spec-only

L1 commands (§5): SPAWN, ATTACH_TERMINAL, DETACH_TERMINAL, KILL_TERMINAL. These ride on the generic COMMAND envelope and expect L1-shaped CommandResult payloads (typically a TerminalId).

Note (deprecation). Earlier drafts of the SPEC carried PANE_DIFF at 0x90 (S → C) with a structured ops body, and named the byte-stream successor PANE_OUTPUT. The wire bytes (frame body shape) match the current TERMINAL_OUTPUT exactly; the rename to TERMINAL_* and terminal_id per ADR-0016 is naming-only. Implementations of pre-0.1.0-draft.3 drafts MUST be updated; there is no on-wire compatibility between PANE_DIFF and TERMINAL_OUTPUT.

1.1 Terminal lifecycle frames

Allocated by phux-4li.10. Four L1 frames unblock the daily-drive arc: split-pane / kill-pane (previously stubbed warn+bell in the reference client) and per-pane ioctl(TIOCSWINSZ) after a SIGWINCH (the server-side half of phux-4li.9’s reflow wire-up).

Wire bodies (positional, per appendix-encoding.md primitives):

SPAWN_TERMINAL   { request_id: u32,
                   collection: CollectionId,
                   command: optional<list<str>>,
                   cwd: optional<str>,
                   env: optional<list<(str, str)>> }
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 {
    COLLECTION_NOT_FOUND,        // tag 0x00; empty body
    SPAWN_FAILED (str),          // tag 0x01; UTF-8 diagnostic
    // #[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 deliberately 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” (same convention as AttachTarget::CreateIfMissing.command = None from §7). 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.

v0.1 servers expose a single default Collection at CollectionId(1) (per the L2-dependency note in L3.md). Other collection ids MAY surface as SpawnError::CollectionNotFound in the reply frame’s SpawnResult::Err arm.

TERMINAL_CLOSED.exit_status is Some(n) when the process called _exit(n) and None for signal kills and unknown-cause exits — a deliberately compact subset of §6.1’s ExitStatus tagged union. The wider ExitStatus shape grows in a follow-up wire bump if the additional structure proves load-bearing; the Option<i32> shape is sufficient for the v0.1 split-pane / kill-pane use 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-pane 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 (this section) is the C→S resize frame. The S→C TERMINAL_RESIZED discriminant at 0x92 is left spec-only for now; it lands when multi-client per-Terminal resize fan-out (§6.2) becomes load-bearing for non-attaching observers.

1.2 BELL

BELL { terminal_id: TerminalId }

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

1.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. Under ADR-0015, TERMINAL_EVENT is a load-bearing L1 surface: it is how an L1-only consumer (agent, recorder, CI orchestrator) answers questions like “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,
}

Earlier drafts called this message OSC_EVENT and its body OscEvent. The rename to TERMINAL_EVENT / TerminalEventBody under ADR-0015 reflects that the union now carries synthesized non-OSC events (EXIT_CODE) in addition to parsed OSC sequences; the wire body shape is unchanged.

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 listed above are those that affect client UX (chrome, notifications, clipboard, status bar widgets) or that an L1-only consumer needs for command-boundary detection.

1.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 §1.2 for clients that prefer one channel
}

2. Terminal state synchronization — the hot path

This section is the protocol’s centerpiece. The server’s libghostty_vt::Terminal is the canonical owner of each Terminal’s grid, scrollback, cursor, and modes. The 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. See ADR-0013 for the design rationale (libghostty-bytes-on-wire).

2.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 remains 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 themselves 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 the sequence number seq, used solely for acknowledgement (proto.md §8) and predictive-echo correlation; seq carries no structural meaning.

A TERMINAL_SNAPSHOT (§2.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).

2.2 Cells

Cells are not wire-level concepts in phux. 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, etc.), as canonicalised by the server’s Terminal and downsampled per the client’s capabilities.

2.3 Diff operations

Diff operations are not present on the wire. See 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 implementation.

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.

2.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 (resetting visible screen), 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, §7), 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 (§7).
  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 (§6.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.

2.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, etc.) live entirely inside each end’s libghostty_vt::Terminal. They are not separate wire concepts.

Clients that need cursor or mode state — for example 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(), etc.). They MUST NOT expect a CursorState or TerminalModes block in TERMINAL_OUTPUT or TERMINAL_SNAPSHOT.


6. Terminal lifecycle and viewport

This section defines the L1 lifecycle messages and the viewport- resize protocol. Both are normative for every conforming consumer.

6.1 Terminal lifecycle

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; reserved for v0.2+ (ADR-0007)
}

v0.1 servers only ever construct LOCAL. v0.1 decoders MUST accept the SATELLITE tag and, if not configured as a federation hub, respond with an ERROR { code: UnsupportedSatelliteRoute } (proto.md §9) rather than failing the frame. This forward-compat reservation avoids a wire-format break when satellites land.

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

The server-side L1 command set (carried by the generic COMMAND envelope, §5) is:

  • SPAWN { cwd, command, initial_size, parent_collection: optional<CollectionId> } — returns TerminalId. Asynchronously emits TERMINAL_OPENED. parent_collection is meaningful only when L2 is in the negotiated tier set.
  • ATTACH_TERMINAL { terminal_id, role_policy } — wire the calling client to receive TERMINAL_OUTPUT for that Terminal, with the role and takeover semantics described in §7.1. A Terminal MAY be attached by multiple clients simultaneously; the server multicasts.
  • DETACH_TERMINAL { terminal_id } — stop receiving output. The Terminal itself is not affected.
  • KILL_TERMINAL { terminal_id } — terminate the underlying PTY. Asynchronously emits TERMINAL_CLOSED.

ATTACH_TERMINAL / DETACH_TERMINAL are per-consumer subscription operations; they do not affect the Terminal’s existence. KILL_TERMINAL is the only L1 command that destroys state.

KILL_TERMINAL and RESIZE_TERMINAL require the caller to be PRIMARY for the target Terminal. A server receiving either command 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. SPAWN is not role-gated; the creating client becomes PRIMARY for the newly spawned Terminal unless a future protocol revision adds an explicit spawn role.

6.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 attach to the same Terminal with different viewport sizes, the server uses the smallest common bounding box (configurable: aggressive mode resizes per attached client). This matches tmux’s well-understood behavior and avoids surprising shrink-and-grow on attach/detach. 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 at all; layout-tree state is L3 metadata (see L3.md), not a wire concept.


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),
    COLLECTION_ID(CollectionId),  // L2 only
    STATE(StateSnapshot),
    JSON(str),                    // for structured returns
    BYTES(bytes),                 // for L3 metadata values
}

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 commands (Terminal substrate)

Command_L1 = tagged_union {
    SPAWN            { cwd: optional<str>, command: optional<list<str>>,
                       initial_size: optional<{cols: u16, rows: u16}>,
                       parent_collection: optional<CollectionId> },  // L2 if set
    ATTACH_TERMINAL  { terminal_id: TerminalId, role_policy: RolePolicy },
    DETACH_TERMINAL  { terminal_id: TerminalId },
    KILL_TERMINAL    { terminal_id: TerminalId },
    RESIZE_TERMINAL  { terminal_id: TerminalId, cols: u16, rows: u16 },
    GET_STATE        { scope: StateScope },
    RUN_HOOK         { name: str, args: list<str> },
    GET_SCREEN       { terminal_id: TerminalId, request_scrollback: optional<u32>, cells: bool },
    ROUTE_INPUT      { terminal_id: TerminalId, event: InputEvent },
    CREATE_SESSION   { collection: CollectionId, name: str,
                       command: optional<list<str>>, cwd: optional<str> },
    KILL_COLLECTION  { collection: CollectionId, name: str },
}

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

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.

The optional request_scrollback field 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 the additive ScreenState.scrollback[] field (oldest first, right-trimmed); the viewport lines[] are unchanged. Walking history is still side-effect-free — the server reads history cells in place and does not scroll the live viewport (phux-o1v).

The trailing 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 (phux-8yl).

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 }, or ERROR { TERMINAL_NOT_FOUND } for an unknown id. 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.

CREATE_SESSION creates a named session under collection and seeds its primary pane without attaching, subscribing, or resizing — the create-only counterpart to the always-attaching ATTACH_TERMINAL create-if-missing path. The server allocates the session and its seed pane in one step, so the existence check and the create are atomic with respect to other clients: two concurrent CREATE_SESSION for the same name cannot both succeed. A name already in use is refused with ERROR { INVALID_COMMAND } (create-only, never create-or-attach); an unknown collection is refused likewise (v0.1 servers host only the default CollectionId(1)). On success the reply is COMMAND_RESULT { OK_WITH(TERMINAL_ID(..)) } carrying the seed pane’s TerminalId, asynchronously correlated by request_id — the same shape SPAWN uses, but session-level. It backs phux new --json (create the session and print its id, no attach) and closes the GET_STATEATTACH race the v0.1 client-side always-new path carried (ADR-0021 §3). Allowed for primaries.

KILL_COLLECTION is the teardown counterpart to CREATE_SESSION: it destroys the session named name under collection, tearing down every Terminal the session owns in one round-trip. The server resolves name to its session and terminates each pane’s PTY — the same effect as a KILL_TERMINAL per pane, but resolved server-side in a single command rather than over N client round-trips. The reply is COMMAND_RESULT { OK }, issued as soon as the teardown is initiated; the per-Terminal TERMINAL_CLOSED notifications follow asynchronously as the panes reap (a COMMAND MAY interleave other frames before its result, §5). An unknown collection or an unknown name is refused with ERROR { INVALID_COMMAND } / ERROR { SESSION_NOT_FOUND } respectively; v0.1 servers host only the default CollectionId(1). It backs phux kill SESSION, collapsing that verb’s prior N KILL_TERMINAL round-trips into one (ADR-0021 §3). Allowed for primaries.

5.2 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 a directional decision documented in ADR-0013 (which supersedes ADR-0002) and CONTRIBUTING.md.


7. 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 L2/L3 are in the negotiated tier set, the set of Collections and 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 §2.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. See ADR-0013 for the bytes-on-wire rationale.

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),                    // L2 collection name (if L2 in tier set);
                                     //   else implementation-defined
    BY_COLLECTION_ID(CollectionId),  // L2 only
    BY_TERMINAL_ID(TerminalId),      // L1: 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; `collections` empty; no metadata.
//   - L1+L2:          `terminals` and `collections` populated.
//   - L1+L2+L3:       all three populated (metadata listing only — values
//                     fetched on demand via GET_METADATA).
SubstrateSnapshot {
    terminals:   list<TerminalInfo>,
    collections: list<CollectionInfo>,         // empty if L2 not negotiated
    metadata_keys: list<MetadataKey>,          // empty if L3 not negotiated
}

7.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 Collection. A client attached to multiple Terminals MAY be PRIMARY for one Terminal 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 §input authority, §6.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 deliberately 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 Collection), 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 existing default that an interactive attach is the input-capable client, while making watch-only and deliberate takeover semantics explicit for clients that need them.

This is the protocol’s killer feature: 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. tmux loses scrollback on detach; phux does not.

Wire-bytes note. v0.1.0-draft.6 declared ATTACHED.snapshot as a SessionSnapshot carrying sessions/windows/panes/layouts/focus. The on-wire blob field-IDs and shape are unchanged in this revision — the rename from SessionSnapshot to SubstrateSnapshot and from session/window/pane content to Terminal/Collection content is a doc rename only. Where v0.1.0-draft.6 placed session/window identifiers, this revision places TerminalId and (optionally) CollectionId. The TUI-convention vocabulary (focused session, focused window, focused pane, layout tree) moves to L3 metadata per L3.md.