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
2 changes: 2 additions & 0 deletions docs/site/content/docs/agents/claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Orca reads your local `~/.claude` usage state and shows current usage plus rate-

Orca supports multiple Claude accounts and can swap between them in one click, matching the Codex flow. Switching accounts works even with live Claude sessions running — Orca holds the in-progress switch behind a guard so you don't trigger overlapping auth refreshes. See [Hot-swap Codex accounts](/docs/agents/codex-hot-swap) — the Claude flow is identical in shape.

With two or more Claude accounts on this device, **Settings → Accounts → Claude → Switch accounts before a usage limit** lets Orca do the swap for you. Once the selected account passes 90% of its 5-hour or weekly limit and another account has clearly more room left, the next Claude you start uses that account. Running sessions keep their account. It is off by default.

## Subagents and teams

Background subagents and Agent Teams teammates can show as expandable child rows under the lead agent in the worktree agent list and [Agent Dashboard](/docs/model/agents-sessions#agent-dashboard). Selecting a child focuses the lead terminal.
Expand Down
2 changes: 2 additions & 0 deletions docs/site/content/docs/agents/codex.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Pick **Codex** from the agent combobox. Orca launches `codex` with the worktree

Many Codex users run multiple accounts to stretch rate limits. Orca's account switcher swaps the active account without re-login or config edits. See [Hot-swap Codex accounts](/docs/agents/codex-hot-swap).

With two or more managed Codex accounts, enable **Switch accounts before a usage limit** in **Settings → Accounts → Codex** to move new sessions to the account with the most room when the selected account reaches 90% of its session or weekly limit. Running sessions keep their account.

## System default vs extra accounts

**System default** uses your real `~/.codex` login (the same home a bare `codex` outside Orca would use). Extra accounts Orca manages get their own home under Orca's account data so credentials and rollouts stay isolated. New Codex launches follow the active account; already-running sessions keep the home they started with until you restart them.
Expand Down
62 changes: 62 additions & 0 deletions src/main/accounts/account-auto-switch-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { InactiveAccountUsage, ProviderRateLimits } from '../../shared/rate-limit-types'

const AUTO_SWITCH_THRESHOLD_PERCENT = 90
// Why: a switch only reaches the next agent started, so a fresh pick needs time to be used.
export const ACCOUNT_AUTO_SWITCH_COOLDOWN_MS = 5 * 60_000
// Why: an account only a few points under the line would cross it again within minutes.
const MIN_PERCENT_GAINED = 10

/** The tightest 5-hour or weekly window still running (0 once both reset), or null when usage could not be read. */
function getAccountUsedPercent(limits: ProviderRateLimits | null, now: number): number | null {
if (limits?.status !== 'ok') {
return null
}
const windows = [limits.session, limits.weekly].filter((window) => window !== null)
if (windows.length === 0) {
return null
}
// Why: a window past its reset has started over, yet an idle session still reports its old usage.
const current = windows.filter((window) => window.resetsAt === null || window.resetsAt > now)
return Math.max(0, ...current.map((window) => window.usedPercent))
}

export function isAccountOverAutoSwitchLine(
limits: ProviderRateLimits | null,
now: number
): boolean {
return (getAccountUsedPercent(limits, now) ?? 0) >= AUTO_SWITCH_THRESHOLD_PERCENT
}

/** The account new agent sessions should move to, or null to stay. */
export function pickAccountAutoSwitchTarget(
active: ProviderRateLimits | null,
candidates: InactiveAccountUsage[],
{ now, lastSwitchAt }: { now: number; lastSwitchAt: number }
): string | null {
const used = getAccountUsedPercent(active, now)
if (used === null || used < AUTO_SWITCH_THRESHOLD_PERCENT) {
return null
}
const atLimit = used >= 100
if (!atLimit && now - lastSwitchAt < ACCOUNT_AUTO_SWITCH_COOLDOWN_MS) {
return null
}
const best = candidates
.flatMap((candidate) => {
const candidateUsed = getAccountUsedPercent(candidate.rateLimits, now)
return candidateUsed !== null && candidateUsed < 100
? [{ accountId: candidate.accountId, used: candidateUsed }]
: []
})
.sort((a, b) => a.used - b.used)[0]
if (!best) {
return null
}
// Why: at the limit the agent stops anyway, so any account with room beats staying.
if (atLimit) {
return best.accountId
}
const clearlyBetter =
best.used < AUTO_SWITCH_THRESHOLD_PERCENT && best.used <= used - MIN_PERCENT_GAINED
return clearlyBetter ? best.accountId : null
}
128 changes: 128 additions & 0 deletions src/main/claude-accounts/claude-account-auto-switch-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest'
import type { InactiveAccountUsage, ProviderRateLimits } from '../../shared/rate-limit-types'
import {
ACCOUNT_AUTO_SWITCH_COOLDOWN_MS as CLAUDE_AUTO_SWITCH_COOLDOWN_MS,
pickAccountAutoSwitchTarget as pickClaudeAutoSwitchTarget
} from '../accounts/account-auto-switch-policy'

function limits(session: number, weekly = 0, status: ProviderRateLimits['status'] = 'ok') {
const window = (usedPercent: number, windowMinutes: number) => ({
usedPercent,
windowMinutes,
resetsAt: null,
resetDescription: null
})
return {
provider: 'claude' as const,
session: window(session, 300),
weekly: window(weekly, 10_080),
updatedAt: 0,
error: null,
status
}
}

function usage(accountId: string, rateLimits: ProviderRateLimits): InactiveAccountUsage {
return { accountId, rateLimits, updatedAt: rateLimits.updatedAt, isFetching: false }
}

// The 5-hour window ended a minute ago, as an idle session's stale report would show it.
function sessionEnded(usedPercent: number): ProviderRateLimits {
const reading = limits(usedPercent)
return { ...reading, session: { ...reading.session, resetsAt: NOW - 60_000 } }
}

const NOW = Date.UTC(2026, 9, 12, 9)
const COOLED_DOWN = CLAUDE_AUTO_SWITCH_COOLDOWN_MS

function pick(
active: ProviderRateLimits,
candidates: InactiveAccountUsage[],
msSinceLastSwitch: number
): string | null {
return pickClaudeAutoSwitchTarget(active, candidates, {
now: NOW,
lastSwitchAt: NOW - msSinceLastSwitch
})
}

describe('pickClaudeAutoSwitchTarget', () => {
it('stays while the active account is under the line', () => {
expect(pick(limits(89), [usage('b', limits(0))], COOLED_DOWN)).toBe(null)
})

it('moves to the account with the most room once the active account crosses the line', () => {
const candidates = [usage('b', limits(50)), usage('c', limits(20)), usage('d', limits(35))]

expect(pick(limits(90), candidates, COOLED_DOWN)).toBe('c')
})

it('counts the weekly window as well as the 5-hour one', () => {
expect(pick(limits(10, 95), [usage('b', limits(10, 10))], COOLED_DOWN)).toBe('b')
})

it('ranks candidates by their tightest window', () => {
const candidates = [usage('b', limits(5, 85)), usage('c', limits(40, 40))]

expect(pick(limits(92), candidates, COOLED_DOWN)).toBe('c')
})

it('stays when the best account is not clearly better', () => {
expect(pick(limits(91), [usage('b', limits(86))], COOLED_DOWN)).toBe(null)
})

it('never moves to an account that is itself over the line before the limit is hit', () => {
expect(pick(limits(99), [usage('b', limits(90))], COOLED_DOWN)).toBe(null)
})

it('skips accounts whose usage could not be read', () => {
const candidates = [usage('expired', limits(0, 0, 'error')), usage('b', limits(40))]

expect(pick(limits(95), candidates, COOLED_DOWN)).toBe('b')
})

it('stays while the active usage cannot be read', () => {
expect(pick(limits(100, 0, 'error'), [usage('b', limits(0))], COOLED_DOWN)).toBe(null)
})

it('waits out the cooldown after the last switch', () => {
const candidates = [usage('b', limits(10))]

expect(pick(limits(95), candidates, COOLED_DOWN - 1)).toBe(null)
})

it('at the limit, takes any account with room even during the cooldown', () => {
const candidates = [usage('b', limits(97)), usage('c', limits(100))]

expect(pick(limits(100), candidates, 0)).toBe('b')
})

it('ignores usage from a window that has already reset', () => {
expect(pick(sessionEnded(96), [usage('b', limits(0))], COOLED_DOWN)).toBe(null)
})

it('counts an account as free again once its window has reset', () => {
expect(pick(limits(95), [usage('b', sessionEnded(100))], COOLED_DOWN)).toBe('b')
})

it('reads an account whose windows have all reset as empty', () => {
const allEnded = (usedPercent: number): ProviderRateLimits => {
const reading = limits(usedPercent, usedPercent)
const ended = { resetsAt: NOW - 60_000 }
return {
...reading,
session: { ...reading.session, ...ended },
weekly: { ...reading.weekly, ...ended }
}
}

expect(pick(allEnded(100), [usage('b', limits(0))], COOLED_DOWN)).toBe(null)
expect(pick(limits(95), [usage('b', allEnded(100))], COOLED_DOWN)).toBe('b')
})

it('stays when every account is out', () => {
const candidates = [usage('b', limits(100)), usage('c', limits(20, 100))]

expect(pick(limits(100), candidates, COOLED_DOWN)).toBe(null)
})
})
Loading