Documentation
¶
Overview ¶
Package hooks implements vixd's lifecycle-hooks engine: user-authored hook specs (~/.vix/hooks/<id>/hook.json) that fire on agent-loop events (a tool about to run, a prompt submitted, a thread starting, …) rather than on a timer.
A hook either runs synchronously and returns a Decision that can veto/rewrite the triggering action (mode "sync"), or fires-and-forgets in an isolated thread (mode "async"). The package owns parsing, validation, and the in-memory registry; actual execution is delegated to the daemon, keeping the dependency direction daemon → hooks.
Index ¶
- Constants
- type Decision
- type HookSnapshot
- type HookTrigger
- type Permissions
- type Registry
- func (r *Registry) Has(event string) bool
- func (r *Registry) Invalid() map[string]string
- func (r *Registry) Match(event, field string) (sync, async []Spec)
- func (r *Registry) RecordRun(id string, rec RunRecord)
- func (r *Registry) Reload()
- func (r *Registry) SetEnabled(id string, enabled bool) error
- func (r *Registry) Snapshot() []HookSnapshot
- func (r *Registry) SpecByID(id string) (Spec, bool)
- func (r *Registry) StateByID(id string) *State
- type RunRecord
- type Spec
- type State
- type Store
- func (st *Store) DeleteState(id string) error
- func (st *Store) LoadSpecs() ([]Spec, map[string]string)
- func (st *Store) LoadState() map[string]*State
- func (st *Store) SaveStateFor(id string, state *State) error
- func (st *Store) SetEnabled(id string, enabled bool) error
- func (st *Store) SpecsDir() string
Constants ¶
const ( BehaviorAllow = "allow" BehaviorDeny = "deny" BehaviorModify = "modify" BehaviorContext = "context" )
Decision behaviours a sync hook can return.
const ( EventPreToolUse = "PreToolUse" EventPostToolUse = "PostToolUse" EventUserPromptSubmit = "UserPromptSubmit" EventThreadStart = "ThreadStart" EventStop = "Stop" EventPreCompact = "PreCompact" EventPostCompact = "PostCompact" EventSubagentStart = "SubagentStart" EventSubagentStop = "SubagentStop" EventPermissionRequest = "PermissionRequest" )
Lifecycle events a hook can subscribe to. Only events listed in supportedEvents are accepted by Validate; the rest of the catalogue is added as it gets wired into the thread loop.
const ( ModeSync = "sync" ModeAsync = "async" )
Execution modes.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Decision ¶
type Decision struct {
Behavior string `json:"behavior,omitempty"`
Reason string `json:"reason,omitempty"` // shown to the model on deny
Input map[string]any `json:"input,omitempty"` // replacement tool input (modify)
Context string `json:"context,omitempty"` // injected developer text (context)
}
Decision is the structured verdict a synchronous hook returns. The zero value (BehaviorAllow, empty fields) means "no opinion, proceed".
func Combine ¶
Combine folds several hook decisions into one, most-restrictive-wins: deny > modify > context > allow. Context strings from every hook are concatenated so observational hooks don't lose their output to a sibling.
func ParseCommandDecision ¶
ParseCommandDecision derives a Decision from a finished command hook:
- exit 2 → deny, reason from stderr (fallback stdout)
- exit 0, JSON out → that Decision
- exit 0, text out → context = stdout
- exit 0, no out → allow
- any other exit → allow (fail-open; the hook errored, don't wedge the loop)
func ParseTextDecision ¶
ParseTextDecision derives a Decision from the final text of a workflow/prompt hook. A leading/trailing JSON object is honoured first, then the BLOCK: sentinel, otherwise the run is treated as allow (with the text available as context for non-blocking hooks).
type HookSnapshot ¶
type HookSnapshot struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Trigger HookTrigger `json:"trigger"`
Mode string `json:"mode"`
Blocking bool `json:"blocking"`
Command string `json:"command,omitempty"`
WorkflowID string `json:"workflow_id,omitempty"`
WorkflowInline bool `json:"workflow_inline,omitempty"`
Prompt string `json:"prompt,omitempty"`
CWD string `json:"cwd,omitempty"`
Timeout string `json:"timeout"`
Description string `json:"description,omitempty"`
Permissions map[string]any `json:"permissions"`
CreatedBy string `json:"created_by"`
// Runtime history, attached from the hook's persisted State. LastFiredAt is
// the zero time when the hook has never fired; RecentRuns is newest-last,
// capped at maxRecentRuns.
LastFiredAt time.Time `json:"last_fired_at,omitempty"`
RecentRuns []RunRecord `json:"recent_runs,omitempty"`
}
HookSnapshot is a read-only view of a hook for external consumers (the web UI hooks tab). It carries the spec fields the UI renders, with the mode resolved to its effective value and permissions flattened to resolved booleans.
type HookTrigger ¶
HookTrigger selects which event fires the hook and (optionally) narrows it with a regex matched against the event's match field (tool name for tool events, source for ThreadStart, …). An empty or "*" matcher matches all.
type Permissions ¶
type Permissions struct {
AutoWrite *bool `json:"auto_write,omitempty"`
AutoDirs *bool `json:"auto_dirs,omitempty"`
}
Permissions maps onto the isolated thread's automatic-permission flags for workflow/prompt hooks. Pointers so "absent" defaults to true.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry is the in-memory, hot-reloadable index of enabled hook specs grouped by event. It is safe for concurrent use: the thread loop reads it on every matching lifecycle point while the config watcher swaps it on disk changes.
func NewRegistry ¶
NewRegistry builds a registry over the store and performs the initial load.
func (*Registry) Has ¶
Has reports whether any enabled hook subscribes to event (cheap pre-check so the thread loop can skip building a context when nothing is listening).
func (*Registry) Invalid ¶
Invalid returns the most recent validation errors keyed by id, for surfacing in a /hooks browser or logs.
func (*Registry) Match ¶
Match returns the enabled hooks for event whose matcher accepts field, split into synchronous and asynchronous groups in deterministic spec order.
func (*Registry) RecordRun ¶
RecordRun appends one fire to the hook's recent-run history, updates the last-* summary fields, and persists the state file. Best-effort: a persist error is swallowed (state is reconstructible from the run log). Safe for concurrent fires of the same hook.
func (*Registry) Reload ¶
func (r *Registry) Reload()
Reload re-reads the spec directory and atomically swaps the index. Per-hook runtime state (recent-fire history) is preserved across the swap; state for ids that no longer exist on disk is dropped and its state file removed.
func (*Registry) SetEnabled ¶ added in v0.5.3
SetEnabled flips a hook spec's `enabled` field on disk and reloads the index. The edit is surgical (only the enabled value is rewritten via the store), so the rest of the user's hook.json is preserved. Returns an error when the id is unknown or the file cannot be patched.
func (*Registry) Snapshot ¶
func (r *Registry) Snapshot() []HookSnapshot
Snapshot returns every valid hook spec (enabled and disabled) as read-only views, sorted by id for stable rendering. Safe to call concurrently with the thread loop and config watcher.
type RunRecord ¶
type RunRecord struct {
At time.Time `json:"at"`
Status string `json:"status"`
Async bool `json:"async"`
Event string `json:"event,omitempty"`
Error string `json:"error,omitempty"`
ThreadID string `json:"thread_id,omitempty"`
Duration string `json:"duration,omitempty"` // Go duration string
}
RunRecord is one entry in a hook's recent-fire history (State.RecentRuns). Status carries the resolved outcome: for synchronous hooks it is the decision behavior (allow | deny | context | modify); for asynchronous hooks it is done | error. ThreadID is set only for workflow/prompt hooks that ran in their own thread (command hooks have none).
type Spec ¶
type Spec struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Enabled bool `json:"enabled"`
Trigger HookTrigger `json:"trigger"`
Mode string `json:"mode,omitempty"` // sync | async (default async)
Blocking bool `json:"blocking,omitempty"` // sync only; may veto
Command string `json:"command,omitempty"` // shell command (fast path)
WorkflowID string `json:"workflow_id,omitempty"` // named workflow (config/workflow.json)
Workflow *workflow.Def `json:"workflow,omitempty"` // inline workflow definition
Prompt string `json:"prompt,omitempty"` // plain prompt (LLM)
CWD string `json:"cwd,omitempty"`
Permissions Permissions `json:"permissions,omitempty"`
Timeout string `json:"timeout,omitempty"`
CreatedBy string `json:"created_by,omitempty"`
Description string `json:"description,omitempty"` // free-form, shown in the web UI
// contains filtered or unexported fields
}
Spec is a user-authored hook definition, one hook.json per hook under ~/.vix/hooks/<id>/. Exactly one action runs: Command, a workflow (named via WorkflowID or embedded inline in Workflow), or Prompt.
func (*Spec) EffectiveMode ¶
EffectiveMode returns the resolved execution mode, defaulting to async so a hook never blocks the loop unless it opts in.
func (*Spec) EffectiveTimeout ¶
EffectiveTimeout returns the resolved timeout as a short human string: the author's own value when set, otherwise the mode-based default ("5s" sync, "10m" async). Used by HookSnapshot so the web UI never has to guess.
func (*Spec) Matches ¶
Matches reports whether field satisfies the hook's matcher. A nil/absent matcher matches everything.
func (*Spec) TimeoutDuration ¶
TimeoutDuration returns the per-run wall-clock budget, defaulting by mode.
type State ¶
type State struct {
LastFiredAt time.Time `json:"last_fired_at,omitempty"`
LastStatus string `json:"last_status,omitempty"`
LastError string `json:"last_error,omitempty"`
LastFireID string `json:"last_fire_id,omitempty"`
RecentRuns []RunRecord `json:"recent_runs,omitempty"` // newest last, capped at maxRecentRuns
}
State is the machine-written runtime state of one hook, persisted as <id>/state.json inside the hook's own subdirectory (a sibling of hook.json). Never hand-edited. Unlike a hook spec, this is appended to on every fire.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store reads hook specs from a directory and round-trips per-hook state files. Specs are user-authored (<id>/hook.json); state is machine-written (<id>/state.json, a sibling) so spec files never churn.
func NewStore ¶
NewStore creates a store over the given spec directory. An empty path disables loading (LoadSpecs returns nothing) — the "no home directory" degradation.
func (*Store) DeleteState ¶
DeleteState removes one hook's state file. Used when a spec vanishes from disk but its subdirectory lingers. A missing file is not an error. No-op when the spec directory or id is unavailable.
func (*Store) LoadSpecs ¶
LoadSpecs reads every hook spec under the hooks directory. Each hook lives in its own subdirectory as <id>/hook.json; the directory name is the default id. Returns the valid specs and a map of validation errors keyed by id (or the subdirectory name when the id itself is unusable). Subdirectories without a hook.json are ignored; ones that fail to parse or validate are reported, never fatal.
func (*Store) LoadState ¶
LoadState reads every hook's state file (<id>/state.json) under the spec directory and returns them keyed by id. Missing or corrupt files are skipped (state is reconstructible). Returns an empty map when the spec directory is unavailable.
func (*Store) SaveStateFor ¶
SaveStateFor atomically writes one hook's state file to <id>/state.json (temp file + rename). The hook's subdirectory is created if needed. No-op when the spec directory or id is unavailable.
func (*Store) SetEnabled ¶ added in v0.5.3
SetEnabled surgically rewrites the `enabled` field of <id>/hook.json in place, preserving every other key, its order, and the file's formatting. The edited bytes are written atomically (temp file + rename). Errors when the spec directory or id is unavailable, or when the hook.json cannot be read/patched.