hooks

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Sep 19, 2026 License: MIT Imports: 18 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 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.

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

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

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

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

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

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

func IsInstalled

func IsInstalled(pluginsDir, id string, plugins []Plugin) (string, bool)

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

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

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 Remove

func Remove(name, pluginsDir string) (dir string, inCheckout bool, err error)

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.

func RunAction

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

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

func (a Action) DisplayLabel() string

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

func Resolve(ctx context.Context, arg string, entries []registry.Entry) (InstallRequest, error)

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"`
	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

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

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

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

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

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

Action returns the action with the given ID.

func (Plugin) RoutinesDir

func (p Plugin) RoutinesDir() string

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

func (Plugin) ScriptFor

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

func (p Plugin) WorkflowsDir() string

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

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

func (r *Runner) HasHostsHandler() bool

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

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

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

OnStatusChange triggers appropriate hooks based on status transition.

func (*Runner) Plugins

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

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

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

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

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.

Jump to

Keyboard shortcuts

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