Skip to content

feat(plugins): add the task.route pre-spawn hook - #690

Open
bborn wants to merge 4 commits into
mainfrom
task/5133-plugin-to-route-between-claude-profiles
Open

feat(plugins): add the task.route pre-spawn hook#690
bborn wants to merge 4 commits into
mainfrom
task/5133-plugin-to-route-between-claude-profiles

Conversation

@bborn

@bborn bborn commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Why

Two Claude logins means a decision on every task: which account should this one spend? That decision was manual and uninformed, so one account got hammered into a 429 while the other sat idle.

This PR adds only the thing a plugin cannot do for itself: a moment, before the spawn, where something gets to choose. The policy and the usage data both live in the plugin — taskyou/plugins#1.

task.route

A new plugin hook, and the first one ty waits on. It fires before a task spawns and reads the script's stdout back as a decision:

CLAUDE_CONFIG_DIR=/Users/me/.claude-work
HOLD=1
REASON=every profile above 90%

Every other hook can only react to a task having run. A router has to answer before the command is built, so this one is synchronous, bounded at 15s, and first-answer-wins in plugin-name order.

Why it can't be a plugin-only feature. Plugins see four events, all dispatched from OnStatusChange — and the earliest, task.started, fires when execution begins, i.e. after the command is already built. (task.created exists but lives in internal/events, which drives only the legacy single-script hooks dir; plugins never receive it.) The workaround would be a service polling for queued tasks, but the daemon ticks every 2s and spawns immediately, and there's no per-task API for claude_config_dir — the HTTP API exposes it on projects only — so it would have to write SQLite directly, which docs/plugins.md explicitly warns against. Racy or wrong, either way.

How the decision is carried

Task.ClaudeConfigDir, which both command builders (daemon runClaude and the TUI's BuildCommand) have always honored. No second spawn path, and no new way for the daemon and TUI to disagree about which profile is in play — the failure mode this repo has hit before.

Session affinity comes for free, and is required. A routed task is stamped once and skipped on later passes, so a resume runs under the same profile. That isn't tidiness: a Claude session lives inside one config dir, and resuming elsewhere would find no session and quietly start a fresh conversation. The cost — a task pinned to a spent profile waits for that profile's reset rather than hopping — is the correct trade and is documented.

Routing is never a reason work doesn't happen. No router installed, a script that fails, times out, or prints nothing — every one of those spawns the task exactly as it would have before. An explicit config dir (set by hand, or by a workflow step's config_dir:) is never overruled. HOLD leaves a task queued, never blocked, so it starts by itself on a later tick; it's ignored for a manually started task, where silently refusing would read as a broken button. Repeated holds log once per distinct reason rather than once per daemon tick.

Scope

An earlier revision of this PR also carried the usage reader — a ty usage command backed by an internal/claudeusage package, ~1250 lines. That's been moved into the plugin, which reads the keychain and calls the endpoint itself. It never needed to be in core, and knowledge of someone else's endpoint is better placed where a change is a git pull rather than a ty release. What's left here is 900 lines, half of it tests.

The router in action (that plugin, driving this hook):

status

Testing

  • internal/hooks — stdout parsing (quoting, noise, HOLD aliases, values containing =), name-order precedence, failing/timed-out/silent scripts, stderr not being parsed as a decision, nil task, cancelled context.
  • internal/executor — apply-and-persist, explicit dir not overridden, resume affinity, non-Claude executors skipped, hold keeps a task queued, hold logged once across ticks, hold ignored on manual run, failing router still spawns, ~ expansion, empty executor treated as claude.
  • Mutation-checked the load-bearing ones: removing the override guard and the hold-log memo each failed exactly the test that claims to cover it.
  • golangci-lint run ./... clean (v2.8.0, matching CI); gofmt clean.
  • Verified end-to-end against two real accounts via the plugin.

Merge order

This first, then taskyou/plugins#1. Until task.route exists the plugin loads and does nothing, since the event never fires.

🤖 Generated with Claude Code

bborn and others added 3 commits August 15, 2026 17:08
Two Claude logins (two CLAUDE_CONFIG_DIRs) means a choice on every task —
which account should this one spend? Until now that choice was manual and
uninformed, so one account got hammered to a 429 while the other sat idle.

Three pieces, each useful on its own:

- internal/claudeusage reads a profile's stored OAuth token (macOS Keychain,
  namespaced per config dir by a sha256 prefix, or .credentials.json) and calls
  the same /api/oauth/usage endpoint Claude Code's own /usage command uses. It
  is strictly read-only: no token is refreshed, rewritten, or printed. The
  endpoint rate-limits and routing probes it on every spawn, so snapshots are
  cached on disk for a minute, and a cached snapshot up to 30 minutes old
  rescues a failed probe rather than leaving routing blind.

- `ty usage` surfaces those numbers per profile. --percent prints one bare
  number, because the caller that matters is a shell script with no JSON parser.

- task.route is a new plugin hook, and the first one TaskYou *waits* on: it
  fires before a task spawns and reads the script's stdout back as a decision
  (CLAUDE_CONFIG_DIR=…, HOLD=1, REASON=…). Every other hook can only react to a
  task having run; a router has to answer before the command is built.

The decision is carried by Task.ClaudeConfigDir, which both command builders
have always honored — so there is no second spawn path and no way for the
daemon and the TUI to disagree about which profile is in play. It also gives
session affinity for free: a routed task is stamped once and stays there, which
is required, not merely tidy, since a Claude session lives inside one config dir
and resuming elsewhere would silently start a fresh conversation.

Routing never becomes a reason work doesn't happen: no router, a failing script,
a timeout, or an unreadable profile all mean the task spawns exactly as it would
have. An explicit config dir (set by hand or by a workflow step) is never
overruled. HOLD leaves a task queued, never blocked, so it starts by itself once
limits reset — and is ignored on a manual run.

examples/plugins/claude-profile-router is the working plugin: list your profile
dirs, and each task goes to the account with the most headroom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both changes gate the spawn loop at the same point. The branch-contention
backoff runs first: it is a map lookup, while routing may shell out to a
plugin, and a task serving a backoff shouldn't pay for a usage probe every
tick to learn it still can't start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It has config, a README, and a surface of its own — the repo's own rule is
"start in examples/; graduate to a repo when it earns one", and this one earns
it. examples/plugins/ stays what it says on the tin: short, canonical starting
points you read in one sitting.

The hook stays here, because that is core: task.route is a ty event, and only ty
can emit it. What moves is the policy that answers it.

Plugin lands in taskyou/plugins; docs here point at the collection instead of a
local directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ty usage` and internal/claudeusage were ~79% of this branch, and none of it had
to be here — the plugin can read the keychain and call the endpoint itself, as it
now does.

What's left is the part a plugin genuinely cannot do. Plugins see four events,
all from OnStatusChange, and the earliest of them (task.started) fires after the
spawn command is already built. The alternative — a service polling for queued
tasks — races a 2s tick and would have to write claude_config_dir straight into
SQLite, since the HTTP API exposes that field on projects only. So ty has to
offer the moment; it does not have to offer the data.

Moving it also puts knowledge of someone else's endpoint where it can be fixed
with a git pull instead of a ty release, which matters for something this likely
to drift.

Core keeps: the task.route hook, its executor wiring, and
UpdateTaskClaudeConfigDir. Data and policy both live in
taskyou/plugins#1 now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@bborn bborn changed the title feat(plugins): route tasks to the Claude profile with the most headroom feat(plugins): add the task.route pre-spawn hook Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant