From bd7d1e5d5c136f28c9f6eb4e9aa98cf58ab85d7b Mon Sep 17 00:00:00 2001 From: Alejandro Ponce Date: Fri, 18 Sep 2026 12:04:20 +0300 Subject: [PATCH] feat(mecatui): emit agent lifecycle hooks to host editors 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) --- cmd/mecatui/agenthook/hook.go | 306 ++++++++++++++++++ cmd/mecatui/agenthook/hook_test.go | 401 ++++++++++++++++++++++++ cmd/mecatui/agenthook/runner_test.go | 170 ++++++++++ cmd/mecatui/agenthook/superset_host.go | 89 ++++++ cmd/mecatui/main.go | 14 + cmd/mecatui/ui/agenthook_notify_test.go | 197 ++++++++++++ cmd/mecatui/ui/approval.go | 8 + cmd/mecatui/ui/model.go | 30 ++ cmd/mecatui/ui/update.go | 52 +++ user-docs/mecatui/using-the-tui.md | 15 + 10 files changed, 1282 insertions(+) create mode 100644 cmd/mecatui/agenthook/hook.go create mode 100644 cmd/mecatui/agenthook/hook_test.go create mode 100644 cmd/mecatui/agenthook/runner_test.go create mode 100644 cmd/mecatui/agenthook/superset_host.go create mode 100644 cmd/mecatui/ui/agenthook_notify_test.go diff --git a/cmd/mecatui/agenthook/hook.go b/cmd/mecatui/agenthook/hook.go new file mode 100644 index 0000000000..fbfdb156d8 --- /dev/null +++ b/cmd/mecatui/agenthook/hook.go @@ -0,0 +1,306 @@ +// 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, 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. +package agenthook + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "strings" + "sync" + "time" +) + +// 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. +type EventType string + +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" +) + +// runnerFunc dispatches one already-built hook invocation. It is the single seam +// tests replace to stay offline (no real hook command, no host service): the +// production runner shells the host's hook command, the test runner records it. +// extraEnv carries whatever host-specific entries the detected host requires +// (see the per-host files); the generic core never names them. +type runnerFunc func(ctx context.Context, script string, extraEnv []string, payload string) + +// 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. +type Notifier struct { + script string // absolute path to the host's hook command + extraEnv []string // host-specific env entries for each invocation + run runnerFunc // dispatch seam + timeout time.Duration // per-call wall-clock bound + + mu sync.Mutex + running bool // dedupe: busy signal once per turn, terminal once per turn + queue chan dispatch // ordered single-worker delivery; lazily started +} + +// dispatch is one queued hook invocation. Ordering matters (the busy signal must +// reach the host before the terminal), so deliveries flow through one worker +// goroutine rather than a goroutine each. +type dispatch struct { + ev EventType + sessionID string + message string +} + +// queueDepth bounds the pending-delivery buffer. A full buffer drops the oldest +// pending event rather than blocking the UI reducer — lifecycle notifications +// are best-effort and a stall must never back-pressure the agent loop. In +// practice the depth is tiny (a handful of transitions per run), so the cap is +// only a safety valve against a wedged hook command. +const queueDepth = 64 + +// agentID identifies mecatl to the host. Hosts that multiplex several agents +// cross-check it against the identity their wrapper exported for the launched +// process and DROP a mismatch (a foreign agent replaying our hook config, or our +// binary invoked from another agent's tool call). Kept as one constant so a +// host-side registration and this emitter cannot drift. +const agentID = "mecatl" + +// newNotifier builds a Notifier for a resolved host hook command plus the +// host-specific env entries that command expects. Host detection lives in the +// per-host files (see superset_host.go). +func newNotifier(script string, extraEnv []string) *Notifier { + return &Notifier{ + script: script, + extraEnv: extraEnv, + run: defaultRunner, + timeout: 5 * time.Second, + } +} + +// 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 (n *Notifier) Start(_ context.Context, sessionID string) { + if n == nil { + return + } + n.mu.Lock() + if n.running { + n.mu.Unlock() + return + } + n.running = true + n.mu.Unlock() + n.enqueue(EventPromptSubmit, sessionID, "") +} + +// 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). +func (n *Notifier) Stop(_ context.Context, sessionID string, failed bool, message string) { + if n == nil { + return + } + n.mu.Lock() + if !n.running { + n.mu.Unlock() + return + } + n.running = false + n.mu.Unlock() + ev := EventStop + if failed { + ev = EventStopFailure + } + n.enqueue(ev, sessionID, message) +} + +// 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 (n *Notifier) PermissionRequest(_ context.Context, sessionID, message string) { + if n == nil { + return + } + n.enqueue(EventPermissionRequest, sessionID, message) +} + +// enqueue hands one event to the ordered delivery worker (started lazily on the +// first event). It never blocks the caller: a full buffer drops the OLDEST +// pending event to make room, because a stalled hook command must not +// back-pressure the UI reducer. +// +// It deliberately takes NO context. Each delivery is DETACHED from the caller's +// per-run context and bounded by n.timeout instead (see worker): the terminal +// event fires exactly as the run's context is torn down, so honouring that +// context would abort the hook before it could deliver the completion — the same +// cancel-detached rationale as the server's durable event append. The timeout +// still guarantees a wedged hook command cannot wedge the worker forever. +func (n *Notifier) enqueue(ev EventType, sessionID, message string) { + n.mu.Lock() + if n.queue == nil { + n.queue = make(chan dispatch, queueDepth) + //nolint:contextcheck // deliberate: see the doc comment above — a + // lifecycle notification must outlive the run context that triggered it. + go n.worker(n.queue) + } + q := n.queue + n.mu.Unlock() + + d := dispatch{ev: ev, sessionID: sessionID, message: message} + for { + select { + case q <- d: + return + default: + // Buffer full: drop the oldest, then retry. Best-effort delivery. + select { + case <-q: + default: + } + } + } +} + +// worker delivers queued events in arrival order, one at a time. +func (n *Notifier) worker(q chan dispatch) { + for d := range q { + payload := buildPayload(d.ev, d.sessionID, d.message) + ctx, cancel := context.WithTimeout(context.Background(), n.timeout) + n.run(ctx, n.script, n.extraEnv, payload) + cancel() + } +} + +// defaultRunner shells the host's hook command with the JSON payload on STDIN — +// the delivery the shared schema specifies for a command hook in both Claude +// Code and Codex. (Codex's older `notify` callback passed the blob as argv +// instead; stdin is the current contract and avoids any argv quoting concern.) +// It inherits the process environment so the host's own terminal markers remain +// visible to the hook, plus whatever host-specific entries the host requires. +// Output is discarded and any error is swallowed — best-effort. +func defaultRunner(ctx context.Context, script string, extraEnv []string, payload string) { + cmd := exec.CommandContext(ctx, script) + cmd.Env = append(os.Environ(), extraEnv...) + cmd.Stdin = strings.NewReader(payload) + cmd.Stdout = nil + cmd.Stderr = nil + _ = cmd.Run() +} + +// notifyPayload is the shared agent-hook input shape: `hook_event_name` plus the +// schema's common fields. The event name is ALWAYS present (a host drops an +// event with no type rather than guessing a terminal); session_id and message +// are omitted when empty. Marshalled with encoding/json so a message with +// quotes/newlines/control bytes cannot break the payload or forge extra fields +// (the injection boundary — never hand-escape here). +type notifyPayload struct { + Event EventType `json:"hook_event_name"` + SessionID string `json:"session_id,omitempty"` + Message string `json:"message,omitempty"` +} + +func buildPayload(ev EventType, sessionID, message string) string { + p := notifyPayload{ + Event: ev, + SessionID: strings.TrimSpace(sessionID), + Message: clip(strings.TrimSpace(message), maxMessageBytes), + } + b, err := json.Marshal(p) + if err != nil { + // Marshalling a struct of strings cannot fail; fall back to the + // minimal well-formed event so a bug here still yields valid JSON. + return `{"hook_event_name":"` + string(ev) + `"}` + } + return string(b) +} + +// maxMessageBytes bounds the preview forwarded to the host (Superset's opencode +// plugin slices to the same 4000). Hosts clamp again; this keeps the payload +// handed to a shell small. +const maxMessageBytes = 4000 + +// clip trims s to at most limit bytes on a UTF-8 rune boundary so the JSON +// string stays valid. +func clip(s string, limit int) string { + if len(s) <= limit { + return s + } + cut := limit + for cut > 0 && !utf8Start(s[cut]) { + cut-- + } + return s[:cut] +} + +func utf8Start(b byte) bool { return b&0xC0 != 0x80 } diff --git a/cmd/mecatui/agenthook/hook_test.go b/cmd/mecatui/agenthook/hook_test.go new file mode 100644 index 0000000000..2f03b169a6 --- /dev/null +++ b/cmd/mecatui/agenthook/hook_test.go @@ -0,0 +1,401 @@ +package agenthook + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +// recorder is the offline dispatch seam: it captures every notify.sh invocation +// instead of shelling one, so tests never touch a real script or the network. +type recorder struct { + mu sync.Mutex + calls []call + done chan struct{} +} + +type call struct { + script string + extraEnv []string + payload string +} + +func newRecorder(expect int) *recorder { + return &recorder{done: make(chan struct{}, expect+1)} +} + +func (r *recorder) run(_ context.Context, script string, extraEnv []string, payload string) { + r.mu.Lock() + r.calls = append(r.calls, call{script, extraEnv, payload}) + r.mu.Unlock() + r.done <- struct{}{} +} + +// wait blocks until n dispatches have landed (they run on their own goroutines) +// or the deadline fires. +func (r *recorder) wait(t *testing.T, n int) { + t.Helper() + deadline := time.After(2 * time.Second) + for i := 0; i < n; i++ { + select { + case <-r.done: + case <-deadline: + t.Fatalf("timed out waiting for dispatch %d/%d", i+1, n) + } + } +} + +func (r *recorder) snapshot() []call { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]call, len(r.calls)) + copy(out, r.calls) + return out +} + +// newTestNotifier builds a Notifier wired to the recorder, bypassing New's +// environment gate so the dispatch/dedup logic is exercised directly. +func newTestNotifier(r *recorder) *Notifier { + return &Notifier{ + script: "notify.sh", + extraEnv: []string{supersetHarnessVar + "=" + agentID}, + run: r.run, + timeout: time.Second, + } +} + +func decode(t *testing.T, payload string) notifyPayload { + t.Helper() + var p notifyPayload + if err := json.Unmarshal([]byte(payload), &p); err != nil { + t.Fatalf("payload is not valid JSON (%v): %s", err, payload) + } + return p +} + +func TestNilNotifierIsNoOp(t *testing.T) { + // The "not in a Superset terminal" state: every method must be inert, so a + // caller can hold a nil *Notifier and never branch on it. + defer func() { + if r := recover(); r != nil { + t.Fatalf("a nil Notifier must be inert, got panic: %v", r) + } + }() + var n *Notifier + n.Start(context.Background(), "s1") + n.PermissionRequest(context.Background(), "s1", "why") + n.Stop(context.Background(), "s1", false, "done") +} + +func TestStartStopHappyPath(t *testing.T) { + r := newRecorder(2) + n := newTestNotifier(r) + n.Start(context.Background(), "sess-1") + n.Stop(context.Background(), "sess-1", false, "all good") + r.wait(t, 2) + + calls := r.snapshot() + if len(calls) != 2 { + t.Fatalf("want 2 calls, got %d", len(calls)) + } + wantEnv := supersetHarnessVar + "=" + agentID + if len(calls[0].extraEnv) != 1 || calls[0].extraEnv[0] != wantEnv { + t.Errorf("host env = %v, want [%s]", calls[0].extraEnv, wantEnv) + } + start := decode(t, calls[0].payload) + if start.Event != EventPromptSubmit || start.SessionID != "sess-1" { + t.Errorf("start payload = %+v", start) + } + stop := decode(t, calls[1].payload) + if stop.Event != EventStop || stop.Message != "all good" { + t.Errorf("stop payload = %+v", stop) + } +} + +func TestStartDedupedWithinRun(t *testing.T) { + r := newRecorder(2) + n := newTestNotifier(r) + n.Start(context.Background(), "s") + n.Start(context.Background(), "s") // second Start within the same run: dropped + n.Stop(context.Background(), "s", false, "") + r.wait(t, 2) // exactly Start + Stop + + calls := r.snapshot() + if len(calls) != 2 { + t.Fatalf("want 2 calls (one Start, one Stop), got %d: %+v", len(calls), calls) + } + if decode(t, calls[0].payload).Event != EventPromptSubmit { + t.Errorf("first call not Start: %s", calls[0].payload) + } + if decode(t, calls[1].payload).Event != EventStop { + t.Errorf("second call not Stop: %s", calls[1].payload) + } +} + +func TestStopWithoutStartIsDropped(t *testing.T) { + r := newRecorder(1) + n := newTestNotifier(r) + // No Start: a terminal replayed on reconnect has no busy period to close. + n.Stop(context.Background(), "s", false, "done") + select { + case <-r.done: + t.Fatalf("Stop without a preceding Start should not dispatch: %+v", r.snapshot()) + case <-time.After(200 * time.Millisecond): + } +} + +func TestStopOnlyOncePerRun(t *testing.T) { + r := newRecorder(2) + n := newTestNotifier(r) + n.Start(context.Background(), "s") + n.Stop(context.Background(), "s", false, "") + n.Stop(context.Background(), "s", true, "second stop") // dropped + r.wait(t, 2) + calls := r.snapshot() + if len(calls) != 2 { + t.Fatalf("want 2 calls, got %d: %+v", len(calls), calls) + } + if decode(t, calls[1].payload).Event != EventStop { + t.Errorf("terminal should be the first Stop, not the second: %s", calls[1].payload) + } +} + +func TestFailedTerminal(t *testing.T) { + r := newRecorder(2) + n := newTestNotifier(r) + n.Start(context.Background(), "s") + n.Stop(context.Background(), "s", true, "boom") + r.wait(t, 2) + if got := decode(t, r.snapshot()[1].payload).Event; got != EventStopFailure { + t.Errorf("failed terminal event = %q, want %q", got, EventStopFailure) + } +} + +func TestStartStopStartAcrossRuns(t *testing.T) { + r := newRecorder(4) + n := newTestNotifier(r) + n.Start(context.Background(), "s") + n.Stop(context.Background(), "s", false, "") + n.Start(context.Background(), "s") // new run: Start fires again + n.Stop(context.Background(), "s", false, "") + r.wait(t, 4) + if len(r.snapshot()) != 4 { + t.Fatalf("want 4 calls across two runs, got %d", len(r.snapshot())) + } +} + +func TestPermissionRequestDoesNotAffectRunState(t *testing.T) { + r := newRecorder(3) + n := newTestNotifier(r) + n.Start(context.Background(), "s") + n.PermissionRequest(context.Background(), "s", "approve Shell?") + n.Stop(context.Background(), "s", false, "") // Stop must still fire + r.wait(t, 3) + + calls := r.snapshot() + events := []EventType{ + decode(t, calls[0].payload).Event, + decode(t, calls[1].payload).Event, + decode(t, calls[2].payload).Event, + } + want := []EventType{EventPromptSubmit, EventPermissionRequest, EventStop} + for i := range want { + if events[i] != want[i] { + t.Errorf("event[%d] = %q, want %q", i, events[i], want[i]) + } + } +} + +func TestPayloadEscapesHostileMessage(t *testing.T) { + r := newRecorder(2) + n := newTestNotifier(r) + // Embedded quotes/braces plus a control byte: a hand-escaper would either + // forge extra fields or emit invalid JSON. The message is intentionally + // trimmed (leading/trailing space) before marshalling, so assert the + // security property (no forgery, valid JSON, embedded delimiters preserved) + // rather than a byte-identical round-trip. + hostile := "hi\",\"hook_event_name\":\"Stop\",\"x\":\"y" + n.Start(context.Background(), "s") + n.Stop(context.Background(), "s", false, hostile) + r.wait(t, 2) + + stop := r.snapshot()[1].payload + p := decode(t, stop) // must still be valid JSON (no field forgery) + if p.Event != EventStop { + t.Errorf("hostile message forged the event: %+v (%s)", p, stop) + } + if p.Message != hostile { + t.Errorf("message round-trip changed: %q", p.Message) + } +} + +// TestEventNamesAreCanonicalSchemaNames pins the emitted vocabulary to the +// CROSS-VENDOR hook schema (identical in Anthropic's Claude Code and OpenAI's +// Codex). These strings are a wire contract with every host tool that consumes +// agent lifecycle hooks — renaming one to a host's normalized alias (Superset +// collapses UserPromptSubmit→Start server-side, for example) would silently stop +// other hosts from recognising the event, and a host that cannot map the name +// drops it rather than guessing. Change these only alongside the vendors. +func TestEventNamesAreCanonicalSchemaNames(t *testing.T) { + for _, tc := range []struct { + got EventType + want string + }{ + {EventPromptSubmit, "UserPromptSubmit"}, + {EventStop, "Stop"}, + {EventStopFailure, "StopFailure"}, + {EventPermissionRequest, "PermissionRequest"}, + } { + if string(tc.got) != tc.want { + t.Errorf("event name = %q, want the canonical %q", tc.got, tc.want) + } + } +} + +// TestPayloadUsesSchemaFieldNames pins the JSON field names to the schema's +// common input fields, which every host parses. +func TestPayloadUsesSchemaFieldNames(t *testing.T) { + var raw map[string]any + if err := json.Unmarshal([]byte(buildPayload(EventStop, "s1", "m")), &raw); err != nil { + t.Fatal(err) + } + for _, field := range []string{"hook_event_name", "session_id", "message"} { + if _, ok := raw[field]; !ok { + t.Errorf("payload is missing the schema field %q: %v", field, raw) + } + } +} + +func TestPayloadOmitsEmptyFields(t *testing.T) { + got := buildPayload(EventPromptSubmit, "", "") + if got != `{"hook_event_name":"UserPromptSubmit"}` { + t.Errorf("empty session/message should omit both: %s", got) + } +} + +func TestClipBoundsMessage(t *testing.T) { + long := make([]byte, maxMessageBytes+500) + for i := range long { + long[i] = 'a' + } + p := decode(t, buildPayload(EventStop, "s", string(long))) + if len(p.Message) > maxMessageBytes { + t.Errorf("message not clipped: %d bytes", len(p.Message)) + } +} + +func TestClipKeepsUTF8Valid(t *testing.T) { + // A multi-byte rune straddling the cut must not produce a broken string. + var b []byte + for len(b) < maxMessageBytes+3 { + b = append(b, "€"...) // 3 bytes each + } + out := clip(string(b), maxMessageBytes) + if len(out) > maxMessageBytes { + t.Fatalf("clip exceeded max: %d", len(out)) + } + // Marshalling must succeed and round-trip (no invalid UTF-8 in the tail). + p := decode(t, buildPayload(EventStop, "s", out)) + if p.Message != out { + t.Errorf("clipped message did not round-trip") + } +} + +func TestNewReturnsNilOutsideSupersetTerminal(t *testing.T) { + if n := New([]string{"HOME=/tmp", "PATH=/usr/bin"}); n != nil { + t.Errorf("New should be nil without SUPERSET_TERMINAL_ID, got %+v", n) + } +} + +func TestNewReturnsNilWhenScriptMissing(t *testing.T) { + dir := t.TempDir() // no hooks/notify.sh planted + env := []string{ + "SUPERSET_TERMINAL_ID=term-1", + "SUPERSET_HOME_DIR=" + dir, + } + if n := New(env); n != nil { + t.Errorf("New should be nil when notify.sh is absent, got %+v", n) + } +} + +func TestNewReturnsNilWhenScriptNotExecutable(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "hooks", "notify.sh") + if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(script, []byte("#!/bin/sh\n"), 0o644); err != nil { // not +x + t.Fatal(err) + } + if n := New(env(dir)); n != nil { + t.Errorf("New should be nil for a non-executable notify.sh, got %+v", n) + } +} + +func TestNewResolvesExecutableScript(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "hooks", "notify.sh") + if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(script, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + n := New(env(dir)) + if n == nil { + t.Fatal("New should resolve an executable notify.sh") + } + if n.script != script { + t.Errorf("script = %q, want %q", n.script, script) + } +} + +func TestNewFallsBackToHomeSupersetDir(t *testing.T) { + home := t.TempDir() + script := filepath.Join(home, ".superset", "hooks", "notify.sh") + if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(script, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + // No SUPERSET_HOME_DIR: must fall back to $HOME/.superset like notify.sh. + n := New([]string{"SUPERSET_TERMINAL_ID=t", "HOME=" + home}) + if n == nil { + t.Fatal("New should fall back to $HOME/.superset") + } + if n.script != script { + t.Errorf("script = %q, want %q", n.script, script) + } +} + +func env(homeDir string) []string { + return []string{ + "SUPERSET_TERMINAL_ID=term-1", + "SUPERSET_HOME_DIR=" + homeDir, + } +} + +func TestConcurrentStartsFireExactlyOne(t *testing.T) { + r := newRecorder(16) + n := newTestNotifier(r) + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + wg.Add(1) + go func() { + defer wg.Done() + n.Start(context.Background(), "s") + }() + } + wg.Wait() + // Exactly one Start should have dispatched; give stragglers a moment. + r.wait(t, 1) + time.Sleep(100 * time.Millisecond) + if got := len(r.snapshot()); got != 1 { + t.Errorf("concurrent Starts dispatched %d times, want 1", got) + } +} diff --git a/cmd/mecatui/agenthook/runner_test.go b/cmd/mecatui/agenthook/runner_test.go new file mode 100644 index 0000000000..922968b450 --- /dev/null +++ b/cmd/mecatui/agenthook/runner_test.go @@ -0,0 +1,170 @@ +package agenthook + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestDefaultRunnerDeliversPayloadAndHarness exercises the REAL shell-out +// (defaultRunner), which every other test replaces. It proves the two contract +// points Superset's notify.sh actually reads: the JSON payload arrives on STDIN, +// and SUPERSET_HOOK_HARNESS is exported as the mecatl agent id. A drift in +// either would make notify.sh silently drop our events (no event type => exit 0; +// a foreign harness => exit 0), which is exactly the failure that is invisible +// in production. +// +// Offline: the "notify.sh" here is a local recording script; no host-service and +// no network are involved. +func TestDefaultRunnerDeliversPayloadAndHarness(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "recorded.txt") + script := filepath.Join(dir, "notify.sh") + // Record the harness env var and the stdin payload, the two things notify.sh + // consumes. + body := "#!/bin/sh\n" + + "printf 'harness=%s\\n' \"$SUPERSET_HOOK_HARNESS\" > " + out + "\n" + + "cat >> " + out + "\n" + if err := os.WriteFile(script, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + + payload := buildPayload(EventStop, "sess-42", "finished cleanly") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + defaultRunner(ctx, script, []string{supersetHarnessVar + "=" + agentID}, payload) + + recorded, err := os.ReadFile(out) + if err != nil { + t.Fatalf("script did not run (nothing recorded): %v", err) + } + got := string(recorded) + + wantHarness := "harness=" + agentID + "\n" + if !strings.HasPrefix(got, wantHarness) { + t.Errorf("%s not exported as %q; recorded:\n%s", supersetHarnessVar, agentID, got) + } + stdin := strings.TrimPrefix(got, wantHarness) + var p notifyPayload + if err := json.Unmarshal([]byte(stdin), &p); err != nil { + t.Fatalf("stdin payload is not the JSON notify.sh expects (%v): %q", err, stdin) + } + if p.Event != EventStop || p.SessionID != "sess-42" || p.Message != "finished cleanly" { + t.Errorf("payload round-trip mismatch: %+v", p) + } +} + +// TestDefaultRunnerSurvivesFailingScript proves delivery is best-effort: a +// notify.sh that exits non-zero must never surface as a panic or a blocked +// caller (the agent run must be unaffected by the notification channel). +func TestDefaultRunnerSurvivesFailingScript(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "notify.sh") + if err := os.WriteFile(script, []byte("#!/bin/sh\nexit 7\n"), 0o755); err != nil { + t.Fatal(err) + } + defaultRunner(context.Background(), script, nil, buildPayload(EventPromptSubmit, "s", "")) + // Reaching here without panic is the assertion. +} + +// TestDefaultRunnerBoundedByContext proves a wedged notify.sh cannot hang the +// delivery worker forever — the context deadline terminates it. +func TestDefaultRunnerBoundedByContext(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "notify.sh") + if err := os.WriteFile(script, []byte("#!/bin/sh\nsleep 30\n"), 0o755); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + + done := make(chan struct{}) + go func() { + defaultRunner(ctx, script, nil, buildPayload(EventStop, "s", "")) + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("defaultRunner did not honour the context deadline") + } +} + +// TestEndToEndThroughNotifierToScript drives the full public path — New's +// environment gate, the ordered worker, and the real shell-out — against a +// recording script planted in a synthetic SUPERSET_HOME_DIR, proving the +// assembled pipeline delivers Start then Stop in order. +func TestEndToEndThroughNotifierToScript(t *testing.T) { + home := t.TempDir() + out := filepath.Join(home, "events.txt") + script := filepath.Join(home, "hooks", "notify.sh") + if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil { + t.Fatal(err) + } + // Append one line per event so ordering is observable. + body := "#!/bin/sh\ncat >> " + out + "\nprintf '\\n' >> " + out + "\n" + if err := os.WriteFile(script, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + + n := New([]string{ + "SUPERSET_TERMINAL_ID=term-e2e", + "SUPERSET_HOME_DIR=" + home, + }) + if n == nil { + t.Fatal("New should build a Notifier for a planted executable notify.sh") + } + + ctx := context.Background() + n.Start(ctx, "sess-e2e") + n.Stop(ctx, "sess-e2e", false, "done") + + // The worker delivers asynchronously; poll for both lines. + var lines []string + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + b, err := os.ReadFile(out) + if err == nil { + lines = nonEmptyLines(string(b)) + if len(lines) >= 2 { + break + } + } + time.Sleep(20 * time.Millisecond) + } + if len(lines) < 2 { + t.Fatalf("want 2 delivered events, got %d: %v", len(lines), lines) + } + + first := decodePayload(t, lines[0]) + second := decodePayload(t, lines[1]) + if first.Event != EventPromptSubmit { + t.Errorf("first delivered event = %q, want Start", first.Event) + } + if second.Event != EventStop || second.Message != "done" { + t.Errorf("second delivered event = %+v, want Stop/done", second) + } +} + +func nonEmptyLines(s string) []string { + var out []string + for _, l := range strings.Split(s, "\n") { + if strings.TrimSpace(l) != "" { + out = append(out, l) + } + } + return out +} + +func decodePayload(t *testing.T, line string) notifyPayload { + t.Helper() + var p notifyPayload + if err := json.Unmarshal([]byte(line), &p); err != nil { + t.Fatalf("delivered line is not valid JSON (%v): %q", err, line) + } + return p +} diff --git a/cmd/mecatui/agenthook/superset_host.go b/cmd/mecatui/agenthook/superset_host.go new file mode 100644 index 0000000000..a27de97f1b --- /dev/null +++ b/cmd/mecatui/agenthook/superset_host.go @@ -0,0 +1,89 @@ +package agenthook + +import ( + "os" + "path/filepath" + "strings" +) + +// This file holds the ONE host-specific half of the package: discovering where a +// given host tool keeps its hook command, and which environment entries that +// command expects. The event schema in hook.go is vendor-neutral and shared; a +// second supported host becomes a sibling detect function here, not a new +// package and not a change to the schema. + +// 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 New(env []string) *Notifier { + return detectSuperset(envMap(env)) +} + +// Superset host. +// +// Superset (the editor) funnels every integrated agent's lifecycle through one +// Superset-owned script, ~/.superset/hooks/notify.sh, which normalizes the event +// and POSTs it to the host-service that raises the OS notification and drives the +// pane's busy/idle chrome. That script is a NORMALIZER over the shared schema: +// it reads `hook_event_name` (or camelCase `hookEventName`, or Codex's older +// `type`) from stdin or argv[1], and collapses the vendor vocabulary into its +// own Start | PermissionRequest | Stop triple — which is why emitting the +// canonical cross-vendor names from hook.go is correct here. +const ( + // supersetTerminalIDVar is set only inside a Superset v2 terminal. It is the + // participation gate every notify.sh caller applies (the script itself exits + // 0 immediately when the SUPERSET_* markers are absent), so honouring it + // here keeps a mecatui launched outside Superset completely inert. + supersetTerminalIDVar = "SUPERSET_TERMINAL_ID" + // supersetHomeVar locates the Superset state directory holding hooks/. + supersetHomeVar = "SUPERSET_HOME_DIR" + // supersetHarnessVar names the agent whose hook config fired. notify.sh + // cross-checks it against the SUPERSET_AGENT_ID its wrapper exported and + // DROPS a mismatch, so a foreign agent replaying our config (or our binary + // invoked from another agent's tool call) cannot relabel the terminal. + supersetHarnessVar = "SUPERSET_HOOK_HARNESS" +) + +// detectSuperset resolves the Superset notify.sh for this process, or nil when +// this is not a Superset terminal or the script is missing/non-executable +// (yielding nil rather than a Notifier that would shell a dead path every turn). +// The home-directory fallback mirrors notify.sh's own ${SUPERSET_HOME_DIR:-$HOME/.superset}. +func detectSuperset(env map[string]string) *Notifier { + if strings.TrimSpace(env[supersetTerminalIDVar]) == "" { + return nil // not a Superset v2 terminal — inert. + } + home := strings.TrimSpace(env[supersetHomeVar]) + if home == "" { + if h := strings.TrimSpace(env["HOME"]); h != "" { + home = filepath.Join(h, ".superset") + } + } + if home == "" { + return nil + } + script := filepath.Join(home, "hooks", "notify.sh") + if !isExecutableFile(script) { + return nil + } + return newNotifier(script, []string{supersetHarnessVar + "=" + agentID}) +} + +func envMap(env []string) map[string]string { + m := make(map[string]string, len(env)) + for _, kv := range env { + if i := strings.IndexByte(kv, '='); i > 0 { + m[kv[:i]] = kv[i+1:] + } + } + return m +} + +func isExecutableFile(path string) bool { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return false + } + return info.Mode().Perm()&0o111 != 0 +} diff --git a/cmd/mecatui/main.go b/cmd/mecatui/main.go index ed13628b10..17c67ec412 100644 --- a/cmd/mecatui/main.go +++ b/cmd/mecatui/main.go @@ -37,6 +37,7 @@ import ( "github.com/adrg/xdg" "golang.org/x/term" + "github.com/stacklok/mecatl/cmd/mecatui/agenthook" "github.com/stacklok/mecatl/cmd/mecatui/client" "github.com/stacklok/mecatl/cmd/mecatui/embed" "github.com/stacklok/mecatl/cmd/mecatui/statusline" @@ -294,6 +295,7 @@ func runWithOptions(argv []string, options runOptions) error { connectionMode := resolveConnectionMode(cfg) deps := applyLaunchIntent(cfg, ui.Deps{ Session: &sessionAdapter{cl: cl, mode: cfg.mode, debugTarget: cfg.debugTarget, debugMCP: cfg.debugMCP}, + AgentHook: agentLifecycleHook(), Conv: cl, MCP: cl, Cmds: cl, @@ -668,6 +670,18 @@ func applyLaunchIntent(cfg config, deps ui.Deps) ui.Deps { return deps } +// agentLifecycleHook builds the host-editor agent-lifecycle hook emitter, or +// returns an honestly-nil interface when no supported host is detected (so the +// reducer's nil check reflects the real "no external channel" state instead of a +// typed-nil wrapper). See cmd/mecatui/agenthook. +func agentLifecycleHook() ui.LifecycleNotifier { + n := agenthook.New(os.Environ()) + if n == nil { + return nil + } + return n +} + const defaultDebugPrompt = "Diagnose the bound target session and explain the most likely cause of its reported behavior." func initialPromptForConfig(cfg config) string { diff --git a/cmd/mecatui/ui/agenthook_notify_test.go b/cmd/mecatui/ui/agenthook_notify_test.go new file mode 100644 index 0000000000..88cadf28ad --- /dev/null +++ b/cmd/mecatui/ui/agenthook_notify_test.go @@ -0,0 +1,197 @@ +package ui + +import ( + "context" + "sync" + "testing" + + "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/theme" +) + +// fakeLifecycleNotifier records the lifecycle transitions the reducer emits, so +// the UI wiring can be asserted offline (no real notify.sh, no Superset host). +type fakeLifecycleNotifier struct { + mu sync.Mutex + calls []lifecycleCall +} + +type lifecycleCall struct { + kind string // "start" | "permission" | "stop" + sessionID string + failed bool + message string +} + +func (f *fakeLifecycleNotifier) Start(_ context.Context, sessionID string) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, lifecycleCall{kind: "start", sessionID: sessionID}) +} + +func (f *fakeLifecycleNotifier) PermissionRequest(_ context.Context, sessionID, message string) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, lifecycleCall{kind: "permission", sessionID: sessionID, message: message}) +} + +func (f *fakeLifecycleNotifier) Stop(_ context.Context, sessionID string, failed bool, message string) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, lifecycleCall{kind: "stop", sessionID: sessionID, failed: failed, message: message}) +} + +func (f *fakeLifecycleNotifier) kinds() []string { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]string, len(f.calls)) + for i, c := range f.calls { + out[i] = c.kind + } + return out +} + +func (f *fakeLifecycleNotifier) snapshot() []lifecycleCall { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]lifecycleCall, len(f.calls)) + copy(out, f.calls) + return out +} + +func modelWithNotifier(t *testing.T) (Model, *fakeLifecycleNotifier) { + t.Helper() + fake := &fakeLifecycleNotifier{} + m, _, _ := newTestModel(t, theme.New("aztec", theme.AztecPalette()), func(d *Deps) { d.AgentHook = fake }) + m.sessionID = "sess-super-1" + return m, fake +} + +// TestAgentHookStartFiresOnTurnStart proves the Start transition is wired to the +// real reducer's turn.start handling. +func TestAgentHookStartFiresOnTurnStart(t *testing.T) { + m, fake := modelWithNotifier(t) + applyAll(m, client.TurnStartMsg{Turn: 1}) + if got := fake.kinds(); len(got) != 1 || got[0] != "start" { + t.Fatalf("turn.start should emit exactly one Start, got %v", got) + } + if fake.snapshot()[0].sessionID != "sess-super-1" { + t.Errorf("Start carried wrong session id: %+v", fake.snapshot()[0]) + } +} + +// TestAgentHookPermissionRequestFiresOnMainAsk proves a MAIN-session permission +// ask is mirrored as PermissionRequest. +func TestAgentHookPermissionRequestFiresOnMainAsk(t *testing.T) { + m, fake := modelWithNotifier(t) + m = applyAll(m, + client.TurnStartMsg{Turn: 1}, + client.PermissionAskMsg{AskID: "sess-super-1:1:call-1:r0", Tool: "Shell", Args: `{"command":"ls"}`, Reason: "run ls?"}, + ) + kinds := fake.kinds() + if len(kinds) != 2 || kinds[0] != "start" || kinds[1] != "permission" { + t.Fatalf("want [start permission], got %v", kinds) + } + perm := fake.snapshot()[1] + if perm.message != "run ls?" { + t.Errorf("PermissionRequest should forward the reason, got %q", perm.message) + } + _ = m +} + +// TestAgentHookPermissionRequestSkippedForChildAsk proves a surfaced SUBAGENT ask +// does NOT drive a terminal-level notification (Superset drives status from the +// main loop only). +func TestAgentHookPermissionRequestSkippedForChildAsk(t *testing.T) { + m, fake := modelWithNotifier(t) + // A child ask is prefixed with the CHILD session id, not the live session id. + m = applyAll(m, + client.TurnStartMsg{Turn: 1}, + client.PermissionAskMsg{AskID: "subagent-child-9:1:call-1:r0", Tool: "Shell", Args: `{"command":"ls"}`, Reason: "child wants ls"}, + ) + for _, c := range fake.snapshot() { + if c.kind == "permission" { + t.Fatalf("child ask must not emit PermissionRequest: %+v", fake.snapshot()) + } + } + _ = m +} + +// TestAgentHookStopFiresOnResult proves a clean terminal is mirrored as Stop. +func TestAgentHookStopFiresOnResult(t *testing.T) { + m, fake := modelWithNotifier(t) + applyAll(m, + client.TurnStartMsg{Turn: 1}, + client.ResultMsg{Stop: "end_turn"}, + ) + kinds := fake.kinds() + if len(kinds) != 2 || kinds[0] != "start" || kinds[1] != "stop" { + t.Fatalf("want [start stop], got %v", kinds) + } + if fake.snapshot()[1].failed { + t.Errorf("clean terminal should not be Failed") + } +} + +// TestAgentHookFailedFiresOnErrorResult proves an error terminal is mirrored as a +// Failed Stop with the error preview. +func TestAgentHookFailedFiresOnErrorResult(t *testing.T) { + m, fake := modelWithNotifier(t) + applyAll(m, + client.TurnStartMsg{Turn: 1}, + client.ResultMsg{Stop: stopError, Error: "provider exploded"}, + ) + stop := lastStop(t, fake) + if !stop.failed { + t.Errorf("error terminal should be Failed: %+v", stop) + } + if stop.message != "provider exploded" { + t.Errorf("Failed should forward the error preview, got %q", stop.message) + } +} + +// TestAgentHookFullLifecycleOrder proves the whole Start → PermissionRequest → +// Stop order for a single run through the real reducer. +func TestAgentHookFullLifecycleOrder(t *testing.T) { + m, fake := modelWithNotifier(t) + applyAll(m, + client.TurnStartMsg{Turn: 1}, + client.PermissionAskMsg{AskID: "sess-super-1:1:call-1:r0", Tool: "Shell", Args: `{"command":"ls"}`, Reason: "?"}, + client.ResultMsg{Stop: "end_turn"}, + ) + want := []string{"start", "permission", "stop"} + got := fake.kinds() + if len(got) != len(want) { + t.Fatalf("want %v, got %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("order mismatch: want %v, got %v", want, got) + } + } +} + +// TestAgentHookNilNotifierIsInert proves the default (no Superset terminal) wiring +// makes the reducer emit nothing and never panic. +func TestAgentHookNilNotifierIsInert(t *testing.T) { + m, _, _ := newTestModel(t, theme.New("aztec", theme.AztecPalette())) // no host hook dep + m.sessionID = "sess-x" + // Drive a full lifecycle; the nil interface must be a no-op. + applyAll(m, + client.TurnStartMsg{Turn: 1}, + client.PermissionAskMsg{AskID: "sess-x:1:call-1:r0", Tool: "Shell", Args: `{"command":"ls"}`, Reason: "?"}, + client.ResultMsg{Stop: "end_turn"}, + ) +} + +func lastStop(t *testing.T, f *fakeLifecycleNotifier) lifecycleCall { + t.Helper() + calls := f.snapshot() + for i := len(calls) - 1; i >= 0; i-- { + if calls[i].kind == "stop" { + return calls[i] + } + } + t.Fatalf("no Stop recorded: %+v", calls) + return lifecycleCall{} +} diff --git a/cmd/mecatui/ui/approval.go b/cmd/mecatui/ui/approval.go index 2cba87e8a3..831d0f903c 100644 --- a/cmd/mecatui/ui/approval.go +++ b/cmd/mecatui/ui/approval.go @@ -132,6 +132,14 @@ func (m Model) applyPermissionAsk(msg client.PermissionAskMsg) (tea.Model, tea.C m.phase = phaseAwaitingApproval m.activeTool = "" m.toolProgress = "" + // Host hook PermissionRequest: the agent is blocked on a human. Only a + // MAIN-session ask counts — a host drives terminal status from the main + // loop, never a surfaced subagent ask (isChildAsk). Fire only when this + // ask becomes the visible head, so a queued/deduped ask does not double + // the notification. + if m.deps.AgentHook != nil && !isChildAsk(msg.AskID, m.sessionID) { + m.deps.AgentHook.PermissionRequest(m.deps.Ctx, m.sessionID, msg.Reason) + } } return m.afterEvent() } diff --git a/cmd/mecatui/ui/model.go b/cmd/mecatui/ui/model.go index 1b8a04d5f1..77b09787b2 100644 --- a/cmd/mecatui/ui/model.go +++ b/cmd/mecatui/ui/model.go @@ -25,6 +25,28 @@ import ( const unknownLabel = "unknown" +// LifecycleNotifier receives the session-lifecycle transitions the reducer +// observes, for a client that mirrors them to an external channel — a host +// editor's agent lifecycle hook (see cmd/mecatui/agenthook). The ui owns this +// narrow consumer interface so its import surface stays client+theme only; +// *agenthook.Notifier satisfies it. A nil LifecycleNotifier is the "no external +// channel" state and every method must be a no-op, so the reducer holds one and +// never branches on it. +// +// Start is idempotent within a run (the notifier dedupes to one busy signal); +// Stop fires once per run and is a no-op with no preceding Start; both are +// best-effort and non-blocking (they must never delay or fail the run). +type LifecycleNotifier interface { + // Start signals the agent began work (the run's first turn). + Start(ctx context.Context, sessionID string) + // PermissionRequest signals the agent is blocked on a human approval. It + // does not affect the run's busy state (a Stop must still follow). + PermissionRequest(ctx context.Context, sessionID, message string) + // Stop signals the run reached a terminal state; failed selects the error + // terminal, message is an optional preview for the notification. + Stop(ctx context.Context, sessionID string, failed bool, message string) +} + // SessionCreator creates a server-side session and returns its id together with // the server's advertised capabilities. *client.Client satisfies it (via the // sessionAdapter); tests supply a fake. Keeping it an interface lets the ui be @@ -297,6 +319,14 @@ type Deps struct { // Ctx is the program-level context; per-run stream contexts derive from it. Ctx context.Context //nolint:containedctx // stored to parent per-run stream cancels + // AgentHook mirrors the session lifecycle to the host tool's AGENT LIFECYCLE + // HOOK, in the cross-vendor hook schema Claude Code originated and Codex + // adopted. nil when no supported host is detected — the ordinary standalone + // run — so the reducer's nil check reflects real absence. Start fires on the + // run's first turn, PermissionRequest on a MAIN (non-child) approval ask, and + // Stop on the terminal result. See cmd/mecatui/agenthook. + AgentHook LifecycleNotifier + // NoAltScreen disables the alternate screen buffer, rendering inline in the // terminal's normal buffer. Default false (full-screen TUI on the alt screen). // Set true by the --no-alt-screen/--inline flag — a first-class user opt-out diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index 119c842305..1742fc2f4e 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -895,6 +895,7 @@ func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd, bool) { if msg.Err != nil { m.conv.addError("stream error: " + msg.Err.Error()) } + m.notifyHookFailed(streamErrReason(msg.Err)) m = m.endRun(stopError) m.failedStepRetryRun = false m.failedStepRetryAuthoritative = false @@ -917,6 +918,7 @@ func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd, bool) { if m.failedStepRetryRun && !m.failedStepRetryAuthoritative { // RetryStart transport/server rejection is non-destructive. The server is // authoritative, so preserve textarea, transcript, queue, and /retry access. + m.notifyHookFailed(streamErrReason(msg.Err)) m = m.endRun(stopError) m.failedStepRetryRun = false m.statusMsg = m.deps.Theme.Style("warning").Render("retry was not started: " + sanitizeTerminal(msg.Err.Error()) + " — resolve the condition and use /retry") @@ -934,6 +936,7 @@ func (m Model) updateLifecycle(msg tea.Msg) (tea.Model, tea.Cmd, bool) { m.promptRecovery.autoReplay = true } m.conv.addError("stream error: " + friendlyWorkspaceEnrollmentRejection(msg.Err.Error())) + m.notifyHookFailed(streamErrReason(msg.Err)) m = m.endRun(stopError) liveCmd := m.armLiveFeed() mm, drainCmd := m.drainQueue(stopError) @@ -1243,6 +1246,13 @@ func (m *Model) beginTurnEvent() { if m.failedStepRetryRun { m.failedStepRetryAuthoritative = true } + // Host hook busy signal (schema: UserPromptSubmit) — the agent began work. + // The notifier dedupes to one busy signal per run, so firing on every + // turn.start is correct (and covers the first turn whether the run began from + // a prompt, a resume, or a retry). + if m.deps.AgentHook != nil { + m.deps.AgentHook.Start(m.deps.Ctx, m.sessionID) + } m.conv.startAssistant() m.activeTool = "" m.toolProgress = "" @@ -1555,6 +1565,40 @@ func (m Model) settleFailedClearSource() (Model, tea.Cmd, bool) { return m, m.prompt.Focus(), true } +// notifyHookStop mirrors a genuine run terminal to the host's agent lifecycle +// hook. It is NOT called on the auto-retry (FailedStepRetryEligible) branch, +// where the run continues — only on paths that end the run. The notifier fires +// the terminal once per busy period and no-ops without a preceding Start, so the +// clearPending settle path and the deferred-retry path calling it is harmless. +func (m Model) notifyHookStop(msg client.ResultMsg) { + if m.deps.AgentHook == nil { + return + } + failed := msg.Stop == stopError + m.deps.AgentHook.Stop(m.deps.Ctx, m.sessionID, failed, msg.Error) +} + +// streamErrReason renders a transport error for the hook's notification preview, +// tolerating a nil error (some stream-death branches carry the fact without an +// err value). +func streamErrReason(err error) string { + if err == nil { + return "stream error" + } + return err.Error() +} + +// notifyHookFailed mirrors a TRANSPORT/stream-death terminal (not a server +// ResultMsg) to the host hook as a FAILED terminal. Like notifyHookStop it +// no-ops without a preceding Start, so a transport error before any turn began +// emits nothing. +func (m Model) notifyHookFailed(reason string) { + if m.deps.AgentHook == nil { + return + } + m.deps.AgentHook.Stop(m.deps.Ctx, m.sessionID, true, reason) +} + // applyResult handles a terminal ResultMsg: it folds the run's usage into the running // totals, surfaces a terminal error, ends the run, and drains any queued prompts. // Extracted from updateStreamEvent's switch to keep that dispatcher flat. @@ -1579,6 +1623,7 @@ func (m Model) applyResult(msg client.ResultMsg) (tea.Model, tea.Cmd) { // The terminal facts still belong in the source projection, but Clear owns // what happens next. Settle only: no queued prompt, failed-step retry, // pending-mode retry, plan continuation, live-feed rearm, or other source run. + m.notifyHookStop(msg) m = m.endRun(msg.Stop) m.failedStepRetryRun = false m.failedStepRetryAuthoritative = false @@ -1598,14 +1643,21 @@ func (m Model) applyResult(msg client.ResultMsg) (tea.Model, tea.Cmd) { if len(m.queued) > 0 { m.queuePaused = "retry_pending" } + // A genuine end: the run stopped and waits for a manual /retry. + m.notifyHookStop(msg) m.statusMsg = m.deps.Theme.Style("warning").Render("retry stopped before the model was called — adjust configuration and use /retry") return m, tea.Batch(m.refreshCmd(), m.retryPendingModeCmd(), m.armLiveFeed()) } if msg.FailedStepRetryEligible() && !m.failedStepRetryTried { + // NOT a genuine end: an automatic retry run starts now, so no Superset + // Stop. The retry's turn.start re-Starts (deduped, still busy), and the + // eventual real terminal fires Stop below. m.failedStepRetryTried = true rm, retryCmd := m.startFailedStepRetry() return rm, tea.Batch(m.refreshCmd(), retryCmd, m.armLiveFeed()) } + // Every remaining path is a genuine run terminal that returns to idle. + m.notifyHookStop(msg) if msg.Stop == stopError && msg.RetryDispositionPresent && msg.RetryDisposition == client.RetryDispositionRetryable { m.statusMsg = m.deps.Theme.Style("warning").Render("model step failed — use /retry to retry without duplicating the prompt") } diff --git a/user-docs/mecatui/using-the-tui.md b/user-docs/mecatui/using-the-tui.md index f2ed6fad5b..bb575e05cb 100644 --- a/user-docs/mecatui/using-the-tui.md +++ b/user-docs/mecatui/using-the-tui.md @@ -99,6 +99,21 @@ offered, or deny it. Long arguments can be scrolled; `ctrl+t` opens a full-screen view when needed. Mouse buttons activate the same choices as their displayed keys. +## Get editor notifications + +Run `mecatui` in a terminal provided by a supported editor and the editor tells +you when the agent needs an approval and when a run ends. This needs no +configuration. + +`mecatui` reports three lifecycle points to the editor's agent hook: the start of +a turn, an approval request from the main session, and the run's terminal state. +The editor decides how to present them. Approval requests raised by a subagent +stay out of the report, so delegated work does not compete with the main session +for your attention. + +Superset provides this integration. In any other terminal, `mecatui` skips the +report and its behavior is unchanged. + ## Complete browser authorization When workspace-service enrollment or an MCP tool opens a browser, complete the