hooks

package
v0.3.30 Latest Latest
Warning

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

Go to latest
Published: Sep 11, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package hooks provides a system for executing scripts on task events.

Index

Constants

View Source
const (
	EventTaskBlocked  = "task.blocked"
	EventTaskDone     = "task.done"
	EventTaskFailed   = "task.failed"
	EventTaskStarted  = "task.started"
	EventAuthRequired = "task.auth_required" // Executor session needs re-authentication

)

Event types for hooks

View Source
const ActionTimeout = 60 * time.Second

ActionTimeout bounds how long a user-invoked action may run. Actions are synchronous (their output is shown to the user), so this is more generous than the fire-and-forget hook timeout.

View Source
const DefaultPlacementTimeout = 5 * time.Second

DefaultPlacementTimeout bounds a placement handler. This runs in the task spawn path, so a slow resolver must never hang a task: past this budget ty stops waiting, logs loudly, and runs the task locally.

View Source
const EventTaskPlacement = "task.placement"

EventTaskPlacement is the one hook ty *consults* rather than merely notifies.

Every other event in this package is fire-and-forget: the script is launched in the background and nothing waits for it or reads what it says. Placement cannot work that way — where a task runs has to be decided BEFORE the executor spawns, and the answer has to come back. So this event is synchronous, bounded by a short timeout, and its stdout is parsed.

A handler receives the request as JSON on stdin:

{"event":"task.placement","task":{"id":5228,"title":"...","project":"taskyou",
 "repo_path":"/abs/path","executor":"claude"}}

and answers with JSON on stdout:

{"target":"ol-agents","workdir":"~/projects/engineering","reason":"most free memory"}

An empty target means "run locally", which is also what ty assumes for every way a handler can fail. Core never learns what a host is: it asks a question and uses the answer.

View Source
const EventTaskRoute = "task.route"

EventTaskRoute is fired immediately before a task is spawned, and is the one event a plugin can *answer* rather than merely observe.

Every other hook is a notification: it fires after the fact, runs detached, and nothing waits for it. A router has to be the opposite — the decision it makes (which Claude profile this task runs under) is only useful before the command is built, so this hook runs synchronously, in the foreground of the spawn, and its stdout is read back.

That inversion is deliberate, and bounded: RouteTimeout caps the wait, a failing or silent script yields no decision and the task spawns exactly as it would have, and the first plugin to answer wins so a slow one can't be made to re-litigate a settled choice.

View Source
const ManifestName = "plugin.yaml"

ManifestName is the file every plugin directory must contain to be loaded.

View Source
const RouteTimeout = 15 * time.Second

RouteTimeout bounds a routing hook. A task spawn blocks on this, so it is tight: a router that needs longer than this to pick a profile is a router that should be caching, and the safe answer while it does is "spawn as configured".

Variables

This section is empty.

Functions

func ActionEnv added in v0.3.18

func ActionEnv(p Plugin, task *db.Task) []string

ActionEnv builds the environment for a plugin action. task may be nil when an action is invoked without a task in context; the TASK_* vars are then omitted.

func DefaultHooksDir

func DefaultHooksDir() string

DefaultHooksDir returns the default hooks directory path.

func DefaultPluginsDir added in v0.3.18

func DefaultPluginsDir() string

DefaultPluginsDir returns the plugins directory path. The TY_PLUGINS_DIR environment variable overrides the default (~/.config/task/plugins), which is handy for relocating plugins and for test isolation.

func EnsureHooksDir

func EnsureHooksDir() (string, error)

EnsureHooksDir creates the hooks directory if it doesn't exist.

func FindAction added in v0.3.18

func FindAction(plugins []Plugin, pluginName, actionID string) (Plugin, Action, error)

FindAction locates a plugin and action by name/id across the given plugins.

func PluginRoutineDirs added in v0.3.19

func PluginRoutineDirs(pluginsDir string) []string

PluginRoutineDirs returns the routines/ subdir of every installed plugin that ships one. The routine package folds these into its search path, so a plugin's routines become resolvable by `ty run <name>` and visible in `ty routines`.

