phux
Decisions

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...

evolving document
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 in phux-protocol’s Command enum, 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 an AgentEvent push frame; the unbuilt docs/spec/L2_AGENT_PROTOCOL.md (marked stability: 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 the CollectionId plumbing that only served them) is real migration work, not a doc edit, and bumps PROTOCOL_VERSION 0.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.

View exact source