0102 - Resources: the server serves kinds; Terminal is the first
phux serves resources, not terminals. A resource is an id, an open kind, an optional parent, a lifecycle, an ordered opaque output stream with a per-stream...
Full source summary
phux serves resources, not terminals. A resource is an id, an open kind, an optional parent, a lifecycle, an ordered opaque output stream with a per-stream negotiated codec, a bootstrap, a kind-defined input channel, a tagged event stream, and an L3 scope. The PTY-plus-libghostty Terminal is kind 0 and AgentSession is kind 1. TerminalId becomes ResourceId with the same bytes in protocol 0.9.0. Terminal-only operations fail on other kinds with WRONG_RESOURCE_KIND.
Status: Accepted Date: 2026-09-09
Context
The server knows one served thing. Every frame, table, handle, and document
says Terminal, because ADR-0016 made
TerminalId the wire primary and nothing since has needed a second one. The
agent program then grew one without a home: an agent’s session is a real
server-side thing with a lifecycle, an ordered log, and observers, carried
today as an L3 record (ADR-0040) whose
state the server recovers by scraping the screen
(ADR-0046). The reserved
PROCESS_* and FORWARD_PORT discriminants show where this was heading: one
frame family per new thing, each with its own spawn, close, and inventory.
ADR-0030 states the wire’s job as a closed list (identity, lifecycle, transport, opaque bytes, metadata); ADR-0070 gave the bytes a negotiated codec and a bootstrap generation. Neither requires a PTY.
Decision
- A resource is the served unit. It has a
ResourceId, aResourceKind, an optionalparent: ResourceId(ADR-0104), a lifecycle (spawn, then closed with a reason), an ordered opaque output stream with a per-stream negotiated codec, a bootstrap in the ADR-0070 shape (a replaceable replica generation cut at a stream sequence), a kind-defined input channel, a tagged event stream, and an L3 metadata scope. ResourceKindis an openu8.Terminal = 0,AgentSession = 1,Unknown { tag }on decode. A tag is never reused.ResourceIdreplacesTerminalId. Same tagged union,Local { id }tag 0 andSatellite { host, id }tag 1. Location stays orthogonal to kind: a hub routes anAgentSessionexactly as it routes a Terminal.- The Terminal facet is cols, rows, title, cwd, and a PTY child. Only
Terminal-kind resources accept input atoms,
RESIZE_TERMINAL,GET_SCREEN,GET_TERMINAL_STATE,HISTORY_*,ACQUIRE_INPUTandRELEASE_INPUT,SIGNAL_TERMINAL,APPLY_INPUT,PUT_FILE, andTRANSCRIBE. Sent to any other kind they fail with the newERROR { code: WRONG_RESOURCE_KIND }or that code in the command’s result. - Protocol 0.9.0 renames the substrate once, keeping discriminants.
TERMINAL_OUTPUTbecomesRESOURCE_OUTPUT(0x90),SPAWN_TERMINALbecomesSPAWN_RESOURCE(0x22),TERMINAL_SPAWNEDbecomesRESOURCE_SPAWNED(0xA2),TERMINAL_CLOSEDbecomesRESOURCE_CLOSED(0xA1),TERMINAL_RESIZEbecomesRESIZE_TERMINAL(0x23, a facet frame).MOVE_,ATTACH_,DETACH_,KILL_,KILL_*S,SUBSCRIBE_*_EVENTS,TerminalInfo,SessionSnapshot.panes,focused_pane,active_pane,Scope::Terminal,CommandValue::TerminalId,TerminalEventType, andAgentEvent::PaneSpawned/PaneClosedtake the resource spelling. Facet frames keep Terminal in their name (INPUT_*,INPUT_TERMINAL_REPLY,GET_SCREEN,GET_TERMINAL_STATE,RESIZE_TERMINAL,SIGNAL_TERMINAL). - Kind rides additive fields where the body is TLV.
SPAWN_RESOURCEfields 1 to 10 are unchanged and are the Terminal backing; field 11 iskind: u8(absent means Terminal) and the decoder validates the rest per kind.ResourceInfokeeps its positional prefix (id, window_id, cols, rows, title, cwd) and gains trailingkind,parent, and a kind facet; a non-terminal entry carriescols = rows = 0andwindow_id = 0as a documented no-window sentinel.ServerFeature::RESOURCE_KINDS = 0x4000advertises that the server spawns kinds other than Terminal. - The codec is negotiated per stream. A Terminal stream negotiates as
ADR-0070 specifies. Every other kind names its codec in
BOOTSTRAP_BEGIN.codec;BootstrapCodectags are never reused. - The reserved
PROCESS_*andFORWARD_PORTdiscriminants are subsumed by kinds and stay unallocated. - “Pane” stays a TUI and CLI word for a Terminal-kind resource in a
layout slot, off the wire except the
watchgate namespane_spawnedandpane_closed, frozen by ADR-0071. - Names not used.
Workloadis an authenticated client (ADR-0098),WorkSessionis coordinator identity (ADR-0092);Block,Surface, andTargetalready mean something in a consumer.
This supersedes ADR-0016 and scopes ADR-0014 (the actor is the Terminal engine inside a kind-agnostic resource core), ADR-0015 (L1 is the resource substrate), ADR-0022 (projections are per kind), and ADR-0030 (the closed list reads identity, lifecycle, transport, opaque bytes per kind under a negotiated codec, metadata).
Why
Vocabulary is the decision. A second served thing could be bolted on as
TerminalId plus a kind byte. The documents, the CLI, the FFI header, and
every new contributor would then keep saying “terminal” for things that have
no PTY, and each new kind would re-open the argument about which frames apply
to it. Naming the unit once, and making the Terminal facet the exception list
rather than the default, is what stops the PROCESS_* pattern recurring.
One lifecycle, N kinds. Spawn, close with reason, attach, detach, kill, atomic multi-kill, inventory, event subscription, and L3 scope are identical for every kind. A per-kind frame family multiplies each, federation included.
ADR-0030’s argument generalizes instead of breaking. That ADR refused structured terminal state on the wire because both ends run the engine. An agent session has no shared engine, but the same discipline holds: an opaque stream under a named codec, projected by consumers. Kind selects the codec; it is not a licence for typed frames.
One minor bump, spent once. Under
ADR-0061 renamed type names
alone would not justify a break. The inventory does: a 0.8 client reads every
SessionSnapshot entry as a pane with a PTY, and hiding non-terminal entries
per client would make the fleet view lie. The program pays the break once for
ADR-0102, 0103, and 0104; everything optional in 0.9.0 sits behind the bit.
Tradeoffs
- The Cockpit projection lags the rename. Cockpit still shows Terminal-only panes; wiring the AgentSession row is tracked as phux-am9y.25.
- The rename touches every crate, the C ABI, Cockpit, and phux-web. Golden snapshots survive where discriminants and prefixes are kept.
- The no-window sentinel is a compromise.
ResourceInfois positional at its head, so a non-terminal entry carries zeros a reader must know to ignore. A fresh frame would be cleaner and would cost a second inventory. Unknown { tag }is a client obligation. A 0.9.0 client skips kinds it does not know; the TUI never paints one as a pane. Facet dispatch suits a short closed list; a kind with two output streams needs another ADR.
Alternatives
Keep TerminalId and add a kind byte. Smallest diff, rejected because
the vocabulary is the point: every document and consumer would still call a
non-terminal a terminal, and the facet exception list would never be written.
Make an agent session a metadata convention. ADR-0040’s shape, extended. Rejected: L3 has no ordered stream, no bootstrap generation, no observer contract, and no cascade; each would be re-invented on last-writer-wins bytes.
Model each kind as its own frame family, as the reserved PROCESS_* and
FORWARD_PORT discriminants anticipated. Rejected: N parallel lifecycles,
N inventories, N federation arms, and no shared attach or kill semantics.