From 3dfbfc1033e7da13500fdf5cdcf8dcef65dab520 Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:08:14 +0200 Subject: [PATCH 01/11] feat(claude-accounts): switch accounts before a usage limit Opt-in setting. When the selected managed Claude account passes 90% of its 5-hour or weekly window, new Claude sessions move to the managed account on the same runtime with the most room left. A 10-point margin and a 5-minute cooldown (also after a manual pick) stop two accounts trading places; at a hard limit any account with room wins. Accounts that need a sign-in, sit on another runtime, or have no reading from the last 15 minutes are skipped; stale readings are refreshed before ranking. Only readings tagged with the selected account count, and a switch queued behind a sign-in re-decides on the latest usage and yields to anything the user did meanwhile. --- .../claude-account-auto-switch-policy.test.ts | 91 ++++++ .../claude-account-auto-switch-policy.ts | 54 ++++ .../claude-account-auto-switch.test.ts | 276 ++++++++++++++++++ .../claude-account-auto-switch.ts | 170 +++++++++++ .../claude-account-selection.ts | 2 +- .../claude-account-service.test.ts | 27 ++ src/main/claude-accounts/service.ts | 22 +- .../startup/main-process-account-services.ts | 23 ++ .../src/components/settings/AccountsPane.tsx | 2 +- .../settings/accounts-claude-search.ts | 46 +++ .../accounts-pane-claude-section.test.tsx | 13 + .../settings/accounts-pane-claude-section.tsx | 33 +++ .../settings/accounts-search.test.ts | 11 + .../components/settings/accounts-search.ts | 23 +- src/renderer/src/i18n/locales/en.json | 7 + src/shared/default-global-settings.ts | 1 + src/shared/global-settings-types.ts | 2 + src/shared/managed-account-types.ts | 2 + 18 files changed, 780 insertions(+), 25 deletions(-) create mode 100644 src/main/claude-accounts/claude-account-auto-switch-policy.test.ts create mode 100644 src/main/claude-accounts/claude-account-auto-switch-policy.ts create mode 100644 src/main/claude-accounts/claude-account-auto-switch.test.ts create mode 100644 src/main/claude-accounts/claude-account-auto-switch.ts create mode 100644 src/renderer/src/components/settings/accounts-claude-search.ts 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..9794bc34b6ba --- /dev/null +++ b/src/main/claude-accounts/claude-account-auto-switch-policy.test.ts @@ -0,0 +1,91 @@ +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 } +} + +const COOLED_DOWN = CLAUDE_AUTO_SWITCH_COOLDOWN_MS + +describe('pickClaudeAutoSwitchTarget', () => { + it('stays while the active account is under the line', () => { + expect(pickClaudeAutoSwitchTarget(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(pickClaudeAutoSwitchTarget(limits(90), candidates, COOLED_DOWN)).toBe('c') + }) + + it('counts the weekly window as well as the 5-hour one', () => { + expect( + pickClaudeAutoSwitchTarget(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(pickClaudeAutoSwitchTarget(limits(92), candidates, COOLED_DOWN)).toBe('c') + }) + + it('stays when the best account is not clearly better', () => { + expect(pickClaudeAutoSwitchTarget(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(pickClaudeAutoSwitchTarget(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(pickClaudeAutoSwitchTarget(limits(95), candidates, COOLED_DOWN)).toBe('b') + }) + + it('stays while the active usage cannot be read', () => { + expect( + pickClaudeAutoSwitchTarget(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(pickClaudeAutoSwitchTarget(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(pickClaudeAutoSwitchTarget(limits(100), candidates, 0)).toBe('b') + }) + + it('stays when every account is out', () => { + const candidates = [usage('b', limits(100)), usage('c', limits(20, 100))] + + expect(pickClaudeAutoSwitchTarget(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..16376a148114 --- /dev/null +++ b/src/main/claude-accounts/claude-account-auto-switch-policy.ts @@ -0,0 +1,54 @@ +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 of the 5-hour and weekly windows, or null when usage could not be read. */ +function getClaudeUsedPercent(limits: ProviderRateLimits | null): number | null { + if (limits?.status !== 'ok') { + return null + } + const windows = [limits.session, limits.weekly].filter((window) => window !== null) + return windows.length > 0 ? Math.max(...windows.map((window) => window.usedPercent)) : null +} + +export function isClaudeOverAutoSwitchLine(limits: ProviderRateLimits | null): boolean { + return (getClaudeUsedPercent(limits) ?? 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[], + msSinceLastSwitch: number +): string | null { + const used = getClaudeUsedPercent(active) + if (used === null || used < CLAUDE_AUTO_SWITCH_THRESHOLD_PERCENT) { + return null + } + const atLimit = used >= 100 + if (!atLimit && msSinceLastSwitch < CLAUDE_AUTO_SWITCH_COOLDOWN_MS) { + return null + } + const best = candidates + .flatMap((candidate) => { + const candidateUsed = getClaudeUsedPercent(candidate.rateLimits) + 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..a127463e8892 --- /dev/null +++ b/src/main/claude-accounts/claude-account-auto-switch.test.ts @@ -0,0 +1,276 @@ +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 + })) + }) +} + +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({ from: 'a@example.com', to: 'c@example.com' }) + }) + + 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('reads the other accounts again before deciding on old usage', () => { + const { autoSwitch, selectAccount, fetchInactiveUsage } = setup() + const hourAgo = Date.now() - 60 * 60_000 + + autoSwitch(usageState(limits(95), { b: limits(10, hourAgo) })) + autoSwitch(usageState(limits(95), { b: limits(10, hourAgo) })) + + expect(fetchInactiveUsage).toHaveBeenCalledOnce() + expect(selectAccount).not.toHaveBeenCalled() + }) + + 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', { coveredBySystemDefault: 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('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", () => { + const { autoSwitch, selectAccount, fetchInactiveUsage } = setup() + const hourAgo = Date.now() - 60 * 60_000 + + autoSwitch(usageState(limits(95), { b: limits(70), c: limits(0, hourAgo) })) + 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() + selectAccount.mockReturnValue(new Promise(() => {})) + + autoSwitch(usageState(limits(100), { b: limits(10) })) + autoSwitch(usageState(limits(100), { b: limits(10) })) + + expect(selectAccount).toHaveBeenCalledOnce() + }) + + it('switches once when reading the other accounts pushes new usage straight away', () => { + 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(state)) + + autoSwitch(state) + + expect(selectAccount).toHaveBeenCalledOnce() + }) + + 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..557cc9e37d41 --- /dev/null +++ b/src/main/claude-accounts/claude-account-auto-switch.ts @@ -0,0 +1,170 @@ +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: (emails: { from: string; to: string }) => void +} + +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) + ) { + return STAY + } + + const candidateIds = new Set( + accounts + .filter( + (account) => + account.id !== selectedId && + !account.needsSignIn && + isOnTarget(account, state.claudeTarget) + ) + .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: rank only once every candidate has a recent reading, or a read was just tried. + if (candidates.length < candidateIds.size) { + if (usages.some((usage) => usage.isFetching)) { + 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) { + deps.onSwitched({ from: from.email, to: to.email }) + } + }) + .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') { + lastUsageReadAt = now + 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.coveredBySystemDefault) { + return provenance === 'system' + } + return ( + provenance === `profile:${account.id}` || !!provenance?.startsWith(`profile:${account.id}:`) + ) +} + +function isOnTarget(account: ClaudeManagedAccountSummary, target: RateLimitRuntimeTarget): boolean { + const accountTarget = normalizeClaudeAccountSelectionTarget({ + runtime: account.managedAuthRuntime, + wslDistro: account.wslDistro + }) + return accountTarget.runtime === target.runtime && accountTarget.wslDistro === target.wslDistro +} diff --git a/src/main/claude-accounts/claude-account-selection.ts b/src/main/claude-accounts/claude-account-selection.ts index b26010420781..0df0950aa9e6 100644 --- a/src/main/claude-accounts/claude-account-selection.ts +++ b/src/main/claude-accounts/claude-account-selection.ts @@ -117,7 +117,7 @@ export class ClaudeAccountSelection { } // Why: an account System default is signed in to runs there until it signs in on its own. return this.runtimeAuth.router.coveredBySystemDefault(account.id) - ? summary + ? { ...summary, coveredBySystemDefault: true } : { ...summary, needsSignIn: true } } diff --git a/src/main/claude-accounts/claude-account-service.test.ts b/src/main/claude-accounts/claude-account-service.test.ts index 211f769b3b67..9bab84a65a7e 100644 --- a/src/main/claude-accounts/claude-account-service.test.ts +++ b/src/main/claude-accounts/claude-account-service.test.ts @@ -129,6 +129,9 @@ describe('ClaudeAccountService', () => { expect(f.service.listAccounts().accounts.find((row) => row.id === 'b')?.needsSignIn).toBe( undefined ) + expect(f.service.listAccounts().accounts.find((row) => row.id === 'b')).toMatchObject({ + coveredBySystemDefault: true + }) await expect(f.service.selectAccount('b')).resolves.toMatchObject({ activeAccountId: 'b' }) }) @@ -372,6 +375,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 ac4ee0748edc..0bce2b9f8b2f 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -19,6 +19,7 @@ export class ClaudeAccountService { private mutationQueue: Promise = Promise.resolve() private cancelPendingClaudeLogin: (() => boolean) | null = null private pendingSignInLink: Promise | null = null + private userActionCount = 0 private readonly selection: ClaudeAccountSelection private readonly registration: ClaudeAccountRegistration @@ -99,6 +100,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) } @@ -135,8 +153,10 @@ export class ClaudeAccountService { } // Why before the queue, not inside it: the abandoned login owns the queue slot every later - // account action waits for. Only user-driven actions supersede it. + // account action waits for. Only user-driven actions supersede it, and each one also outranks a + // queued automatic switch. 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/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index bb54e610d02b..d011108093bb 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -4,6 +4,9 @@ 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 } from '../claude-accounts/claude-account-auto-switch' +import { getRuntimeDesktopSurface } from '../runtime/runtime-desktop-surface' +import { translateMain } from '../i18n/main-i18n' import { KeybindingService } from '../keybindings/keybinding-service' import { createCodexSessionMigrationScheduler } from '../codex/codex-session-migration-scheduler' import { startCodexSessionBackfillInBackground } from '../codex/codex-session-backfill' @@ -79,6 +82,26 @@ export function initializeMainProcessAccountServices(): void { state.codexSessionMigration.scheduleInitialRun() state.claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) state.claudeAccounts = new ClaudeAccountService(store, state.rateLimits, state.claudeRuntimeAuth) + state.rateLimits.onStateChange( + createClaudeAccountAutoSwitch({ + getSettings: () => store.getSettings(), + rateLimits: state.rateLimits, + accounts: state.claudeAccounts, + onSwitched: ({ from, to }) => { + 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, to } + ) + }) + } + }) + ) // Why: quota polls are background work and must not boot a stopped distro; user-driven // resets call prepareForRateLimitFetch directly. state.rateLimits.setCodexHomePathResolver(async (target) => diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 119991af3457..99c02e69d84f 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -16,7 +16,6 @@ import { watchProviderAccounts } from '@/runtime/runtime-provider-accounts-client' import { - getAccountsClaudeSearchEntries, getAccountsCodexSearchEntries, getAccountsGeminiSearchEntries, getAccountsCursorSearchEntries, @@ -28,6 +27,7 @@ import { getAccountsPaneSearchEntries, getAccountsZcodePlanSearchEntries } from './accounts-search' +import { getAccountsClaudeSearchEntries } from './accounts-claude-search' import { getRemoteAccountsPaneScope } from './provider-account-scope' import { ProviderHostScopeControl } from './ProviderHostScopeControl' import { SettingsSectionStack } from './SettingsSectionStack' diff --git a/src/renderer/src/components/settings/accounts-claude-search.ts b/src/renderer/src/components/settings/accounts-claude-search.ts new file mode 100644 index 000000000000..df4431011ee0 --- /dev/null +++ b/src/renderer/src/components/settings/accounts-claude-search.ts @@ -0,0 +1,46 @@ +import { translate } from '@/i18n/i18n' +import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { translateSearchKeyword } from './settings-search-keywords' + +export const getAccountsClaudeSearchEntries = createLocalizedCatalog(() => [ + { + title: translate('auto.components.settings.accounts.search.75682e1b62', 'Claude Accounts'), + description: translate( + 'auto.components.settings.accounts.search.dd75a73991', + 'Optional account switching for Claude while preserving shared chat context.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.accounts.search.e14049e1a8', 'claude'), + ...translateSearchKeyword('auto.components.settings.accounts.search.06662af91e', 'account'), + ...translateSearchKeyword('auto.components.settings.accounts.search.5b3f18ef4a', 'switch'), + ...translateSearchKeyword('auto.components.settings.accounts.search.8b06729e0f', 'active'), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.86edc96bc9', + 'status bar' + ), + ...translateSearchKeyword('auto.components.settings.accounts.search.c759741d77', 'quota'), + ...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, new Claude sessions start on the account with the most room left. Running sessions keep their account.' + ), + 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') + ] + } +]) diff --git a/src/renderer/src/components/settings/accounts-pane-claude-section.test.tsx b/src/renderer/src/components/settings/accounts-pane-claude-section.test.tsx index a3c8f97bf45a..f6b8b07ab93e 100644 --- a/src/renderer/src/components/settings/accounts-pane-claude-section.test.tsx +++ b/src/renderer/src/components/settings/accounts-pane-claude-section.test.tsx @@ -154,4 +154,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-pane-claude-section.tsx b/src/renderer/src/components/settings/accounts-pane-claude-section.tsx index 69ae293d79d2..b058a9745b26 100644 --- a/src/renderer/src/components/settings/accounts-pane-claude-section.tsx +++ b/src/renderer/src/components/settings/accounts-pane-claude-section.tsx @@ -5,6 +5,7 @@ import { Badge } from '../ui/badge' import { Button } from '../ui/button' import { ButtonGroup } from '../ui/button-group' import { Label } from '../ui/label' +import { Switch } from '../ui/switch' import { ClaudeIcon } from '../status-bar/icons' import { SearchableSetting } from './SearchableSetting' import { @@ -364,6 +365,38 @@ export function renderClaudeAccountsSection(model: AccountsPaneSectionModel): Re )} + + {!isRemoteAccountScope && visibleClaudeAccounts.length > 1 ? ( + +
+ +

+ {translate( + 'accounts.claude.autoSwitchDescription', + 'When the selected account passes 90% of its 5-hour or weekly limit, new Claude sessions start on the account with the most room left. Running sessions keep their account.' + )} +

+
+ updateSettings({ claudeAutoSwitchEnabled: checked })} + /> +
+ ) : 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/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index 948f258323f3..c31998ead112 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -2,6 +2,7 @@ import type { SettingsSearchEntry } from './settings-search' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { getAccountsClaudeSearchEntries } from './accounts-claude-search' export const getAccountsLocationSearchEntries = createLocalizedCatalog(() => [ { @@ -22,28 +23,6 @@ export const getAccountsLocationSearchEntries = createLocalizedCatalog(() => [ } ]) -export const getAccountsClaudeSearchEntries = createLocalizedCatalog(() => [ - { - title: translate('auto.components.settings.accounts.search.75682e1b62', 'Claude Accounts'), - description: translate( - 'auto.components.settings.accounts.search.dd75a73991', - 'Optional account switching for Claude while preserving shared chat context.' - ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.accounts.search.e14049e1a8', 'claude'), - ...translateSearchKeyword('auto.components.settings.accounts.search.06662af91e', 'account'), - ...translateSearchKeyword('auto.components.settings.accounts.search.5b3f18ef4a', 'switch'), - ...translateSearchKeyword('auto.components.settings.accounts.search.8b06729e0f', 'active'), - ...translateSearchKeyword( - 'auto.components.settings.accounts.search.86edc96bc9', - 'status bar' - ), - ...translateSearchKeyword('auto.components.settings.accounts.search.c759741d77', 'quota'), - ...translateSearchKeyword('auto.components.settings.accounts.search.f2d666a886', 'optional') - ] - } -]) - export const getAccountsCodexSearchEntries = createLocalizedCatalog(() => [ { title: translate('auto.components.settings.accounts.search.17c5d244eb', 'Codex Accounts'), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e545c6927855..b4e953ab44b6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19613,6 +19613,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. Running sessions keep their account.", + "autoSwitch": "Switch accounts before a usage limit", + "autoSwitchDescription": "When the selected account passes 90% of its 5-hour or weekly limit, new Claude sessions start on the account with the most room left. Running sessions keep their account.", + "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/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index 39e42cfdc763..e7e76f19e41f 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -181,6 +181,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/global-settings-types.ts b/src/shared/global-settings-types.ts index 97ab0925e9f1..57109ea50742 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -318,6 +318,8 @@ export type GlobalSettings = NativeChatGlobalSettings & { activeClaudeManagedAccountIdsByRuntime?: ClaudeManagedAccountRuntimeSelection /** Dismissed the "System default may hold a copied login" notice in Settings > Accounts. */ claudeCopiedSystemDefaultNoticeDismissed?: boolean + /** Opt-in: new Claude sessions move to the managed account with the most room before a usage limit; optional for legacy settings. */ + claudeAutoSwitchEnabled?: boolean /** Per-worktree shell history so ArrowUp doesn't surface other worktrees' commands (a HISTFILE for * bash/zsh, a `fish_history` session name for fish). Defaults to true. */ terminalScopeHistoryByWorktree: boolean diff --git a/src/shared/managed-account-types.ts b/src/shared/managed-account-types.ts index 6f9cc642ce7e..156ed729ae18 100644 --- a/src/shared/managed-account-types.ts +++ b/src/shared/managed-account-types.ts @@ -97,6 +97,8 @@ export type ClaudeManagedAccountSummary = { lastAuthenticatedAt: number /** Its folder holds no login yet, e.g. an account saved by an older Orca; it needs a sign-in. */ needsSignIn?: true + /** Its folder holds no login, but System default is signed in to it, so it runs there. */ + coveredBySystemDefault?: true } /** A sign-in to a new account folder, or to a saved account's own when `accountId` is given. */ From dd626ea589b0cc476dc5bc1d3cc4eb233c2118c1 Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:19:33 +0200 Subject: [PATCH 02/11] fix(claude-accounts): count an inactive usage read only once it is under way The usage service skips a read it debounces, one already running, or one where every account is inside Retry-After. The auto-switch listener recorded the ask as a read anyway and would not ask again for 15 minutes. It now counts a read only when the published state shows one in flight. --- .../claude-account-auto-switch.test.ts | 37 ++++++++++++++----- .../claude-account-auto-switch.ts | 5 ++- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/main/claude-accounts/claude-account-auto-switch.test.ts b/src/main/claude-accounts/claude-account-auto-switch.test.ts index a127463e8892..4634824e9290 100644 --- a/src/main/claude-accounts/claude-account-auto-switch.test.ts +++ b/src/main/claude-accounts/claude-account-auto-switch.test.ts @@ -55,6 +55,17 @@ function usageState( }) } +// 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'), @@ -127,14 +138,19 @@ describe('createClaudeAccountAutoSwitch', () => { expect(fetchInactiveUsage).not.toHaveBeenCalled() }) - it('reads the other accounts again before deciding on old usage', () => { - const { autoSwitch, selectAccount, fetchInactiveUsage } = setup() + it('asks for a new reading of the other accounts until one is under way', () => { + 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) }) - autoSwitch(usageState(limits(95), { b: limits(10, hourAgo) })) - autoSwitch(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) + expect(fetchInactiveUsage).toHaveBeenCalledTimes(2) - expect(fetchInactiveUsage).toHaveBeenCalledOnce() + autoSwitch(withReadUnderWay(stale)) + autoSwitch(stale) + expect(fetchInactiveUsage).toHaveBeenCalledTimes(2) expect(selectAccount).not.toHaveBeenCalled() }) @@ -239,7 +255,7 @@ describe('createClaudeAccountAutoSwitch', () => { }) it('switches once while a switch is still running', () => { - const { autoSwitch, selectAccount } = setup() + const { autoSwitch, selectAccount } = setup([account('a'), account('b')]) selectAccount.mockReturnValue(new Promise(() => {})) autoSwitch(usageState(limits(100), { b: limits(10) })) @@ -248,15 +264,18 @@ describe('createClaudeAccountAutoSwitch', () => { expect(selectAccount).toHaveBeenCalledOnce() }) - it('switches once when reading the other accounts pushes new usage straight away', () => { + it('picks from the accounts it could read once a read was tried', () => { 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(state)) + fetchInactiveUsage.mockImplementation(async () => autoSwitch(withReadUnderWay(state))) autoSwitch(state) + expect(selectAccount).not.toHaveBeenCalled() - expect(selectAccount).toHaveBeenCalledOnce() + 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 () => { diff --git a/src/main/claude-accounts/claude-account-auto-switch.ts b/src/main/claude-accounts/claude-account-auto-switch.ts index 557cc9e37d41..059951efd9ad 100644 --- a/src/main/claude-accounts/claude-account-auto-switch.ts +++ b/src/main/claude-accounts/claude-account-auto-switch.ts @@ -129,12 +129,15 @@ export function createClaudeAccountAutoSwitch( lastSwitchAt = now } selectedAccountId = selectedId + // Why: the usage service skips a read it debounces, so only one under way counts as tried. + if (state.inactiveClaudeAccounts.some((usage) => usage.isFetching)) { + lastUsageReadAt = now + } if (switching) { return } const decision = decide(state, now) if (decision.kind === 'read-usage') { - lastUsageReadAt = now deps.rateLimits .fetchInactiveClaudeAccountsOnOpen() .catch((error: unknown) => From 9d5d6cd165ec054660715d21674c21077b52edc6 Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:36:35 +0200 Subject: [PATCH 03/11] fix(claude-accounts): ignore usage from a window that has already reset An idle Claude session keeps reporting the usage of a window that has since rolled over (the stale statusline snapshots #23421 describes), and an inactive account's cached reading can outlive its window. The auto-switch policy counted both as current, so a stale 96% could trigger a switch and an account whose window had reset could be skipped as exhausted. A window past its reset time no longer counts. --- .../claude-account-auto-switch-policy.test.ts | 52 +++++++++++++------ .../claude-account-auto-switch-policy.ts | 24 ++++++--- .../claude-account-auto-switch.ts | 4 +- 3 files changed, 55 insertions(+), 25 deletions(-) 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 index 9794bc34b6ba..9b6f8567d093 100644 --- a/src/main/claude-accounts/claude-account-auto-switch-policy.test.ts +++ b/src/main/claude-accounts/claude-account-auto-switch-policy.test.ts @@ -26,66 +26,88 @@ function usage(accountId: string, rateLimits: ProviderRateLimits): InactiveAccou 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(pickClaudeAutoSwitchTarget(limits(89), [usage('b', limits(0))], COOLED_DOWN)).toBe(null) + 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(pickClaudeAutoSwitchTarget(limits(90), candidates, COOLED_DOWN)).toBe('c') + expect(pick(limits(90), candidates, COOLED_DOWN)).toBe('c') }) it('counts the weekly window as well as the 5-hour one', () => { - expect( - pickClaudeAutoSwitchTarget(limits(10, 95), [usage('b', limits(10, 10))], COOLED_DOWN) - ).toBe('b') + 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(pickClaudeAutoSwitchTarget(limits(92), candidates, COOLED_DOWN)).toBe('c') + expect(pick(limits(92), candidates, COOLED_DOWN)).toBe('c') }) it('stays when the best account is not clearly better', () => { - expect(pickClaudeAutoSwitchTarget(limits(91), [usage('b', limits(86))], COOLED_DOWN)).toBe(null) + 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(pickClaudeAutoSwitchTarget(limits(99), [usage('b', limits(90))], COOLED_DOWN)).toBe(null) + 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(pickClaudeAutoSwitchTarget(limits(95), candidates, COOLED_DOWN)).toBe('b') + expect(pick(limits(95), candidates, COOLED_DOWN)).toBe('b') }) it('stays while the active usage cannot be read', () => { - expect( - pickClaudeAutoSwitchTarget(limits(100, 0, 'error'), [usage('b', limits(0))], COOLED_DOWN) - ).toBe(null) + 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(pickClaudeAutoSwitchTarget(limits(95), candidates, COOLED_DOWN - 1)).toBe(null) + 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(pickClaudeAutoSwitchTarget(limits(100), candidates, 0)).toBe('b') + 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('stays when every account is out', () => { const candidates = [usage('b', limits(100)), usage('c', limits(20, 100))] - expect(pickClaudeAutoSwitchTarget(limits(100), candidates, COOLED_DOWN)).toBe(null) + 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 index 16376a148114..b2f9b23ffbc7 100644 --- a/src/main/claude-accounts/claude-account-auto-switch-policy.ts +++ b/src/main/claude-accounts/claude-account-auto-switch-policy.ts @@ -7,35 +7,43 @@ export const CLAUDE_AUTO_SWITCH_COOLDOWN_MS = 5 * 60_000 const MIN_PERCENT_GAINED = 10 /** The tightest of the 5-hour and weekly windows, or null when usage could not be read. */ -function getClaudeUsedPercent(limits: ProviderRateLimits | null): number | null { +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) - return windows.length > 0 ? Math.max(...windows.map((window) => window.usedPercent)) : 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): boolean { - return (getClaudeUsedPercent(limits) ?? 0) >= CLAUDE_AUTO_SWITCH_THRESHOLD_PERCENT +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[], - msSinceLastSwitch: number + { now, lastSwitchAt }: { now: number; lastSwitchAt: number } ): string | null { - const used = getClaudeUsedPercent(active) + const used = getClaudeUsedPercent(active, now) if (used === null || used < CLAUDE_AUTO_SWITCH_THRESHOLD_PERCENT) { return null } const atLimit = used >= 100 - if (!atLimit && msSinceLastSwitch < CLAUDE_AUTO_SWITCH_COOLDOWN_MS) { + if (!atLimit && now - lastSwitchAt < CLAUDE_AUTO_SWITCH_COOLDOWN_MS) { return null } const best = candidates .flatMap((candidate) => { - const candidateUsed = getClaudeUsedPercent(candidate.rateLimits) + const candidateUsed = getClaudeUsedPercent(candidate.rateLimits, now) return candidateUsed !== null && candidateUsed < 100 ? [{ accountId: candidate.accountId, used: candidateUsed }] : [] diff --git a/src/main/claude-accounts/claude-account-auto-switch.ts b/src/main/claude-accounts/claude-account-auto-switch.ts index 059951efd9ad..75d8f101eb9e 100644 --- a/src/main/claude-accounts/claude-account-auto-switch.ts +++ b/src/main/claude-accounts/claude-account-auto-switch.ts @@ -62,7 +62,7 @@ export function createClaudeAccountAutoSwitch( !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) + !isClaudeOverAutoSwitchLine(state.claude, now) ) { return STAY } @@ -88,7 +88,7 @@ export function createClaudeAccountAutoSwitch( return { kind: 'read-usage' } } } - const targetId = pickClaudeAutoSwitchTarget(state.claude, candidates, now - lastSwitchAt) + const targetId = pickClaudeAutoSwitchTarget(state.claude, candidates, { now, lastSwitchAt }) const to = accounts.find((account) => account.id === targetId) return to ? { kind: 'switch', from, to } : STAY } From 377b023fd8c2813e5762e07499c1669b29fab008 Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:36:37 +0200 Subject: [PATCH 04/11] docs(claude-code): describe switching accounts before a usage limit --- docs/site/content/docs/agents/claude-code.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/site/content/docs/agents/claude-code.mdx b/docs/site/content/docs/agents/claude-code.mdx index a0d733abd4a6..a34f9981ef79 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, the next Claude you start uses the account with the most room left. 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. From dd2aa0952ec19b9e4f1be40ef3c24a6549315e8d Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:51:41 +0200 Subject: [PATCH 05/11] fix(rate-limits): tag a successful Claude usage reading with its account A failed read and a live session's statusline post both carry the account they belong to (usageMetadata.authProvenance), but a successful OAuth read carried no metadata at all. The auto-switch only trusts a reading tagged with the selected account, so it never acted on a polled reading: with no session posting, as when the account has already hit its limit, it stayed put. A successful read now carries the same credential source and account tag. --- src/main/rate-limits/claude-active-usage-fetch.ts | 4 +++- src/main/rate-limits/claude-profile-usage.test.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) 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 }) From 55ceeaecedba6a22aae20c88199da1406aad8b07 Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:51:43 +0200 Subject: [PATCH 06/11] fix(claude-accounts): rank the accounts that can be read, on the selected account's runtime Two cases left the auto-switch with nothing to pick. With the default WSL distro the usage target names no distro while accounts do, so every other account was filtered out; candidates are now the accounts on the selected account's own runtime. And an account inside its Retry-After cannot be read, so waiting for its reading stalled ranking while a usable account was right there; it no longer waits for those. The docs paragraph now says the switch needs another account with clearly more room. --- docs/site/content/docs/agents/claude-code.mdx | 2 +- .../claude-account-auto-switch.test.ts | 35 +++++++++++++++++++ .../claude-account-auto-switch.ts | 25 ++++++++----- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/docs/site/content/docs/agents/claude-code.mdx b/docs/site/content/docs/agents/claude-code.mdx index a34f9981ef79..426709fe2354 100644 --- a/docs/site/content/docs/agents/claude-code.mdx +++ b/docs/site/content/docs/agents/claude-code.mdx @@ -22,7 +22,7 @@ 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, the next Claude you start uses the account with the most room left. Running sessions keep their account. It is off by default. +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 diff --git a/src/main/claude-accounts/claude-account-auto-switch.test.ts b/src/main/claude-accounts/claude-account-auto-switch.test.ts index 4634824e9290..8e71d02e791e 100644 --- a/src/main/claude-accounts/claude-account-auto-switch.test.ts +++ b/src/main/claude-accounts/claude-account-auto-switch.test.ts @@ -215,6 +215,41 @@ describe('createClaudeAccountAutoSwitch', () => { 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('does not wait for an account the usage service cannot read yet', () => { + 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 })) + + 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() diff --git a/src/main/claude-accounts/claude-account-auto-switch.ts b/src/main/claude-accounts/claude-account-auto-switch.ts index 75d8f101eb9e..bdabbb0af7f3 100644 --- a/src/main/claude-accounts/claude-account-auto-switch.ts +++ b/src/main/claude-accounts/claude-account-auto-switch.ts @@ -71,16 +71,19 @@ export function createClaudeAccountAutoSwitch( accounts .filter( (account) => - account.id !== selectedId && - !account.needsSignIn && - isOnTarget(account, state.claudeTarget) + 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: rank only once every candidate has a recent reading, or a read was just tried. - if (candidates.length < candidateIds.size) { + // 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) { if (usages.some((usage) => usage.isFetching)) { return STAY } @@ -164,10 +167,16 @@ function isReadingOf( ) } -function isOnTarget(account: ClaudeManagedAccountSummary, target: RateLimitRuntimeTarget): boolean { - const accountTarget = normalizeClaudeAccountSelectionTarget({ +/** 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 }) - return accountTarget.runtime === target.runtime && accountTarget.wslDistro === target.wslDistro } From bdbdda43e6c371e9c6b57ed60bdad6eb46c9d309 Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:59:25 +0200 Subject: [PATCH 07/11] fix(claude-accounts): ask for inactive usage outside the rate-limit publish The auto-switch listener asked the usage service for a read from inside the service's own publish loop. The service publishes again from within that call, so the window and paired clients were handed the newer state first and the older one last. The ask now runs after the publish it reacted to. A read also counts as tried only when one of the accounts being ranked is fetching, not an account on another runtime. --- .../claude-account-auto-switch-policy.test.ts | 15 +++++++ .../claude-account-auto-switch-policy.ts | 2 +- .../claude-account-auto-switch.test.ts | 45 +++++++++++++++++-- .../claude-account-auto-switch.ts | 19 ++++---- 4 files changed, 67 insertions(+), 14 deletions(-) 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 index 9b6f8567d093..7812bd4082db 100644 --- a/src/main/claude-accounts/claude-account-auto-switch-policy.test.ts +++ b/src/main/claude-accounts/claude-account-auto-switch-policy.test.ts @@ -105,6 +105,21 @@ describe('pickClaudeAutoSwitchTarget', () => { 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))] diff --git a/src/main/claude-accounts/claude-account-auto-switch-policy.ts b/src/main/claude-accounts/claude-account-auto-switch-policy.ts index b2f9b23ffbc7..46669dfcc7b9 100644 --- a/src/main/claude-accounts/claude-account-auto-switch-policy.ts +++ b/src/main/claude-accounts/claude-account-auto-switch-policy.ts @@ -6,7 +6,7 @@ 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 of the 5-hour and weekly windows, or null when usage could not be read. */ +/** 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 diff --git a/src/main/claude-accounts/claude-account-auto-switch.test.ts b/src/main/claude-accounts/claude-account-auto-switch.test.ts index 8e71d02e791e..9596d4fe2b3c 100644 --- a/src/main/claude-accounts/claude-account-auto-switch.test.ts +++ b/src/main/claude-accounts/claude-account-auto-switch.test.ts @@ -138,7 +138,7 @@ describe('createClaudeAccountAutoSwitch', () => { expect(fetchInactiveUsage).not.toHaveBeenCalled() }) - it('asks for a new reading of the other accounts until one is under way', () => { + 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) }) @@ -146,14 +146,48 @@ describe('createClaudeAccountAutoSwitch', () => { // 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'), @@ -236,7 +270,7 @@ describe('createClaudeAccountAutoSwitch', () => { expect(selectAccount).toHaveBeenCalledWith('b', defaultDistro, expect.any(Function)) }) - it('does not wait for an account the usage service cannot read yet', () => { + 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 = { @@ -245,6 +279,7 @@ describe('createClaudeAccountAutoSwitch', () => { } autoSwitch(usageState(limits(95), { b: limits(20), c: insideRetryAfter })) + await Promise.resolve() expect(fetchInactiveUsage).not.toHaveBeenCalled() expect(selectAccount).toHaveBeenCalledWith('b', HOST, expect.any(Function)) @@ -277,11 +312,12 @@ describe('createClaudeAccountAutoSwitch', () => { expect(onSwitched).not.toHaveBeenCalled() }) - it("waits for every account's reading before picking one", () => { + 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() @@ -299,13 +335,14 @@ describe('createClaudeAccountAutoSwitch', () => { expect(selectAccount).toHaveBeenCalledOnce() }) - it('picks from the accounts it could read once a read was tried', () => { + 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) diff --git a/src/main/claude-accounts/claude-account-auto-switch.ts b/src/main/claude-accounts/claude-account-auto-switch.ts index bdabbb0af7f3..0c49db9268f7 100644 --- a/src/main/claude-accounts/claude-account-auto-switch.ts +++ b/src/main/claude-accounts/claude-account-auto-switch.ts @@ -84,7 +84,9 @@ export function createClaudeAccountAutoSwitch( ) // 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) { @@ -132,20 +134,19 @@ export function createClaudeAccountAutoSwitch( lastSwitchAt = now } selectedAccountId = selectedId - // Why: the usage service skips a read it debounces, so only one under way counts as tried. - if (state.inactiveClaudeAccounts.some((usage) => usage.isFetching)) { - lastUsageReadAt = now - } if (switching) { return } const decision = decide(state, now) if (decision.kind === 'read-usage') { - deps.rateLimits - .fetchInactiveClaudeAccountsOnOpen() - .catch((error: unknown) => - console.warn('[claude-accounts] Failed to read inactive Claude usage:', error) - ) + // 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) } From 0860942f98bf441018d241f152c696196ebd1bb5 Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:54:21 +0200 Subject: [PATCH 08/11] feat(claude-accounts): treat running sessions after an automatic switch as after one by hand The automatic switch runs in main, so the running-session question from #29898 never came up for it. Main now tells the renderer which accounts it switched between, and the renderer hands that to the same step a switch from the menu runs: ask, always restart, or never restart. --- docs/site/content/docs/agents/claude-code.mdx | 2 +- .../claude-account-auto-switch.test.ts | 6 +- .../claude-account-auto-switch.ts | 10 ++- src/main/provider-account-services.ts | 9 ++- .../startup/main-process-account-services.ts | 12 ++- src/preload/api/agent-account-api.ts | 2 + src/preload/api/claude-accounts-bridge.ts | 9 ++- .../ClaudeAccountSwitchDialog.test.tsx | 27 +++++++ .../components/ClaudeAccountSwitchDialog.tsx | 9 ++- .../src/lib/claude-auto-switch-follow.test.ts | 75 +++++++++++++++++++ .../src/lib/claude-auto-switch-follow.ts | 13 ++++ .../web/preload-api/web-agent-accounts-api.ts | 4 +- src/shared/managed-account-types.ts | 7 ++ 13 files changed, 173 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/lib/claude-auto-switch-follow.test.ts create mode 100644 src/renderer/src/lib/claude-auto-switch-follow.ts diff --git a/docs/site/content/docs/agents/claude-code.mdx b/docs/site/content/docs/agents/claude-code.mdx index 426709fe2354..9cc8257efa4b 100644 --- a/docs/site/content/docs/agents/claude-code.mdx +++ b/docs/site/content/docs/agents/claude-code.mdx @@ -22,7 +22,7 @@ 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. +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 diff --git a/src/main/claude-accounts/claude-account-auto-switch.test.ts b/src/main/claude-accounts/claude-account-auto-switch.test.ts index f371b1afa87c..6829f377b060 100644 --- a/src/main/claude-accounts/claude-account-auto-switch.test.ts +++ b/src/main/claude-accounts/claude-account-auto-switch.test.ts @@ -125,7 +125,11 @@ describe('createClaudeAccountAutoSwitch', () => { await vi.runAllTimersAsync() expect(selectAccount).toHaveBeenCalledWith('c', HOST, expect.any(Function)) - expect(onSwitched).toHaveBeenCalledWith({ from: 'a@example.com', to: 'c@example.com' }) + expect(onSwitched).toHaveBeenCalledWith({ + 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', () => { diff --git a/src/main/claude-accounts/claude-account-auto-switch.ts b/src/main/claude-accounts/claude-account-auto-switch.ts index bc44bd13fdb0..96eb55f0b2b7 100644 --- a/src/main/claude-accounts/claude-account-auto-switch.ts +++ b/src/main/claude-accounts/claude-account-auto-switch.ts @@ -28,7 +28,13 @@ type ClaudeAutoSwitchDeps = { getSettings: () => ClaudeAutoSwitchSettings rateLimits: Pick accounts: Pick - onSwitched: (emails: { from: string; to: string }) => void + onSwitched: (switched: ClaudeAutoSwitched) => void +} + +export type ClaudeAutoSwitched = { + from: ClaudeManagedAccountSummary + to: ClaudeManagedAccountSummary + target: RateLimitRuntimeTarget } type ClaudeAutoSwitchDecision = @@ -113,7 +119,7 @@ export function createClaudeAccountAutoSwitch( .autoSelectAccountForTarget(to.id, target, stillWanted) .then((switched) => { if (switched) { - deps.onSwitched({ from: from.email, to: to.email }) + deps.onSwitched({ from, to, target }) } }) .catch((error: unknown) => { diff --git a/src/main/provider-account-services.ts b/src/main/provider-account-services.ts index e62b5b1f0c12..9573f85ed8a2 100644 --- a/src/main/provider-account-services.ts +++ b/src/main/provider-account-services.ts @@ -8,7 +8,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 } from './claude-accounts/claude-account-auto-switch' +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' @@ -35,7 +38,7 @@ export function createProviderAccountServices( options: { codexAccountLifecycle?: ConstructorParameters[3] setClaudeStatusLineListener: (listener: (event: ClaudeStatusLineRateLimits) => void) => void - onClaudeAutoSwitched?: (emails: { from: string; to: string }) => void + onClaudeAutoSwitched?: (switched: ClaudeAutoSwitched) => void } ): ProviderAccountServices { const rateLimits = new RateLimitService() @@ -53,7 +56,7 @@ export function createProviderAccountServices( getSettings: () => store.getSettings(), rateLimits, accounts: claudeAccounts, - onSwitched: (emails) => options.onClaudeAutoSwitched?.(emails) + onSwitched: (switched) => options.onClaudeAutoSwitched?.(switched) }) ) // Why: quota polls are background work and must not boot a stopped distro; user-driven diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index cb25e5483030..d380a74d652a 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -19,6 +19,7 @@ import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent 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. @@ -60,7 +61,14 @@ export function initializeMainProcessAccountServices(): void { codexAccountLifecycle: { onHostSystemDefaultSelected: state.codexSessionMigration.requestRun }, setClaudeStatusLineListener: (listener) => agentHookServer.setClaudeStatusLineListener(listener), - onClaudeAutoSwitched: ({ from, to }) => { + onClaudeAutoSwitched: ({ from, to, target }) => { + // Why: running sessions follow the running-session setting, as after a switch by hand. + const switched: ClaudeAutoSwitchedEvent = { + fromAccountId: from.id, + toAccountId: to.id, + target + } + state.mainWindow?.webContents.send('claudeAccounts:autoSwitched', switched) if (!store.getSettings().notifications.enabled) { return } @@ -69,7 +77,7 @@ export function initializeMainProcessAccountServices(): void { body: translateMain( 'accounts.claude.autoSwitchedBody', '{{from}} is near its usage limit. New Claude sessions start on {{to}}.', - { from, to } + { from: from.email, to: to.email } ) }) } diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts index 5c1379324f13..47e1128b133c 100644 --- a/src/preload/api/agent-account-api.ts +++ b/src/preload/api/agent-account-api.ts @@ -1,5 +1,6 @@ import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' import type { + ClaudeAutoSwitchedEvent, ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/managed-account-types' @@ -63,6 +64,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..24d1153905db 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,15 @@ describe('ClaudeAccountSwitchDialog', () => { beforeEach(() => { vi.clearAllMocks() resetClaudeAccountSwitchSessionsForTests() + vi.stubGlobal('api', { + claudeAccounts: { + list: async () => ROSTER, + onAutoSwitched: (callback: typeof autoSwitched) => { + autoSwitched = callback + return () => {} + } + } + }) useAppStore.setState({ updateSettings, pendingPaneAccountRestarts: {}, @@ -76,6 +87,22 @@ 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({ + 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/lib/claude-auto-switch-follow.test.ts b/src/renderer/src/lib/claude-auto-switch-follow.test.ts new file mode 100644 index 000000000000..ccb01f768f8a --- /dev/null +++ b/src/renderer/src/lib/claude-auto-switch-follow.test.ts @@ -0,0 +1,75 @@ +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')], activeAccountId: 'account-b' } +const SWITCHED = { + fromAccountId: 'account-a', + toAccountId: 'account-b', + target: { runtime: 'host' as const, wslDistro: null } +} + +describe('followClaudeAutoSwitch', () => { + beforeEach(() => { + vi.clearAllMocks() + resetClaudeAccountSwitchSessionsForTests() + store.settings = {} + vi.stubGlobal('window', { api: { claudeAccounts: { list: vi.fn(async () => ROSTER) } } }) + }) + + 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..ccd0e3b03b75 --- /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 = await window.api.claudeAccounts.list() + 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/managed-account-types.ts b/src/shared/managed-account-types.ts index a83bb7615610..24db6487e1c7 100644 --- a/src/shared/managed-account-types.ts +++ b/src/shared/managed-account-types.ts @@ -126,6 +126,13 @@ export type ClaudeRateLimitAccountsState = { systemDefaultMayBeCopied?: boolean } +/** Orca moved Claude to another account before a usage limit, so running sessions can follow. */ +export type ClaudeAutoSwitchedEvent = { + fromAccountId: string + toAccountId: string + target: { runtime?: 'host' | 'wsl'; wslDistro?: string | null } +} + export type ClaudeManagedAccountRuntimeSelection = { host: string | null wsl: Record From 893323cab592b572be0adabf00fc03a8dcb82055 Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:00:00 +0200 Subject: [PATCH 09/11] fix(claude-accounts): report an automatic switch on the switched account's own distro With the default WSL distro the usage target names no distro, so the running-session step looked in the wrong lane and missed every session in the account's distro. A switch from the menu passes the concrete distro; the automatic one now does too. --- .../claude-account-auto-switch.test.ts | 20 +++++++++++++++++++ .../claude-account-auto-switch.ts | 4 +++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/main/claude-accounts/claude-account-auto-switch.test.ts b/src/main/claude-accounts/claude-account-auto-switch.test.ts index 6829f377b060..500799e835b0 100644 --- a/src/main/claude-accounts/claude-account-auto-switch.test.ts +++ b/src/main/claude-accounts/claude-account-auto-switch.test.ts @@ -274,6 +274,26 @@ describe('createClaudeAccountAutoSwitch', () => { 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 diff --git a/src/main/claude-accounts/claude-account-auto-switch.ts b/src/main/claude-accounts/claude-account-auto-switch.ts index 96eb55f0b2b7..ccb832c99a17 100644 --- a/src/main/claude-accounts/claude-account-auto-switch.ts +++ b/src/main/claude-accounts/claude-account-auto-switch.ts @@ -119,7 +119,9 @@ export function createClaudeAccountAutoSwitch( .autoSelectAccountForTarget(to.id, target, stillWanted) .then((switched) => { if (switched) { - deps.onSwitched({ from, to, target }) + // Why the account's own runtime: with the default WSL distro the usage target names no + // distro, but the panes on those accounts sit in the account's distro's lane. + deps.onSwitched({ from, to, target: runtimeOf(to) }) } }) .catch((error: unknown) => { From f1d676bed43b0e15489bc52631af26b3d1ed8448 Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:12:45 +0200 Subject: [PATCH 10/11] fix(claude-accounts): send the accounts with an automatic switch, so following it waits on nothing Following the switch read the account list first, so a switch by hand during that read was overtaken: the follow then asked about the automatic one, and a failed read was left unhandled. Main has the list at the switch and now sends it along. --- .../claude-accounts/claude-account-auto-switch.test.ts | 5 +++++ src/main/claude-accounts/claude-account-auto-switch.ts | 8 +++++++- src/main/startup/main-process-account-services.ts | 3 ++- .../src/components/ClaudeAccountSwitchDialog.test.tsx | 2 +- src/renderer/src/lib/claude-auto-switch-follow.test.ts | 8 ++++++-- src/renderer/src/lib/claude-auto-switch-follow.ts | 2 +- src/shared/managed-account-types.ts | 2 ++ 7 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/main/claude-accounts/claude-account-auto-switch.test.ts b/src/main/claude-accounts/claude-account-auto-switch.test.ts index 500799e835b0..e9a627689b7f 100644 --- a/src/main/claude-accounts/claude-account-auto-switch.test.ts +++ b/src/main/claude-accounts/claude-account-auto-switch.test.ts @@ -126,6 +126,11 @@ describe('createClaudeAccountAutoSwitch', () => { 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 diff --git a/src/main/claude-accounts/claude-account-auto-switch.ts b/src/main/claude-accounts/claude-account-auto-switch.ts index ccb832c99a17..fe5977628d65 100644 --- a/src/main/claude-accounts/claude-account-auto-switch.ts +++ b/src/main/claude-accounts/claude-account-auto-switch.ts @@ -32,6 +32,7 @@ type ClaudeAutoSwitchDeps = { } export type ClaudeAutoSwitched = { + accounts: ClaudeManagedAccountSummary[] from: ClaudeManagedAccountSummary to: ClaudeManagedAccountSummary target: RateLimitRuntimeTarget @@ -121,7 +122,12 @@ export function createClaudeAccountAutoSwitch( if (switched) { // Why the account's own runtime: with the default WSL distro the usage target names no // distro, but the panes on those accounts sit in the account's distro's lane. - deps.onSwitched({ from, to, target: runtimeOf(to) }) + deps.onSwitched({ + accounts: deps.accounts.listAccounts().accounts, + from, + to, + target: runtimeOf(to) + }) } }) .catch((error: unknown) => { diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index d380a74d652a..864453479b18 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -61,9 +61,10 @@ export function initializeMainProcessAccountServices(): void { codexAccountLifecycle: { onHostSystemDefaultSelected: state.codexSessionMigration.requestRun }, setClaudeStatusLineListener: (listener) => agentHookServer.setClaudeStatusLineListener(listener), - onClaudeAutoSwitched: ({ from, to, target }) => { + 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 diff --git a/src/renderer/src/components/ClaudeAccountSwitchDialog.test.tsx b/src/renderer/src/components/ClaudeAccountSwitchDialog.test.tsx index 24d1153905db..3e4fc72c79fd 100644 --- a/src/renderer/src/components/ClaudeAccountSwitchDialog.test.tsx +++ b/src/renderer/src/components/ClaudeAccountSwitchDialog.test.tsx @@ -61,7 +61,6 @@ describe('ClaudeAccountSwitchDialog', () => { resetClaudeAccountSwitchSessionsForTests() vi.stubGlobal('api', { claudeAccounts: { - list: async () => ROSTER, onAutoSwitched: (callback: typeof autoSwitched) => { autoSwitched = callback return () => {} @@ -96,6 +95,7 @@ describe('ClaudeAccountSwitchDialog', () => { act(() => autoSwitched({ + accounts: ROSTER.accounts, fromAccountId: 'account-a', toAccountId: 'account-b', target: { runtime: 'host' } diff --git a/src/renderer/src/lib/claude-auto-switch-follow.test.ts b/src/renderer/src/lib/claude-auto-switch-follow.test.ts index ccb01f768f8a..099f3558ca61 100644 --- a/src/renderer/src/lib/claude-auto-switch-follow.test.ts +++ b/src/renderer/src/lib/claude-auto-switch-follow.test.ts @@ -43,8 +43,9 @@ const row = (id: string) => ({ updatedAt: 1, lastAuthenticatedAt: 1 }) -const ROSTER = { accounts: [row('account-a'), row('account-b')], activeAccountId: 'account-b' } +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 } @@ -55,7 +56,10 @@ describe('followClaudeAutoSwitch', () => { vi.clearAllMocks() resetClaudeAccountSwitchSessionsForTests() store.settings = {} - vi.stubGlobal('window', { api: { claudeAccounts: { list: vi.fn(async () => ROSTER) } } }) + // 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 () => { diff --git a/src/renderer/src/lib/claude-auto-switch-follow.ts b/src/renderer/src/lib/claude-auto-switch-follow.ts index ccd0e3b03b75..69870ec42e0c 100644 --- a/src/renderer/src/lib/claude-auto-switch-follow.ts +++ b/src/renderer/src/lib/claude-auto-switch-follow.ts @@ -3,7 +3,7 @@ import { handleRunningClaudeSessionsAfterSwitch } from './claude-account-switch- /** Treats running Claude sessions after an automatic switch the way a switch by hand does. */ export async function followClaudeAutoSwitch(event: ClaudeAutoSwitchedEvent): Promise { - const state = await window.api.claudeAccounts.list() + const state = { accounts: event.accounts } await handleRunningClaudeSessionsAfterSwitch({ owner: { kind: 'local' }, target: event.target, diff --git a/src/shared/managed-account-types.ts b/src/shared/managed-account-types.ts index 24db6487e1c7..e9396b3a3d3a 100644 --- a/src/shared/managed-account-types.ts +++ b/src/shared/managed-account-types.ts @@ -128,6 +128,8 @@ export type ClaudeRateLimitAccountsState = { /** 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 } From 309dbf11c1146960efbc3fa8401a76b33f0075d7 Mon Sep 17 00:00:00 2001 From: Adif Sgaid <69080239+adifsgaid@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:01:21 +0200 Subject: [PATCH 11/11] refactor(accounts): give the auto-switch toggle its own setting file After main moved on, the Claude accounts section and the settings type passed their max-lines caps. The toggle now sits in its own component and its setting in ClaudeAccountSwitchSettings, the same way the running-session setting does. --- .../claude-account-auto-switch.ts | 3 +- .../settings/accounts-pane-claude-section.tsx | 37 ++----------------- .../settings/claude-auto-switch-setting.tsx | 37 +++++++++++++++++++ src/shared/claude-account-switch-settings.ts | 2 + src/shared/global-settings-types.ts | 2 - 5 files changed, 44 insertions(+), 37 deletions(-) create mode 100644 src/renderer/src/components/settings/claude-auto-switch-setting.tsx diff --git a/src/main/claude-accounts/claude-account-auto-switch.ts b/src/main/claude-accounts/claude-account-auto-switch.ts index fe5977628d65..37738d2e3437 100644 --- a/src/main/claude-accounts/claude-account-auto-switch.ts +++ b/src/main/claude-accounts/claude-account-auto-switch.ts @@ -120,8 +120,7 @@ export function createClaudeAccountAutoSwitch( .autoSelectAccountForTarget(to.id, target, stillWanted) .then((switched) => { if (switched) { - // Why the account's own runtime: with the default WSL distro the usage target names no - // distro, but the panes on those accounts sit in the account's distro's lane. + // Why not the usage target: a default-distro target names no distro; panes do. deps.onSwitched({ accounts: deps.accounts.listAccounts().accounts, from, 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 53988b4ab167..819618b8b749 100644 --- a/src/renderer/src/components/settings/accounts-pane-claude-section.tsx +++ b/src/renderer/src/components/settings/accounts-pane-claude-section.tsx @@ -5,9 +5,9 @@ import { Badge } from '../ui/badge' import { Button } from '../ui/button' import { ButtonGroup } from '../ui/button-group' import { Label } from '../ui/label' -import { Switch } from '../ui/switch' 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, @@ -384,38 +384,9 @@ export function renderClaudeAccountsSection(model: AccountsPaneSectionModel): Re )} - - {!isRemoteAccountScope && visibleClaudeAccounts.length > 1 ? ( - -
- -

- {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.' - )} -

-
- updateSettings({ claudeAutoSwitchEnabled: checked })} - /> -
- ) : null} + {isRemoteAccountScope || visibleClaudeAccounts.length < 2 ? null : ( + + )} {isRemoteAccountScope ? null : ( )} 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/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/global-settings-types.ts b/src/shared/global-settings-types.ts index d8fd8217092e..a0ced277a2ab 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -335,8 +335,6 @@ export type GlobalSettings = GlobalSettingsBase & { activeClaudeManagedAccountIdsByRuntime?: ClaudeManagedAccountRuntimeSelection /** Dismissed the "System default may hold a copied login" notice in Settings > Accounts. */ claudeCopiedSystemDefaultNoticeDismissed?: boolean - /** Opt-in: new Claude sessions move to the managed account with the most room before a usage limit; optional for legacy settings. */ - claudeAutoSwitchEnabled?: boolean /** Per-worktree shell history so ArrowUp doesn't surface other worktrees' commands (a HISTFILE for * bash/zsh, a `fish_history` session name for fish). Defaults to true. */ terminalScopeHistoryByWorktree: boolean