Help us shape the future of Go! Take the 2026 Go Developer Survey

agenthook

package
v0.0.50 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 23, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

Documentation

Overview

Package agenthook emits mecatui's session lifecycle as AGENT LIFECYCLE HOOK events, in the cross-vendor hook schema that coding-agent host tools consume.

The schema is a de-facto standard, not one vendor's convention

Anthropic's Claude Code originated it: a host registers a command hook, and the agent hands that command one JSON object on STDIN carrying `hook_event_name` plus common fields (`session_id`, `transcript_path`, `cwd`), with the lifecycle vocabulary SessionStart / UserPromptSubmit / PreToolUse / PostToolUse / PermissionRequest / Stop / StopFailure / SessionEnd / SessionEnd (see code.claude.com/docs/en/hooks). OpenAI's Codex adopted the SAME shape field-for-field — same `hook_event_name` on stdin, same event names, the same event → matcher-group → handler `hooks.json` nesting — and even exports CLAUDE_PLUGIN_ROOT/CLAUDE_PLUGIN_DATA "for compatibility with existing plugin hooks" (learn.chatgpt.com/docs/hooks). Other agents (Gemini CLI, opencode, Droid, Kimi, Grok, Mastra) expose the same vocabulary with minor spelling variance (camelCase `hookEventName`, Codex's older argv-delivered `notify` callback).

So this package speaks a SHARED contract, which is why it is named for the mechanism (an agent lifecycle hook) rather than for any single host.

Why mecatui emits these itself

Host tools drive their notification/busy chrome off these events, and they reach a vendor's agent through something the host can write into: a hook config file, a wrapper script, or a plugin the agent loads. mecatl has none of those — its only hooks are the per-tool-call PreToolUse/PostToolUse permission gate (engine/governance), which carries no session-lifecycle signal. mecatui already consumes the session.Event stream to render, so it emits the lifecycle directly: turn start, a main-session permission ask, the operator's answer, and the terminal result.

Host binding, and why it is inert by default

The SCHEMA above is vendor-neutral; DISCOVERY of the host's hook command is necessarily host-specific and lives in its own file (superset_host.go today). A host is detected from the environment it injects into the agent's terminal; with no supported host present New returns nil, every method is a no-op, and there is zero behaviour change for an ordinary mecatui run.

Delivery is best-effort: events are queued to one ordered worker, each invocation is separately bounded, failures are swallowed, and nothing here can block or fail the agent run.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type EventType

type EventType string

EventType is a `hook_event_name` value in the shared agent-hook schema. These are the CANONICAL cross-vendor spellings (identical in Claude Code and Codex), deliberately preferred over any host's normalized aliases: a host that collapses them (Superset maps UserPromptSubmit→Start server-side, for example) understands the canonical name, while a host-specific alias would not travel.

const (
	// EventPromptSubmit marks the agent beginning work on a turn — the busy
	// signal. Canonical per-turn start event in both vendors' schemas.
	EventPromptSubmit EventType = "UserPromptSubmit"
	// EventStop marks the agent finishing a turn cleanly: the completion
	// notification, and back to idle.
	EventStop EventType = "Stop"
	// EventStopFailure marks a turn that ended in an error terminal. Hosts route
	// its preview from the error fields rather than the last assistant message.
	EventStopFailure EventType = "StopFailure"
	// EventPermissionRequest marks the agent blocked waiting on a human
	// approval — the "needs attention" notification.
	EventPermissionRequest EventType = "PermissionRequest"
	// EventPermissionResult marks a human approval as answered and returns the
	// host's status to working while the same run continues. Kimi and Superset's
	// shared-hook normalizer use this spelling; other hosts may map the semantic
	// transition differently when another binding is added.
	EventPermissionResult EventType = "PermissionResult"
)

type Notifier

type Notifier struct {
	// contains filtered or unexported fields
}

Notifier emits lifecycle hook events to a host's hook command. A nil *Notifier is valid and every method is a no-op — that is the "no supported host" state, so callers hold a *Notifier and never branch on it.

func New

func New(env []string) *Notifier

New returns a Notifier bound to whichever supported host tool this process is running under, or nil when there is none (the ordinary standalone mecatui run — every method on a nil Notifier is a no-op). env is the process environment as name=value entries (os.Environ() in production); it is passed in so tests need not mutate global state.

func (*Notifier) Close

func (n *Notifier) Close(ctx context.Context)

Close settles this notifier: it stops accepting events, then waits — bounded by ctx — for the worker to drain what is already queued and exit. If that bound expires it REVOKES the generation instead of merely walking away.

This is the GENERATION BOUNDARY, and it is why delivery being "best-effort" is not on its own enough. mecatui can restart its whole run in-process (a /connect restart re-enters runWithOptions), which builds a FRESH notifier. Without an owned shutdown the previous generation's worker keeps running, so a slow hook command could deliver generation N's queued Stop AFTER generation N+1's UserPromptSubmit — marking the host idle during a live run. Dropping a late event is acceptable; delivering it out of order across generations is not.

Note that setting closed and closing the queue is NOT sufficient for that: it stops new enqueues, while the worker's `range` goes on delivering the whole existing backlog, each event with a fresh full timeout. So a timed-out Close additionally revokes (see worker), which cancels the delivery in flight and discards everything still queued, and only then joins.

The guarantee this gives, stated exactly: once Close returns, no delivery from this generation can START. The clean path also leaves nothing running; on the revoked path the only residual is the single invocation that was already in flight and has been cancelled, which the bounded join normally collects too.

Close is idempotent and safe on a nil *Notifier or one that never enqueued. After it returns, every method is a no-op.

func (*Notifier) PermissionRequest

func (n *Notifier) PermissionRequest(_ context.Context, sessionID, message string)

PermissionRequest emits a PermissionRequest event. It does NOT touch the running flag: the turn is still active (paused on a human), so the eventual Stop must still fire. The caller is responsible for filtering child/subagent asks — a host drives terminal-level status from the main loop only (the same reason the schema carries `agent_id`/`agent_type` on subagent events).

The context is accepted for the caller's interface but deliberately UNUSED (see Start).

func (*Notifier) PermissionResult

func (n *Notifier) PermissionResult(_ context.Context, sessionID string)

PermissionResult emits the matching approval-answer transition. It leaves the running flag set because the same run resumes and must still emit its eventual Stop. Hosts use this event to clear their waiting notification/status and show the agent as working again.

func (*Notifier) Start

func (n *Notifier) Start(_ context.Context, sessionID string)

Start emits the per-turn busy signal (UserPromptSubmit) on an idle→running transition. Repeated Starts within one active period (e.g. one per turn) are collapsed to a single event: a host wants one busy signal per active period, not one per turn. sessionID is the mecatl session id, forwarded on the schema's `session_id` field so a host can bind native resume.

The context is accepted for the caller's interface but deliberately UNUSED: deliveries are cancel-detached and separately bounded (see enqueue).

func (*Notifier) Stop

func (n *Notifier) Stop(_ context.Context, sessionID string, failed bool, message string)

Stop emits the terminal event exactly once per active period, closing the busy period a Start opened. failed selects StopFailure over Stop (an error terminal); message is an optional preview (the terminal error or last assistant text) forwarded so the host's notification can show it. A Stop with no preceding Start is a no-op (no busy period to close — e.g. a terminal replayed on reconnect).

The context is accepted for the caller's interface but deliberately UNUSED (see Start).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL