Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3c6103c
feat: add quota ledger and routing fuse (ORB-131)
thomasluizon Jul 28, 2026
0aeebe3
Merge remote-tracking branch 'origin/main' into feature/orb-131-build…
thomasluizon Jul 28, 2026
23bc689
Merge remote-tracking branch 'origin/main' into feature/orb-131-build…
thomasluizon Jul 28, 2026
dd420b7
ORB-131: Switch quota ledger to token budgets
thomasluizon Jul 28, 2026
7833902
ORB-131: Keep ledger recorder cross-repo safe
thomasluizon Jul 28, 2026
4ce0f16
ORB-131: Make quota reservations atomic
thomasluizon Jul 28, 2026
d2142a1
Merge remote-tracking branch 'origin/main' into feature/orb-131-build…
thomasluizon Jul 28, 2026
05bcd30
ORB-131: Isolate merged launcher fixtures
thomasluizon Jul 28, 2026
c4ead75
ORB-131: Preserve uncertain quota usage
thomasluizon Jul 28, 2026
3b27d9a
Merge remote-tracking branch 'origin/main' into feature/orb-131-build…
thomasluizon Jul 28, 2026
0afb18b
ORB-131: Recover failed quota setup
thomasluizon Jul 28, 2026
4e29ea8
Merge remote-tracking branch 'origin/main' into feature/orb-131-build…
thomasluizon Jul 28, 2026
4ec6ec8
ORB-131: Reclaim abandoned budget locks
thomasluizon Jul 29, 2026
05f0e54
ORB-131: Align quota windows and tiers
thomasluizon Jul 29, 2026
d61ce2f
Merge remote-tracking branch 'origin/main' into feature/orb-131-build…
thomasluizon Jul 29, 2026
f0ae1d7
ORB-131: Preserve reserved launches
thomasluizon Jul 29, 2026
a517d43
ORB-131: Retain accepted send reservations
thomasluizon Jul 29, 2026
2759205
Merge remote-tracking branch 'origin/main' into feature/orb-131-build…
thomasluizon Jul 29, 2026
29c6a03
ORB-131: Resolve the Orca quota binary
thomasluizon Jul 29, 2026
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
6 changes: 6 additions & 0 deletions .claude/orchestrator.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"deep": { "model": "opus", "args": ["--effort", "max"] }
},
"interactive": true,
"automationBudget": {
"tier": "routine"
},
"notes": "The prompt is the ticket body (D2). Model routing: the default is opus, tier:cheap selects sonnet, and tier:deep selects opus with --effort max so hard work gets a distinct invocation without changing the no-label default. The legacy worker:sonnet label is rejected loudly with remediation to use tier:cheap. The mode MUST be bypassPermissions, never acceptEdits: acceptEdits auto-approves file writes only, so every shell command still prompts, and a worker with nobody at the keyboard is stuck. Measured on the ORB-75 Phase 7 run, where git switch, dotnet build/test/format, gh and orca were all denied and the worker delivered files with zero gates run, zero commit and zero PR. There is deliberately no -p here either: headless mode is invisible to Orca, so the worktree card shows no Agents row and clicking it reveals a bare shell. The worker runs as a TUI, which means orca terminal wait must use --for tui-idle, never --for exit. One more thing a TUI worker needs answered for it: a brand-new worktree is a folder Claude Code has never seen, so its first screen is the workspace-trust gate (Quick safety check: Is this a project you created or one you trust? with 1. Yes, I trust this folder). Measured on the 2026-07-24 ORB-75 launch, where orca terminal wait --for tui-idle returned satisfied: false with blockedReason codex-trust-workspace and the worker sat on that screen with nobody at the keyboard. Same failure class as the permission mode above: the fix is to send 1 and Enter and wait again, which tools/launch-worker.mjs now does on every launch. Two more things measured on that same run, both about talking to a live worker. First, never orca terminal send to a worker that is not tui-idle: a send issued mid-turn is not delivered as a user turn at all. In the worker's own session transcript it appears only as four type: queue-operation records, never as a type: user entry, and the running turn was cut short on the mid-flow sentence Now the auth-side signup tests., leaving 14 modified and 7 untracked files with zero commits, zero gates and no PR. Everything a worker needs belongs in the prompt FILE at launch; mid-run information is appended to that file and pointed at, which is what tools/nudge-worker.mjs enforces by refusing to send while the terminal is busy. Second, tui-idle means stopped, not done: the same run read satisfied: true as completion when the worker had abandoned the task with nothing committed. Idle is a trigger to run tools/worker-status.mjs, which derives the verdict from commits, push, PR and Linear state, never a report of success."
},
"codex": {
Expand All @@ -30,6 +33,9 @@
}
},
"interactive": true,
"automationBudget": {
"tier": "routine"
},
"notes": "Model routing: the default is gpt-5.6-sol at high reasoning, tier:cheap selects gpt-5.6-luna at low reasoning, and tier:deep selects gpt-5.6-sol at max reasoning. Sol-high is the default because Terra-medium lost two review rounds on ORB-124 / PR #619 due care failures; xhigh and max were held in reserve for this per-ticket routing. The legacy worker:sonnet label is rejected loudly with remediation to use tier:cheap. Requires a paid ChatGPT plan plus codex login; a headless session can start that itself with codex login --device-auth, which prints a URL and a one-time code for the account owner to enter. CODEX_HOME DECIDES WHETHER A WORKER IS LOGGED IN, and it is the first thing to check before believing any auth verdict: Orca redirects codex's home for the terminals it spawns, so on this machine the real credential is C:\\Users\\thoma\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\auth.json, not the default ~/.codex/auth.json. Measured 2026-07-27: a shell that had lost CODEX_HOME reported 'Not logged in' and codex doctor reported 'no Codex credentials were found', while the very same CLI in an Orca terminal in the same worktree reported 'Logged in using ChatGPT' minutes earlier. For an unattended worker those two states are indistinguishable from having no plan at all, and the worker dies on a login screen nobody is there to answer. Two consequences. Never diagnose codex auth from an ad hoc shell without printing CODEX_HOME first. And a codex worker that never reaches tui-idle should be suspected of sitting on the login splash: that splash ANIMATES, so the repaint check in tools/launch-worker.mjs now turns it into a loud exit 1 rather than a prompt pointer typed into a sign-in screen. Reads AGENTS.md natively. The invocation is bare codex with no subcommand, which is what codex --help means by 'If no subcommand is specified, options will be forwarded to the interactive CLI'. codex exec is the NON-interactive subcommand and lands in the same unsupervisable place as claude -p, so tools/launch-worker.mjs still refuses it (per-engine headless tokens: exec and its alias e for codex, -p and --print for claude, because codex's -p is --profile, a legitimate interactive flag). Approval and sandbox policy is --dangerously-bypass-approvals-and-sandbox, not the equivalent pair -a never --sandbox danger-full-access, and never --full-auto: --full-auto is -a on-request --sandbox workspace-write, and on-request lets the MODEL decide when to ask a human who is not at the keyboard. The single flag beats the pair because the pair has a half-state: an edit that drops -a never while keeping the sandbox flag silently restores approval prompts to a worker nobody is watching, which is the exact failure this entry exists to prevent. The containment story is the disposable Orca worktree, the same one that justifies claude's bypassPermissions. Everything below was measured on 2026-07-27 against codex-cli 0.145.0 on Windows 11. -c windows.sandbox=\"unelevated\" is load-bearing: Codex's default Windows sandbox is elevated, its setup needs Administrator rights, and the first-run TUI paints 'Set up default sandbox (requires Administrator permissions)' then sits on 'Setting up sandbox... Input disabled until setup completes' forever in a PTY that has no desktop to raise UAC on. Measured still spinning after 2.5 minutes, with orca terminal wait reporting satisfied: true throughout, a false idle. The unelevated fallback (restricted tokens and ACLs, learn.chatgpt.com/docs/windows/windows-sandbox) needs no elevation, and the worker never executes inside it anyway because the bypass flag runs commands unsandboxed. cwd needs no -C: orca terminal create --worktree already starts the TUI in the worktree. Supervision, all measured: a real TUI opens, so the Orca card carries an Agents row and orca terminal wait --for tui-idle has a state to observe. The first screen in a brand-new worktree is codex's own trust gate, 'Do you trust the contents of this directory?', which normally reports blockedReason codex-interactive-prompt. Orca 1.4.156 was also measured retaining codex-trust-workspace on an idle codex terminal that never saw a trust gate. WHY: PR #629 owner adjudication makes that live capture authoritative over the older one-reason-per-engine mapping. It is a preselected list whose own instruction reads 'Press enter to continue', so the answer is Enter ALONE; the 1 then Enter that answers Claude Code's gate left the codex process exited (-1). launch-worker.mjs therefore keeps the trust answer per engine. Never blind-Enter an unrecognised codex screen: a stray Enter meant for one prompt was measured confirming the NEXT one, which is how the Administrator sandbox setup above got selected. Everything the claude entry says about talking to a live worker holds here unchanged: never send to a terminal that is not tui-idle, and tui-idle means stopped, not done, so it is the trigger to run tools/worker-status.mjs, never a report of success."
}
},
Expand Down
18 changes: 18 additions & 0 deletions .claude/skills/quota/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
name: quota
description: Read the current Claude and Codex quota windows as one machine-readable object. Use when the user asks for quota, allowance, usage percentage, reset timing, or whether either AI engine is available.
argument-hint: (none)
effort: low
---

# Quota

Run the repository quota reader from the orbit-ui-mobile root:

```bash
node tools/ai-quota.mjs --json
```

Return its JSON object unchanged. A single unavailable engine is a valid partial read. If both
engines are unavailable, show the object the tool printed and state that the read failed with
the tool's non-zero exit code.
3 changes: 3 additions & 0 deletions tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
> - POSIX `.sh` is the baseline; add a `.ps1` twin only when it must run in the user's PowerShell shell.
> - Add a new tool with `/make-tool`; add its row to the catalog below in the same change.
> - `test-tools.mjs` EXECUTES every script here (Harness Execution CI job). A new tool with no coverage entry fails it, so coverage lands in the same PR as the tool.
> - `ai-quota.mjs` reads both AI pools; `automation-budget.mjs` keeps their per-invocation costs separate and protects the routine automation allowance.
> - `check-lockstep.mjs` gates the six load-bearing harness twins shared with orbit-api.
> - `agent-review` gets its cross-model verdict from GPT-5.6 Sol through Codex.
> - `preflight.mjs` verifies an autonomous run's environment before any worktree is created and never repairs a failed precondition.
Expand All @@ -27,6 +28,8 @@ Read `CONVENTIONS.md` before adding one. Use the `/make-tool` skill to scaffold
| `capture-surfaces.mjs` | Playwright capture of one screenshot per manifest cell into `.artifacts/surfaces/`, against a running local stack. Reports every surface it cannot reach generically instead of skipping it. The evidence-gate screenshot mechanism (D7). | `ORBIT_AUTH_TOKEN=... npm run surfaces:capture` |
| `redesign-coverage.mjs` | Asserts the redesign denominator twice: every surface in `surfaces.json` is claimed by exactly one #539 redesign ticket (D35), and every `.tsx` under `apps/*/app` + `apps/*/components` maps to exactly one ticket by directory rule (D38); exits 1 on any unclaimed surface or orphaned file. Survives until #539 completes (D39). | `npm run redesign:coverage` (`--json`) |
| `arch-map.mjs` | Generates `architecture.json` + `architecture.html` (routes, parity pairs, endpoints, i18n ownership); the drift CI job (`arch-map.yml`) regenerates and fails on drift. | `node tools/arch-map.mjs` |
| `ai-quota.mjs` | Reads the current Claude and Codex quota windows from Orca's labeled Usage control and the Codex app-server JSON-RPC API, returning both engines in one object while preserving a healthy side when the other is unavailable. Backs `/quota`. | `node tools/ai-quota.mjs --json` (`--help`) |
| `automation-budget.mjs` | Maintains the append-only per-invocation automation ledger and applies the engine-local weekly routing fuse: routine work warns at 20 percent and blocks at 25 percent, while explicitly reserved deep work remains available. | `node tools/automation-budget.mjs check|record|report --help` |
| `check-dashes.mjs` | The cross-repo dash ban: em dashes banned everywhere, en dashes only in numeric ranges. Backs the Dash Ban CI job, lefthook, and the shrink-only `dash-baseline.json`. | `--files <f>...` \| `--check-baseline` \| `--write-baseline` \| `--text "<s>"` |
| `check-copy.mjs` | The copy register: whole-file, values-only scan of locale copy for AI cliches, placeholder content, typed uppercase, and hardcoded brand colors. Backs the Copy Register CI job. | `node tools/check-copy.mjs --check` \| `--write-baseline` |
| `check-context-budget.mjs` | Enforces the shrink-only byte budget for repo-visible always-loaded context and the structural allowlists for sibling `@` imports and unconditional rule files. Reports resolvable sibling context without enforcing it. Backs the Context Budget CI job. | `node tools/check-context-budget.mjs --check` \| `--write-baseline` \| `--json` |
Expand Down
245 changes: 245 additions & 0 deletions tools/ai-quota.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
#!/usr/bin/env node

import { execFile, spawn } from "node:child_process"
import { promisify } from "node:util"

const USAGE = `usage: ai-quota.mjs --json

Reads the current Claude and Codex quota from their authoritative sources.
One unavailable provider does not fail the combined read.

--json print the combined quota object
--help, -h print this usage and exit 0

exit codes: 0 at least one provider was read, 1 both providers unavailable, 2 usage error`

const argv = process.argv.slice(2)
if (argv.includes("--help") || argv.includes("-h")) {
process.stdout.write(`${USAGE}\n`)
process.exit(0)
}
if (argv.length !== 1 || argv[0] !== "--json") {
process.stderr.write(`ai-quota: expected exactly --json\n\n${USAGE}\n`)
process.exit(2)
}

const execFileAsync = promisify(execFile)
const ORCA = process.env.ORCA_BIN || "orca"
Comment thread
thomasluizon marked this conversation as resolved.
Resolved
const configuredTimeout = Number(process.env.AI_QUOTA_TIMEOUT_MS)
const PROVIDER_TIMEOUT_MS =
Number.isInteger(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : 30_000

const unavailableClaude = () => ({
status: "UNAVAILABLE",
weeklyPercent: null,
sessionPercent: null,
resetsIn: null,
})

const unavailableCodex = () => ({
status: "UNAVAILABLE",
usedPercent: null,
windowDays: null,
resetsAt: null,
hasCredits: null,
planType: null,
})

const parseOrcaTree = (raw) => {
const envelope = JSON.parse(raw)
if (envelope?.ok !== true || typeof envelope?.result?.snapshot?.treeText !== "string") {
throw new Error("Orca did not return an accessibility tree")
}
return envelope.result.snapshot.treeText
}

const findUsageIndex = (tree) => {
const match = tree.match(/^\s*(\d+)\s+button Usage(?:,.*)?$/m)
if (!match) throw new Error("Orca Usage control was not found by label")
return match[1]
}

const parseClaudeQuota = (tree) => {
const match = tree.match(
/Claude\s+Resets in\s+(.+?)\s+5h\s+(\d+(?:\.\d+)?)%\s+wk\s+(\d+(?:\.\d+)?)%/i,
)
if (!match) return null
return {
status: "OK",
weeklyPercent: Number(match[3]),
sessionPercent: Number(match[2]),
resetsIn: match[1].trim(),
}
}

const readOrcaTree = async (args, deadline) => {
const timeout = deadline - Date.now()
if (timeout <= 0) throw new Error("Orca quota read timed out")
const { stdout } = await execFileAsync(ORCA, args, {
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
timeout,
})
return parseOrcaTree(stdout)
}

const readClaudeQuota = async () => {
const deadline = Date.now() + PROVIDER_TIMEOUT_MS
try {
for (let attempt = 0; attempt < 2; attempt++) {
const stateTree = await readOrcaTree(
["computer", "get-app-state", "--app", "Orca", "--json", "--no-screenshot"],
deadline,
)
const usageIndex = findUsageIndex(stateTree)
const clickedTree = await readOrcaTree(
[
"computer",
"click",
"--app",
"Orca",
"--element-index",
usageIndex,
"--json",
"--no-screenshot",
],
deadline,
)
const quota = parseClaudeQuota(clickedTree)
if (quota) return quota
}
} catch {
return unavailableClaude()
}
return unavailableClaude()
}

const parseCodexQuota = (message) => {
const rateLimits = message?.result?.rateLimits
const usedPercent = rateLimits?.primary?.usedPercent
const windowDurationMins = rateLimits?.primary?.windowDurationMins
const resetsAt = rateLimits?.primary?.resetsAt
Comment thread
thomasluizon marked this conversation as resolved.
Resolved
const hasCredits = rateLimits?.credits?.hasCredits
const planType = rateLimits?.planType
if (
!Number.isFinite(usedPercent) ||
!Number.isFinite(windowDurationMins) ||
!Number.isFinite(resetsAt) ||
typeof hasCredits !== "boolean" ||
Comment thread
thomasluizon marked this conversation as resolved.
Resolved
typeof planType !== "string"
) {
throw new Error("Codex returned incomplete rate limits")
}
return {
status: "OK",
usedPercent,
windowDays: windowDurationMins / 1_440,
resetsAt,
hasCredits,
planType,
}
}

const spawnCodexServer = () => {
const options = { stdio: ["pipe", "pipe", "pipe"] }
if (process.env.CODEX_BIN) {
return spawn(process.env.CODEX_BIN, ["app-server"], options)
}
if (process.platform === "win32") {
return spawn(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "codex app-server"], options)
}
return spawn("codex", ["app-server"], options)
}

