feat(mecatui): emit agent lifecycle hooks to host editors - #1697
Open
aponcedeleonch wants to merge 1 commit into
Open
aponcedeleonch wants to merge 1 commit into
aponcedeleonch wants to merge 1 commit into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.