Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,6 @@ ty-chrome.zip
.pr-body.md
.pr-comment.md
.qacfg/

# Plugin user config (copied from *.example.env)
examples/plugins/**/config.env
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ The same UI is also served in your browser at `http://localhost:8484` whenever `
- **Git Worktrees** - Each task runs in an isolated worktree, no conflicts between parallel tasks
- **Pluggable Executors** - Choose between Claude Code, OpenAI Codex, Gemini, Pi, OpenClaw, or OpenCode per task
- **Workflows** - Turn one goal into a multi-step DAG (e.g. plan → code → parallel review → collect), each step on its own executor/model, advancing automatically (see [Workflows](#workflows))
- **Event Hooks** - Run scripts when tasks change state (see [Event Hooks](#event-hooks))
- **Event Hooks & Plugins** - Run scripts when tasks change state, or drop in self-contained plugins (see [Event Hooks](#event-hooks) and [Plugins](#plugins))
- **Ghost Text Autocomplete** - LLM-powered suggestions for task titles and descriptions as you type
- **VS Code-style Fuzzy Search** - Quick task navigation with smart matching (e.g., "dsno" matches "diseno website")
- **Markdown Rendering** - Task descriptions render with proper formatting in the detail view
Expand Down Expand Up @@ -630,6 +630,24 @@ TASK_TIMESTAMP # ISO 8601 timestamp

See [examples/hooks/](examples/hooks/) for examples.

### Plugins

The hooks dir above holds **one script per event**, so two integrations that
both want `task.done` collide. **Plugins** solve that: a plugin is a
self-contained directory under `~/.config/task/plugins/` with a `plugin.yaml`
manifest declaring which events it handles. Drop it in and it's live — any
number of plugins can handle the same event, and all of them run.

```bash
cp -R examples/plugins/desktop-notify ~/.config/task/plugins/
ty plugins list
```

See [docs/plugins.md](docs/plugins.md) for the manifest format and the authoring
guide, [`examples/plugins/`](examples/plugins/) for ready-to-copy plugins
(desktop-notify, slack, worktree), and [docs/plugin-ideas.md](docs/plugin-ideas.md)
for a gallery of things to build.

## Configuration

### Settings
Expand Down
3 changes: 3 additions & 0 deletions cmd/task/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,9 @@ Examples:

rootCmd.AddCommand(sessionsCmd)

// Plugins subcommand - inspect installed task plugins
rootCmd.AddCommand(newPluginsCmd())

// Alias: claudes -> sessions (for backwards compatibility)
claudesCmd := &cobra.Command{
Use: "claudes",
Expand Down
142 changes: 142 additions & 0 deletions cmd/task/plugins.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package main

import (
"context"
"fmt"
"os"
"sort"
"strconv"

"github.com/spf13/cobra"

"github.com/bborn/workflow/internal/db"
"github.com/bborn/workflow/internal/hooks"
)

// newPluginsCmd returns the `ty plugins` command group for inspecting installed
// task plugins (self-contained hook bundles under ~/.config/task/plugins/).
func newPluginsCmd() *cobra.Command {
pluginsCmd := &cobra.Command{
Use: "plugins",
Short: "List and inspect installed task plugins",
Long: `Task plugins are self-contained directories under ~/.config/task/plugins/
that react to task events. Each plugin has a plugin.yaml manifest declaring
which events it handles; drop a plugin directory in and it is active. Any number
of plugins may handle the same event.`,
Run: func(cmd *cobra.Command, args []string) {
listPlugins()
},
}

pluginsCmd.AddCommand(&cobra.Command{
Use: "list",
Short: "List installed plugins and the events they handle",
Run: func(cmd *cobra.Command, args []string) { listPlugins() },
})

pluginsCmd.AddCommand(&cobra.Command{
Use: "dir",
Short: "Print the plugins directory path",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println(hooks.DefaultPluginsDir())
},
})

runCmd := &cobra.Command{
Use: "run <plugin> <action> [task-id]",
Short: "Run a plugin action, optionally in the context of a task",
Args: cobra.RangeArgs(2, 3),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
var taskID int64
if len(args) == 3 {
id, err := strconv.ParseInt(args[2], 10, 64)
if err != nil {
return fmt.Errorf("invalid task id %q: %w", args[2], err)
}
taskID = id
}
return runPluginAction(cmd.Context(), args[0], args[1], taskID)
},
}
pluginsCmd.AddCommand(runCmd)

return pluginsCmd
}

func runPluginAction(ctx context.Context, pluginName, actionID string, taskID int64) error {
plugins, warnings := hooks.LoadPlugins(hooks.DefaultPluginsDir())
for _, w := range warnings {
fmt.Fprintln(os.Stderr, "warning: "+w)
}

plugin, action, err := hooks.FindAction(plugins, pluginName, actionID)
if err != nil {
return err
}

// Load the task for context, if one was named.
var task *db.Task
if taskID != 0 {
database, dberr := openTaskDB(db.DefaultPath())
if dberr != nil {
return dberr
}
defer database.Close()
task, err = database.GetTask(taskID)
if err != nil {
return fmt.Errorf("task #%d: %w", taskID, err)
}
}

out, runErr := hooks.RunAction(ctx, plugin, action, task)
if len(out) > 0 {
fmt.Print(string(out))
if out[len(out)-1] != '\n' {
fmt.Println()
}
}
if runErr != nil {
return fmt.Errorf("action %s/%s failed: %w", pluginName, actionID, runErr)
}
return nil
}

func listPlugins() {
dir := hooks.DefaultPluginsDir()
plugins, warnings := hooks.LoadPlugins(dir)

for _, w := range warnings {
fmt.Fprintln(os.Stderr, "warning: "+w)
}

if len(plugins) == 0 {
fmt.Printf("No plugins installed in %s\n", dir)
fmt.Println("Add one by creating <name>/plugin.yaml there. See docs/plugins.md.")
return
}

fmt.Printf("Plugins in %s:\n\n", dir)
for _, p := range plugins {
ver := p.Version
if ver == "" {
ver = "—"
}
fmt.Printf(" %s (%s)\n", p.Name, ver)
if p.Description != "" {
fmt.Printf(" %s\n", p.Description)
}
events := make([]string, 0, len(p.Hooks))
for e := range p.Hooks {
events = append(events, e)
}
sort.Strings(events)
for _, e := range events {
fmt.Printf(" hook %-18s → %s\n", e, p.Hooks[e])
}
for _, a := range p.Actions {
fmt.Printf(" action %-18s → %s (ty plugins run %s %s)\n", a.DisplayLabel(), a.Command, p.Name, a.ID)
}
fmt.Println()
}
}
62 changes: 62 additions & 0 deletions docs/plugin-ideas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Plugin ideas

A menu of things TaskYou plugins can do, to seed community (and your own)
plugins. Everything here is buildable on today's contract — [hooks](plugins.md)
(fire on task events) and [actions](plugins.md#actions-user-invoked) (run on
demand with the task's env). A plugin is just a directory with executables; it
can be any language and can bundle its own config/binaries.

✅ = shipped as an example in [`examples/plugins/`](../examples/plugins/).

## Notifications & awareness (hooks)

- ✅ **desktop-notify** — native OS notification on done/blocked/failed.
- ✅ **slack** — post task updates to a Slack channel (incoming webhook).
- **sound** — play a chime on `task.done`, a different one on `task.failed`
(`afplay`/`paplay`/terminal bell). ~10 lines.
- **say / TTS** — `say "task 42 is done"` (macOS) or `espeak`.
- **phone push** — ntfy.sh / Pushover / Telegram bot for away-from-desk pings.
- **auth-alert** — loud, unmissable alert on `task.auth_required` (the executor
needs re-login and everything is stalled until you notice).

## Logging & analytics (hooks)

- **webhook** — POST the task-event JSON to a configured URL. The universal
primitive; every integration above is a specialization of this.
- **timetrack** — append one JSONL row per event; derive cycle time, throughput,
blocked-rate over time. A personal productivity ledger.
- **status-file** — maintain a tiny JSON of live counts for a tmux statusline or
menubar widget.

## Worktree & quality (actions)

- ✅ **worktree** — show the task's diff; run its tests.
- **lint** — run the project linter in the worktree, surface pass/fail.
- **format** — run the formatter and report what changed.
- **branch-copy** — copy the task's branch name / PR URL to the clipboard.

## Integrations (hooks + actions)

- **linear / jira / github-issue** — on `task.done`, comment or transition the
linked issue; an action to open it. (Needs an id convention + API token in the
plugin's `config.env`.)
- **calendar / timeblock** — log completed tasks to a calendar.
- **journal** — append a daily standup line ("finished #42: …") to a notes file.

## Ambient / fun

- **confetti** — a celebratory splash (or `cowsay`) on `task.done`.
- **now-playing** — pause music while an executor is actively running.

## Where should plugins live? (in-repo vs. own repo)

- **In-repo `examples/plugins/`** — small, canonical, copy-paste starting points
that ship with TaskYou and are covered by the loader's tests. The three above
live here. Best for anything short enough to read in one sitting.
- **Its own repo** — when a plugin grows an independent release cadence, ships a
compiled binary or heavier dependencies, or has a real surface of its own
(config, docs, versioning). Install by dropping (or symlinking) its directory
into `~/.config/task/plugins/`. This is where a bigger integration — say a
token-compressing proxy or a full issue-tracker sync — belongs.

Rule of thumb: **start in `examples/`; graduate to a repo when it earns one.**
136 changes: 136 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# TaskYou plugins

A plugin is a **self-contained directory** that reacts to task events. Drop it
into `~/.config/task/plugins/` and it's live — no rebuild, no config edits, and
no collisions with other plugins. This is the easy on-ramp for community
integrations (notifications, proxies, trackers, chat bridges, …).

It builds on TaskYou's existing [event hooks](../README.md#event-hooks). The
difference: the legacy hooks dir allows **one script per event**, so two
integrations that both want `task.done` fight over the same file. A plugin
namespaces its scripts in its own directory and declares what it handles in a
manifest, so **any number of plugins can handle the same event** and all of them
run.

## Anatomy

```
~/.config/task/plugins/
└── my-plugin/
├── plugin.yaml # manifest (required)
└── on-done.sh # your script(s)
```

### `plugin.yaml`

```yaml
name: my-plugin # required, unique
version: 0.1.0 # optional
description: What it does. # optional
hooks: # event -> script path (relative to this dir)
task.done: on-done.sh
task.blocked: on-blocked.sh
actions: # user-invoked commands (optional)
- id: sync
label: Sync to tracker
command: sync.sh
```

Script paths are resolved relative to the plugin directory. Scripts must be
executable (`chmod +x`). A plugin needs at least one usable hook **or** action.

## Events

Plugins handle the same events as the [event hooks](../README.md#event-hooks)
system. The ones dispatched today:

| Event | When |
|-------|------|
| `task.started` | Execution begins |
| `task.done` | Agent finished successfully |
| `task.blocked` | Task needs input |
| `task.failed` | Agent execution failed |
| `task.auth_required` | Executor session needs re-authentication |

A plugin may declare any event string; it only runs for events TaskYou actually
emits, so unknown events are harmless.

## Environment

Every hook receives the standard task variables:

```
TASK_ID TASK_TITLE TASK_STATUS TASK_PROJECT TASK_TYPE
TASK_MESSAGE TASK_EVENT WORKTREE_PATH
```

Plugin hooks additionally receive:

```
TASK_PLUGIN_NAME # this plugin's name
TASK_PLUGIN_DIR # absolute path to this plugin's directory
```

The script's working directory is set to the plugin directory, so it can read
its own bundled files (config, templates, helper binaries) with relative paths.

## Actions (user-invoked)

Hooks fire automatically on events. **Actions** are commands the user triggers on
demand. Each action has an `id`, an optional `label`, and a `command` (a script
path relative to the plugin dir).

Run one from the CLI, optionally against a task:

```bash
ty plugins run my-plugin sync # no task context
ty plugins run my-plugin sync 42 # with task #42's env
```

An action script receives `TASK_PLUGIN_NAME` / `TASK_PLUGIN_DIR` always, and the
`TASK_*` variables (`TASK_ID`, `TASK_TITLE`, `TASK_STATUS`, `TASK_PROJECT`,
`TASK_TYPE`, `WORKTREE_PATH`) when a task is in context. Unlike hooks, actions
run synchronously (up to 60s) and their output is shown to the caller (the CLI
prints it; the TUI shows the first line in the notification banner).

In the TUI, actions are reachable two ways, both running the same command:

- **Detail view:** press `A` on a task to open a picker of that task's plugin
actions; the chosen one runs with the task's env.
- **Command palette:** open it (`p` / `Ctrl+P`) and type a leading `>` to switch
from task search to action search.

## Behavior & guarantees

- **Fan-out**: for each event, the legacy single-script hook *and* every plugin
that declares the event run, concurrently and in the background. A slow or
failing plugin never blocks task execution or the other plugins.
- **Isolation**: a malformed manifest, a missing script, or a plugin with no
name is skipped (surfaced via `ty plugins list` and the daemon log) — one bad
community plugin can't break your pipeline.
- **Timeout**: each hook is given 30s before it's killed.
- **Deterministic order**: plugins run sorted by name.

## Inspecting

```bash
ty plugins list # what's installed and which events each handles
ty plugins dir # the plugins directory path
```

## Examples

Complete, copy-pasteable plugins live in [`examples/plugins/`](../examples/plugins/):

| Plugin | Kind | What it shows |
|---|---|---|
| [`desktop-notify`](../examples/plugins/desktop-notify/) | hooks + action | native notifications; a `test` action |
| [`slack`](../examples/plugins/slack/) | hooks | webhook integration; bundled `config.env` |
| [`worktree`](../examples/plugins/worktree/) | actions | task-scoped `diff` / `test` using `WORKTREE_PATH` |

```bash
cp -R examples/plugins/desktop-notify ~/.config/task/plugins/
ty plugins list
```

For more, see the [plugin idea gallery](plugin-ideas.md).
Loading
Loading