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 IsInstalled(pluginsDir, id string, plugins []Plugin) (string, bool)
- func LoadSources(pluginsDir string) map[string]InstalledSource
- func PluginRoutineDirs(pluginsDir string) []string
- func PluginWorkflowDirs(pluginsDir string) []string
- func Remove(name, pluginsDir string) (dir string, inCheckout bool, err error)
- func RunAction(ctx context.Context, p Plugin, a Action, task *db.Task) ([]byte, error)
- type Action
- type Host
- type InstallRequest
- type InstallResult
- type InstalledSource
- type Placement
- type PlacementTaskInfo
- type Plugin
- type RouteDecision
- type Runner
- func (r *Runner) HandlesRoute() bool
- func (r *Runner) HasHostsHandler() bool
- func (r *Runner) HasPlacementHandler() bool
- func (r *Runner) ListHosts(ctx context.Context, info PlacementTaskInfo) []Host
- 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 DefaultHostsTimeout = 5 * time.Second
DefaultHostsTimeout bounds a task.hosts handler. This is answered while a human waits on a form rather than in the spawn path, but the answer is only useful while the form is open: past this budget the picker is simply not offered, which is exactly the behaviour of having no plugin at all.
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 EventTaskHosts = "task.hosts"
EventTaskHosts is the second question ty asks a placement plugin: not "where does this task go" but "where COULD it go".
task.placement is answered once, at spawn, by policy. That is the right default and the wrong thing when a person already knows which machine they want — and until now the only way to say so was to create the task, let the resolver choose, then move it with `ty place`. Asking the same plugin for its candidates lets the new-task form offer them up front, so a deliberate choice costs a keystroke instead of a move.
The request is the placement request with no task id (nothing has been created yet); the answer is a list of hosts:
{"hosts":[{"name":"ol-agents","target":"ol-agents",
"workdir":"~/projects/engineering","detail":"serves taskyou"}]}
Core still never learns what a host IS. It asks for names it can show, and hands whichever one the user picked straight back as a placement decision.
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 ¶
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 ¶
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 ¶
FindAction locates a plugin and action by name/id across the given plugins.
func IsInstalled ¶
IsInstalled reports whether a catalog entry is already installed, and under which directory. An entry counts as installed when a directory records it as its source, or when a plugin's manifest name or directory name matches the entry ID — the last two cover a hand-copied plugin, which should still show as installed in the browser rather than inviting a duplicate.
func LoadSources ¶
func LoadSources(pluginsDir string) map[string]InstalledSource
LoadSources returns the provenance records for pluginsDir, keyed by the installed directory's base name. A missing or unreadable file yields an empty map — provenance is a convenience, never a precondition.
func PluginRoutineDirs ¶
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 ¶
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 Remove ¶
Remove uninstalls the plugin named name by deleting its directory.
The name is matched against the loaded plugins' manifest names (what `plugins list` shows), falling back to the directory's base name, so it works even when a plugin's directory name differs from its manifest name.
It returns the removed directory and whether that directory sat inside a multi-plugin collection checkout — in which case only the plugin's own subdir is deleted (leaving the shared git checkout and its siblings), and a later install of the source may restore it. The plugin dir is verified to live strictly inside pluginsDir before anything is deleted, so a corrupt manifest can't point removal at an arbitrary path.
Types ¶
type Action ¶
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 ¶
DisplayLabel returns the label, falling back to the ID.
type Host ¶
type Host struct {
// Name is what the user sees — the inventory's name for the machine.
Name string `json:"name"`
// Target is the SSH destination to record as the placement. Empty means
// "same as Name", which is what an inventory without an ssh field implies.
Target string `json:"target"`
// WorkDir is the project's checkout on that host, which a placement needs
// and a user should not have to look up.
WorkDir string `json:"workdir"`
// Detail is an optional one-liner shown beside the name (why this host is a
// candidate, what it is provisioned for).
Detail string `json:"detail"`
}
Host is one machine a task could be placed on. Everything but Target is for the human reading the list.
type InstallRequest ¶
type InstallRequest struct {
// ID is the catalog entry ID, when the install came from the catalog.
ID string
// Source is the git URL or local path to clone.
Source string
// Subdir is the plugin's path inside Source. When set, only that
// subdirectory is installed, so a user can take one plugin out of a
// collection instead of all of them.
Subdir string
// Name overrides the directory name to install under.
Name string
// Entry is the catalog entry this request came from, for display.
Entry *registry.Entry
}
InstallRequest says what to install. It is the resolved form — Resolve turns whatever the user typed into one of these.
func Resolve ¶
Resolve turns what a user typed into an InstallRequest, consulting the catalog so a bare handle works: `ty plugins add slack`.
The precedence is unambiguous-first: an explicit URL or an existing path is taken literally, and only a bare word is looked up in the catalog. A bare word that isn't in the catalog produces an error naming the closest entries, which is the whole point of having a catalog — a wrong guess should teach, not just fail.
type InstallResult ¶
type InstallResult struct {
// Dir is the installed directory.
Dir string
// Name is that directory's base name.
Name string
// Plugins are the manifest names that became active.
Plugins []string
// Updated is true when an existing install was refreshed rather than created.
Updated bool
}
InstallResult describes what an install actually did.
func Install ¶
func Install(ctx context.Context, pluginsDir string, req InstallRequest) (InstallResult, error)
Install installs (or updates) a plugin into pluginsDir.
Two shapes, because collection repos are the norm: with no Subdir the source is cloned straight into pluginsDir/<name> and every plugin inside becomes active (and a re-install is a `git pull`); with a Subdir the source is cloned to a staging dir and only that subdirectory is copied in, so one plugin can be taken out of a collection of ten.
A fresh install that turns out not to contain a usable plugin is rolled back, so a mistyped URL never leaves a stray directory behind.
func Update ¶
func Update(ctx context.Context, pluginsDir, name string) (InstallResult, error)
Update re-installs an already-installed plugin from wherever it came from: a `git pull` for a checkout, a re-copy from the collection repo for a subdir install. Name is the manifest name or the directory name, as `plugins list` shows it.
The unit of update is the *install root*, not the plugin: one `plugins add` of a collection repo produces one checkout holding many plugins, and updating any of them means pulling that checkout.
func (InstallResult) Verb ¶
func (r InstallResult) Verb() string
Verb returns "Installed" or "Updated", for one-line user feedback.
type InstalledSource ¶
type InstalledSource struct {
// ID is the catalog entry this came from, when it was installed by ID.
ID string `json:"id,omitempty"`
// Source is the git URL (or local path) it was cloned from.
Source string `json:"source"`
// Subdir is the path inside Source that was copied out, if any.
Subdir string `json:"subdir,omitempty"`
// InstalledAt is an RFC3339 timestamp of the last install or update.
InstalledAt string `json:"installed_at,omitempty"`
}
InstalledSource is one directory's provenance.
func InstallRoot ¶
func InstallRoot(pluginsDir, dir string) (root string, src InstalledSource, ok bool)
InstallRoot walks up from an installed plugin's directory to the directory that `plugins add` actually created — the plugin's own dir for a single install, the shared checkout for a plugin nested in a collection repo — and returns the provenance recorded for it, if any.
type Placement ¶
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 ¶
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 ¶
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 ¶
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 ¶
RoutinesDir returns the plugin's routines subdir, whether or not it exists.
func (Plugin) ScriptFor ¶
ScriptFor returns the absolute path to the script handling event, and whether the plugin handles that event at all.
func (Plugin) WorkflowsDir ¶
WorkflowsDir returns the plugin's workflows subdir, whether or not it exists.
type RouteDecision ¶
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 ¶
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 ¶
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 ¶
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) HasHostsHandler ¶
HasHostsHandler reports whether any loaded plugin answers task.hosts. The forms check this before asking, so a user with no placement plugin pays nothing and is shown no host picker.
func (*Runner) HasPlacementHandler ¶
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) ListHosts ¶
func (r *Runner) ListHosts(ctx context.Context, info PlacementTaskInfo) []Host
ListHosts asks the installed handlers which machines could run this task.
Unlike ResolvePlacement, every handler is consulted and the answers are merged: two fleets installed side by side are two sets of candidates, not a race between them. Duplicates (same target) keep the first answer, so plugin name order decides which description wins.
A handler that fails, times out or answers unusably contributes nothing. An empty result is the honest answer to "nothing is offering a choice here" and means the surfaces show no picker at all.
func (*Runner) OnStatusChange ¶
OnStatusChange triggers appropriate hooks based on status transition.
func (*Runner) ResolvePlacement ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
func (s *ServiceSet) Count() int
Count reports how many services are running.
func (*ServiceSet) Stop ¶
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.