feat(plugins): add the task.route pre-spawn hook - #690
Open
bborn wants to merge 4 commits into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.routeA 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:
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.createdexists but lives ininternal/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 forclaude_config_dir— the HTTP API exposes it on projects only — so it would have to write SQLite directly, whichdocs/plugins.mdexplicitly warns against. Racy or wrong, either way.How the decision is carried
Task.ClaudeConfigDir, which both command builders (daemonrunClaudeand the TUI'sBuildCommand) 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.HOLDleaves 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 usagecommand backed by aninternal/claudeusagepackage, ~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 agit pullrather than a ty release. What's left here is 900 lines, half of it tests.The router in action (that plugin, driving this hook):
Testing
internal/hooks— stdout parsing (quoting, noise,HOLDaliases, 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.golangci-lint run ./...clean (v2.8.0, matching CI);gofmtclean.Merge order
This first, then taskyou/plugins#1. Until
task.routeexists the plugin loads and does nothing, since the event never fires.🤖 Generated with Claude Code