Skip to content

feat(detect): agent-agnostic turn-end status contract (MAT-125) - #39

Merged
matthias-scale merged 19 commits into
fork/pr-basefrom
mat125/status-contract-split
Aug 9, 2026
Merged

feat(detect): agent-agnostic turn-end status contract (MAT-125)#39
matthias-scale merged 19 commits into
fork/pr-basefrom
mat125/status-contract-split

Conversation

@matthias-scale

Copy link
Copy Markdown
Owner

Summary

  • accepts turn-end status from Claude Stop and Codex notify adapters through the agent-agnostic herdr:<agent>-closing-block source contract
  • keeps hook authority only while the matching agent process is live, retires stale blocked authority on new input/output, and preserves settled screen evidence for new turns
  • carries independent blocking and agents channels and writes the durable atomic mirror at ~/.local/state/herdr/agent-status/<pane-id-with-colons-replaced-by-underscores>.json
  • preserves AgentStatus::Done for non-Codex agents by limiting the measured recent-output fallback to Codex

Split from #31

The sidebar grouping/pinning, dock/editor, compact worklist-row, mouse, and unrelated input churn is excluded from this PR. All 15 UI-assigned source nodes are preserved in draft PR #38 (mat125/ui-churn-split). PR #31 remains open and untouched for planner cleanup.

Acceptance criteria

  • AC1: full turn-end contract: Claude + Codex ingestion, live-process authority, mirror writes, and independent blocking/agents channels
  • AC2: no dock/editor, sidebar-group/pin, worklist-row, mouse, tabs, or unrelated input churn
  • AC3: all 29 source nodes allocated: 14 contract nodes here and 15 UI nodes in feat(ui): sidebar groups, dock editor, worklist rows [no-ticket] #38; merge-only nodes retained as explicit provenance commits
  • AC4: focused contract tests green; repository checks green except the unchanged baseline one-second work_context microbenchmark, which reproduces alone at 1.04–1.79s and is excluded from the final local pass; exact-head CI is required
  • AC5: lowercase conventional commits with approved author identity; PR titles carry MAT-125 / [no-ticket]
  • AC6: PR feat(detect): agent-agnostic turn-end status contract #31 left open and unmodified

Local verification

  • python3 -m pytest -q demo/test_closing_block.py — 7 passed
  • focused closing-block, hook-retirement, recent-output, hook-idle, and AgentStatus::Done tests — green
  • just check-parallel with Zig 0.15.2 — contract failure found and fixed; repeated runs exposed only unchanged baseline timing/load flakes
  • final full pass excluding only work_context::tests::ac25_preview_url_extraction_rejects_large_whitespace_free_input_quickly — lint, 3,487 Rust tests, Windows lint, maintenance, integrations, marketplace green

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.
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.
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

Pull request: #39

Terminal contract ready

This change has no web preview. Run the named repository-owned nonvisual journeys; each emits a head-bound local assertion artifact.

All five repository-owned nonvisual journeys passed head-bound automated assertions (cargo nextest suites) at this exact head.

@matthias-scale

Copy link
Copy Markdown
Owner Author

No description provided.

@matthias-scale
matthias-scale merged commit 29cc01b into fork/pr-base Aug 9, 2026
6 checks passed
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.

1 participant