L1 — Terminal substrate
The required conformance tier: the wire surface scoped to a single Terminal.
Full source summary
The required conformance tier: the wire surface scoped to a single Terminal. Lifecycle, VT-bytes-on-wire state sync, 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 second wire model.
1. Scope
L1 is Terminal-scoped. Every message in this tier names exactly one
ResourceId or operates over an explicit list of them. There is no
collection lifecycle tier: grouping is L3 metadata plus client logic
(L2.md, L3.md).
Both ends run libghostty. The wire carries identity, lifecycle, transport framing, opaque terminal bytes, and L3 metadata — not a second encoding of terminal state. Any structured view of a Terminal (a cell grid, a command-boundary stream, a pane tree, a layout) is a consumer-side projection from the engine the consumer already runs. The product picture is CONCEPTS.md.
Durable work uses the separate coordinator endpoint. Those records are not L1 frames, structured terminal state, or an L3 fallback.
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.
1.1 Resources and kinds
Status: shipped. The reference server advertises
RESOURCE_KINDSinHELLO_OKand serves both kinds: Terminal, andAgentSessionbound to a Terminal parent (§4.8).
The thing an L1 identity names is a resource: a server-owned, addressable
served thing with a stable id, a kind, an optional parent (§1.2), a lifecycle
(spawned, then closed with a reason), one ordered opaque output stream with a
per-stream codec (§4), a replaceable bootstrap generation (§4.3), a
kind-defined input channel, a tagged event stream (§7), and an L3 metadata
scope. A Terminal — one PTY plus one libghostty grid — is the first kind, and
the ResourceId / TERMINAL_* names throughout this document are that
kind’s names for the shared resource identity and lifecycle frames. Location
(LOCAL / SATELLITE, §9.1) is orthogonal to kind: any kind may live on a
satellite.
ResourceKind = enum (u8) {
TERMINAL = 0, // PTY + libghostty grid; the value an absent field decodes as
AGENT_SESSION = 1, // producer-fed record stream bound to a Terminal parent (§4.8)
// open enum: a decoder maps any other value to Unknown { tag };
// a tag, once allocated, is never reused.
}
ResourceKind is an open u8 enum. A decoder MUST map a value it does not
recognise to Unknown { tag } rather than fail the frame, and a server MUST
NOT reallocate a retired tag. The kind is fixed by SPAWN_RESOURCE field 11
(§1.2) and never changes for the life of the resource.
Facets. Every kind shares the substrate: identity; spawn and close
(§3.1); the ATTACH_RESOURCE / DETACH_RESOURCE output subscription with
its §4 generations; KILL_RESOURCE and KILL_RESOURCES;
SUBSCRIBE_RESOURCE_EVENTS and SUBSCRIBE_EVENTS; GET_STATE; and the L3
Terminal scope. On top of the substrate each kind has a facet, the
surface only that kind answers:
| Kind | Facet state | Facet frames and commands |
|---|---|---|
TERMINAL | cols, rows, title, cwd, PTY child | INPUT_KEY, INPUT_PASTE, INPUT_MOUSE, INPUT_FOCUS, INPUT_RAW, INPUT_TERMINAL_REPLY, RESIZE_TERMINAL, HISTORY_REQUEST, FRAME_ACK, MOVE_RESOURCE; commands GET_SCREEN, ROUTE_INPUT, APPLY_INPUT, PUT_FILE, TRANSCRIBE, GET_TERMINAL_STATE, ACQUIRE_INPUT, RELEASE_INPUT, SIGNAL_TERMINAL, REPORT_ASKED, REPORT_AGENT_STATE |
AGENT_SESSION | provider, native_id, derived state (§7.2) | command APPEND_RESOURCE_OUTPUT (§5.5); output codec AgentEventsJsonlV1 (§4.8) |
The WRONG_RESOURCE_KIND rule. A facet frame or command addressed to a
resource of another kind is refused with the WRONG_RESOURCE_KIND error
code and has no effect. For a COMMAND the refusal is
COMMAND_RESULT { ERROR(WRONG_RESOURCE_KIND, ..) }; for a frame with no reply
(INPUT_*, RESIZE_TERMINAL, HISTORY_REQUEST, FRAME_ACK) the server
drops the frame and pushes an uncorrelated ERROR { code: WRONG_RESOURCE_KIND }.
The kind check runs after existence (an unknown id is still
TERMINAL_NOT_FOUND) and after the phux-workload/v1 classifier when one is
present, and before any facet handler, so a mis-addressed frame never
touches a PTY, a lease, or a record ring. WRONG_RESOURCE_KIND is
Terminal-scoped in the sense of proto.md §9: the consumer keeps
every other resource and its attach. APPEND_RESOURCE_OUTPUT sent to a
Terminal-kind resource is refused by the same rule, in the same shape.
Unknown-kind tolerance. ResourceInfo gains three trailing additive
fields after its positional prefix (id, window_id, cols, rows, title, cwd):
ResourceInfo {
.. // existing positional prefix, unchanged
kind: ResourceKind, // trailing; absent = TERMINAL
parent: optional<ResourceId>, // trailing; §1.2
agent: optional<AgentFacet>, // trailing; present only for AGENT_SESSION
}
AgentFacet {
provider: str,
native_id: optional<str>,
state: str, // the §7.2 vocabulary; open, "unknown" when underived
}
A non-terminal resource reports cols = 0, rows = 0, no title, no cwd,
and window_id = 0. Zero is a sentinel, not a window: it has no entry in the
snapshot’s windows list and a consumer MUST NOT join it, exactly as it MUST
NOT join a satellite pane’s verbatim window_id (§9.1). A consumer that
receives an entry whose kind is Unknown { tag }, or any kind it does not
implement, MUST retain the entry — its id, parent, and lifecycle are still
facts about the server — MUST NOT render it as a pane, MUST NOT send it a
Terminal-facet frame, and SHOULD NOT ATTACH_RESOURCE to it, since the
stream’s codec (§4.3) is defined by the kind. KILL_RESOURCE, event
subscription, and GET_STATE remain available for every kind. A
session-scoped ATTACH (§8) bootstraps Terminal-kind resources only;
non-terminal resources appear in the ATTACHED snapshot and are reached
through an explicit ATTACH_RESOURCE.
A client MUST NOT send SPAWN_RESOURCE field 11, a non-zero kind, or
APPEND_RESOURCE_OUTPUT to a server that does not advertise
RESOURCE_KINDS (proto.md §6.2). The gate is not a nicety: a
server that predates the field skips it by length and spawns a Terminal,
which is the one outcome a caller asking for another kind cannot want.
1.2 Parent bindings and cascade
Status: shipped. The reference server keeps a binding graph (a resource’s parent is set at spawn, immutable, one level deep) and states a
RESOURCE_CLOSEDreason for every close:Killed,ParentClosed,Exited, orServerShutdown. Closing a parent cascades to its children in the same lock that closes it; closing a child never touches the parent.
A resource MAY be bound to a parent at spawn. The binding is a lifecycle
fact the server enforces, not metadata: it is set once, it is immutable, it
is one level deep, and closing the parent closes the child. SPAWN_RESOURCE
carries it in four additive fields after initial_size (field 10, §3.1):
SPAWN_RESOURCE {
.. // fields 1-10 as in §3.1: the Terminal backing
kind: optional<ResourceKind>, // field 11; absent = TERMINAL
parent: optional<ResourceId>, // field 12; the Terminal-kind resource this one binds to
provider: optional<str>, // field 13; AgentSession facet
native_id: optional<str>, // field 14; AgentSession facet
}
The decoder validates the body per kind. A row marked forbidden returns
SPAWN_FAILED with a diagnostic and creates nothing:
| Field | TERMINAL (field 11 absent or 0) | AGENT_SESSION (field 11 = 1) |
|---|---|---|
1 request_id, 2 group | as §3.1 | as §3.1; group MUST equal the parent’s resolved Group |
3 command, 4 cwd, 5 env, 6 term | optional, as §3.1 | forbidden: there is no process |
7 satellite | optional, as §3.1 | optional; MUST agree with the parent’s host |
8 owner_terminal | optional, as §3.1 | forbidden: there is no window to place it in |
9 agent_session | optional, as §3.1 | optional; installed under the new resource’s own phux.agent-session/v1 key with L3 §3.7.1 semantics |
10 initial_size | optional, as §3.1 | forbidden: there is no grid |
12 parent | forbidden: a Terminal has no parent | REQUIRED; MUST name a live Terminal-kind resource on the same server |
13 provider | forbidden | REQUIRED; 1–120 control-free UTF-8 bytes, the same bound as L3 §3.7.1’s integration_id |
14 native_id | forbidden | optional; 1–1024 control-free UTF-8 bytes, MUST NOT begin with -, the same bound as L3 §3.7.1’s native_id |
Three SpawnError variants join the §3.1 union, allocated sequentially after
SATELLITE_UNREACHABLE and, like their neighbours, additive under its
#[non_exhaustive] rule:
UNSUPPORTED_KIND— field 11 names a kind this server does not serve, includingUnknown { tag }.PARENT_NOT_FOUND— field 12 names no live resource on the receiving server. A parent on a different host is not found: bindings never cross a server boundary.PARENT_KIND_MISMATCH— field 12 names a live resource that is not Terminal-kind. Only a Terminal may be a parent, and a Terminal never has a parent, so a binding graph is at most one level deep by construction; there is no separate depth check to fail.
Immutability. The parent is fixed for the life of the child. No verb
re-parents a resource; MOVE_RESOURCE moves a Terminal between windows and
leaves its bindings untouched, because a binding names a resource id, not a
window. The child inherits nothing from the parent — no geometry, no cwd, no
input lease — beyond the two facts below.
Cascade. When a parent closes, for any reason — PTY EOF or process exit,
KILL_RESOURCE, membership in a KILL_RESOURCES batch, or server shutdown —
the server MUST close every child in the same acquisition of its single
Mutex<ServerState> lock that removes the parent. The guarantee is
identical to KILL_RESOURCES (§5.2): no observer sees a state in which the
parent is gone and a child remains, or a GET_STATE snapshot listing a child
whose parent is absent. Each child receives its own RESOURCE_CLOSED with
reason = PARENT_CLOSED; the per-resource frames follow the commit
asynchronously, as they do after KILL_RESOURCES, and a consumer MUST NOT
infer an order between a parent’s frame and its children’s. No frame
carrying a child’s id is emitted after that child’s RESOURCE_CLOSED.
Closing a child never affects its parent. A KILL_RESOURCES batch that
names both a parent and its children kills each once. pane_spawned and
pane_closed (§7.1) are emitted for every resource the server creates or
closes, whatever its kind; the envelope id identifies the resource and
GET_STATE supplies its kind.
CloseReason. RESOURCE_CLOSED gains one additive field:
RESOURCE_CLOSED {
terminal_id: ResourceId, // field 1
exit_status: optional<i32>, // field 2; §3.1
reason: optional<CloseReason>, // field 3; absent = unstated
}
CloseReason = enum (u8) {
EXITED = 0, // the resource's own backing ended: PTY EOF or process exit
KILLED = 1, // KILL_RESOURCE or KILL_RESOURCES named it
PARENT_CLOSED = 2, // cascade from its parent's close
SERVER_SHUTDOWN = 3, // SHUTDOWN or process stop reaped it
// open enum: a decoder maps any other value to Unknown { tag }
}
The field follows the DetachReason rules of proto.md §7.2: a
sender SHOULD state a reason it knows and MUST NOT encode one to fill the
field; a consumer MUST treat an absent reason as unstated and MUST treat an
unrecognised value exactly as absent, never by failing the frame. exit_status
keeps its §3.1 meaning and is absent for every non-terminal kind. Values are
allocated sequentially and a new value is additive.
Federation. A child lives on the server that owns its parent. A hub
receiving SPAWN_RESOURCE { satellite: Some(H), parent: Some(p), .. }
applies the owner_terminal rule of §3.1 to p: it MUST be
SATELLITE { host: H, id }, a LOCAL or different-host parent is
SPAWN_FAILED before relay, and the hub rewrites it to LOCAL { id } across
H’s link. On the return leg the hub re-tags both id and parent in every
ResourceInfo, and re-tags a child’s RESOURCE_CLOSED { reason: PARENT_CLOSED }
like any other lifecycle frame (§9.1). The cascade itself executes on the
owning server under its own lock; a hub relays its results and adds no
cross-host atomicity, consistent with §5.2.
2. L1 message catalog
These are the messages every conforming consumer (L1, L1+L3) speaks. They carry
ResourceId 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_RESOURCE | §3.1 | shipped |
| 0x23 | C → S | RESIZE_TERMINAL | §3.1 | partial |
| 0x2A | C → S | MOVE_RESOURCE | §3.1 | shipped |
| 0x41 | C → S | SUBSCRIBE_EVENTS | §7 | partial |
| 0x90 | S → C | RESOURCE_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 | RESOURCE_CLOSED | §3.1 | shipped |
| 0xA2 | S → C | RESOURCE_SPAWNED | §3.1 | shipped |
| 0xA8 | S → C | RESOURCE_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 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; a 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_RESOURCE { 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<ResourceId>,
agent_session: optional<bytes>,
initial_size: optional<(u16, u16)>,
kind: optional<ResourceKind>, // field 11; absent = TERMINAL (§1.2)
parent: optional<ResourceId>, // field 12 (§1.2)
provider: optional<str>, // field 13 (§1.2)
native_id: optional<str>, // field 14 (§1.2)
bind_instance: optional<u8> } // field 15; 1 = bind (ADR-0109)
RESOURCE_SPAWNED { request_id: u32, result: SpawnResult,
instance: optional<bytes16> } // field 3; only when bound (ADR-0109)
RESOURCE_CLOSED { terminal_id: ResourceId, exit_status: optional<i32>,
reason: optional<CloseReason> } // field 3; absent = unstated (§1.2)
RESIZE_TERMINAL { terminal_id: ResourceId, cols: u16, rows: u16 }
SpawnResult = tagged_union {
OK (ResourceId), // 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)
UNSUPPORTED_KIND, // tag 0x04; empty body (§1.2)
PARENT_NOT_FOUND, // tag 0x05; empty body (§1.2)
PARENT_KIND_MISMATCH, // tag 0x06; empty body (§1.2)
// #[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_RESOURCE is asynchronous: the server replies with RESOURCE_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). Without owner_terminal, 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 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 RESOURCE_SPAWNED, a layout-aware caller publishes the new
leaf through L3 metadata. owner_terminal = None preserves the legacy server
placement policy. A local spawn requires a Local owner. For a spawn addressed
to satellite: H, an owner MUST be Satellite { host: H, id }; a local owner
or a different host returns SPAWN_FAILED before relay. The hub rewrites the
matching owner to Local { id } across H’s link, where the satellite resolves
its exact window or refuses a missing owner. It MUST NOT strip the owner and
fall back to placement policy.
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.
bind_instance (field 15) and instance (RESOURCE_SPAWNED field 3) bind a
new resource’s id to the id space it was allocated from
(ADR-0109).
A server mints a random 16-byte instance token whenever its ResourceId
allocator starts over, and keeps it exactly as long as the allocator. A
graceful upgrade that restores the allocator restores the token. A cold
restart, which reissues ids from the start, replaces it. A server that
advertises CONDITIONAL_KILL (proto.md §6.2) MUST answer a
successful spawn whose bind_instance is 1 with its current token in
instance, and MUST NOT write instance otherwise, so a consumer that never
asks sees the reply it always had. 0 and absence both mean unbound; any
other value is malformed. An instance beside an ERR result binds nothing
and a decoder drops it. A server without the bit skips field 15 and answers an
unbound OK. For a satellite spawn a hub forwards bind_instance and returns
the satellite’s instance unchanged, because the id is the satellite’s
(§9.1). The token is not HELLO_OK.server_id, which changes on every re-exec;
consumers compare its bytes only and hand it back in KILL_RESOURCE_IF
(§5.2.1).
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. A hub MUST
forward nonzero dimensions on a satellite-routed spawn; the satellite applies
the same initial-grid contract as for a local spawn. Absent or zero-axis
dimensions leave the satellite’s own default in force.
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 RESIZE_TERMINAL, 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_RESOURCE 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.
RESOURCE_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.
RESOURCE_CLOSED is the single L1 lifecycle event for a Terminal ceasing to
exist, whether it died from PTY EOF or process exit, a KILL_RESOURCE command,
or a KILL_RESOURCES 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_RESOURCE (§5.1, which explicitly requires no
ATTACH) are equally subscribers, and a consumer that only ever sent
ATTACH_RESOURCE MUST receive RESOURCE_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.
RESIZE_TERMINAL is sent in addition to (not in place of) VIEWPORT_RESIZE:
the outer-viewport frame conveys the client’s smallest-common-bounding-box;
RESIZE_TERMINAL 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.
RESIZE_TERMINAL 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_RESOURCE { request_id: u32,
terminal: ResourceId,
owner_terminal: ResourceId }
RESOURCE_MOVED { request_id: u32, result: MoveResult }
MoveResult = tagged_union {
OK (ResourceId), // 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_RESOURCE 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_RESOURCE.owner_terminal: it conveys no split direction, ratio, or
focus, and after a successful RESOURCE_MOVED a layout-aware caller
publishes geometry through L3 metadata — two envelope writes, one per
session, with the caller issuing the inverse MOVE_RESOURCE 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_RESOURCE is gated on the MOVE_RESOURCE 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: ResourceId }
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
RESOURCE_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: ResourceId,
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: ResourceId, 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 ResourceId:
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
RESOURCE_OUTPUT { // 0x90, field-id order
terminal_id: ResourceId, // 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: ResourceId, // 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
AgentEventsJsonlV1, // tag 3; §4.8 (tag 2 skipped: it is the v2 version byte above)
}
BOOTSTRAP_CHUNK { // 0x94
terminal_id: ResourceId, // 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: ResourceId, // 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: ResourceId, // 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: ResourceId, // 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: ResourceId, // 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: ResourceId, // 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: ResourceId, // 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, ResourceId, 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.
4.8 AgentEventsJsonlV1 — the producer-fed stream codec
Status: shipped. The reference server keeps a bounded ring per
AgentSession(defaults.agent-log-bytes) and replays it as this stream’s bootstrap; a producer feeds it throughAPPEND_RESOURCE_OUTPUT(§5.5).
A Terminal’s output stream is fed by its PTY. An AgentSession’s is fed by a
producer — the client that calls APPEND_RESOURCE_OUTPUT (§5.5), in the
reference integration the agent harness’s hook shim — and carries newline
delimited JSON records rather than VT bytes. The stream rides the same §4
machinery: one StreamId per subscription, one BootstrapId per generation,
BOOTSTRAP_BEGIN / CHUNK / READY, generation-bound RESOURCE_OUTPUT,
and BOOTSTRAP_TOMBSTONE. What differs is the codec and the profile.
Profile. Profile negotiation is per stream. An ATTACH_RESOURCE to an
AGENT_SESSION resource opens a stream whose BOOTSTRAP_BEGIN carries
codec = AgentEventsJsonlV1 (its own BootstrapCodec tag in §4.3; the
SynthesizedVtV1 and Native tags are never reused), output_mode = Raw,
cols = 0, and rows = 0, on every connection regardless of the profile
HELLO_OK selected. This is the one exemption from §4.3’s rule that BEGIN
matches the connection profile, and a consumer MUST accept zero geometry
under this codec. The stream is raw-only: FRAME_ACK naming it is
MALFORMED_MESSAGE, exactly as for any raw profile (proto.md §8.2).
BOOTSTRAP_READY.history_cursor MUST be absent, and a HISTORY_REQUEST
naming the resource is refused with an uncorrelated
ERROR { code: WRONG_RESOURCE_KIND } (§1.1) — not HISTORY_REJECTED, which
presumes a cursor, and not a tombstone, which presumes a lease. Retained
history is the bootstrap, below. FRAME_COMPRESSED applies unchanged; it is
decode-invisible.
Record grammar. The stream is a sequence of records. Each record is one
JSON object, UTF-8, followed by exactly one LF, at most 16,384 bytes
including the terminator:
record = %x7B seq "," ts "," typ "," data %x7D LF
seq = %s"\"seq\":" 1*DIGIT ; u64, server-assigned
ts = %s"\"ts_ms\":" 1*DIGIT ; u64, server-assigned
typ = %s"\"type\":" DQUOTE type DQUOTE
data = %s"\"data\":" json-object ; RFC 8259 object, no LF inside
type = "session_start" / "prompt" / "tool_start" / "tool_end"
/ "notification" / "ask" / "stop" / "session_end"
/ "state" / "provider_raw"
| Member | Type | Assigned by | Rule |
|---|---|---|---|
seq | u64 | server | 1-based, contiguous per resource, checked addition; a producer-supplied value is ignored |
ts_ms | u64 | server | Unix milliseconds at append; consumers order by seq, never by ts_ms |
type | str | producer | the closed v1 set above; any other value is RECORD_INVALID |
data | object | producer | per-type members below; unknown members are preserved by the server and MUST be ignored by consumers |
The retained record is the server’s serialization — the four members in the
order shown, data semantically equal to what the producer sent. A producer
MAY omit seq and ts_ms; it gains nothing by sending them.
type | data members | Meaning |
|---|---|---|
session_start | provider: str (REQUIRED, equals the facet’s), native_id?: str | the provider session opened; SHOULD be the first record |
prompt | length: u64 (REQUIRED), text?: str | a turn began; text is absent unless the producer opted in |
tool_start | tool_name: str (REQUIRED), tool_use_id?: str | a tool call began; never the tool’s input |
tool_end | tool_name: str, tool_use_id?: str, ok?: bool | that call ended |
notification | kind: str (REQUIRED, open: permission, elicitation, idle, others), message?: str | the provider raised a notice |
ask | id: str, question: str, suggestions?: list<str> | the agent blocked on a human-answerable question; mirrors the §7.1 asked payload |
stop | (empty object) | the turn ended |
session_end | reason?: str | the provider session closed; terminal in the grammar |
state | state: str (REQUIRED: working, blocked, or done) | an explicit state assertion; the server synthesizes one from REPORT_AGENT_STATE (§7.2) |
provider_raw | provider: str, event: any | an opaque provider event, opt-in at the producer |
The server validates the envelope, not the payload: UTF-8, exactly one JSON
object per line, the 16,384-byte record bound, type in the closed set,
data an object, state.state in its three-word vocabulary, and no record
after session_end. Anything else is RECORD_INVALID and the whole call is
refused (§5.5). The server MUST NOT interpret data beyond the members §7.2
derives from, MUST NOT rewrite it, and MUST NOT log record bodies in routine
telemetry; prompt.text and provider_raw exist precisely because the
default record carries neither a prompt nor a provider payload.
Sequence assignment and ordering. seq is a per-resource record counter.
For this codec, RESOURCE_OUTPUT.seq is the last record’s sequence in the
batch, an exception to §4.1’s Terminal frame counter. One accepted call
stamps its records consecutively and broadcasts them in one RESOURCE_OUTPUT
frame; a multi-record call advances the envelope sequence by its record count,
not by one. Calls from one connection are applied in the order sent; across
connections the order is the server’s acceptance order; records from two calls
never interleave. Live RESOURCE_OUTPUT.bytes carry one or more complete
records, each LF-terminated, never a partial one. Before applying any part of
a live batch, a consumer MUST validate that its first record follows the last
accepted sequence, every record is contiguous, and its final record equals
RESOURCE_OUTPUT.seq. An empty batch, duplicate, gap, malformed record, or
envelope mismatch MUST NOT publish partial records or derived state. A consumer
that sees a gap inside a live generation requires a fresh cut. Sequence
arithmetic is checked; accepting record u64::MAX exhausts the live sequence
space rather than wrapping it.
Retention ring and bootstrap. The server retains each AgentSession’s
records in a per-resource ring bounded in bytes by
defaults.agent-log-bytes (default 4 MiB; the same explicit-ceiling pattern
as defaults.history-bytes,
ADR-0094).
Eviction is whole-record, oldest first; a record is never partially
retained. A bootstrap generation is the ring at the cut: BOOTSTRAP_CHUNK
payloads, concatenated in chunk_seq order, are the retained records in
seq order, each LF-terminated. Chunks MAY split a record at any byte,
consistent with §4.3; the concatenation is whole records. base_seq is the
resource’s record counter at the cut. The first live record is
base_seq + 1; its frame’s seq names the last record in that batch and may
therefore be greater than base_seq + 1. A first retained seq greater than 1
means older records were evicted; a consumer MUST NOT treat that as an error.
Eviction never tombstones a live generation, because live subscribers already
received the evicted records.
Overflow. A subscriber whose bounded outbound queue overflows receives
BOOTSTRAP_TOMBSTONE { reason: RawReplayOverflow } and a replacement
generation whose chunks carry the ring as it now stands (§4.6). The producer
side has its own bound: a call whose bytes exceed 65,536, or that the
server’s bounded per-resource append lane cannot accept, is refused with
OVERFLOW and appends nothing (§5.5). Neither path loses a record silently.
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(ResourceId),
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_RESOURCE { terminal_id: ResourceId, // tag 0x01
role_policy: RolePolicy }, // (role_policy not yet encoded; §8.1 default applies)
DETACH_RESOURCE { terminal_id: ResourceId }, // tag 0x02
KILL_RESOURCE { terminal_id: ResourceId },
GET_SCREEN { terminal_id: ResourceId, // tag 0x07
request_scrollback: optional<u32>, cells: bool },
ROUTE_INPUT { terminal_id: ResourceId, event: InputEvent },// tag 0x08
KILL_RESOURCES { ids: list<ResourceId> }, // tag 0x09
RESIZE_TERMINAL { terminal_id: ResourceId, cols: u16, rows: u16 },
GET_STATE { scope: StateScope },
RUN_HOOK { name: str, args: list<str> },
GET_TERMINAL_STATE { terminal_id: ResourceId }, // tag 0x0c
SUBSCRIBE_RESOURCE_EVENTS { terminal_id: ResourceId }, // tag 0x0d
UPGRADE, // tag 0x0e; no payload (ADR-0032)
ACQUIRE_INPUT { terminal_id: ResourceId, mode: InputMode, // tag 0x0f (ADR-0033)
ttl_ms: u32 },
RELEASE_INPUT { terminal_id: ResourceId }, // tag 0x10 (ADR-0033)
SIGNAL_TERMINAL { terminal_id: ResourceId, // tag 0x11 (ADR-0033)
signal: TerminalSignal },
REPORT_ASKED { terminal_id: ResourceId, // 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: ResourceId,
events: list<InputEvent> },
PUT_FILE { upload_id: bytes16, // tag 0x15 (ADR-0059)
terminal_id: ResourceId, extension: str,
offset: u64, data: bytes, final_chunk: bool,
sha256: optional<bytes32> },
SHUTDOWN, // tag 0x16; no payload
REPORT_AGENT_STATE { terminal_id: ResourceId, // tag 0x17 (ADR-0085)
state: ReportedAgentState },
GET_PERF { reset: bool }, // tag 0x18 (ADR-0096)
TRANSCRIBE { upload_id: bytes16, terminal_id: ResourceId }, // tag 0x19
APPEND_RESOURCE_OUTPUT { terminal_id: ResourceId, bytes: bytes }, // tag 0x1a (§5.5)
KILL_RESOURCE_IF { terminal_id: ResourceId, // tag 0x1b (§5.2.1; ADR-0109)
instance: optional<bytes16>, conditions: u8 },
}
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 ResourceId 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_RESOURCE and DETACH_RESOURCE 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_RESOURCE
wires the calling client to receive RESOURCE_OUTPUT with the role and takeover
semantics of §8.1.
ATTACH_RESOURCE (tag 0x01) and DETACH_RESOURCE (tag 0x02) shipped in
v0.5.0-draft.15 (phux-v45.7) with the following reference semantics.
ATTACH_RESOURCE 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 RESOURCE_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 RESIZE_TERMINAL. The role_policy field is not yet encoded, so the
existing default remains { requested_role: PRIMARY, takeover: NEVER }.
DETACH_RESOURCE 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_RESOURCE terminates one Terminal’s underlying PTY and asynchronously
emits RESOURCE_CLOSED. It is the single-Terminal destroy verb.
KILL_RESOURCE_IF (§5.2.1) is its conditional form, for a consumer whose kill
may arrive after the resource stopped being its to kill.
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_RESOURCES { ids } (§5.2) is the atomic group-teardown verb.
RESIZE_TERMINAL resizes one Terminal. KILL_RESOURCE, KILL_RESOURCES, 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_RESOURCE and GET_STATE are allowed for viewers.
Status: spec-only. Every role gate in this section — here, on
ROUTE_INPUT(§6.2), and onKILL_RESOURCES(§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_RESOURCE/RESIZE_TERMINALframes (§3.1) instead; hooks are a server-side config surface (docs/consumers/tui.md), not a wire verb.
GET_SCREEN, ROUTE_INPUT, APPLY_INPUT, PUT_FILE,
GET_TERMINAL_STATE, and SUBSCRIBE_RESOURCE_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_RESOURCE, 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_RESOURCES — atomic group teardown
KILL_RESOURCES { ids: list<ResourceId> } // tag 0x09
Wire body: a u16 count followed by that many tagged ResourceIds
(field-tagged TLV, per appendix-encoding.md).
KILL_RESOURCES 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 RESOURCE_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_RESOURCES.
5.2.1 KILL_RESOURCE_IF — conditional kill
KILL_RESOURCE_IF { terminal_id: ResourceId, // tag 0x1b
instance: optional<bytes16>,
conditions: KillConditions }
KillConditions = bitset (u8) {
UNATTACHED_SINCE_SPAWN = 0x01,
}
Wire body, positional after the tag: the tagged ResourceId, an Option tag
(0 or 1) followed by the 16 token bytes when it is 1, then the condition
bits as one u8. Gated on CONDITIONAL_KILL (proto.md §6.2): a
client MUST NOT send it unless the bit is advertised. It is a new tag rather
than a field on KILL_RESOURCE so that a peer without the feature fails to
decode it instead of skipping the precondition and killing unconditionally
(ADR-0109).
The server kills terminal_id, exactly as KILL_RESOURCE would, only when
every stated condition holds:
instance. When present it MUST equal the server’s current instance token (§3.1). A token from before a cold restart names an id space the server no longer has, where the id may name someone else’s resource.UNATTACHED_SINCE_SPAWN. Requiresinstance: a server MUST refuse the bit wheninstanceis absent, because without the token the id may name a resource from an id space the caller never saw. The condition holds when three things are true. The resource was created by aSPAWN_RESOURCE. Since then no connection other than the one that sent thatSPAWN_RESOURCE(the spawning connection) has attached or used it. And it has no child resource (§1.2).- A connection attaches the resource by being subscribed to its output,
however that arose: an accepted
ATTACH_RESOURCE, a sessionATTACHor re-attach whose windows held the resource, and one later detached or rolled back. - A connection uses the resource by sending a command that names it:
ROUTE_INPUT,APPLY_INPUT,ACQUIRE_INPUT,PUT_FILE,TRANSCRIBE,SIGNAL_TERMINAL, orGET_SCREEN. These drive or read a resource with no subscription, asphux send-keysandphux snapshotdo. A use counts when the command arrives, whether or not it succeeds. - The spawning connection’s own attaches and uses never count, including an
ATTACH_RESOURCEit sent whose refusal its consumer saw. - A child resource refuses the condition: the kill closes every child with it (§1.2), and nothing checked who uses the child.
- A resource the server did not create through
SPAWN_RESOURCE, such as a session’s seed or a resource rebuilt by a graceful upgrade, never satisfies the condition.
- A connection attaches the resource by being subscribed to its output,
however that arose: an accepted
Conditions are checked in this order, and the first failure decides the
reply: a condition bit the server does not know, then
UNATTACHED_SINCE_SPAWN without instance, then instance, then the
existence of terminal_id, then the rest of UNATTACHED_SINCE_SPAWN.
instance comes before the lookup because under another token the id says
nothing about the resource the caller meant. A failed or unknown condition answers
COMMAND_RESULT { ERROR(PRECONDITION_FAILED, message) } (proto.md
§9) and kills nothing; the message names the condition. An id that names
nothing answers TERMINAL_NOT_FOUND. Otherwise the reply is
COMMAND_RESULT { OK } and RESOURCE_CLOSED follows as for KILL_RESOURCE.
The server evaluates the conditions and applies the kill in one acquisition of
its state lock, so no attach can land between them. With no instance and no
condition bits the command is KILL_RESOURCE.
A consumer that receives PRECONDITION_FAILED MUST NOT fall back to an
unconditional KILL_RESOURCE for the same resource: the refusal means the
resource may no longer be the consumer’s. Relaying through a federation hub
is specified in §9.1.
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_RESOURCE(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_RESOURCES(§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.
5.5 APPEND_RESOURCE_OUTPUT (0x1a) — producer-fed output
Status: shipped. The reference server validates and stamps every append, requiring the caller hold the local-producer policy (
NotProducerotherwise), and refuses a malformed record withRecordInvalid. It is the input channel of theAGENT_SESSIONkind (§1.1) and is served with it.
APPEND_RESOURCE_OUTPUT { resource_id: ResourceId, bytes: bytes } // tag 0x1a
Its nested positional command body is:
u8 command_tag = 0x1a
ResourceId resource_id
bytes bytes
APPEND_RESOURCE_OUTPUT appends one or more complete AgentEventsJsonlV1
records (§4.8) to a producer-fed resource’s output stream. It is available
only when HELLO_OK.server_caps.features contains RESOURCE_KINDS
(proto.md §6.2); a client MUST NOT probe an older server with
the unknown tag. bytes MUST be non-empty and at most 65,536 bytes
(MAX_APPEND); a server MUST reject an over-limit body before parsing a
record. Each record within bytes is bounded separately at 16,384 bytes by
§4.8.
Bounds and atomicity. The call is all-or-nothing. The server validates
every record in bytes before stamping any; a single invalid record refuses
the call and nothing is appended. On success every record is stamped with
consecutive seq values and one ts_ms, retained in the ring, and
broadcast to the resource’s subscribers in one RESOURCE_OUTPUT frame.
COMMAND_RESULT { OK } means retained and stamped; it does not mean any
subscriber has received the frame. There is no operation id and no dedupe
cache: a producer that loses its connection between the call and the result
MUST NOT resend blindly, because a resend is a new record with a new seq.
A producer needing exactly-once semantics carries its own correlation
member inside data and the consumer deduplicates on it.
Producer authority. The producer of an AgentSession is any client
authorized to write input to its parent Terminal: under phux-workload/v1,
BIND and INPUT on the parent (workload-auth.md §7);
under the one-server-per-user local policy, every authenticated client; a
VIEWER-role subscription to the parent (§8.1) cannot produce. Authority
is evaluated against the parent at append time, as a domain invariant after
the workload classifier and after the kind check. The check is deliberately
not bound to a connection: the reference producer is a hook shim that opens
a fresh connection per hook invocation, so “the connection that spawned it”
would refuse every legitimate record after the first. Nor is it bound to
the parent’s ADR-0033 input lease: a supervisor that seizes the wheel on a
pane takes over the human’s keystrokes, not the agent’s own account of what
it is doing.
Errors. The reply is COMMAND_RESULT { OK } or one of:
| Result | Meaning | Nothing appended | Retry |
|---|---|---|---|
ERROR(TERMINAL_NOT_FOUND) | resource_id names no live resource | yes | no |
ERROR(WRONG_RESOURCE_KIND) | the resource is not producer-fed (a Terminal) | yes | no |
ERROR(NOT_PRODUCER) | the caller lacks producer authority on the parent | yes | no |
ERROR(RECORD_INVALID) | a record fails the §4.8 grammar, exceeds 16,384 bytes, or follows session_end | yes | not with the same bytes |
ERROR(OVERFLOW) | bytes exceeds 65,536, or the bounded append lane is full | yes | split the call; or back off and resend the same bytes |
ERROR(UNSUPPORTED_SATELLITE_ROUTE), ERROR(SATELLITE_UNREACHABLE) | §9.1 relay refusals | yes | per §9.1 |
ERROR(PERMISSION_DENIED) | the phux-workload/v1 classifier refused the call | yes | no |
The two OVERFLOW causes are distinguished by the diagnostic string, not
the code: an over-limit bytes is permanent for that call and a full lane is
transient. Both are safe to act on, because neither appended anything.
Ordering and relay. Calls are applied in the order the server accepts
them (§4.8). A hub relays the command like the rest of the per-Terminal
catalog (§9.1): resource_id rewritten to LOCAL, bytes forwarded
verbatim, the result correlated back. The owning server stamps seq and
ts_ms; a hub never does.
6. Live agent affordances (engine convenience)
GET_SCREEN, ROUTE_INPUT, GET_TERMINAL_STATE,
SUBSCRIBE_RESOURCE_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_RESOURCE, 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_RESOURCE, 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
ResourceId 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
ResourceId 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_RESOURCE_EVENTS (0x0d)
SUBSCRIBE_RESOURCE_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<ResourceId> } // 0x41
EVENT { terminal: optional<ResourceId>, event: AgentEvent } // 0xB3
SUBSCRIBE_EVENTS.terminal scopes the subscription: Some(id) delivers only
that Terminal’s events (equivalent to SUBSCRIBE_RESOURCE_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 | field-tagged TLV: kind: optional<ResourceKind> (field 1; absent = TERMINAL), parent: optional<ResourceId> (field 2; absent = root); empty for a root Terminal, id on envelope (§1.1, §1.2) |
| 0x05 | pane_closed | exit_status: optional<i32> |
| 0x06 | dirty | (empty) |
| 0x07 | idle | (empty) |
| 0x08 | terminal_control | lifecycle: ResourceLifecycle, 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_RESOURCE_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_RESOURCEadding 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_RESOURCEpath that emitsRESOURCE_CLOSED(§3.1), and itsexit_statuscarries the same value asRESOURCE_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’sResourceInfo.cwd(the spawn cwd) and refines from this stream; a pane that never produces output after acdkeeps the seed until its next prompt boundary.
7.2 Agent state derivation from the stream
Status: shipped. The reference detector ranks a live
AgentSessionchild’s stream above hook, process, and screen evidence (ADR-0046, ADR-0085, ADR-0103) and derives the parent’sphux.agent/v1statefrom it by the table below.
While a Terminal has a live AgentSession child — one that exists and has
not yet recorded session_end — the server derives the parent’s
phux.agent/v1 state (L3.md §3.7) from that child’s records.
Derivation is a table, not a heuristic:
| Record | Derived state |
|---|---|
session_start | none; identity only (name / kind from provider if the record is otherwise absent) |
prompt | working |
tool_start | working |
tool_end | none; the turn is still open |
notification { kind: permission }, notification { kind: elicitation } | blocked |
notification with any other kind | none |
ask | blocked |
stop | done |
state { state } | the named value |
session_end | retract: stream evidence is withdrawn and the child is no longer live |
provider_raw | none |
The stream never derives idle. Idle and departure — the agent finished and
is available for input, or the agent is gone — remain the detector’s
(ADR-0085): while a live child exists, screen derivation runs only for those
two outcomes, so a working or blocked derived from the stream cannot be
overwritten by a screen rule that reads the same quiet chrome as working.
A notification { kind: idle } therefore derives nothing; it is the
provider’s opinion, and the detector forms its own.
Precedence. Among derived sources, Stream > Hook > Process > Screen.
REPORT_AGENT_STATE (§5.1) stays: when the target Terminal has a live
AgentSession child, its handler appends a synthesized state record to that
child’s stream and the ordinary stream path publishes it, so a hook-driven
integration and a stream-driven one converge on one ledger; when no live
child exists the hook path of ADR-0085 applies unchanged. An explicit
SET_METADATA that supplies state outranks every derived source for as
long as L3 §3.7 says it does; the stream is derivation, and derivation
never overwrites a declaration. After session_end the arbiter falls back
through Hook > Process > Screen as if the child had never existed; the
retracted value is not held.
The derived value is also the AgentFacet.state a GET_STATE snapshot
reports for the child (§1.1), so a consumer that never mounts L3 reads the
same word.
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_RESOURCE, 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_RESOURCEcaller — 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_RESOURCE. 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_RESOURCE.
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_RESOURCE. 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_RESOURCE, 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_RESOURCE is scoped to a single Terminal and fails or succeeds
independently.
RolePolicy is encoded as an additive field on both ATTACH and
ATTACH_RESOURCE. 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 theRESOURCE_SPAWNEDreply to its ownSPAWN_RESOURCE(§3.1), which is why nothing observes Terminals it did not create. The wideExitStatusunion below is likewise unbuilt:RESOURCE_CLOSEDships the compactexit_status: optional<i32>of §3.1, so a signal kill is indistinguishable from an unknown cause.
TERMINAL_OPENED {
terminal_id: ResourceId,
initial_size: { cols: u16, rows: u16 },
cwd: str,
command: list<str>,
}
RESOURCE_CLOSED {
terminal_id: ResourceId,
exit_status: optional<ExitStatus>,
}
ExitStatus = tagged_union {
EXITED(u8), // process called _exit(n)
SIGNALED(u8), // killed by signal n
UNKNOWN,
}
ResourceId is a tagged union per
ADR-0016,
federation-routable like every other identity in the protocol:
ResourceId = 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 ResourceId 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_RESOURCE,KILL_RESOURCE_IF,GET_TERMINAL_STATE,SUBSCRIBE_RESOURCE_EVENTS,ACQUIRE_INPUT,RELEASE_INPUT,SIGNAL_TERMINAL,REPORT_ASKED; theSATELLITE-tagged ids inside aKILL_RESOURCESbatch are partitioned per host and relayed as per-satellite batches),INPUT_*,HISTORY_REQUEST, StateSync-onlyFRAME_ACK,RESIZE_TERMINAL, 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,RESOURCE_OUTPUT,RESOURCE_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_RESOURCEatomically 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 ResourceId, 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.RESOURCE_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_RESOURCElocally and tears down the shared upstream subscription only when its last proxy subscriber leaves, including on consumer disconnect. The hub MUST remove the caller’s proxy, including any retained content awaiting delivery, before replyingCOMMAND_RESULT { Ok }. After that success no unsolicited terminal stream frame may be queued for the withdrawn proxy. A stalled, not-yet-applied withdrawal MUST be atomically cancelled before returning a bounded refusal, preserving the subscription so the client can safely resume its current generation. If withdrawal has already committed, the hub MUST return its actual result, even if the waiter resumes after its deadline. Disconnect cleanup remains undroppable. Last-subscriber upstream detach MUST be sent before a subsequent reattach on the same link; other subscribers remain attached throughout a peer’s withdrawal. An explicit detach with no proxy subscribers also retires any unobserved automatic spawn output on the link; it does not kill the durable terminal. If a newer attachment has superseded a queued withdrawal, the hub preserves that attachment and refuses the stale detach rather than reporting success. Event-only subscriptions do not establish explicit upstream content ownership: the firstATTACH_RESOURCEstill retires automatic spawn content before taking its fresh cut. ExistingSUBSCRIBE_RESOURCE_EVENTSsubscriptions and event delivery survive this content barrier. If the upstream detach removes a legacySUBSCRIBE_EVENTSsubscription, the hub restores it in wire order before forwarding the new content attach. -
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 makesResourceIdthe 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 theirResourceId.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). -
Host-session inventory (
GET_STATE,HOST_SESSIONS). Sessions do not merge, but a hub that advertisesHOST_SESSIONSMUST NOT drop them either: it reports each satellite’s sessions in a trailinghostslist on the same snapshot, written after the resource facets (which are then written even when empty, as a zero count, so the two trailing lists keep a fixed order):HostInventory { host: str, // the hub-local satellite name unreachable: optional<str>, // absent = the satellite answered sessions: list<HostSessionInfo>, // empty when unreachable } HostSessionInfo { id: u32, // SATELLITE-LOCAL session id name: str, created_at_unix_secs: i64, window_count: u16, pane_count: u16, // Terminal-kind resources in the session attached_client_count: u16, active_resource: optional<ResourceId>, // SATELLITE-tagged, or absent }The hub MUST NOT renumber a satellite session into its own id space:
idis the satellite’s own and MUST NOT be joined against the snapshot’ssessionslist, exactly as a satellite pane’swindow_idMUST NOT be joined againstwindows.active_resourceis the one federation-routable handle in the row — re-taggedLOCAL { id }→SATELLITE { host, id }, so every relayed verb above reaches it — and aSATELLITE-tagged id in the satellite’s own answer is dropped rather than chained. A satellite the hub could not reach keeps a row withunreachableset and no sessions, in addition to the un-correlatedERRORabove: a consumer can then show it as degraded instead of silently losing it. A server that is not a hub sends an empty list, which with the bit set means “no satellites”. A hub relayingGET_STATEto a satellite ignores anyhoststhe satellite itself reports (no chaining).Order of the snapshot’s trailing lists. After
focused_resourceaSessionSnapshotcarries up to four trailing fields, in this order: the resource facets (§1.2),hosts(above), the session facets of L3.md §3.1 (KEEP_EMPTY_SESSIONS, ADR-0105), and an optional remote-listeners JSON object (phux-kyna):{ schema_version: 1, listeners: [{ transport, expected, bound, addr?, disabled_reason? }] }withtransportinwss/quic/wt. An encoder writes a list only when it or a later list is non-empty, and then writes every earlier list explicitly, with a zero count when it has no rows, so a later list never aliases an earlier one. A decoder reads each list only while bytes remain in the field. A snapshot with none of them is byte-identical to one encoded before any existed, and a decoder that stops after the facets, afterhosts, or after the session facets still decodes every field it knows. -
Satellite-targeted spawn (
SPAWN_RESOURCE.satellite, §3.1). A hub receivingSPAWN_RESOURCE { 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’sRESOURCE_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 (RESOURCE_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. Abind_instancerequest crosses the link unchanged, and the satellite’sinstancetoken comes back unchanged beside the re-tagged id (§3.1). -
Conditional kill (
KILL_RESOURCE_IF, §5.2.1). A hub forwards the precondition unchanged, with the id rewritten toLOCAL, and the satellite evaluates it against its own instance token and attachments. A hub MUST refuse the command withPRECONDITION_FAILED, without touching the link, when the satellite did not advertiseCONDITIONAL_KILL. Every hub consumer shares one link connection on the satellite, so the satellite’s spawning connection is the link and it cannot tell hub consumers apart. ForUNATTACHED_SINCE_SPAWNthe hub therefore checks its own half first: it MUST refuse withPRECONDITION_FAILEDunless it relayed the spawn that created the resource, that spawn’s reply was bound to the kill’sinstance, and no hub consumer other than the one that sent the spawn has attached or used the resource through the hub (the verbs of §5.2.1). The hub’s record is keyed by host and id, which a satellite restart reuses, so the token is what keeps a record from vouching for a new pane. The reference hub notes an attach or use when it forwards it, remembers its 1024 most recent satellite spawns across link reconnects, and refuses a resource it no longer remembers. -
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).
ResourceIds are stable for the life of the server and are not reused after
close (the counter is monotonic for the server’s lifetime). A cold restart
starts the counter over, so across restarts an id names a resource only
together with the server’s instance token (§3.1).
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: ResourceId, 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
RESOURCE_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.