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. Claude sessions already running are handled as after a switch by hand, following **When you switch accounts while Claude is running** (ask, always restart, or never restart). 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
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 {
CLAUDE_AUTO_SWITCH_COOLDOWN_MS,
pickClaudeAutoSwitchTarget
} from './claude-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)
})
})
62 changes: 62 additions & 0 deletions src/main/claude-accounts/claude-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 CLAUDE_AUTO_SWITCH_THRESHOLD_PERCENT = 90
// Why: a switch only reaches the next Claude started, so a fresh pick needs time to be used.
export const CLAUDE_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 getClaudeUsedPercent(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 isClaudeOverAutoSwitchLine(
limits: ProviderRateLimits | null,
now: number
): boolean {
return (getClaudeUsedPercent(limits, now) ?? 0) >= CLAUDE_AUTO_SWITCH_THRESHOLD_PERCENT
}

/** The account new Claude sessions should move to, or null to stay. */
export function pickClaudeAutoSwitchTarget(
active: ProviderRateLimits | null,
candidates: InactiveAccountUsage[],
{ now, lastSwitchAt }: { now: number; lastSwitchAt: number }
): string | null {
const used = getClaudeUsedPercent(active, now)
if (used === null || used < CLAUDE_AUTO_SWITCH_THRESHOLD_PERCENT) {
return null
}
const atLimit = used >= 100
if (!atLimit && now - lastSwitchAt < CLAUDE_AUTO_SWITCH_COOLDOWN_MS) {
return null
}
const best = candidates
.flatMap((candidate) => {
const candidateUsed = getClaudeUsedPercent(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 < CLAUDE_AUTO_SWITCH_THRESHOLD_PERCENT && best.used <= used - MIN_PERCENT_GAINED
return clearlyBetter ? best.accountId : null
}
Loading