func PluginWorkflowDirs added in v0.3.18

func PluginWorkflowDirs(pluginsDir string) []string

PluginWorkflowDirs returns the workflows/ subdir of every installed plugin that actually ships one. The pipeline registry feeds these into its workflow search path, so an installed plugin's workflows become resolvable by `ty pipeline -d`.

func RunAction added in v0.3.18

func RunAction(ctx context.Context, p Plugin, a Action, task *db.Task) ([]byte, error)

RunAction runs a plugin action to completion and returns its combined output. It is synchronous by design: actions are user-triggered and their result is surfaced back to the user (CLI stdout, a TUI banner, etc.).

Types

type Action added in v0.3.18

type Action struct {
	ID      string `yaml:"id"`      // stable identifier, unique within the plugin
	Label   string `yaml:"label"`   // human-facing label; defaults to ID if empty
	Command string `yaml:"command"` // script path, relative to the plugin dir
}

Action is a user-triggered plugin command.

func (Action) DisplayLabel added in v0.3.18

func (a Action) DisplayLabel() string

DisplayLabel returns the label, falling back to the ID.

type Placement added in v0.3.28

type Placement struct {
	// Target names the host to run on. Empty means local.
	Target      string `json:"target"`
	Unavailable bool   `json:"unavailable,omitempty"`
	// WorkDir is the task's directory ON THAT HOST. It is a remote path, so a
	// leading "~" is left alone for the remote shell to expand.
	WorkDir string `json:"workdir"`
	// Reason explains the choice in the user's words. Always surfaced, so that a
	// surprising placement can be understood without digging.
	Reason string `json:"reason"`

	// Handler is the plugin that answered, or "" when no handler is installed.
	// Not part of the wire contract — ty fills it in.
	Handler string `json:"-"`
}

Placement is a handler's answer: where the task should run.

func (Placement) Consulted added in v0.3.28

func (p Placement) Consulted() bool

Consulted reports whether any handler actually answered. False is the common case — no placement plugin installed — and must leave behaviour untouched.

func (Placement) IsLocal added in v0.3.28

func (p Placement) IsLocal() bool

IsLocal reports whether this placement means "run here", which is both the explicit empty-target answer and the answer to every failure.

type PlacementTaskInfo added in v0.3.28

type PlacementTaskInfo struct {
	ID             int64  `json:"id"`
	Title          string `json:"title"`
	Project        string `json:"project"`
	RepoPath       string `json:"repo_path"`
	Executor       string `json:"executor"`
	RemoteRequired bool   `json:"remote_required,omitempty"`
}

PlacementTaskInfo is the task half of the placement request — the facts a resolver needs to pick a host, and nothing more.

type Plugin added in v0.3.18

type Plugin struct {
	Name        string `yaml:"name"`
	Version     string `yaml:"version"`
	Description string `yaml:"description"`
	// Hooks maps a task event name (e.g. "task.done") to a script path,
	// resolved relative to the plugin directory. Hooks fire automatically.
	Hooks map[string]string `yaml:"hooks"`
	// Actions are user-invoked commands (from `ty plugins run`, the detail-view
	// picker, or the command palette), each backed by a script in the plugin dir.
	Actions []Action `yaml:"actions"`
	// Services are long-running processes the daemon supervises for the plugin's
	// lifetime — a sidecar/service an extension used to run on its own (e.g. an MCP
	// proxy, a sync loop). Started when the daemon comes up, stopped when it exits.
	Services []Service `yaml:"services"`

	// Dir is the absolute path to the plugin directory (not from the manifest).
	Dir string `yaml:"-"`
	// Workflows holds the names (filename stems) of the workflow definitions this
	// plugin ships in its workflows/ subdir. Discovered by convention at load time,
	// not declared in the manifest — a plugin is workflow cargo the moment it has a
	// workflows/*.yaml. Populated for display; the definitions themselves are loaded
	// by the pipeline registry via PluginWorkflowDirs.
	Workflows []string `yaml:"-"`
	// Routines holds the names of the routine definitions this plugin ships in its
	// routines/ subdir (each a <name>/prompt.md). Discovered by convention, like
	// workflows. Populated for display; the routine package resolves the definitions
	// via PluginRoutineDirs. A routine is the right home for a plugin's periodic job
	// (a poller, a digest): the daemon-supervised alternative, a service, is for
	// processes that must stay up, not run-to-completion on a schedule.
	Routines []string `yaml:"-"`
}

