diff --git a/docs/site/content/docs/agents/claude-code.mdx b/docs/site/content/docs/agents/claude-code.mdx index a0d733abd4a6..9cc8257efa4b 100644 --- a/docs/site/content/docs/agents/claude-code.mdx +++ b/docs/site/content/docs/agents/claude-code.mdx @@ -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. diff --git a/src/main/claude-accounts/claude-account-auto-switch-policy.test.ts b/src/main/claude-accounts/claude-account-auto-switch-policy.test.ts new file mode 100644 index 000000000000..7812bd4082db --- /dev/null +++ b/src/main/claude-accounts/claude-account-auto-switch-policy.test.ts @@ -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) + }) +}) diff --git a/src/main/claude-accounts/claude-account-auto-switch-policy.ts b/src/main/claude-accounts/claude-account-auto-switch-policy.ts new file mode 100644 index 000000000000..46669dfcc7b9 --- /dev/null +++ b/src/main/claude-accounts/claude-account-auto-switch-policy.ts @@ -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 +} diff --git a/src/main/claude-accounts/claude-account-auto-switch.test.ts b/src/main/claude-accounts/claude-account-auto-switch.test.ts new file mode 100644 index 000000000000..e9a627689b7f --- /dev/null +++ b/src/main/claude-accounts/claude-account-auto-switch.test.ts @@ -0,0 +1,396 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import type { ClaudeManagedAccountSummary } from '../../shared/managed-account-types' +import type { ProviderRateLimits, RateLimitState } from '../../shared/rate-limit-types' +import { createEmptyRateLimitState } from '../../shared/rate-limit-state-factory' +import { createClaudeAccountAutoSwitch } from './claude-account-auto-switch' +import { CLAUDE_AUTO_SWITCH_COOLDOWN_MS } from './claude-account-auto-switch-policy' + +const HOST = { runtime: 'host' as const, wslDistro: null } + +function account( + id: string, + overrides: Partial = {} +): ClaudeManagedAccountSummary { + return { + id, + email: `${id}@example.com`, + managedAuthRuntime: 'host', + wslDistro: null, + authMethod: 'subscription-oauth', + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: 0, + ...overrides + } +} + +function limits(usedPercent: number, updatedAt = Date.now()): ProviderRateLimits { + return { + provider: 'claude', + session: { usedPercent, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: null, + updatedAt, + error: null, + status: 'ok' + } +} + +// `tag` names the account the active reading was fetched for, as the usage fetcher does. +function usageState( + active: ProviderRateLimits, + inactive: Record, + tag = 'profile:a', + target: RateLimitState['claudeTarget'] = HOST +): RateLimitState { + return createEmptyRateLimitState({ + claude: { ...active, usageMetadata: { authProvenance: tag } }, + claudeTarget: target, + inactiveClaudeAccounts: Object.entries(inactive).map(([accountId, rateLimits]) => ({ + accountId, + rateLimits, + updatedAt: rateLimits.updatedAt, + isFetching: false + })) + }) +} + +// What the usage service publishes, synchronously, once it has started reading the accounts. +function withReadUnderWay(state: RateLimitState): RateLimitState { + return { + ...state, + inactiveClaudeAccounts: state.inactiveClaudeAccounts.map((usage) => ({ + ...usage, + isFetching: true + })) + } +} + +function setup(accounts = [account('a'), account('b'), account('c')]) { + const settings = { + ...getDefaultSettings('/tmp'), + activeClaudeManagedAccountId: 'a', + claudeAutoSwitchEnabled: true + } + const selectAccount = vi.fn(async (accountId: string, _target, stillWanted: () => boolean) => { + if (!stillWanted()) { + return false + } + settings.activeClaudeManagedAccountId = accountId + return true + }) + const fetchInactiveUsage = vi.fn(async () => {}) + const onSwitched = vi.fn() + const autoSwitch = createClaudeAccountAutoSwitch({ + getSettings: () => settings, + rateLimits: { fetchInactiveClaudeAccountsOnOpen: fetchInactiveUsage }, + accounts: { + listAccounts: () => ({ accounts, activeAccountId: settings.activeClaudeManagedAccountId }), + autoSelectAccountForTarget: selectAccount + }, + onSwitched + }) + // Holds the next switch in the queue until `release` runs, as a pending sign-in would. + const holdNextSwitch = (): (() => void) => { + let release: () => void = () => {} + selectAccount.mockImplementationOnce(async (accountId, _target, stillWanted) => { + await new Promise((resolve) => { + release = resolve + }) + if (!stillWanted()) { + return false + } + settings.activeClaudeManagedAccountId = accountId + return true + }) + return () => release() + } + return { settings, autoSwitch, selectAccount, fetchInactiveUsage, onSwitched, holdNextSwitch } +} + +describe('createClaudeAccountAutoSwitch', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-10-12T09:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('moves new sessions to the account with the most room and reports it', async () => { + const { autoSwitch, selectAccount, onSwitched } = setup() + + autoSwitch(usageState(limits(95), { b: limits(50), c: limits(20) })) + await vi.runAllTimersAsync() + + expect(selectAccount).toHaveBeenCalledWith('c', HOST, expect.any(Function)) + expect(onSwitched).toHaveBeenCalledWith({ + accounts: [ + expect.objectContaining({ id: 'a' }), + expect.objectContaining({ id: 'b' }), + expect.objectContaining({ id: 'c' }) + ], + from: expect.objectContaining({ id: 'a', email: 'a@example.com' }), + to: expect.objectContaining({ id: 'c', email: 'c@example.com' }), + target: HOST + }) + }) + + it('does nothing while auto-switch is off', () => { + const { settings, autoSwitch, selectAccount, fetchInactiveUsage } = setup() + settings.claudeAutoSwitchEnabled = false + + autoSwitch(usageState(limits(100), { b: limits(0) })) + + expect(selectAccount).not.toHaveBeenCalled() + expect(fetchInactiveUsage).not.toHaveBeenCalled() + }) + + it('asks for a new reading of the other accounts until one is under way', async () => { + const { autoSwitch, selectAccount, fetchInactiveUsage } = setup([account('a'), account('b')]) + const hourAgo = Date.now() - 60 * 60_000 + const stale = usageState(limits(95), { b: limits(10, hourAgo) }) + + // The usage service skips a read it debounces, so an ask can start nothing. + autoSwitch(stale) + autoSwitch(stale) + await Promise.resolve() + expect(fetchInactiveUsage).toHaveBeenCalledTimes(2) + + autoSwitch(withReadUnderWay(stale)) + autoSwitch(stale) + await Promise.resolve() + expect(fetchInactiveUsage).toHaveBeenCalledTimes(2) + expect(selectAccount).not.toHaveBeenCalled() + }) + + it('asks for the reading only after the update it reacted to has gone out', async () => { + const { autoSwitch, fetchInactiveUsage } = setup([account('a'), account('b')]) + const hourAgo = Date.now() - 60 * 60_000 + + // The usage service publishes from inside the ask, and must not do so mid-publish. + autoSwitch(usageState(limits(95), { b: limits(10, hourAgo) })) + expect(fetchInactiveUsage).not.toHaveBeenCalled() + + await Promise.resolve() + expect(fetchInactiveUsage).toHaveBeenCalledOnce() + }) + + it('does not count a read of another runtime as a read of its own accounts', async () => { + const { autoSwitch, fetchInactiveUsage } = setup([ + account('a'), + account('b'), + account('wsl', { managedAuthRuntime: 'wsl', wslDistro: 'Ubuntu' }) + ]) + const hourAgo = Date.now() - 60 * 60_000 + const state = usageState(limits(95), { b: limits(10, hourAgo), wsl: limits(0) }) + + autoSwitch({ + ...state, + inactiveClaudeAccounts: state.inactiveClaudeAccounts.map((usage) => + usage.accountId === 'wsl' ? { ...usage, isFetching: true } : usage + ) + }) + await Promise.resolve() + + expect(fetchInactiveUsage).toHaveBeenCalledOnce() + }) + + it('never picks an account from another runtime or one that needs a sign-in', () => { + const { autoSwitch, selectAccount } = setup([ + account('a'), + account('wsl', { managedAuthRuntime: 'wsl', wslDistro: 'Ubuntu' }), + account('signed-out', { needsSignIn: true }), + account('b') + ]) + + autoSwitch(usageState(limits(95), { wsl: limits(0), 'signed-out': limits(0), b: limits(40) })) + + expect(selectAccount).toHaveBeenCalledWith('b', HOST, expect.any(Function)) + }) + + it('leaves an account picked by hand alone until the cooldown ends', () => { + const { settings, autoSwitch, selectAccount } = setup() + autoSwitch(usageState(limits(30), { b: limits(95), c: limits(20) })) + + settings.activeClaudeManagedAccountId = 'b' + autoSwitch(usageState(limits(95), { a: limits(30), c: limits(20) }, 'profile:b')) + expect(selectAccount).not.toHaveBeenCalled() + + vi.advanceTimersByTime(CLAUDE_AUTO_SWITCH_COOLDOWN_MS) + autoSwitch(usageState(limits(95), { a: limits(30), c: limits(20) }, 'profile:b')) + expect(selectAccount).toHaveBeenCalledWith('c', HOST, expect.any(Function)) + }) + + it("never reads the previous account's usage as the new account's", () => { + const { settings, autoSwitch, selectAccount } = setup() + autoSwitch(usageState(limits(50), { b: limits(10), c: limits(20) })) + + settings.activeClaudeManagedAccountId = 'b' + autoSwitch(usageState(limits(50), { a: limits(50), c: limits(20) }, 'profile:a')) + vi.advanceTimersByTime(1_000) + autoSwitch(usageState(limits(100), { a: limits(50), c: limits(20) }, 'profile:a')) + + expect(selectAccount).not.toHaveBeenCalled() + }) + + it('trusts System default usage only for an account that runs on System default', () => { + const covered = setup([account('a', { runsOnSystemDefault: true }), account('b')]) + covered.autoSwitch(usageState(limits(95), { b: limits(10) }, 'system')) + expect(covered.selectAccount).toHaveBeenCalledWith('b', HOST, expect.any(Function)) + + const own = setup([account('a'), account('b')]) + own.autoSwitch(usageState(limits(95), { b: limits(10) }, 'system')) + expect(own.selectAccount).not.toHaveBeenCalled() + }) + + it('reads a WSL account by its distro-tagged usage', () => { + const ubuntu = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + const { settings, autoSwitch, selectAccount } = setup([ + account('a', { managedAuthRuntime: 'wsl', wslDistro: 'Ubuntu' }), + account('b', { managedAuthRuntime: 'wsl', wslDistro: 'Ubuntu' }) + ]) + settings.activeClaudeManagedAccountIdsByRuntime = { host: null, wsl: { Ubuntu: 'a' } } + + autoSwitch(usageState(limits(95), { b: limits(10) }, 'profile:a:wsl:Ubuntu', ubuntu)) + + expect(selectAccount).toHaveBeenCalledWith('b', ubuntu, expect.any(Function)) + }) + + it('finds the other accounts of the default WSL distro', () => { + const defaultDistro = { runtime: 'wsl' as const, wslDistro: null } + const { settings, autoSwitch, selectAccount } = setup([ + account('a', { managedAuthRuntime: 'wsl', wslDistro: 'Ubuntu' }), + account('b', { managedAuthRuntime: 'wsl', wslDistro: 'Ubuntu' }), + account('host') + ]) + settings.activeClaudeManagedAccountIdsByRuntime = { host: null, wsl: { Ubuntu: 'a' } } + + autoSwitch( + usageState( + limits(95), + { b: limits(10), host: limits(0) }, + 'profile:a:wsl:Ubuntu', + defaultDistro + ) + ) + + expect(selectAccount).toHaveBeenCalledWith('b', defaultDistro, expect.any(Function)) + }) + + it("reports the switch on the accounts' own distro, as a switch from the menu does", async () => { + const { settings, autoSwitch, onSwitched } = setup([ + account('a', { managedAuthRuntime: 'wsl', wslDistro: 'Ubuntu' }), + account('b', { managedAuthRuntime: 'wsl', wslDistro: 'Ubuntu' }) + ]) + settings.activeClaudeManagedAccountIdsByRuntime = { host: null, wsl: { Ubuntu: 'a' } } + + autoSwitch( + usageState(limits(95), { b: limits(10) }, 'profile:a:wsl:Ubuntu', { + runtime: 'wsl', + wslDistro: null + }) + ) + await vi.runAllTimersAsync() + + expect(onSwitched).toHaveBeenCalledWith( + expect.objectContaining({ target: { runtime: 'wsl', wslDistro: 'Ubuntu' } }) + ) + }) + + it('does not wait for an account the usage service cannot read yet', async () => { + const { autoSwitch, selectAccount, fetchInactiveUsage } = setup() + const hourAgo = Date.now() - 60 * 60_000 + const insideRetryAfter = { + ...limits(10, hourAgo), + usageMetadata: { retryAtMs: Date.now() + 60_000 } + } + + autoSwitch(usageState(limits(95), { b: limits(20), c: insideRetryAfter })) + await Promise.resolve() + + expect(fetchInactiveUsage).not.toHaveBeenCalled() + expect(selectAccount).toHaveBeenCalledWith('b', HOST, expect.any(Function)) + }) + + it('drops a queued switch once the selection changed by hand', async () => { + const { settings, autoSwitch, onSwitched, holdNextSwitch } = setup() + const release = holdNextSwitch() + + autoSwitch(usageState(limits(95), { b: limits(50), c: limits(20) })) + settings.activeClaudeManagedAccountId = 'b' + release() + await vi.runAllTimersAsync() + + expect(settings.activeClaudeManagedAccountId).toBe('b') + expect(onSwitched).not.toHaveBeenCalled() + }) + + it('drops a queued switch once the usage is back under the line', async () => { + const { settings, autoSwitch, onSwitched, holdNextSwitch } = setup() + const release = holdNextSwitch() + + autoSwitch(usageState(limits(95), { b: limits(50), c: limits(20) })) + vi.advanceTimersByTime(1_000) + autoSwitch(usageState(limits(40), { b: limits(50), c: limits(20) })) + release() + await vi.runAllTimersAsync() + + expect(settings.activeClaudeManagedAccountId).toBe('a') + expect(onSwitched).not.toHaveBeenCalled() + }) + + it("waits for every account's reading before picking one", async () => { + const { autoSwitch, selectAccount, fetchInactiveUsage } = setup() + const hourAgo = Date.now() - 60 * 60_000 + + autoSwitch(usageState(limits(95), { b: limits(70), c: limits(0, hourAgo) })) + await Promise.resolve() + expect(fetchInactiveUsage).toHaveBeenCalledOnce() + expect(selectAccount).not.toHaveBeenCalled() + + autoSwitch(usageState(limits(95), { b: limits(70), c: limits(0) })) + expect(selectAccount).toHaveBeenCalledWith('c', HOST, expect.any(Function)) + }) + + it('switches once while a switch is still running', () => { + const { autoSwitch, selectAccount } = setup([account('a'), account('b')]) + selectAccount.mockReturnValue(new Promise(() => {})) + + autoSwitch(usageState(limits(100), { b: limits(10) })) + autoSwitch(usageState(limits(100), { b: limits(10) })) + + expect(selectAccount).toHaveBeenCalledOnce() + }) + + it('picks from the accounts it could read once a read was tried', async () => { + const { autoSwitch, selectAccount, fetchInactiveUsage } = setup() + const hourAgo = Date.now() - 60 * 60_000 + const state = usageState(limits(95), { b: limits(20), c: limits(10, hourAgo) }) + fetchInactiveUsage.mockImplementation(async () => autoSwitch(withReadUnderWay(state))) + + autoSwitch(state) + await Promise.resolve() + expect(selectAccount).not.toHaveBeenCalled() + + autoSwitch(state) + expect(fetchInactiveUsage).toHaveBeenCalledOnce() + expect(selectAccount).toHaveBeenCalledWith('b', HOST, expect.any(Function)) + }) + + it('waits for a new usage reading before trying again after a failed switch', async () => { + const { autoSwitch, selectAccount } = setup([account('a'), account('b')]) + selectAccount.mockRejectedValueOnce(new Error('sync failed')) + const atLimit = limits(100) + + autoSwitch(usageState(atLimit, { b: limits(10) })) + vi.advanceTimersByTime(1_000) + await vi.runAllTimersAsync() + autoSwitch(usageState(atLimit, { b: limits(10) })) + expect(selectAccount).toHaveBeenCalledOnce() + + autoSwitch(usageState(limits(100), { b: limits(10) })) + expect(selectAccount).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/claude-accounts/claude-account-auto-switch.ts b/src/main/claude-accounts/claude-account-auto-switch.ts new file mode 100644 index 000000000000..37738d2e3437 --- /dev/null +++ b/src/main/claude-accounts/claude-account-auto-switch.ts @@ -0,0 +1,196 @@ +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { ClaudeManagedAccountSummary } from '../../shared/managed-account-types' +import type { + ProviderRateLimits, + RateLimitRuntimeTarget, + RateLimitState +} from '../../shared/rate-limit-types' +import type { RateLimitService } from '../rate-limits/service' +import { DEFAULT_POLL_MS } from '../rate-limits/service/service-types' +import { + isClaudeOverAutoSwitchLine, + pickClaudeAutoSwitchTarget +} from './claude-account-auto-switch-policy' +import { + getSelectedClaudeAccountIdForTarget, + normalizeClaudeAccountSelectionTarget +} from './runtime-selection' +import type { ClaudeAccountService } from './service' + +type ClaudeAutoSwitchSettings = Pick< + GlobalSettings, + | 'claudeAutoSwitchEnabled' + | 'activeClaudeManagedAccountId' + | 'activeClaudeManagedAccountIdsByRuntime' +> + +type ClaudeAutoSwitchDeps = { + getSettings: () => ClaudeAutoSwitchSettings + rateLimits: Pick + accounts: Pick + onSwitched: (switched: ClaudeAutoSwitched) => void +} + +export type ClaudeAutoSwitched = { + accounts: ClaudeManagedAccountSummary[] + from: ClaudeManagedAccountSummary + to: ClaudeManagedAccountSummary + target: RateLimitRuntimeTarget +} + +type ClaudeAutoSwitchDecision = + | { kind: 'stay' } + | { kind: 'read-usage' } + | { kind: 'switch'; from: ClaudeManagedAccountSummary; to: ClaudeManagedAccountSummary } + +const STAY: ClaudeAutoSwitchDecision = { kind: 'stay' } + +/** Moves new Claude sessions to the managed account with the most room before a usage limit. */ +export function createClaudeAccountAutoSwitch( + deps: ClaudeAutoSwitchDeps +): (state: RateLimitState) => void { + let latestState: RateLimitState | null = null + let selectedAccountId: string | null | undefined + let lastSwitchAt = 0 + let lastUsageReadAt = 0 + let switching = false + + const decide = (state: RateLimitState, now: number): ClaudeAutoSwitchDecision => { + const settings = deps.getSettings() + const selectedId = getSelectedClaudeAccountIdForTarget(settings, state.claudeTarget) + if (!settings.claudeAutoSwitchEnabled || !selectedId) { + return STAY + } + const accounts = deps.accounts.listAccounts().accounts + const from = accounts.find((account) => account.id === selectedId) + if ( + !from || + // Why: mid-switch, the snapshot can still be the previous account's. + !isReadingOf(state.claude, from) || + // Why: after a switch or a failed attempt, act only on a newer reading. + (state.claude?.updatedAt ?? 0) < lastSwitchAt || + !isClaudeOverAutoSwitchLine(state.claude, now) + ) { + return STAY + } + + const candidateIds = new Set( + accounts + .filter( + (account) => + account.id !== selectedId && !account.needsSignIn && sharesRuntime(account, from) + ) + .map((account) => account.id) + ) + const usages = state.inactiveClaudeAccounts.filter((usage) => candidateIds.has(usage.accountId)) + const candidates = usages.filter((usage) => now - usage.updatedAt <= DEFAULT_POLL_MS) + // Why: an account inside its Retry-After cannot be read, so ranking does not wait for it. + const unreadable = usages.filter( + (usage) => + !candidates.includes(usage) && (usage.rateLimits?.usageMetadata?.retryAtMs ?? 0) > now + ) + // Why: rank only once every readable candidate has a recent reading, or a read was just tried. + if (candidates.length + unreadable.length < candidateIds.size) { + // Why: the usage service skips a read it debounces, so only one under way counts as tried. + if (usages.some((usage) => usage.isFetching)) { + lastUsageReadAt = now + return STAY + } + if (now - lastUsageReadAt >= DEFAULT_POLL_MS) { + return { kind: 'read-usage' } + } + } + const targetId = pickClaudeAutoSwitchTarget(state.claude, candidates, { now, lastSwitchAt }) + const to = accounts.find((account) => account.id === targetId) + return to ? { kind: 'switch', from, to } : STAY + } + + const switchAccount = ( + from: ClaudeManagedAccountSummary, + to: ClaudeManagedAccountSummary, + target: RateLimitRuntimeTarget + ): void => { + switching = true + // Why: the switch can wait behind a sign-in, so it re-decides on the latest usage when it runs. + const stillWanted = (): boolean => { + const decision = latestState ? decide(latestState, Date.now()) : STAY + return decision.kind === 'switch' && decision.from.id === from.id && decision.to.id === to.id + } + deps.accounts + .autoSelectAccountForTarget(to.id, target, stillWanted) + .then((switched) => { + if (switched) { + // Why not the usage target: a default-distro target names no distro; panes do. + deps.onSwitched({ + accounts: deps.accounts.listAccounts().accounts, + from, + to, + target: runtimeOf(to) + }) + } + }) + .catch((error: unknown) => { + lastSwitchAt = Date.now() + console.warn('[claude-accounts] Auto-switch failed:', error) + }) + .finally(() => { + switching = false + }) + } + + return (state) => { + latestState = state + const now = Date.now() + const selectedId = getSelectedClaudeAccountIdForTarget(deps.getSettings(), state.claudeTarget) + // Why any change, not only ours: an account picked by hand gets the same cooldown. + if (selectedAccountId !== undefined && selectedId !== selectedAccountId) { + lastSwitchAt = now + } + selectedAccountId = selectedId + if (switching) { + return + } + const decision = decide(state, now) + if (decision.kind === 'read-usage') { + // Why deferred: the service publishes from inside this call, and this runs mid-publish. + queueMicrotask(() => { + deps.rateLimits + .fetchInactiveClaudeAccountsOnOpen() + .catch((error: unknown) => + console.warn('[claude-accounts] Failed to read inactive Claude usage:', error) + ) + }) + } else if (decision.kind === 'switch') { + switchAccount(decision.from, decision.to, state.claudeTarget) + } + } +} + +/** Usage is tagged `profile:` (`profile::wsl:` in WSL) by the account it was read for. */ +function isReadingOf( + limits: ProviderRateLimits | null, + account: ClaudeManagedAccountSummary +): boolean { + const provenance = limits?.usageMetadata?.authProvenance + // Why: an account System default covers runs on that login, so its usage is tagged `system`. + if (account.runsOnSystemDefault) { + return provenance === 'system' + } + return ( + provenance === `profile:${account.id}` || !!provenance?.startsWith(`profile:${account.id}:`) + ) +} + +/** Why not the usage target: with the default WSL distro it names no distro, but accounts do. */ +function sharesRuntime(a: ClaudeManagedAccountSummary, b: ClaudeManagedAccountSummary): boolean { + const left = runtimeOf(a) + const right = runtimeOf(b) + return left.runtime === right.runtime && left.wslDistro === right.wslDistro +} + +function runtimeOf(account: ClaudeManagedAccountSummary): RateLimitRuntimeTarget { + return normalizeClaudeAccountSelectionTarget({ + runtime: account.managedAuthRuntime, + wslDistro: account.wslDistro + }) +} diff --git a/src/main/claude-accounts/claude-account-service.test.ts b/src/main/claude-accounts/claude-account-service.test.ts index da97c54e8cac..d182b0a37e80 100644 --- a/src/main/claude-accounts/claude-account-service.test.ts +++ b/src/main/claude-accounts/claude-account-service.test.ts @@ -480,6 +480,30 @@ describe('ClaudeAccountService', () => { expect(f.settings().claudeManagedAccounts.map((entry) => entry.id)).toEqual(['a', 'b']) }) + it('drops a queued automatic switch once the user acts', async () => { + const f = fixture([account('a'), account('b'), account('c')]) + f.signIn('a', 'a@example.test') + f.signIn('b', 'b@example.test') + f.signIn('c', 'c@example.test') + f.runLogin.mockImplementationOnce( + (_args, _config, _timeoutMs, options) => + new Promise((_resolve, reject) => + options?.signal?.addEventListener('abort', () => + reject(new Error('Claude sign-in was cancelled.')) + ) + ) + ) + const adding = f.service.addAccount({ runtime: 'host' }).catch(() => null) + await vi.waitFor(() => expect(f.runLogin).toHaveBeenCalled()) + + const autoSwitch = f.service.autoSelectAccountForTarget('b', { runtime: 'host' }, () => true) + await f.service.removeAccount('c') + await adding + + await expect(autoSwitch).resolves.toBe(false) + expect(f.settings().activeClaudeManagedAccountIdsByRuntime?.host).toBe('a') + }) + it('clears the selection, republishes, then deletes the folder on remove', async () => { const f = fixture() f.signIn('a', 'a@example.test') diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index b1921b9c1190..1d62f98779dd 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -21,6 +21,7 @@ export class ClaudeAccountService { private mutationQueue: Promise = Promise.resolve() private cancelPendingClaudeLogin: (() => boolean) | null = null private pendingSignInLink: Promise | null = null + private userActionCount = 0 private openWslSignInLink: ((signInLink: string) => void) | null = null private readonly selection: ClaudeAccountSelection private readonly registration: ClaudeAccountRegistration @@ -118,6 +119,23 @@ export class ClaudeAccountService { return this.serializeMutation(() => this.selection.select(accountId, target)) } + /** A usage-driven switch waits behind a pending sign-in instead of cancelling it. */ + autoSelectAccountForTarget( + accountId: string, + target: ClaudeAccountSelectionTarget, + stillWanted: () => boolean + ): Promise { + const userActionCountWhenQueued = this.userActionCount + return this.serializeMutation(async () => { + // Why: anything the user did while this waited in the queue outranks it. + if (this.userActionCount !== userActionCountWhenQueued || !stillWanted()) { + return false + } + await this.selection.select(accountId, target) + return true + }) + } + getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string { return this.runtimeAuth.getRuntimeConfigDir(target) } @@ -156,6 +174,7 @@ export class ClaudeAccountService { // User actions cancel before queueing because the pending login holds the queue slot. private supersedePendingLogin(): void { + this.userActionCount += 1 if (this.cancelPendingLogin()) { console.info( '[claude-accounts] Cancelled a pending Claude login superseded by a new request.' diff --git a/src/main/provider-account-services.ts b/src/main/provider-account-services.ts index 94ee172797e3..cf8ff358a70c 100644 --- a/src/main/provider-account-services.ts +++ b/src/main/provider-account-services.ts @@ -5,6 +5,10 @@ import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' import { CodexAccountService } from './codex-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' import { ClaudeAccountService } from './claude-accounts/service' +import { + createClaudeAccountAutoSwitch, + type ClaudeAutoSwitched +} from './claude-accounts/claude-account-auto-switch' import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target' import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' import { getKimiRuntimeTarget, resolveKimiHome } from './kimi/kimi-runtime-home' @@ -31,6 +35,7 @@ export function createProviderAccountServices( options: { codexAccountLifecycle?: ConstructorParameters[3] setClaudeStatusLineListener: (listener: (event: ClaudeStatusLineRateLimits) => void) => void + onClaudeAutoSwitched?: (switched: ClaudeAutoSwitched) => void } ): ProviderAccountServices { const rateLimits = new RateLimitService() @@ -43,6 +48,14 @@ export function createProviderAccountServices( ) const claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) const claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) + rateLimits.onStateChange( + createClaudeAccountAutoSwitch({ + getSettings: () => store.getSettings(), + rateLimits, + accounts: claudeAccounts, + onSwitched: (switched) => options.onClaudeAutoSwitched?.(switched) + }) + ) // Background quota polls must not boot stopped distros; user-driven resets may prepare them. rateLimits.setCodexHomePathResolver(async (target) => target?.runtime === 'wsl' && !(await isWslDistroRunningForBackgroundWork(target.wslDistro)) diff --git a/src/main/rate-limits/claude-active-usage-fetch.ts b/src/main/rate-limits/claude-active-usage-fetch.ts index df9867efd3b4..dc48e36eb1e1 100644 --- a/src/main/rate-limits/claude-active-usage-fetch.ts +++ b/src/main/rate-limits/claude-active-usage-fetch.ts @@ -59,7 +59,9 @@ export async function fetchActiveClaudeRateLimits( ) } try { - return await fetchClaudeOAuthUsage(credentials.token, options?.signal) + const usage = await fetchClaudeOAuthUsage(credentials.token, options?.signal) + // Why: a successful reading names its account too, as failures and live session posts do. + return { ...usage, usageMetadata: { ...metadata, ...usage.usageMetadata } } } catch (error) { const { failureKind } = classifyClaudeOAuthUsageError(error) // Why: the 429 wait gates the poll, and both messages tell the user why usage is missing. diff --git a/src/main/rate-limits/claude-profile-usage.test.ts b/src/main/rate-limits/claude-profile-usage.test.ts index e718eb5a453d..4a680c7d58a2 100644 --- a/src/main/rate-limits/claude-profile-usage.test.ts +++ b/src/main/rate-limits/claude-profile-usage.test.ts @@ -100,6 +100,18 @@ it('lets the server decide a locally expired token and never refreshes it', asyn usageMetadata: { failureKind: 'stale-token' } }) }) +it('tags a successful reading with the account it was read for', async () => { + const f = profile() + writeFileSync( + join(f.home, '.credentials.json'), + JSON.stringify({ claudeAiOauth: { accessToken: 'fake', expiresAt: Date.now() + 3_600_000 } }) + ) + calls.usage.mockResolvedValueOnce({ provider: 'claude', status: 'ok' }) + expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ + status: 'ok', + usageMetadata: { authProvenance: 'profile:fake' } + }) +}) it('reads only the requested scoped Keychain, with no unsuffixed fallback', async () => { const f = profile() await readClaudeOAuthCredentials({ credentialsFileConfigDir: f.home, keychainConfigDir: f.home }) diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index 380a0cf7fcb7..864453479b18 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -17,6 +17,9 @@ import { browserManager } from '../browser/browser-manager' import { prewarmStructuredAgentModelCatalogs } from '../runtime/structured-agent-model-catalog-wiring' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { createAgentModelCatalogSettingsExpiry } from '../native-chat/agent-model-catalog/agent-model-catalog-account-expiry' +import { getRuntimeDesktopSurface } from '../runtime/runtime-desktop-surface' +import { translateMain } from '../i18n/main-i18n' +import type { ClaudeAutoSwitchedEvent } from '../../shared/managed-account-types' import { mainProcessState as state } from './main-process-state' // Settings that change which account or binary a new chat's agent runs under. @@ -56,7 +59,29 @@ export function initializeMainProcessAccountServices(): void { }) const services = createProviderAccountServices(store, { codexAccountLifecycle: { onHostSystemDefaultSelected: state.codexSessionMigration.requestRun }, - setClaudeStatusLineListener: (listener) => agentHookServer.setClaudeStatusLineListener(listener) + setClaudeStatusLineListener: (listener) => + agentHookServer.setClaudeStatusLineListener(listener), + onClaudeAutoSwitched: ({ accounts, from, to, target }) => { + // Why: running sessions follow the running-session setting, as after a switch by hand. + const switched: ClaudeAutoSwitchedEvent = { + accounts, + fromAccountId: from.id, + toAccountId: to.id, + target + } + state.mainWindow?.webContents.send('claudeAccounts:autoSwitched', switched) + if (!store.getSettings().notifications.enabled) { + return + } + getRuntimeDesktopSurface().showNotification({ + title: translateMain('accounts.claude.autoSwitchedTitle', 'Claude account switched'), + body: translateMain( + 'accounts.claude.autoSwitchedBody', + '{{from}} is near its usage limit. New Claude sessions start on {{to}}.', + { from: from.email, to: to.email } + ) + }) + } }) state.rateLimits = services.rateLimits state.codexRuntimeHome = services.codexRuntimeHome diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts index ee59efe1be9c..7718470aa775 100644 --- a/src/preload/api/agent-account-api.ts +++ b/src/preload/api/agent-account-api.ts @@ -1,4 +1,5 @@ import type { + ClaudeAutoSwitchedEvent, ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/managed-account-types' @@ -62,6 +63,7 @@ export type ClaudeAccountsApi = { runtime?: 'host' | 'wsl' wslDistro?: string | null }) => Promise + onAutoSwitched: (callback: (switched: ClaudeAutoSwitchedEvent) => void) => () => void } export type GrokAccountsApi = { diff --git a/src/preload/api/claude-accounts-bridge.ts b/src/preload/api/claude-accounts-bridge.ts index 2dd7d2c23b62..70b3cc3594fc 100644 --- a/src/preload/api/claude-accounts-bridge.ts +++ b/src/preload/api/claude-accounts-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { PreloadApi } from '../api-types' +import type { ClaudeAutoSwitchedEvent } from '../../shared/managed-account-types' export const claudeAccountsApi = { list: () => ipcRenderer.invoke('claudeAccounts:list'), @@ -16,5 +17,11 @@ export const claudeAccountsApi = { accountId: string | null runtime?: 'host' | 'wsl' wslDistro?: string | null - }) => ipcRenderer.invoke('claudeAccounts:select', args) + }) => ipcRenderer.invoke('claudeAccounts:select', args), + onAutoSwitched: (callback: (switched: ClaudeAutoSwitchedEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, switched: ClaudeAutoSwitchedEvent) => + callback(switched) + ipcRenderer.on('claudeAccounts:autoSwitched', listener) + return () => ipcRenderer.removeListener('claudeAccounts:autoSwitched', listener) + } } satisfies PreloadApi['claudeAccounts'] diff --git a/src/renderer/src/components/ClaudeAccountSwitchDialog.test.tsx b/src/renderer/src/components/ClaudeAccountSwitchDialog.test.tsx index 4fedda89cbf6..3e4fc72c79fd 100644 --- a/src/renderer/src/components/ClaudeAccountSwitchDialog.test.tsx +++ b/src/renderer/src/components/ClaudeAccountSwitchDialog.test.tsx @@ -11,6 +11,7 @@ import { } from '@/lib/claude-account-switch-sessions' import ClaudeAccountSwitchDialog from './ClaudeAccountSwitchDialog' import type * as AccountMove from '@/lib/claude-session-account-move' +import type { ClaudeAutoSwitchedEvent } from '../../../shared/managed-account-types' vi.mock('@/lib/claude-session-account-move', async (importOriginal) => ({ ...(await importOriginal()), @@ -36,6 +37,7 @@ const row = (id: string) => ({ }) const ROSTER = { accounts: [row('account-a'), row('account-b')] } const updateSettings = vi.fn(async () => {}) +let autoSwitched: (switched: ClaudeAutoSwitchedEvent) => void = () => {} async function switchWith(count: number, from = 'account-a', to = 'account-b'): Promise { vi.mocked(findMovableClaudeSessions).mockResolvedValue( @@ -57,6 +59,14 @@ describe('ClaudeAccountSwitchDialog', () => { beforeEach(() => { vi.clearAllMocks() resetClaudeAccountSwitchSessionsForTests() + vi.stubGlobal('api', { + claudeAccounts: { + onAutoSwitched: (callback: typeof autoSwitched) => { + autoSwitched = callback + return () => {} + } + } + }) useAppStore.setState({ updateSettings, pendingPaneAccountRestarts: {}, @@ -76,6 +86,23 @@ describe('ClaudeAccountSwitchDialog', () => { afterEach(() => { cleanup() + vi.unstubAllGlobals() + }) + + it('asks the same question after Orca switches accounts on its own', async () => { + vi.mocked(findMovableClaudeSessions).mockResolvedValue([candidate(1)]) + render() + + act(() => + autoSwitched({ + accounts: ROSTER.accounts, + fromAccountId: 'account-a', + toAccountId: 'account-b', + target: { runtime: 'host' } + }) + ) + + expect(await screen.findByText('Switched to account-b@example.com')).toBeTruthy() }) it('stays closed when no session needs restarting', async () => { diff --git a/src/renderer/src/components/ClaudeAccountSwitchDialog.tsx b/src/renderer/src/components/ClaudeAccountSwitchDialog.tsx index 232cb99f6d5a..8746a0937cb2 100644 --- a/src/renderer/src/components/ClaudeAccountSwitchDialog.tsx +++ b/src/renderer/src/components/ClaudeAccountSwitchDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useSyncExternalStore } from 'react' +import { useEffect, useState, useSyncExternalStore } from 'react' import { RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' @@ -18,8 +18,15 @@ import { subscribeClaudeAccountSwitchPrompt, type ClaudeAccountSwitchPrompt } from '@/lib/claude-account-switch-sessions' +import { followClaudeAutoSwitch } from '@/lib/claude-auto-switch-follow' export default function ClaudeAccountSwitchDialog(): React.JSX.Element | null { + // Why here: a switch Orca makes before a usage limit asks, or restarts, through this same question. + useEffect( + () => + window.api.claudeAccounts.onAutoSwitched((switched) => void followClaudeAutoSwitch(switched)), + [] + ) const prompt = useSyncExternalStore( subscribeClaudeAccountSwitchPrompt, getClaudeAccountSwitchPrompt, diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx index 552f827ef1bf..f96806245ae6 100644 --- a/src/renderer/src/components/settings/AccountsPane.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.test.tsx @@ -327,4 +327,17 @@ describe('Claude accounts section', () => { expect(own).toContain('System default: me@example.test') expect(own).not.toContain('An earlier Orca version') }) + + it('offers auto-switching only once there is another account to switch to', () => { + const one = render({ accounts: [account('a', 'a@example.test')] }) + const two = render( + { accounts: [account('a', 'a@example.test'), account('b', 'b@example.test')] }, + { claudeAutoSwitchEnabled: true } + ) + expect(one).not.toContain('Switch accounts before a usage limit') + expect(two).toContain('Switch accounts before a usage limit') + expect(two).toMatch( + /aria-label="Switch accounts before a usage limit"[^>]*aria-checked="true"|aria-checked="true"[^>]*aria-label="Switch accounts before a usage limit"/ + ) + }) }) diff --git a/src/renderer/src/components/settings/accounts-claude-search.ts b/src/renderer/src/components/settings/accounts-claude-search.ts index 4015850fae72..73ee09da5a08 100644 --- a/src/renderer/src/components/settings/accounts-claude-search.ts +++ b/src/renderer/src/components/settings/accounts-claude-search.ts @@ -22,6 +22,27 @@ export const getAccountsClaudeSearchEntries = createLocalizedCatalog(() => [ ...translateSearchKeyword('auto.components.settings.accounts.search.f2d666a886', 'optional') ] }, + { + title: translate('accounts.claude.autoSwitch', 'Switch accounts before a usage limit'), + description: translate( + 'accounts.claude.autoSwitchDescription', + 'When the selected account passes 90% of its 5-hour or weekly limit, Orca switches to the account with the most room left, as if you had switched by hand.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.accounts.search.e14049e1a8', 'claude'), + ...translateSearchKeyword('auto.components.settings.accounts.search.5b3f18ef4a', 'switch'), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.e949b08ffb', + 'rate limit' + ), + ...translateSearchKeyword('auto.components.settings.accounts.search.c759741d77', 'quota'), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.cursor.kw.usage', + 'usage' + ), + ...translateSearchKeyword('accounts.claude.keyword.auto', 'auto') + ] + }, { title: translate( 'accounts.claude.switchSessions.settingTitle', diff --git a/src/renderer/src/components/settings/accounts-pane-claude-section.tsx b/src/renderer/src/components/settings/accounts-pane-claude-section.tsx index eca8e0d20efc..819618b8b749 100644 --- a/src/renderer/src/components/settings/accounts-pane-claude-section.tsx +++ b/src/renderer/src/components/settings/accounts-pane-claude-section.tsx @@ -7,6 +7,7 @@ import { ButtonGroup } from '../ui/button-group' import { Label } from '../ui/label' import { ClaudeIcon } from '../status-bar/icons' import { SearchableSetting } from './SearchableSetting' +import { ClaudeAutoSwitchSetting } from './claude-auto-switch-setting' import { ClaudeRunningSessionSwitchSetting } from './claude-running-session-switch-setting' import { getProviderAccountRuntime, @@ -383,6 +384,9 @@ export function renderClaudeAccountsSection(model: AccountsPaneSectionModel): Re )} + {isRemoteAccountScope || visibleClaudeAccounts.length < 2 ? null : ( + + )} {isRemoteAccountScope ? null : ( )} diff --git a/src/renderer/src/components/settings/accounts-search.test.ts b/src/renderer/src/components/settings/accounts-search.test.ts index 4931510b1b92..5e8bafb58176 100644 --- a/src/renderer/src/components/settings/accounts-search.test.ts +++ b/src/renderer/src/components/settings/accounts-search.test.ts @@ -15,6 +15,8 @@ vi.mock('./settings-search-keywords', () => ({ translateSearchKeyword: (_key: string, fallback: string) => [fallback] })) +import { matchesSettingsSearch } from './settings-search' +import { getAccountsClaudeSearchEntries } from './accounts-claude-search' import { getAccountsMiniMaxSearchEntries, getAccountsOpencodeSearchEntries, @@ -39,6 +41,15 @@ describe('getAccountsPaneSearchEntries', () => { }) }) +describe('getAccountsClaudeSearchEntries', () => { + it('finds the Claude section by the auto-switch setting', () => { + expect(matchesSettingsSearch('before a usage limit', getAccountsClaudeSearchEntries())).toBe( + true + ) + expect(matchesSettingsSearch('auto', getAccountsClaudeSearchEntries())).toBe(true) + }) +}) + describe('getAccountsMiniMaxSearchEntries', () => { it('returns a single entry that targets the MiniMax session cookie flow', () => { const entries = getAccountsMiniMaxSearchEntries() diff --git a/src/renderer/src/components/settings/claude-auto-switch-setting.tsx b/src/renderer/src/components/settings/claude-auto-switch-setting.tsx new file mode 100644 index 000000000000..24b08be73d1e --- /dev/null +++ b/src/renderer/src/components/settings/claude-auto-switch-setting.tsx @@ -0,0 +1,37 @@ +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { translate } from '@/i18n/i18n' +import { Label } from '../ui/label' +import { Switch } from '../ui/switch' +import { SearchableSetting } from './SearchableSetting' + +export function ClaudeAutoSwitchSetting({ + settings, + updateSettings +}: { + settings: GlobalSettings + updateSettings: (updates: Partial) => void +}): React.JSX.Element { + const title = translate('accounts.claude.autoSwitch', 'Switch accounts before a usage limit') + const description = translate( + 'accounts.claude.autoSwitchDescription', + 'When the selected account passes 90% of its 5-hour or weekly limit, Orca switches to the account with the most room left, as if you had switched by hand.' + ) + return ( + +
+ +

{description}

+
+ updateSettings({ claudeAutoSwitchEnabled: checked })} + /> +
+ ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ee55b0940d87..33b424bfc41f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -20754,6 +20754,13 @@ "added": "Account added. Select it to use it for the next Claude you start.", "nextLaunch": "{{value0}} → {{value1}}. The next Claude you start uses it. Running sessions keep their account.", "profileSwitching": "Switching applies to the next Claude you start in any tab.", + "autoSwitch": "Switch accounts before a usage limit", + "autoSwitchDescription": "When the selected account passes 90% of its 5-hour or weekly limit, Orca switches to the account with the most room left, as if you had switched by hand.", + "autoSwitchedTitle": "Claude account switched", + "autoSwitchedBody": "{{from}} is near its usage limit. New Claude sessions start on {{to}}.", + "keyword": { + "auto": "auto" + }, "systemDefaultNamed": "System default: {{value0}}", "systemDefaultIsSaved": "System default is signed in as {{value0}}, which is also a saved account. An earlier Orca version may have copied that login there. If it is not your own login, select System default and run claude /login.", "systemDefaultIsSavedDismiss": "Dismiss", diff --git a/src/renderer/src/lib/claude-auto-switch-follow.test.ts b/src/renderer/src/lib/claude-auto-switch-follow.test.ts new file mode 100644 index 000000000000..099f3558ca61 --- /dev/null +++ b/src/renderer/src/lib/claude-auto-switch-follow.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClaudeAccountSwitchRunningSessions } from '../../../shared/claude-account-switch-settings' +import type * as AccountMove from './claude-session-account-move' +import { + getClaudeAccountSwitchPrompt, + resetClaudeAccountSwitchSessionsForTests +} from './claude-account-switch-sessions' +import { followClaudeAutoSwitch } from './claude-auto-switch-follow' + +type StoreStub = { + settings: { claudeAccountSwitchRunningSessions?: ClaudeAccountSwitchRunningSessions } + terminalLayoutsByTabId: Record }> + updateSettings: () => Promise + queuePaneAccountRestarts: (ptyIds: string[], agent: string) => void + consumePendingPaneAccountRestart: (ptyId: string) => void +} + +const store = vi.hoisted((): StoreStub => ({ + settings: {}, + terminalLayoutsByTabId: { 'tab-1': { ptyIdsByLeafId: { 'leaf-1': 'pty-1' } } }, + updateSettings: vi.fn(async () => {}), + queuePaneAccountRestarts: vi.fn(), + consumePendingPaneAccountRestart: vi.fn() +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => store } })) +vi.mock('sonner', () => ({ toast: { success: vi.fn() } })) +vi.mock('./claude-session-account-move', async (importOriginal) => ({ + ...(await importOriginal()), + findMovableClaudeSessions: vi.fn(async () => [ + { ptyId: 'pty-1', worktreeId: 'wt-1', tabId: 'tab-1', leafId: 'leaf-1' } + ]), + findQueuedClaudeRestartsOnAccount: vi.fn(() => []) +})) + +const row = (id: string) => ({ + id, + email: `${id}@example.com`, + managedAuthRuntime: 'host' as const, + wslDistro: null, + authMethod: 'subscription-oauth' as const, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 +}) +const ROSTER = { accounts: [row('account-a'), row('account-b')] } +const SWITCHED = { + accounts: ROSTER.accounts, + fromAccountId: 'account-a', + toAccountId: 'account-b', + target: { runtime: 'host' as const, wslDistro: null } +} + +describe('followClaudeAutoSwitch', () => { + beforeEach(() => { + vi.clearAllMocks() + resetClaudeAccountSwitchSessionsForTests() + store.settings = {} + // Why never answering: the switch carries its accounts, so nothing waits on a read meanwhile. + vi.stubGlobal('window', { + api: { claudeAccounts: { list: () => new Promise(() => {}) } } + }) + }) + + it('restarts sessions still on the old account when the setting is Always restart', async () => { + store.settings = { claudeAccountSwitchRunningSessions: 'restart' } + + await followClaudeAutoSwitch(SWITCHED) + + expect(store.queuePaneAccountRestarts).toHaveBeenCalledWith(['pty-1'], 'claude') + }) + + it('asks about them by default, as a switch by hand does', async () => { + await followClaudeAutoSwitch(SWITCHED) + + expect(getClaudeAccountSwitchPrompt()?.sessions).toHaveLength(1) + expect(store.queuePaneAccountRestarts).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/claude-auto-switch-follow.ts b/src/renderer/src/lib/claude-auto-switch-follow.ts new file mode 100644 index 000000000000..69870ec42e0c --- /dev/null +++ b/src/renderer/src/lib/claude-auto-switch-follow.ts @@ -0,0 +1,13 @@ +import type { ClaudeAutoSwitchedEvent } from '../../../shared/managed-account-types' +import { handleRunningClaudeSessionsAfterSwitch } from './claude-account-switch-sessions' + +/** Treats running Claude sessions after an automatic switch the way a switch by hand does. */ +export async function followClaudeAutoSwitch(event: ClaudeAutoSwitchedEvent): Promise { + const state = { accounts: event.accounts } + await handleRunningClaudeSessionsAfterSwitch({ + owner: { kind: 'local' }, + target: event.target, + before: { state, accountId: event.fromAccountId }, + after: { state, accountId: event.toAccountId } + }) +} diff --git a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts index 66ca2493f3be..2e3e7fbb8ebc 100644 --- a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts +++ b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts @@ -141,7 +141,9 @@ export function createClaudeAccountsApi(): PreloadApi['claudeAccounts'] { { accountId }, ACCOUNT_MUTATION_TIMEOUT_MS ) - } + }, + // Why: an automatic switch on the server is the server's own; this client restarts nothing. + onAutoSwitched: () => () => {} } } diff --git a/src/shared/claude-account-switch-settings.ts b/src/shared/claude-account-switch-settings.ts index c246a470ff91..3fd95a0385b1 100644 --- a/src/shared/claude-account-switch-settings.ts +++ b/src/shared/claude-account-switch-settings.ts @@ -4,4 +4,6 @@ export type ClaudeAccountSwitchRunningSessions = 'ask' | 'restart' | 'keep' export type ClaudeAccountSwitchSettings = { /** Absent means 'ask'. */ claudeAccountSwitchRunningSessions?: ClaudeAccountSwitchRunningSessions + /** Opt-in: before a usage limit, new Claude sessions move to the account with the most room. */ + claudeAutoSwitchEnabled?: boolean } diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index 0ef592bffd41..68eae30ac6b8 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -186,6 +186,7 @@ export function buildDefaultSettings(args: { activeCodexManagedAccountIdsByRuntime: { host: null, wsl: {} }, claudeManagedAccounts: [], activeClaudeManagedAccountId: null, + claudeAutoSwitchEnabled: false, terminalScopeHistoryByWorktree: true, terminalHiddenViewParking: true, // C1 kill switches — runtime reads stay `!== false` so older persisted diff --git a/src/shared/managed-account-types.ts b/src/shared/managed-account-types.ts index a83bb7615610..e9396b3a3d3a 100644 --- a/src/shared/managed-account-types.ts +++ b/src/shared/managed-account-types.ts @@ -126,6 +126,15 @@ export type ClaudeRateLimitAccountsState = { systemDefaultMayBeCopied?: boolean } +/** Orca moved Claude to another account before a usage limit, so running sessions can follow. */ +export type ClaudeAutoSwitchedEvent = { + /** The accounts at the switch, so following it waits on no read another switch could overtake. */ + accounts: ClaudeManagedAccountSummary[] + fromAccountId: string + toAccountId: string + target: { runtime?: 'host' | 'wsl'; wslDistro?: string | null } +} + export type ClaudeManagedAccountRuntimeSelection = { host: string | null wsl: Record