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