0076 — Prompting an agent is acknowledged; waiting on one is event-driven
phux agent prompt submits through the acknowledged APPLY_INPUT batch rather than fire-and-forget ROUTE_INPUT: text and Enter ride one operation, so a...
Full source summary
phux agent prompt submits through the acknowledged APPLY_INPUT batch rather than fire-and-forget ROUTE_INPUT: text and Enter ride one operation, so a partial write can only lose the submission, never the meaning. phux agent wait is satisfied by an observed transition, never by a level read of idle — the value phux publishes when nothing matched.
Status: Proposed Date: 2026-08-08
Context
ADR-0046 gave phux.agent/v1
(ADR-0040,
L3.md §3.7) a server-side writer, so state is live.
Nothing reads it as a control-flow input, and nothing writes to an agent with
a receipt: send-keys and paste use fire-and-forget ROUTE_INPUT, wait
polls a screen, watch subscribes to events and never metadata, and
APPLY_INPUT (ADR-0053,
L1.md §6.2.1) shipped acknowledged with no CLI caller.
Fire-and-forget stops being acceptable when driving an agent: a prompt is not
idempotent at the receiver, a doubled one can run a destructive tool twice, a
dropped one burns a timeout the orchestrator blames on the agent, and the only
recovery — resend — produces the duplicate. Reading the result back is thinner
than it looks. The five shipped manifests declare five working rules and five
blocked rules and nothing else; idle is the fail-safe fallthrough (“no
state-bearing rule matched => Idle”), and claude.toml records why its authors
declined a positive idle rule.
Decision
-
phux agent prompt TARGET TEXTsubmits viaAPPLY_INPUT. It requiresACKNOWLEDGED_INPUTinHELLO_OK.server_caps; an older server is refused (exit 2), never downgraded.APPLY_INPUTis local-only, so a satellite target (ADR-0066) is refused (exit 2).agent waitrefuses one too: L3 does not federate, so it would report a healthy remote agent as absent. -
OKis a kernel-queue receipt, and each typed error has one reading. It meanswrite_allandflushcompleted on the PTY master: every byte accepted into the tty’s input queue, strictly more thanROUTE_INPUTstates and strictly less than consumption (L1.md§6.2.1).INPUT_DELIVERY_UNKNOWNis terminal — a same-id retry replays the cached unknown, a new-id retry is the duplicate this design prevents — so the CLI exits 1 with the operation id, not 3, whose published meaning (docs/consumers/agents.md§5.2) is retry is correct. Pre-handoff refusals wrote nothing and may be retried unchanged under the same id:RESOURCE_EXHAUSTED(backoff, then exit 1),INPUT_LEASE_HELD(exit 2).CANONICAL_LIMIT_EXCEEDEDalso wrote nothing but cannot succeed unchanged (exit 2).UNSAFE_PASTEcannot arise: point 3 sends TRUSTED, which skips safety classification, deliberately bypassing the pane’s untrusted-paste policy on caller-supplied text. -
Text and Enter are ONE batch, and the text is single-line.
[Paste(trusted, text), Key(Enter)], the shapesend-keysalready builds, encoded against one mode snapshot and written as one PTY job with no submit delay. Enter last means a partial delivery drops the submission and leaves unsubmitted text on screen, the recoverable failure. Text with a raw newline is refused (exit 2):paste::encodeturns newlines into carriage returns when the pane has not set DEC 2004, a mode the CLI cannot observe, so a multi-line prompt can otherwise become N submissions. -
Ownership is re-verified from the record, not a fresh syscall. The CLI reads
phux.agent/v1and refuses when it is absent or names a different(kind, name)than the caller asserted (exit 2) — the check shippedphux agent send-keys --expect-agent/--expect-kindalready performs, whichpromptinherits rather than reinvents. It is an identity comparison and nothing more: the level gate on the withdrawn record shape belongs to ADR-0075 point 5, which owns it for every input-delivering verb. The comparison bites only for a target spelled some other way —@N,host/@N,#tag, a session path — since for%namethe name came out of the very record it re-reads. For a detector-owned record the staleness bound is one re-identification interval (~5 s, ADR-0046 point 10) plus one detect tick, so a prompt can land in a shell that replaced the agent inside that window; the CLI holds its subscription across the submit and reports an identity change, a withdrawal tounknown, or a tombstone arriving before the result as delivery to an unknown occupant (exit 1). The bound holds only there. A record whosestatewas explicitly declared (ADR-0046 point 8) stands the detector down — phux’s own Claude shim stopped declaring one for exactly that reason, but any other hook writer may — so there is no staleness bound, no tombstone, and no way for a consumer to tell the two classes apart.--jsonreports the record the check passed on rather than claiming a freshnesspromptlacks. The server gains no agent-aware precondition onAPPLY_INPUT: detection fails safe towardidle, input must fail safe toward delivery. -
phux agent wait TARGET --until STATE... --timeout MSis satisfied by a transition, not by a level.SUBSCRIBE_METADATAfirst, then oneGET_METADATArecording the pre-wait value, thenMETADATA_CHANGED; the wait completes on an observed transition into a member of--until. It never completes on a level read ofidle, which asserts only that no state-bearing rule matched and is equally true of a finished agent, a repainting TUI, a crashed one, and a pane runningless. A completion gate firing on that returns success on a corpse — instantly, and on every pane with no manifest at all. There is no level fast path, not even on a positively asserted level. An earlier draft carved one out forblockedanddone, on the argument that neither is reachable by fallthrough;L3.md§3.7 makes no such carve-out — a completion gate “MUST require an observed transition” — and phux ships nodonewriter at all, so the carve-out bought one state,blocked, at the cost of the only structural enforcement the rule has. The shippedEdgeTrackertherefore seeds the baseline and never evaluates it, and a pane already resting in a target state times out at 124 with a diagnostic saying so.--until donestill means “wait for an instrumented agent to declare completion”, and on today’s manifests it is inert. BecauseMETADATA_CHANGEDistry_sendand dropped on a full mailbox, and publication is edge-filtered (ADR-0046 point 7), the CLI re-readsGET_METADATAon the existingwaitcadence under the same deadline and treats a value differing from the last it held as the edge it missed: level-triggered recovery of an edge, not a level gate.--untilrepeats and ORs, defaulting toidle,done,blocked; an unknown spelling is a usage error (exit 2), andunknownis not spellable, being departure rather than a state to await. A tombstone or withdrawal tounknownends the wait as a departure (exit 1); an absent record is refused likeprompt(exit 2); timeout is 124. -
phux agent prompt --waitis one process on one connection, and that is why no sequence counter is needed. The CLI subscribes, records the pre-submit value, then submits; the server writes before replying and pushes frames on that connection in order, so everyMETADATA_CHANGEDafter the result was published post-write. The screen behind it may be a tick plus the idle hold older, a skew a counter shares. Only a post-result transition satisfiesprompt --wait; the pre-submit level never does. The reasoning fails for a caller that submits on one connection and waits on another —prompt … && wait …, two MCP calls, a federated path. Subscriptions are connection-scoped, so that caller has no shared ordering point and must useprompt --wait. -
--jsonstates what the receipt attests, in oneschema_version: 1document:delivery(acked/unknown/refused),operation_id, theagentrecord the ownership check passed on,pre_submit_state,transition_observed,matched_by(transition/level),waited_ms, and whether the wait degraded to polling. Errors use ADR-0065’s error object. The connection must declareLayer::L3or the subscribe is dropped silently.
No frame, tag, capability bit, or phux.agent/v1 field is allocated,
PROTOCOL_VERSION does not move, and ADR-0071’s frozen surfaces are untouched;
docs/consumers/agents.md §2 and §5.2 are owed updates.
Why
The failure modes worth engineering against are the silent double and the silent drop, and one shipped mechanism answers both: an operation id with a cached result. It costs nothing new on the wire and turns the orchestrator’s worst ambiguity into a typed, reportable outcome.
idle is read two ways here on purpose. As a level it asserts only the
absence of contrary evidence — the right predicate for “do not disturb this
pane”, the wrong one for “this pane finished”. As an edge, working -> idle
asserts that whatever claimed working stopped claiming it: positive evidence
about a transition even where the level is not positive evidence about a
condition. Safety gates take the level, completion gates take the edge, which is
why point 5 refuses to be satisfied by a read. Claude’s captured OSC 9;4 remove
signal now supplies one positive-idle source, but that does not establish when
the level was reached relative to a wait’s baseline. A separate open-enum
quiescent value remains unnecessary.
Tradeoffs
An acknowledged submit is slower than fire-and-forget input and holds its
Terminal until the write resolves, so two prompts to the same pane still
collide into RESOURCE_EXHAUSTED. That is the exclusion the idempotency
guarantee rests on and it stays. Drafting this ADR exposed a lane that was
worse: admission was one flag for the whole server and the completion wait ran
on the thread every attached keystroke also flows through, so unrelated panes
collided and one pane that stopped reading stdin stalled input everywhere.
phux-w7z2.58 scoped admission to the Terminal and moved the wait off the lane;
this verb is safe to fan out only because that landed first. Refusing satellite
targets makes both verbs less uniform than send-keys until federation carries
the guarantee.
A slave that flushes its input queue — TCSAFLUSH on a raw-mode toggle, which
every TUI does when it shells out and returns — discards an ACKed batch
silently. The honest recovery is the timeout plus phux agent explain, not a
shorter inference window. The ownership bound is likewise real for a detected
record and absent for a declared one, reported after the fact rather than
prevented, because prevention means putting a derived agent judgment in the
input path.
Requiring an edge costs the sub-tick turn: a prompt answered inside one detect
tick derives idle -> idle, publishes nothing, and times out at 124 on work
that succeeded. Dropping the fast path widens that to any pane already resting
in a target state. That is the price of never returning 0 on a corpse, and
transition_observed: false says which happened. The default --until set
keeps done, a member no shipped phux writer emits, so on an uninstrumented
pane the set reduces to idle and blocked; whether to drop it is open
(phux-w7z2.28) and is a CLI change, not a doc one. A concurrent ADR-0078
transcript harvest freezes detector publication on the same Terminal, which
this wait sees as a blind window on both its push and its poll path — bounded,
self-healing under level-triggered recovery, and worth a timeout wide enough
to outlast it.
Alternatives
Keep ROUTE_INPUT for prompts. Rejected: the caller cannot distinguish a
lost prompt from a working agent, and its only recovery duplicates it.
A five-second post-submit activity gate and a stalled error. Rejected: inferring delivery from repaint behavior is the output-inference oracle ADR-0053 declined, and it is unsound both ways — a slow agent fails it having received the prompt, a spinner passes it having dropped one.
A state_change_seq field on phux.agent/v1. Rejected: additive surface on
a record ADR-0071 wants frozen, buying
causality subscription ordering already provides and only moving the
split-connection race rather than closing it.
A server-side “seen” bit. Rejected: unseen-ness is per-viewer, so deriving it server-side would let one client’s focus change what another renders — the authority ADR-0049 keeps client-local.
Extend phux wait with an agent-state condition. Rejected: wait is a
screen-condition poll, this is a subscription with different sources and exit
semantics; the cost is one verb under an existing noun
(ADR-0065). The shared --until spelling carries a
different value domain, which the noun disambiguates.