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.
| ID | Direction | Name | Reference | Status |
|---|---|---|---|---|
| 0x10 | C → S | INPUT_KEY | input.md §INPUT_KEY | shipped |
| 0x11 | C → S | INPUT_PASTE | input.md §INPUT_PASTE | partial |
| 0x12 | C → S | INPUT_MOUSE | input.md §INPUT_MOUSE | partial |
| 0x13 | C → S | INPUT_RAW | input.md §INPUT_RAW | spec-only |
| 0x14 | C → S | INPUT_FOCUS | input.md §INPUT_FOCUS | partial |
| 0x20 | C → S | VIEWPORT_RESIZE | §6.2 | partial |
| 0x22 | C → S | SPAWN_TERMINAL | §1.1 | partial |
| 0x23 | C → S | TERMINAL_RESIZE | §1.1 | partial |
| 0x90 | S → C | TERMINAL_OUTPUT | §2 | shipped |
| 0x91 | S → C | TERMINAL_SNAPSHOT | §2.4 | shipped |
| 0x92 | S → C | TERMINAL_RESIZED | §6.2 | spec-only |
| 0xA0 | S → C | TERMINAL_OPENED | §6.1 | spec-only |
| 0xA1 | S → C | TERMINAL_CLOSED | §1.1 | partial |
| 0xA2 | S → C | TERMINAL_SPAWNED | §1.1 | partial |
| 0xB0 | S → C | BELL | §1.2 | shipped |
| 0xB1 | S → C | TERMINAL_EVENT | §1.3 | spec-only |
| 0xB2 | S → C | ALERT | §1.4 | spec-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_DIFFat0x90(S → C) with a structured ops body, and named the byte-stream successorPANE_OUTPUT. The wire bytes (frame body shape) match the currentTERMINAL_OUTPUTexactly; the rename toTERMINAL_*andterminal_idper ADR-0016 is naming-only. Implementations of pre-0.1.0-draft.3 drafts MUST be updated; there is no on-wire compatibility betweenPANE_DIFFandTERMINAL_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:
- The Terminal’s PTY emits VT bytes.
- The server feeds those bytes to the Terminal’s canonical
libghostty_vt::Terminal, which becomes the authoritative parse (grid, scrollback, cursor, modes). - 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.
- 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:
- A client first attaches (§7).
- Backpressure forced the server to compact pending output and resume from a known state (proto.md §8).
- The grid resized in a way that requires full retransmission (§6.2).
- 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> }— returnsTerminalId. Asynchronously emitsTERMINAL_OPENED.parent_collectionis meaningful only when L2 is in the negotiated tier set.ATTACH_TERMINAL { terminal_id, role_policy }— wire the calling client to receiveTERMINAL_OUTPUTfor 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 emitsTERMINAL_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_STATE→ATTACH 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:
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.- 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 appliesscrollback_bytes(if present) and thenvt_replay_bytesto a freshlibghostty_vt::Terminalof the declared dimensions; the client’s local Terminal is then in sync with the server’s canonical Terminal for that Terminal. - 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 withERROR { code: ALREADY_ATTACHED }forATTACHorCOMMAND_RESULT { result: ERROR(ALREADY_ATTACHED, ...) }forATTACH_TERMINAL. No subscription role changes. - If
takeover = DELIBERATE, the server MUST transfer primary status to the requester. The displaced client remains attached asVIEWERunless server policy requires exclusive-primary eviction; in that case the server MUST sendDETACHED { 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.snapshotas aSessionSnapshotcarrying sessions/windows/panes/layouts/focus. The on-wire blob field-IDs and shape are unchanged in this revision — the rename fromSessionSnapshottoSubstrateSnapshotand 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 placesTerminalIdand (optionally)CollectionId. The TUI-convention vocabulary (focused session, focused window, focused pane, layout tree) moves to L3 metadata per L3.md.