L3 — Metadata storage
The OPTIONAL typed key-value service the server hosts but does not interpret.
Full source summary
The OPTIONAL typed key-value service the server hosts but does not interpret. Scopes are Terminal, Group, and Global; values are opaque bytes with a conventional CBOR-and-versioned-key shape. This document owns the metadata model and the grouping/session-name conventions consumers use to build sessions, groups, and layouts on top of L1, since there is no L2 collection tier. It also owns one host query, LIST_DIRECTORY (§4), which lists directories on the serving host for a directory picker.
1. L3 message catalog
A typed key-value store the server hosts and does not interpret. Scopes:
Terminal { terminal_id, key, value }Group { group_id, key, value }—GroupIdis an opaque grouping key, not a lifecycle tier (see L2.md)Global { key, value }
Status: spec-only. L3 does not yet enforce the workload grant described below.
Values are opaque bytes. The metadata service does not interpret them and
enforces only size limits plus the connection’s phux-workload/v1 grant:
read/list/subscribe and mutation map to the closed matrix in
workload-auth.md §7. The conventional value shape is
CBOR-encoded structured data under a versioned key (phux.session.name/v1,
phux.tui.layout/v1); see §3 for the conventions consumers share.
L3 messages:
| ID | Direction | Name | Reference | Status |
|---|---|---|---|---|
| 0x50 | C → S (cmd) | GET_METADATA | §2 | shipped |
| 0x51 | C → S (cmd) | SET_METADATA | §2 | shipped |
| 0x52 | C → S (cmd) | DELETE_METADATA | §2 | shipped |
| 0x53 | C → S (cmd) | LIST_METADATA | §2 | shipped |
| 0x54 | C → S | SUBSCRIBE_METADATA | §2 | shipped |
| 0xD0 | S → C | METADATA_CHANGED | §1 | shipped |
| 0xD1 | S → C | METADATA_VALUE | §1 | shipped |
| 0xD2 | S → C | METADATA_KEYS | §1 | shipped |
| 0x55 | C → S | LIST_DIRECTORY | §4 | shipped |
| 0xD3 | S → C | DIRECTORY_LISTING | §4 | shipped |
Wire bodies (field-tagged TLV, per appendix-encoding.md; fields listed in field-id order, leaf primitives and nested unions positional within each field):
GET_METADATA { request_id: u32, scope: Scope, key: str }
SET_METADATA { request_id: u32, scope: Scope, key: str, value: bytes }
DELETE_METADATA { request_id: u32, scope: Scope, key: str }
LIST_METADATA { request_id: u32, scope: Scope }
SUBSCRIBE_METADATA { scope: Scope, key: str }
METADATA_CHANGED { scope: Scope, key: str, value: optional<bytes> }
METADATA_VALUE { request_id: u32, value: optional<bytes> }
METADATA_KEYS { request_id: u32, keys: list<str> }
Scope = tagged_union {
TERMINAL (ResourceId), // tag 0x00
GROUP (GroupId), // tag 0x01; u32 wire body
GLOBAL, // tag 0x02; empty body
}
1.1 The inline-value rationale
Three S→C frames carry their value inline rather than make the consumer
issue a follow-up GET_METADATA:
METADATA_CHANGED { scope, key, value }—value: Some(bytes)on aSET,value: None(a tombstone) on aDELETE.METADATA_VALUE { request_id, value }— correlates to a priorGET_METADATA.request_id, withvalue: Nonewhen the key is absent.METADATA_KEYS { request_id, keys }— correlates to a priorLIST_METADATA.request_id; keys are lexicographically sorted and values are NOT included (clients fetch them withGET_METADATA, since aLISTresult is large and most keys are not read).
The shared reason: the layout-coordination use case
(ADR-0019) is a
read-on-every-change pattern, so a separate notify-then-fetch round trip is
waste the consumer always pays. An earlier draft routed GET/LIST replies
through a generic COMMAND_RESULT envelope and had consumers GET after a
change notification; both are dropped for these three frames. COMMAND_RESULT
remains the envelope for L1 commands that need its tagged union (e.g. SPAWN
returning a ResourceId); it does not subsume the L3 reply frames.
1.2 Subscriptions, conformance, and scope
A client subscribing via SUBSCRIBE_METADATA { scope, key } MUST receive
METADATA_CHANGED when that specific (scope, key) is written or deleted.
The reply frames and METADATA_CHANGED MUST NOT be emitted to a consumer
whose HELLO.client_caps.layers does not include L3
(proto.md §11.5). A server that receives an L3 request from a
non-L3 consumer MAY drop it silently (matching the SUBSCRIBE_METADATA
precedent) or reply with ERROR { MALFORMED_MESSAGE }. The withdrawn
OUT_OF_TIER code is permanently retired and MUST NOT be emitted.
Subscriptions are connection-scoped: a client’s subscriptions are dropped
automatically on DETACH (proto.md §7.2) and on transport
close. There is no explicit UNSUBSCRIBE_METADATA.
A server MAY bound the number of subscriptions one connection holds at
once and refuse a SUBSCRIBE_METADATA past that bound; a refusal has no
wire signal of its own for the same reason a non-L3 refusal does not (this
command has no reply frame), so a client MUST NOT assume a SUBSCRIBE_METADATA
it sent was actually installed. A server SHOULD also drop a subscription
naming a Terminal scope once that Terminal closes, even while the
subscribing connection stays open, so a long-lived watcher does not
accumulate subscriptions to panes that no longer exist.
1.3 L3 does not federate
L3 metadata is server-local. A federation hub (ADR-0007)
relays L1 commands and SUBSCRIBE_EVENTS across a satellite link. Of L3 it
relays exactly one thing: a per-request host query naming that satellite,
today LIST_DIRECTORY with host (§4.1,
ADR-0108). For
that, a hub negotiates L3 on each satellite link. It relays no metadata frame
in either direction (no GET_METADATA, SET_METADATA, LIST_METADATA, or
SUBSCRIBE_METADATA), and it never chains a request past the satellite it
names. A hub therefore holds no metadata for a satellite-owned Terminal and
can never emit a METADATA_CHANGED about one.
A server that receives a SUBSCRIBE_METADATA whose scope is
Terminal(ResourceId::Satellite { .. }) MUST NOT install the
subscription, and SHOULD push
ERROR { request_id: None, code: UNSUPPORTED_SATELLITE_ROUTE } naming the
key and the satellite. Installing it silently is the one outcome a
consumer cannot recover from: with no reply frame to distinguish
acceptance from a drop, the caller blocks on a notification no code path
can produce. The uncorrelated push is the same signal
L1.md already defines for a SUBSCRIBE_EVENTS with no route,
for the same reason — a command with no reply frame still owes a refusal
somewhere.
UNSUPPORTED_SATELLITE_ROUTE is reused rather than extended: it already
means “this frame carried a ResourceId::Satellite and there is no route
for it”, and “this verb has no satellite route on any server” is that
same fact on a different axis. A new ErrorCode value would be a hard
decode failure on any peer that predates it, and 106 has shipped since
0.7.0.
GET_METADATA / LIST_METADATA on a satellite scope keep answering
normally (value: None, empty keys): they report the receiving server’s
store, and on that server the key genuinely is unset. A consumer that
needs a satellite pane’s metadata connects to that satellite’s own server.
A future protocol version MAY route L3 across a hub. That is a strict
upgrade of this rule — a refusal becoming a METADATA_CHANGED breaks no
consumer — so nothing here is a barrier to it.
LIST_DIRECTORY is a host query, not metadata: its relay stores nothing on
either server, so the rules above stand for every metadata key.
2. L3 commands
Wire discriminants are allocated above in §1.
Command_L3 = tagged_union {
GET_METADATA { scope: MetadataScope, key: str },
SET_METADATA { scope: MetadataScope, key: str, value: bytes },
DELETE_METADATA { scope: MetadataScope, key: str },
LIST_METADATA { scope: MetadataScope, prefix: optional<str> },
}
MetadataScope = tagged_union {
TERMINAL (ResourceId),
GROUP (GroupId), // opaque grouping key, not a tier
GLOBAL,
}
The server MUST NOT interpret metadata values. Implementations MAY enforce a
per-key size limit (recommended: 256 KiB) and return RESOURCE_EXHAUSTED if
exceeded.
3. Grouping and session conventions (non-normative)
This section is the spec’s registry of well-known metadata keys. The server stores the values and does not interpret them, except where a bullet below names a server-intercepted key. There is no L2 collection tier (L2.md); session, window, pane, layout, and focus are consumer vocabulary, not wire concepts. How the reference TUI reads and writes these keys is consumers/tui.md. A consumer MAY ignore this section and use its own prefixes.
Keys identify the convention; the layout envelope carries its own schema version. Values are CBOR-encoded structured data unless noted.
3.1 Session conventions
Withdrawn L1 session/collection verbs decompose into SPAWN_RESOURCE
plus the following keys. Atomic group teardown is KILL_RESOURCES
(L1.md).
-
phux.session.name/v1— the human-facing group name. ASETon this key is a rename; there is noRENAME_SESSIONwire verb. Scope:Global. Value:current\0new(NUL-separated UTF-8) — the v0.3.0 re-tier convention this spec’s CHANGELOG records; the server intercepts the write and applies the authoritative registry rename. When the name actually changed, the server broadcasts aMETADATA_CHANGEDcarrying the appliedcurrent\0newvalue to subscribers of the written(scope, key); a refused or no-op rename broadcasts nothing, and the value is not retained in the store (aGETon this key returns absent). -
phux.session.create/v1— a create-without-attach request interpreted atomically by the reference server. Scope:Global. Value: a UTF-8 JSON object{name: str, command?: list<str>, cwd?: str, env?: map<str,str>, request_token?: UUID, agent_session?: list<u8>, keep_empty?: bool, empty?: bool}.commandpreserves argv boundaries,cwdselects the seed pane’s working directory, andenvis added to the seed process environment. A presentagent_sessioncontains 1–4096 encoded bytes of the §3.7.1 record and is installed in the same state transaction that creates and interns the seed Terminal. The server creates the named session and seed Terminal without attaching or resizing. This replaces the oldCREATE_SESSIONverb.keep_empty: truemarks the new session keep-empty (ADR-0105): it is not removed when its last window closes.empty: truecreates the session with no seed Terminal and zero windows, and implieskeep_empty; a request carryingempty: truetogether withcommandoragent_sessionis malformed and ignored. Both fields are gated onServerFeature::KEEP_EMPTY_SESSIONS = 0x00020000(proto.md §6.2). A client MUST see the bit before sendingempty: true: an older server ignores unknown JSON fields and would seed a shell under the name. Underphux-workload/v1, this server-interpretedSET_METADATArequires bothCREATEandBINDon Global before the value is parsed; a BIND-only grant cannot reach process creation. -
phux.session.created/v1/<request_token>— the one-shot result published after a successful nonce-bearing create request. Scope:Global. Value: UTF-8 JSON{name: str, terminal_id: u32, request_token: UUID}; for anempty: truecreate,terminal_idisnulland the object addsempty: true. BecauseSET_METADATAhas no reply body, the creating connection reads its exact nonce-specific key and verifies bothnameandrequest_token; other connections receive an absent value even if they know the nonce. This prefix is server-owned: underphux-workload/v1, ordinarySET_METADATA,DELETE_METADATA, andSUBSCRIBE_METADATAtargeting the exact key or its slash-prefixed results are default-denied before the handler. The pre-profile reference server ignores them. The server consumes the value after a successful owner read, removes abandoned values when their owner disconnects, bounds unread results per connection, and excludes nonce-bearing result keys fromLIST_METADATA. The un-suffixedphux.session.created/v1{name, terminal_id}convention remains for legacy clients. -
phux.session.keep_empty/v1— the keep-empty mark of a session (ADR-0105). Scope:Global. Value:name\0trueorname\0false(NUL-separated UTF-8, the shape ofphux.session.name/v1). The server intercepts the write and applies it to the named session; the value is not stored, so aGETreturns absent. A write that changes the mark is broadcast asMETADATA_CHANGEDwith the applied value to subscribers of the key; an unknown name, a malformed value, or a write of the mark the session already carries broadcasts nothing. A keep-empty session is not reaped when its last window closes: the window goes and the session stays with zero windows, and the server keeps running for it exactly as it does for an idle session. Clearing the mark on a session that holds no windows removes that session, which is how an empty session is killed; that removal is broadcast too, detaches clients attached to the session withDETACHED { SESSION_KILLED }, and may end the server under the same last-session rule as the ordinary cascade. Clearing it on a populated session only restores the default cascade. AKILL_RESOURCESnaming every Terminal of a keep-empty session is a group teardown: the server clears the mark in the same transaction, so the session goes with its panes. The mark is reported per session in theATTACHEDandGET_STATEsnapshots through the trailing session facets (SessionSnapshotbelow). A server advertising the bit also acceptsATTACHto a session with no windows: its snapshot carries the0sentinels infocused_windowandfocused_resource, which no allocator mints. Gated onServerFeature::KEEP_EMPTY_SESSIONS. When a keep-empty session loses its last window the server deletes that session’sphux.tui.layout/v1/<session-id>key (§3.2), broadcasting the tombstone, because the stored tree then names only dead panes; this is the one place the server acts on that key. A groupKILL_RESOURCESthat releases a mark broadcastsname\0falseon this key in the same transaction, and once the cascade removes the released session its session-attached clients receiveDETACHED { SESSION_KILLED }. Underphux-workload/v1setting the mark requiresCREATEandBINDon Global and clearing it requiresSIGNAL(workload-auth.md §7).The snapshot’s session facets are its third trailing
u32-counted list, after the resource facets and thehostsinventory (L1.md §9.1 fixes the order), one row per keep-empty session:session_row = id: SessionId (u32) || flags: u8 // bit 0 = keep_emptyThe list is written only when some session is keep-empty, and then the resource-facet list and the
hostslist are always written before it, each with a zero count if it has no rows, so no list aliases another. A decoder reads it only when bytes remain afterhosts, ignores unknown flag bits, and ignores a row naming no session. An older decoder stops before it and reads every session as not keep-empty; a snapshot with no keep-empty session is byte-identical to one encoded before the list existed.
Group membership remains a consumer projection over server-owned sessions and
Terminals. Atomic teardown is delegated to KILL_RESOURCES; no L2 collection
verb is reintroduced.
3.2 phux.tui.layout/v1/<session-id> — the layout tree
Scoped to Group 1 and keyed by session ID. The current CBOR envelope contains the session’s ordered windows and binary-split trees:
Layout = {
version: 3,
windows: list<Window>,
focused_window_index: u32,
}
Window = {
id: array<u8, 16>, // stable shared layout identity
name: str,
root: LayoutNode,
focused_terminal: ResourceId,
}
LayoutNode =
{ kind: "leaf", pane: ResourceId }
| { kind: "split", dir: "horizontal" | "vertical", ratio: f32,
left: LayoutNode, right: LayoutNode }
ResourceId =
{ kind: "local", id: u32 }
| { kind: "satellite", host: str, id: u32 }
This schema is a consumer convention, not a wire concept. A layout names
terminals by ResourceId; an L3 link to a terminal is a metadata value,
not a second wire identity. How the reference TUI stores, decodes, and
reconciles the envelope is consumers/tui.md.
Metadata mutation is whole-value last-write-wins. A read after the write confirms the value currently held by the server, not an atomic compare-and-swap.
3.3 phux.tui.window_order/v1 and phux.tui.focus/v1
phux.tui.window_order/v1— scoped to aGroupId; alist<u32>of stable window indices in display order, driving tab-bar ordering.phux.tui.focus/v1— per-client state (a Global key namespaced by client UUID, since the server exposes noClientIdscope). Records which terminal the local user is aiming input at; not synchronized across clients. The L1INPUT_FOCUSmessage (input.md) is unrelated — it carries host-OS focus into the terminal so VT-aware programs can react.
3.4 What these conventions do NOT use
- No “session” or “window” wire concept. Both are names for structure encoded in metadata.
- No
LAYOUT_CHANGED/FOCUS_CHANGED/WINDOW_*events. A change isMETADATA_CHANGEDon the relevant key; subscribers re-read the value.
3.5 Alternative consumers
A native GUI consumer mounting L3 MAY (and SHOULD) use its own metadata keys
with a different prefix (e.g. app.foo.layout/v1) rather than reuse the
TUI’s schema. Sharing schema across consumers is opt-in, not the default. An
agent SDK consumer typically declares HELLO.layers = { L1 } and ignores
this section.
3.6 phux.tags/v1 and phux.link/v1 — terminal tags and links
Unlike the rest of §3, the schema of these two keys is normative
(ADR-0027 decision
point 4): tags and links are a cross-consumer projection over ResourceId,
so their meaning MUST NOT drift between clients. The server still stores the
bytes opaquely and interprets nothing; “normative” constrains the consumers,
not the wire. Both ride the existing SET_METADATA / GET_METADATA /
LIST_METADATA / SUBSCRIBE_METADATA verbs (§2) — no new wire tag.
-
phux.tags/v1— scoped to aResourceId. Value: a UTF-8 JSON array of tag strings, each non-empty and free of the#sigil, the array duplicate-free, e.g.["build","ci"]. An empty array or an absent key both mean “no tags”. The#tagselector (ADR-0027 decision point 5; tui.md) resolves to the set ofResourceIds whosephux.tags/v1value containstag, evaluated client-side against the snapshot exactly as a session/window name resolves — the server stays selector-agnostic (ADR-0017). -
phux.link/v1— scoped to the sourceResourceId. Value: a UTF-8 JSON array of link records{ "target": u32, "kind": str }, wheretargetis the linked Terminal’s local wire id andkindis an open enum. v1 defines"group"(a soft grouping edge); a consumer that reads an unknownkindMUST preserve it on rewrite rather than drop it, so the vocabulary grows additively. A link is a metadata value, never a second wire identity — there is noLinkId.
Per-key size limits (§2, recommended 256 KiB) apply; a tag/link set that would exceed them is the client’s concern to bound.
3.7 phux.agent/v1 — agent identity and lifecycle
Like §3.6, the schema of this key is normative
(ADR-0040): agent identity is a
cross-consumer projection over ResourceId, so its meaning MUST NOT drift
between clients. The server stores the bytes opaquely on the L3 read/write path;
a reference server MAY additionally act as a writer of this one key
(ADR-0046, and see
“Server as a producer” below). The key rides the existing SET_METADATA /
GET_METADATA / DELETE_METADATA / SUBSCRIBE_METADATA verbs (§2) — no new
wire tag.
-
phux.agent/v1— scoped to theResourceIdthe agent runs in. Value: a UTF-8 JSON object:{ "name": str, // REQUIRED, non-empty human-facing name "kind": optional<str>, // open vocabulary slug, e.g. "claude", "codex" "state": optional<str>, // OPEN enum: "unknown" | "idle" | "working" // | "blocked" | "done" "attention": optional<str>, // OPEN enum: "none" | "low" | "normal" | "high" "session": optional<str> // free-form association label (fleet/job name) }stateandattentionare OPEN string enums: a consumer reading an unrecognized value MUST treat it asunknown(forstate) ornormal(forattention) rather than fail the parse, so the vocabulary grows additively. An absentstatemeansunknown; an absentattentionis derived fromstate(consumers conventionally mapblockedtohigh). An absent key or a value that is not a JSON object with a non-emptynamemeans “no declared agent”.Writes are whole-record (last writer wins); there are no field-merge semantics.
DELETE_METADATAclears the declaration. The Terminal scope IS the terminal association; the per-Terminal store is dropped when the Terminal closes, so a record never outlives its pane.A consumer that finds this record MUST prefer it over heuristic derivations (OSC-title conventions such as the §-adjacent
phux-asksentinel of ADR-0035, or screen scraping); heuristics remain the fallback when the key is absent. Backsphux agent set/clear, thephux agent list/show/explainprovenance-ranked report, and the reference TUI’s sidebar/tab labels.Server as a producer. A server MAY derive this record for a Terminal it owns — from the Terminal’s own OSC title, its live screen, or its PTY’s foreground process — and write it on the same L3 path any other writer uses (ADR-0046). This is a convention on top of the existing verbs, not a format change: a server-derived record is byte-identical in shape to a declared one, and a consumer neither can nor needs to distinguish them. A server that does so:
- MUST NOT overwrite, with a derived value, a record whose
statewas supplied by an explicitSET_METADATA. An explicit declaration ofstateoutranks any derivation for as long as the pane is occupied by the agent it describes; the derivation resumes after aDELETE_METADATAclears the record, or after the server withdraws the declaration under the rule below. - MAY withdraw an explicit declaration of
state— by settingstateto"unknown", never by substituting a derived value and never byDELETE_METADATA— when it has positive evidence that the declared occupant of the pane is gone: for example, the PTY’s foreground process group no longer resolves to any agent, or resolves to a different one. Positive evidence means an observation the server successfully made and which found no such agent; it is NOT an observation the server was unable to make. A server that cannot determine occupancy MUST hold the declaration. A withdrawal MUST preservename,kind, andsession; it SHOULD clearattention, whose basis was the state being withdrawn. A withdrawal MUST be idempotent at the byte level, so withdrawing an already-withdrawn record broadcasts nothing. Once withdrawn, the declaration no longer outranks the derivation. A record’s lifetime is bounded by its pane, but a declaration’s truth is bounded by its subject: this is the one rule that lets a server close that gap, and"unknown"is how it does so without ever asserting a state it did not derive. - MUST preserve the
name,kind, andsessionfields of an identity-only declaration (one that supplied nostate) when it fillsstatein. - MUST only
DELETE_METADATAa record it authored itself, never one an explicit writer set. - SHOULD write only on a change of the derived value, so a long-running
derived
stateproduces no repeatedMETADATA_CHANGEDbroadcasts (a reference server already deduplicates an equal-bytesSET, §2).
A consumer MUST NOT assume a server derives the record: the key is absent on a server that does not, exactly as before.
Reading
state: levels and edges. A consumer readsstatein one of two ways, and the two readings do NOT assert the same thing. Which one a consumer is entitled to use follows from the shape of the gate it is building, not from convenience.A level read — “what does
statesay right now?” — asserts only that no contrary state is being asserted about that pane at this moment.idlecarries the weakest claim of the vocabulary: it is the value a producer is expected to fall back to when it has no positive evidence of anything else, so a consumer MUST tolerate anidlelevel that is equally true of an agent that finished its turn, an agent that crashed, a pane running a pager or an editor, an agent still painting its splash screen, and a pane whose occupant the producer never identified. This is the same “no information” reading the spec already gives an absentstateand an unrecognizedstate(bothunknown);idlediffers from those in provenance, not in the strength of what it asserts. A producer MAY publishidleon positive evidence, and nothing here forbids it — but the record carries no field distinguishing a positively-derivedidlefrom a fallthrough one, so a consumer MUST NOT assume it is reading the former.An edge — a transition the consumer itself observed, from a value it read earlier to a different value it reads now — asserts strictly more than either level does: that whatever was asserting the old value stopped asserting it.
working->idleis positive evidence about a transition even thoughidleis not positive evidence about a condition.Therefore:
- A consumer implementing a completion gate — anything that reports “the agent finished”, such as a blocking wait on a pane or a send-a-prompt-and-wait — MUST require an observed transition into one of the states it is waiting for, and MUST NOT be satisfied by a level read of the current state. A consumer that has only ever observed the pane in a state it accepts has observed no edge and MUST keep waiting, subject to its own timeout, rather than report completion. This is what keeps a wait from reporting success on a pane whose agent crashed, exited, or was never running.
- A consumer implementing a safety gate — anything that declines to disturb a pane, such as refusing to scroll a screen that may be repainting, or refusing to write into a pane whose occupant may have changed — MAY read the level. Absence of contrary evidence is the correct predicate for “do not disturb this”, and a crashed or unidentified pane reading as “do not disturb” errs in the conservative direction.
doneneeds its own caution. It is the one value in the vocabulary that no title- or screen-derived rule can honestly produce, so a producer that derivesstatefrom observation alone will never emit it. A consumer MUST NOT treatdoneas reachable on an arbitrary pane and MUST NOT read its absence as evidence that the agent did not finish. An exclusive wait for it is meaningful only when an integration supplies lifecycle evidence.In the reference server none of this is hypothetical. Its derivation (ADR-0046 decision point 5) is a fail-safe fallthrough:
agent_detectreturnsidlewhenever no state-bearing rule matched, and the five detection manifests the binary ships (crates/phux-server/rules/*.toml, for Claude Code, Codex, OpenCode, Pi, and OMP) declare eightworkingrules, fiveblockedrules, exactly oneidlerule and zerodonerules between them.That single
idlerule is worth stating precisely, because it is the exception that shows where the general caution above comes from. It isclaude.toml’sosc-progress-idle, and it derives from neither the title nor the screen: it reads Claude Code’s OSC 9;4 progress channel, on which the CLI explicitly states that the turn’s progress indicator was removed.claude.tomlstill records in prose why a title- or screen-derived positiveidlerule was deliberately declined — the live chrome is byte-identical between idle and working, and the quiet title covers a pending permission dialog as well as an idle prompt. A consumer gains nothing from knowing which of these produced a given record, and the record carries no field distinguishing them, which is exactly why the level read above must tolerate the weakest reading regardless.The opt-in Claude hook shim supplies
donethrough ADR-0085’sREPORT_AGENT_STATE, which feeds the detector without declaring metadata state and therefore without disabling later screen correction.doneremains unreachable by derivation alone: no manifest rule produces it, on this server or in principle. A different server that authors further positiveidlerules or another lifecycle integration is fully conforming; the requirements above constrain what a consumer MUST tolerate, not what every server MUST do. - MUST NOT overwrite, with a derived value, a record whose
Status: shipped. The reference server serves
AGENT_SESSIONand ranks a live child’s stream above hook, process, and screen evidence when deriving the parent’sphux.agent/v1state. Declared, hook-fed, and screen-derived records remain the fallback when no live child exists.
Projection of a producer-fed stream. When the Terminal has a live
AGENT_SESSION child — a resource of that kind bound to it whose stream
has not recorded session_end (L1.md §1.2, §4.8) — the
server-derived record above is a projection of that child’s stream: name
and kind come from the session’s provider when no explicit declaration
supplies them, and state follows the derivation table of
L1.md §7.2. The schema of this key is unchanged; a consumer
cannot and need not tell a stream-projected record from a screen-derived
one. Precedence among derived sources is Stream > Hook > Process > Screen,
and the stream never derives idle: while the child is live, screen
derivation runs only to reach idle or to detect departure. Every rule in
this section about explicit declarations holds unchanged — a SET_METADATA
that supplies state outranks the stream exactly as it outranks the
screen, and the withdrawal rule applies to it in the same way. After
session_end the arbiter falls back through the remaining sources as if
the child had never existed. A consumer that wants the stream itself rather
than its projection attaches to the child with ATTACH_RESOURCE.
3.7.1 phux.agent-session/v1 — native resume provenance
This record is distinct from phux.agent/v1: it authorizes reconstruction of
a provider-native resume invocation after the live Terminal is gone, so its
shape and writers are deliberately narrower
(ADR-0068).
-
phux.agent-session/v1— scoped to the exact localResourceIdreturned by the agent’s spawn. Value: a UTF-8 JSON object:{ "plugin_id": str, "integration_id": str, "native_id": str }All fields are REQUIRED and control-free.
plugin_idandintegration_idcontain 1–120 UTF-8 bytes;native_idcontains 1–1024 UTF-8 bytes and MUST NOT begin with-. Values are trimmed. The complete encoded record contains 1–4096 bytes; the reference server rejects empty or oversized values on both atomic spawn/create and ordinary reserved-key SET paths. Unknown fields invalidate v1 rather than extending executable authority accidentally.The record contains no executable path or argv. A restoring consumer MUST resolve the current enabled
integration_id, MUST verify its unique owningplugin_id, and MUST construct resume argv through that integration’s structured policy. The identity placeholder MUST occupy one complete argv element,native_envMUST use a dedicatedPHUX_*_SESSION_IDname, and template policy MUST NOT expose the identity as executable or evaluator source; a fixed plugin-owned interpreter script is permitted. Restore MUST fail closed on missing, stale, invalid, ambiguous, or ownership-mismatched policy. A writer SHOULD read the value back before reporting launch/restore success.Closing the Terminal drops this live record with its other metadata. Durable replay comes only from a consumer copying a validated record into a versioned workspace archive; the metadata key itself is not a database.
3.7.2 phux.pane-occupant/v1 — foreground process
This Terminal-scoped record publishes the privacy-bounded process fact needed by an available-shell safety gate. It uses the existing L3 read and subscription path; no frame, command, or capability is added.
-
phux.pane-occupant/v1— scoped to a localResourceId. Value: a UTF-8 JSON object:{ "foreground": str, "is_pane_shell": bool }Both fields are REQUIRED.
foregroundis the non-empty, control-free, login-dash-stripped basename of the foreground process group’sargv[0]. It MUST NOT contain a path separator. The record deliberately carries no pid, argv tail, cwd, environment, or command text.is_pane_shellis true only when the PTY’s foreground process-group id is the pane’s original child pid andforegroundis a known interactive shell. It does not assert that every member of that process group is the shell. A consumer MUST treat an absent or malformed record as no answer, never astrue.The owning server is the sole writer. Clients MAY
GET_METADATAandSUBSCRIBE_METADATA, but MUST NOTSET_METADATAorDELETE_METADATAthis key; workload-authenticated mutation is default-denied before the handler. The pre-profile reference server ignores such mutations. A server SHOULD reuse an already-required foreground-process query and SHOULD write only when either field changes. A failed process query is absence of evidence: the server MUST hold its last record rather than manufacture a transition. Terminal closure drops the record with the Terminal’s metadata.A safety gate MAY use a current
truevalue as positive available-shell evidence and MUST refuse a currentfalsevalue. Because the observation is periodic, stronger contradictory evidence observed by the consumer (for example an OSC-133 mark proving the cursor is not at a prompt) still wins.
3.8 phux.config.reload/v1 — the config-reload doorbell
A pure signal key: its broadcast, not its value, carries
the meaning. The server stores the bytes opaquely and interprets nothing;
the key rides the existing SET_METADATA / SUBSCRIBE_METADATA verbs
(§2) — no new wire tag.
-
phux.config.reload/v1—Globalscope. Value: an opaque, writer-chosen nonce (the reference CLI writes a UTF-8unix-nanos-pidstring). Its only requirement is to DIFFER from the previously stored value: a reference server deduplicates an equal-bytesSET(no broadcast), so a repeated constant would ring the doorbell at most once.A consumer subscribed to this key treats a non-tombstone
METADATA_CHANGEDas “re-read your local configuration now”: it re-runs its own config load and rebuilds its config-derived state in place. Configuration itself NEVER crosses the wire — each consumer reads its own file, so hosts with different configs each apply their own. A consumer whose re-read fails MUST keep its previous configuration intact (surface the error locally; never crash, never half-apply). Underphux-workload/v1, ringing this doorbell requiresSIGNALon Global andDELETE_METADATAtargeting it is default-denied. The pre-profile reference server ignores tombstones; deleting the key is housekeeping, not a reload request.Writers SHOULD validate their local config before ringing the doorbell so an obviously broken file fails at the writer with a useful error instead of fanning out no-op reload attempts. Backs
phux config reloadand the reference TUI’s in-place reload (tui.md).
3.9 phux.whoami/v1 — connection identity
A server-owned, read-only key
(ADR-0106). The server
answers it per connection from the identity the accepting transport
established. Nothing is stored under it and no frame is added. A server
advertises it with ServerFeature::WHOAMI = 0x00040000. A client MUST see the
bit before trusting an answer, because an older server answers the unknown key
as absent.
-
phux.whoami/v1—Globalscope. Value: a UTF-8 JSON object:{ "schema_version": 1, "principal": str | null, "credential_id": str | null, "auth_route": str, "peer_uid": u32 | null, "serving_user": { "uid": u32, "name": str | null }, "host": str, "server_version": str, "ssh_client": { "addr": str, "port": u16 } | null }principalandcredential_idare the bearer credential’s principal and its non-secret id (the idphux pair rotatetakes). Both are null on a route with no credential.auth_routeis an open vocabulary; a reader shows an unknown value as-is. The values defined today:auth_routeMeaning udsUnix-domain socket, trusted by the kernel’s peer credentials ssh-stdioa Unix-socket connection that phux stdio-bridgeannounced as arriving overssh HOST phux stdio-bridge(below); trusted exactly asudsbearer-quicQUIC admitted by a bearer credential, directly or bridged through a relay bearer-wssTLS WebSocket admitted by a bearer credential bearer-webtransportWebTransport admitted by a bearer credential loopback-quic,loopback-ws,loopback-webtransporta loopback listener that carries no credential peer_uidis the kernel peer uid of audsorssh-stdioconnection (onssh-stdio, the bridge process’s uid) and null on every network route, where the transport has no such fact.ssh_clientis the ssh client endpoint of anssh-stdioconnection and null on every other route.serving_useris the OS user the server runs as. Itsnameis null when the uid has no password-database entry. The server never switches users, so this is also the user every pane runs as.hostis the serving host’s name, or the empty string when it cannot be read.server_versionis the server’s release version. Readers MUST ignore unknown fields; an additive field keepsschema_versionat 1.The owning server is the sole writer.
GET_METADATAanswers the asking connection’s own record, so two connections can read different values at once.SET_METADATAandDELETE_METADATAof this key are refused in every scope. Underphux-workload/v1they are default-denied before the handler, and the pre-profile reference server ignores them.LIST_METADATAdoes not enumerate the key.SUBSCRIBE_METADATAnever fires, because the value is fixed for the life of a connection. The refusal is by key, not scope: under a Terminal or Group scope the key is readable but always absent, and unwritable, so a client must not store its own data there. A hub answers for the connection to itself and does not route the key to a satellite. Reporting identity changes nothing: no key, frame, or command switches the serving user.ssh-stdioand the bridge announcement.phux stdio-bridgeis byte-transparent except for the client’s HELLO. It rewrites that one frame: it removes anyssh_origin(HELLO field 9, proto.md §6.1) the remote client sent. When sshd setSSH_CONNECTIONin its environment (SSH_CLIENTis the fallback), it then appends its own, carrying the ssh client address and port and, when known, the sshd address and port. The field carries nothing secret. A server that advertisesServerFeature::SSH_ORIGIN = 0x00100000honors it only from a Unix-socket peer whose kernel uid equals the serving uid, which a same-user bridge always is. For that connection the server reportsauth_route: "ssh-stdio"andssh_client. It checks the field after HELLO authorization, and the policy engine never reads it, so authentication and authorization are exactly those of anyudspeer. The server ignores the field from any other peer (a network transport, the in-process transport, or another uid such as root on the owner-only socket), and when the value does not parse.The value is a label, not an authenticated fact. It is whatever the connecting side reported. sshd sets
SSH_CONNECTION, but the ssh client chooses the remote command, sossh HOST 'SSH_CONNECTION="..." phux stdio-bridge'sets any value. A bridge that predates the field forwards a remote client’s own field 9 unchanged, and a server with the bit honors it, because the bridge is a same-uid Unix-socket peer. A local process running as the serving user can send the field directly. The bridge’s strip-and-replace guarantees the sshd-reported endpoints only when ssh forces the bridge command, as with acommand=restriction inauthorized_keys.That is safe because the field grants nothing. It can only relabel a
udsconnection asssh-stdio, never turn a connection into a route with more trust. Anyone who can have it honored is already a same-uid local client, or a user who can log in over ssh as the serving user, and so already holds that user’s full authority. A forged value misstates only that connection’s own label. The bridge does not wait for the feature bit. Field 9 is skipped by length, so an older server ignores it and keeps reportinguds. Learning the bit first would mean holding the client’s bytes while parsing the server’s reply stream. The bit tells a reader that this server reports an announced connection asssh-stdio. A current bridge run outside ssh, with noSSH_CONNECTIONorSSH_CLIENT, strips any client-supplied field 9, so that connection reportsuds.
4. Host query: LIST_DIRECTORY
LIST_DIRECTORY asks the serving server for the child directories of a path
on its own host. It is not a metadata key: it reads the host filesystem, as
the server’s OS user, and stores nothing. It rides L3 because it follows the
L3 request/reply shape (a request_id correlated to a dedicated reply frame,
like GET_METADATA / METADATA_VALUE) and serves the same consumers. A
server advertises it with ServerFeature::LIST_DIRECTORY = 0x00008000
(proto.md §6.2). A client MUST see that bit before sending the
frame: an older server drops the unknown discriminant, and the client would
wait for a reply that never comes.
LIST_DIRECTORY { request_id: u32, // field 1
path: str, // field 2
host: optional<str> } // field 3; §4.1
DIRECTORY_LISTING { request_id: u32,
path: str, // field 2
parent: optional<str>, // field 3
entries: list<Entry>, // field 4
truncated: optional<u8>, // field 5; absent = 0
error: optional<DirectoryErrorCode>, // field 6
message: optional<str> } // field 7
Entry = { name: str, flags: u8 } // flags bit 0 = symlink; other bits reserved
DirectoryErrorCode = enum (u8) {
NOT_FOUND = 0,
PERMISSION_DENIED = 1,
NOT_A_DIRECTORY = 2,
OTHER = 3,
}
entries is one field whose value is a positional u32 count followed by
that many (str, u8) pairs. A receiver MUST reject a reply whose count
exceeds 1024 as malformed, before allocating for it. A receiver MUST ignore
unknown flags bits and
MUST read an unallocated DirectoryErrorCode as OTHER, so both grow
additively.
Request path. path is one of:
- the empty string or exactly
~: the serving user’s home directory; ~/rest:restunder that home directory;- an absolute path.
Anything else (a relative path) is refused with OTHER. The server resolves
the home directory from its own environment ($HOME in the reference
server); if it has none, the home forms are refused with OTHER. The server
normalizes the resolved path lexically: it drops . segments and applies
.. by removing the previous segment, and it does not follow symlinks while
doing so. The path it reports is therefore the one the user navigated, not a
canonical spelling. .. at the root stays at the root.
Reply. A reply without error is a listing. path is the resolved
absolute path. parent is its lexical parent, absent at the filesystem root.
entries holds the child directories of path, sorted by name in ascending
byte order. An entry is a directory or a symbolic link that resolves to a
directory; the symlink case sets flag bit 0. Regular files, dangling
symlinks, and symlinks to non-directories are omitted, as are names that are
not valid UTF-8 (the wire str cannot carry them). Hidden (dot-prefixed)
directories are included; hiding them is a consumer choice.
A reply with error is a refusal. path is the path the server attempted,
normalized when it got that far, and message is diagnostic text that a
consumer MUST NOT parse. The listing fields are absent and MUST be ignored if
present.
Bounds. A reply carries at most 1024 entries (MAX_DIRECTORY_ENTRIES).
A server MAY also stop reading a directory early; the reference server reads
at most 16,384 raw entries. When either bound cuts the listing short, the
server sets truncated and returns the first entries by name among those it
read. A server SHOULD run the filesystem walk off its event loop and under a
deadline, refusing with OTHER when the deadline passes. The reference
server uses the blocking pool, a 5 s deadline, and a spawned reply task, so a
slow mount never stalls the connection’s other traffic. The deadline bounds
the reply, not the worker: a walk stuck in a hung filesystem keeps its thread
until the call returns. The reference server therefore lets at most 8
listings hold workers at once, server-wide, and refuses further requests with
OTHER (“too many directory listings in flight”) until one finishes, so a
hung mount costs a bounded number of stuck threads. It also refuses a path
longer than 4096 bytes with OTHER before touching the filesystem.
Gating and locality. The §1.2 rule for non-L3 consumers applies: a
server MAY silently drop a LIST_DIRECTORY from a consumer whose HELLO
layers exclude L3. Without host the query is server-local, like
metadata (§1.3): a federation hub answers it from its own host. A request
that names a satellite is relayed by the hub (§4.1). A consumer attached
straight to a satellite’s own server (phux --remote) lists that host with
no host at all.
Security. The listing exposes nothing a connected client could not already learn by spawning a shell as the same user; see operations.md, “Security model and trust boundaries”.
4.1 Listing a satellite through a hub
LIST_DIRECTORY.host (field 3, UTF-8) names a satellite in the serving
hub’s registry: the same token that tags ResourceId::Satellite { host, .. }
(ADR-0007). A
server advertises that it understands the field with
ServerFeature::LIST_DIRECTORY_HOST = 0x00080000
(proto.md §6.2). An older server skips the unknown field by
length and lists its own host, so a client MUST see the bit before treating
a reply as the named host’s listing. An absent host is the serving host,
and the frame is then byte-identical to one without the field.
A hub that receives a host naming one of its satellites relays the request
over that satellite’s link as a LIST_DIRECTORY without host, under a
link-side request_id of its own. It answers the consumer with the
satellite’s DIRECTORY_LISTING under the consumer’s request_id. The hub
does not rewrite the listing: its paths are the satellite’s, and ~ and the
empty path mean the satellite user’s home. The hub negotiates L3 on its link
so that the satellite answers (§1.2). A hub never forwards host, so
hub-and-spoke does not chain.
Every routing failure is a refusal, never an ERROR and never silence: a
DIRECTORY_LISTING with error = OTHER, path set to the requested path,
and a message naming the host. The failures are:
- a
hostthat is not in the hub’s registry; - any
hostsent to a server that is not a hub; - a satellite whose link is down, still connecting, or saturated;
- a satellite whose
HELLO_OKdid not advertiseLIST_DIRECTORY; - a correlated
ERRORfrom the satellite; - a satellite that does not answer within the hub’s deadline.
OTHER is reused rather than extended. The consumer recovers the same way
from each of these (list somewhere else), and the message says which one it
was. A new code would still read as OTHER on every receiver that predates
it.
Bounds. The reference hub puts the local handler’s bounds on a relayed
request. It refuses a path longer than 4096 bytes before the link, lets at
most 8 relayed listings wait at once server-wide and at most 2 per
satellite, so a satellite that never answers cannot starve the others. It
refuses a listing the satellite has not answered within 10 s: the satellite’s own 5 s walk
deadline plus the link round trip. The reply is produced on a spawned task,
like the local one, so a slow satellite never stalls the consumer’s other
traffic. If the consumer disconnects first, the hub abandons the request and
releases its permits.
Security. The listing reads the satellite as the satellite’s server user. That is the identity the hub already acts as when it relays a spawn there, so the listing exposes nothing a relayed shell could not.
The reference TUI’s go-to-directory picker is the first consumer
(tui.md).