// Package hooks is the lifecycle of a session, published. // // Codey already tells a front end what is happening through the agent's // Observe callback, but that stream is about rendering: it carries text // deltas and tool payloads, there is exactly one of it, and whoever owns the // screen owns it. This is the other kind of notification — a small, stable set // of things that happened, broadcast to anything that asked, carrying no // terminal concerns and no conversation content. // // What wants that is anything outside the process: a terminal multiplexer that // wants to know whether this pane is working or waiting on a human, a // notifier, a script that logs turns somewhere. The events are therefore named // for states a bystander cares about rather than for the loop's internals, and // the set is kept short on purpose. Every one of them is cheap to fire and // must stay that way: they are fired from the agent's own goroutine, so a // handler that blocks is a handler that stalls the turn. // // The zero value and a nil *Hooks both work and drop everything, so callers // never guard a Fire with a nil check. package hooks import ( "context" "fmt" "sync" "time" ) // Event names something that happened. The set is deliberately small: these // are the moments an observer outside the process can act on, not a trace of // the loop. type Event string const ( // SessionStart and SessionEnd bracket one conversation. A second tab is a // second session and fires its own pair. SessionStart Event = "session_start" SessionEnd Event = "session_end" // RunStart and RunEnd bracket one prompt: everything from the user's // message to the model's final answer, tool calls and all. Between them // the agent is busy; after RunEnd it is waiting for a human. RunStart Event = "run_start" RunEnd Event = "run_end" // TurnStart is one model call within a run. TurnStart Event = "turn_start" // ToolStart and ToolResult bracket one tool call. ToolStart Event = "tool_start" ToolResult Event = "tool_result" // Blocked and Unblocked bracket a wait on a human — an approval prompt or // a question from the ask tool. This is the distinction a bystander most // wants and cannot get by watching the process: an agent that is thinking // and an agent that has been sitting on a prompt for ten minutes look // exactly alike from outside. Blocked Event = "blocked" Unblocked Event = "unblocked" // Paused and Resumed are the loop being held at a turn boundary. Paused Event = "paused" Resumed Event = "resumed" ) // Reasons a run is blocked, carried in Payload.Reason. const ( ReasonApproval = "approval" ReasonQuestion = "question" ) // Payload is one event. Every field beyond Event and Time is optional, and // which ones are set depends on the event; a handler must treat an empty field // as "not applicable here" rather than as an error. // // Nothing here carries conversation content. Message is a short human-readable // summary — which tool wants approval, what a question is about — because the // point of these events is to reach things outside the process, and what the // model wrote is not theirs to have. type Payload struct { Event Event Time time.Time // Session is the session id this came from, stamped by the Hooks the // event was fired on. Tabs mean several sessions in one process. Session string // Agent is the subagent id, empty for a session's own agent. Agent string Turn int Tool string // Reason says what kind of thing happened, where the event alone does not // distinguish: ReasonApproval or ReasonQuestion on Blocked. Reason string // Message is a short summary safe to show a bystander. Message string // Err is set when the event reports something that failed. Err string } // Handler receives events. Returning an error does not stop the run or the // other handlers; it is reported through Hooks.OnError and otherwise ignored, // because a broken notifier must not break the agent. // // Handle is called from the goroutine that fired the event and must return // promptly. A handler with slow work to do owns its own queue. type Handler interface { Handle(context.Context, Payload) error } // HandlerFunc adapts a function to Handler. type HandlerFunc func(context.Context, Payload) error // Handle implements Handler. func (f HandlerFunc) Handle(ctx context.Context, p Payload) error { return f(ctx, p) } // set is the handler list a Hooks and everything derived from it share. // Derived hooks exist so a tab can stamp its own session id on every event // without registering the handlers a second time, and sharing the lock is what // keeps two tabs from reporting over each other. type set struct { mu sync.Mutex handlers []Handler onError func(Payload, error) } // Hooks is a fan-out of lifecycle events to registered handlers, stamped with // one session's id. type Hooks struct { set *set session string } // New returns hooks that stamp every event with a session id. Handlers are // added with Add; until one is, firing costs a lock and a map-free return. func New(session string) *Hooks { return &Hooks{set: &set{}, session: session} } // For returns hooks that stamp a different session id and write to the same // handlers these do. Adding a handler to either adds it to both; closing // either closes the handlers once. func (h *Hooks) For(session string) *Hooks { if h == nil || h.set == nil { return nil } return &Hooks{set: h.set, session: session} } // Session reports the id stamped on events fired here. func (h *Hooks) Session() string { if h == nil { return "" } return h.session } // Add registers handlers, in the order they will be called. Nil handlers are // skipped, so a detector that returns nil when its thing is not present can be // passed straight in. func (h *Hooks) Add(handlers ...Handler) { if h == nil || h.set == nil { return } h.set.mu.Lock() defer h.set.mu.Unlock() for _, handler := range handlers { if handler == nil { continue } // A typed nil pointer in a non-nil interface is what a detector // returning (*Reporter)(nil) produces, and it would panic on the // first event rather than at the call that was wrong. if r, ok := handler.(interface{ IsNil() bool }); ok && r.IsNil() { continue } h.set.handlers = append(h.set.handlers, handler) } } // OnError installs what to do with a handler's error or panic. It is where a // caller wires the metrics log; the default is to drop them, because a hook // failing is not something to interrupt a session over and there is no // guarantee anything is listening. func (h *Hooks) OnError(f func(Payload, error)) { if h == nil || h.set == nil { return } h.set.mu.Lock() defer h.set.mu.Unlock() h.set.onError = f } // Enabled reports whether anything is listening, so a caller can skip building // a Message nobody will read. func (h *Hooks) Enabled() bool { if h == nil || h.set == nil { return false } h.set.mu.Lock() defer h.set.mu.Unlock() return len(h.set.handlers) > 0 } // Fire delivers an event to every handler, in order, on the calling goroutine. // Time and Session are filled in when the caller left them empty. // // It never panics and never returns an error: a hook is a bystander's // convenience, and the agent it is watching does not stop because the // bystander fell over. func (h *Hooks) Fire(ctx context.Context, p Payload) { if h == nil || h.set == nil { return } h.set.mu.Lock() handlers := h.set.handlers onError := h.set.onError h.set.mu.Unlock() if len(handlers) == 0 { return } if p.Time.IsZero() { p.Time = time.Now() } if p.Session == "" { p.Session = h.session } if ctx == nil { ctx = context.Background() } for _, handler := range handlers { if err := safeHandle(ctx, handler, p); err != nil && onError != nil { onError(p, err) } } } // safeHandle turns a panicking handler into an ordinary error. A hook is // third-party code as far as the loop is concerned — the config points it at // an arbitrary command — and the agent's goroutine is not a good place to find // out that it was wrong. func safeHandle(ctx context.Context, handler Handler, p Payload) (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("hook handler panicked: %v", r) } }() return handler.Handle(ctx, p) } // Close closes every handler that has a Close method, once, and forgets them. // It is how a reporter gets to say goodbye — releasing a claim, flushing a // queue — and is safe to call twice and on a nil *Hooks. func (h *Hooks) Close() error { if h == nil || h.set == nil { return nil } h.set.mu.Lock() handlers := h.set.handlers h.set.handlers = nil onError := h.set.onError h.set.mu.Unlock() var first error for _, handler := range handlers { c, ok := handler.(interface{ Close() error }) if !ok { continue } if err := c.Close(); err != nil { if onError != nil { onError(Payload{Event: SessionEnd}, err) } if first == nil { first = err } } } return first }