Skip to content

feat(detect): agent-agnostic turn-end status contract - #31

Closed
matthias-scale wants to merge 29 commits into
fork/pr-basefrom
demo/closing-block-signals
Closed

feat(detect): agent-agnostic turn-end status contract#31
matthias-scale wants to merge 29 commits into
fork/pr-basefrom
demo/closing-block-signals

Conversation

@matthias-scale

Copy link
Copy Markdown
Owner

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 of Blocked is harness-blocked (an on-screen permission form, or Action Required in 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:codex are 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) and agents (still working) are independent channels, not one collapsed status.

Two deliberate design calls:

  • The hook is the trigger, not the model. A turn-end hook fires deterministically; a tool call the model has to remember every turn does not. Agents maintain nothing.
  • Transport is the existing socket. $HERDR_SOCKET_PATH is 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>.json is the durable fallback.

Change

is_closing_block_source admits any herdr:<agent>-closing-block whose 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_effective independently 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:

Entry point Input herdr status
Claude Stop hook nothing to act on + 3 agents working
Claude Stop hook 2 blocking gates blocked
Claude Stop hook nothing to act on + done idle
Codex notify same 2-gate payload blocked
Bare CLI as opencode --blocking 0 --agents 4 working

Sidebar renders gate ×2 / 3 agents via state_labels.

  • Parser: 6/6 unit tests over closing blocks lifted from real transcripts.
  • New Rust tests: 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 fmt clean, cargo clippy --all-targets clean.
  • Full cargo test single-threaded, this branch vs baseline: 13 failures on both sides, zero new. Pre-existing codex-manifest cache drift, unrelated.

Two constraints worth recording

  1. A full-lifecycle source is only honoured while the real agent process is confirmed in the pane (hook_authority_is_effective). A synthetic shell pane can never qualify — the report is stored, then ignored at read time, silently.
  2. A source must announce its own session before its state reports count. Sequences are tracked per source, and herdr's managed hook announces under herdr:<agent>, so it does not cover a new source. report() sends pane.report_agent_session at seq-1 first; 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

  • No UI work: blockers and running agents ride the existing status field plus tokens/state_labels. Separate sidebar columns are not built.
  • Verified against a synthetic pane and unit tests, not a live claude process in a herdr pane.
  • The Codex adapter is verified against a synthesised agent-turn-complete payload, not a live codex turn.

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.
@matthias-scale

Copy link
Copy Markdown
Owner Author

Correction: the override claim was wrong on the first two commits

Tested against a live claude process in a herdr pane (previous verification used a synthetic shell pane) and the headline claim did not hold: a blocked report was accepted with {"type":"ok"} and the prompt box still won.

Root cause was upstream of the detect/mod.rs change, which armed a suppression that was never reachable. handle_pane_report_agent discards the mutation, so the RPC reports success regardless. Two gates rejected the report, both keyed on session identity:

  1. route_full_lifecycle_hook_report requires an AgentSessionRef, but agent_resume::session_ref_from_report mints one only for the is_official_agent_source allowlist. A closing-block source reports what the agent said, not where to resume it — its ref is always None, so the report landed in a pending-replacement slot that nothing drains while the agent stays continuously detected.
  2. set_hook_authority_at rejected it as an owner conflict on any pane where the real agent had already announced itself under herdr:<agent> — every pane that matters.

Fixed in 4cbf03c2: both accept on process presence, the only guarantee this source claims. accept_hook_report still enforces per-source sequence monotonicity.

The test was the weaker artifact

closing_block_authority_outranks_visible_idle_prompt_box passed the whole time. It supplied a session ref the RPC can never produce and announced a session under its own source. It now reports with session_ref: None against a pane owned by herdr:claude, and was confirmed to fail without the fix before being accepted.

Live end-to-end, real claude process

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.

matthias-scale and others added 25 commits August 6, 2026 17:28
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.
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.
@matthias-scale

Copy link
Copy Markdown
Owner Author

Superseded by the split: contract landed via #39 (merge commit 29cc01b); remaining UI churn continues on draft #38. Closing per plan.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants