feat(mecatui): emit agent lifecycle hooks to host editors - #1697
Conversation
JAORMX
left a comment
There was a problem hiding this comment.
Panel review — PR #1697
Reviewed: bd7d1e5d5c136f28c9f6eb4e9aa98cf58ab85d7b against the merge base with d1b659c1fe99bb9d5b5d8486c04b00d8edfe7b5a: 10 files, +1,282/−0, one commit.
Request changes for two lifecycle defects described inline: missing terminal transitions leave the notifier stuck busy, and detached workers can reorder lifecycle events across /connect restarts. The other findings are non-blocking. Superset-side registration is an explicitly accepted follow-up, not a merge blocker.
Spec
Source: this PR's description and commit contract.
- Blocker — incomplete transport-terminal handling. Active
StreamClosedMsg(including the clear handoff) and authentication recovery callendRunwithout notifying the hook. This violates the promised terminal notifications and suppresses the next busy signal becauseNotifier.runningstays true. See the inline comment onui/update.go. - Important — documentation overstates availability.
user-docs/mecatui/using-the-tui.md:104–115promises automatic Superset notifications, while this PR explicitly says the required host registration is deferred and the feature remains dormant until then. State that prerequisite; the deferral itself is fine.
Standards
Checked applicable AGENTS.md, test-isolation guidance, TUI architecture and lifecycle inventory conventions; skipped tooling-enforced formatting/lint rules.
- Important — incomplete subprocess containment.
agenthook/hook.go:252–258kills only the direct command on timeout, unlike the existing process-group containment convention documented in ADR 0027 and implemented byprocgroup.Configure. Descendants can survive the claimed bound. - Advisory — resource inventory. The worker/queue introduced at
agenthook/hook.go:209–243has no close/join owner and no ADR 0027 List 1 entry. Document ownership, cleanup, and reset behavior alongside the lifecycle fix.
Independent test adequacy
- Important — transport terminals.
ui/agenthook_notify_test.go:138–150exercises an errorResultMsg, not transport termination. Cover ordinary stream errors, active EOF, clear handoff, and authentication recovery; then start a new run and assert a fresh busy signal. - Important — retry boundary. Existing semantic-retry tests do not attach a notifier. Exercise retry-eligible result → retry turn-start → final result and prove there is no intermediate terminal or duplicate busy signal.
- Important — worker boundary contract. Current recorder tests and the direct runner deadline test do not prove queue saturation/oldest-drop, nonblocking producers, or the worker's independently bounded detached contexts. Use a controlled blocking runner and canceled caller context to assert those properties.
Domain
Panel: security, software architecture, library reuse, and duplication; disputed findings received a separate validation pass.
- Blocker / High — missing terminal transitions. Cross-confirmed by architecture and library-reuse reviewers.
- Blocker / High — cross-restart ordering. A prior notifier's delayed
Stopcan arrive after the replacement notifier'sUserPromptSubmit; workers need an owned, bounded shutdown boundary before restart. - Important / Medium — subprocess descendants outlive timeout. Cross-confirmed by security and library-reuse reviewers; reuse the existing process-group facility.
- Important / Medium — promoted approvals are silent. A main ask queued behind a background-child ask never emits
PermissionRequestwhen it becomes visible. - Advisory / Info — standard-library reuse.
agenthook/hook.go:300–306: replace the customutf8Startpredicate withunicode/utf8.RuneStart.
The validation pass rejected the claimed Medium credential-boundary defect: this is an operator-installed host hook, not an agent-facing shell. It also rejected speculative Superset-only restructuring, removal of intentionally ignored interface contexts, and consolidation of focused tests. No upheld duplication finding.
Verification and totals
All applicable checks on the reviewed revision are green; live e2e jobs are skipped. No local tests or linters were run. No implementation changes made.
Counts preserve the independent axes: a root cause reported by Spec and Domain counts in both; duplicates within Domain count once. Thus the three blocker findings below represent two distinct blocking defects.
PANEL: ship_blockers=3 important=7 advisory=2 reviewer_failures=0
mecatui now reports its session lifecycle to a host editor's agent lifecycle hook, so an editor hosting the TUI can raise a notification when the agent needs an approval and when a run ends. The emitted payload follows the cross-vendor agent-hook schema rather than any single host's convention: one JSON object on stdin carrying `hook_event_name` plus the schema's common fields (`session_id`). Anthropic's Claude Code originated that contract and OpenAI's Codex adopted it field-for-field, down to exporting CLAUDE_PLUGIN_ROOT for plugin-hook compatibility, so the package is named for the mechanism (agenthook) and emits the canonical event names (UserPromptSubmit / Stop / StopFailure / PermissionRequest). A host that normalizes those names understands them; a host-specific alias would not travel. mecatl has no hook surface of its own that carries session lifecycle. Its only hooks are the per-tool-call PreToolUse/PostToolUse permission gate, so neither of the integration shapes a host can write into (a hook config file, or a plugin the agent loads) applies. mecatui already consumes the session event stream to render, so it emits the lifecycle directly from three reducer points: turn start becomes the busy signal, a main-session permission ask becomes PermissionRequest, and the terminal result becomes Stop or StopFailure. Design notes: - Host discovery is the one host-specific half and lives in superset_host.go; the schema and delivery core are vendor-neutral. A second supported host is a sibling detect function, not a new package. - Inert by default: with no supported host detected, New returns nil, every method is a no-op, and behavior is byte-identical to before. - Subagent asks are filtered (isChildAsk). A host drives terminal-level status from the main loop, so delegated work must not relabel the session. - Delivery is ordered through one worker. A goroutine per event lost ordering, which could land the terminal before the busy signal. - Each delivery is detached from the run context and separately bounded. The terminal event fires as the run's context is torn down, so honoring that context would abort the hook before it could report completion. - A full queue drops the oldest event instead of blocking. A stalled hook must never back-pressure the agent loop. - The payload is marshalled with encoding/json. It carries model- and tool-influenced text, so hand-escaping would be a field-forgery hole. - The auto-retry branch deliberately emits no terminal, since the run continues. Tests are offline via an injected runner seam, and cover the real shell-out (payload on stdin, host env, best-effort on failure, context bound) against a local recording script. TestEventNamesAreCanonicalSchemaNames and TestPayloadUsesSchemaFieldNames pin the wire contract. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bd7d1e5 to
189bc73
Compare
Panel review on #1697 found two lifecycle defects plus three smaller ones. Missing terminal transitions. The notifier closes a busy period only when it sees a terminal, and Start dedupes while running stays true. Three run-ending paths called endRun without telling it: StreamClosedMsg for an active run, the same message under the /clear handoff, and reduceLiveAuthRecovery. After any of them the host stayed busy forever, because the NEXT run's Start was silently deduped. All three now report a failed terminal. The genuine non-terminals keep their exemption: an automatic failed-step retry continues the same run, and a parked authorization (phaseAuthorizing) is suspended rather than over, so neither emits. Cross-generation reordering. A /connect restart re-enters runWithOptions and builds a fresh notifier, but the old worker had no owner: its channel was never closed and nothing joined it. With a slow hook command the retired generation could deliver its queued Stop after the successor's UserPromptSubmit, marking the host idle during a live run. Notifier.Close closes the queue and joins the worker within a bound, then abandons; past that boundary enqueue drops instead of resurrecting a worker. main settles it inside runCleanup, before any restart can build a successor. Writing the concurrency test for that found a real bug in the first version of Close: enqueue captured the channel under the mutex and sent after releasing it, so a concurrent Close could close the channel under an in-flight send. Both the send and the close now happen under the lock, which is sound because every send arm is non-blocking and the worker never takes the lock. Descendant containment. exec.CommandContext kills the script, not helpers it spawned, so a hook that backgrounds a curl or a sleep outlived the bound this package advertises and repeated deliveries accumulated orphans. Reuse the convention already in hookexec and statusline: procgroup.Configure plus a nonzero WaitDelay backstop. Silent promoted approvals. A main ask arriving behind an open background-child card was queued with no notification, and promoting it only re-rendered, so the host never learned the main session wanted attention. Both entry points now route through one emitter keyed on the visible-head transition, so the child filter cannot drift between them. Docs and inventory. The user-docs page promised automatic notifications while the required Superset-side registration is still deferred; it now states that prerequisite. The worker and its queue get ADR 0027 List 1 row 75 with a re-audit recording why they take no List 2 row. Tests. Reducer coverage for all four transport terminals, each followed by a second run so a stuck-busy notifier fails; the retry boundary proving one busy signal and exactly one terminal at the end; both promotion cases. In the package: nonblocking producers against a stalled worker, oldest-drop saturation, cancel-detached but independently bounded deliveries, the drain and its bound, and the close boundary. Every new test was mutation-checked against the reverted fix. The containment test withholds the kill until the descendant provably exists — an earlier fixed-timeout version passed with procgroup removed, because the shell had not forked yet, and asserted nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks Ozz, this was a good catch list. Both blockers are fixed, and I took every other finding too. Inline replies cover the four threads; the rest:
Two things I'd rather you know than not: The concurrency test I wrote for the shutdown found a real bug in my first My first descendant-containment test was vacuous. It used a fixed 200ms timeout and passed with Gates: |
Follow-up reviewReviewed Addressed
Blocker: shutdown still allows old notifications into the successor runAt Main allows three seconds for shutdown ( Please give shutdown an owned cancellation path, discard remaining work when the drain deadline expires, and ensure the worker has joined before starting the successor. The inventory’s guarantee that retired generations cannot affect successors should match that behavior. The regression test also needs to cross this actual boundary. Important, non-blocking: two other terminal paths remain
Both can leave Optional test strengthening
The reducer and notifier tests are otherwise reasonable at their separate layers; I’m not asking for redundant full-composition tests for retry or every transport terminal. Superset-side registration remains an accepted follow-up, not a blocker. Applicable CI checks are green. No local tests or linters were run, and no implementation changes were made. Panel totals retain independent axes: the production shutdown blocker also appears as a test-adequacy blocker; the inaccurate lifetime guarantee is a Standards finding. Thus the two blocker entries represent one production defect plus its missing regression coverage. PANEL: ship_blockers=2 important=3 advisory=2 reviewer_failures=0 |
Close treated "closed the queue" as "no more deliveries", but those are different facts. Closing the queue only stops new enqueues; the worker's `for d := range q` goes on draining everything already buffered, each event getting a fresh full 5s timeout. So when the 3s drain bound expired, Close returned while the retired worker was still delivering. main then built the successor notifier, and a queued generation-N Stop could land after generation N+1's UserPromptSubmit, marking the host idle during a live run. That is the cross-generation reordering the boundary exists to prevent, and the inventory claimed it could not happen. Close now REVOKES on the timeout path: a new abandon channel cancels the context every delivery descends from, so the invocation in flight is torn down and the worker discards the rest of the backlog rather than delivering it, and Close then joins. The guarantee is now stated exactly, in the doc comment and in ADR 0027 row 75 -- once Close returns no delivery from that generation can START; on the revoked path the only residual is the single already-cancelled invocation. Two more active-run terminals were also reaching endRun without settling the hook, each leaving Notifier.running true and silently deduping the next run's busy signal: - The /clear RPC response winning the race against its own source stream terminal. The two arrive on independent commands, so server-side completion does not fix their reducer order, and the StreamClosedMsg branch that settles the hook never runs when the response lands first. - An MCP authorization continuation whose control stream dies while it owns the active run. No ResultMsg follows, so that is a genuine transport terminal. Tests. TestRevokedGenerationCannotDeliverIntoItsSuccessor crosses the timed-out shutdown specifically, with both generations sharing one runner so the recorded order is what the host would observe; reverting the revocation reproduces the exact reported sequence (gen-1 start, gen-2 start, gen-1 StopFailure). TestCloseJoinsTheWorkerOnTheRevokedPath pins the join. Both new reducer terminals get a start-failed stop-start test. Every new test was mutation-checked against the reverted fix. Also tightened two existing assertions the review called out: the oldest-drop test now parks the worker first so the surviving set is deterministic and asserts the exact contiguous FIFO suffix, and the independently-bounded test now proves the second delivery gets a fresh live context with its own deadline, sampled at entry so it cannot race the bound. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Good catch, and you're right that it's the original issue rather than a new requirement. I'd conflated "closed the queue" with "no more deliveries", and those aren't the same fact. The blockerFixed in ec394e9. Closing the queue only stopped new enqueues; the worker's
I've also stated the guarantee exactly rather than gesturing at it, in both the doc comment and ADR 0027 row 75: once You were also right that the old regression test proved nothing about this.
The two other terminal pathsBoth fixed, each with a The The MCP authorization continuation now settles before Test strengtheningTook both. The oldest-drop test parks the worker before saturating, which makes the surviving set deterministic instead of racing the worker's first receive, so it can assert the exact contiguous FIFO suffix rather than loose bounds. The independently-bounded test now proves the second delivery gets a fresh live context carrying its own deadline, sampled at entry so the assertion can't race the 60ms bound. Every new test was mutation-checked against the reverted fix. Gates: |
Follow-up review — approvedReviewed Addressed
Important, non-blocking: handle premature MCP control-stream EOF tooAt Please apply the same owned-active terminal treatment as the newly fixed stream-error branch, preserving harmless EOF after a result and ordinary authorization-polling closure. Add an EOF-before-result regression followed by another run. This has the same non-blocking severity as the additional MCP error-terminal omission in the previous review. Advisory: qualify the lifecycle inventory wording
VerificationAll applicable CI checks on this revision are green: 29 successful checks, none pending or failing. No local tests or linters were run, and no implementation changes were made. Spec and requested regression coverage pass. Standards retains the inventory wording advisory; Domain retains the premature-EOF finding, independently identified by two reviewers. A disputed shutdown blocker based on a context-ignoring runner was rejected after validating the production runner and restart ownership. PANEL: ship_blockers=0 important=1 advisory=1 reviewer_failures=0 |
JAORMX
left a comment
There was a problem hiding this comment.
Approved at ec394e9. The requested fixes and original cross-restart ordering blocker are addressed. All applicable CI checks are green. Two non-blocking follow-ups (premature MCP control-stream EOF and inventory wording) are detailed in #1697 (comment).
What
mecatuinow reports its session lifecycle to a host editor's agent lifecycle hook, so an editor hosting the TUI can notify you when the agent needs an approval and when a run ends.Zero configuration, and inert unless a supported host is detected.
Why the payload follows a cross-vendor schema
The hook contract here is not one host's convention. It is a de-facto standard:
hook_event_namehook_event_name(identical)session_id,transcript_path,cwdsession_id,transcript_path,cwdSessionStart,UserPromptSubmit,PreToolUse,PermissionRequest,PostToolUse,Stop,StopFailure,SessionEnd,SubagentStart/Stophooks.json)Codex even exports
CLAUDE_PLUGIN_ROOT/CLAUDE_PLUGIN_DATA"for compatibility with existing plugin hooks", so OpenAI is explicitly targeting Anthropic's contract.Sources: Claude Code hooks reference, Codex hooks.
Consequently the package is named for the mechanism (
agenthook), not for a host, and it emits the canonical event names (UserPromptSubmit/Stop/StopFailure/PermissionRequest). A host that normalizes those names understands them, whereas a host-specific alias would not travel to another host.Why
mecatuiemits this itselfmecatlhas no hook surface that carries session lifecycle. Its only hooks are the per-tool-callPreToolUse/PostToolUsepermission gate (engine/governance), so neither integration shape a host can write into applies:mecatuialready consumes the session event stream to render, so it emits the lifecycle directly.Where it hooks in
Three reducer points, each nil-safe:
ui/update.gobeginTurnEventUserPromptSubmit(busy)ui/approval.goapplyPermissionAskPermissionRequestui/update.goapplyResult+ 3 transport-death pathsStop/StopFailureDesign decisions worth reviewing
superset_host.goholds the only host-specific code (env detection plus the host's env var). The schema and delivery core are vendor-neutral, so a second host is a sibling detect function rather than a new package.Newreturnsnil, every method is a no-op, and behavior is byte-identical to before. Composition returns an honestly-nil interface to avoid the typed-nil trap.isChildAsk). A host drives terminal-level status from the main loop, so delegated work must not relabel the session.encoding/jsonmarshals the payload. It carries model- and tool-influenced text, so hand-escaping would be a field-forgery hole. Pinned by a hostile-input test.Verification
task lint: 0 issues across all 7 modules.go test -race ./cmd/mecatui/...: all green (36 new tests).task docs: 431 documents, 3922 references, 0 broken links/anchors/orphans.task site:build: succeeds.notify.sh: the fullUserPromptSubmit->PermissionRequest->Stopsequence delivered in order, each accepted200, with the agent identity intact. Outside a host terminal the notifier isnil.Tests stay offline through an injected runner seam. A separate group exercises the real shell-out (payload on stdin, host env, best-effort on a failing script, context bound) against a local recording script.
TestEventNamesAreCanonicalSchemaNamesandTestPayloadUsesSchemaFieldNamespin the wire contract so a rename fails CI.Docs
user-docs/mecatui/using-the-tui.mdgains a short section. Peruser-docs/_README.md,mecatui/owns terminal-client behavior and this extends the existing page rather than adding one.Scope and follow-up
This is the
mecatlhalf. Making it fire under a specific host also needs that host to registermecatlas a known agent so its wrapper exports the agent identity the hook script cross-checks. For Superset that is five touch points in its own repo (agent-setup-targets.ts,agent-setup.ts,builtin-terminal-agents.ts, the wrapper, and the hook-matrix CI script). Until then this code is dormant and harmless.Two unrelated pre-existing failures were observed while verifying and are not from this change (both reproduce with the change stashed):
deploy/helm/mecak8sTestMecak8sHelmChart_DeployCheckProductionFixtureRuntimeAndSpreadresolves--session-lease-k8s-namespacefrom the ambient kubeconfig (toolhive-system) instead ofdefault.internal/appflaked once under full-suite load with a TLS handshake error, and passes in isolation with-race.