hooks

package
v0.5.7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 28, 2026 License: AGPL-3.0 Imports: 11 Imported by: 0

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

View Source
const (
	BehaviorAllow   = "allow"
	BehaviorDeny    = "deny"
	BehaviorModify  = "modify"
	BehaviorContext = "context"
)

Decision behaviours a sync hook can return.

View Source
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.

View Source
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

func Combine(decisions []Decision) Decision

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

func ParseCommandDecision(exitCode int, stdout, stderr string) Decision

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

func ParseTextDecision(text string) Decision

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

type HookTrigger struct {
	Event   string `json:"event"`
	Matcher string `json:"matcher,omitempty"`
}

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

func NewRegistry(store *Store) *Registry

NewRegistry builds a registry over the store and performs the initial load.

func (*Registry) Has

func (r *Registry) Has(event string) bool

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

func (r *Registry) Invalid() map[string]string

Invalid returns the most recent validation errors keyed by id, for surfacing in a /hooks browser or logs.

func (*Registry) Match

func (r *Registry) Match(event, field string) (sync, async []Spec)

Match returns the enabled hooks for event whose matcher accepts field, split into synchronous and asynchronous groups in deterministic spec order.

func (*Registry) RecordRun

func (r *Registry) RecordRun(id string, rec RunRecord)

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

func (r *Registry) SetEnabled(id string, enabled bool) error

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.

func (*Registry) SpecByID

func (r *Registry) SpecByID(id string) (Spec, bool)

SpecByID returns the hook spec with the given id, including disabled ones, so callers (e.g. an on-demand `vix hook trigger <id>`) can fire a hook by id regardless of whether it is currently enabled. The second result is false when no spec carries that id.

func (*Registry) StateByID

func (r *Registry) StateByID(id string) *State

StateByID returns a copy of the hook's runtime state, or nil when none has been recorded yet.

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) AutoDirs

func (s *Spec) AutoDirs() bool

AutoDirs reports the effective auto_dirs permission (default true).

func (*Spec) AutoWrite

func (s *Spec) AutoWrite() bool

AutoWrite reports the effective auto_write permission (default true).

func (*Spec) EffectiveMode

func (s *Spec) EffectiveMode() string

EffectiveMode returns the resolved execution mode, defaulting to async so a hook never blocks the loop unless it opts in.

func (*Spec) EffectiveTimeout

func (s *Spec) EffectiveTimeout() string

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

func (s *Spec) Matches(field string) bool

Matches reports whether field satisfies the hook's matcher. A nil/absent matcher matches everything.

func (*Spec) TimeoutDuration

func (s *Spec) TimeoutDuration() time.Duration

TimeoutDuration returns the per-run wall-clock budget, defaulting by mode.

func (*Spec) Validate

func (s *Spec) Validate() error

Validate reports the first problem with the spec, or nil. It also compiles the matcher.

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

func NewStore(specsDir string) *Store

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

func (st *Store) DeleteState(id string) error

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

func (st *Store) LoadSpecs() ([]Spec, map[string]string)

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

func (st *Store) LoadState() map[string]*State

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

func (st *Store) SaveStateFor(id string, state *State) error

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

func (st *Store) SetEnabled(id string, enabled bool) error

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.

func (*Store) SpecsDir

func (st *Store) SpecsDir() string

SpecsDir returns the directory the store reads specs from.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL