From 170b2e7c2ce98aaf3965f8ea7dc8d5916a49acb3 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sat, 15 Aug 2026 17:23:52 -0500 Subject: [PATCH 1/2] Add claude-profile-router: route tasks by Claude account headroom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Claude logins means a choice on every task — which account should this one spend? Made by hand it is uninformed, so one gets hammered into a 429 while the other sits idle. This plugin answers ty's task.route hook, which fires just before a task spawns and reads the script's stdout back as a decision. It asks `ty usage` how much of each profile's limits are gone, sends the task to the one with the most headroom, and holds it in the queue when every account is spent. First hook plugin in the collection rather than a workflow, so it needs a ty new enough to emit task.route (taskyou#690) — the README says so and `ty usage` is the check. Failure is always silent: no credentials, an expired login, ty missing from the daemon's PATH, or any other surprise means the plugin prints nothing and the task spawns exactly as it would have without it. Co-Authored-By: Claude Opus 5 --- README.md | 11 +- claude-profile-router/README.md | 126 +++++++++++++++++++++++ claude-profile-router/config.example.env | 21 ++++ claude-profile-router/plugin.yaml | 19 ++++ claude-profile-router/route.sh | 102 ++++++++++++++++++ claude-profile-router/status.sh | 31 ++++++ 6 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 claude-profile-router/README.md create mode 100644 claude-profile-router/config.example.env create mode 100644 claude-profile-router/plugin.yaml create mode 100755 claude-profile-router/route.sh create mode 100755 claude-profile-router/status.sh diff --git a/README.md b/README.md index 95cad41..1627fbf 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ same command to update them all (`git pull` under the hood). |--------|-------------------| | **rpi** | `ty pipeline -d rpi ""` — Research → Plan → Implement, **human-gated *and* reality-gated**. Neutral research questions, goal-blind research, a design gate and a plan gate you approve, then implement + simplify — each gated on your build + tests — then a PR. A human okays the approach; the machine proves the code. | | **plan-code-review** | `ty pipeline -d plan-code-review ""` — Plan → Code → two parallel reviewers → collect, on one shared branch. Each step's model/executor is configurable per project. | +| **claude-profile-router** | Two Claude logins? Each task goes to whichever account has the most rate-limit headroom left, and waits in the queue when both are spent. The one **hook** plugin here rather than a workflow — it answers ty's `task.route` hook just before a task spawns. | | **arc-solve** | `ty pipeline -d arc-solve "solve "` — play a live [ARC-AGI-3](https://arcprize.org/arc-agi/3) game and complete a level. The gate **replays your solution against the real game**, so a win can't be faked. Needs only an ARC API key at `~/.config/arc/key`. | ## The idea: gate progress on reality @@ -51,9 +52,15 @@ Workflows are picked up by convention — any `workflows/*.yaml` in a plugin bec `ty pipeline -d ` definition. To author your own, add a directory here (or in your own repo) and open a PR. +Most plugins here are workflows, which work on any `ty`. A plugin that declares a +**hook** needs a `ty` new enough to emit that event — `claude-profile-router` uses +`task.route`, which fires before a task spawns. Each plugin's README states what it +needs; `ty plugins list` shows what your install actually discovered. + ## Trust Installing a plugin runs its scripts and workflow prompts with your agent's access, and a workflow step can set `env:` / `config_dir:` that route your agent's -credentials. **Only `ty plugins add` sources you trust.** Read a plugin before you -install it. +credentials — as can a `task.route` hook, for every task. `claude-profile-router` +additionally reads your stored Claude credentials to check each account's usage. +**Only `ty plugins add` sources you trust.** Read a plugin before you install it. diff --git a/claude-profile-router/README.md b/claude-profile-router/README.md new file mode 100644 index 0000000..ef9a1af --- /dev/null +++ b/claude-profile-router/README.md @@ -0,0 +1,126 @@ +# claude-profile-router + +Route each task to whichever of your Claude accounts has the most rate-limit +headroom left. + +If you have two logins — a personal one and a work one, say — you already have +two Claude config dirs. This plugin checks how much of each account's 5-hour and +weekly limits are spent and points every task ty spawns at the one with room. If +both are spent, it holds the task in the queue instead of burning a session on a +429. + +``` +Routing threshold: skip a profile at or above 90% used + +/Users/me/.claude-personal + me@personal.example + 99% used (weekly, resets Sun 05:00) — 1% headroom + +/Users/me/.claude-work + me@work.example + 11% used (weekly, resets Thu 16:00) — 89% headroom +``` + +→ the next task runs under `.claude-work`. + +## Requirements + +A `ty` that supports the **`task.route`** hook and the **`ty usage`** command +(TaskYou [#690](https://github.com/bborn/taskyou/pull/690)). Check with: + +```bash +ty usage --help # if this errors, upgrade: ty upgrade +``` + +Unlike the other plugins in this collection, this one is a *hook* plugin rather +than a workflow — it needs that hook in ty itself, not just a workflow file. + +## Setup + +1. **Have two profiles.** A profile is a `CLAUDE_CONFIG_DIR` with its own login: + + ```bash + CLAUDE_CONFIG_DIR=~/.claude-work claude # then /login as the second account + ``` + +2. **Install and configure:** + + ```bash + ty plugins add https://github.com/taskyou/plugins + cd "$(ty plugins dir)/plugins/claude-profile-router" + cp config.example.env config.env + $EDITOR config.env # list your profile dirs in TY_CLAUDE_PROFILES + ``` + + (`ty plugins add` clones the collection into `/plugins/`, so each + plugin lives one level down. `ty plugins list` shows what was discovered.) + +3. **Check it sees both accounts:** + + ```bash + ty plugins run claude-profile-router status + ``` + +That's it — the next task ty spawns is routed. `ty logs` and the task's own log +record which profile it landed on and why. + +## How it decides + +- Each profile's **binding limit** is the worst of its reported windows (5-hour + session, weekly, per-model weekly). A session window at 98% blocks the next + task even when the weekly one is untouched, so the worst window is the one + that matters. +- The profile with the **lowest** binding percent wins. +- Profiles at or above `TY_CLAUDE_MAX_PERCENT` (default 90) are skipped. The + margin exists because usage is sampled at spawn, not metered continuously — + a long task started at 89% can still cross the line mid-run. +- If every profile is over the threshold, the task is **held**: it stays queued + and is reconsidered on the next daemon tick, with one log line saying why. +- A task that already names a config dir (set by hand, or by a workflow step) is + left alone. Routing fills a vacuum; it doesn't overrule you. +- **A task is routed once and stays there.** Its Claude session lives inside that + config dir, so a resume has to happen under the same profile or it would start + a fresh conversation. A task already running on a profile therefore waits for + *that* profile to reset rather than hopping to the other one. +- Anything that goes wrong — no credentials, an expired login, `ty` not on the + daemon's `PATH` — means the plugin says nothing and the task spawns exactly as + it would have without it. + +## Configuration + +See [`config.example.env`](config.example.env). The knobs: + +| Variable | Default | Meaning | +| --- | --- | --- | +| `TY_CLAUDE_PROFILES` | *(required)* | Space-separated config dirs to route between | +| `TY_CLAUDE_MAX_PERCENT` | `90` | Skip a profile at or above this percent used | +| `TY_CLAUDE_PROJECTS` | *(all)* | Only route tasks in these projects | +| `TY_BIN` | `ty` | Path to the ty binary, if the daemon's `PATH` lacks it | + +Anything already set in the environment overrides `config.env`, so you can try a +threshold without editing the file: + +```bash +TY_CLAUDE_MAX_PERCENT=50 ./route.sh +``` + +## Caveats + +- **A config dir is more than an account.** It also carries that profile's + plugins, MCP servers, and trusted-worktree state. Set both profiles up the + same way, or a task routed to the quieter one may find tools missing. If you + want to swap only credentials, use a per-task `env:` override instead (see + `docs/plugins.md` in the main repo). +- **Usage is read, never written.** `ty usage` reads each profile's stored OAuth + token to call the same endpoint Claude Code's `/usage` uses. It never + refreshes or rewrites a credential. A profile whose token has gone stale + reports as unavailable until you run a `claude` session under it. +- **Two probes per spawn**, each a single HTTPS GET, and ty caches the result for + a minute. The hook is capped at 15s; if it overruns, the task spawns normally. + +## Trust + +This plugin reads your Claude credentials (to call the usage endpoint) and +decides which account your tasks spend. Read `route.sh` before installing — it's +about 90 lines of shell and does exactly two things: shell out to `ty usage`, and +print the winning directory. diff --git a/claude-profile-router/config.example.env b/claude-profile-router/config.example.env new file mode 100644 index 0000000..19550d8 --- /dev/null +++ b/claude-profile-router/config.example.env @@ -0,0 +1,21 @@ +# Copy to config.env (next to this file) and edit. + +# The Claude profiles to route between: a space-separated list of +# CLAUDE_CONFIG_DIR paths. Each is one logged-in account. +# +# To create a second profile, log in with a different config dir: +# CLAUDE_CONFIG_DIR=~/.claude-work claude # then /login +# +# Check what each one is with: ty usage +TY_CLAUDE_PROFILES="$HOME/.claude $HOME/.claude-work" + +# Skip a profile once its binding limit (5-hour session or weekly, whichever is +# worse) is at or above this percent. When every profile is above it, tasks are +# held in the queue instead of spawning into a 429. +TY_CLAUDE_MAX_PERCENT=90 + +# Only route tasks in these projects (space-separated). Empty = all projects. +TY_CLAUDE_PROJECTS="" + +# Path to the ty binary, if the daemon's PATH doesn't include it. +# TY_BIN=/usr/local/bin/ty diff --git a/claude-profile-router/plugin.yaml b/claude-profile-router/plugin.yaml new file mode 100644 index 0000000..02b3ee2 --- /dev/null +++ b/claude-profile-router/plugin.yaml @@ -0,0 +1,19 @@ +name: claude-profile-router +version: 0.1.0 +description: >- + Send each task to whichever Claude account has the most rate-limit headroom + left, and hold it in the queue when every account is spent. Install with + `ty plugins add https://github.com/taskyou/plugins`, then copy + config.example.env to config.env and list your profile dirs. Needs a ty with + `task.route` support — check with `ty usage`. + +hooks: + # task.route fires just before a task spawns and ty reads this script's stdout + # back as a decision — the one hook that changes how a task runs rather than + # reacting to it having run. See docs/plugins.md in the main repo. + task.route: route.sh + +actions: + - id: status + label: Show usage for each Claude profile + command: status.sh diff --git a/claude-profile-router/route.sh b/claude-profile-router/route.sh new file mode 100755 index 0000000..bd72ef7 --- /dev/null +++ b/claude-profile-router/route.sh @@ -0,0 +1,102 @@ +#!/bin/bash +# task.route hook: pick the Claude profile with the most rate-limit headroom. +# +# ty runs this synchronously just before it spawns a task and reads stdout back +# as the decision, so stdout carries KEY=VALUE lines and nothing else — every +# diagnostic goes to stderr (which lands in the daemon log). +# +# CLAUDE_CONFIG_DIR= run this task under that profile +# HOLD=1 / REASON= every profile is spent; keep the task queued +# (no output) no opinion; ty spawns as already configured +# +# Printing nothing is always safe, so every failure path here does exactly that. +set -uo pipefail + +say() { echo "claude-profile-router: $*" >&2; } + +# config.env holds the settings, but anything already in the environment wins — +# that is what makes a one-off `TY_CLAUDE_MAX_PERCENT=50 ./route.sh` a usable way +# to try a threshold without editing the file. +_pre_profiles="${TY_CLAUDE_PROFILES:-}" +_pre_max="${TY_CLAUDE_MAX_PERCENT:-}" +_pre_projects="${TY_CLAUDE_PROJECTS:-}" +_pre_bin="${TY_BIN:-}" +if [[ -n "${TASK_PLUGIN_DIR:-}" && -f "$TASK_PLUGIN_DIR/config.env" ]]; then + # shellcheck disable=SC1091 + source "$TASK_PLUGIN_DIR/config.env" +fi +[[ -n "$_pre_profiles" ]] && TY_CLAUDE_PROFILES="$_pre_profiles" +[[ -n "$_pre_max" ]] && TY_CLAUDE_MAX_PERCENT="$_pre_max" +[[ -n "$_pre_projects" ]] && TY_CLAUDE_PROJECTS="$_pre_projects" +[[ -n "$_pre_bin" ]] && TY_BIN="$_pre_bin" + +TY="${TY_BIN:-ty}" +MAX_PERCENT="${TY_CLAUDE_MAX_PERCENT:-90}" + +if [[ -z "${TY_CLAUDE_PROFILES:-}" ]]; then + say "TY_CLAUDE_PROFILES not set (see config.example.env)" + exit 0 +fi + +if ! command -v "$TY" >/dev/null 2>&1; then + say "ty not found on PATH (set TY_BIN in config.env)" + exit 0 +fi + +# Optional project allowlist, so routing can be tried on one project first. +if [[ -n "${TY_CLAUDE_PROJECTS:-}" ]]; then + match="" + for p in $TY_CLAUDE_PROJECTS; do + [[ "$p" == "${TASK_PROJECT:-}" ]] && match=1 && break + done + [[ -z "$match" ]] && exit 0 +fi + +best_dir="" +best_pct="" +exhausted_low="" # lowest usage among profiles that were over the threshold + +for raw in $TY_CLAUDE_PROFILES; do + dir="${raw/#\~/$HOME}" + + # --percent prints one bare number: the binding limit's used percent. + if ! pct=$("$TY" usage --config-dir "$dir" --percent 2>/dev/null); then + say "skipping $dir (usage unavailable — expired login?)" + continue + fi + if [[ ! "$pct" =~ ^[0-9]+$ ]]; then + say "skipping $dir (unparseable usage: '$pct')" + continue + fi + + if (( pct >= MAX_PERCENT )); then + say "$dir at ${pct}% (>= ${MAX_PERCENT}%), skipping" + if [[ -z "$exhausted_low" ]] || (( pct < exhausted_low )); then + exhausted_low="$pct" + fi + continue + fi + + if [[ -z "$best_pct" ]] || (( pct < best_pct )); then + best_pct="$pct" + best_dir="$dir" + fi +done + +if [[ -n "$best_dir" ]]; then + say "routing to $best_dir (${best_pct}% used)" + echo "CLAUDE_CONFIG_DIR=$best_dir" + echo "REASON=${best_pct}% of its binding limit used" + exit 0 +fi + +# Nothing usable. Only hold the task if we actually saw an exhausted profile — +# if every probe merely failed, stay out of the way and let ty spawn normally +# rather than parking the whole board behind a broken credential lookup. +if [[ -n "$exhausted_low" ]]; then + echo "HOLD=1" + echo "REASON=every Claude profile is at or above ${MAX_PERCENT}% (best is ${exhausted_low}%)" + exit 0 +fi + +say "no profile could be evaluated; leaving this task alone" diff --git a/claude-profile-router/status.sh b/claude-profile-router/status.sh new file mode 100755 index 0000000..bd630a9 --- /dev/null +++ b/claude-profile-router/status.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# `ty plugins run claude-profile-router status` — show what the router sees. +# Same numbers route.sh decides on, so a surprising routing choice can be +# checked against reality without reading the daemon log. +set -uo pipefail + +_pre_profiles="${TY_CLAUDE_PROFILES:-}" +_pre_max="${TY_CLAUDE_MAX_PERCENT:-}" +_pre_bin="${TY_BIN:-}" +if [[ -n "${TASK_PLUGIN_DIR:-}" && -f "$TASK_PLUGIN_DIR/config.env" ]]; then + # shellcheck disable=SC1091 + source "$TASK_PLUGIN_DIR/config.env" +fi +[[ -n "$_pre_profiles" ]] && TY_CLAUDE_PROFILES="$_pre_profiles" +[[ -n "$_pre_max" ]] && TY_CLAUDE_MAX_PERCENT="$_pre_max" +[[ -n "$_pre_bin" ]] && TY_BIN="$_pre_bin" + +TY="${TY_BIN:-ty}" +MAX_PERCENT="${TY_CLAUDE_MAX_PERCENT:-90}" + +if [[ -z "${TY_CLAUDE_PROFILES:-}" ]]; then + echo "No profiles configured. Copy config.example.env to config.env and set TY_CLAUDE_PROFILES." + exit 0 +fi + +echo "Routing threshold: skip a profile at or above ${MAX_PERCENT}% used" +echo +for raw in $TY_CLAUDE_PROFILES; do + dir="${raw/#\~/$HOME}" + "$TY" usage --config-dir "$dir" || echo " (unavailable)" +done From e63f9ce1b15aa60a121764ab4b9e6af81555afa2 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sat, 15 Aug 2026 17:44:52 -0500 Subject: [PATCH 2/2] Read usage in the plugin instead of leaning on a ty command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first draft had ty carry the usage reader (a `ty usage` command backed by a Go package) and this plugin just call it. That put ~1250 lines of knowledge about someone else's endpoint into a released binary, where a change on Anthropic's side needs a ty release to fix. Here it's a git pull. usage.sh does what that package did: read the profile's OAuth token (macOS Keychain, namespaced per config dir by a hash of its path, or .credentials.json elsewhere), call the usage endpoint, and report the binding window — the worst of session/weekly/per-model, since that's the one that stops work first. The endpoint rate-limits and routing probes it on every spawn, so readings are cached for a minute, with a 30-minute stale window that rescues a failed read rather than leaving the router blind. JSON needs a real parser, so it's jq or python3. TY_CLAUDE_JSON forces one: without it a machine with both would only ever exercise the jq branch and the python3 dialect could rot unnoticed. Verified both agree, on real accounts. Nothing shells out to ty any more, which also retires the "ty isn't on the daemon's PATH" failure mode. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- claude-profile-router/README.md | 81 +++++--- claude-profile-router/config.example.env | 9 +- claude-profile-router/plugin.yaml | 4 +- claude-profile-router/route.sh | 19 +- claude-profile-router/status.sh | 21 +- claude-profile-router/usage.sh | 254 +++++++++++++++++++++++ 7 files changed, 341 insertions(+), 49 deletions(-) create mode 100755 claude-profile-router/usage.sh diff --git a/README.md b/README.md index 1627fbf..d60f0a8 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ same command to update them all (`git pull` under the hood). |--------|-------------------| | **rpi** | `ty pipeline -d rpi ""` — Research → Plan → Implement, **human-gated *and* reality-gated**. Neutral research questions, goal-blind research, a design gate and a plan gate you approve, then implement + simplify — each gated on your build + tests — then a PR. A human okays the approach; the machine proves the code. | | **plan-code-review** | `ty pipeline -d plan-code-review ""` — Plan → Code → two parallel reviewers → collect, on one shared branch. Each step's model/executor is configurable per project. | -| **claude-profile-router** | Two Claude logins? Each task goes to whichever account has the most rate-limit headroom left, and waits in the queue when both are spent. The one **hook** plugin here rather than a workflow — it answers ty's `task.route` hook just before a task spawns. | +| **claude-profile-router** | Two Claude logins? Each task goes to whichever account has the most rate-limit headroom left, and waits in the queue when both are spent. The one **hook** plugin here rather than a workflow — it answers ty's `task.route` hook just before a task spawns. Needs `jq` or `python3`. | | **arc-solve** | `ty pipeline -d arc-solve "solve "` — play a live [ARC-AGI-3](https://arcprize.org/arc-agi/3) game and complete a level. The gate **replays your solution against the real game**, so a win can't be faked. Needs only an ARC API key at `~/.config/arc/key`. | ## The idea: gate progress on reality diff --git a/claude-profile-router/README.md b/claude-profile-router/README.md index ef9a1af..969ca62 100644 --- a/claude-profile-router/README.md +++ b/claude-profile-router/README.md @@ -10,30 +10,31 @@ both are spent, it holds the task in the queue instead of burning a session on a 429. ``` +$ ty plugins run claude-profile-router status Routing threshold: skip a profile at or above 90% used /Users/me/.claude-personal me@personal.example - 99% used (weekly, resets Sun 05:00) — 1% headroom + 99% used (weekly_all, resets 2026-08-16 10:00:00) + -> skipped by the router (at or above 90%) /Users/me/.claude-work me@work.example - 11% used (weekly, resets Thu 16:00) — 89% headroom + 12% used (weekly_all, resets 2026-08-20 21:00:00) ``` → the next task runs under `.claude-work`. ## Requirements -A `ty` that supports the **`task.route`** hook and the **`ty usage`** command -(TaskYou [#690](https://github.com/bborn/taskyou/pull/690)). Check with: - -```bash -ty usage --help # if this errors, upgrade: ty upgrade -``` - -Unlike the other plugins in this collection, this one is a *hook* plugin rather -than a workflow — it needs that hook in ty itself, not just a workflow file. +- **A `ty` that emits the `task.route` hook** (TaskYou + [#690](https://github.com/bborn/taskyou/pull/690)). Unlike the other plugins in + this collection, this one is a *hook* plugin rather than a workflow, so it needs + support in ty itself, not just a workflow file. On an older ty the plugin loads + and does nothing — the event never fires. To confirm it's working, start a task + and look for a `Routed to Claude profile …` line in its log. +- **`jq` or `python3`** on the daemon's `PATH`, to read the usage API's JSON. + macOS has shipped `/usr/bin/jq` for a while; otherwise `brew install jq`. ## Setup @@ -58,7 +59,7 @@ than a workflow — it needs that hook in ty itself, not just a workflow file. 3. **Check it sees both accounts:** ```bash - ty plugins run claude-profile-router status + ty plugins run claude-profile-router status # or just: ./status.sh ``` That's it — the next task ty spawns is routed. `ty logs` and the task's own log @@ -82,9 +83,12 @@ record which profile it landed on and why. config dir, so a resume has to happen under the same profile or it would start a fresh conversation. A task already running on a profile therefore waits for *that* profile to reset rather than hopping to the other one. -- Anything that goes wrong — no credentials, an expired login, `ty` not on the - daemon's `PATH` — means the plugin says nothing and the task spawns exactly as - it would have without it. +- Anything that goes wrong — no credentials, an expired login, no `jq`/`python3`, + the usage API unreachable with no cached reading — means the plugin says nothing + and the task spawns exactly as it would have without it. The one exception is + deliberate: if *every* profile merely failed to probe, it does **not** hold your + tasks, because that's the plugin being broken rather than the accounts being + spent. ## Configuration @@ -95,7 +99,9 @@ See [`config.example.env`](config.example.env). The knobs: | `TY_CLAUDE_PROFILES` | *(required)* | Space-separated config dirs to route between | | `TY_CLAUDE_MAX_PERCENT` | `90` | Skip a profile at or above this percent used | | `TY_CLAUDE_PROJECTS` | *(all)* | Only route tasks in these projects | -| `TY_BIN` | `ty` | Path to the ty binary, if the daemon's `PATH` lacks it | +| `TY_CLAUDE_JSON` | `auto` | Force a JSON backend (`jq` or `python3`) | +| `TY_CLAUDE_CACHE_TTL` | `60` | Seconds a usage reading is served before refetching | +| `TY_CLAUDE_CACHE_STALE` | `1800` | Seconds a cached reading stays usable when a live read fails | Anything already set in the environment overrides `config.env`, so you can try a threshold without editing the file: @@ -111,16 +117,41 @@ TY_CLAUDE_MAX_PERCENT=50 ./route.sh same way, or a task routed to the quieter one may find tools missing. If you want to swap only credentials, use a per-task `env:` override instead (see `docs/plugins.md` in the main repo). -- **Usage is read, never written.** `ty usage` reads each profile's stored OAuth - token to call the same endpoint Claude Code's `/usage` uses. It never - refreshes or rewrites a credential. A profile whose token has gone stale - reports as unavailable until you run a `claude` session under it. -- **Two probes per spawn**, each a single HTTPS GET, and ty caches the result for - a minute. The hook is capped at 15s; if it overruns, the task spawns normally. +- **Usage is read, never written.** `usage.sh` reads each profile's stored OAuth + token — from the macOS Keychain, or `/.credentials.json` elsewhere — + to call the same endpoint Claude Code's `/usage` uses. It never refreshes, + rewrites, or prints a credential. A profile whose token has gone stale reports + as unavailable until you run a `claude` session under it. +- **The keychain lookup depends on undocumented Anthropic behavior.** Claude Code + namespaces each config dir's credentials by a hash of its path; `usage.sh` + reproduces that. If Anthropic changes it, profiles report "no credentials" + (loudly, on stderr) and routing stops — it never silently reads as "0% used". +- **Two probes per spawn**, each a single HTTPS GET, cached for a minute under + `~/.cache/ty/claude-usage`. The endpoint rate-limits, so the cache is not + optional; a cached reading up to 30 minutes old is used if a live read fails. + The hook is capped at 15s by ty; if it overruns, the task spawns normally. ## Trust This plugin reads your Claude credentials (to call the usage endpoint) and -decides which account your tasks spend. Read `route.sh` before installing — it's -about 90 lines of shell and does exactly two things: shell out to `ty usage`, and -print the winning directory. +decides which account your tasks spend. Read it before installing — it's two +short shell scripts: + +- `usage.sh` — reads one profile's token and reports its used percent. +- `route.sh` — asks `usage.sh` about each profile and prints the winner. + +## Files + +| File | What it is | +| --- | --- | +| `route.sh` | The `task.route` hook: picks a profile, or holds the task | +| `usage.sh` | Reads one profile's rate-limit usage (`usage.sh percent\|show `) | +| `status.sh` | The `status` action: what the router currently sees | +| `config.env` | Your profiles and threshold (copy from `config.example.env`) | + +`usage.sh` is usable on its own: + +```bash +./usage.sh percent ~/.claude-work # -> 12 +./usage.sh show ~/.claude-work +``` diff --git a/claude-profile-router/config.example.env b/claude-profile-router/config.example.env index 19550d8..41a86ca 100644 --- a/claude-profile-router/config.example.env +++ b/claude-profile-router/config.example.env @@ -6,7 +6,7 @@ # To create a second profile, log in with a different config dir: # CLAUDE_CONFIG_DIR=~/.claude-work claude # then /login # -# Check what each one is with: ty usage +# Check what each one is with: ./status.sh TY_CLAUDE_PROFILES="$HOME/.claude $HOME/.claude-work" # Skip a profile once its binding limit (5-hour session or weekly, whichever is @@ -17,5 +17,8 @@ TY_CLAUDE_MAX_PERCENT=90 # Only route tasks in these projects (space-separated). Empty = all projects. TY_CLAUDE_PROJECTS="" -# Path to the ty binary, if the daemon's PATH doesn't include it. -# TY_BIN=/usr/local/bin/ty +# Advanced (usage.sh): force a JSON backend (auto|jq|python3), relocate or +# retune the usage cache. Defaults are fine. +# TY_CLAUDE_JSON=auto +# TY_CLAUDE_CACHE_TTL=60 +# TY_CLAUDE_CACHE_STALE=1800 diff --git a/claude-profile-router/plugin.yaml b/claude-profile-router/plugin.yaml index 02b3ee2..c13c335 100644 --- a/claude-profile-router/plugin.yaml +++ b/claude-profile-router/plugin.yaml @@ -4,8 +4,8 @@ description: >- Send each task to whichever Claude account has the most rate-limit headroom left, and hold it in the queue when every account is spent. Install with `ty plugins add https://github.com/taskyou/plugins`, then copy - config.example.env to config.env and list your profile dirs. Needs a ty with - `task.route` support — check with `ty usage`. + config.example.env to config.env and list your profile dirs. Needs a ty that + emits the `task.route` hook, plus jq or python3. See README.md. hooks: # task.route fires just before a task spawns and ty reads this script's stdout diff --git a/claude-profile-router/route.sh b/claude-profile-router/route.sh index bd72ef7..1e10359 100755 --- a/claude-profile-router/route.sh +++ b/claude-profile-router/route.sh @@ -12,6 +12,7 @@ # Printing nothing is always safe, so every failure path here does exactly that. set -uo pipefail +PLUGIN_DIR="${TASK_PLUGIN_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" say() { echo "claude-profile-router: $*" >&2; } # config.env holds the settings, but anything already in the environment wins — @@ -20,17 +21,14 @@ say() { echo "claude-profile-router: $*" >&2; } _pre_profiles="${TY_CLAUDE_PROFILES:-}" _pre_max="${TY_CLAUDE_MAX_PERCENT:-}" _pre_projects="${TY_CLAUDE_PROJECTS:-}" -_pre_bin="${TY_BIN:-}" -if [[ -n "${TASK_PLUGIN_DIR:-}" && -f "$TASK_PLUGIN_DIR/config.env" ]]; then +if [[ -f "$PLUGIN_DIR/config.env" ]]; then # shellcheck disable=SC1091 - source "$TASK_PLUGIN_DIR/config.env" + source "$PLUGIN_DIR/config.env" fi [[ -n "$_pre_profiles" ]] && TY_CLAUDE_PROFILES="$_pre_profiles" [[ -n "$_pre_max" ]] && TY_CLAUDE_MAX_PERCENT="$_pre_max" [[ -n "$_pre_projects" ]] && TY_CLAUDE_PROJECTS="$_pre_projects" -[[ -n "$_pre_bin" ]] && TY_BIN="$_pre_bin" -TY="${TY_BIN:-ty}" MAX_PERCENT="${TY_CLAUDE_MAX_PERCENT:-90}" if [[ -z "${TY_CLAUDE_PROFILES:-}" ]]; then @@ -38,11 +36,6 @@ if [[ -z "${TY_CLAUDE_PROFILES:-}" ]]; then exit 0 fi -if ! command -v "$TY" >/dev/null 2>&1; then - say "ty not found on PATH (set TY_BIN in config.env)" - exit 0 -fi - # Optional project allowlist, so routing can be tried on one project first. if [[ -n "${TY_CLAUDE_PROJECTS:-}" ]]; then match="" @@ -59,8 +52,10 @@ exhausted_low="" # lowest usage among profiles that were over the threshold for raw in $TY_CLAUDE_PROFILES; do dir="${raw/#\~/$HOME}" - # --percent prints one bare number: the binding limit's used percent. - if ! pct=$("$TY" usage --config-dir "$dir" --percent 2>/dev/null); then + # usage.sh prints one bare number: the binding limit's used percent. It exits + # non-zero for anything it can't vouch for (no credentials, expired login, a + # failed read with no usable cache), which is exactly "skip this profile". + if ! pct=$("$PLUGIN_DIR/usage.sh" percent "$dir" 2>/dev/null); then say "skipping $dir (usage unavailable — expired login?)" continue fi diff --git a/claude-profile-router/status.sh b/claude-profile-router/status.sh index bd630a9..7b4e334 100755 --- a/claude-profile-router/status.sh +++ b/claude-profile-router/status.sh @@ -4,18 +4,17 @@ # checked against reality without reading the daemon log. set -uo pipefail +PLUGIN_DIR="${TASK_PLUGIN_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" + _pre_profiles="${TY_CLAUDE_PROFILES:-}" _pre_max="${TY_CLAUDE_MAX_PERCENT:-}" -_pre_bin="${TY_BIN:-}" -if [[ -n "${TASK_PLUGIN_DIR:-}" && -f "$TASK_PLUGIN_DIR/config.env" ]]; then +if [[ -f "$PLUGIN_DIR/config.env" ]]; then # shellcheck disable=SC1091 - source "$TASK_PLUGIN_DIR/config.env" + source "$PLUGIN_DIR/config.env" fi [[ -n "$_pre_profiles" ]] && TY_CLAUDE_PROFILES="$_pre_profiles" [[ -n "$_pre_max" ]] && TY_CLAUDE_MAX_PERCENT="$_pre_max" -[[ -n "$_pre_bin" ]] && TY_BIN="$_pre_bin" -TY="${TY_BIN:-ty}" MAX_PERCENT="${TY_CLAUDE_MAX_PERCENT:-90}" if [[ -z "${TY_CLAUDE_PROFILES:-}" ]]; then @@ -27,5 +26,15 @@ echo "Routing threshold: skip a profile at or above ${MAX_PERCENT}% used" echo for raw in $TY_CLAUDE_PROFILES; do dir="${raw/#\~/$HOME}" - "$TY" usage --config-dir "$dir" || echo " (unavailable)" + if ! out=$("$PLUGIN_DIR/usage.sh" show "$dir" 2>&1); then + echo "$dir" + echo " unavailable — ${out#usage.sh: }" + else + echo "$out" + pct=$("$PLUGIN_DIR/usage.sh" percent "$dir" 2>/dev/null) + if [[ "$pct" =~ ^[0-9]+$ ]] && (( pct >= MAX_PERCENT )); then + echo " -> skipped by the router (at or above ${MAX_PERCENT}%)" + fi + fi + echo done diff --git a/claude-profile-router/usage.sh b/claude-profile-router/usage.sh new file mode 100755 index 0000000..b7d7fdc --- /dev/null +++ b/claude-profile-router/usage.sh @@ -0,0 +1,254 @@ +#!/bin/bash +# Read how much of a Claude subscription a profile has spent. +# +# usage.sh percent -> one bare number, the binding limit's used % +# usage.sh show -> a human summary (used by status.sh) +# +# A "profile" is a CLAUDE_CONFIG_DIR: one logged-in Claude account. This reads +# that profile's stored OAuth token and calls the same endpoint Claude Code's own +# /usage command uses. It is strictly read-only — no token is refreshed, +# rewritten, or printed. +# +# Exits non-zero (with a message on stderr) whenever the answer isn't +# trustworthy: no credentials, an expired login, a failed request with no usable +# cache. route.sh treats that as "skip this profile", so a broken probe never +# routes a task somewhere wrong. +set -uo pipefail + +API="${TY_CLAUDE_API:-https://api.anthropic.com}" +CACHE_DIR="${TY_CLAUDE_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/ty/claude-usage}" +CACHE_TTL="${TY_CLAUDE_CACHE_TTL:-60}" # serve without refetching, seconds +CACHE_STALE="${TY_CLAUDE_CACHE_STALE:-1800}" # usable when a live fetch fails + +die() { echo "usage.sh: $*" >&2; exit 1; } + +# --- JSON --------------------------------------------------------------------- +# The usage response is too nested for sed to be honest about, so this needs a +# real parser. jq if it's here, python3 otherwise; both are absent often enough +# that "neither" gets a clear message rather than a confusing empty answer. +# +# TY_CLAUDE_JSON forces a backend. That exists so the two dialects can be tested +# against each other — a machine with both installed would otherwise only ever +# exercise jq, and the python3 branch could rot unnoticed until it ran on a box +# without jq. +case "${TY_CLAUDE_JSON:-auto}" in + jq) command -v jq >/dev/null 2>&1 || die "TY_CLAUDE_JSON=jq but jq is not installed"; JSON=jq ;; + python3) command -v python3 >/dev/null 2>&1 || die "TY_CLAUDE_JSON=python3 but python3 is not installed"; JSON=python3 ;; + auto) + if command -v jq >/dev/null 2>&1; then JSON=jq + elif command -v python3 >/dev/null 2>&1; then JSON=python3 + else die "needs jq or python3 to read the usage API (brew install jq)" + fi ;; + *) die "TY_CLAUDE_JSON must be auto, jq, or python3" ;; +esac + +# json_field +# Two dialects for one question. Keep the pair in sync when you touch either. +json_field() { + local body="$1" jqf="$2" pyf="$3" + if [[ "$JSON" == jq ]]; then + printf '%s' "$body" | jq -r "$jqf" 2>/dev/null + else + printf '%s' "$body" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) +except Exception: + sys.exit(1) +$pyf" 2>/dev/null + fi +} + +# --- credentials -------------------------------------------------------------- +# Claude Code namespaces each config dir's credentials by the first 8 hex digits +# of the SHA-256 of the absolute path, so two logged-in accounts can coexist. +# Reproducing that derivation is what lets this read a profile's token without +# asking anyone to paste one. If Anthropic changes the scheme, the keychain +# lookup misses, the .credentials.json fallback misses, and the profile reports +# as unavailable — loudly on stderr, never silently as "0% used". +sha256_of() { + if command -v shasum >/dev/null 2>&1; then + printf '%s' "$1" | shasum -a 256 | cut -d' ' -f1 + else + printf '%s' "$1" | sha256sum | cut -d' ' -f1 + fi +} + +normalize_dir() { + local d="${1/#\~/$HOME}" + while [[ "$d" == */ && "$d" != "/" ]]; do d="${d%/}"; done + printf '%s' "$d" +} + +# creds_blob -> the raw credential JSON on stdout +creds_blob() { + local dir="$1" svc blob + svc="Claude Code-credentials-$(sha256_of "$dir" | cut -c1-8)" + + if command -v security >/dev/null 2>&1; then + blob=$(security find-generic-password -s "$svc" -w 2>/dev/null) + [[ -n "$blob" ]] && { printf '%s' "$blob"; return 0; } + fi + if [[ -f "$dir/.credentials.json" ]]; then + cat "$dir/.credentials.json"; return 0 + fi + # The default ~/.claude may still use the pre-namespacing service name. + if [[ "$dir" == "$HOME/.claude" ]] && command -v security >/dev/null 2>&1; then + blob=$(security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null) + [[ -n "$blob" ]] && { printf '%s' "$blob"; return 0; } + fi + return 1 +} + +# --- cache -------------------------------------------------------------------- +# The usage endpoint rate-limits (429), and routing probes it on every spawn — +# without a cache a busy board walks straight into losing the numbers it routes +# on. Cached on disk rather than in memory because every probe is a fresh +# process. +cache_file() { printf '%s/%s.json' "$CACHE_DIR" "$(sha256_of "$1" | cut -c1-16)"; } + +file_age() { + local f="$1" mtime now + mtime=$(stat -f %m "$f" 2>/dev/null || stat -c %Y "$f" 2>/dev/null) || return 1 + now=$(date +%s) + echo $(( now - mtime )) +} + +cache_read() { # + local f; f=$(cache_file "$1") + [[ -f "$f" ]] || return 1 + local age; age=$(file_age "$f") || return 1 + (( age <= $2 )) || return 1 + cat "$f" +} + +cache_write() { # + local f; f=$(cache_file "$1") + mkdir -p "$(dirname "$f")" 2>/dev/null || return 0 + # write-then-rename: several spawns can probe the same profile at once, and a + # reader must never see a half-written file. + printf '%s' "$2" > "$f.tmp.$$" 2>/dev/null && mv -f "$f.tmp.$$" "$f" 2>/dev/null + rm -f "$f.tmp.$$" 2>/dev/null + return 0 +} + +# --- fetch -------------------------------------------------------------------- +# fetch_usage -> usage JSON on stdout +fetch_usage() { + local dir="$1" blob token expires now body code + + blob=$(creds_blob "$dir") || die "no Claude credentials for $dir (log in once with CLAUDE_CONFIG_DIR=$dir claude)" + + token=$(json_field "$blob" '.claudeAiOauth.accessToken // empty' \ + "print(d.get('claudeAiOauth',{}).get('accessToken',''))") + [[ -n "$token" ]] || die "no OAuth token stored for $dir" + + # expiresAt is epoch MILLIseconds. Don't refresh it here — that would fight + # Claude Code's own credential management and risk corrupting the entry. + expires=$(json_field "$blob" '.claudeAiOauth.expiresAt // 0' \ + "print(d.get('claudeAiOauth',{}).get('expiresAt',0) or 0)") + now=$(( $(date +%s) * 1000 )) + if [[ "$expires" =~ ^[0-9]+$ ]] && (( expires > 0 && expires < now )); then + die "credentials for $dir have expired (run a claude session with CLAUDE_CONFIG_DIR=$dir to refresh)" + fi + + if body=$(cache_read "$dir" "$CACHE_TTL"); then + printf '%s' "$body"; return 0 + fi + + body=$(curl -sS --max-time 10 -w $'\n%{http_code}' \ + -H "Authorization: Bearer $token" "$API/api/oauth/usage" 2>/dev/null) + code="${body##*$'\n'}" + body="${body%$'\n'*}" + + if [[ "$code" == "200" ]]; then + cache_write "$dir" "$body" + printf '%s' "$body"; return 0 + fi + + # A failed request is not the same as a bad account: a snapshot from a few + # minutes ago is a far better basis for routing than nothing. + if body=$(cache_read "$dir" "$CACHE_STALE"); then + echo "usage.sh: $dir — live read failed (HTTP ${code:-?}), using cached numbers" >&2 + printf '%s' "$body"; return 0 + fi + + case "$code" in + 401) die "credentials for $dir were rejected (401) — this profile needs a fresh login" ;; + 429) die "the usage API is rate-limiting (429) — not the account's quota; retry shortly" ;; + *) die "could not read usage for $dir (HTTP ${code:-no response})" ;; + esac +} + +# --- reporting ---------------------------------------------------------------- +# The binding limit is the WORST window, not the average: a 5-hour session at 98% +# blocks the next task even when the weekly window is untouched. Falls back to +# the older five_hour/seven_day shape if `limits` is missing, so an API rollback +# doesn't leave routing reading 0%. +binding_percent() { + json_field "$1" \ + '[(.limits // [])[].percent, (.five_hour.utilization // empty), (.seven_day.utilization // empty)] | map(select(. != null)) | (max // 0) | floor' \ + " +lim = d.get('limits') or [] +vals = [l.get('percent') or 0 for l in lim] +if not vals: + for k in ('five_hour','seven_day'): + w = d.get(k) or {} + if w.get('utilization') is not None: vals.append(w['utilization']) +print(int(max(vals or [0])))" +} + +# account_email — which login this profile is, for the human-facing +# `show` output. A second request, so it is never made on the routing path, and +# it is best-effort: a profile's numbers are the point, its address is a label. +# Cached for a day, since it only changes when you re-login. +account_email() { + local dir="$1" f blob token body age + f="$(cache_file "$dir").email" + if [[ -f "$f" ]] && age=$(file_age "$f") && (( age <= 86400 )); then + cat "$f"; return 0 + fi + blob=$(creds_blob "$dir") || return 1 + token=$(json_field "$blob" '.claudeAiOauth.accessToken // empty' \ + "print(d.get('claudeAiOauth',{}).get('accessToken',''))") + [[ -n "$token" ]] || return 1 + body=$(curl -sS --max-time 10 -H "Authorization: Bearer $token" \ + "$API/api/oauth/profile" 2>/dev/null) || return 1 + local email + email=$(json_field "$body" '.account.email // empty' \ + "print(d.get('account',{}).get('email',''))") + [[ -n "$email" ]] || return 1 + mkdir -p "$(dirname "$f")" 2>/dev/null && printf '%s' "$email" > "$f" 2>/dev/null + printf '%s' "$email" +} + +describe() { + json_field "$1" \ + '[(.limits // [])[]] | if length == 0 then "no limits reported" else (max_by(.percent) | "\(.percent|floor)% used (\(.kind)\(if .resets_at then ", resets " + (.resets_at|sub("\\..*";"")|sub("T";" ")) else "" end))") end' \ + " +lim = d.get('limits') or [] +if not lim: + print('no limits reported') +else: + w = max(lim, key=lambda l: l.get('percent') or 0) + r = w.get('resets_at') + r = ', resets ' + r.split('.')[0].replace('T',' ') if r else '' + print('%d%% used (%s%s)' % (int(w.get('percent') or 0), w.get('kind'), r))" +} + +# --- main --------------------------------------------------------------------- +[[ $# -ge 2 ]] || die "usage: usage.sh {percent|show} " +mode="$1"; dir="$(normalize_dir "$2")" +usage_json="$(fetch_usage "$dir")" || exit 1 + +case "$mode" in + percent) binding_percent "$usage_json" ;; + show) + echo "$dir" + if email=$(account_email "$dir" 2>/dev/null) && [[ -n "$email" ]]; then + echo " $email" + fi + echo " $(describe "$usage_json")" + ;; + *) die "unknown mode: $mode" ;; +esac