From 4f2167118ae31e439e09e1bf3b50c5eae0edaf47 Mon Sep 17 00:00:00 2001 From: Franco Arza Date: Mon, 6 Jul 2026 16:24:39 -0300 Subject: [PATCH 1/2] feat(quota): automated quota steering via statusline rate-limit cache Statusline persists rate_limits to ~/.claude/tmp/rate-limits.json on every refresh and shows a weekly wk% badge at >=50%. New quota-steer UserPromptSubmit hook reads the cache, computes a band (elevated at 60% 5h / 65% weekly, critical at 85%), and injects routing guidance: bulk work to Codex when the bridge is available, sonnet downshift when not. Emits on band upgrade only, re-reminds every 30min at critical, fails open on missing/stale cache. --- config/40-hooks.json | 9 +++ src/hooks/quota-steer.ts | 52 ++++++++++++++ src/hooks/statusline.ts | 35 ++++++++++ src/lib/quota.ts | 136 ++++++++++++++++++++++++++++++++++++ tests/quota.test.ts | 104 +++++++++++++++++++++++++++ tests/scripts-smoke.test.ts | 13 +++- 6 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 src/hooks/quota-steer.ts create mode 100644 src/lib/quota.ts create mode 100644 tests/quota.test.ts diff --git a/config/40-hooks.json b/config/40-hooks.json index 707de92..bf981a1 100644 --- a/config/40-hooks.json +++ b/config/40-hooks.json @@ -19,6 +19,15 @@ "timeout": 3 } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "bun \"$HOME/.claude/src/hooks/quota-steer.ts\"", + "timeout": 3 + } + ] } ], "PreToolUse": [ diff --git a/src/hooks/quota-steer.ts b/src/hooks/quota-steer.ts new file mode 100644 index 0000000..500342e --- /dev/null +++ b/src/hooks/quota-steer.ts @@ -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 { + 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); diff --git a/src/hooks/statusline.ts b/src/hooks/statusline.ts index 09b47f1..8853550 100644 --- a/src/hooks/statusline.ts +++ b/src/hooks/statusline.ts @@ -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 = { @@ -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 = ""; @@ -136,6 +141,30 @@ async function main(): Promise { 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; @@ -170,6 +199,12 @@ async function main(): Promise { 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. diff --git a/src/lib/quota.ts b/src/lib/quota.ts new file mode 100644 index 0000000..47a55d6 --- /dev/null +++ b/src/lib/quota.ts @@ -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 { + const raw = await readState(RATE_LIMITS_CACHE_FILE, null); + const parsed = RateLimitsCacheSchema.safeParse(raw); + return parsed.success ? parsed.data : null; +} + +export async function writeRateLimitsCache(cache: RateLimitsCache): Promise { + await writeState(RATE_LIMITS_CACHE_FILE, cache); +} + +export async function readQuotaSteerState(): Promise { + const raw = await readState(QUOTA_STEER_STATE_FILE, null); + const parsed = QuotaSteerStateSchema.safeParse(raw); + return parsed.success ? parsed.data : null; +} + +export async function writeQuotaSteerState(state: QuotaSteerState): Promise { + await writeState(QUOTA_STEER_STATE_FILE, state); +} diff --git a/tests/quota.test.ts b/tests/quota.test.ts new file mode 100644 index 0000000..476c33c --- /dev/null +++ b/tests/quota.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test"; +import { + buildSteerMessage, + CRITICAL_REMIND_MS, + computeBand, + shouldEmit, +} from "../src/lib/quota.ts"; + +describe("computeBand", () => { + test("five-hour boundaries", () => { + expect(computeBand(59, undefined)).toBe("normal"); + expect(computeBand(60, undefined)).toBe("elevated"); + expect(computeBand(84, undefined)).toBe("elevated"); + expect(computeBand(85, undefined)).toBe("critical"); + }); + + test("seven-day boundaries", () => { + expect(computeBand(undefined, 64)).toBe("normal"); + expect(computeBand(undefined, 65)).toBe("elevated"); + expect(computeBand(undefined, 84)).toBe("elevated"); + expect(computeBand(undefined, 85)).toBe("critical"); + }); + + test("undefined dimensions are normal", () => { + expect(computeBand(undefined, undefined)).toBe("normal"); + }); + + test("returns max severity across dimensions", () => { + expect(computeBand(59, 65)).toBe("elevated"); + expect(computeBand(60, 85)).toBe("critical"); + expect(computeBand(85, 64)).toBe("critical"); + }); +}); + +describe("shouldEmit", () => { + const now = 1_000_000; + + test("null previous state", () => { + expect(shouldEmit(null, "normal", now)).toBe(false); + expect(shouldEmit(null, "elevated", now)).toBe(true); + }); + + test("same severity does not repeat except critical reminder interval", () => { + expect(shouldEmit({ band: "elevated", lastEmit: now - 1 }, "elevated", now)).toBe(false); + expect(shouldEmit({ band: "critical", lastEmit: now - 1 }, "critical", now)).toBe(false); + expect( + shouldEmit({ band: "critical", lastEmit: now - CRITICAL_REMIND_MS }, "critical", now), + ).toBe(true); + }); + + test("severity transitions", () => { + expect(shouldEmit({ band: "elevated", lastEmit: now - 1 }, "critical", now)).toBe(true); + expect( + shouldEmit({ band: "critical", lastEmit: now - CRITICAL_REMIND_MS }, "elevated", now), + ).toBe(false); + expect( + shouldEmit({ band: "critical", lastEmit: now - CRITICAL_REMIND_MS }, "normal", now), + ).toBe(false); + expect( + shouldEmit({ band: "elevated", lastEmit: now - CRITICAL_REMIND_MS }, "normal", now), + ).toBe(false); + }); +}); + +describe("buildSteerMessage", () => { + test("available elevated message includes percentages, codex, and batching", () => { + const msg = buildSteerMessage("elevated", "available", 61, 66); + expect(msg).toContain("[quota:elevated]"); + expect(msg).toContain("5h 61%"); + expect(msg).toContain("7d 66%"); + expect(msg.toLowerCase()).toContain("codex"); + expect(msg.toLowerCase()).toContain("batched"); + expect(msg.toLowerCase()).toContain("few large"); + }); + + test("available critical message is stronger than elevated", () => { + const elevated = buildSteerMessage("elevated", "available", 61, 66); + const critical = buildSteerMessage("critical", "available", 86, 90); + expect(critical).toContain("[quota:critical]"); + expect(critical).toContain("5h 86%"); + expect(critical).toContain("7d 90%"); + expect(critical).toContain("Avoid Opus/Fable subagents entirely"); + expect(critical).toContain("all executable work"); + expect(elevated).not.toContain("Avoid Opus/Fable subagents entirely"); + }); + + test("unavailable elevated message mentions sonnet downshift and codex state", () => { + const msg = buildSteerMessage("elevated", "unauthenticated", 70, undefined); + expect(msg).toContain("5h 70%"); + expect(msg).toContain("7d unknown"); + expect(msg).toContain("sonnet"); + expect(msg).toContain("Codex bridge is unauthenticated"); + expect(msg).toContain("do not attempt the codex bridge"); + }); + + test("unavailable critical message mentions sonnet downshift and codex state", () => { + const msg = buildSteerMessage("critical", "rate-limited", undefined, 90); + expect(msg).toContain("5h unknown"); + expect(msg).toContain("7d 90%"); + expect(msg).toContain("sonnet"); + expect(msg).toContain("Codex bridge is rate-limited"); + expect(msg).toContain("do not attempt the codex bridge"); + }); +}); diff --git a/tests/scripts-smoke.test.ts b/tests/scripts-smoke.test.ts index bbb9e13..823645d 100644 --- a/tests/scripts-smoke.test.ts +++ b/tests/scripts-smoke.test.ts @@ -516,11 +516,20 @@ describe("checkpoint.ts save/restore — real rollback (#80)", () => { const { tmpdir } = await import("node:os"); const { join } = await import("node:path"); const dir = mkdtempSync(join(tmpdir(), "cc-checkpoint-repo-")); - const git = (args: string[]) => - Bun.spawnSync(["git", ...args], { cwd: dir, stdout: "pipe", stderr: "pipe" }); + const git = (args: string[]) => { + const r = Bun.spawnSync(["git", ...args], { cwd: dir, stdout: "pipe", stderr: "pipe" }); + if (r.exitCode !== 0) { + throw new Error( + `git ${args.join(" ")} failed: ${r.stderr.toString() || r.stdout.toString()}`, + ); + } + return r; + }; git(["init", "-q"]); git(["config", "user.email", "test@example.com"]); git(["config", "user.name", "Test"]); + git(["config", "commit.gpgsign", "false"]); + git(["config", "core.hooksPath", "/dev/null"]); // Windows runners default core.autocrlf=true, which rewrites the restored // file to CRLF and breaks byte-exact content assertions. git(["config", "core.autocrlf", "false"]); From 7b432958a506d58ea9efa04f3a2932d44a760405 Mon Sep 17 00:00:00 2001 From: Franco Arza Date: Mon, 6 Jul 2026 16:24:39 -0300 Subject: [PATCH 2/2] feat(agents): repin implementer/reviewer to sonnet, document quota routing Execution and diff-reading move to the roomy Sonnet 5 pool; planner, maestro, and security-reviewer stay top-tier for gate decisions. Docs updated for the automated steering mechanism and the Fable promo window status. --- CLAUDE-FULL.md | 2 ++ agents/implementer.md | 2 +- agents/reviewer.md | 2 +- docs/agent-models.md | 25 ++++++++++++++----------- docs/codex-bridge.md | 6 ++++++ skills/codex/SKILL.md | 1 + 6 files changed, 25 insertions(+), 13 deletions(-) diff --git a/CLAUDE-FULL.md b/CLAUDE-FULL.md index 8c651c1..1bcc9b6 100644 --- a/CLAUDE-FULL.md +++ b/CLAUDE-FULL.md @@ -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 diff --git a/agents/implementer.md b/agents/implementer.md index 8bae610..207bb01 100644 --- a/agents/implementer.md +++ b/agents/implementer.md @@ -1,6 +1,6 @@ --- name: implementer -model: opus +model: sonnet description: | Code execution agent. Writes, edits, and tests code based on approved plans. diff --git a/agents/reviewer.md b/agents/reviewer.md index b5edbac..68920c0 100644 --- a/agents/reviewer.md +++ b/agents/reviewer.md @@ -1,6 +1,6 @@ --- name: reviewer -model: opus[1m] +model: sonnet memory: project description: | Code review and quality assurance. Checks against Darkroom standards. diff --git a/docs/agent-models.md b/docs/agent-models.md index 3f0c879..8d1d8ae 100644 --- a/docs/agent-models.md +++ b/docs/agent-models.md @@ -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. @@ -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. diff --git a/docs/codex-bridge.md b/docs/codex-bridge.md index 9d458f9..cbff7ae 100644 --- a/docs/codex-bridge.md +++ b/docs/codex-bridge.md @@ -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 diff --git a/skills/codex/SKILL.md b/skills/codex/SKILL.md index 52ae3f8..2317bc6 100644 --- a/skills/codex/SKILL.md +++ b/skills/codex/SKILL.md @@ -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. ---