You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This is a child of #162 — the activity-lease slice of that boundary, measured and made readable. Driver-side classification stays where #162 assigns it; this asks only for the generic envelope and its exposure.
Context
We are a downstream consumer of st2 running one catalog with 627 seats. This is a suggestion from
that vantage point, not a request; the shape below is offered only so there is something concrete to
react to.
#62 draws a boundary this proposal has to satisfy, and states it directly:
A structured durable-state inventory is a separate consumer contract and should be proposed
separately only when a concrete consumer needs it.
The concrete consumer is the same one named in #261: our TUI renders a seat's working state. It
can render what a seat declares. It cannot render what a seat is observed to be doing, because
nothing outside the Codex wrapper can read that. This proposal is the separate one #62 invites, with
the consumer named.
Relationship to #261, which is a sibling and not a competitor.#261 asks for the declared
working-state document to have a Resource identity. This asks for an observed harness signal to
have an envelope and a reader. They are complementary and deliberately separate: one is what the
agent says about itself, the other is what the harness is seen doing, and §E below exists precisely
so an operator can see the two disagree. Neither subsumes the other, and neither should be blocked on
the other.
One measurement that motivates the separation, scoped narrowly. Across every catalog on this host
there are 1298status files — 1176 of them in the live default catalog — and zero contain
a version 1 record. All are legacy one-line values: 1217 available, 63 busy, 8 away, 7 dnd,
3 offline. Read this only as what it is: on this host, the presence path is entirely legacy
records, so either the merged version 1 code is not deployed here or these seats have not been
restarted onto it. It is not a claim about the fleet, and it is not a defect in #254. What it
does support is narrower and sufficient: declared presence is a cooperative signal on a five-minute
timer, and a reader cannot currently assume even its version. An observed signal is a different axis,
not a better spelling of this one.
Goal
#162 lists, under "Generic protocols st2 may consume", activity leases
(idle, active, child, unknown) and composer guards (empty, nonempty, unknown),
and it assigns "provider-specific idle/active/child/unknown classification" to drivers while
keeping the generic envelope and fail-closed consumption of driver evidence in st2 core. This issue
is the activity-lease half of that, specified and made readable outside the wrapper that owns it.
Driver-side ownership is yours and is not in question here. What this asks for is the generic
envelope, its fencing, and its exposure — which #162 already places in core.
Name. The signal is observed harness state, deliberately not "activity status". docs/vrs/requirements.md:120-122 (R08) reserves "declared activity status" for an
agent-authored signal, and src/agents.rs:98-99 already ships lastActivity in the roster. Three
meanings of "activity" in one payload would be the terminology defect #84 drew CHANGES_REQUESTED for.
The ontology entry and a ## Collision rules line separating it from presence and session state
ship with it.
Where it belongs in the VRS. It is the observed half of DQ3 Remaining catalog agent state
(docs/vrs/spec.md:957+, "Activity status, current plan, and current plan step remain undefined.
Prove their stale-state and supervisor-following behavior before adding their shape") and the evented
signal DQ2 asks for. DQ3's supervisor-following half is not proved by anything below — see ## Limits.
Implementation fact: the concept is already half-built and locked in one wrapper
src/codex_app_server.rs:160-189 defines CodexObservedState with a doc comment that already reads
as an observability contract. It is persisted as st2.codex-control-state.v1
(src/codex_app_server.rs:41) at $XDG_STATE_HOME/st2/codex/<runtime_key(catalog_root, identity)>/control-state.json (:1082, :2108). runtime_key is a hash; the directory is host-local, outside the catalog, and not
replicated. So the state exists, is typed, and is durable — and is invisible to R08 catalog
observability, to st2 agents, to Doctor, and to every remote supervisor. Claude has no counterpart
at all: src/claude_mcp.rs and src/claude_session.rs contain no observed-state type.
Preferred delivery path
Five separable pieces, in dependency order. Each is a - [ ] block that can be lifted into its own
issue unchanged.
§ A — Publish an observed-harness-state record, reusing the DING-R12 vocabulary
The state words are #123's, not new ones.idle | active | child | unknown crossed with inputBuffer ∈ empty | nonempty | unknown is DING-R12 from PR #123, which #162 lists as a generic
protocol st2 consumes. This publishes that same tuple for non-delivery consumers, spelled the way #123 spells it. Nothing about the vocabulary is re-proposed.
The one deliberate difference is direction of use. #123ingests a lease to authorize a PTY write:
strict, validForMs capped at two seconds, fails closed. This publishes for a roster, Doctor and a
CLI: durable, with a staleness horizon instead of a lease, and additive-tolerant rather than deny_unknown_fields, because a reader pinned to an older crate may be older than the binary. #123
is strict correctly because it authorizes a write.
child is reserved, and no producer emits it yet. The Codex projection in §B yields only idle, active and unknown across all six hold reasons and the three non-held arms; Claude's hooks give
turn boundaries and permission requests and nothing that marks "a twenty-minute test run is blocking
the pane". The producer that would have supplied child was the PTY screen observer, which is cut
(see ## Limits). The word is kept because it is #123's, and because §C's argument leans on the
reasoning that kept it — not because anything writes it today. Say so in the spec rather than
shipping a four-value vocabulary that only ever emits three.
unknown is mandatory, derived, and never written, mirroring src/status.rs and R23
("unprovable evidence … is never reported as absence"). Enforce it structurally: one constructor for
an indeterminate observation, so no path can produce idle from missing evidence.
Write-side self-wake — a prerequisite, not a footnote.src/codex_app_server.rs:326 arms an
unfiltered recursive watcher on the whole agent directory
(watch_recursive_mutations(&config.agent_dir, wake_tx)). Any record placed under <agent_dir>
makes the writer wake its own delivery pump. The Mutation-only filesystem wakeups invariant does
not cover this: is_mutation (src/watch.rs:67-73) filters access events, which stops a read
self-wake; a write is a genuine mutation. The supervisor already does the safe thing — watch_catalog_declarations (src/run.rs:1995) filters to _templates/** and files named agent.kdl (src/watch.rs:52-65), with the doc comment "Runtime state … must never wake
reconciliation". Scope the Codex watcher, or exclude the record's directory, before any record
lands beside the inbox. Related: src/ding/mod.rs:909-915 falls back to inbox_dir.parent() — the
agent directory — when the inbox does not yet exist.
unknown is derived and never written, with a distinct reason per absence, and no absence
derives idle.
Staleness derives from an embedded timestamp. No read path calls metadata().modified() — a
file written on 2026-08-10 arrives in a fresh clone with the checkout mtime, measured seven days
later, and docs/vrs/spec.md:776-777 already states this.
The record is byte-distinct on every write. Rewriting identical bytes leaves zero staged
changes — git commit reports "nothing to commit" — so the transport never carries it. Measured.
A write into the record's directory does not wake the Codex delivery pump; an inbox write still
does. Regression test in the shape of src/ding/mod.rs::idle_ding_does_not_spin_on_its_own_inbox_reads.
§ B — The Codex producer reads the existing control-state.json, with the two wrong active rows fixed
The Codex arm needs no new observation path: CodexControlState is already written on every
transition. It needs a projection.
Held must not enter the contract. Its own doc comment defines Active as "the only state that
permits turn/steer", which makes Held the complement of steerable — a delivery predicate.
A signal whose consumers include the roster and Doctor cannot carry a delivery gate inside it. That is
structurally the error docs/vrs/.decisions/0001 corrected when it stopped one harness's rendering
from governing another harness's safety gate.
Two rows of the obvious decomposition are wrong and are corrected here. An earlier draft of this
work claimed all six CodexHoldReason values decompose "with no residue". An independent adversarial
review refuted that: a total function into a four-element set always exists, and the meaningful
property is faithfulness. Two rows report unknown for states Codex positively reported as
active:
CodexHoldReason
Naive projection
Correct projection
Why
Compaction
active
active
The agent is working
Review
active
active + blocked axis (§C)
A human is the thing that unblocks it
NotLoaded
unknown
unknown
Correct
SystemError
unknown
unknown
Correct; see #264 for the catch-all defect behind it
ActiveWithoutTurn
unknown
active
Minted inside the "active" arm at :782-786. Codex said active; st2 merely cannot name a turn to steer
ConflictingTurn
unknown
active
Two turns believed live at once (:818-821, :845-848) — maximally active
The residual information — which turn, or that no turn is attributable — belongs in the diagnostic
reason, not in the machine-readable state.
The projection reads the existing control-state.json; no new Codex observation path.
ActiveWithoutTurn and ConflictingTurn project to active, not unknown.
The reason string is diagnostic only and no consumer branches on it.
A test enumerating the projection is behavioral, not a restatement of its own match arms. The
earlier version of that test would have passed if every row mapped to unknown; that is not
acceptable as an oracle.
Held does not appear in the published vocabulary.
Depends on Issues 1 and 2: both fix states that this projection would otherwise faithfully report
as wrong.
§ C — A first-class blocked-on-human axis, with both producers wired
This is the part that is genuinely new capability rather than a nicer rendering of something that
already exists, and it is the only part with a closing window.
Why it must not be a per-driver arm. Four independent reasons. All four were raised by an
independent adversarial reader, not by the author of the proposal; an earlier draft of this work
called blocked-on-human a defensible open boundary, and that reader refuted it.
It is a category error against the tuple's own semantics. Blocked-on-human is neither working
nor idle: the model is stopped, and the thing that restarts it is a person. Folding it into active erases the distinction between "wait" and "act" at the only point where a reader could
have acted on it.
The Child precedent decides it.child was kept first-class on the argument that both hold
delivery, so collapsing them is invisible to the gate, "but they are different situations for an
operator and for Doctor" — different expected durations, different interventions. Every clause
holds here, and this adds one child does not: the intervention is yours, and no amount of
waiting substitutes for it.
Deferring is not free, and this is the sequencing argument. The state enum uses unknown as
its #[serde(other)] catch-all, so a reader pinned to v1 decodes a later blocked as unknown —
not as active. A deferred axis therefore degrades to indeterminate in every older reader, which
is the opposite of the conservative default the rest of the design chooses. The window in which
this is cheap is before any reader is pinned.
Shape. Either a fifth state, or a blockedOn: human | none field alongside the input-buffer axis.
The field composes better: an agent can be blocked while a child command runs.
Both producers exist today, and entry is proved on both harnesses.
Codex:activeFlags (waitingOnApproval, waitingOnUserInput) is level-triggered and on the wire;
the ten */requestApproval and */requestUserInput server→client requests are edge-triggered with a serverRequest/resolved exit edge. Both are discarded today — see #265, which is a prerequisite. Both were read from codex-cli 0.147.0, which SUPPORTED_CODEX_CLI_VERSIONS
(src/codex_app_server.rs:38) rejects, and neither is verified on 0.145.0 or 0.146.0. So the Codex
producer is gated on #267 or on a capture from a supported version, and this section does not
assume the field exists where st2 can currently reach it. A populatedactiveFlags has never been
observed on any version; see the inference in #265.
The retention defense that "the driver arm keeps the flags" is currently vacuous: CodexControlState
(src/codex_app_server.rs:189-201) has no field for them, so a mirror reading them with #[serde(default)] reads an empty vector forever.
Claude:PermissionRequest fires in exactly 2 of 9 captures — --permission-mode manual
(tool_name="Bash") and --permission-mode plan (tool_name="AskUserQuestion") — and in zero bypassPermissions and auto runs. Its meaning is therefore specifically "a human is about to be
asked", not "a tool ran". Measured timeline: the hook fires at 8.067 s and an independent screen
observer sees the prompt at 8.068 s; a Notification with notification_type="permission_prompt" follows at a fixed +6.01 s (6.014 s and 6.010 s across two
runs) and is corroboration, too slow to be primary. Stop marks idle at 40.626 s. The binary's master
hook enum carries 31 event names against the ten its own documentation lists; PermissionRequest, PermissionDenied, MessageDisplay, PostToolBatch, SessionEnd, SubagentStop and CwdChanged were all observed dispatching from a plain settings.local.json.
Open question, and it is not solved: how the blocked state is left.
An earlier draft specified "blocked is left only by a resolution of the same tool_use_id". The
event that enters blocked carries no tool_use_id — verified across both occurrences in the
corpus. PreToolUse and PostToolUse carry it; PermissionRequest carries tool_name, tool_input and permission_suggestions and no call identity. So the rule degrades to matching on
tool name, and Claude batches tool calls (PostToolBatch carries a plural tool_calls): two Bash
calls in one batch with the second needing permission means the first call's PostToolUse clears blocked while the human is still looking at the prompt for the second. No key in the payload
prevents this. Correlating through the immediately preceding PreToolUse is a temporal heuristic that
fails for exactly the parallel case it would need to handle.
The evidence base for the exit edge is also thin by construction: blocked is entered in 2 of 9
captures and exited in 1, with one tool, one prompt, and no batching. A rule validated on a single
exit path is not validated.
So: entry is proved on both harnesses; exit is an open question on the Claude side and needs a
capture with batched tool calls before it is specified. The Codex side has a real exit edge
(serverRequest/resolved) and does not share this problem.
One more producer-side hazard, on the Claude side.SubagentStop arrives 1.5-2.9 s after Stop in every completed-turn run and carries agent_id, agent_type and agent_transcript_path,
where top-level Stop carries none of them. Counted naively as activity, a session reads active
forever after every normal turn. Nesting on the presence of agent_id fixes it — but note agent_type is the empty string in all three runs and no subagent ran in any of them, so the fix
rests on an undocumented, unversioned emergent property: that a phantomSubagentStop populates agent_id. If a future Claude build omits it, the naive failure returns silently and nothing in the
corpus would catch it.
Blocked-on-human is a generic axis. A reader answers "is this agent blocked waiting on me"
without branching on a driver-specific detail type.
The axis exists before any reader is pinned to v1 of the state enum.
Codex entry from activeFlags; exit from serverRequest/resolved.
Claude entry from PermissionRequest, corroborated but not driven by Notification/permission_prompt.
Claude exit is specified only after a capture with batched tool calls. Until then the record
states the limit rather than a rule that cannot hold.
Any event carrying agent_id is nested and never moves top-level state, with the emergent-property
caveat written into the spec.
The spec states that blocked is vacuous under bypassPermissions, which is what examples/native/agent-claude.kdl:13 ships today. A fleet running entirely on bypassPermissions gains nothing here until that changes.
§ D — A wrapper-owned terminal write, plus a PTY-correct generation cross-check
This section extends #260's line of investigation to the Claude wrapper; it does not restate it. #260 establishes that the Codex wrapper installs no SIGTERM handler and leaks its app-server group,
and contrasts it with the Claude wrapper, which "catches SIGTERM, leaves its poll loop, and calls stop_provider_group before exit." That contrast is #260's, it is correct, and the Claude side is
the better-behaved one on the path #260 is about.
The finding below is about what happens after that path. #260 draws its own boundary explicitly:
SIGKILL cannot run in-process cleanup and is outside this guarantee.
That is the right scope for #260, which asks for in-process cleanup. It is also exactly the case a
liveness record cannot leave outside its guarantee, because a record that stops being written is
still being read. So the two are complementary: #260 fixes the graceful path in-process; this section
argues that the ungraceful path needs an out-of-process check, since by #260's own correct reasoning
no in-process write can cover it.
Claude death is silent. Two trials started claude -p on a long-running Bash call, waited until
the hook log showed a turn genuinely in flight, then signalled the process group:
Nothing fires — not on SIGKILL, and not on SIGTERM, which is the signal st2 itself sends. The SessionEnd reason set is closed and contains no death value: ["clear","resume","logout","prompt_input_exit","other","bypass_permissions_disabled"]. A hooks-only
record therefore sticks on active forever.
The wrapper-owned write works, and it is not sufficient. A prototype mirroring run_provider's
existing loop (src/claude_session.rs:93-107 — spawn, poll try_wait at 250 ms) wrote a terminal
state when the child was reaped: hooks-only said active, wrapper-owned said ended, detection
latency 0.25 s, one tick of a poll the wrapper already runs.
It covers exactly the case where only the child dies:
SIGTERM: covered. The wrapper installs a handler (src/claude_session.rs:29-40), so it survives stop_provider_group's kill(-getpgrp(), SIGTERM) (src/claude_session.rs:114-121) and can write.
SIGKILL escalation: not covered. After STOP_GRACE (5 s, src/claude_session.rs:19) the same
function sends kill(-getpgrp(), SIGKILL) (:130-132) into its own process group. SIGKILL
cannot be caught, so the wrapper dies with the provider and the following let _ = child.wait()
(:133) never runs. A Claude session with a modal open or a long tool in flight plausibly exceeds
5 s, so this is a routine path on st2's own teardown, not an edge. This is the complement of driver codex leaves its app-server process group alive on SIGTERM #260's Codex finding: there the wrapper exits before cleanup runs, here the wrapper is inside the
group it is killing — and it is the case driver codex leaves its app-server process group alive on SIGTERM #260 correctly places outside its own guarantee.
External forced kill: not covered. Invariant row 11 names SIGKILL explicitly
(tests/nomad_survival.rs::forced_kill_and_binary_replacement_adopt_pty_unchanged_without_duplicate),
as does pty kill on the pane. Both take the wrapper and the provider together.
So a cross-check is a shipping requirement, not an experiment bullet. In every uncovered case the
only fallback is staleness decay, and if the horizon is inherited from presence that is fifteen
minutes of a dead agent reading active.
And the cross-check must be specified against PTY, not exec. An earlier draft proposed reading the
record's runtimeIncarnation against ExecGeneration / observe_generation
(src/exec_backend.rs:34-48, :276-294) — the exec backend. That is the wrong backend: a
driver-declared agent lowers to TaskKind::Pty (crates/agent-spec/src/spec.rs:908, :917), so the
Claude and Codex wrappers that write these records are PTY tasks. The PTY liveness primitive is ding::session_alive — pidfile plus kill(pid, 0) at src/ding/mod.rs:692-703. The defense named
for the exact failure mode was written against the wrong backend; an independent reviewer caught that,
not the author.
Two limits of the cross-check, stated up front. It is host-local by construction, so a cross-host
reader cannot evaluate it and is left with the staleness horizon alone. And a heartbeat that blindly
re-stamps a belief keeps a wrong state fresh indefinitely — staleness catches a writer that stopped;
it cannot catch a writer that is confidently wrong. So the writer rule is: stop heartbeating on
evidence loss. An observer that loses contact with its harness lets the record age to unknown
rather than refreshing a state it is no longer watching.
One constant to choose rather than inherit. If the staleness horizon is aliased to status::STATUS_STALE, a future presence-tuning change silently retunes observed harness state with
nothing failing. Row 22 pins those constants with named tests; the alias is invisible to them. One
decay horizon for an operator is a good rationale — it should be a documented constant with its own
value, not a re-export.
The wrapper writes a terminal record where try_wait returns Some(exit)
(src/claude_session.rs:97-99) and on the stop_provider_group path, carrying the exit status.
A generation cross-check specified against the PTY liveness primitive
(src/ding/mod.rs:692-703), not ExecGeneration. A record whose generation is dead reads as unknown even while fresh.
The observer stops heartbeating on evidence loss rather than preserving a state it cannot see.
The staleness horizon is its own documented constant, not an alias of status::STATUS_*.
Named test: SIGKILL the provider mid-turn, the record reads ended rather than active. Per CLAUDE.md, the INVARIANTS.md row is added only once that test is green.
§ E — One join into agents --json: declared presence beside observed state
The value of the surface is the comparison. A declared busy beside an observed idle is the
wedged-agent signal, and it is not derivable from anything shipping today. Making a caller join two
commands invites them to skip it.
What the declared side looks like on this host today, as a snapshot and nothing more: of 1298 status files, 1217 read available, 63 busy, 8 away, 7 dnd, 3 offline. That distribution
is consistent with agents declaring busy correctly and with agents never declaring it at all, and
the presence file cannot distinguish those two readings. That is the gap the join closes — not by
correcting presence, which stays exactly as it is, but by putting a second, independently produced
axis next to it. This is a census, not a divergence measurement: it does not show declared
presence diverging from observed state, because there is no observed state to compare it against yet.
That measurement becomes available once §A ships, and it is the one worth taking.
The cost, stated rather than discovered in review. The roster wire shape is pinned three times by full-stringassert_eq!:
src/agents.rs::agents_json_has_stable_wire_shape — two full-string asserts, on to_json(&rows, false) and to_json(&rows, true). The literal is at src/agents.rs:226.
src/agents.rs::agents_json_preserves_opaque_declared_resource_descriptors — a third.
and by INVARIANTS.md row 21 (Stable roster JSON), whose guarantee ends "…and inbox counts.
Presence remains independent from desired lifecycle." Observed harness state is a third
independent axis, so that wording changes and needs a new proof.
(tests/status_agents.rs asserts by field index and is additive-safe either way.)
Approach
Pinned full-string edits
INVARIANTS.md
Field added to the roster JSON (what this asks for)
3 literal edits
Row 21 wording changes; new proof needed
--observed flag with a third serializer struct
0
Wording must name the new shape
A sibling st2 activity --json, precedent st2 tasks --json
0
No change
The sibling command is the cheaper option and it defers the wire commitment until real producers
exist. The join is the more useful one. This is a maintainer call and is flagged as one rather than
resolved here.
Declared presence and observed harness state appear side by side in one payload.
Whichever surface is chosen, the three pinned full-string assertions and row 21's wording are
updated deliberately, in the same change, with the new proof named.
lastActivity keeps its existing meaning and is not conflated with the new axis.
Explicitly out of scope, with the reason
Named here so the boundary is visible rather than discovered.
A durable JSONL transition history, and --watch. Deferred, not deleted. Burst coalescing — 4
transitions per turn arriving 0.1-0.4 ms apart, measured — is required for the record to be
truthful, and it defeats the dwell-time analysis the history was justified by: a blocked state
shorter than the coalescing window is erasable by construction. A streaming watch has the same
problem from the other side: re-projecting on wakeup reports the latest state, never each
intermediate, and two transitions between wakeups collapse with nothing to recover the dropped one
from. History earns its keep once §C exists and dwell time is the thing being recorded.
The host-local hot tier with a throttled catalog projection. Withdrawn, not deferred. It was
designed to keep the delivery gate from reading a state older than one minute. The layer boundary
says the delivery gate does not read this state at all, so the tier's stated justification is gone.
Any future version needs a fresh one.
The PTY screen observer. Cut. See ## Limits.
is_interruptible and the routing story. Cut. It serves a router that does not exist: st2 has
no router today, and every call site of the prototype's routing predicates is #[cfg(test)].
Any AGENT-SPEC.md change. That file lives in compoundingtech/evals with separate authority.
Limits
Stated before anyone has to find them.
The PTY screen observer is broken for one of the two shipped harnesses. A prototype implemented
it as classify_composer(screen, ""), mapping only EmptySafe to a proven idle. That reasoning is
Codex-specific: Codex has distinct empty and typed markers, but the Claude arm extracts composer
text and string-compares, so for an empty Claude composer the empty expected notice matches as an exact match and the observer returns unproven. Measured against the repo's own fixtures, a used
Claude pane reads unproven while a fresh one reads idle — so the observer proves idle for a Claude
pane exactly once, before anyone types into it. The root cause is a sentinel collision, not a
missing match arm: expected = "" collides with the Claude arm's representation of an empty
composer. That is the same shape of error 0001 corrected. The failure direction is safe — it
under-reports and never produces a false idle — but the observer is advertised as the only path
covering hand-authored and unmanaged PTY agents, and it delivers nothing for one of two harnesses.
It is cut for that reason. Found by an independent adversarial reader; the prototype's own test hid
it by fixturing Codex in the positive slot.
The wrapper-owned death write does not cover SIGKILL escalation or an external forced kill.
§D above states this in full; repeated here because it is the limit most likely to be assumed away.
The transport cost model is not identified. No measurement here establishes what a per-turn
replicated write costs on a real catalog under a real transport. Presence refreshes every five
minutes; turn boundaries are far more frequent. The catalog exposure in §E is a read of a
host-local record, and how — or whether — the record itself replicates is deliberately left open
rather than answered with a shape that cannot be defended.
DQ3's supervisor-following half is unproven.docs/vrs/spec.md:957+ sets two gates:
stale-state behavior and supervisor-following behavior. The stale-state half is addressed
thoroughly above. The supervisor-following half is not addressed anywhere in this work. Half
the repo's own acceptance criterion for this exact feature is unmet, and that is a reason the
decision record ships Status: draft.
All Codex wire evidence is codex-cli 0.147.0, which SUPPORTED_CODEX_CLI_VERSIONS
(src/codex_app_server.rs:38) rejects, so every Codex protocol fact here is unverified on the
versions st2 supports — including activeFlags, on which §C's Codex producer depends. st2 refuses the locally installed codex-cli, and the version pin is now behind by one release #267
covers the drift itself. All Claude evidence is from one bundle version, on Linux. CI is ubuntu-latest only, so nothing here says anything about Darwin.
The Codex control protocol is expected to be replaced. The note Add native delivery for Codex and Claude #254 added to docs/vrs/01-ding/02-codex/spec.md says so directly. This is an argument for the generic
envelope: the producer changes, the contract does not.
The VRS is behind the implementation in this whole region.docs/vrs/ contains no match for app-server, claude-mcp, or driver expand, and 01-ding/requirements.md still opens by calling
DING a sidecar. PR Specify native DING and Codex app-server delivery #242 is the only written contract for the delivery layer this observes, and it is
still open. Landing Specify native DING and Codex app-server delivery #242 first is more valuable than landing this, and help rebasing it is offered.
Measurement provenance
Following #261's practice of naming where the numbers came from.
1298 / 1176 status files, zero with a version 1 record — every catalog under ~/.local/state/st2 on this host, read 2026-08-17. Host-scoped. Not a fleet claim and not a
statement about Add native delivery for Codex and Claude #254.
Claude hook events — 9 captures against one Claude bundle, on Linux, driven under a PTY. About
five distinct behavioural shapes: three of the nine are the same scripted run under different kills,
and one contributes two events. The nine rows over-count the evidence and are described that way.
Not measured anywhere here: turn durations, transport cost for a replicated record, how many
seats have hit any of these defects, and anything at all on Darwin.
This is a child of #162 — the activity-lease slice of that boundary, measured and made readable. Driver-side classification stays where #162 assigns it; this asks only for the generic envelope and its exposure.
Context
We are a downstream consumer of st2 running one catalog with 627 seats. This is a suggestion from
that vantage point, not a request; the shape below is offered only so there is something concrete to
react to.
#62 draws a boundary this proposal has to satisfy, and states it directly:
The concrete consumer is the same one named in #261: our TUI renders a seat's working state. It
can render what a seat declares. It cannot render what a seat is observed to be doing, because
nothing outside the Codex wrapper can read that. This proposal is the separate one #62 invites, with
the consumer named.
Relationship to #261, which is a sibling and not a competitor. #261 asks for the declared
working-state document to have a Resource identity. This asks for an observed harness signal to
have an envelope and a reader. They are complementary and deliberately separate: one is what the
agent says about itself, the other is what the harness is seen doing, and §E below exists precisely
so an operator can see the two disagree. Neither subsumes the other, and neither should be blocked on
the other.
One measurement that motivates the separation, scoped narrowly. Across every catalog on this host
there are 1298
statusfiles — 1176 of them in the livedefaultcatalog — and zero containa version 1 record. All are legacy one-line values: 1217
available, 63busy, 8away, 7dnd,3
offline. Read this only as what it is: on this host, the presence path is entirely legacyrecords, so either the merged version 1 code is not deployed here or these seats have not been
restarted onto it. It is not a claim about the fleet, and it is not a defect in #254. What it
does support is narrower and sufficient: declared presence is a cooperative signal on a five-minute
timer, and a reader cannot currently assume even its version. An observed signal is a different axis,
not a better spelling of this one.
Goal
#162 lists, under "Generic protocols st2 may consume", activity leases
(
idle,active,child,unknown) and composer guards (empty,nonempty,unknown),and it assigns "provider-specific idle/active/child/unknown classification" to drivers while
keeping the generic envelope and fail-closed consumption of driver evidence in st2 core. This issue
is the activity-lease half of that, specified and made readable outside the wrapper that owns it.
Driver-side ownership is yours and is not in question here. What this asks for is the generic
envelope, its fencing, and its exposure — which #162 already places in core.
Name. The signal is observed harness state, deliberately not "activity status".
docs/vrs/requirements.md:120-122(R08) reserves "declared activity status" for anagent-authored signal, and
src/agents.rs:98-99already shipslastActivityin the roster. Threemeanings of "activity" in one payload would be the terminology defect #84 drew CHANGES_REQUESTED for.
The ontology entry and a
## Collision rulesline separating it frompresenceandsession stateship with it.
Where it belongs in the VRS. It is the observed half of
DQ3 Remaining catalog agent state(
docs/vrs/spec.md:957+, "Activity status, current plan, and current plan step remain undefined.Prove their stale-state and supervisor-following behavior before adding their shape") and the evented
signal
DQ2asks for.DQ3's supervisor-following half is not proved by anything below — see## Limits.Implementation fact: the concept is already half-built and locked in one wrapper
src/codex_app_server.rs:160-189definesCodexObservedStatewith a doc comment that already readsas an observability contract. It is persisted as
st2.codex-control-state.v1(
src/codex_app_server.rs:41) at$XDG_STATE_HOME/st2/codex/<runtime_key(catalog_root, identity)>/control-state.json(:1082,:2108).runtime_keyis a hash; the directory is host-local, outside the catalog, and notreplicated. So the state exists, is typed, and is durable — and is invisible to
R08catalogobservability, to
st2 agents, to Doctor, and to every remote supervisor. Claude has no counterpartat all:
src/claude_mcp.rsandsrc/claude_session.rscontain no observed-state type.Preferred delivery path
Five separable pieces, in dependency order. Each is a
- [ ]block that can be lifted into its ownissue unchanged.
§ A — Publish an observed-harness-state record, reusing the
DING-R12vocabularyThe state words are #123's, not new ones.
idle | active | child | unknowncrossed withinputBuffer ∈ empty | nonempty | unknownisDING-R12from PR #123, which #162 lists as a genericprotocol st2 consumes. This publishes that same tuple for non-delivery consumers, spelled the way
#123 spells it. Nothing about the vocabulary is re-proposed.
The one deliberate difference is direction of use. #123 ingests a lease to authorize a PTY write:
strict,
validForMscapped at two seconds, fails closed. This publishes for a roster, Doctor and aCLI: durable, with a staleness horizon instead of a lease, and additive-tolerant rather than
deny_unknown_fields, because a reader pinned to an older crate may be older than the binary. #123is strict correctly because it authorizes a write.
childis reserved, and no producer emits it yet. The Codex projection in §B yields onlyidle,activeandunknownacross all six hold reasons and the three non-held arms; Claude's hooks giveturn boundaries and permission requests and nothing that marks "a twenty-minute test run is blocking
the pane". The producer that would have supplied
childwas the PTY screen observer, which is cut(see
## Limits). The word is kept because it is #123's, and because §C's argument leans on thereasoning that kept it — not because anything writes it today. Say so in the spec rather than
shipping a four-value vocabulary that only ever emits three.
unknownis mandatory, derived, and never written, mirroringsrc/status.rsandR23("unprovable evidence … is never reported as absence"). Enforce it structurally: one constructor for
an indeterminate observation, so no path can produce
idlefrom missing evidence.Write-side self-wake — a prerequisite, not a footnote.
src/codex_app_server.rs:326arms anunfiltered recursive watcher on the whole agent directory
(
watch_recursive_mutations(&config.agent_dir, wake_tx)). Any record placed under<agent_dir>makes the writer wake its own delivery pump. The Mutation-only filesystem wakeups invariant does
not cover this:
is_mutation(src/watch.rs:67-73) filters access events, which stops a readself-wake; a write is a genuine mutation. The supervisor already does the safe thing —
watch_catalog_declarations(src/run.rs:1995) filters to_templates/**and files namedagent.kdl(src/watch.rs:52-65), with the doc comment "Runtime state … must never wakereconciliation". Scope the Codex watcher, or exclude the record's directory, before any record
lands beside the inbox. Related:
src/ding/mod.rs:909-915falls back toinbox_dir.parent()— theagent directory — when the inbox does not yet exist.
DING-R12state and input-buffer words verbatim, with a testpinning the reuse so a future drift from feat(ding): add harness-neutral guarded adapter seam #123's words fails the suite.
unknownis derived and never written, with a distinct reason per absence, and no absencederives
idle.metadata().modified()— afile written on 2026-08-10 arrives in a fresh clone with the checkout mtime, measured seven days
later, and
docs/vrs/spec.md:776-777already states this.changes —
git commitreports "nothing to commit" — so the transport never carries it. Measured.does. Regression test in the shape of
src/ding/mod.rs::idle_ding_does_not_spin_on_its_own_inbox_reads.§ B — The Codex producer reads the existing
control-state.json, with the two wrongactiverows fixedThe Codex arm needs no new observation path:
CodexControlStateis already written on everytransition. It needs a projection.
Heldmust not enter the contract. Its own doc comment definesActiveas "the only state thatpermits
turn/steer", which makesHeldthe complement of steerable — a delivery predicate.A signal whose consumers include the roster and Doctor cannot carry a delivery gate inside it. That is
structurally the error
docs/vrs/.decisions/0001corrected when it stopped one harness's renderingfrom governing another harness's safety gate.
Two rows of the obvious decomposition are wrong and are corrected here. An earlier draft of this
work claimed all six
CodexHoldReasonvalues decompose "with no residue". An independent adversarialreview refuted that: a total function into a four-element set always exists, and the meaningful
property is faithfulness. Two rows report
unknownfor states Codex positively reported asactive:
CodexHoldReasonCompactionactiveactiveReviewactiveactive+ blocked axis (§C)NotLoadedunknownunknownSystemErrorunknownunknownActiveWithoutTurnunknownactive"active"arm at:782-786. Codex said active; st2 merely cannot name a turn to steerConflictingTurnunknownactive:818-821,:845-848) — maximally activeThe residual information — which turn, or that no turn is attributable — belongs in the diagnostic
reason, not in the machine-readable state.
control-state.json; no new Codex observation path.ActiveWithoutTurnandConflictingTurnproject toactive, notunknown.earlier version of that test would have passed if every row mapped to
unknown; that is notacceptable as an oracle.
Helddoes not appear in the published vocabulary.Depends on Issues 1 and 2: both fix states that this projection would otherwise faithfully report
as wrong.
§ C — A first-class blocked-on-human axis, with both producers wired
This is the part that is genuinely new capability rather than a nicer rendering of something that
already exists, and it is the only part with a closing window.
Why it must not be a per-driver arm. Four independent reasons. All four were raised by an
independent adversarial reader, not by the author of the proposal; an earlier draft of this work
called blocked-on-human a defensible open boundary, and that reader refuted it.
nor idle: the model is stopped, and the thing that restarts it is a person. Folding it into
activeerases the distinction between "wait" and "act" at the only point where a reader couldhave acted on it.
Childprecedent decides it.childwas kept first-class on the argument that both holddelivery, so collapsing them is invisible to the gate, "but they are different situations for an
operator and for Doctor" — different expected durations, different interventions. Every clause
holds here, and this adds one
childdoes not: the intervention is yours, and no amount ofwaiting substitutes for it.
blocked waiting on me" must otherwise branch on a driver-specific detail type — the anti-pattern
Define Axe harness drivers and extract all harness-specific behavior from st2 core #162 exists to eliminate ("produced by a driver and consumed generically").
unknownasits
#[serde(other)]catch-all, so a reader pinned to v1 decodes a laterblockedasunknown—not as
active. A deferred axis therefore degrades to indeterminate in every older reader, whichis the opposite of the conservative default the rest of the design chooses. The window in which
this is cheap is before any reader is pinned.
Shape. Either a fifth state, or a
blockedOn: human | nonefield alongside the input-buffer axis.The field composes better: an agent can be blocked while a child command runs.
Both producers exist today, and entry is proved on both harnesses.
Codex:
activeFlags(waitingOnApproval,waitingOnUserInput) is level-triggered and on the wire;the ten
*/requestApprovaland*/requestUserInputserver→client requests are edge-triggered with aserverRequest/resolvedexit edge. Both are discarded today — see #265, which is a prerequisite.Both were read from codex-cli 0.147.0, which
SUPPORTED_CODEX_CLI_VERSIONS(
src/codex_app_server.rs:38) rejects, and neither is verified on 0.145.0 or 0.146.0. So the Codexproducer is gated on #267 or on a capture from a supported version, and this section does not
assume the field exists where st2 can currently reach it. A populated
activeFlagshas never beenobserved on any version; see the inference in #265.
The retention defense that "the driver arm keeps the flags" is currently vacuous:
CodexControlState(
src/codex_app_server.rs:189-201) has no field for them, so a mirror reading them with#[serde(default)]reads an empty vector forever.Claude:
PermissionRequestfires in exactly 2 of 9 captures —--permission-mode manual(
tool_name="Bash") and--permission-mode plan(tool_name="AskUserQuestion") — and in zerobypassPermissionsandautoruns. Its meaning is therefore specifically "a human is about to beasked", not "a tool ran". Measured timeline: the hook fires at 8.067 s and an independent screen
observer sees the prompt at 8.068 s; a
Notificationwithnotification_type="permission_prompt"follows at a fixed +6.01 s (6.014 s and 6.010 s across tworuns) and is corroboration, too slow to be primary.
Stopmarks idle at 40.626 s. The binary's masterhook enum carries 31 event names against the ten its own documentation lists;
PermissionRequest,PermissionDenied,MessageDisplay,PostToolBatch,SessionEnd,SubagentStopandCwdChangedwere all observed dispatching from a plainsettings.local.json.Open question, and it is not solved: how the blocked state is left.
An earlier draft specified "
blockedis left only by a resolution of the sametool_use_id". Theevent that enters
blockedcarries notool_use_id— verified across both occurrences in thecorpus.
PreToolUseandPostToolUsecarry it;PermissionRequestcarriestool_name,tool_inputandpermission_suggestionsand no call identity. So the rule degrades to matching ontool name, and Claude batches tool calls (
PostToolBatchcarries a pluraltool_calls): two Bashcalls in one batch with the second needing permission means the first call's
PostToolUseclearsblockedwhile the human is still looking at the prompt for the second. No key in the payloadprevents this. Correlating through the immediately preceding
PreToolUseis a temporal heuristic thatfails for exactly the parallel case it would need to handle.
The evidence base for the exit edge is also thin by construction:
blockedis entered in 2 of 9captures and exited in 1, with one tool, one prompt, and no batching. A rule validated on a single
exit path is not validated.
So: entry is proved on both harnesses; exit is an open question on the Claude side and needs a
capture with batched tool calls before it is specified. The Codex side has a real exit edge
(
serverRequest/resolved) and does not share this problem.One more producer-side hazard, on the Claude side.
SubagentStoparrives 1.5-2.9 s afterStopin every completed-turn run and carriesagent_id,agent_typeandagent_transcript_path,where top-level
Stopcarries none of them. Counted naively as activity, a session readsactiveforever after every normal turn. Nesting on the presence of
agent_idfixes it — but noteagent_typeis the empty string in all three runs and no subagent ran in any of them, so the fixrests on an undocumented, unversioned emergent property: that a phantom
SubagentStoppopulatesagent_id. If a future Claude build omits it, the naive failure returns silently and nothing in thecorpus would catch it.
without branching on a driver-specific detail type.
activeFlags; exit fromserverRequest/resolved.PermissionRequest, corroborated but not driven byNotification/permission_prompt.states the limit rather than a rule that cannot hold.
agent_idis nested and never moves top-level state, with the emergent-propertycaveat written into the spec.
blockedis vacuous underbypassPermissions, which is whatexamples/native/agent-claude.kdl:13ships today. A fleet running entirely onbypassPermissionsgains nothing here until that changes.Depends on #265 for the Codex producer.
§ D — A wrapper-owned terminal write, plus a PTY-correct generation cross-check
This section extends #260's line of investigation to the Claude wrapper; it does not restate it.
#260 establishes that the Codex wrapper installs no SIGTERM handler and leaks its app-server group,
and contrasts it with the Claude wrapper, which "catches SIGTERM, leaves its poll loop, and calls
stop_provider_groupbefore exit." That contrast is #260's, it is correct, and the Claude side isthe better-behaved one on the path #260 is about.
The finding below is about what happens after that path. #260 draws its own boundary explicitly:
That is the right scope for #260, which asks for in-process cleanup. It is also exactly the case a
liveness record cannot leave outside its guarantee, because a record that stops being written is
still being read. So the two are complementary: #260 fixes the graceful path in-process; this section
argues that the ungraceful path needs an out-of-process check, since by #260's own correct reasoning
no in-process write can cover it.
Claude death is silent. Two trials started
claude -pon a long-running Bash call, waited untilthe hook log showed a turn genuinely in flight, then signalled the process group:
Nothing fires — not on SIGKILL, and not on SIGTERM, which is the signal st2 itself sends. The
SessionEndreason set is closed and contains no death value:["clear","resume","logout","prompt_input_exit","other","bypass_permissions_disabled"]. A hooks-onlyrecord therefore sticks on
activeforever.The wrapper-owned write works, and it is not sufficient. A prototype mirroring
run_provider'sexisting loop (
src/claude_session.rs:93-107— spawn, polltry_waitat 250 ms) wrote a terminalstate when the child was reaped: hooks-only said
active, wrapper-owned saidended, detectionlatency 0.25 s, one tick of a poll the wrapper already runs.
It covers exactly the case where only the child dies:
src/claude_session.rs:29-40), so it survivesstop_provider_group'skill(-getpgrp(), SIGTERM)(src/claude_session.rs:114-121) and can write.STOP_GRACE(5 s,src/claude_session.rs:19) the samefunction sends
kill(-getpgrp(), SIGKILL)(:130-132) into its own process group. SIGKILLcannot be caught, so the wrapper dies with the provider and the following
let _ = child.wait()(
:133) never runs. A Claude session with a modal open or a long tool in flight plausibly exceeds5 s, so this is a routine path on st2's own teardown, not an edge. This is the complement of
driver codex leaves its app-server process group alive on SIGTERM #260's Codex finding: there the wrapper exits before cleanup runs, here the wrapper is inside the
group it is killing — and it is the case driver codex leaves its app-server process group alive on SIGTERM #260 correctly places outside its own guarantee.
(
tests/nomad_survival.rs::forced_kill_and_binary_replacement_adopt_pty_unchanged_without_duplicate),as does
pty killon the pane. Both take the wrapper and the provider together.So a cross-check is a shipping requirement, not an experiment bullet. In every uncovered case the
only fallback is staleness decay, and if the horizon is inherited from presence that is fifteen
minutes of a dead agent reading
active.And the cross-check must be specified against PTY, not exec. An earlier draft proposed reading the
record's
runtimeIncarnationagainstExecGeneration/observe_generation(
src/exec_backend.rs:34-48,:276-294) — the exec backend. That is the wrong backend: adriver-declared agent lowers to
TaskKind::Pty(crates/agent-spec/src/spec.rs:908,:917), so theClaude and Codex wrappers that write these records are PTY tasks. The PTY liveness primitive is
ding::session_alive— pidfile pluskill(pid, 0)atsrc/ding/mod.rs:692-703. The defense namedfor the exact failure mode was written against the wrong backend; an independent reviewer caught that,
not the author.
Two limits of the cross-check, stated up front. It is host-local by construction, so a cross-host
reader cannot evaluate it and is left with the staleness horizon alone. And a heartbeat that blindly
re-stamps a belief keeps a wrong state fresh indefinitely — staleness catches a writer that stopped;
it cannot catch a writer that is confidently wrong. So the writer rule is: stop heartbeating on
evidence loss. An observer that loses contact with its harness lets the record age to
unknownrather than refreshing a state it is no longer watching.
One constant to choose rather than inherit. If the staleness horizon is aliased to
status::STATUS_STALE, a future presence-tuning change silently retunes observed harness state withnothing failing. Row 22 pins those constants with named tests; the alias is invisible to them. One
decay horizon for an operator is a good rationale — it should be a documented constant with its own
value, not a re-export.
try_waitreturnsSome(exit)(
src/claude_session.rs:97-99) and on thestop_provider_grouppath, carrying the exit status.(
src/ding/mod.rs:692-703), notExecGeneration. A record whose generation is dead reads asunknowneven while fresh.status::STATUS_*.CLAUDE.md, theINVARIANTS.mdrow is added only once that test is green.§ E — One join into
agents --json: declared presence beside observed stateThe value of the surface is the comparison. A declared
busybeside an observedidleis thewedged-agent signal, and it is not derivable from anything shipping today. Making a caller join two
commands invites them to skip it.
What the declared side looks like on this host today, as a snapshot and nothing more: of 1298
statusfiles, 1217 readavailable, 63busy, 8away, 7dnd, 3offline. That distributionis consistent with agents declaring
busycorrectly and with agents never declaring it at all, andthe presence file cannot distinguish those two readings. That is the gap the join closes — not by
correcting presence, which stays exactly as it is, but by putting a second, independently produced
axis next to it. This is a census, not a divergence measurement: it does not show declared
presence diverging from observed state, because there is no observed state to compare it against yet.
That measurement becomes available once §A ships, and it is the one worth taking.
The cost, stated rather than discovered in review. The roster wire shape is pinned three times by
full-string
assert_eq!:src/agents.rs::agents_json_has_stable_wire_shape— two full-string asserts, onto_json(&rows, false)andto_json(&rows, true). The literal is atsrc/agents.rs:226.src/agents.rs::agents_json_preserves_opaque_declared_resource_descriptors— a third.and by
INVARIANTS.mdrow 21 (Stable roster JSON), whose guarantee ends "…and inbox counts.Presence remains independent from desired lifecycle." Observed harness state is a third
independent axis, so that wording changes and needs a new proof.
(
tests/status_agents.rsasserts by field index and is additive-safe either way.)INVARIANTS.md--observedflag with a third serializer structst2 activity --json, precedentst2 tasks --jsonThe sibling command is the cheaper option and it defers the wire commitment until real producers
exist. The join is the more useful one. This is a maintainer call and is flagged as one rather than
resolved here.
updated deliberately, in the same change, with the new proof named.
lastActivitykeeps its existing meaning and is not conflated with the new axis.Explicitly out of scope, with the reason
Named here so the boundary is visible rather than discovered.
--watch. Deferred, not deleted. Burst coalescing — 4transitions per turn arriving 0.1-0.4 ms apart, measured — is required for the record to be
truthful, and it defeats the dwell-time analysis the history was justified by: a blocked state
shorter than the coalescing window is erasable by construction. A streaming watch has the same
problem from the other side: re-projecting on wakeup reports the latest state, never each
intermediate, and two transitions between wakeups collapse with nothing to recover the dropped one
from. History earns its keep once §C exists and dwell time is the thing being recorded.
designed to keep the delivery gate from reading a state older than one minute. The layer boundary
says the delivery gate does not read this state at all, so the tier's stated justification is gone.
Any future version needs a fresh one.
## Limits.is_interruptibleand the routing story. Cut. It serves a router that does not exist: st2 hasno router today, and every call site of the prototype's routing predicates is
#[cfg(test)].R20("The generic envelope does not imply resolution, access, readiness, or lifecycle semantics").
st2 owns observation, envelope, fencing, freshness, and exposure.
AGENT-SPEC.mdchange. That file lives incompoundingtech/evalswith separate authority.Limits
Stated before anyone has to find them.
it as
classify_composer(screen, ""), mapping onlyEmptySafeto a proven idle. That reasoning isCodex-specific: Codex has distinct empty and typed markers, but the Claude arm extracts composer
text and string-compares, so for an empty Claude composer the empty expected notice matches as an
exact match and the observer returns unproven. Measured against the repo's own fixtures, a used
Claude pane reads unproven while a fresh one reads idle — so the observer proves idle for a Claude
pane exactly once, before anyone types into it. The root cause is a sentinel collision, not a
missing match arm:
expected = ""collides with the Claude arm's representation of an emptycomposer. That is the same shape of error
0001corrected. The failure direction is safe — itunder-reports and never produces a false idle — but the observer is advertised as the only path
covering hand-authored and unmanaged PTY agents, and it delivers nothing for one of two harnesses.
It is cut for that reason. Found by an independent adversarial reader; the prototype's own test hid
it by fixturing Codex in the positive slot.
§D above states this in full; repeated here because it is the limit most likely to be assumed away.
replicated write costs on a real catalog under a real transport. Presence refreshes every five
minutes; turn boundaries are far more frequent. The catalog exposure in §E is a read of a
host-local record, and how — or whether — the record itself replicates is deliberately left open
rather than answered with a shape that cannot be defended.
DQ3's supervisor-following half is unproven.docs/vrs/spec.md:957+sets two gates:stale-state behavior and supervisor-following behavior. The stale-state half is addressed
thoroughly above. The supervisor-following half is not addressed anywhere in this work. Half
the repo's own acceptance criterion for this exact feature is unmet, and that is a reason the
decision record ships
Status: draft.SUPPORTED_CODEX_CLI_VERSIONS(
src/codex_app_server.rs:38) rejects, so every Codex protocol fact here is unverified on theversions st2 supports — including
activeFlags, on which §C's Codex producer depends. st2 refuses the locally installed codex-cli, and the version pin is now behind by one release #267covers the drift itself. All Claude evidence is from one bundle version, on Linux. CI is
ubuntu-latestonly, so nothing here says anything about Darwin.docs/vrs/01-ding/02-codex/spec.mdsays so directly. This is an argument for the genericenvelope: the producer changes, the contract does not.
docs/vrs/contains no match forapp-server,claude-mcp, ordriver expand, and01-ding/requirements.mdstill opens by callingDING a sidecar. PR Specify native DING and Codex app-server delivery #242 is the only written contract for the delivery layer this observes, and it is
still open. Landing Specify native DING and Codex app-server delivery #242 first is more valuable than landing this, and help rebasing it is offered.
Measurement provenance
Following #261's practice of naming where the numbers came from.
statusfiles, zero with a version 1 record — every catalog under~/.local/state/st2on this host, read 2026-08-17. Host-scoped. Not a fleet claim and not astatement about Add native delivery for Codex and Claude #254.
codex app-servercaptures, codex-cli 0.147.0, replayed throughCodexControlState::observeundercargo test. The version is one st2 refuses to launch (st2 refuses the locally installed codex-cli, and the version pin is now behind by one release #267).five distinct behavioural shapes: three of the nine are the same scripted run under different kills,
and one contributes two events. The nine rows over-count the evidence and are described that way.
seats have hit any of these defects, and anything at all on Darwin.
Related
DING-R12vocabulary and fencing tuple this reuses rather than re-proposes. Notefeat(ding): add harness-neutral guarded adapter seam #123 also deletes
DING-A01; this must not silently re-delete it.TUI named above. Declared working state; this is observed harness state. Complementary, not
competing.
st2 resourcemanages linked references, not agent state #62 — states the "propose separately only when a concrete consumer needs it" rule this issueanswers. Note Clarify that
st2 resourcemanages linked references, not agent state #62 is open; the sentence is from its body, not from a closure.builds on. §D extends its investigation to the Claude wrapper and does not restate its findings.
unknownis no fresh observation, notproof of ill health, and never gates local work.
distinguishes the two.
re-derived.