L1 — Terminal substrate
Defines the required conformance tier: the wire surface scoped to a single Terminal.
Full source summary
Defines the required conformance tier: the wire surface scoped to a single Terminal. Lifecycle (spawn, attach, detach, kill, atomic group kill), the VT-bytes-on-wire state synchronization hot path, viewport and per-Terminal resize, terminal-originated events, state replay on attach, and the L1 command set. Group create and rename are not here; they decompose into L1 lifecycle plus L3 metadata. Structured agent affordances are engine-derived conveniences, not a normative structured contract.
1. Scope and the engine-delegation constraint
L1 is Terminal-scoped. Every message in this tier names exactly one
TerminalId (per ADR-0016) or
operates over an explicit list of them. There is no collection lifecycle tier
on the wire: grouping is L3 metadata plus client logic
(ADR-0030),
and the conventions live in L3.md.
Both ends of the wire run the same terminal engine (libghostty). The wire therefore carries identity, lifecycle, transport framing, opaque terminal bytes, and L3 metadata — and nothing that re-encodes terminal state into a second representation. Any structured view of a Terminal (a cell grid, a command-boundary stream, a pane tree, a layout, a run-and-wait result) is a consumer-side projection computed from the engine the consumer already runs, not a wire tier. See ADR-0013 for the bytes-on-wire rationale and ADR-0030 for the delegation principle this tier follows.
Encoding is field-tagged TLV, per
appendix-encoding.md, which owns the normative codec
statement: each message body is a sequence of field_id || wire_type || length-delimited value fields, matched by id and skip-by-length over unknown
ids. The wire bodies below list each message’s fields in field-id order
(ids 1, 2, 3, … per message); the leaf primitives and nested tagged unions
within a field are big-endian, length-prefixed, and positional.
2. L1 message catalog
These are the messages every conforming consumer (L1, L1+L3) speaks. They carry
TerminalId and form the substrate over which L3 composes. L1 is always
implemented by the server.
| 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 |
| 0x16 | C → S | HISTORY_REQUEST | §4.5 | shipped |
| 0x17 | C → S | INPUT_TERMINAL_REPLY | input.md §6 | 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 |
| 0x2A | C → S | MOVE_TERMINAL | §3.1 | shipped |
| 0x41 | C → S | SUBSCRIBE_EVENTS | §7 | partial |
| 0x90 | S → C | TERMINAL_OUTPUT | §4.1 | shipped |
| 0x91 | — | permanently retired | ADR-0070 | retired |
| 0x92 | S → C | TERMINAL_RESIZED | §9.2 | spec-only |
| 0x93 | S → C | BOOTSTRAP_BEGIN | §4.3 | shipped |
| 0x94 | S → C | BOOTSTRAP_CHUNK | §4.3 | shipped |
| 0x95 | S → C | BOOTSTRAP_READY | §4.3 | shipped |
| 0x96 | S → C | HISTORY_PAGE | §4.5 | shipped |
| 0x97 | S → C | BOOTSTRAP_TOMBSTONE | §4.6 | shipped |
| 0x98 | S → C | HISTORY_TOMBSTONE | §4.5 | shipped |
| 0x99 | S → C | HISTORY_REJECTED | §4.5 | shipped |
| 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 |
| 0xA8 | S → C | TERMINAL_MOVED | §3.1 | shipped |
| 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 |
INPUT_TERMINAL_REPLY is the sole protocol-0.7 opaque client-to-server input
frame. It carries only byte-exact replies generated by an attached client’s
terminal emulator; it never substitutes for structured user input,
INPUT_RAW, or INPUT_PASTE. It is available only when
HELLO_OK.server_caps.features contains TERMINAL_REPLY = 0x80; an older 0.7
HELLO_OK without the bit does not support it. Its attachment, authorization,
ordering, and 65,536-byte bound are normative in input.md §6.
The L1 command set rides on the generic COMMAND envelope (§5) and is
catalogued in §5.1.
Status: spec-only. The workload grant and classifier are specified but not present in the reference server.
When a connection carries a phux-workload/v1 grant, every client-originated
L1 frame and nested command is additionally governed by the total verb/selector
matrix in workload-auth.md §7. That check runs before
Terminal lookup, handler dispatch, or satellite forwarding. The role and input
lease rules below are additional domain invariants, never substitutes for the
workload grant.
3. Terminal lifecycle frames
3.1 Spawn, resize, and close
Wire bodies (field-tagged TLV, per appendix-encoding.md; fields listed in field-id order, leaf primitives and nested unions positional within each field):
SPAWN_TERMINAL { request_id: u32,
group: GroupId,
command: optional<list<str>>,
cwd: optional<str>,
env: optional<list<(str, str)>>,
term: optional<str>,
satellite: optional<str>,
owner_terminal: optional<TerminalId>,
agent_session: optional<bytes>,
initial_size: optional<(u16, u16)> }
TERMINAL_SPAWNED { request_id: u32, result: SpawnResult }
TERMINAL_CLOSED { terminal_id: TerminalId, exit_status: optional<i32> }
TERMINAL_RESIZE { terminal_id: TerminalId, cols: u16, rows: u16 }
SpawnResult = tagged_union {
OK (TerminalId), // tag 0x00
ERR (SpawnError), // tag 0x01
}
SpawnError = tagged_union {
GROUP_NOT_FOUND, // tag 0x00; empty body
SPAWN_FAILED (str), // tag 0x01; UTF-8 diagnostic
UNSUPPORTED_SATELLITE_ROUTE, // tag 0x02; empty body (§9.1)
SATELLITE_UNREACHABLE (str), // tag 0x03; UTF-8 diagnostic (§9.1)
// #[non_exhaustive] — future codes (PermissionDenied,
// ResourceExhausted, ...) MAY be added without bumping major.
}
The SpawnResult tag convention (Ok = 0x00, Err = 0x01) is established here
for reuse by future Result<T, E>-shaped reply frames; it mirrors the Option
tag convention (None = 0x00, Some = 0x01) so hex-dump readers do not need a
second per-shape table.
SPAWN_TERMINAL is asynchronous: the server replies with TERMINAL_SPAWNED
correlated by request_id. command = None means “use the server’s default
shell” (the same convention as AttachTarget::CreateIfMissing.command = None,
§8). cwd = None means “use the server’s default cwd” (typically the user’s
$HOME; the exact policy is implementation-defined). env = None inherits the
server’s environment as-is; env = Some([]) is distinct — it starts with an
empty environment.
term (field id 6) is a first-class, optional per-spawn TERM override: a
typed knob so a consumer can advertise a specific terminfo entry for the new
Terminal without hand-rolling a TERM env pair. term = None defers to the
server’s defaults.term (and ultimately its compiled-in baseline). A TERM
entry inside env still wins over term, because the server applies env
last; the precedence, lowest-to-highest, is compiled-in default →
defaults.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.
Under phux-workload/v1, the side-effect-free resolved owner Group, not the
payload group, is the CREATE subject; the owner Terminal separately requires
BIND. After both checks, a payload Group unequal to the resolved Group returns
SPAWN_FAILED and creates nothing. Unauthorized and absent owners remain
indistinguishable at the policy guard.
agent_session (field id 9) is optional, bounded opaque provenance installed
under the new local Terminal’s phux.agent-session/v1 metadata key in the same
server state transaction that creates and interns the Terminal. Its semantics
and 4096-byte encoded bound are specified by L3 §3.7.1; the server MUST NOT
interpret provider fields. Empty or oversized values return SPAWN_FAILED.
The field is local-only, MUST NOT be combined with satellite, and is
wire-additive: peers that predate field 9 ignore it, after which a client MAY
use ordinary L3 SET/GET confirmation as a compatibility fallback.
initial_size (field id 10) is the optional (cols, rows) — two big-endian
u16s, positional within the field — that the new Terminal’s grid and PTY
winsize are created at. A server advertising SPAWN_INITIAL_SIZE
(proto.md §6.2) MUST create the Terminal at that grid, and MUST record it as
the Terminal’s dimensions so GET_STATE and the next ATTACHED snapshot
report it, before the Terminal becomes visible to any consumer. A zero on
either axis MUST be read as “the sender does not know its geometry” — not as
a zero-cell grid, which no terminal emulator has — and leaves the server’s
default in force, consistent with §9.2’s zero-viewport no-op rule. The field
is local-only: a hub MUST NOT forward it on a satellite-routed spawn, and a
satellite therefore applies its own default.
The field exists because the geometry is knowable one round trip earlier than
it was being applied. A layout-owning consumer computes the tile a new leaf
will occupy from its own layout before it has an id for that leaf; without
this field the pane bootstrapped at the server’s default, the consumer’s real
tile arrived immediately afterwards as TERMINAL_RESIZE, and — because an
authoritative resize invalidates the bootstrap generation (§4.3, ADR-0070) —
the capture the server had just computed and published was discarded
unread. Sending the tile up front makes that follow-up resize a no-op
instead. A consumer with no honest answer (a headless spawner, a hub relay,
an empty layout) omits the field rather than guessing.
A server that predates the field skips it by length and spawns at its default, which is exactly the pre-field behavior, so a consumer that sends it unadvertised degrades rather than breaks. Consumers SHOULD still gate on the advertised bit, since it is what tells them whether their follow-up resize is redundant.
group is GroupId, a documented opaque grouping key, not a
lifecycle tier. The reference server exposes a single default value at
GroupId(1); other ids MAY surface as SpawnError::GroupNotFound.
GroupId is retained because some SPAWN_TERMINAL and L3 scoping paths
still carry it; this is settled, not a remnant awaiting removal (bead
phux-0bmc closed as resolved-by-rename). It SHALL NOT be read as a grouping
lifecycle tier — there is none.
TERMINAL_CLOSED.exit_status is Some(n) when the process called _exit(n)
and None for signal kills and unknown-cause exits — a compact subset of
§9.1’s ExitStatus tagged union. The wider ExitStatus shape MAY grow in a
later wire bump; the Option<i32> shape is sufficient for the spawn / kill use
cases.
TERMINAL_CLOSED is the single L1 lifecycle event for a Terminal ceasing to
exist, whether it died from PTY EOF or process exit, a KILL_TERMINAL command,
or a KILL_TERMINALS group teardown (§5.1). The server MUST emit it to every
client subscribed to the Terminal and MUST NOT additionally infer a client
DETACHED from a Terminal’s death: the server reports lifecycle facts and does
not interpret them (ADR-0015). Whether
the death of a Terminal should detach a client is a consumer policy. A client
still detaches explicitly via DETACH (proto.md §7.3), and the
server still sends DETACHED for server-initiated teardown (shutdown,
takeover); EOF is not one of those cases.
“Subscribed to the Terminal” spans both subscription paths and is not
limited to the session-scoped one: a client subscribed by ATTACH (§9.1) and
a client subscribed by ATTACH_TERMINAL (§5.1, which explicitly requires no
ATTACH) are equally subscribers, and a consumer that only ever sent
ATTACH_TERMINAL MUST receive TERMINAL_CLOSED. This is not a nicety: the
content stream ends silently on a Terminal’s death, so without the lifecycle
frame a dead Terminal is indistinguishable from a quiet one, and the consumer
most exposed to that is exactly the one watching a single Terminal — an agent
observing one pane, or a federation hub’s proxy subscription, neither of which
attaches to a session. A client subscribed by both paths at once MUST receive
the frame exactly once, not once per path.
TERMINAL_RESIZE is sent in addition to (not in place of) VIEWPORT_RESIZE:
the outer-viewport frame conveys the client’s smallest-common-bounding-box;
TERMINAL_RESIZE conveys the resolved per-Terminal dimensions after the
client’s layout walk. The server’s PTY layer drives ioctl(TIOCSWINSZ) from
this. Implementations SHOULD treat cols or rows of zero as a no-op rather
than a kernel error; the wire codec round-trips zero faithfully.
TERMINAL_RESIZE is the C→S resize frame. The S→C TERMINAL_RESIZED
discriminant at 0x92 is spec-only for now; it lands when multi-client
per-Terminal resize fan-out (§9.2) is needed for non-attaching observers.
Cross-window move (ADR-0056):
MOVE_TERMINAL { request_id: u32,
terminal: TerminalId,
owner_terminal: TerminalId }
TERMINAL_MOVED { request_id: u32, result: MoveResult }
MoveResult = tagged_union {
OK (TerminalId), // tag 0x00; the moved Terminal's unchanged id
ERR (MoveError), // tag 0x01
}
MoveError = tagged_union {
MOVE_FAILED (str), // tag 0x00; UTF-8 diagnostic
UNSUPPORTED_SATELLITE_ROUTE, // tag 0x01; empty body
// #[non_exhaustive] — future codes MAY be added without bumping major.
}
MOVE_TERMINAL re-parents the live Terminal terminal into the window
that currently owns owner_terminal — possibly in a different session —
without touching its process, PTY, scrollback, metadata, or agent record.
owner_terminal is an ownership address exactly as in
SPAWN_TERMINAL.owner_terminal: it conveys no split direction, ratio, or
focus, and after a successful TERMINAL_MOVED a layout-aware caller
publishes geometry through L3 metadata — two envelope writes, one per
session, with the caller issuing the inverse MOVE_TERMINAL if the
destination write fails (ADR-0056). The server MUST perform the re-parent
atomically under its state lock, and MUST reap a source window the move
emptied by the same rules as pane death. The moved Terminal’s id is stable
across the move: subscriptions, per-Terminal metadata, and outstanding
waits survive. If reaping removes the source session, the server MUST detach
session-attached clients observing that now-nonexistent session; independent
per-Terminal subscriptions survive. When the moved Terminal was the source
window’s only pane, no owner_terminal remains there for an inverse move, so
a destination-publication failure is the documented best-effort rollback case:
the caller reports that ownership could not be restored, tells the user to
inspect the Terminal’s current location, and names insert-pane as recovery.
The operation is local-only: a satellite-tagged id on either end is
refused with UNSUPPORTED_SATELLITE_ROUTE. A missing Terminal on either
end, or an owner_terminal with no owning window, is MOVE_FAILED with
a diagnostic.
MOVE_TERMINAL is gated on the MOVE_TERMINAL server feature bit
(proto.md §6.2): a client MUST NOT send it to a server that
does not advertise the bit — an older server treats the discriminant as an
unknown frame and drops it, so an ungated sender would wait forever.
3.2 BELL
BELL { terminal_id: TerminalId }
The Terminal received a bell character. The server MUST NOT translate this into VT output; clients decide policy.
3.3 TERMINAL_EVENT
Status: spec-only. The frame has no codec entry, so nothing below is observable on the wire today. A consumer that needs OSC-derived facts reads them from its own engine over the
TERMINAL_OUTPUTbytes, which carry the same sequences. TheTerminalEventtype inphux-clientis that consumer-side projection, not this frame.
A channel for terminal-originated events the server has parsed (via libghostty-vt’s OSC parser) and chooses to surface to clients. It is how an L1-only consumer (agent, recorder, CI orchestrator) answers questions such as “did the command finish, what was the exit code, what directory am I in?”
TERMINAL_EVENT {
terminal_id: TerminalId,
event: TerminalEventBody,
}
TerminalEventBody = tagged_union {
TITLE { title: str }, // OSC 0/1/2
CHANGE_WINDOW_ICON, // OSC 1 (icon-only)
CURRENT_DIR { uri: str }, // OSC 7
HYPERLINK_START { id: u32, uri: str, params: str }, // OSC 8 begin
HYPERLINK_END { id: u32 }, // OSC 8 end
USER_NOTIFICATION { body: str, tag: optional<str> }, // OSC 9 / iTerm2 / OSC 777
SEMANTIC_PROMPT { kind: PromptMarkKind, info: optional<str> }, // OSC 133
CLIPBOARD { selection: ClipboardSelection, data: bytes }, // OSC 52
MOUSE_SHAPE { shape: str }, // OSC 22
PROGRESS_REPORT { state: ProgressState, value: optional<u8> }, // ConEmu OSC 9;4
EXIT_CODE { code: i32 }, // synthesized at PTY exit
CUSTOM { kind: u32, payload: bytes }, // pass-through escape hatch
}
PromptMarkKind = enum {
PROMPT_START = 1, // OSC 133;A
COMMAND_START = 2, // OSC 133;B
COMMAND_END = 3, // OSC 133;C
PROMPT_END = 4, // OSC 133;D (optional exit code in `info`)
}
ProgressState = enum {
REMOVE = 0,
DEFAULT = 1,
ERROR = 2,
INDETERMINATE = 3,
WARNING = 4,
PAUSED = 5,
}
ClipboardSelection = enum {
SYSTEM = 0,
PRIMARY = 1,
SECONDARY = 2,
}
The union carries synthesized non-OSC events (EXIT_CODE) alongside parsed OSC
sequences. The server does not forward every OSC type libghostty recognises.
Color operations, kitty color protocol commands, and kitty text-sizing are
purely terminal-state concerns; they are applied to the Terminal’s
libghostty_vt::Terminal and clients see their effect through normal cell
diffs. The variants above are those that affect client UX (chrome,
notifications, clipboard, status-bar widgets) or that an L1-only consumer needs
for command-boundary detection.
3.4 ALERT
Status: spec-only. No codec entry and no server-side activity or silence tracking.
BELL(§3.2) is the only one of these three signals a consumer receives today.
Server-internal notifications about a Terminal:
ALERT { terminal_id: TerminalId, kind: AlertKind }
AlertKind = enum {
ACTIVITY = 0, // Terminal wrote output while consumer was inactive
SILENCE = 1, // Terminal has been quiet for the configured threshold
BELL = 2, // duplicate of §3.2 for clients that prefer one channel
}
4. Terminal state synchronization
ADR-0070 defines two wire identities in addition to TerminalId:
StreamId = nonzero<u64> // one logical subscription on one connection
BootstrapId = nonzero<u64> // one replaceable replica generation in that stream
Zero is malformed. Every bootstrap, history, live output, and StateSync ACK frame carries all three identities. A generation is immutable once announced; replacement always allocates a new BootstrapId.
4.1 Live frame model
TERMINAL_OUTPUT { // 0x90, field-id order
terminal_id: TerminalId, // 1
seq: u64, // 2
bytes: bytes, // 3
stream_id: StreamId, // 4
bootstrap_id: BootstrapId, // 5
}
The Terminal actor stamps checked, non-wrapping sequence values before
broadcast. Within (terminal, stream, bootstrap), output is contiguous. A
bootstrap’s inclusive actor cut covers seq <= base_seq; the first live frame
after READY is exactly base_seq + 1. Receivers reject duplicate, decreasing,
gapped, wrong-generation, or post-tombstone frames and request a new cut.
For NativeState, bytes are exact PTY bytes. The server, hub, transport,
recorder, and client MUST NOT color-downsample, strip images/hyperlinks, scan,
or rewrite them. For the two synthesized profiles the compatibility emitter
semantics in proto.md §6.2 apply.
FRAME_ACK is valid only for SynthesizedVtStateSync, is cumulative after
application, and includes terminal=1, seq=2, stream=3, bootstrap=4. Raw
profiles do not ACK; neither bootstrap publication nor raw release waits an RTT.
4.2 Cells, cursor, modes, and engine ownership
Cells, parser continuation, graphics, active screen, history pages, cursor, modes, selections, and semantic anchors are engine-owned, not phux wire structures. A native consumer hands opaque payload bytes directly to the exact selected libghostty codec. phux never scans checkpoint magic/records, READY, FINISH, Page layout, pointers, padding, allocator, or mappings and never synthesizes a native record.
4.3 Bootstrap frames and exact field order
BOOTSTRAP_BEGIN { // 0x93
terminal_id: TerminalId, // field 1
stream_id: StreamId, // field 2
bootstrap_id: BootstrapId, // field 3
codec: BootstrapCodec, // field 4
cols: u16, // field 5, nonzero
rows: u16, // field 6, nonzero
output_mode: OutputMode, // field 7
base_seq: u64, // field 8, inclusive cut
}
BootstrapCodec = tagged_union {
SynthesizedVtV1, // tag 0
Native { engine_version: u8 } // tag 1; v2 = 2
}
BOOTSTRAP_CHUNK { // 0x94
terminal_id: TerminalId, // 1
stream_id: StreamId, // 2
bootstrap_id: BootstrapId, // 3
chunk_seq: u32, // 4, starts at zero and is contiguous
payload: bytes, // 5, opaque
}
BOOTSTRAP_READY { // 0x95
terminal_id: TerminalId, // 1
stream_id: StreamId, // 2
bootstrap_id: BootstrapId, // 3
history_cursor: optional<bytes>, // 4, absent = no retained history
}
The BEGIN codec/output_mode pair admits exactly: native-v2 + Raw,
synthesized-v1 + Raw, or synthesized-v1 + StateSync. Native + StateSync is
malformed. It MUST also match the connection’s HELLO_OK profile.
Chunks may split engine records arbitrarily; chunk_seq orders fragments, not
records. Native payload integrity and grammar validation belong exclusively to
libghostty. BOOTSTRAP_READY is emitted only after all bytes through the
selected codec’s READY boundary. The client decodes into invisible staging and
atomically publishes only when its engine has reached READY and it consumes the
matching protocol READY. Reliable ordering permits the first raw live frame
immediately afterward; there is no client bootstrap ACK.
4.4 Inclusive actor cut, pane order, and fairness
A producer subscribes/drains first, then asks the Terminal actor for one
inclusive cut in the same actor turn. The actor applies all bytes through
base_seq, captures authoritative geometry, increments the generation, and
returns a bounded immutable lease/COW view. The coordinator discards subscribed
duplicates <= base_seq and queues only contiguous output > base_seq, bounded
by bytes and age. The PTY is never paused for network delivery.
For a multi-pane ATTACH, send ATTACHED, then one BEGIN per pane in stable
snapshot traversal order. Emit bounded chunks round-robin across panes. Each
pane’s READY immediately releases that pane’s live queue; a large checkpoint
cannot block another pane’s first render. ATTACH_READY follows once all panes
are READY or closed. History never blocks live writes or aggregate attach
readiness.
4.5 Native client-pull history
These frames are legal only under the negotiated NativeState profile:
HISTORY_REQUEST { // 0x16
terminal_id: TerminalId, // 1
stream_id: StreamId, // 2
bootstrap_id: BootstrapId, // 3
cursor: bytes, // 4, opaque stable lease capability
max_bytes: u32, // 5, requested byte budget
max_rows: u32, // 6, requested row budget
}
HISTORY_PAGE { // 0x96
terminal_id: TerminalId, // 1
stream_id: StreamId, // 2
bootstrap_id: BootstrapId, // 3
cursor: bytes, // 4, echoes request cursor
next_cursor: optional<bytes>, // 5, next older lease capability
payload: bytes, // 6, opaque selected-codec records
page_seq: u64, // 7, nonzero
rows: u32, // 8, authenticated history row count
}
HISTORY_TOMBSTONE { // 0x98
terminal_id: TerminalId, // 1
stream_id: StreamId, // 2
bootstrap_id: BootstrapId, // 3
cursor: bytes, // 4, invalidated lease capability
reason: HistoryTombstoneReason, // 5
}
HistoryTombstoneReason = enum {
Stale = 0, Pruned = 1, Reset = 2, Resize = 3,
Expired = 4, Released = 5, Limit = 6, CodecFailure = 7,
}
HISTORY_REJECTED { // 0x99
terminal_id: TerminalId, // 1
stream_id: StreamId, // 2
bootstrap_id: BootstrapId, // 3
cursor: bytes, // 4, not advanced
reason: HistoryRejectionReason, // 5
required_bytes: u32, // 6, nonzero retry minimum
required_rows: u32, // 7, nonzero retry minimum
}
HistoryRejectionReason = enum {
ZeroLimit = 0, TooSmall = 1, Busy = 2,
}
History is newest-to-oldest, at most one request outstanding per logical
stream, and lower priority than live/READY-prefix work. Explicit scroll demand
outranks prefetch. cursor is a stable engine-owned lease capability, not a
page identifier. It is echoed byte-identically by every response in its
lineage and is capped at 4 KiB.
page_seq starts at 1 for each
(terminal_id, stream_id, bootstrap_id, cursor) and advances with checked
addition. A repeated sequence with byte-identical payload, rows, and
next_cursor is idempotent. The same sequence with different content, a gap,
or sequence exhaustion invalidates that history cursor and requires
HISTORY_TOMBSTONE plus a fresh history lease/request; it does not invalidate
the live generation.
Clients request nonzero max_bytes and max_rows. Before engine work, the
server clamps both budgets to the request, the negotiated byte bound, the
4,096-row protocol bound, and its engine/resource limits. A zero budget,
next record too large for either effective budget, or temporary capture
contention returns HISTORY_REJECTED without advancing the cursor. Ghostty
OutOfSpace maps to TooSmall with exact nonzero required budgets, never to a
tombstone. required_bytes cannot exceed the negotiated page bound and
required_rows cannot exceed 4,096; a client may clamp and retry the same
cursor.
Every response is validated against both effective request budgets before
client import. rows is the engine-authenticated number of history rows in the
opaque payload and may be zero only when that payload contains exclusively
non-row HISTORY_BEGIN/FINISH records. next_cursor = None is legal only on
the final page whose payload engine-authenticates FINISH and reaches the
beginning of retained history.
Pages are generation-bound and independently engine-validated; applying them
MUST NOT change active screen, parser continuation, cursor, modes, raw live
sequence, or input. HISTORY_TOMBSTONE invalidates only the exact progressive
history cursor and its derived cache. It never retires the BootstrapId or
affects live/raw/input state. Stale, pruned, reset, resized, expired, released,
bounded-limit, and history-codec failures use its typed reasons. A nearby range
is never substituted.
4.6 Tombstone and resume
BOOTSTRAP_TOMBSTONE { // 0x97
terminal_id: TerminalId, // 1
stream_id: StreamId, // 2
bootstrap_id: BootstrapId, // 3
reason: TombstoneReason, // 4
last_valid_seq: u64, // 5
}
TombstoneReason = enum {
RawReplayOverflow = 0,
OutboundGap = 1,
Resize = 2,
RelayReconnect = 3,
ExplicitReattach = 4,
CodecFailure = 5,
Other = 6,
}
The server sends a bootstrap tombstone before any replacement BEGIN. After it, no chunk, READY, history page/status, output, or ACK carrying the old BootstrapId is legal; an already-in-flight history response is fenced rather than emitted after retirement. The client keeps its last published terminal visible until a replacement reaches READY.
Reconnect resumes only when authenticated server incarnation, TerminalId, selected profile/codec/features, stream mapping, BootstrapId, and last contiguous sequence all prove continuity. Otherwise it takes a fresh cut. Sequence gap/wrap, bounded raw/outbound overflow, live engine-integrity failure, authoritative resize, relay reconnect, stale generation, or explicit reattach require a bootstrap tombstone and replacement. Cursor-only history failures use §4.5 statuses and MUST NOT reach this path.
4.7 Resource bounds
HELLO_OK’s max_chunk_bytes and max_history_page_bytes are nonzero,
per-axis minima capped at 8 MiB; the hard outer frame cap remains 16 MiB.
History row requests/responses are capped at 4,096. Reference advertisements
are 256 KiB chunks and 1 MiB pages. Implementations validate response limits
before allocation/import and clamp request budgets before engine work. Capture
leases, post-cut queues by bytes and age, per-client outbound queues, history
jobs, and local caches remain independently bounded. Raw/live overflow uses a
bootstrap tombstone; progressive history exhaustion uses
HISTORY_REJECTED or HISTORY_TOMBSTONE, never silent loss or unbounded
retention.
5. L1 commands
Commands are typed messages, not strings. They are sent over the same connection
and correlated via request_id. Commands are partitioned by tier; the server
MUST reject (with ERROR { code: INVALID_COMMAND }) any command outside the
negotiated tier set (proto.md §6.1).
COMMAND { request_id: u32, cmd: Command }
COMMAND_RESULT { request_id: u32, result: CommandResult }
CommandResult = tagged_union {
OK,
OK_WITH(CommandValue),
ERROR(ErrorCode, str),
}
CommandValue = tagged_union {
TERMINAL_ID(TerminalId),
GROUP_ID(GroupId), // opaque grouping key, not a tier
STATE(StateSnapshot),
JSON(str), // for structured returns
BYTES(bytes), // for L3 metadata values
FILE_UPLOAD { next_offset: u64, path: optional<str> }, // tag 0x05 (ADR-0059)
}
A COMMAND is asynchronous: the server MAY emit other messages (including
events relevant to the command’s effect) before COMMAND_RESULT. Clients MUST
tolerate that ordering.
5.1 L1 command catalog (Terminal substrate)
Command_L1 = tagged_union {
SPAWN { cwd: optional<str>, command: optional<list<str>>,
initial_size: optional<{cols: u16, rows: u16}>,
group: optional<GroupId> }, // opaque grouping key
ATTACH_TERMINAL { terminal_id: TerminalId, // tag 0x01
role_policy: RolePolicy }, // (role_policy not yet encoded; §8.1 default applies)
DETACH_TERMINAL { terminal_id: TerminalId }, // tag 0x02
KILL_TERMINAL { terminal_id: TerminalId },
GET_SCREEN { terminal_id: TerminalId, // tag 0x07
request_scrollback: optional<u32>, cells: bool },
ROUTE_INPUT { terminal_id: TerminalId, event: InputEvent },// tag 0x08
KILL_TERMINALS { ids: list<TerminalId> }, // tag 0x09
RESIZE_TERMINAL { terminal_id: TerminalId, cols: u16, rows: u16 },
GET_STATE { scope: StateScope },
RUN_HOOK { name: str, args: list<str> },
GET_TERMINAL_STATE { terminal_id: TerminalId }, // tag 0x0c
SUBSCRIBE_TERMINAL_EVENTS { terminal_id: TerminalId }, // tag 0x0d
UPGRADE, // tag 0x0e; no payload (ADR-0032)
ACQUIRE_INPUT { terminal_id: TerminalId, mode: InputMode, // tag 0x0f (ADR-0033)
ttl_ms: u32 },
RELEASE_INPUT { terminal_id: TerminalId }, // tag 0x10 (ADR-0033)
SIGNAL_TERMINAL { terminal_id: TerminalId, // tag 0x11 (ADR-0033)
signal: TerminalSignal },
REPORT_ASKED { terminal_id: TerminalId, // tag 0x12 (ADR-0036)
id: str, question: str,
suggestions: list<str>,
elapsed_seconds: optional<u64> },
APPLY_INPUT { operation_id: bytes16, // tag 0x14 (ADR-0053)
terminal_id: TerminalId,
events: list<InputEvent> },
PUT_FILE { upload_id: bytes16, // tag 0x15 (ADR-0059)
terminal_id: TerminalId, extension: str,
offset: u64, data: bytes, final_chunk: bool,
sha256: optional<bytes32> },
SHUTDOWN, // tag 0x16; no payload
REPORT_AGENT_STATE { terminal_id: TerminalId, // tag 0x17 (ADR-0085)
state: ReportedAgentState },
GET_PERF { reset: bool }, // tag 0x18 (ADR-0096)
TRANSCRIBE { upload_id: bytes16, terminal_id: TerminalId }, // tag 0x19
}
InputMode = enum (u8) { // ACQUIRE_INPUT acquisition mode
COOPERATIVE = 0, // grant only if the lease is free
SEIZE = 1, // preempt the current holder
}
TerminalSignal = enum (u8) { // SIGNAL_TERMINAL signal selector
INTERRUPT = 0, FREEZE = 1, RESUME = 2, TERMINATE = 3, KILL = 4,
}
ReportedAgentState = enum (u8) {
WORKING = 0, BLOCKED = 1, DONE = 2,
}
InputEvent = tagged_union {
KEY(KeyEvent), // INPUT_KEY atom (input.md §2)
MOUSE(MouseEvent), // INPUT_MOUSE atom (input.md §3)
FOCUS(FocusEvent), // INPUT_FOCUS atom (input.md §4)
PASTE(PasteEvent), // INPUT_PASTE atom (input.md §5)
}
SPAWN returns a TerminalId and asynchronously emits TERMINAL_OPENED
(§9.1). collection is the opaque grouping key from §3.1, not a parent in a
lifecycle tier. SPAWN is not role-gated; the creating client becomes PRIMARY
for the newly spawned Terminal unless a future revision adds an explicit spawn
role.
ATTACH_TERMINAL and DETACH_TERMINAL are per-consumer subscription
operations; they do not affect the Terminal’s existence. A Terminal MAY be
attached by multiple clients simultaneously; the server multicasts. ATTACH_TERMINAL
wires the calling client to receive TERMINAL_OUTPUT with the role and takeover
semantics of §8.1.
ATTACH_TERMINAL (tag 0x01) and DETACH_TERMINAL (tag 0x02) shipped in
v0.5.0-draft.15 (phux-v45.7) with the following reference semantics.
ATTACH_TERMINAL registers the caller as an output subscriber, allocates a
nonzero StreamId and fresh BootstrapId, sends the selected profile’s
BEGIN/CHUNK/READY sequence, then streams generation-bound TERMINAL_OUTPUT.
The subscription also opens attach-scoped INPUT_*; it opens FRAME_ACK only
for SynthesizedVtStateSync. A session-scoped ATTACH is not required.
Re-attach tombstones any current generation and takes a fresh cut without
duplicating the logical stream. It does not resize the Terminal; callers follow
with TERMINAL_RESIZE. The role_policy field is not yet encoded, so the
existing default remains { requested_role: PRIMARY, takeover: NEVER }.
DETACH_TERMINAL drops the caller’s
per-Terminal output subscription and its per-Terminal SUBSCRIBE_EVENTS
scope; it is idempotent (Ok for unknown Terminals and never-attached
callers, so a detach can never race a natural close into an error). This
pair is the unit the federation hub relays for two-hop attach (§9.1).
KILL_TERMINAL terminates one Terminal’s underlying PTY and asynchronously
emits TERMINAL_CLOSED. It is the single-Terminal destroy verb.
SHUTDOWN (tag 0x16) stops the server process, ending every Terminal it
holds. It is the only command whose subject is the server rather than a
Terminal or a collection, and it carries no payload.
A server MUST reply COMMAND_RESULT { Ok } before it begins tearing down, and
MUST then terminate as if it had been stopped by any other means — every
Terminal reaped through the ordinary close path, so consumers observe the
lifecycle they already handle rather than a bare transport drop. The
connection closes as part of that teardown, so a consumer MUST treat a
disconnect following its request as success, not as a failure to stop.
SHUTDOWN is gated on the SHUTDOWN server feature (§6.2 of
proto.md); a client MUST NOT send it unless the bit is
advertised, because a server that predates the tag drops it silently and
“nothing happened” is indistinguishable from “the server declined”.
SHUTDOWN requires both SIGNAL on the Global selector and an
owner-authenticated UDS transport under phux-workload/v1
(workload-auth.md §7). The owner UDS receives the
operator grant in local policy; a paired UDS needs the same scope explicitly.
A remote paired grant cannot stop the server, regardless of its scope bits.
REPORT_AGENT_STATE feeds high-priority lifecycle evidence into the target
pane’s detector. It does not write phux.agent/v1; the detector publishes via
the ordinary L3 arbiter and the next screen derivation may supersede it. The
server retains a report that narrowly precedes its first identity poll for at
most 1.5 seconds, then publishes it only if agent identity resolves. idle is
not reportable. Clients MUST observe the REPORT_AGENT_STATE feature bit
before sending the command.
GET_PERF { reset } returns the server’s in-process performance telemetry as
COMMAND_RESULT { OkWith(Json(report)) }. The JSON is a phux_perf::PerfReport
carrying its own schema_version, the reporting process id and uptime, a
getrusage(2) section, and one entry per metric: a latency or size histogram
(non-empty log-linear buckets plus count, sum, min, max), a monotone counter,
or a gauge. Metric names are diagnostic and are not part of this contract; a
consumer MUST tolerate names appearing and disappearing between servers and
MUST NOT fail on an unknown kind. reset = true zeroes every metric after
the snapshot is taken, so consecutive resetting requests read as intervals.
The command carries no session or Terminal state and is allowed for viewers;
a server MAY restrict it by transport. Clients MUST observe the GET_PERF
feature bit before sending the command. See ADR-0096.
TRANSCRIBE { upload_id, terminal_id } turns a completed PUT_FILE upload
(its final chunk acknowledged, §6.2.2) into text with the server’s configured
transcriber and pastes that text into terminal_id as one acknowledged
APPLY_INPUT batch under a server-minted operation id, so a reconnect between
the reply and the paste cannot double it. The reply is
COMMAND_RESULT { OkWith(Json(result)) } where result carries its own
schema_version, the text the transcriber returned, pasted (false when
the transcriber heard nothing, in which case nothing was written), and timing
fields; the JSON field set beyond schema_version/text/pasted is
diagnostic. The paste INSERTS and never submits: a consumer shows text and
lets the user press Enter. A server with no transcriber configured MUST refuse
with INVALID_COMMAND and a remedy naming [voice] transcriber; an unknown or
unfinished upload id is refused with INVALID_COMMAND; a transcriber that
exceeds its deadline is refused with RESOURCE_EXHAUSTED; a satellite-routed
Terminal is refused with UNSUPPORTED_SATELLITE_ROUTE. The transcript is
capped at 64 KiB. Clients MUST observe the TRANSCRIBE feature bit before
sending the command.
KILL_TERMINALS { ids } (§5.2) is the atomic group-teardown verb.
RESIZE_TERMINAL resizes one Terminal. KILL_TERMINAL, KILL_TERMINALS, and
RESIZE_TERMINAL require the caller to be PRIMARY for each target Terminal. A
server receiving any of them from a viewer MUST return
COMMAND_RESULT { result: ERROR(PERMISSION_DENIED, ...) } and MUST NOT perform
the operation. DETACH_TERMINAL and GET_STATE are allowed for viewers.
Status: spec-only. Every role gate in this section — here, on
ROUTE_INPUT(§6.2), and onKILL_TERMINALS(§5.2) — depends on the subscription roles of §8.1, which no server implements. The trust boundary that is actually enforced today is the transport’s: one server per user, authenticated peer (proto.md §10).
Three entries in the catalog above are also unallocated:
Status: spec-only.
SPAWN,RESIZE_TERMINAL, andRUN_HOOKhave no command tag and decode asUnknownEnumValue. Spawning and resizing ride the dedicatedSPAWN_TERMINAL/TERMINAL_RESIZEframes (§3.1) instead; hooks are a server-side config surface (docs/consumers/tui.md §9), not a wire verb.
GET_SCREEN, ROUTE_INPUT, APPLY_INPUT, PUT_FILE,
GET_TERMINAL_STATE, and SUBSCRIBE_TERMINAL_EVENTS are the live agent
affordances; §6 frames them.
ACQUIRE_INPUT, RELEASE_INPUT, and SIGNAL_TERMINAL are the supervisory
verbs (ADR-0033, “take the wheel + kill”). ACQUIRE_INPUT asserts an
exclusive input lease over a Terminal: while held, only the holder’s input
(INPUT_* frames, ROUTE_INPUT, and APPLY_INPUT) reaches the PTY. Blocked
fire-and-forget input is dropped; blocked APPLY_INPUT is refused with
ERROR(INPUT_LEASE_HELD) before any write.
mode = COOPERATIVE grants only if the Terminal is unheld and otherwise
replies ERROR(INPUT_LEASE_HELD); mode = SEIZE preempts the current holder.
ttl_ms is advisory; the reference server holds the lease until the holder
releases it (RELEASE_INPUT) or its connection drops. SIGNAL_TERMINAL
delivers a POSIX signal to the Terminal’s process group — distinct from
KILL_TERMINAL, which removes the pane; SIGNAL_TERMINAL signals the process
and leaves the pane addressable. FREEZE (SIGSTOP) / RESUME (SIGCONT) is the
reversible brake. Each lease change and signal broadcasts a terminal_control
event (§6.5) to every subscriber.
5.2 KILL_TERMINALS — atomic group teardown
KILL_TERMINALS { ids: list<TerminalId> } // tag 0x09
Wire body: a u16 count followed by that many tagged TerminalIds
(field-tagged TLV, per appendix-encoding.md).
KILL_TERMINALS is the one irreducible group operation. A consumer-side
projection cannot tear down N Terminals atomically: killing them one at a time
exposes intermediate states and races a concurrent observer. The server applies
the whole list under its single std::sync Mutex<ServerState> lock in one
acquisition — all-or-nothing for a local server (cross-host atomicity is out of
scope). No observer sees a partial teardown.
The reply is COMMAND_RESULT { OK }, issued once the teardown is committed under
the lock; the per-Terminal TERMINAL_CLOSED notifications follow asynchronously
as each PTY reaps (a COMMAND MAY interleave other frames before its result,
§5). The caller MUST be PRIMARY for every Terminal in ids; if any target is
not owned or not found, the server rejects the whole command with
ERROR { PERMISSION_DENIED } or ERROR { TERMINAL_NOT_FOUND } and kills
nothing.
This verb replaces the former collection-teardown command. “Kill this group” is
a client-assembled id list, not a named server-side entity. The CLI verb
phux kill SESSION resolves the session’s member ids from L3 metadata
(L3.md) and issues one KILL_TERMINALS.
5.3 Group create and rename are not L1
There is no CREATE_SESSION, KILL_COLLECTION, or RENAME_SESSION command.
Group create and rename are not wire lifecycle; they decompose:
- Create a group:
SPAWN_TERMINAL(one or more) plus an L3 metadata write recording membership and a name. The conventions — the well-known keys (phux.session.create/v1, read back 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 §8). Input is fire-and-forget
(input.md §8): if the Terminal’s input mailbox is full the event is
dropped, but the command still acks OK (the event was accepted for delivery).
Allowed for primaries; the read-only GET_SCREEN remains the viewer-safe
surface.
6.2.1 APPLY_INPUT (0x14)
APPLY_INPUT is the acknowledged, reconnect-safe counterpart to
ROUTE_INPUT. It is available only when HELLO_OK.server_caps.features
contains ACKNOWLEDGED_INPUT (proto.md §6.2). A client MUST NOT
probe an older server by sending the unknown command.
Its nested positional command body is:
u8 command_tag = 0x14
bytes16 operation_id
TerminalId terminal_id
u16-BE event_count
InputEvent[event_count]
operation_id MUST be a non-zero 128-bit value generated from a CSPRNG and
MUST identify one immutable { terminal_id, events } payload. A batch MUST
contain 1..=256 events. The complete nested command body and the resulting
encoded PTY byte vector MUST each be at most 65,536 bytes. A server MUST reject
an over-limit body before allocating its event list or handing input to a pane.
The command is local-terminal only in this version. A SATELLITE target MUST
return ERROR(UNSUPPORTED_SATELLITE_ROUTE) without forwarding any event.
For a new operation, the server MUST resolve input authority, capture one
terminal-mode snapshot, and validate and encode every event before handing off
any bytes. It MUST reject an unsafe paste, failed event encoding, or authority
failure for the whole batch. It then submits the combined bytes as one ordered
PTY-writer job through a bounded queue. No other input job may interleave inside
that byte vector. If the pane has no PTY, or the bounded writer queue cannot
accept the job (full or already closed), no writer thread ever observes the
job and the result is INPUT_NOT_WRITTEN (phux-w7z2.60): the server MUST make
this determination without ever invoking the platform write call, and the
queue MUST NOT grow without bound.
COMMAND_RESULT { OK } means write_all and flush completed on the PTY
master and the server retained the operation’s dedupe record. It does not mean
the foreground program consumed the bytes or a shell command completed.
ERROR(INPUT_DELIVERY_UNKNOWN) means a writer thread attempted the write and
it failed, its completion was lost, or the bounded completion wait expired
after handoff to a writer that received the job; some or all bytes MAY have
reached the PTY. A client MUST NOT present that result as delivered, and MUST
NOT resubmit the operation under any id.
ERROR(CANONICAL_LIMIT_EXCEEDED) (phux-mjmc) means the write was refused with
zero bytes written: at write time the pane’s line discipline was in canonical
(ICANON) mode and the batch’s combined encoded PTY bytes contained a line —
a run between two terminators, or from the last terminator to the end of the
bytes — longer than the pane’s canonical-line byte limit, named in the error
message. Writing such a payload would silently truncate at the kernel’s
canonical-queue boundary instead of delivering it, and if the payload’s own
terminator falls past the truncation point, the terminator is dropped too,
permanently wedging the line. Unlike INPUT_DELIVERY_UNKNOWN, delivery here
is known, not merely unconfirmed: the server never called write_all. A
client MUST NOT retry the identical operation expecting a different outcome —
the payload itself must change (split into newline-terminated lines, sized
under the limit, or sent after the pane leaves canonical mode) — but MAY reuse
ROUTE_INPUT/INPUT_* framing for line-oriented input, which this guard does
not reject as long as each line stays under the limit.
ERROR(INPUT_NOT_WRITTEN) (phux-w7z2.60) means zero bytes were written and
the server can prove it: the pane has no PTY, the bounded writer queue was
full or its channel already closed, the pane’s own actor was already gone
before the batch reached its mailbox, or the operation was refused, or its
registration abandoned, at any other point strictly before a writer thread
took the job. Unlike INPUT_DELIVERY_UNKNOWN, no writer ever attempted the
write for this result. A client MAY resubmit the identical operation under
the same id (idempotent, per the caching rule below) or under a fresh id
(safe because nothing already written exists to duplicate) — the two
ERROR codes exist precisely so a client does not have to guess which
recovery an opaque INTERNAL_ERROR would have meant.
The server retains the canonical payload digest and final OK,
INPUT_DELIVERY_UNKNOWN, CANONICAL_LIMIT_EXCEEDED, or INPUT_NOT_WRITTEN
result by operation id for ten minutes, bounded to 65,536 entries. A same-id,
same-payload retry inside that horizon MUST return the cached result without
writing again. Same-id, different-payload reuse MUST return
ERROR(INVALID_COMMAND). A pre-handoff refusal retains the id-to-digest
binding but not the refusal result: the unchanged operation MAY be evaluated
again after its cause is repaired, while changed input requires a new id.
INPUT_NOT_WRITTEN spans both cases — a full or closed writer queue and a
pane with no PTY are decided at handoff and so are cached final results like
OK; every other INPUT_NOT_WRITTEN cause (a full or closed input lane, the
pane’s actor gone, or its completion tracking unavailable) is decided before
handoff and so is not — but a client’s recovery is identical either way:
resubmitting is always safe, under the same id or a fresh one.
The reference server admits at most one unresolved acknowledged operation per
Terminal. A concurrent operation against the same Terminal, or a full input
lane, is refused immediately with ERROR(RESOURCE_EXHAUSTED); the server MUST
NOT retain additional caller payloads outside its bounded lane while waiting for
PTY completion. Operations against distinct Terminals proceed concurrently, and
one Terminal’s unresolved write MUST NOT delay input to another Terminal. The
completion wait is bounded at five seconds, so a caller’s own deadline for the
submit leg must exceed that plus lane-queue time, and a backoff schedule for
RESOURCE_EXHAUSTED must tolerate a same-Terminal retry interval that long.
If a connection closes before the matching result, the client MAY reconnect
and resend the unchanged operation with a new connection-local request_id
only when HELLO_OK.server_id is unchanged and the ten-minute horizon has not
elapsed. If the server incarnation changed or the horizon elapsed, delivery is
unknown and the client MUST NOT replay automatically. This bounds idempotency
to one server incarnation while making loss of volatile dedupe state explicit
(ADR-0053).
6.2.2 PUT_FILE (0x15)
PUT_FILE transfers a local artifact to the host that owns terminal_id
without routing bytes through the PTY. It is available only when
HELLO_OK.server_caps.features contains FILE_UPLOAD
(proto.md §6.2). A client MUST NOT probe an older server by
sending the unknown command.
Its nested positional command body is:
u8 command_tag = 0x15
bytes16 upload_id
TerminalId terminal_id
str extension
u64-BE offset
bytes data
u8 final_chunk
u8 sha256_present
bytes32? sha256
upload_id MUST be a non-zero 128-bit value generated from a CSPRNG and MUST
identify one immutable file. extension MUST contain 1..=16 ASCII
alphanumeric bytes without a dot. data MUST be at most 8 MiB and
offset + len(data) MUST be at most 64 MiB. A non-final chunk MUST contain at
least one byte and no digest. A final chunk MUST carry the expected SHA-256 of
the complete file; an empty final chunk is valid when the previous chunk ended
on a boundary.
terminal_id selects the destination host and MUST resolve to a live Terminal.
For SATELLITE { host, id }, a federation hub rewrites the id to LOCAL and
relays the command; the satellite performs all storage and digest work. The
client supplies no destination path. The terminal-owning server writes only
under its mode-0700 upload sandbox and chooses
phux-upload-<upload_id>.<extension> as a mode-0600 final name.
The server MUST reject an offset gap. When an offset overlaps retained bytes,
the overlap MUST match exactly; the server appends only the unseen suffix. This
makes a same-id/same-offset retry idempotent across reconnect and server
restart. Same-id/different-byte or changed-extension reuse is
ERROR(INVALID_COMMAND).
Every retained chunk replies:
COMMAND_RESULT {
OK_WITH(FILE_UPLOAD {
next_offset: u64,
path: optional<str>,
})
}
next_offset is the complete contiguous byte count the server retained.
path MUST be absent until a final digest matches. Before returning a path the
server MUST flush and sync the partial file, atomically rename it to the final
name, and return that absolute path. A digest mismatch is
ERROR(INVALID_COMMAND) and leaves no final path visible. I/O failure is
ERROR(INTERNAL_ERROR); a size limit is ERROR(RESOURCE_EXHAUSTED).
Completed files remain until explicit user cleanup. A server MUST NOT silently delete a completed path on a timer while a terminal program may still consume it. Payload bytes and completed paths MUST NOT appear in routine telemetry (ADR-0059).
6.3 GET_TERMINAL_STATE (0x0c)
GET_TERMINAL_STATE returns an engine-derived snapshot of a single Terminal’s
non-grid state (the server’s view of its libghostty_vt::Terminal metadata) with
no side effects: it does not attach, subscribe, or resize. Like GET_SCREEN, the
returned structure is a convenience snapshot, not a normative wire type; a
carry-your-own-engine consumer reads the same facts from its local engine. The
reply is COMMAND_RESULT { OK_WITH(JSON(..)) } or
ERROR { TERMINAL_NOT_FOUND }. Allowed for viewers.
6.4 SUBSCRIBE_TERMINAL_EVENTS (0x0d)
SUBSCRIBE_TERMINAL_EVENTS registers the calling client for pushed EVENT
frames (§7) scoped to one Terminal, without attaching, subscribing for output, or
sending a snapshot. It is a pure push registration, so an agent can watch a
Terminal without disturbing the live session. The server-scoped subscription
variant is SUBSCRIBE_EVENTS (§7).
7. Agent event stream
The push half of the live agent affordances. A client subscribes to a stream of
extensible tagged lifecycle / activity events, and the server pushes EVENT
frames (0xB3) as those events occur. This is an additive accelerator of the
CLI-side poll-floor wait (which ships over GET_SCREEN, §6.1): match
conditions stay evaluated client-side, but an event wakes the waiter immediately
instead of on the next poll tick, cutting latency without changing correctness. A
consumer that ignores the stream entirely still converges via polling. Per §6,
this is a convenience accelerator, not a normative structured contract.
Wire bodies (field-tagged TLV, per appendix-encoding.md; fields listed in field-id order, leaf primitives and nested unions positional within each field):
SUBSCRIBE_EVENTS { terminal: optional<TerminalId> } // 0x41
EVENT { terminal: optional<TerminalId>, event: AgentEvent } // 0xB3
SUBSCRIBE_EVENTS.terminal scopes the subscription: Some(id) delivers only
that Terminal’s events (equivalent to SUBSCRIBE_TERMINAL_EVENTS, §6.4); None
delivers every event the server emits for any Terminal the client may observe
(server-scoped), including pane_spawned / pane_closed across the attached
group. Subscription is idempotent (re-subscribing the same scope is a no-op) and
is implicitly torn down on detach, matching SUBSCRIBE_METADATA (an explicit
UNSUBSCRIBE_EVENTS is future work). Subscribing does not attach, resize, or
send a snapshot.
EVENT.terminal identifies the Terminal the event concerns, or None for a
server-scoped event with no single owning Terminal.
7.1 AgentEvent taxonomy
AgentEvent is an extensible tagged union, encoded TLV: a tag: u8 followed by a
length-prefixed body: bytes. The length prefix is the forward-compatibility
lever — a decoder that does not recognise tag reads (and skips) the declared
body length and surfaces the event as an opaque Unknown { tag, body }, so a
later minor version MAY add event kinds and an older client skips them cleanly
rather than failing the frame parse. Tags are allocated sequentially:
| 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.pane_spawnedis emitted for every Terminal the server creates, not only those a client asked for by name: aSPAWN_TERMINALadding a pane to an existing session, and equally the seed pane of a newly created session — whether that session came fromATTACH { CreateIfMissing }(§3.1) or from the headlessphux.session.create/v1L3 write (L3.md §3.1), which creates without attaching. A server-scoped subscriber therefore observes session creation, not merely the creations it happened to request.pane_closedis sourced from the PTY-EOF /KILL_TERMINALpath that emitsTERMINAL_CLOSED(§3.1), and itsexit_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 publication on attach
An ATTACH carries field-tagged TLV in this order:
ATTACH {
target: AttachTarget, // field 1
viewport: ViewportInfo, // field 2
request_scrollback: bool, // field 3
scrollback_limit_lines: u32, // field 4
attach_id: u32, // field 5, client correlation
}
ATTACHED {
snapshot: SubstrateSnapshot, // field 1; metadata/graph only
initial_client_id: ClientId, // field 2
attach_id: u32, // field 3, echoes ATTACH
}
ATTACH_READY { attach_id: u32 } // 0x83, field 1
AttachTarget::Last is server-resolved. A live most-recently-touched session
MUST win. When the server has no touch history, but was started with a
configured seed session, Last MUST resolve to that seed while it remains
live. The configured identity is the server’s effective seed name (after any
server-side or launch-time template resolution), not a name reconstructed by
the attaching client. If there is touch history but no touched session remains
live, or if an untouched server has no live configured seed, the server MUST
return ERROR { code: SESSION_NOT_FOUND }.
Last is lookup-only. A server MUST NOT create or recreate a session while
resolving it, and a client MUST NOT reinterpret SESSION_NOT_FOUND as
permission to retry with CreateIfMissing. Creation remains an explicit
caller choice expressed by AttachTarget::CreateIfMissing.
AttachTarget and SubstrateSnapshot retain their existing tagged/positional
layouts. ATTACHED carries no terminal content. For every Terminal in stable
snapshot traversal order the server allocates a nonzero StreamId and fresh
BootstrapId, then emits §4 BEGIN/CHUNK/READY. Chunk turns are round-robin.
Per-pane live output begins immediately after that pane’s READY. Once every
pane has reached READY or closed, the server sends matching ATTACH_READY.
History pages are pulled after READY and never delay ATTACH_READY.
The client stages each pane invisibly and publishes it at matching READY. It
publishes the aggregate attachment only at ATTACH_READY, while panes already
published remain eligible for live updates. request_scrollback permits the
server to include a READY cursor and the client to issue bounded page requests;
it does not make history part of attach readiness. scrollback_limit_lines is
a client retention preference, not permission to parse native history.
Sequence continuity is explicit rather than inferred: BEGIN declares the
inclusive base_seq and the first live output is exactly base_seq + 1.
Reconnect uses §4.6 proof or a fresh generation; no snapshot-era implicit base
or stale output survives.
8.1 Terminal roles and takeover policy
Status: spec-only. No part of this section is implemented.
phux-serverhas no role state,RolePolicyis not encoded on eitherATTACHorATTACH_TERMINAL, and every subscription behaves as an unconstrained primary — thePERMISSION_DENIEDrefusals named in §5.1 and input.md §8 never fire. Read the MUSTs below as the contract role enforcement will have to satisfy, not as behavior to test against a live server. The default this section specifies,{ PRIMARY, NEVER }, is also the shape that would make every existingATTACH_TERMINALcaller — including the observer the recorder uses (ADR-0060) — start failing against an attached TUI the day it lands, so landing it means giving observers an explicitVIEWERrequest first.
Every client-to-Terminal subscription has a TerminalRole chosen by
RolePolicy on ATTACH and ATTACH_TERMINAL. Roles are per Terminal, not per
transport and not per group. A client attached to multiple Terminals MAY be
PRIMARY for one and VIEWER for another.
RolePolicy {
requested_role: TerminalRole,
takeover: TakeoverPolicy,
}
TerminalRole = enum {
PRIMARY = 0,
VIEWER = 1,
}
TakeoverPolicy = enum {
NEVER = 0, // fail rather than displace an existing primary
DELIBERATE = 1, // explicitly displace an existing primary
}
The server MUST maintain at most one PRIMARY subscription per Terminal at a
time. Any number of VIEWER subscriptions MAY coexist. Both roles receive the
Terminal’s output, snapshots, and terminal-originated events, subject to the
usual tier and subscription rules. Only PRIMARY may send Terminal input or
terminal-mutating commands (input.md, §5.1 above).
When requested_role = VIEWER, takeover MUST be NEVER; non-NEVER takeover
on a viewer attach is invalid and MUST be rejected with
ERROR { code: MALFORMED_MESSAGE } for ATTACH or
COMMAND_RESULT { result: ERROR(INVALID_COMMAND, ...) } for ATTACH_TERMINAL.
When requested_role = PRIMARY and no primary exists for a target Terminal, the
server grants PRIMARY. When a primary already exists:
- If
takeover = NEVER, the server MUST reject the request 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
Status: spec-only.
TERMINAL_OPENEDhas no codec entry; a consumer learns about a new Terminal from theTERMINAL_SPAWNEDreply to its ownSPAWN_TERMINAL(§3.1), which is why nothing observes Terminals it did not create. The wideExitStatusunion below is likewise unbuilt:TERMINAL_CLOSEDships the compactexit_status: optional<i32>of §3.1, so a signal kill is indistinguishable from an unknown cause.
TERMINAL_OPENED {
terminal_id: TerminalId,
initial_size: { cols: u16, rows: u16 },
cwd: str,
command: list<str>,
}
TERMINAL_CLOSED {
terminal_id: TerminalId,
exit_status: optional<ExitStatus>,
}
ExitStatus = tagged_union {
EXITED(u8), // process called _exit(n)
SIGNALED(u8), // killed by signal n
UNKNOWN,
}
TerminalId is a tagged union per
ADR-0016,
federation-routable like every other identity in the protocol:
TerminalId = tagged_union {
LOCAL { id: u32 }, // tag = 0
SATELLITE { host: str, id: u32 }, // tag = 1; federation routing (ADR-0007)
}
A non-federated server only ever constructs LOCAL. Decoders MUST accept the
SATELLITE tag; a server that is not configured as a federation hub (or a
hub whose satellite registry has no entry for host) MUST respond with an
ERROR { code: UnsupportedSatelliteRoute } (proto.md §9) rather
than failing the frame.
Satellite routing on a federation hub (ADR-0007 §4). A server started as a
hub (phux server --hub) relays frames whose TerminalId carries the
SATELLITE tag over the hub’s outbound link to the named satellite:
-
Outbound leg. The hub rewrites
SATELLITE { host, id }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),INPUT_*,HISTORY_REQUEST, StateSync-onlyFRAME_ACK,TERMINAL_RESIZE, 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, BEGIN/CHUNK/READY,HISTORY_PAGE,HISTORY_TOMBSTONE,HISTORY_REJECTED,BOOTSTRAP_TOMBSTONE,TERMINAL_OUTPUT,TERMINAL_CLOSED,BELL) are re-taggedLOCAL { id }toSATELLITE { host, id }. Checkpoint/history/cursor/live payload bytes remain byte-identical. A satellite-originatedSATELLITEtag is dropped: routes never chain. -
Reachability. A relayable frame for a satellite whose link is down, still dialing, or refused fail-closed (ADR-0038) fails fast with
ERROR { code: SatelliteUnreachable }— never an indefinite wait. When a satellite link drops, the hub fails every in-flight relayed command 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. The hub relays
ATTACH_TERMINALatomically with proxy registration; an error rolls registration back. The satellite remains codec authority. The hub owns only connection-local StreamId/BootstrapId mapping, generation/watermark tracking, bounds, and lifecycle: it re-tags TerminalId, maps each downstream tuple to one upstream tuple, and forwards opaque BEGIN/CHUNK/READY/history/live payloads without decoding or rewriting them. The READY fence and contiguousbase_seq + 1live release survive the hop. On mailbox overflow, sequence gap, or link loss the hub sends a downstream tombstone before any replacement; it never lets later live bytes bypass a missing prefix. A relay reconnect always takes a fresh upstream cut and maps a fresh downstream BootstrapId unless the complete §4.6 resume proof holds. Each consumer is isolated: one slow proxy cannot stall the satellite link or another consumer.TERMINAL_CLOSEDandBELLmay pass a generation gate because they are independent lifecycle/ephemeral signals.Attach opens satellite
INPUT_*; it opensFRAME_ACKonly for negotiatedSynthesizedVtStateSync. Native and synthesized-raw ACKs are rejected. The hub resolvesDETACH_TERMINALlocally and tears down the shared upstream subscription only when its last proxy subscriber leaves, including on consumer disconnect. -
Input-lease aliasing (phux-v45.7). Every hub consumer reaches a satellite through the link’s single client identity, so the satellite’s ADR-0033 lease map cannot distinguish them. The hub MUST therefore enforce lease exclusion between its own consumers itself: it keeps a per-
(host, terminal)ledger of which hub consumer holds the relayed lease, refuses a 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 }
Status: spec-only. The frame has no codec entry. A resized Terminal is observed the way any other grid change is: through the
TERMINAL_OUTPUTbytes and the consumer’s own engine. Thedefaults.window-sizeaggregation below is implemented; the notification frame is not.
When multiple clients subscribe to the same Terminal with different viewport
sizes, the server resolves the one authoritative PTY geometry by applying the
defaults.window-size policy (phux-nk07, ADR-0027) across every subscriber’s
viewport — NOT last-writer-wins, which let differently-sized clients thrash the
shared grid. The policy vocabulary mirrors tmux:
smallest(default) — the per-axis minimum across subscribers; nothing is ever cropped, and larger views letterbox. Avoids surprising shrink-and-grow for the smallest viewer.largest— the per-axis maximum; smaller views clamp (content may be cut).latest— the most recently resized client’s viewport.manual— geometry is fixed externally; view sizes are ignored (the server leaves the PTY size unchanged on attach and resize).
A viewport report that leaves authoritative PTY geometry unchanged does not
replace any bootstrap generation. If policy resolution changes authoritative
geometry, the Terminal actor orders the resize with its live sequence, every
affected stream receives BOOTSTRAP_TOMBSTONE { reason: Resize }, and each
desired subscription takes a fresh post-resize cut. No pre-resize chunk,
history page, or live byte is relabeled into the new generation.
Degenerate zero-dimension viewports are ignored in the min/max so a transient
resize-to-zero cannot collapse the grid. Per-Terminal resize events are then
emitted as TERMINAL_RESIZED. How Terminals are laid out within an attached
client’s viewport is a consumer concern: TUIs paint borders and chrome; agents
may not paint anything; layout-tree state is L3 metadata (see L3.md),
not a wire concept.
9.2.1 Pixel geometry
pixel_w / pixel_h (on the ATTACH viewport and VIEWPORT_RESIZE) are the
client’s outer text area in pixels, coherent with the same report’s cols /
rows. The server derives a per-cell pixel size from them (pixel / cells,
floored) and applies cells x cell size — for the authoritative grid the
window-size policy resolved, which may match no single client’s viewport — to
everything that advertises pixel geometry to the Terminal’s child process: the
PTY winsize pixel fields (ws_xpixel / ws_ypixel), XTWINOPS size replies
(CSI 14/16/18 t), and mode-2048 in-band size reports. This keeps the
kernel-reported geometry self-consistent: ws_xpixel / ws_col is exactly the
cell width, the division pixel-aware programs (kitten icat-class preflights,
sixel sizers) perform.
The donor report is the most recent usable pixel report among the Terminal’s current subscribers — recency, not the window-size policy, because cell pixel size is a property of one physical display and a min/max across mixed-DPI viewports would synthesize a cell belonging to no real screen. Reports without pixel metrics (or with degenerate, sub-pixel cells) never displace an established cell size; until any subscriber supplies a usable report the server advertises zero pixel dimensions, matching a terminal that does not know its pixel geometry.