0030 — Engine-delegated wire and projection consumers
Both ends of the wire run the same terminal engine (libghostty), so the wire carries only identity, lifecycle, transport, opaque terminal bytes, and L3...
Full source summary
Both ends of the wire run the same terminal engine (libghostty), so the wire carries only identity, lifecycle, transport, opaque terminal bytes, and L3 metadata; every structured surface — screen state, semantic events, panes, layouts, "run and wait" — is a consumer-side projection of that shared engine, never a wire tier. This rejects the gRPC/cells-on-wire agent design, dissolves the L2 collection tier into L3 grouping plus a single atomic L1 batch operation (KILL_TERMINALS), names phux-web as the reference projection pattern, and reaffirms ADR-0017 as the constraint that keeps the reference TUI a pure consumer.
Status: Accepted Date: 2026-06-06
Context
A docs-wide audit surfaced a cluster of contradictions that all reduce to one unresolved question: what is allowed to be a wire tier, and what must be a consumer-side projection of the shared engine? The symptoms:
- Session/collection lifecycle verbs (
CREATE_SESSION,KILL_COLLECTION,RENAME_SESSION) ship today as L1 commands inphux-protocol’sCommandenum, while ADR-0017 says session vocabulary is banished from the wire and ADR-0015 reserves the collection lifecycle for L2. - Two agent surfaces disagree. The live wire realizes agent needs as L1
commands (
GET_SCREEN,ROUTE_INPUT,GET_TERMINAL_STATE) plus anAgentEventpush frame; the unbuiltdocs/spec/L2_AGENT_PROTOCOL.md(markedstability: scratch) prescribes a gRPC+JSON transport carrying structured cells and a different event taxonomy. - Two SDK docs disagree: a hand-rolled L1 codec versus a gRPC/tonic structured-state service.
- The encoding spec prescribes field-tagged TLV; every message body is positional. This is acknowledged divergence, not a settled choice.
These are not four bugs. They are four places where structure that belongs to a consumer’s view of the terminal has either leaked onto the wire or been proposed for it. Without a stated principle, each gets re-litigated in isolation and the wire accretes product opinions one verb at a time — the failure mode ADR-0015 and ADR-0017 already named for sessions and layout, now recurring for agent state.
ADR-0013 established that both ends run
libghostty and the wire carries VT bytes. ADR-0018
generalized that to lazy state synchronization of engine state.
ADR-0022 framed every consumer as a different
projection of one source-of-truth Terminal. This ADR makes the shared
premise of all three normative and uses it to settle the audit.
Decision
1. The engine is delegated; structure is always a projection
phux does not own terminal semantics. libghostty owns the mapping from bytes to a grid of cells and from input atoms to bytes, and both ends of the wire run that same engine (ADR-0013). phux never re-encodes terminal state into a second representation on the wire.
It follows that any structured view of a terminal — a cell grid, a semantic command-boundary stream, a pane tree, a layout, a “run a command and collect its output” result — is computed by a consumer from the engine it already runs. Structure is a projection, not a transmission. Putting a structured terminal representation on the wire would re-create the tmux re-parse liability one layer up: a second model that can drift from the engine and degrade under capability mismatch, which is precisely the cost ADR-0013 paid to remove.
2. The wire’s job, stated as a closed list
The wire carries exactly: terminal identity (ADR-0016); terminal lifecycle, including an atomic multi-terminal batch operation; transport framing and capability negotiation; opaque terminal bytes (output, snapshot, input atoms per ADR-0024); and L3 metadata the server stores without interpreting. It does not carry structured screen state, semantic event taxonomies as a normative type system, panes, layouts, or command-runner results. Those are consumer projections.
The existing L1 agent affordances are read in this light. GET_SCREEN and
GET_TERMINAL_STATE return engine-derived snapshots a consumer could also
compute locally; they are a convenience for consumers that have not yet
adopted the carry-your-own-engine pattern (point 4), not a license to make
structured state a normative wire contract. New structured surfaces SHALL NOT
be added to the wire; they belong in the projection.
3. The gRPC/cells-on-wire agent design is rejected
docs/spec/L2_AGENT_PROTOCOL.md proposes a separate gRPC+JSON transport that
puts structured cells and a parallel event enum on the wire. It is rejected
and superseded by this ADR. It violates point 1 (structure on the wire), it
forks the codec and event taxonomy from the live L1 surface, and it
contradicts ADR-0022’s “agents are a projection,
the CLI + JSON schema is the contract.” The structured agent surface
(cells, command results, semantic events) is a local projection over the
shared engine, exposed to agents through the CLI and its versioned JSON
schema, not a wire service.
4. Consumers are peers; phux-web is the reference projection
The TUI, the browser client, and the agent surface are peers — none is
protocol-privileged (ADR-0017). The
reference pattern for a consumer is carry your own engine and project
locally: phux-web (ADR-0025) runs
ghostty-vt.wasm, speaks the exact wire codec (ADR-0024),
and computes its rendered view from engine state it owns. The agent SDK
SHOULD follow this pattern — run the engine, project to structured state
locally — rather than become a gRPC structured-state service. The wire stays
identical across all three; only the projection differs.
5. Group lifecycle is L3 metadata plus an atomic L1 batch op; no L2 tier
The one thing a consumer-side projection genuinely cannot do is an atomic
group operation — kill a bundle of terminals such that no observer sees a
partial state. That, and only that, is irreducible — and it is a single L1
operation, not a tier. The wire gains KILL_TERMINALS { ids: [TerminalId] },
applied atomically under the server’s existing single
Mutex<ServerState> lock (one lock acquisition, all-or-nothing for a local
server; cross-host atomicity is out of scope). There is no L2 collection
tier: group membership and names are L3 metadata plus client logic, and
sessions, windows, panes, and layouts remain L3 conventions plus client
logic (ADR-0017,
ADR-0019,
ADR-0027).
The current code is wrong here: CREATE_SESSION, KILL_COLLECTION, and
RENAME_SESSION ride as L1 commands, putting session vocabulary and
collection lifecycle in the substrate tier. They decompose and are removed:
create is SPAWN_TERMINAL(s) plus L3 metadata, rename is an L3 metadata SET
on a name key, and kill-group is the new KILL_TERMINALS { ids }. The
CollectionId plumbing is retired where it existed only to serve those
verbs; where it is entangled with SPAWN_TERMINAL or L3 metadata scoping it
may remain as a documented opaque grouping key (not a lifecycle tier) with
the remnant noted for a follow-up bead. The migration is tracked as a code
task; until it lands the docs state the target and flag the divergence
inline.
6. The reference TUI is the wedge, held pure by ADR-0017
The reference TUI is the daily-driver adoption surface that bootstraps a population of terminals-on-the-wire, and it is worth heavy product investment. Its differentiator is the wire itself — attach/detach, remoting, and humans and their agents sharing the same live terminals — not local splits, so it is not merely a second local multiplexer. The constraint that keeps the wedge from corrupting the platform is ADR-0017: the TUI gets no protocol-level standing, and its needs land as L3 conventions and client logic, never as new wire surface. Investing in the TUI as a product and holding it as a pure consumer are not in tension; the leaked L1 session verbs (point 5) are the current breach of that line, and the thin or unpublished wire/agent/web docs are the current gap.
Rationale
The delegation principle is what gives phux its central property: because the engine is shared and never re-encoded, the wire cannot introduce a second terminal model that drifts or degrades. Every time structure has been proposed for the wire — layout in an early draft of ADR-0015, session verbs in ADR-0021, cells-on-wire in the agent spec — the same argument retires it: the structure is recoverable from the shared engine, so transmitting it adds a drift surface and a conformance tax on consumers that do not want it, while buying nothing the projection lacks.
Group lifecycle is the lone exception because atomicity is not recoverable
from a projection: a client tearing down N terminals one at a time exposes
intermediate states and races a concurrent observer. A server-side atomic
operation is the cheapest correct answer — and the cheapest form of that
answer is a single L1 verb (KILL_TERMINALS { ids }) under the existing
server lock, not a whole tier. Atomicity earns one op; it does not earn L2.
Naming phux-web as the reference pattern turns an abstract principle into a copyable shape: a consumer that wants structure runs the engine and reads it, exactly as the browser client already does in shipping code.
Tradeoffs
- A structured agent surface costs each agent consumer an engine. Running libghostty to project structured state is more work than reading cells off a gRPC stream. We accept it: it is the same cost the browser client already pays, and it removes the drift surface a structured wire would add.
- Removing the leaked verbs is a wire-affecting change that today’s code
has already shipped at L1. Decomposing them into
SPAWN_TERMINAL+ L3 metadata +KILL_TERMINALS(and retiring theCollectionIdplumbing that only served them) is real migration work, not a doc edit, and bumpsPROTOCOL_VERSION0.2.0 → 0.3.0; the docs carry an inline divergence marker until it lands. - The encoding question is left open here. Whether message bodies migrate from positional to field-tagged TLV is orthogonal to tiering and is not decided by this ADR; it remains tracked separately. Naming it avoids the reader inferring that “engine-delegated wire” settled the codec shape.
- Heavy TUI investment under a no-privilege constraint means TUI features must be expressible as L3 conventions and client logic. That is a real design discipline, occasionally more work than a bespoke wire message would be, and it is the price of keeping the substrate product-agnostic.
Alternatives
(B) Dissolve L2 entirely into L3 metadata plus an L1 batch-kill — ADOPTED.
Drop the Collection tier; represent grouping as L3 metadata (a set of
TerminalIds under a well-known key) and provide atomicity through a single
L1 batch operation (KILL_TERMINALS { ids }) that the server applies as one
unit under its existing lock. Adopted because it is the most faithful
expression of this ADR’s own principle — atomicity is the lone exception, so
it earns exactly one op, not a tier — and because it is better for
federation: there is a single federated identity, TerminalId, with no
second CollectionId to federate and reconcile. The honest cost is that it
pushes membership consistency into client-maintained metadata (no
server-enforced membership view) and turns “kill this group” into a
client-assembled id list rather than a named server-side entity. We accept
that: grouping is presentation, and the one correctness need (atomic
teardown) is fully served by the batch op.
(A) Minimal lifecycle-only L2 collection tier — considered, not chosen.
Keep L2 as a narrow tier whose entire job is atomic create/kill/membership of
a Collection, with sessions/windows/panes/layout as L3 plus client logic.
Honest description: it isolates the one operation a projection cannot perform
atomically behind a server surface, makes grouping a named server-consistent
entity, and leaves ADR-0015’s three-tier model
intact. Not chosen because it adds wire surface (a whole tier) for what is one
op of irreducible need, and it introduces a second federated identity
(CollectionId) to carry, reconcile, and version alongside TerminalId —
weight that option B avoids while still delivering atomic teardown.
(C) Adopt the gRPC/cells-on-wire agent protocol. Build the agent surface
as the separate structured-state service L2_AGENT_PROTOCOL.md describes.
Rejected per Decision point 3: it puts structure on the wire, forks the codec
and event taxonomy, and contradicts the projection thesis. Recorded here only
to mark it as considered and closed.