phux docs
protocol

L3 — Metadata storage

The OPTIONAL typed key-value service the server hosts but does not interpret.

wire 0.9.0stable document
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 }GroupId is 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:

IDDirectionNameReferenceStatus
0x50C → S (cmd)GET_METADATA§2shipped
0x51C → S (cmd)SET_METADATA§2shipped
0x52C → S (cmd)DELETE_METADATA§2shipped
0x53C → S (cmd)LIST_METADATA§2shipped
0x54C → SSUBSCRIBE_METADATA§2shipped
0xD0S → CMETADATA_CHANGED§1shipped
0xD1S → CMETADATA_VALUE§1shipped
0xD2S → CMETADATA_KEYS§1shipped
0x55C → SLIST_DIRECTORY§4shipped
0xD3S → CDIRECTORY_LISTING§4shipped

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 a SET, value: None (a tombstone) on a DELETE.
  • METADATA_VALUE { request_id, value } — correlates to a prior GET_METADATA.request_id, with value: None when the key is absent.
  • METADATA_KEYS { request_id, keys } — correlates to a prior LIST_METADATA.request_id; keys are lexicographically sorted and values are NOT included (clients fetch them with GET_METADATA, since a LIST result 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. A SET on this key is a rename; there is no RENAME_SESSION wire 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 a METADATA_CHANGED carrying the applied current\0new value to subscribers of the written (scope, key); a refused or no-op rename broadcasts nothing, and the value is not retained in the store (a GET on 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}. command preserves argv boundaries, cwd selects the seed pane’s working directory, and env is added to the seed process environment. A present agent_session contains 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 old CREATE_SESSION verb. keep_empty: true marks the new session keep-empty (ADR-0105): it is not removed when its last window closes. empty: true creates the session with no seed Terminal and zero windows, and implies keep_empty; a request carrying empty: true together with command or agent_session is malformed and ignored. Both fields are gated on ServerFeature::KEEP_EMPTY_SESSIONS = 0x00020000 (proto.md §6.2). A client MUST see the bit before sending empty: true: an older server ignores unknown JSON fields and would seed a shell under the name. Under phux-workload/v1, this server-interpreted SET_METADATA requires both CREATE and BIND on 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 an empty: true create, terminal_id is null and the object adds empty: true. Because SET_METADATA has no reply body, the creating connection reads its exact nonce-specific key and verifies both name and request_token; other connections receive an absent value even if they know the nonce. This prefix is server-owned: under phux-workload/v1, ordinary SET_METADATA, DELETE_METADATA, and SUBSCRIBE_METADATA targeting 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 from LIST_METADATA. The un-suffixed phux.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\0true or name\0false (NUL-separated UTF-8, the shape of phux.session.name/v1). The server intercepts the write and applies it to the named session; the value is not stored, so a GET returns absent. A write that changes the mark is broadcast as METADATA_CHANGED with 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 with DETACHED { 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. A KILL_RESOURCES naming 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 the ATTACHED and GET_STATE snapshots through the trailing session facets (SessionSnapshot below). A server advertising the bit also accepts ATTACH to a session with no windows: its snapshot carries the 0 sentinels in focused_window and focused_resource, which no allocator mints. Gated on ServerFeature::KEEP_EMPTY_SESSIONS. When a keep-empty session loses its last window the server deletes that session’s phux.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 group KILL_RESOURCES that releases a mark broadcasts name\0false on this key in the same transaction, and once the cascade removes the released session its session-attached clients receive DETACHED { SESSION_KILLED }. Under phux-workload/v1 setting the mark requires CREATE and BIND on Global and clearing it requires SIGNAL (workload-auth.md §7).

    The snapshot’s session facets are its third trailing u32-counted list, after the resource facets and the hosts inventory (L1.md §9.1 fixes the order), one row per keep-empty session:

    session_row = id: SessionId (u32) || flags: u8   // bit 0 = keep_empty

    The list is written only when some session is keep-empty, and then the resource-facet list and the hosts list 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 after hosts, 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 a GroupId; a list<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 no ClientId scope). Records which terminal the local user is aiming input at; not synchronized across clients. The L1 INPUT_FOCUS message (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 is METADATA_CHANGED on 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.

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 a ResourceId. 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 #tag selector (ADR-0027 decision point 5; tui.md) resolves to the set of ResourceIds whose phux.tags/v1 value contains tag, 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 source ResourceId. Value: a UTF-8 JSON array of link records { "target": u32, "kind": str }, where target is the linked Terminal’s local wire id and kind is an open enum. v1 defines "group" (a soft grouping edge); a consumer that reads an unknown kind MUST 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 no LinkId.

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 the ResourceId the 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)
    }

    state and attention are OPEN string enums: a consumer reading an unrecognized value MUST treat it as unknown (for state) or normal (for attention) rather than fail the parse, so the vocabulary grows additively. An absent state means unknown; an absent attention is derived from state (consumers conventionally map blocked to high). An absent key or a value that is not a JSON object with a non-empty name means “no declared agent”.

    Writes are whole-record (last writer wins); there are no field-merge semantics. DELETE_METADATA clears 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-ask sentinel of ADR-0035, or screen scraping); heuristics remain the fallback when the key is absent. Backs phux agent set/clear, the phux agent list/show/explain provenance-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 state was supplied by an explicit SET_METADATA. An explicit declaration of state outranks any derivation for as long as the pane is occupied by the agent it describes; the derivation resumes after a DELETE_METADATA clears the record, or after the server withdraws the declaration under the rule below.
    • MAY withdraw an explicit declaration of state — by setting state to "unknown", never by substituting a derived value and never by DELETE_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 preserve name, kind, and session; it SHOULD clear attention, 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, and session fields of an identity-only declaration (one that supplied no state) when it fills state in.
    • MUST only DELETE_METADATA a 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 state produces no repeated METADATA_CHANGED broadcasts (a reference server already deduplicates an equal-bytes SET, §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 reads state in 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 state say right now?” — asserts only that no contrary state is being asserted about that pane at this moment. idle carries 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 an idle level 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 absent state and an unrecognized state (both unknown); idle differs from those in provenance, not in the strength of what it asserts. A producer MAY publish idle on positive evidence, and nothing here forbids it — but the record carries no field distinguishing a positively-derived idle from 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 -> idle is positive evidence about a transition even though idle is 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.

    done needs 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 derives state from observation alone will never emit it. A consumer MUST NOT treat done as 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_detect returns idle whenever 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 eight working rules, five blocked rules, exactly one idle rule and zero done rules between them.

    That single idle rule is worth stating precisely, because it is the exception that shows where the general caution above comes from. It is claude.toml’s osc-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.toml still records in prose why a title- or screen-derived positive idle rule 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 done through ADR-0085’s REPORT_AGENT_STATE, which feeds the detector without declaring metadata state and therefore without disabling later screen correction. done remains unreachable by derivation alone: no manifest rule produces it, on this server or in principle. A different server that authors further positive idle rules or another lifecycle integration is fully conforming; the requirements above constrain what a consumer MUST tolerate, not what every server MUST do.

Status: shipped. The reference server serves AGENT_SESSION and ranks a live child’s stream above hook, process, and screen evidence when deriving the parent’s phux.agent/v1 state. 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 local ResourceId returned 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_id and integration_id contain 1–120 UTF-8 bytes; native_id contains 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 owning plugin_id, and MUST construct resume argv through that integration’s structured policy. The identity placeholder MUST occupy one complete argv element, native_env MUST use a dedicated PHUX_*_SESSION_ID name, 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 local ResourceId. Value: a UTF-8 JSON object:

    {
      "foreground":   str,
      "is_pane_shell": bool
    }

    Both fields are REQUIRED. foreground is the non-empty, control-free, login-dash-stripped basename of the foreground process group’s argv[0]. It MUST NOT contain a path separator. The record deliberately carries no pid, argv tail, cwd, environment, or command text.

    is_pane_shell is true only when the PTY’s foreground process-group id is the pane’s original child pid and foreground is 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 as true.

    The owning server is the sole writer. Clients MAY GET_METADATA and SUBSCRIBE_METADATA, but MUST NOT SET_METADATA or DELETE_METADATA this 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 true value as positive available-shell evidence and MUST refuse a current false value. 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/v1Global scope. Value: an opaque, writer-chosen nonce (the reference CLI writes a UTF-8 unix-nanos-pid string). Its only requirement is to DIFFER from the previously stored value: a reference server deduplicates an equal-bytes SET (no broadcast), so a repeated constant would ring the doorbell at most once.

    A consumer subscribed to this key treats a non-tombstone METADATA_CHANGED as “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). Under phux-workload/v1, ringing this doorbell requires SIGNAL on Global and DELETE_METADATA targeting 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 reload and 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/v1Global scope. 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
    }

    principal and credential_id are the bearer credential’s principal and its non-secret id (the id phux pair rotate takes). Both are null on a route with no credential. auth_route is 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-bridge announced as arriving over ssh HOST phux stdio-bridge (below); trusted exactly as uds
    bearer-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_uid is the kernel peer uid of a uds or ssh-stdio connection (on ssh-stdio, the bridge process’s uid) and null on every network route, where the transport has no such fact. ssh_client is the ssh client endpoint of an ssh-stdio connection and null on every other route. serving_user is the OS user the server runs as. Its name is null when the uid has no password-database entry. The server never switches users, so this is also the user every pane runs as. host is the serving host’s name, or the empty string when it cannot be read. server_version is the server’s release version. Readers MUST ignore unknown fields; an additive field keeps schema_version at 1.

    The owning server is the sole writer. GET_METADATA answers the asking connection’s own record, so two connections can read different values at once. SET_METADATA and DELETE_METADATA of this key are refused in every scope. Under phux-workload/v1 they are default-denied before the handler, and the pre-profile reference server ignores them. LIST_METADATA does not enumerate the key. SUBSCRIBE_METADATA never 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-stdio and the bridge announcement. phux stdio-bridge is byte-transparent except for the client’s HELLO. It rewrites that one frame: it removes any ssh_origin (HELLO field 9, proto.md §6.1) the remote client sent. When sshd set SSH_CONNECTION in its environment (SSH_CLIENT is 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 advertises ServerFeature::SSH_ORIGIN = 0x00100000 honors 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 reports auth_route: "ssh-stdio" and ssh_client. It checks the field after HELLO authorization, and the policy engine never reads it, so authentication and authorization are exactly those of any uds peer. 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, so ssh 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 a command= restriction in authorized_keys.

    That is safe because the field grants nothing. It can only relabel a uds connection as ssh-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 reporting uds. 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 as ssh-stdio. A current bridge run outside ssh, with no SSH_CONNECTION or SSH_CLIENT, strips any client-supplied field 9, so that connection reports uds.


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: rest under 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 host that is not in the hub’s registry;
  • any host sent to a server that is not a hub;
  • a satellite whose link is down, still connecting, or saturated;
  • a satellite whose HELLO_OK did not advertise LIST_DIRECTORY;
  • a correlated ERROR from 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).

View exact source