0046 — The server derives agent state; detection is level-triggered
0046 — The server derives agent state; detection is level-triggered
TL;DR. ADR-0040 defined phux.agent/v1 but left it with no writer, so
state stayed unknown forever. The server now derives it: a per-terminal
detector re-reads the OSC title and the live screen on a timer, matches
region-scoped declarative rules, and writes the record. An explicit
SET_METADATA supplying state outranks the detector. Unmatched means idle,
never blocked.
Status: Accepted Date: 2026-07-12
Context
ADR-0040 put agent identity and lifecycle
in one normative L3 record, phux.agent/v1, and named the verbs that carry it.
It did not say who writes it. In practice nobody does: the only writer that
shipped is a human running phux agent set, so state reads unknown on
every pane, and the reference TUI’s sidebar falls back to an OSC-title
substring guess that can only ever produce idle. The states the record
defines — working, blocked, done — have never been displayed.
ADR-0036 placed a passive screen detector
last in its source ladder, behind the phux-ask sentinel and an opt-in hook.
That ordering is right for the Asked event, which needs an id and a
question string only the agent knows. It is the wrong ordering for a state
value, which every pane needs continuously and which no agent CLI reports.
Decision
- The server is a producer of
phux.agent/v1. One detector per PTY-backed Terminal, driven by its own interval on the terminal actor’sselect!(adaptive: ~500 ms unidentified, ~300 ms identified, ~100 ms while confirming aworking→idletransition). PTY bytes never wake it, so a chatty agent costs no extra detector work; the grid scan is skipped whenever the actor’s dirty flag is clear and something has already been derived — a clean grid cannot yield a different answer than the last one, whatever that answer was. The dirty flag is consumed only by a tick that actually scans, so a tick that skips can never launder “no evidence” into a derived state. - Detection is level-triggered. Each tick re-derives the state from scratch — identify the PTY’s foreground process, read the title, read the live viewport, match rules, publish if the answer changed. Nothing is remembered but the last published tuple.
- The title outranks the screen. libghostty already parses OSC 0/2 and the actor already mirrors it; the detector reads that mirror, and a title-derived rule beats any screen-derived rule.
- Rules are region-scoped data, not code. A rule is
{id, state, priority, region, predicate-tree, flags}, shipped as a TOML manifest per agent kind and compiled once at load. Regions are structural sub-slices of the live screen — the title, the bottom non-empty lines, the text below the last horizontal rule, the body of the live prompt box — never the scrollback. An invalid manifest is logged atwarnand dropped whole. - Fail safe. An identified agent whose screen matches no state-bearing
rule is
idle. Neverblocked. A rule may declareskip-state-update, meaning the screen is a transcript viewer, pager, or picker and carries no information about the agent: freeze the last state rather than guess. - Asymmetric hysteresis. Transitions that demand a human are instant;
transitions that release one are debounced.
blockedandworkingpublish on the first tick that sees them.working→idle— the ambiguous direction, where a spinner may merely have been cleared mid-redraw — holds for three confirmations at ~100 ms, capped at ~700 ms, bypassed when a rule shows positive idle evidence. A ~3 s startup grace suppresses publication while an agent paints its splash. - Publication is edge-filtered. The record is written only when the derived
(kind, name, state)tuple changes. An agent that isworkingand spewing output for ten minutes produces zero metadata writes and zeroMETADATA_CHANGEDframes. - Authority. An explicit
SET_METADATAonphux.agent/v1that supplies astateoutranks the detector; the detector makes no further writes to that Terminal until the record isDELETEd. An explicit write that supplies only identity (name/kind/session) is preserved field-for-field and the detector fillsstatearound it. The detector deletes only records it itself wrote. - Built-ins are capture-backed. The binary ships manifests for Claude
Code, Codex, OpenCode, Pi, and OMP. Every screen predicate is pinned to an
unedited
phux snapshot --jsonviewport captured from the named CLI; idle, working, blocked, and historical-dialog false-positive behavior are tested before a manifest ships. - Staleness is answered by re-identification, not a TTL. Identity is re-derived every ~5 s from the PTY’s foreground process group; when the agent is gone and the detector owns the record, the record is deleted. A dead process does not keep a live badge.
No wire change: no frame, tag, AgentEvent variant, or PROTOCOL_VERSION bump.
The detector rides the shipped SET_METADATA / METADATA_CHANGED path.
AgentEvent::Asked (ADR-0035/0036) keeps its own source ladder untouched — the
detector writes metadata only, having no honest question text to report.
Why
Level-triggered, and this is the reason the rest follows. The cheap design
is an edge-triggered reporter: a hook that fires on start and stop.
Edge-triggered reporters are lossy. One missed stop — the agent was killed,
the hook crashed, the wrapper was bypassed — and the pane is wedged in a lie
with no path back to truth, because nothing will re-assert the correct value. A
periodic re-derivation from the screen re-establishes ground truth on the next
tick and is self-healing by construction. The trade is explicit: we accept a
scan every few hundred milliseconds per agent pane and the fuzziness of matching
text a third party is free to restyle, in exchange for a state value that cannot
get permanently stuck. Hooks stay welcome as an additive, higher-confidence
source — decision point 8 is the door they walk through — but they may not be
the only source.
Regions, not the whole screen. The string “do you want to proceed?” inside a diff in the scrollback means nothing. The same string inside the live prompt box means the agent is blocked. Scoping a predicate to a structurally derived sub-slice turns a fuzzy text problem into a mostly structural one, and it is what makes a hand-written manifest defensible rather than a pile of substrings.
Fail safe toward idle. The two error directions are not symmetric. A missed
notification costs a glance at a pane the user was going to visit anyway. A
false blocked leaves a red row that never clears, and a sidebar that cries
wolf is worse than no sidebar: the user stops reading it.
Rules as TOML. Agent TUIs churn on their own cadence. A manifest a user can
edit, and one we can replace in a patch release, decouples that churn from ours;
a match arm in the server does not.
Tradeoffs
- Screen matching is brittle. An agent restyles its prompt box and the
manifest goes stale. The decay is bounded by the fail-safe — toward
idle, not toward a permanent alarm — andPHUX_AGENT_RULES_DIRlets a user fix a manifest without a release.PHUX_AGENT_DETECT=0disables detection entirely. - A manifest rule is only as good as the screen it was written against, and
it fails SILENTLY. This bit us during implementation: the first draft was
written against an imagined Claude TUI — a box-drawn dialog, a
? for shortcutsidle hint — and every screen rule in it matched nothing in the shipped CLI. Nothing failed loudly.idleis the fail-safe, so a pane sitting on a live permission prompt simply reportedidleforever, and the unit tests went green because they fed the matcher the same invented screens the rules were written from. A synthetic screen tests the matcher against itself. The rule is therefore: every screen rule must be justified by a captured viewport, committed as a golden fixture (src/agent_detect/fixtures/), and a rule whose provenance cannot be restated must be deleted rather than guessed. Three were. - An idle pane is no longer strictly free. It is close: a timer wake, plus a foreground-pgid read every ~5 s, with the grid scan skipped while clean.
- Process introspection is platform-specific. Foreground pgid is portable;
argv is
/procon Linux and asysctlon macOS. Elsewhere the detector never identifies an agent and stays quiet. - The server now interprets bytes it previously only stored. L3’s opacity
rule still governs the read/write path, but not this one conventional key
(
docs/spec/L3.md§3.7 says so). Confining the exception to a single normative, ADR-owned key keeps ADR-0017’s dumb-server stance intact everywhere else. - The client had to change with it. Every agent-state change previously
routed to a full-frame repaint, harmless only because the state never changed;
a live detector on that path is a full-screen clear per tick. This work
therefore also implements the
RepaintLevelaccumulator ADR-0029 accepted and never built, plus an in-place chrome paint. This ADR supersedes nothing.
Alternatives
Agent-side hooks as the only source. Rejected: lossy and unrecoverable per the argument above, and it makes the sidebar blank until every agent vendor opts in. Retained as an additive source that outranks the detector.
A phux-owned wrapper every agent launches through. Reliable where it is
used, absent where it is not — and a user who types claude at a prompt has
bypassed it. The detector must work on the pane the user actually spawned.
Client-side detection. Rejected: every consumer would re-derive the same answer with a different heuristic — the drift ADR-0040 set out to end — and a detached pane would report nothing.
A new AgentEvent::StateChanged. Rejected for the reason ADR-0040 already
gave: an event leaves a late subscriber nothing to read back, and an open
vocabulary on a positional wire body is a versioning trap.