Plugin is a self-contained, droppable unit that reacts to task events.

A plugin is a directory under the plugins dir (default ~/.config/task/plugins/<name>/) containing a plugin.yaml manifest and the scripts it references. Unlike the legacy single-script hooks dir — where one file per event means two integrations collide — any number of plugins may each declare a handler for the same event, and all of them run.

func LoadPlugins added in v0.3.18

func LoadPlugins(pluginsDir string) (plugins []Plugin, warnings []string)

LoadPlugins discovers and validates every plugin under pluginsDir.

It is intentionally forgiving: a malformed or incomplete plugin is skipped (and reported via the returned warnings) rather than failing the whole load, so one bad community plugin can't break a user's task pipeline. A missing plugins dir is not an error — it just yields no plugins.

func (Plugin) Action added in v0.3.18

func (p Plugin) Action(id string) (Action, bool)

Action returns the action with the given ID.

func (Plugin) RoutinesDir added in v0.3.19

func (p Plugin) RoutinesDir() string

RoutinesDir returns the plugin's routines subdir, whether or not it exists.

func (Plugin) ScriptFor added in v0.3.18

func (p Plugin) ScriptFor(event string) (string, bool)

ScriptFor returns the absolute path to the script handling event, and whether the plugin handles that event at all.

func (Plugin) WorkflowsDir added in v0.3.18

func (p Plugin) WorkflowsDir() string

WorkflowsDir returns the plugin's workflows subdir, whether or not it exists.

type RouteDecision added in v0.3.28

type RouteDecision struct {
	// Plugin is the name of the plugin that answered, for logging.
	Plugin string
	// ClaudeConfigDir routes the task to a particular Claude profile. Empty
	// leaves the task's existing (project or per-task) config dir alone.
	ClaudeConfigDir string
	// Hold asks the caller not to start this task yet — every candidate profile
	// is out of headroom, and running now would only burn a session on a 429.
	// The task stays queued and is reconsidered on the next tick.
	Hold bool
	// Reason explains a Hold (or annotates a routing choice) for the task log.
	Reason string
}

RouteDecision is what a routing hook answers with. The zero value means "no opinion" — the caller proceeds with the task's existing configuration.

func ParseRouteOutput added in v0.3.28

func ParseRouteOutput(out string) RouteDecision

ParseRouteOutput reads a routing script's stdout.

The format is deliberately the dullest thing that works — KEY=VALUE, one per line, unknown keys ignored — because the scripts writing it are shell. A router shouldn't need a JSON encoder to say "use this directory".

CLAUDE_CONFIG_DIR=/Users/me/.claude-work
HOLD=1
REASON=both profiles above 90%, next reset 14:00

Values are taken literally after the first '='; surrounding quotes are stripped so `CLAUDE_CONFIG_DIR="$dir"` from a script that quoted its output still parses.

func (RouteDecision) Empty added in v0.3.28

func (d RouteDecision) Empty() bool

Empty reports whether the decision carries no instruction at all.

type Runner

type Runner struct {
	// contains filtered or unexported fields
}

Runner executes hooks for task events.

func New

func New(hooksDir string) *Runner

New creates a new hook runner. hooksDir is typically ~/.config/task/hooks/

func NewSilent

func NewSilent(hooksDir string) *Runner

NewSilent creates a hook runner without logging.

func (*Runner) HandlesRoute added in v0.3.28

func (r *Runner) HandlesRoute() bool

HandlesRoute reports whether any loaded plugin declares a task.route hook. Spawn checks this first so the overwhelmingly common case — nobody has installed a router — costs a slice scan instead of a subprocess.

func (*Runner) HasPlacementHandler added in v0.3.28

func (r *Runner) HasPlacementHandler() bool

