feat(detect): agent-agnostic turn-end status contract - #31
feat(detect): agent-agnostic turn-end status contract#31matthias-scale wants to merge 29 commits into
Conversation
Surface the closing block as three independent channels -- blocking gates, running agents, idle -- instead of collapsing them into one screen-scraped status. Screen scraping sees Claude's prompt box the instant a turn ends and calls the pane idle (manifests/claude.toml live_prompt_box, priority 950). That is right about the harness and wrong about the work: agents may still be running, or a Gate may be waiting on a human. Adds the closing-block sources to full_lifecycle_hook_authority so the Stop hook outranks the prompt box, with a regression test asserting the authority does not outlive the agent process. Demo harness under demo/.
Replace prose-shaped reporting with a structured payload any coding agent can
emit from its own turn-end hook:
{"v":1,"agent":"claude","blocking":2,"agents":3,"gates":[...]}
blocking (items only a human can clear) and agents (still working) are
independent, so "nothing for you, but 3 agents are still running" becomes
visible for the first time.
The hook is the trigger, not the model: a turn-end hook fires deterministically,
a tool call the model must remember does not. Transport is the existing herdr
socket, with an atomic JSON mirror under XDG_STATE_HOME as durable fallback.
is_closing_block_source admits any herdr:<agent>-closing-block whose suffix
names the agent it speaks for, so adopting the contract needs no herdr release.
hook_authority_is_effective still requires that agent's process to be present.
Adapters for Claude (Stop) and Codex (notify), plus a dependency-free CLI.
The turn-end status contract was accepted at the RPC edge and then dropped
inside TerminalState, while the caller still saw {"type":"ok"} because
handle_pane_report_agent discards the mutation.
Two gates rejected it, both keyed on session identity:
- route_full_lifecycle_hook_report requires an AgentSessionRef, but
agent_resume::session_ref_from_report mints one only for sources on the
is_official_agent_source allowlist. A closing-block source reports what the
agent just said, not where to resume it, so its ref is always None and every
admitting path was unreachable.
- set_hook_authority_at rejected the report as an owner conflict on any pane
where the real agent had already announced itself under herdr:<agent> -- that
is, every pane that matters.
Accept on process presence instead, which is the only guarantee this source
claims; accept_hook_report still enforces per-source sequence monotonicity.
The existing test passed a session ref the RPC can never produce, so it stayed
green while the live path dropped every report. It now reports with None
against a pane whose session is owned by herdr:claude, and fails without the
fix.
Verified end-to-end against a live claude process: blocked survives the visible
prompt box, repeated detection ticks, and focusing the tab.
Correction: the override claim was wrong on the first two commitsTested against a live Root cause was upstream of the
Fixed in The test was the weaker artifact
Live end-to-end, real
|
| Reported | status | label |
|---|---|---|
blocking=0 agents=3 |
working |
3 agents |
blocking=2 agents=2 + gate |
blocked |
gate ×2 |
| same, 3 detection ticks later | blocked |
gate ×2 |
| same, after focusing the tab | blocked |
gate ×3 |
blocking=0 agents=0 |
idle |
— |
Blocked persists across visiting the tab with no new code: ui/status.rs:380 and ui/sidebar.rs:380 match (AgentState::Blocked, _), ignoring seen. A gate stays red until a later turn reports blocking=0 — cleared by answering it, not by glancing at it, same as a real permission dialog.
cargo fmt and cargo clippy --all-targets clean. Full suite single-threaded: 14 failures on this branch vs 15 on the same tree without the state.rs change; all are the known codex-manifest cache drift plus one shared-state flake that passes in isolation. Zero new.
Still not covered
The Stop hook is not wired into ~/.claude/settings.json, so the live run drove the contract by hand rather than from a real turn end.
Sidebar labels are now "gate" and plain "working". One gate or three, the action is the same; running agents just mean "not done", which the working dot already says. The counts still ride along in the closing_* tokens. Adds INSTALL.md with the wiring verified live for both agents, including the two traps: --settings never loads hooks, and project-scoped hooks are silently skipped until the folder is trusted.
Codex sets its terminal title to the cwd and its rollout files carry no title, so a codex pane has nothing to name it by. The notify handler now derives one from the first turn's user message -- the same thing codex's own resume picker previews -- and pins it in the mirror so later turns do not rename the pane. Rides along as a `session_title` metadata token.
The Blocked and Pinned groups shipped with header rows that reserved a line but never drew one, so the groups read as unexplained gaps above the tree. Paint them with the same chevron a space row uses, and make that chevron real: clicking a header folds its group away, count kept so the header still says how much is hidden. The tree earns a Spaces header -- and a fold of its own -- whenever a group sits above it.
The Blocked and Pinned groups repeated the tree's own row template -- two lines ending in claude or codex -- which answers a question nobody asks of a gate. A grouped row is now a single line: state dot, thread title, then the space it belongs to. Cards carry their row index so a pane listed both in a group and in the tree renders each row in its own shape instead of twice in the first one's.
# Conflicts: # src/app/mod.rs # src/app/state.rs # src/ui.rs # src/ui/dock.rs
A full-lifecycle source that stays silent leaves its last report standing forever, so a pane that reported a gate kept showing it with nothing able to clear it.
Use recent PTY output as a bounded fallback when Codex has no positive idle or blocker evidence. Codex's non-empty OSC cwd title still resolves Idle but no longer sets visible_idle, so pending-idle confirmation and blocker arbitration can observe the difference. Claude's live prompt box and blocker rules remain unchanged.
Verified against live `agent.read` captures of a codex 0.147 pane: a real turn prints no block marker, so the block-marker region emptied the rule and the only working evidence codex has stopped firing.
# Conflicts: # src/pane.rs
A live codex 0.147 turn goes silent for 3.6s between erasing its progress line and the first streamed token; a two-second window showed that gap as `done` mid-turn.
Problem
herdr derives Claude/Codex lifecycle purely from screen scraping.
claude.toml:64-79(live_prompt_box, priority 950) sees the❯box the instant a turn ends and calls the pane idle — even when several agents are still running. And its only notion ofBlockedis harness-blocked (an on-screen permission form, orAction Requiredin the Codex OSC title): waiting on a keypress. It has no way to know an agent is waiting on a human decision.herdr:claude/herdr:codexare on the reserved source list (src/agent_resume.rs:81-93), whose reported state is discarded, which is why herdr's own managed Claude hook only ever reports session identity.Net effect: a pane with nothing for the human but three agents still working is indistinguishable from a pane that is genuinely done.
Contract
One structured payload, emitted by whatever turn-end hook the agent's harness already has:
{"v": 1, "agent": "claude", "blocking": 2, "agents": 3, "gates": ["Merge #30"], "agent_names": ["reviewer A — round 4"]}blocking(items only a human can clear) andagents(still working) are independent channels, not one collapsed status.Two deliberate design calls:
$HERDR_SOCKET_PATHis already injected into every pane and already works; a directory watcher would be new Rust surface for no gain. An atomic JSON mirror at$XDG_STATE_HOME/herdr/agent-status/<pane_id>.jsonis the durable fallback.Change
is_closing_block_sourceadmits anyherdr:<agent>-closing-blockwhose suffix names the agent it claims to speak for — matched by shape, so a new agent adopting the contract needs no herdr release.hook_authority_is_effectiveindependently still requires that agent's process to be present in the pane, so the shape match is not a trust hole.Everything else is in
demo/: the contract module, a dependency-free CLI, adapters for Claude (Stop) and Codex (notify), and an isolated end-to-end harness.Verification
Live socket demo against the stock shipping binary, 5/5:
StophookworkingStophookblockedStophookidlenotifyblockedopencode--blocking 0 --agents 4workingSidebar renders
gate ×2/3 agentsviastate_labels.closing_block_source_matches_only_its_own_agent,closing_block_authority_outranks_visible_idle_prompt_box(the latter also asserts the authority does not outlive the agent process).cargo fmtclean,cargo clippy --all-targetsclean.cargo testsingle-threaded, this branch vs baseline: 13 failures on both sides, zero new. Pre-existing codex-manifest cache drift, unrelated.Two constraints worth recording
hook_authority_is_effective). A synthetic shell pane can never qualify — the report is stored, then ignored at read time, silently.herdr:<agent>, so it does not cover a new source.report()sendspane.report_agent_sessionatseq-1first; without it the report is buffered and dropped with no error.Also fixed: metadata tokens persist across reports, so a key omitted on a later turn kept its old value — a finished pane kept advertising agents that had already exited.
Not covered
state_labels. Separate sidebar columns are not built.claudeprocess in a herdr pane.agent-turn-completepayload, not a livecodexturn.