L1 — Terminal substrate
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.
| ID | Direction | Name | Reference | Status |
|---|---|---|---|---|
| 0x10 | C → S | INPUT_KEY | input.md | shipped |
| 0x11 | C → S | INPUT_PASTE | input.md | partial |
| 0x12 | C → S | INPUT_MOUSE | input.md | partial |
| 0x13 | C → S | INPUT_RAW | input.md | spec-only |
| 0x14 | C → S | INPUT_FOCUS | input.md | partial |
| 0x20 | C → S | VIEWPORT_RESIZE | §9.2 | partial |
| 0x22 | C → S | SPAWN_TERMINAL | §3.1 | partial |
| 0x23 | C → S | TERMINAL_RESIZE | §3.1 | partial |
| 0x41 | C → S | SUBSCRIBE_EVENTS | §7 | partial |
| 0x90 | S → C | TERMINAL_OUTPUT | §4 | shipped |
| 0x91 | S → C | TERMINAL_SNAPSHOT | §4.4 | shipped |
| 0x92 | S → C | TERMINAL_RESIZED | §9.2 | spec-only |
| 0xA0 | S → C | TERMINAL_OPENED | §9.1 | spec-only |
| 0xA1 | S → C | TERMINAL_CLOSED | §3.1 | partial |
| 0xA2 | S → C | TERMINAL_SPAWNED | §3.1 | partial |
| 0xB0 | S → C | BELL | §3.2 | shipped |
| 0xB1 | S → C | TERMINAL_EVENT | §3.3 | spec-only |
| 0xB2 | S → C | ALERT | §3.4 | spec-only |
| 0xB3 | S → C | EVENT | §7 | partial |
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.term → term 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:
- 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 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 h … CSI ? 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:
- A client first attaches (§8).
- 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 (§9.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.
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 viaphux.session.created/v1) and the name key (phux.session.name/v1) — are owned by L3.md. - Rename a group: an L3 metadata
SETon 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:
| Tag | Variant | Body |
|---|---|---|
| 0x00 | command_started | (empty) |
| 0x01 | command_finished | exit_code: optional<i32> |
| 0x02 | title_changed | title: str |
| 0x03 | bell | (empty) |
| 0x04 | pane_spawned | (empty; id on envelope) |
| 0x05 | pane_closed | exit_status: optional<i32> |
| 0x06 | dirty | (empty) |
| 0x07 | idle | (empty) |
| 0x08 | terminal_control | lifecycle: TerminalLifecycle, exit_status: optional<i32>, input_holder: optional<ClientId>, action: ControlAction, actor: optional<ClientId> |
| 0x09 | asked | field-tagged TLV (below) |
| 0x0a | cwd_changed | cwd: 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:
| Field | Id | Type | Notes |
|---|---|---|---|
id | 1 | str | stable id the answer correlates against |
question | 2 | str | the question text presented to the human |
suggestion | 3 | str | one suggested answer; repeated, in order; absent for none |
elapsed_seconds | 4 | u64 | optional; 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_TERMINALand the PTY-EOF /KILL_TERMINALpath that emitsTERMINAL_CLOSED, §3.1).pane_closed.exit_statuscarries the same value asTERMINAL_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 theBELLframe (0xB0).dirty/idle— sourced from the per-pane state-sync tick’s dirty flag. The server coalesces: at most onedirtyper active output burst, then oneidlewhen the grid has settled across an idle window.asked— sourced from explicitREPORT_ASKEDhook commands or thephux-askOSC 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_startedis emitted from theC(command-executed) mark;command_finishedfrom theD(command-end) mark, withcommand_finished.exit_code = Some(n)when the shell integration reported one (OSC 133 ; D ; n ST) andNoneotherwise. 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 querydefaults.cwd-inheritanceuses:/proc/<pid>/cwdon Linux,proc_pidinfo(PROC_PIDVNODEPATHINFO)on macOS), polled at OSC-133Dprompt 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 theATTACHEDsnapshot’sTerminalInfo.cwd(the spawn cwd) and refines from this stream; a pane that never produces output after acdkeeps the seed until its next prompt boundary.
8. 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 L3 is in the negotiated tier set, 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 §4.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
(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 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 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 }toLOCAL { id }and forwards the frame verbatim overhost’s link — the hub never re-encodes VT bytes or input payloads (opaque relay). This covers the per-terminalCOMMANDcatalog (GET_SCREEN,ROUTE_INPUT,KILL_TERMINAL,GET_TERMINAL_STATE,SUBSCRIBE_TERMINAL_EVENTS,ACQUIRE_INPUT,RELEASE_INPUT,SIGNAL_TERMINAL,REPORT_ASKED; theSATELLITE-tagged ids inside aKILL_TERMINALSbatch are partitioned per host and relayed as per-satellite batches), the fire-and-forgetINPUT_*,FRAME_ACK, andTERMINAL_RESIZEframes, and terminal-scopedSUBSCRIBE_EVENTS.COMMAND.request_idnever crosses the link: the hub allocates its own link-side id space and correlates the reply back to the consumer’s originalrequest_id. - Return leg. Responses and subscribed stream frames arriving from the
satellite (
COMMAND_RESULT, correlatedERROR,EVENT,TERMINAL_OUTPUT,TERMINAL_SNAPSHOT,TERMINAL_CLOSED,BELL) are re-taggedLOCAL { 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 aSATELLITEtag 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 withSatelliteUnreachableand pushes one un-correlatedERROR { 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 relayedCOMMANDcarries a hub-side deadline, so a silently partitioned satellite (no FIN/RST) or one that accepts frames but never answers still resolves asSatelliteUnreachablerather than waiting forever, and the hub MUST enforce a keepalive / idle-timeout contract on every link transport (QUIC’s transport-levelkeep_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’sATTACH_TERMINALlike 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 authoritativeTERMINAL_SNAPSHOTcrosses the link verbatim, is re-tagged, and MUST reach the consumer before any relayedTERMINAL_OUTPUTdelta 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-issuesATTACH_TERMINALre-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_CLOSEDandBELLare the two exceptions to the gate: both are snapshot-independent (a lifecycle signal and an ephemeral notification theTERMINAL_SNAPSHOTdoes 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 gatedTERMINAL_OUTPUTdelta is safely superseded by the snapshot (freshest full grid wins). The attach opens the satellite’sINPUT_*/FRAME_ACKgates for the link consumer, so attach-scoped input and acks relayed by the hub flow end to end;FRAME_ACKstays 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 wholeTERMINAL_OUTPUTframes under consumer backpressure, aStateSyncorigin server MAY advance a consumer’s reference onFRAME_ACKrather 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’sDETACH_TERMINALlocally — it withdraws that consumer’s proxy subscription and relays a satellite-sideDETACH_TERMINALonly 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 cooperativeACQUIRE_INPUT(andROUTE_INPUT/INPUT_*) from a non-holder without touching the link, and treats a non-holder’sRELEASE_INPUTas the idempotent no-opOkwithout 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-taggedTerminalControl { 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 relayedINPUT_*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 aRELEASE_INPUTso 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 relaysGET_STATE { scope: SERVER }over each link (links queried concurrently, each bounded by the per-command relay deadline above) and appends the returnedpanesre-taggedLOCAL { id }→SATELLITE { host, id }. Only terminals aggregate: session and window identities are not federation-routable (ADR-0016 makesTerminalIdthe wire primary), so the satellite’ssessions/windowslists and focus fields are discarded — theiru32ids would collide with the hub’s own. A satellite pane’swindow_idis therefore passed through verbatim: it is satellite-local, resolvable only on the satellite, and has no entry in the merged snapshot’swindowslist; consumers MUST NOT join it against the hub’s windows and SHOULD group satellite terminals by thehostin theirTerminalId.cols/rows/title/cwdare 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-correlatedERROR { code: SatelliteUnreachable }naming the host to the requesting consumer before theCOMMAND_RESULT— the same observable-degradation shape as the proxy-subscription teardown notification — and the merged snapshot simply lacks that host’s terminals. ASATELLITE-tagged id in a satellite’s own list is dropped (no chaining, as above). - Satellite-targeted spawn (
SPAWN_TERMINAL.satellite, §3.1). A hub receivingSPAWN_TERMINAL { satellite: Some(host), .. }relays the spawn overhost’s link withsatellitestripped toNone(satellites spawn locally; no chaining) and its own link-siderequest_id, correlating the satellite’sTERMINAL_SPAWNEDback to the consumer with the freshly allocated id re-taggedLOCAL { 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 lackshost, MUST replySpawnError::UNSUPPORTED_SATELLITE_ROUTE; a hub whose link tohostis down, dialing, refused fail-closed, or unanswering within the relay deadline MUST replySpawnError::SATELLITE_UNREACHABLE— fast, never an indefinite wait. ASATELLITE-tagged id in the satellite’s own spawn reply is out of topology and resolves asSPAWN_FAILEDrather than chaining. - Scope. Predictive-echo tuning across the extra hop is defined
separately.
ROUTE_INPUTremains 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.