Skip to content

feat(mecatui): emit agent lifecycle hooks to host editors - #1697

Open
aponcedeleonch wants to merge 1 commit into
mainfrom
i-would-like-to-integrate-meca
Open

aponcedeleonch wants to merge 1 commit into
mainfrom
i-would-like-to-integrate-meca

Conversation

@aponcedeleonch

Copy link
Copy Markdown
Member

What

mecatui now 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:

Anthropic Claude Code OpenAI Codex
Field hook_event_name hook_event_name (identical)
Delivery one JSON object on stdin one JSON object on stdin
Common fields session_id, transcript_path, cwd session_id, transcript_path, cwd
Events SessionStart, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, Stop, StopFailure, SessionEnd, SubagentStart/Stop same vocabulary
Config event -> matcher group -> handler (hooks.json) same three-level nesting

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 mecatui emits this itself

mecatl has no hook surface that carries session lifecycle. Its only hooks are the per-tool-call PreToolUse/PostToolUse permission gate (engine/governance), so neither integration shape a host can write into applies:

  • no lifecycle hook config file to write into, and
  • no plugin API for a host to load a listener into.

mecatui already consumes the session event stream to render, so it emits the lifecycle directly.

Where it hooks in

Three reducer points, each nil-safe:

Lifecycle Reducer site Emitted event
Agent began work ui/update.go beginTurnEvent UserPromptSubmit (busy)
Blocked on a human ui/approval.go applyPermissionAsk PermissionRequest
Run reached a terminal ui/update.go applyResult + 3 transport-death paths Stop / StopFailure

Design decisions worth reviewing

  • Host discovery is isolated. superset_host.go holds 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.
  • Inert by default. With no supported host, New returns nil, 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.
  • 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 and could land the terminal before the busy signal. Caught by a test.
  • Deliveries are cancel-detached and separately bounded. The terminal event fires exactly as the run's context is torn down, so honoring that context would abort the hook before it could report completion. Same rationale as the server's cancel-detached durable append. The timeout still prevents a wedged hook from wedging the worker.
  • A full queue drops the oldest event instead of blocking. A stalled hook must never back-pressure the agent loop.
  • encoding/json marshals the payload. It carries model- and tool-influenced text, so hand-escaping would be a field-forgery hole. Pinned by a hostile-input test.
  • The auto-retry branch emits no terminal, because the run continues. Emitting there would produce a spurious completion notification mid-run.

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.
  • Live end-to-end against a real host notify.sh: the full UserPromptSubmit -> PermissionRequest -> Stop sequence delivered in order, each accepted 200, with the agent identity intact. Outside a host terminal the notifier is nil.

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. TestEventNamesAreCanonicalSchemaNames and TestPayloadUsesSchemaFieldNames pin the wire contract so a rename fails CI.

Docs

user-docs/mecatui/using-the-tui.md gains a short section. Per user-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 mecatl half. Making it fire under a specific host also needs that host to register mecatl as 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/mecak8s TestMecak8sHelmChart_DeployCheckProductionFixtureRuntimeAndSpread resolves --session-lease-k8s-namespace from the ambient kubeconfig (toolhive-system) instead of default.
  • internal/app flaked once under full-suite load with a TLS handshake error, and passes in isolation with -race.

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