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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -688,7 +688,9 @@ A **plugin** is a self-contained directory under `~/.config/task/plugins/` with

- **workflows** (`workflows/*.yaml`) — new `ty pipeline -d <name>` definitions
- **hooks** — scripts that fire on task events. Unlike the one-script-per-event hooks
dir above, any number of plugins can handle the same event and **all of them run**
dir above, any number of plugins can handle the same event and **all of them run**.
One of them, `task.route`, fires *before* a task spawns and lets the plugin pick
which Claude account it runs under — see [Routing](docs/plugins.md#routing-pre-spawn)
- **actions** — user-invoked commands (`ty plugins run <plugin> <action>`)

Install one — or a whole collection, since a single git repo can hold many plugins —
Expand Down
20 changes: 18 additions & 2 deletions docs/plugin-ideas.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ 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/).
📦 = shipped in the [community collection](https://github.com/taskyou/plugins)
(`ty plugins add https://github.com/taskyou/plugins`).

## Notifications & awareness (hooks)

Expand All @@ -28,6 +30,20 @@ can be any language and can bundle its own config/binaries.
- **status-file** — maintain a tiny JSON of live counts for a tmux statusline or
menubar widget.

## Routing (the `task.route` hook)

Fires before a task spawns and its stdout is read back as a decision — the one
hook that changes how a task runs rather than reporting on it. See
[Routing](plugins.md#routing-pre-spawn).

- 📦 **claude-profile-router** — send each task to whichever Claude account has
the most rate-limit headroom; hold the task when both are spent. Ships in the
[community collection](https://github.com/taskyou/plugins).
- **quiet-hours** — `HOLD=1` outside working hours, so overnight queueing doesn't
spend your weekly limit while you sleep.
- **cheap-account-first** — route routine task types (docs, chores) to a Pro
account and keep the Max one for the heavy work.

## Worktree & quality (actions)

- ✅ **worktree** — show the task's diff; run its tests.
Expand All @@ -51,8 +67,8 @@ can be any language and can bundle its own config/binaries.
## 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.
that ship with TaskYou and are covered by the loader's tests. The ✅ entries
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
Expand Down
65 changes: 65 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,75 @@ system. The ones dispatched today:
| `task.blocked` | Task needs input |
| `task.failed` | Agent execution failed |
| `task.auth_required` | Executor session needs re-authentication |
| `task.route` | **Before** a task spawns — the one event you *answer*. See [Routing](#routing-pre-spawn) |

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

## Routing (pre-spawn)

Every hook above is a notification: it fires after the fact, runs detached, and
nothing waits for it. `task.route` is the exception. It fires *before* a task is
spawned, TaskYou waits for it, and it reads your script's **stdout back as a
decision** — which is the only way a plugin can influence how a task runs rather
than just react to it having run.

The decision format is KEY=VALUE, one per line:

```sh
#!/bin/sh
echo "CLAUDE_CONFIG_DIR=$HOME/.claude-work" # run this task under that Claude profile
echo "REASON=7% of its limits used" # optional note for the task log
```

| Key | Effect |
|-----|--------|
| `CLAUDE_CONFIG_DIR` | Run the task under that Claude profile (config dir) |
| `HOLD=1` | Don't start this task yet; leave it queued and reconsider next tick |
| `REASON=…` | Free text, written to the task log alongside the decision |

Everything else on stdout is ignored, so unknown keys and stray output are
harmless — but keep diagnostics on **stderr** (which goes to the daemon log),
since stdout is the decision channel.

**Guarantees.** Printing nothing is always safe, and so is failing:

- **Silence means carry on.** No router installed, a script that errors, exceeds
the 15s timeout, or prints nothing usable — the task spawns exactly as it would
have. Routing is an optimization; failing to optimize never blocks work.
- **An explicit choice wins.** A task that already names a config dir (set by
hand, or by a workflow step's `config_dir:`) is left alone.
- **A routed task stays put.** The decision is written to the task, so a task
resumed later runs under the same profile it started on. This is required, not
merely tidy: a Claude session lives inside one config dir, and resuming under a
different one would find no session and quietly start a fresh conversation. The
cost is that a long-lived task pinned to a profile waits for *that* profile's
limits to reset — it cannot be migrated mid-conversation.
- **First answer stands.** Plugins are consulted in name order and the first
non-empty decision is used, so two installed routers give a deterministic
result.
- **`HOLD` keeps a task queued**, never blocked — it starts by itself once a
later tick gets a different answer. A hold is honored for queued tasks; a task
you started by hand (`ty run`, "start now") runs regardless.
- **Claude only.** `CLAUDE_CONFIG_DIR` means nothing to the codex or gemini
executors, so tasks using them are not routed.

Extra environment on a routing hook, beyond the standard `TASK_*` and
`TASK_PLUGIN_*` variables: `TASK_EXECUTOR` and `TASK_CLAUDE_CONFIG_DIR` (the
task's current config dir, empty when unset).

The worked example is **claude-profile-router** in the
[community collection](https://github.com/taskyou/plugins), which routes each task
to whichever of your Claude accounts has the most rate-limit headroom and holds a
task when every account is spent:

```bash
ty plugins add https://github.com/taskyou/plugins
```

It reads each account's remaining limits itself — ty supplies the hook, the
plugin supplies the policy and the data it routes on.

## Environment

Every hook receives the standard task variables:
Expand Down
17 changes: 17 additions & 0 deletions internal/db/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,23 @@ func (db *DB) UpdateTaskPermissionMode(taskID int64, mode string) error {
return nil
}

// UpdateTaskClaudeConfigDir sets the per-task CLAUDE_CONFIG_DIR override,
// which is how a task is pinned to one Claude profile (account). Writing it as
// its own column update — rather than through UpdateTask — matters at spawn
// time: the routing decision is made from a task struct the daemon has been
// holding, and a full-row write would stomp any field another surface (the TUI,
// a hook) changed in the meantime.
func (db *DB) UpdateTaskClaudeConfigDir(taskID int64, configDir string) error {
_, err := db.Exec(`
UPDATE tasks SET claude_config_dir = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, configDir, taskID)
if err != nil {
return fmt.Errorf("update task claude config dir: %w", err)
}
return nil
}

// UpdateTaskPinned updates only the pinned flag for a task.
func (db *DB) UpdateTaskPinned(taskID int64, pinned bool) error {
_, err := db.Exec(`
Expand Down
18 changes: 18 additions & 0 deletions internal/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -1788,10 +1788,23 @@ func (e *Executor) processNextTask(ctx context.Context) {
// A step deferred for branch contention serves its backoff here. Without
// this gate the task is re-entered on every 2s tick, and each pass writes
// a fresh "Starting task #N" line for a step that cannot start.
//
// This gate goes before routing deliberately: it is a map lookup, while
// routing may shell out to a plugin. A task sitting out a branch backoff
// shouldn't pay for a usage probe on every tick to learn it still can't run.
if !e.branchWaitDue(task.ID) {
continue
}

// Last decision before the spawn: which Claude profile does this run
// under? A routing plugin may pick one (stamping task.ClaudeConfigDir,
// which both command builders already honor) or ask to hold the task
// when every account is out of headroom. With no router installed this
// is a no-op. See routing.go.
if !e.routeTask(ctx, task, true) {
continue
}

// Atomically check-and-set to prevent race where two ticks
// both see the task as not-running and spawn duplicate goroutines
e.mu.Lock()
Expand Down Expand Up @@ -1855,6 +1868,11 @@ func (e *Executor) ExecuteNow(ctx context.Context, taskID int64) error {
e.runningTasks[taskID] = true
e.mu.Unlock()

// Route this run to a Claude profile too, so a manually started task lands
// on the same account the queue would have chosen. A hold is not honored
// here: the user asked for this task to run now.
e.routeTask(ctx, task, false)

e.executeTask(ctx, task)
return nil
}
Expand Down
113 changes: 113 additions & 0 deletions internal/executor/routing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package executor

import (
"context"
"fmt"
"strings"
"sync"

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

// Profile routing gives a plugin the last word on which Claude account a task
// runs under, at the only moment where that word is still worth anything: after
// the task is cleared to run, before its command is built.
//
// Everything downstream already supports this — Task.ClaudeConfigDir has always
// been the per-task profile lever, honored identically by the daemon's command
// builder and the TUI's. What was missing was anyone to set it automatically.
// Routing fills that in: it stamps the column and lets the existing machinery
// carry the decision the rest of the way, so there is no second code path for a
// routed task and no chance of the two builders disagreeing about which profile
// is in play.
//
// Two rules keep it from getting in the way:
//
// - An explicit choice always wins. A task that already names a config dir —
// set by hand, by a workflow step, or by an earlier routing pass — is left
// alone. Routing fills a vacuum; it does not overrule a person.
// - Silence means "carry on". No router installed, a script that fails, times
// out, or prints nothing: the task spawns exactly as it would have before
// any of this existed.

// routeHoldLog remembers the last hold reason logged per task, so a task parked
// behind exhausted profiles writes one log line rather than one per daemon tick.
var routeHoldLog sync.Map // taskID -> last reason written

// routeTask consults the task.route plugin hook and applies its decision.
//
// It returns false only when a router asked to hold the task — every other
// outcome, including every kind of failure, returns true and lets the spawn
// proceed. allowHold is false on the manual "run this now" path: a person who
// explicitly started a task has already made the call, and silently refusing
// would look like the button was broken.
func (e *Executor) routeTask(ctx context.Context, task *db.Task, allowHold bool) bool {
if task == nil || e.hooks == nil {
return true
}
// CLAUDE_CONFIG_DIR is a Claude concept; a codex or gemini task has no
// profile to route between.
if task.Executor != "" && task.Executor != db.ExecutorClaude {
return true
}
if strings.TrimSpace(task.ClaudeConfigDir) != "" {
return true
}
if !e.hooks.HandlesRoute() {
return true
}

decision := e.hooks.Route(ctx, task)
if decision.Empty() {
routeHoldLog.Delete(task.ID)
return true
}

if decision.Hold && allowHold {
e.noteRouteHold(task, decision)
return false
}
routeHoldLog.Delete(task.ID)

dir := strings.TrimSpace(decision.ClaudeConfigDir)
if dir == "" {
return true
}
resolved := ResolveClaudeConfigDir(dir)
if err := e.db.UpdateTaskClaudeConfigDir(task.ID, resolved); err != nil {
// The write is what makes the decision visible to the TUI and to a
// later resume. If it fails, don't apply the route in memory either —
// a task whose spawned profile disagrees with its recorded one is the
// exact confusion this feature is supposed to remove.
e.logger.Error("Failed to record routed Claude profile", "id", task.ID, "dir", resolved, "error", err)
return true
}
task.ClaudeConfigDir = resolved

msg := fmt.Sprintf("Routed to Claude profile %s (by plugin %q)", resolved, decision.Plugin)
if decision.Reason != "" {
msg += ": " + decision.Reason
}
e.logger.Info("Routed task to Claude profile", "id", task.ID, "dir", resolved, "plugin", decision.Plugin)
e.logLine(task.ID, "system", msg)
return true
}

// noteRouteHold records a hold, writing to the task log only when the reason
// changes. The daemon reconsiders a queued task every tick, so an unconditional
// log line would bury the task's real history under thousands of repeats of
// "waiting for headroom".
func (e *Executor) noteRouteHold(task *db.Task, decision hooks.RouteDecision) {
reason := decision.Reason
if reason == "" {
reason = "no Claude profile has headroom right now"
}
e.logger.Info("Holding task: no Claude profile available", "id", task.ID, "plugin", decision.Plugin, "reason", reason)

if prev, ok := routeHoldLog.Load(task.ID); ok && prev == reason {
return
}
routeHoldLog.Store(task.ID, reason)
e.logLine(task.ID, "system", fmt.Sprintf("Waiting to start — %s (plugin %q). Will retry automatically.", reason, decision.Plugin))
}
Loading