Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CLAUDE-FULL.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ The OpenAI Codex CLI runs as a second model alongside Claude via the `/codex` sk

Two roomy pools (Sonnet + Codex) carry volume; the one scarce pool (Opus) does the thinking. If a Codex window drains, fail over to Claude-only rather than stalling.

**Automated steering** — the statusline persists Claude's own rate-limit percentages to `~/.claude/tmp/rate-limits.json`, and the `quota-steer` hook injects routing guidance into the session when usage crosses thresholds (5h ≥ 60% / weekly ≥ 65%): route bulk work to Codex when the bridge is available, downshift subagents to Sonnet when it isn't. The prose above is the policy; the hook is the enforcement nudge.

---

## Effort & Context
Expand Down
2 changes: 1 addition & 1 deletion agents/implementer.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: implementer
model: opus
model: sonnet
description: |
Code execution agent. Writes, edits, and tests code based on approved plans.

Expand Down
2 changes: 1 addition & 1 deletion agents/reviewer.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: reviewer
model: opus[1m]
model: sonnet
memory: project
description: |
Code review and quality assurance. Checks against Darkroom standards.
Expand Down
9 changes: 9 additions & 0 deletions config/40-hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@
"timeout": 3
}
]
},
{
"hooks": [
{
"type": "command",
"command": "bun \"$HOME/.claude/src/hooks/quota-steer.ts\"",
"timeout": 3
}
]
}
],
"PreToolUse": [
Expand Down
25 changes: 14 additions & 11 deletions docs/agent-models.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
# Agent Model Routing

> **Fable 5 / Mythos 5 suspended (2026-06-12).** A US government export-control
> directive disabled all access to Fable 5 and Mythos 5 for every customer
> ([announcement](https://www.anthropic.com/news/fable-mythos-access)). All
> other Claude models are unaffected. Until access is restored, the decision
> tier routes to **Opus 4.8 with 1M context (`opus[1m]`)** — the strongest
> generally-available model, pinned to `[1m]` because (unlike Fable) Opus is not
> 1M-native. When Fable returns, revert the `opus[1m]` entries below to `fable`
> and drop the pins.
> **Fable 5 redeployed 2026-07-01, promo-then-credit-gated.** The export-control
> suspension (2026-06-12) has lifted; Fable is back as a promotional tier.
> `config/10-core.json` temporarily defaults the session model to `fable` for
> the promo window, reverting to **Opus 4.8 with 1M context (`opus[1m]`)** on
> 2026-07-07 once the promo ends and credit-gating applies. The agent pins
> below stay on `opus[1m]` as the steady-state top tier — swap to `fable`
> per-session (`/model fable`) during the promo if you want it.

Routing principle: **explore and execute on the cheaper tiers, decide on the top tier.** The top tier (currently `opus[1m]`, normally `fable`) stays on the main session plus the agents whose *output is a judgment* (orchestration, planning, code-quality review). Read-heavy and execution agents run on Opus or Sonnet (mechanical), then feed their findings back to the session for the decision. All tiers get 1M context on Max plans.

Expand All @@ -16,16 +15,20 @@ Routing principle: **explore and execute on the cheaper tiers, decide on the top
| `maestro` | **opus[1m]** | Orchestration needs the strongest available reasoning (was `fable`) |
| `planner` | **opus[1m]** | Architecture decisions need depth (was `fable`) |
| `oracle` | **opus[1m]** | Expert Q&A needs nuance (was `fable`) |
| `reviewer` | **opus[1m]** | Code-quality judgment is the deliverable (was `fable`) |
| `implementer` | **opus** | Executes already-made decisions; Opus lands clean code — the single biggest token consumer, so per-task context (no 1M pin) keeps cost down |
| `reviewer` | **sonnet** | Diff-reading is bulk work; cross-model `codex-verifier` provides the independent second gate |
| `implementer` | **sonnet** | Executes already-made plans; Sonnet 5 is near-Opus on coding, and plans come from the top tier |
| `security-reviewer` | **opus** | Analysis feeding the session's decision |
| `tester` | **sonnet** | Test writing follows clear patterns |
| `scaffolder` | **sonnet** | Boilerplate generation is mechanical |
| `explore` | **sonnet** | The highest-volume agent — routine investigation is Sonnet-fine; bump per-invocation to Opus for genuinely hard blast-radius/architecture work |
| `deslopper` | **sonnet** | Deletions are tool-grounded (tldr call graph) and guard-railed (no rm/commit/push, conservative auto-fix) |

The `sonnet` tier is now Claude Sonnet 5 — near-Opus quality on coding/agentic work — which reinforces the split above: `tester`, `scaffolder`, `explore`, and `deslopper` stay on Sonnet for fan-out/mechanical work at a fraction of Opus cost, while the judgment-bearing agents (`maestro`, `planner`, `oracle`, `reviewer`, `implementer`, `security-reviewer`) stay on Opus for the decision.
The `sonnet` tier is now Claude Sonnet 5 — near-Opus quality on coding/agentic work — which reinforces the split above: `tester`, `scaffolder`, `explore`, `deslopper`, `implementer`, and `reviewer` stay on Sonnet for fan-out/mechanical/execution work at a fraction of Opus cost, while the judgment-bearing agents that gate a decision (`maestro`, `planner`, `oracle`, `security-reviewer`) stay on the top tier.

Override per-invocation when a specific task warrants it: bump a cheap agent up — `Agent(explore, "...", model: "opus")` for a hard investigation — or drop a decision agent down for a trivial pass. The table is the default, not a ceiling.

**Agent Teams teammates** route separately from the table above: the `CLAUDE_CODE_SUBAGENT_MODEL` env var (in `config/10-core.json`, upstream v2.1.147) picks the model for teammate subprocesses spawned under `teammateMode: "auto"` — independent of both the per-agent table and the main session's pinned model. Set to **`sonnet`** (the steady state): the session and the deep-reasoning agents stay on the top tier while wide teammate fan-out — which re-reads the repo per teammate — drops to Sonnet for cost.

## Automated quota steering

The statusline persists Claude's own rate-limit percentages to `~/.claude/tmp/rate-limits.json` on every refresh. A `quota-steer` `UserPromptSubmit` hook reads that cache and injects routing guidance into the session when usage crosses thresholds (5-hour ≥ 60% or weekly ≥ 65% is "elevated"; either ≥ 85% is "critical") — steering bulk work to the Codex bridge when it's available, or downshifting subagents to Sonnet when it isn't.
6 changes: 6 additions & 0 deletions docs/codex-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ Two roomy pools (Sonnet + Codex) carry volume. The one scarce pool (Opus) direct

---

## Automated quota steering

The routing convention above is enforced automatically, not just documented. The statusline writes `~/.claude/tmp/rate-limits.json` on every refresh, tagged with `updated_at` — the hook ignores the cache once it's older than 10 minutes. `src/hooks/quota-steer.ts` (a `UserPromptSubmit` hook) reads that cache, computes a band (normal / elevated / critical, at 60%/85% for the five-hour window and 65%/85% for the weekly window), reads the cached Codex verdict, and injects `additionalContext` steering bulk work to Codex when it's available. At critical it re-reminds every 30 minutes rather than every turn. Like all hooks here, it fails open — a missing or stale cache is silently skipped rather than blocking the prompt.

---

## Usage

### `/codex` skill subcommands
Expand Down
1 change: 1 addition & 0 deletions skills/codex/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Runs in a `read-only` sandbox. Use for quick factual questions, architecture opi
- Batch work: give `exec` a whole feature or module, not one function at a time.
- `review` and `ask` are read-only and cheap — use them freely as a cross-check.
- If the script reports the bridge is unavailable (not installed, not logged in, or rate-limited), continue Claude-only. Do not block the session.
- Sessions get automatic steering: the `quota-steer` hook injects a routing reminder when Claude usage crosses 60% (5-hour) or 65% (weekly). If you see that reminder, prefer `exec`/`review` here over spawning Claude subagents.

---

Expand Down
52 changes: 52 additions & 0 deletions src/hooks/quota-steer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env bun
// UserPromptSubmit hook — inject quota-aware model-routing guidance when the
// statusline's cached Claude usage crosses elevated/critical thresholds.
// Fail-open: any error → silent success (never block the prompt).

import { readCodexVerdict } from "../lib/codex.ts";
import { readHookInput, runHook } from "../lib/hook-runtime.ts";
import {
buildSteerMessage,
CACHE_STALE_MS,
computeBand,
readQuotaSteerState,
readRateLimitsCache,
shouldEmit,
writeQuotaSteerState,
} from "../lib/quota.ts";

async function main(): Promise<void> {
await readHookInput<{ prompt: string }>({ prompt: "PROMPT" });

const now = Date.now();
const cache = await readRateLimitsCache();
if (!cache || now - cache.updated_at > CACHE_STALE_MS) return;

const fiveHourPct = cache.five_hour?.used_percentage;
const sevenDayPct = cache.seven_day?.used_percentage;
const band = computeBand(fiveHourPct, sevenDayPct);
const prev = await readQuotaSteerState();

if (band === "normal") {
await writeQuotaSteerState({ band, lastEmit: prev?.lastEmit ?? 0 });
return;
}

const codexVerdict = await readCodexVerdict();
if (shouldEmit(prev, band, now)) {
console.log(
JSON.stringify({
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: buildSteerMessage(band, codexVerdict.state, fiveHourPct, sevenDayPct),
},
}),
);
await writeQuotaSteerState({ band, lastEmit: now });
return;
}

await writeQuotaSteerState({ band, lastEmit: prev?.lastEmit ?? 0 });
}

await runHook(main);
35 changes: 35 additions & 0 deletions src/hooks/statusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { readCodexVerdict } from "../lib/codex.ts";
import { palette } from "../lib/colors.ts";
import { runGit as runGitLib } from "../lib/git.ts";
import { readHookInput, readState } from "../lib/hook-runtime.ts";
import { type RateLimitsCache, writeRateLimitsCache } from "../lib/quota.ts";
import { ageMs, formatAge, maxUnreviewed, type ReviewQueueState } from "../lib/review-queue.ts";

type Payload = {
Expand Down Expand Up @@ -116,6 +117,10 @@ function formatTimeToReset(value: number | string): string | null {

const dimSep = `${palette.dim} | ${palette.reset}`;

function cacheResetValue(value: number | string | undefined): string | undefined {
return value === undefined ? undefined : String(value);
}

// Degraded-path capture: filled as soon as the payload parses, so the catch
// block at the bottom can still print the model/cwd segment.
let model = "";
Expand All @@ -136,6 +141,30 @@ async function main(): Promise<void> {

const rateUsed = input.rate_limits?.five_hour?.used_percentage;
const rateResetsAt = input.rate_limits?.five_hour?.resets_at;
const weeklyRateUsed = input.rate_limits?.seven_day?.used_percentage;

if (input.rate_limits) {
try {
const cache: RateLimitsCache = {
five_hour: input.rate_limits.five_hour
? {
used_percentage: input.rate_limits.five_hour.used_percentage,
resets_at: cacheResetValue(input.rate_limits.five_hour.resets_at),
}
: undefined,
seven_day: input.rate_limits.seven_day
? {
used_percentage: input.rate_limits.seven_day.used_percentage,
resets_at: cacheResetValue(input.rate_limits.seven_day.resets_at),
}
: undefined,
updated_at: Date.now(),
};
await writeRateLimitsCache(cache);
} catch {
// Statusline rendering must stay fail-open; quota steering can miss a sample.
}
}

const effortLevel = input.effort?.level;
const thinkingEnabled = input.thinking?.enabled === true;
Expand Down Expand Up @@ -170,6 +199,12 @@ async function main(): Promise<void> {
parts.push(`${color}⚡${rInt}%${palette.reset}${suffix}`);
}

if (weeklyRateUsed !== undefined && weeklyRateUsed >= 50) {
const wInt = Math.round(weeklyRateUsed);
const color = wInt >= 80 ? palette.red : palette.yellow;
parts.push(`${color}wk${wInt}%${palette.reset}`);
}

// Review-queue backpressure: agents spawned since the last commit, awaiting
// your review (written by tool-cadence.ts). Suppressed at 0 — yellow
// under the threshold, red at/over CC_MAX_UNREVIEWED.
Expand Down
136 changes: 136 additions & 0 deletions src/lib/quota.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { z } from "zod";
import type { CodexState } from "./codex.ts";
import { readState, writeState } from "./hook-runtime.ts";

export type QuotaBand = "normal" | "elevated" | "critical";

export interface RateLimitsCache {
five_hour?: {
used_percentage?: number;
resets_at?: string;
};
seven_day?: {
used_percentage?: number;
resets_at?: string;
};
updated_at: number;
}

export const FIVE_HOUR_ELEVATED = 60;
export const FIVE_HOUR_CRITICAL = 85;
export const SEVEN_DAY_ELEVATED = 65;
export const SEVEN_DAY_CRITICAL = 85;
export const CACHE_STALE_MS = 10 * 60_000;
export const CRITICAL_REMIND_MS = 30 * 60_000;

export const RATE_LIMITS_CACHE_FILE = "rate-limits.json";
export const QUOTA_STEER_STATE_FILE = "quota-steer-state.json";

export interface QuotaSteerState {
band: QuotaBand;
lastEmit: number;
}

const CODEX_AVAILABLE: CodexState = "available";

const RateLimitWindowSchema = z.object({
used_percentage: z.number().optional(),
resets_at: z.string().optional(),
});

const RateLimitsCacheSchema = z.object({
five_hour: RateLimitWindowSchema.optional(),
seven_day: RateLimitWindowSchema.optional(),
updated_at: z.number(),
});

const QuotaSteerStateSchema = z.object({
band: z.enum(["normal", "elevated", "critical"]),
lastEmit: z.number(),
});

function severity(band: QuotaBand): number {
if (band === "critical") return 2;
if (band === "elevated") return 1;
return 0;
}

function dimensionBand(
pct: number | undefined,
elevatedThreshold: number,
criticalThreshold: number,
): QuotaBand {
if (pct === undefined) return "normal";
if (pct >= criticalThreshold) return "critical";
if (pct >= elevatedThreshold) return "elevated";
return "normal";
}

function formatPct(label: string, pct: number | undefined): string {
return pct === undefined ? `${label} unknown` : `${label} ${Math.round(pct)}%`;
}

export function computeBand(
fiveHourPct: number | undefined,
sevenDayPct: number | undefined,
): QuotaBand {
const fiveHourBand = dimensionBand(fiveHourPct, FIVE_HOUR_ELEVATED, FIVE_HOUR_CRITICAL);
const sevenDayBand = dimensionBand(sevenDayPct, SEVEN_DAY_ELEVATED, SEVEN_DAY_CRITICAL);
return severity(fiveHourBand) >= severity(sevenDayBand) ? fiveHourBand : sevenDayBand;
}

export function shouldEmit(
prev: { band: QuotaBand; lastEmit: number } | null,
band: QuotaBand,
now: number,
): boolean {
if (band === "normal") return false;
if (prev === null) return true;
if (severity(band) > severity(prev.band)) return true;
return band === "critical" && now - prev.lastEmit >= CRITICAL_REMIND_MS;
}

export function buildSteerMessage(
band: QuotaBand,
codexState: string,
fiveHourPct: number | undefined,
sevenDayPct: number | undefined,
): string {
const marker = `[quota:${band}]`;
const usage = `${formatPct("5h", fiveHourPct)}, ${formatPct("7d", sevenDayPct)}`;
const codexAvailable = codexState === CODEX_AVAILABLE;

if (codexAvailable && band === "critical") {
return `${marker} Claude quota is critical (${usage}). Avoid Opus/Fable subagents entirely; delegate all executable work to Codex in one large batched call via bun codex-run.ts exec, and keep main-session output lean.`;
}

if (codexAvailable && band === "elevated") {
return `${marker} Claude usage is elevated (${usage}). Route bulk/mechanical implementation to Codex via bun codex-run.ts exec, batched into few large calls; keep subagents on sonnet and reserve Opus/Fable turns for planning, synthesis, and gate decisions.`;
}

if (band === "critical") {
return `${marker} Claude quota is critical (${usage}), and the Codex bridge is ${codexState}. Downshift subagents to sonnet, defer bulk work, keep turns lean, and do not attempt the codex bridge while it is ${codexState}.`;
}

return `${marker} Claude usage is elevated (${usage}), and the Codex bridge is ${codexState}. Downshift subagents to sonnet, defer bulk work, keep turns lean, and do not attempt the codex bridge while it is ${codexState}.`;
}

export async function readRateLimitsCache(): Promise<RateLimitsCache | null> {
const raw = await readState<unknown>(RATE_LIMITS_CACHE_FILE, null);
const parsed = RateLimitsCacheSchema.safeParse(raw);
return parsed.success ? parsed.data : null;
}

export async function writeRateLimitsCache(cache: RateLimitsCache): Promise<void> {
await writeState(RATE_LIMITS_CACHE_FILE, cache);
}

export async function readQuotaSteerState(): Promise<QuotaSteerState | null> {
const raw = await readState<unknown>(QUOTA_STEER_STATE_FILE, null);
const parsed = QuotaSteerStateSchema.safeParse(raw);
return parsed.success ? parsed.data : null;
}

export async function writeQuotaSteerState(state: QuotaSteerState): Promise<void> {
await writeState(QUOTA_STEER_STATE_FILE, state);
}
Loading
Loading