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
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ same command to update them all (`git pull` under the hood).
|--------|-------------------|
| **rpi** | `ty pipeline -d rpi "<goal>"` — 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 "<goal>"` — 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. Needs `jq` or `python3`. |
| **arc-solve** | `ty pipeline -d arc-solve "solve <game-id>"` — 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
Expand Down Expand Up @@ -51,9 +52,15 @@ Workflows are picked up by convention — any `workflows/*.yaml` in a plugin bec
`ty pipeline -d <name>` 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.
157 changes: 157 additions & 0 deletions claude-profile-router/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# 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.

```
$ 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_all, resets 2026-08-16 10:00:00)
-> skipped by the router (at or above 90%)

/Users/me/.claude-work
me@work.example
12% used (weekly_all, resets 2026-08-20 21:00:00)
```

→ the next task runs under `.claude-work`.

## Requirements

- **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

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 dir>/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 # or just: ./status.sh
```

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, 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

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_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:

```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.** `usage.sh` reads each profile's stored OAuth
token — from the macOS Keychain, or `<config-dir>/.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 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 <dir>`) |
| `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
```
24 changes: 24 additions & 0 deletions claude-profile-router/config.example.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# 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: ./status.sh
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=""

# 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
19 changes: 19 additions & 0 deletions claude-profile-router/plugin.yaml
Original file line number Diff line number Diff line change
@@ -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 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
# 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
97 changes: 97 additions & 0 deletions claude-profile-router/route.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/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=<dir> run this task under that profile
# HOLD=1 / REASON=<text> 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

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 —
# 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:-}"
if [[ -f "$PLUGIN_DIR/config.env" ]]; then
# shellcheck disable=SC1091
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"

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

# 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}"

# 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
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"
40 changes: 40 additions & 0 deletions claude-profile-router/status.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/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

PLUGIN_DIR="${TASK_PLUGIN_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"

_pre_profiles="${TY_CLAUDE_PROFILES:-}"
_pre_max="${TY_CLAUDE_MAX_PERCENT:-}"
if [[ -f "$PLUGIN_DIR/config.env" ]]; then
# shellcheck disable=SC1091
source "$PLUGIN_DIR/config.env"
fi
[[ -n "$_pre_profiles" ]] && TY_CLAUDE_PROFILES="$_pre_profiles"
[[ -n "$_pre_max" ]] && TY_CLAUDE_MAX_PERCENT="$_pre_max"

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}"
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
Loading