Documentation
¶
Overview ¶
Package hooks provides a system for executing scripts on task events.
Index ¶
- Constants
- func ActionEnv(p Plugin, task *db.Task) []string
- func DefaultHooksDir() string
- func DefaultPluginsDir() string
- func EnsureHooksDir() (string, error)
- func FindAction(plugins []Plugin, pluginName, actionID string) (Plugin, Action, error)
- func PluginRoutineDirs(pluginsDir string) []string
- func PluginWorkflowDirs(pluginsDir string) []string
- func RunAction(ctx context.Context, p Plugin, a Action, task *db.Task) ([]byte, error)
- type Action
- type Placement
- type PlacementTaskInfo
- type Plugin
- type RouteDecision
- type Runner
- func (r *Runner) HandlesRoute() bool
- func (r *Runner) HasPlacementHandler() bool
- func (r *Runner) OnStatusChange(task *db.Task, newStatus, message string)
- func (r *Runner) Plugins() []Plugin
- func (r *Runner) ResolvePlacement(ctx context.Context, info PlacementTaskInfo) Placement
- func (r *Runner) Route(ctx context.Context, task *db.Task) RouteDecision
- func (r *Runner) Run(event string, task *db.Task, message string)
- type Service
- type ServiceSet
Constants ¶
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
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.
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.
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.
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.
const ManifestName = "plugin.yaml"
ManifestName is the file every plugin directory must contain to be loaded.
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
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 ¶
EnsureHooksDir creates the hooks directory if it doesn't exist.
func FindAction ¶ added in v0.3.18
FindAction locates a plugin and action by name/id across the given plugins.
func PluginRoutineDirs ¶ added in v0.3.19
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
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`.
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
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"`
// 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.
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
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) RoutinesDir ¶ added in v0.3.19
RoutinesDir returns the plugin's routines subdir, whether or not it exists.
func (Plugin) ScriptFor ¶ added in v0.3.18
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
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 (*Runner) HandlesRoute ¶ added in v0.3.28
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
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 ¶
OnStatusChange triggers appropriate hooks based on status transition.
func (*Runner) Plugins ¶ added in v0.3.18
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
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.
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.