Skip to content

Latest commit

 

History

History
139 lines (111 loc) · 5.77 KB

File metadata and controls

139 lines (111 loc) · 5.77 KB
package github.com/genai-io/san/internal/hook
layer feature

hook

Executes user-defined hooks at well-known application events (tool calls, session lifecycle, permission requests, file changes, …) and merges their outcomes back into the calling code path.

Purpose

Hooks are how users extend San without writing Go: a shell command, an LLM prompt, an HTTP endpoint, or an in-memory callback runs at a named event. The engine resolves which hooks fire (matchers), runs them synchronously or asynchronously, and reduces their structured outputs into a single HookOutcome for the call site.

This package is intentionally separate from core agent lifecycle: hook events are application-layer concerns (PreToolUse, Stop, SessionStart, …), not part of the agent loop's primitives.

Contract

One role interface plus the concrete engine — no producer-side union.

Role Shape Consumers
Handler — fire hooks at an application event interface{ Execute; ExecuteAsync; HasHooks; StopHookActive } agent loop, slash-command approval flow, compaction, file watcher, worktree / plugin / mcp / subagent
Engine configuration + status (no interface) concrete *EngineSet* runtime knobs, ClearSessionHooks, SetAuditCallback, CurrentStatusMessage app composition root (Set* methods) and the TUI view (CurrentStatusMessage). Each is one caller; an interface would be ceremony.

*Engine is the only implementation; consumers narrow by declaration:

var handler hook.Handler = hook.DefaultEngine()
handler.Execute(ctx, hook.PreToolUse, input)
package hook

// Handler is what callers depend on to fire hooks at application
// events and merge outcomes. Modeled on http.Handler — pass an event
// (the request analogue) and receive the merged HookOutcome (the
// response analogue). Most callers depend on this surface only.
type Handler interface {
    Execute(ctx context.Context, event EventType, input HookInput) HookOutcome
    ExecuteAsync(event EventType, input HookInput)
    HasHooks(event EventType) bool
    StopHookActive() *bool
}

// *Engine is the only implementation; satisfies Handler and carries
// the Set* / status methods used by the app composition root and the
// TUI view layer.
type Engine struct { /* internal fields */ }

var _ Handler = (*Engine)(nil)

// Configuration methods on *Engine (used by the app composition root).
func (e *Engine) SetSettings(*setting.Settings)
func (e *Engine) SetLLMCompleter(LLMCompleter, string)
func (e *Engine) SetTranscriptPath(string)
func (e *Engine) SetCwd(string)
func (e *Engine) SetPermissionMode(string)
func (e *Engine) SetAsyncHookCallback(AsyncHookCallback)
func (e *Engine) SetAuditCallback(AuditCallback)
func (e *Engine) ClearSessionHooks()

// Status read on *Engine (used by the TUI view).
func (e *Engine) CurrentStatusMessage() string

// Package-level access.
func Initialize(opts Options)
func DefaultEngine() *Engine
func SetDefaultEngine(e *Engine)   // test-only
func ResetDefaultEngine()          // test-only

Why one interface

Handler captures the dominant caller pattern (8 cross-package consumers fire events and read outcomes). Everything else on *EngineSet* knobs, ClearSessionHooks, SetAuditCallback, CurrentStatusMessage — has exactly one caller each. Single-caller methods don't earn an interface; TEMPLATE Rule 3.

Internals

  • Engine (engine.go) is the only implementation. It owns:
    • *hookStore — settings-loaded hooks plus session/runtime function hooks
    • *statusTracker — currently-active hook status message for the TUI
    • mutable knobs (settings, cwd, transcriptPath, permissionMode, llmCompleter, httpClient, promptCallback, asyncCallback, auditCallback, envProvider) under one sync.RWMutex
    • a sync.WaitGroup for fire-and-forget detached goroutines
  • Executors live in executors_command.go / executors_http.go / executors_llm.go. Each takes a matched hook + HookInput and returns a HookOutcome.
  • matcher.go resolves which hooks fire for an event by matching against patterns (tool name globs, regex, exact match).
  • audit callback is the single observation seam used by the session recorder to write one hook.fired transcript record per invocation — the hook package does not import transcript.

Lifecycle

  • Construction: Initialize(Options{...}) runs at app startup. Engine is a singleton.
  • Per-event execution: Execute is synchronous; matching hooks run in configuration order, outcomes are reduced left-to-right, and the first ShouldContinue=false short-circuits.
  • Async hooks: ExecuteAsync plus the Command.Async flag on individual hooks both spawn detached goroutines tracked by an internal sync.WaitGroup. Wait() blocks until all detached goroutines drain (used on app shutdown).
  • Concurrency: all Set* methods are mutex-guarded reads/writes; hook execution reads under RLock.

Tests

internal/hook/hooks_test.go         — large table of execution
                                       scenarios (sync, async, matchers,
                                       outcome merging, permission paths).
internal/hook/hooks_test.go         — also covers types/registry roundtrips.

The 49 KB test file is the canonical reference for how outcomes merge and how Claude-Code-compatible hook configs are interpreted.

See Also