HasPlacementHandler reports whether any loaded plugin handles task.placement. The spawn path checks this first so that a user with no placement plugin pays nothing at all: no JSON built, no process spawned, no log line.

func (*Runner) OnStatusChange

func (r *Runner) OnStatusChange(task *db.Task, newStatus, message string)

OnStatusChange triggers appropriate hooks based on status transition.

func (*Runner) Plugins added in v0.3.18

func (r *Runner) Plugins() []Plugin

Plugins returns the loaded plugins (read-only view for inspection/CLI).

func (*Runner) ResolvePlacement added in v0.3.28

func (r *Runner) ResolvePlacement(ctx context.Context, info PlacementTaskInfo) Placement

ResolvePlacement asks the installed placement handlers where a task should run.

Handlers are consulted in plugin-name order and the first one to name a target wins; a handler that answers "local" (empty target) lets the next one try. Any failure — timeout, crash, non-zero exit, unparseable stdout — is logged loudly and treated as "local", because failing to DECIDE where to run must never fail the task. (Failing to EXECUTE where a handler told us to is a different matter, and is not silently downgraded; see the executor's placement handling.)

With no handler installed the zero Placement comes back, Consulted() is false, and the caller must behave exactly as it did before this existed.

func (*Runner) Route added in v0.3.28

func (r *Runner) Route(ctx context.Context, task *db.Task) RouteDecision

Route asks every plugin that handles task.route what to do with this task, in plugin-name order, and returns the first non-empty decision.

Plugins are consulted in order rather than in parallel and the first answer stands, which keeps the outcome deterministic when more than one router is installed — the alternative (merging or last-write-wins) makes the effective policy depend on which script happened to finish first.

A hook that errors, times out, or prints nothing usable is skipped: routing is an optimization, and failing to optimize must never be the reason a task doesn't run.

func (*Runner) Run

func (r *Runner) Run(event string, task *db.Task, message string)

Run executes every hook registered for the given event: the legacy single-script hook in hooksDir (named after the event), plus the matching hook from each loaded plugin. All run concurrently in the background.

type Service added in v0.3.19

type Service struct {
	Name    string   `yaml:"name"`    // stable label, unique within the plugin
	Command string   `yaml:"command"` // shell command to run (sh -c); the long-running process
	Cwd     string   `yaml:"cwd"`     // working dir relative to the plugin dir ("" = the plugin dir)
	Env     []string `yaml:"env"`     // extra KEY=VALUE env entries for the process
}

Service is a long-running process a plugin ships and the daemon supervises. It's how an "extension" (a sidecar that used to run itself) folds into the plugin model: declare it here and the daemon starts/stops it. Command is run via `sh -c` from the plugin dir (or Cwd if set), so it can reference the plugin's own files.

type ServiceSet added in v0.3.19

type ServiceSet struct {
	// contains filtered or unexported fields
}

ServiceSet is the collection of plugin services the daemon started. Hold onto it for the daemon's lifetime and call Stop on shutdown.

func StartServices added in v0.3.19

func StartServices(pluginsDir string, injectEnv []string, logger *log.Logger) *ServiceSet

StartServices starts every service declared by an installed plugin and returns a handle to stop them. Each service runs as `sh -c <command>` from its plugin dir (or Cwd, resolved relative to it), in its own process group so Stop can signal the whole tree. This is how a plugin folds a former "extension" sidecar under the daemon's lifecycle: it comes up with the daemon and goes down with it.

injectEnv is prepended to every service's environment (after the daemon's own environment, before the service's declared Env) — the daemon uses it to hand each service a stable way to find ty (TY_DB_PATH, TY_API_URL) without hardcoding paths or ports.

func (*ServiceSet) Count added in v0.3.19

func (s *ServiceSet) Count() int

Count reports how many services are running.

func (*ServiceSet) Stop added in v0.3.19

func (s *ServiceSet) Stop()

Stop signals every supervised service's process group with SIGTERM, waits briefly for a graceful exit, then SIGKILLs anything still alive.

Jump to

Keyboard shortcuts

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