const readCodexQuota = async () => {
let child
try {
child = spawnCodexServer()
} catch {
return unavailableCodex()
}

return new Promise((resolve) => {
let settled = false
let buffer = ""
const finish = (quota) => {
if (settled) return
settled = true
clearTimeout(timer)
child.stdin.end()
child.kill()
resolve(quota)
}
const fail = () => finish(unavailableCodex())
const timer = setTimeout(fail, PROVIDER_TIMEOUT_MS)
const initialize = {
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
clientInfo: {
name: "ai-quota",
title: "AI Quota",
version: "1.0.0",
},
},
}
const rateLimitsRead = {
jsonrpc: "2.0",
id: 2,
method: "account/rateLimits/read",
params: null,
}

child.on("error", fail)
child.on("close", fail)
child.stdin.on("error", fail)
child.stderr.resume()
child.stdout.setEncoding("utf8")
child.stdout.on("data", (chunk) => {
buffer += chunk
if (buffer.length > 32 * 1024 * 1024) {
fail()
return
}
for (;;) {
const newline = buffer.indexOf("\n")
if (newline < 0) return
const line = buffer.slice(0, newline).trim()
buffer = buffer.slice(newline + 1)
if (!line) continue
let message
try {
message = JSON.parse(line)
} catch {
fail()
return
}
if (message.id === 1) {
if (message.error || !message.result) {
fail()
return
}
child.stdin.write(`${JSON.stringify(rateLimitsRead)}\n`)
}
if (message.id === 2) {
if (message.error) {
fail()
return
}
try {
finish(parseCodexQuota(message))
} catch {
fail()
}
return
}
}
})
child.stdin.write(`${JSON.stringify(initialize)}\n`)
})
}

const [claude, codex] = await Promise.all([readClaudeQuota(), readCodexQuota()])
process.stdout.write(`${JSON.stringify({ claude, codex })}\n`)
process.exitCode = claude.status === "UNAVAILABLE" && codex.status === "UNAVAILABLE" ? 1 : 0
Loading
Loading