feat: add Claude account switcher (#1050)

This commit is contained in:
Jinwoo Hong 2026-04-24 17:05:26 -04:00 committed by GitHub
parent 861f21fc81
commit 5315e87a81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1844 additions and 42 deletions

View File

@ -0,0 +1,52 @@
export const CLAUDE_AUTH_ENV_VARS = [
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'CLAUDE_CODE_OAUTH_TOKEN',
'AWS_BEARER_TOKEN_BEDROCK'
] as const
export type ClaudeEnvPatch = {
CLAUDE_CONFIG_DIR?: string
ANTHROPIC_CUSTOM_HEADERS?: string
}
export function applyClaudeEnvPatch(
baseEnv: Record<string, string>,
patch: ClaudeEnvPatch,
options?: { stripAuthEnv?: boolean }
): Record<string, string> {
if (options?.stripAuthEnv) {
for (const key of CLAUDE_AUTH_ENV_VARS) {
delete baseEnv[key]
}
if (isAuthLikeCustomHeaders(baseEnv.ANTHROPIC_CUSTOM_HEADERS)) {
delete baseEnv.ANTHROPIC_CUSTOM_HEADERS
}
}
if (patch.CLAUDE_CONFIG_DIR) {
baseEnv.CLAUDE_CONFIG_DIR = patch.CLAUDE_CONFIG_DIR
}
if (patch.ANTHROPIC_CUSTOM_HEADERS !== undefined) {
baseEnv.ANTHROPIC_CUSTOM_HEADERS = patch.ANTHROPIC_CUSTOM_HEADERS
}
return baseEnv
}
export function hasClaudeAuthEnvConflict(env: Record<string, string> | undefined): boolean {
if (!env) {
return false
}
return (
CLAUDE_AUTH_ENV_VARS.some((key) => Boolean(env[key])) ||
isAuthLikeCustomHeaders(env.ANTHROPIC_CUSTOM_HEADERS)
)
}
function isAuthLikeCustomHeaders(value: string | undefined): boolean {
if (!value) {
return false
}
return /authorization|x-api-key|api-key|bearer/i.test(value)
}

View File

@ -0,0 +1,126 @@
import { execFile } from 'node:child_process'
const ACTIVE_CLAUDE_SERVICE = 'Claude Code-credentials'
const ORCA_CLAUDE_SERVICE = 'Orca Claude Code Managed Credentials'
export async function readActiveClaudeKeychainCredentials(): Promise<string | null> {
return readKeychainPassword(ACTIVE_CLAUDE_SERVICE, getKeychainUser())
}
export async function writeActiveClaudeKeychainCredentials(contents: string): Promise<void> {
await writeKeychainPassword(ACTIVE_CLAUDE_SERVICE, getKeychainUser(), contents)
}
export async function deleteActiveClaudeKeychainCredentials(): Promise<void> {
await deleteKeychainPassword(ACTIVE_CLAUDE_SERVICE, getKeychainUser())
}
export async function deleteActiveClaudeKeychainCredentialsStrict(): Promise<void> {
await deleteKeychainPassword(ACTIVE_CLAUDE_SERVICE, getKeychainUser(), {
failOnAccessError: true
})
}
export async function readManagedClaudeKeychainCredentials(
accountId: string
): Promise<string | null> {
return readKeychainPassword(ORCA_CLAUDE_SERVICE, accountId)
}
export async function writeManagedClaudeKeychainCredentials(
accountId: string,
contents: string
): Promise<void> {
await writeKeychainPassword(ORCA_CLAUDE_SERVICE, accountId, contents)
}
export async function deleteManagedClaudeKeychainCredentials(accountId: string): Promise<void> {
await deleteKeychainPassword(ORCA_CLAUDE_SERVICE, accountId)
}
function getKeychainUser(): string {
return process.env.USER || process.env.USERNAME || 'user'
}
async function readKeychainPassword(service: string, account: string): Promise<string | null> {
if (process.platform !== 'darwin') {
return null
}
return new Promise((resolve, reject) => {
execFile(
'security',
['find-generic-password', '-s', service, '-a', account, '-w'],
{ timeout: 3_000 },
(error, stdout, stderr) => {
if (!error && stdout.trim()) {
resolve(stdout.trim())
return
}
const message = `${stderr} ${error?.message ?? ''}`.toLowerCase()
const code = (error as { code?: unknown } | null)?.code
if (
code === 44 ||
message.includes('could not be found') ||
message.includes('not be found')
) {
resolve(null)
return
}
reject(error ?? new Error(`Could not read macOS Keychain item ${service}/${account}.`))
}
)
})
}
async function writeKeychainPassword(
service: string,
account: string,
contents: string
): Promise<void> {
if (process.platform !== 'darwin') {
return
}
await execSecurity(['add-generic-password', '-U', '-s', service, '-a', account, '-w', contents])
}
async function deleteKeychainPassword(
service: string,
account: string,
options?: { failOnAccessError?: boolean }
): Promise<void> {
if (process.platform !== 'darwin') {
return
}
await execSecurity(['delete-generic-password', '-s', service, '-a', account], {
ignoreNotFound: true,
ignoreFailure: !options?.failOnAccessError
})
}
function execSecurity(
args: string[],
options?: { ignoreFailure?: boolean; ignoreNotFound?: boolean }
): Promise<void> {
return new Promise((resolve, reject) => {
execFile('security', args, { timeout: 3_000 }, (error, _stdout, stderr) => {
if (!error) {
resolve()
return
}
const code = (error as { code?: unknown }).code
const message = `${stderr} ${error.message}`.toLowerCase()
if (
options?.ignoreNotFound &&
(code === 44 || message.includes('could not be found') || message.includes('not be found'))
) {
resolve()
return
}
if (!options?.ignoreFailure) {
reject(error)
return
}
resolve()
})
})
}

View File

@ -0,0 +1,28 @@
const liveClaudePtyIds = new Set<string>()
let switchInProgress = false
export function markClaudePtySpawned(ptyId: string): void {
liveClaudePtyIds.add(ptyId)
}
export function markClaudePtyExited(ptyId: string): void {
liveClaudePtyIds.delete(ptyId)
}
export function beginClaudeAuthSwitch(): void {
if (switchInProgress) {
throw new Error('A Claude account switch is already in progress.')
}
if (liveClaudePtyIds.size > 0) {
throw new Error('Close or restart live Claude terminals before switching Claude accounts.')
}
switchInProgress = true
}
export function endClaudeAuthSwitch(): void {
switchInProgress = false
}
export function isClaudeAuthSwitchInProgress(): boolean {
return switchInProgress
}

View File

@ -0,0 +1,303 @@
/* eslint-disable max-lines -- Why: Claude account switching has one safety
boundary: runtime auth materialization. Keeping file, Keychain, snapshot, and
env-patch semantics together prevents PTY launch and quota fetch paths drifting. */
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { app } from 'electron'
import type { ClaudeManagedAccount } from '../../shared/types'
import type { Store } from '../persistence'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import type { ClaudeEnvPatch } from './environment'
import { ClaudeRuntimePathResolver } from './runtime-paths'
import {
deleteActiveClaudeKeychainCredentials,
readActiveClaudeKeychainCredentials,
readManagedClaudeKeychainCredentials,
writeActiveClaudeKeychainCredentials
} from './keychain'
export type ClaudeRuntimeAuthPreparation = {
envPatch: ClaudeEnvPatch
stripAuthEnv: boolean
provenance: string
}
type ClaudeSystemDefaultSnapshot = {
credentialsJson: string | null
configOauthAccount: unknown
keychainCredentialsJson: string | null
capturedAt: number
}
export class ClaudeRuntimeAuthService {
private readonly pathResolver = new ClaudeRuntimePathResolver()
private mutationQueue: Promise<unknown> = Promise.resolve()
private lastSyncedAccountId: string | null = null
// Why: tracks the credentials Orca last wrote to the shared credentials file.
// On managed→system-default transition, if the file differs from this value,
// an external login (e.g. `claude auth login`) overwrote it — so Orca adopts
// the file as the new system default instead of restoring a stale snapshot.
private lastWrittenCredentialsJson: string | null = null
constructor(private readonly store: Store) {
this.initializeLastSyncedState()
void this.safeSyncForCurrentSelection()
}
async prepareForClaudeLaunch(): Promise<ClaudeRuntimeAuthPreparation> {
await this.syncForCurrentSelection()
return this.getPreparation()
}
async prepareForRateLimitFetch(): Promise<ClaudeRuntimeAuthPreparation> {
await this.syncForCurrentSelection()
return this.getPreparation()
}
async syncForCurrentSelection(): Promise<void> {
await this.serializeMutation(() => this.doSyncForCurrentSelection())
}
async forceMaterializeCurrentSelectionForRollback(): Promise<void> {
await this.serializeMutation(async () => {
const settings = this.store.getSettings()
if (!settings.activeClaudeManagedAccountId) {
await this.restoreSystemDefaultSnapshot()
this.lastSyncedAccountId = null
return
}
await this.doSyncForCurrentSelection()
})
}
getRuntimeConfigDir(): string {
return this.pathResolver.getRuntimePaths().configDir
}
private initializeLastSyncedState(): void {
const settings = this.store.getSettings()
this.lastSyncedAccountId = settings.activeClaudeManagedAccountId
}
private async safeSyncForCurrentSelection(): Promise<void> {
try {
await this.syncForCurrentSelection()
} catch (error) {
console.warn('[claude-runtime-auth] Failed to sync runtime auth state:', error)
}
}
private serializeMutation<T>(fn: () => Promise<T>): Promise<T> {
const next = this.mutationQueue.then(fn, fn)
this.mutationQueue = next.catch(() => {})
return next
}
private async doSyncForCurrentSelection(): Promise<void> {
const settings = this.store.getSettings()
const activeAccount = this.getActiveAccount(
settings.claudeManagedAccounts,
settings.activeClaudeManagedAccountId
)
if (!activeAccount) {
if (this.lastSyncedAccountId !== null) {
await this.restoreSystemDefaultSnapshot()
this.lastSyncedAccountId = null
}
return
}
await this.captureSystemDefaultSnapshotIfNeeded()
const credentialsJson = await this.readManagedCredentials(activeAccount)
if (!credentialsJson) {
console.warn(
'[claude-runtime-auth] Active managed account is missing credentials, restoring system default'
)
this.store.updateSettings({ activeClaudeManagedAccountId: null })
if (this.lastSyncedAccountId !== null) {
await this.restoreSystemDefaultSnapshot()
this.lastSyncedAccountId = null
}
return
}
this.writeRuntimeCredentials(credentialsJson)
if (process.platform === 'darwin') {
await writeActiveClaudeKeychainCredentials(credentialsJson)
}
this.writeRuntimeOauthAccount(this.readManagedOauthAccount(activeAccount))
this.lastSyncedAccountId = activeAccount.id
}
private getPreparation(): ClaudeRuntimeAuthPreparation {
const settings = this.store.getSettings()
const paths = this.pathResolver.getRuntimePaths()
const activeAccountId = settings.activeClaudeManagedAccountId
return {
envPatch: paths.envPatch,
stripAuthEnv: Boolean(activeAccountId),
provenance: activeAccountId ? `managed:${activeAccountId}` : 'system'
}
}
private getActiveAccount(
accounts: ClaudeManagedAccount[],
activeAccountId: string | null
): ClaudeManagedAccount | null {
if (!activeAccountId) {
return null
}
return accounts.find((account) => account.id === activeAccountId) ?? null
}
private async readManagedCredentials(account: ClaudeManagedAccount): Promise<string | null> {
if (process.platform === 'darwin') {
return readManagedClaudeKeychainCredentials(account.id)
}
const credentialsPath = join(account.managedAuthPath, '.credentials.json')
if (!existsSync(credentialsPath)) {
return null
}
return readFileSync(credentialsPath, 'utf-8')
}
private readManagedOauthAccount(account: ClaudeManagedAccount): unknown {
const oauthPath = join(account.managedAuthPath, 'oauth-account.json')
if (!existsSync(oauthPath)) {
return null
}
try {
return JSON.parse(readFileSync(oauthPath, 'utf-8')) as unknown
} catch {
return null
}
}
private async captureSystemDefaultSnapshotIfNeeded(): Promise<void> {
const snapshotPath = this.getSystemDefaultSnapshotPath()
if (existsSync(snapshotPath)) {
return
}
const paths = this.pathResolver.getRuntimePaths()
const credentialsJson = existsSync(paths.credentialsPath)
? readFileSync(paths.credentialsPath, 'utf-8')
: null
const keychainCredentialsJson = await readActiveClaudeKeychainCredentials()
const snapshot: ClaudeSystemDefaultSnapshot = {
credentialsJson,
configOauthAccount: this.readRuntimeOauthAccount(),
keychainCredentialsJson,
capturedAt: Date.now()
}
this.writeJson(snapshotPath, snapshot)
}
private async restoreSystemDefaultSnapshot(): Promise<void> {
if (this.detectExternalLoginAndUpdateSnapshot()) {
return
}
const snapshotPath = this.getSystemDefaultSnapshotPath()
if (!existsSync(snapshotPath)) {
return
}
const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as ClaudeSystemDefaultSnapshot
if (snapshot.credentialsJson !== null) {
this.writeRuntimeCredentials(snapshot.credentialsJson)
} else {
rmSync(this.pathResolver.getRuntimePaths().credentialsPath, { force: true })
}
this.writeRuntimeOauthAccount(snapshot.configOauthAccount)
if (process.platform === 'darwin') {
await (snapshot.keychainCredentialsJson !== null
? writeActiveClaudeKeychainCredentials(snapshot.keychainCredentialsJson)
: deleteActiveClaudeKeychainCredentials())
}
}
// Why: detects whether an external tool (e.g. `claude auth login`) overwrote
// the credentials file while a managed account was active. If the file
// differs from what Orca last wrote, that external login becomes the new
// system default — no manual "refresh" button needed.
private detectExternalLoginAndUpdateSnapshot(): boolean {
if (this.lastWrittenCredentialsJson === null) {
return false
}
const paths = this.pathResolver.getRuntimePaths()
if (!existsSync(paths.credentialsPath)) {
return false
}
const currentCredentials = readFileSync(paths.credentialsPath, 'utf-8')
if (currentCredentials === this.lastWrittenCredentialsJson) {
return false
}
// External login detected — adopt current state as the new system default
const snapshotPath = this.getSystemDefaultSnapshotPath()
rmSync(snapshotPath, { force: true })
this.lastWrittenCredentialsJson = null
return true
}
private readRuntimeOauthAccount(): unknown {
const configPath = this.pathResolver.getRuntimePaths().configPath
if (!existsSync(configPath)) {
return null
}
try {
const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>
return parsed.oauthAccount ?? null
} catch {
return null
}
}
private writeRuntimeOauthAccount(oauthAccount: unknown): void {
const configPath = this.pathResolver.getRuntimePaths().configPath
const existing = this.readJsonObject(configPath)
if (oauthAccount === null || oauthAccount === undefined) {
delete existing.oauthAccount
} else {
existing.oauthAccount = oauthAccount
}
this.writeJson(configPath, existing)
}
private writeRuntimeCredentials(contents: string): void {
const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath
mkdirSync(dirname(credentialsPath), { recursive: true })
writeFileAtomically(credentialsPath, contents, { mode: 0o600 })
this.lastWrittenCredentialsJson = contents
}
private writeJson(targetPath: string, value: unknown): void {
mkdirSync(dirname(targetPath), { recursive: true })
writeFileAtomically(targetPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
}
private readJsonObject(targetPath: string): Record<string, unknown> {
if (!existsSync(targetPath)) {
return {}
}
try {
const parsed = JSON.parse(readFileSync(targetPath, 'utf-8')) as unknown
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
} catch {
/* Preserve no invalid JSON; Claude can recreate unsupported config files. */
}
return {}
}
private getRuntimeMetadataDir(): string {
const metadataDir = join(app.getPath('userData'), 'claude-runtime-auth')
mkdirSync(metadataDir, { recursive: true })
return metadataDir
}
private getSystemDefaultSnapshotPath(): string {
return join(this.getRuntimeMetadataDir(), 'system-default-auth.json')
}
}

View File

@ -0,0 +1,34 @@
import { existsSync, mkdirSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { ClaudeEnvPatch } from './environment'
export type ClaudeRuntimePaths = {
configDir: string
credentialsPath: string
configPath: string
envPatch: ClaudeEnvPatch
}
export class ClaudeRuntimePathResolver {
getRuntimePaths(): ClaudeRuntimePaths {
const inheritedConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || null
const configDir = inheritedConfigDir || join(homedir(), '.claude')
mkdirSync(configDir, { recursive: true })
return {
configDir,
credentialsPath: join(configDir, '.credentials.json'),
configPath: this.resolveConfigPath(configDir, inheritedConfigDir),
envPatch: inheritedConfigDir ? { CLAUDE_CONFIG_DIR: configDir } : {}
}
}
private resolveConfigPath(configDir: string, inheritedConfigDir: string | null): string {
const colocatedConfigPath = join(configDir, '.claude.json')
if (inheritedConfigDir || existsSync(colocatedConfigPath)) {
return colocatedConfigPath
}
return join(homedir(), '.claude.json')
}
}

View File

@ -0,0 +1,520 @@
/* eslint-disable max-lines -- Why: Claude managed accounts need one audited owner
for login, credential capture, Keychain storage, selection, and rate-limit refresh. */
import { randomUUID } from 'node:crypto'
import { spawn } from 'node:child_process'
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join, relative, resolve, sep } from 'node:path'
import { app } from 'electron'
import type {
ClaudeManagedAccount,
ClaudeManagedAccountSummary,
ClaudeRateLimitAccountsState
} from '../../shared/types'
import type { Store } from '../persistence'
import type { RateLimitService } from '../rate-limits/service'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import { resolveClaudeCommand } from '../codex-cli/command'
import type { ClaudeRuntimeAuthService } from './runtime-auth-service'
import {
deleteActiveClaudeKeychainCredentialsStrict,
deleteManagedClaudeKeychainCredentials,
readActiveClaudeKeychainCredentials,
writeActiveClaudeKeychainCredentials,
writeManagedClaudeKeychainCredentials
} from './keychain'
import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from './live-pty-gate'
const LOGIN_TIMEOUT_MS = 180_000
const STATUS_TIMEOUT_MS = 20_000
const MAX_COMMAND_OUTPUT_CHARS = 4_000
type ClaudeIdentity = {
email: string | null
organizationUuid: string | null
organizationName: string | null
}
type CapturedClaudeAuth = {
credentialsJson: string
oauthAccount: unknown
identity: ClaudeIdentity
}
export class ClaudeAccountService {
private mutationQueue: Promise<unknown> = Promise.resolve()
constructor(
private readonly store: Store,
private readonly rateLimits: RateLimitService,
private readonly runtimeAuth: ClaudeRuntimeAuthService
) {}
listAccounts(): ClaudeRateLimitAccountsState {
this.normalizeActiveSelection()
return this.getSnapshot()
}
async addAccount(): Promise<ClaudeRateLimitAccountsState> {
return this.serializeMutation(() => this.doAddAccount())
}
async reauthenticateAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
return this.serializeMutation(() => this.doReauthenticateAccount(accountId))
}
async removeAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
return this.serializeMutation(() => this.doRemoveAccount(accountId))
}
async selectAccount(accountId: string | null): Promise<ClaudeRateLimitAccountsState> {
return this.serializeMutation(() => this.doSelectAccount(accountId))
}
private serializeMutation<T>(fn: () => Promise<T>): Promise<T> {
const next = this.mutationQueue.then(fn, fn)
this.mutationQueue = next.catch(() => {})
return next
}
private async doAddAccount(): Promise<ClaudeRateLimitAccountsState> {
const accountId = randomUUID()
const managedAuthPath = this.createManagedAuthDir(accountId)
const previousSettings = this.store.getSettings()
try {
const captured = await this.runClaudeLoginAndCapture()
if (!captured.identity.email) {
throw new Error('Claude login completed, but Orca could not resolve the account email.')
}
await this.writeManagedAuth(accountId, managedAuthPath, captured)
const now = Date.now()
const account: ClaudeManagedAccount = {
id: accountId,
email: captured.identity.email,
managedAuthPath,
authMethod: 'subscription-oauth',
organizationUuid: captured.identity.organizationUuid,
organizationName: captured.identity.organizationName,
createdAt: now,
updatedAt: now,
lastAuthenticatedAt: now
}
this.store.updateSettings({
claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account],
activeClaudeManagedAccountId: account.id
})
await this.syncRuntimeAuthWithLivePtyGate()
await this.rateLimits.refreshForClaudeAccountChange()
return this.getSnapshot()
} catch (error) {
this.restoreClaudeSettings(previousSettings)
await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback()
await this.safeRemoveManagedAuth(accountId, managedAuthPath)
throw error
}
}
private async doReauthenticateAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
const account = this.requireAccount(accountId)
const managedAuthPath = this.assertManagedAuthPath(account.managedAuthPath)
const previousSettings = this.store.getSettings()
const captured = await this.runClaudeLoginAndCapture()
if (!captured.identity.email) {
throw new Error('Claude login completed, but Orca could not resolve the account email.')
}
await this.writeManagedAuth(accountId, managedAuthPath, captured)
const settings = this.store.getSettings()
const now = Date.now()
this.store.updateSettings({
claudeManagedAccounts: settings.claudeManagedAccounts.map((entry) =>
entry.id === accountId
? {
...entry,
email: captured.identity.email!,
organizationUuid: captured.identity.organizationUuid,
organizationName: captured.identity.organizationName,
updatedAt: now,
lastAuthenticatedAt: now
}
: entry
)
})
try {
await this.syncRuntimeAuthWithLivePtyGate()
await this.rateLimits.refreshForClaudeAccountChange()
return this.getSnapshot()
} catch (error) {
this.restoreClaudeSettings(previousSettings)
await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback()
throw error
}
}
private async doRemoveAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
const account = this.requireAccount(accountId)
const settings = this.store.getSettings()
const nextAccounts = settings.claudeManagedAccounts.filter((entry) => entry.id !== accountId)
const nextActiveId =
settings.activeClaudeManagedAccountId === accountId
? null
: settings.activeClaudeManagedAccountId
this.store.updateSettings({
claudeManagedAccounts: nextAccounts,
activeClaudeManagedAccountId: nextActiveId
})
try {
await this.syncRuntimeAuthWithLivePtyGate()
await this.safeRemoveManagedAuth(accountId, account.managedAuthPath)
await this.rateLimits.refreshForClaudeAccountChange()
return this.getSnapshot()
} catch (error) {
this.restoreClaudeSettings(settings)
await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback()
throw error
}
}
private async doSelectAccount(accountId: string | null): Promise<ClaudeRateLimitAccountsState> {
if (accountId !== null) {
this.requireAccount(accountId)
}
const previousSettings = this.store.getSettings()
this.store.updateSettings({ activeClaudeManagedAccountId: accountId })
try {
await this.syncRuntimeAuthWithLivePtyGate()
await this.rateLimits.refreshForClaudeAccountChange()
return this.getSnapshot()
} catch (error) {
this.restoreClaudeSettings(previousSettings)
await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback()
throw error
}
}
private getSnapshot(): ClaudeRateLimitAccountsState {
const settings = this.store.getSettings()
return {
accounts: settings.claudeManagedAccounts
.map((account) => this.toSummary(account))
.sort((a, b) => b.updatedAt - a.updatedAt),
activeAccountId: settings.activeClaudeManagedAccountId
}
}
private toSummary(account: ClaudeManagedAccount): ClaudeManagedAccountSummary {
return {
id: account.id,
email: account.email,
authMethod: account.authMethod ?? 'unknown',
organizationUuid: account.organizationUuid ?? null,
organizationName: account.organizationName ?? null,
createdAt: account.createdAt,
updatedAt: account.updatedAt,
lastAuthenticatedAt: account.lastAuthenticatedAt
}
}
private requireAccount(accountId: string): ClaudeManagedAccount {
const account = this.store
.getSettings()
.claudeManagedAccounts.find((entry) => entry.id === accountId)
if (!account) {
throw new Error('That Claude account no longer exists.')
}
return account
}
private normalizeActiveSelection(): void {
const settings = this.store.getSettings()
if (!settings.activeClaudeManagedAccountId) {
return
}
const hasActiveAccount = settings.claudeManagedAccounts.some(
(entry) => entry.id === settings.activeClaudeManagedAccountId
)
if (!hasActiveAccount) {
this.store.updateSettings({ activeClaudeManagedAccountId: null })
}
}
private restoreClaudeSettings(settings: ReturnType<Store['getSettings']>): void {
this.store.updateSettings({
claudeManagedAccounts: settings.claudeManagedAccounts,
activeClaudeManagedAccountId: settings.activeClaudeManagedAccountId
})
}
private async syncRuntimeAuthWithLivePtyGate(operation?: () => Promise<void>): Promise<void> {
beginClaudeAuthSwitch()
try {
await (operation ? operation() : this.runtimeAuth.syncForCurrentSelection())
} finally {
endClaudeAuthSwitch()
}
}
private async runClaudeLoginAndCapture(): Promise<CapturedClaudeAuth> {
const tempConfigDir = mkdtempSync(join(tmpdir(), 'orca-claude-login-'))
const previousActiveKeychain = await readActiveClaudeKeychainCredentials()
try {
await this.runClaudeCommand(['auth', 'login', '--claudeai'], tempConfigDir, LOGIN_TIMEOUT_MS)
const status = await this.runClaudeCommand(
['auth', 'status', '--json'],
tempConfigDir,
STATUS_TIMEOUT_MS,
{ allowFailure: true }
)
return await this.captureAuthFromConfigDir(tempConfigDir, status)
} finally {
if (process.platform === 'darwin' && previousActiveKeychain) {
// Why: Claude login writes the global active Keychain item even when
// CLAUDE_CONFIG_DIR points elsewhere. Restore it so adding an account
// does not switch the user's external Claude CLI out from under them.
await writeActiveClaudeKeychainCredentials(previousActiveKeychain)
} else if (process.platform === 'darwin') {
await deleteActiveClaudeKeychainCredentialsStrict()
}
rmSync(tempConfigDir, { recursive: true, force: true })
}
}
private async captureAuthFromConfigDir(
configDir: string,
statusOutput: string
): Promise<CapturedClaudeAuth> {
const credentialsJson = await this.readCapturedCredentials(configDir)
if (!credentialsJson) {
throw new Error('Claude login completed, but no OAuth credentials were captured.')
}
const oauthAccount = this.readOauthAccountFromConfigDir(configDir)
const identity = this.resolveIdentity(statusOutput, oauthAccount, credentialsJson)
return { credentialsJson, oauthAccount, identity }
}
private async readCapturedCredentials(configDir: string): Promise<string | null> {
if (process.platform === 'darwin') {
return readActiveClaudeKeychainCredentials()
}
const credentialsPath = join(configDir, '.credentials.json')
return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null
}
private readOauthAccountFromConfigDir(configDir: string): unknown {
for (const configPath of [join(configDir, '.claude.json'), join(configDir, '.config.json')]) {
if (!existsSync(configPath)) {
continue
}
try {
const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>
if (parsed.oauthAccount) {
return parsed.oauthAccount
}
} catch {
continue
}
}
return null
}
private resolveIdentity(
statusOutput: string,
oauthAccount: unknown,
credentialsJson: string
): ClaudeIdentity {
const status = this.parseJsonObject(statusOutput)
const oauth = this.asRecord(oauthAccount)
const credentials = this.parseJsonObject(credentialsJson)
const credentialOauth = this.asRecord(credentials?.claudeAiOauth)
return {
email: this.normalizeField(
this.readString(status, 'email') ??
this.readString(oauth, 'emailAddress') ??
this.readString(oauth, 'email') ??
this.readString(credentialOauth, 'email')
),
organizationUuid: this.normalizeField(
this.readString(status, 'organizationUuid') ??
this.readString(status, 'organizationId') ??
this.readString(oauth, 'organizationUuid') ??
this.readString(oauth, 'organizationId')
),
organizationName: this.normalizeField(
this.readString(status, 'organizationName') ?? this.readString(oauth, 'organizationName')
)
}
}
private async writeManagedAuth(
accountId: string,
managedAuthPath: string,
captured: CapturedClaudeAuth
): Promise<void> {
const trustedPath = this.assertManagedAuthPath(managedAuthPath)
if (process.platform === 'darwin') {
await writeManagedClaudeKeychainCredentials(accountId, captured.credentialsJson)
} else {
writeFileAtomically(join(trustedPath, '.credentials.json'), captured.credentialsJson, {
mode: 0o600
})
}
writeFileAtomically(
join(trustedPath, 'oauth-account.json'),
`${JSON.stringify(captured.oauthAccount, null, 2)}\n`,
{ mode: 0o600 }
)
}
private createManagedAuthDir(accountId: string): string {
const managedAuthPath = join(this.getManagedAccountsRoot(), accountId, 'auth')
mkdirSync(managedAuthPath, { recursive: true })
writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), `${accountId}\n`, 'utf-8')
return this.assertManagedAuthPath(managedAuthPath)
}
private getManagedAccountsRoot(): string {
const root = join(app.getPath('userData'), 'claude-accounts')
mkdirSync(root, { recursive: true })
return root
}
private assertManagedAuthPath(candidatePath: string): string {
const rootPath = this.getManagedAccountsRoot()
const resolvedCandidate = resolve(candidatePath)
const resolvedRoot = resolve(rootPath)
if (!existsSync(resolvedCandidate)) {
throw new Error('Managed Claude auth directory does not exist on disk.')
}
const canonicalCandidate = realpathSync(resolvedCandidate)
const canonicalRoot = realpathSync(resolvedRoot)
if (
canonicalCandidate !== canonicalRoot &&
!canonicalCandidate.startsWith(canonicalRoot + sep)
) {
throw new Error(
`Managed Claude auth is outside current storage root (expected under ${canonicalRoot}).`
)
}
const relativePath = relative(canonicalRoot, canonicalCandidate)
const escaped =
relativePath === '' || relativePath.startsWith('..') || relativePath.includes(`..${sep}`)
if (escaped || !existsSync(join(canonicalCandidate, '.orca-managed-claude-auth'))) {
throw new Error('Managed Claude auth storage is not owned by Orca.')
}
return canonicalCandidate
}
private async safeRemoveManagedAuth(accountId: string, candidatePath: string): Promise<void> {
try {
const managedAuthPath = this.assertManagedAuthPath(candidatePath)
rmSync(resolve(managedAuthPath, '..'), { recursive: true, force: true })
} catch (error) {
console.warn('[claude-accounts] Refusing to remove untrusted managed auth:', error)
}
await deleteManagedClaudeKeychainCredentials(accountId)
}
private runClaudeCommand(
args: string[],
configDir: string,
timeoutMs: number,
options?: { allowFailure?: boolean }
): Promise<string> {
return new Promise((resolvePromise, rejectPromise) => {
const claudeCommand = resolveClaudeCommand()
const child = spawn(claudeCommand, args, {
stdio: ['ignore', 'pipe', 'pipe'],
shell: process.platform === 'win32',
env: {
...process.env,
CLAUDE_CONFIG_DIR: configDir
}
})
let settled = false
let output = ''
const appendOutput = (chunk: Buffer): void => {
output = `${output}${chunk.toString()}`
if (output.length > MAX_COMMAND_OUTPUT_CHARS) {
output = output.slice(-MAX_COMMAND_OUTPUT_CHARS)
}
}
const settle = (callback: () => void): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
callback()
}
const timeout = setTimeout(() => {
child.kill()
settle(() => rejectPromise(new Error('Claude sign-in took too long to finish.')))
}, timeoutMs)
child.stdout.on('data', appendOutput)
child.stderr.on('data', appendOutput)
child.on('error', (error) => {
settle(() => rejectPromise(error))
})
child.on('close', (code) => {
settle(() => {
if (code === 0 || options?.allowFailure) {
resolvePromise(output)
return
}
const trimmedOutput = output.trim()
rejectPromise(
new Error(
trimmedOutput
? `Claude command failed: ${trimmedOutput}`
: `Claude command exited with code ${code ?? 'unknown'}.`
)
)
})
})
})
}
private parseJsonObject(value: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(value) as unknown
return this.asRecord(parsed)
} catch {
return null
}
}
private asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
return value as Record<string, unknown>
}
private readString(value: Record<string, unknown> | null, key: string): string | null {
const field = value?.[key]
return typeof field === 'string' ? field : null
}
private normalizeField(value: string | null | undefined): string | null {
if (!value) {
return null
}
const trimmed = value.trim()
return trimmed === '' ? null : trimmed
}
}

View File

@ -74,6 +74,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
promptCacheTtlMs: 300_000,
codexManagedAccounts: [],
activeCodexManagedAccountId: null,
claudeManagedAccounts: [],
activeClaudeManagedAccountId: null,
terminalScopeHistoryByWorktree: true,
defaultTuiAgent: null,
skipDeleteWorktreeConfirm: false,
@ -315,9 +317,9 @@ describe('CodexRuntimeHomeService', () => {
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n')
// External tool changes auth — subsequent syncs must not overwrite
writeFileSync(runtimeAuthPath, '{"account":"cc-switch"}\n', 'utf-8')
writeFileSync(runtimeAuthPath, '{"account":"external-tool"}\n', 'utf-8')
service.syncForCurrentSelection()
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"cc-switch"}\n')
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"external-tool"}\n')
})
it('restores system default on restart when persisted active account is invalid', async () => {

View File

@ -21,7 +21,7 @@ import { writeFileAtomically } from './fs-utils'
export class CodexRuntimeHomeService {
// Why: tracks whether auth.json is currently managed by Orca. When null,
// Orca does NOT own auth.json and must not overwrite external changes
// (e.g. user running `codex login` or using cc-switch). The snapshot
// (e.g. user running `codex login` or another auth tool). The snapshot
// restore only fires on the managed→system-default transition.
private lastSyncedAccountId: string | null = null
@ -58,8 +58,8 @@ export class CodexRuntimeHomeService {
// Why: only restore the snapshot when transitioning FROM a managed
// account back to system default. When no managed account was ever
// active, auth.json belongs to the user and Orca must not touch it.
// This prevents overwriting external auth changes (codex login,
// cc-switch, or other tools) on every PTY launch / rate-limit fetch.
// This prevents overwriting external auth changes (codex login or other
// tools) on every PTY launch / rate-limit fetch.
if (this.lastSyncedAccountId !== null) {
this.restoreSystemDefaultSnapshot()
this.lastSyncedAccountId = null

View File

@ -68,6 +68,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
promptCacheTtlMs: 300_000,
codexManagedAccounts: [],
activeCodexManagedAccountId: null,
claudeManagedAccounts: [],
activeClaudeManagedAccountId: null,
terminalScopeHistoryByWorktree: true,
defaultTuiAgent: null,
skipDeleteWorktreeConfirm: false,

View File

@ -33,6 +33,8 @@ import { attachMainWindowServices } from './window/attach-main-window-services'
import { createMainWindow } from './window/createMainWindow'
import { CodexAccountService } from './codex-accounts/service'
import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service'
import { ClaudeAccountService } from './claude-accounts/service'
import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service'
import { openCodeHookService } from './opencode/hook-service'
import { StarNagService } from './star-nag/service'
import { AgentBrowserBridge } from './browser/agent-browser-bridge'
@ -49,6 +51,8 @@ let claudeUsage: ClaudeUsageStore | null = null
let codexUsage: CodexUsageStore | null = null
let codexAccounts: CodexAccountService | null = null
let codexRuntimeHome: CodexRuntimeHomeService | null = null
let claudeAccounts: ClaudeAccountService | null = null
let claudeRuntimeAuth: ClaudeRuntimeAuthService | null = null
let runtime: OrcaRuntimeService | null = null
let rateLimits: RateLimitService | null = null
let runtimeRpc: OrcaRuntimeRpcServer | null = null
@ -135,6 +139,14 @@ function openMainWindow(): BrowserWindow {
if (!codexRuntimeHome) {
throw new Error('Codex runtime home service must be initialized before opening the main window')
}
if (!claudeAccounts) {
throw new Error('Claude account service must be initialized before opening the main window')
}
if (!claudeRuntimeAuth) {
throw new Error(
'Claude runtime auth service must be initialized before opening the main window'
)
}
const window = createMainWindow(store, {
getIsQuitting: () => isQuitting,
@ -149,10 +161,17 @@ function openMainWindow(): BrowserWindow {
claudeUsage,
codexUsage,
codexAccounts,
claudeAccounts,
rateLimits,
window.webContents.id
)
attachMainWindowServices(window, store, runtime, () => codexRuntimeHome!.prepareForCodexLaunch())
attachMainWindowServices(
window,
store,
runtime,
() => codexRuntimeHome!.prepareForCodexLaunch(),
() => claudeRuntimeAuth!.prepareForClaudeLaunch()
)
rateLimits.attach(window)
rateLimits.start()
window.on('closed', () => {
@ -180,7 +199,10 @@ app.whenReady().then(async () => {
rateLimits = new RateLimitService()
codexRuntimeHome = new CodexRuntimeHomeService(store)
codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome)
claudeRuntimeAuth = new ClaudeRuntimeAuthService(store)
claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth)
rateLimits.setCodexHomePathResolver(() => codexRuntimeHome!.prepareForRateLimitFetch())
rateLimits.setClaudeAuthPreparationResolver(() => claudeRuntimeAuth!.prepareForRateLimitFetch())
runtime = new OrcaRuntimeService(store, stats)
starNag = new StarNagService(store, stats)
starNag.start()

View File

@ -0,0 +1,16 @@
import { ipcMain } from 'electron'
import type { ClaudeAccountService } from '../claude-accounts/service'
export function registerClaudeAccountHandlers(claudeAccounts: ClaudeAccountService): void {
ipcMain.handle('claudeAccounts:list', () => claudeAccounts.listAccounts())
ipcMain.handle('claudeAccounts:add', () => claudeAccounts.addAccount())
ipcMain.handle('claudeAccounts:reauthenticate', (_event, args: { accountId: string }) =>
claudeAccounts.reauthenticateAccount(args.accountId)
)
ipcMain.handle('claudeAccounts:remove', (_event, args: { accountId: string }) =>
claudeAccounts.removeAccount(args.accountId)
)
ipcMain.handle('claudeAccounts:select', (_event, args: { accountId: string | null }) =>
claudeAccounts.selectAccount(args.accountId)
)
}

View File

@ -10,7 +10,14 @@ import type { GlobalSettings } from '../../shared/types'
import { openCodeHookService } from '../opencode/hook-service'
import { piTitlebarExtensionService } from '../pi/titlebar-extension-service'
import { LocalPtyProvider } from '../providers/local-pty-provider'
import type { IPtyProvider } from '../providers/types'
import type { IPtyProvider, PtySpawnOptions } from '../providers/types'
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment'
import {
isClaudeAuthSwitchInProgress,
markClaudePtyExited,
markClaudePtySpawned
} from '../claude-accounts/live-pty-gate'
// ─── Provider Registry ──────────────────────────────────────────────
// Routes PTY operations by connectionId. null = local provider.
@ -43,6 +50,15 @@ function getProviderForPty(ptyId: string): IPtyProvider {
return getProvider(connectionId)
}
function isClaudeLaunchCommand(command: string | undefined): boolean {
if (!command) {
return false
}
return /(^|[\s;&|('"`])(?:[^\s;&|('"`]*[\\/])?claude(?:\.cmd|\.exe)?($|[\s;&|)'"`])/i.test(
command
)
}
/** Register an SSH PTY provider for a connection. */
export function registerSshPtyProvider(connectionId: string, provider: IPtyProvider): void {
sshProviders.set(connectionId, provider)
@ -126,7 +142,8 @@ export function registerPtyHandlers(
mainWindow: BrowserWindow,
runtime?: OrcaRuntimeService,
getSelectedCodexHomePath?: () => string | null,
getSettings?: () => GlobalSettings
getSettings?: () => GlobalSettings,
prepareClaudeAuth?: () => Promise<ClaudeRuntimeAuthPreparation>
): void {
// Remove any previously registered handlers so we can re-register them
// (e.g. when macOS re-activates the app and creates a new window).
@ -182,6 +199,7 @@ export function registerPtyHandlers(
onExit: (id, code) => {
clearProviderPtyState(id)
ptyOwnership.delete(id)
markClaudePtyExited(id)
runtime?.onPtyExit(id, code)
},
onData: (id, data, timestamp) => runtime?.onPtyData(id, data, timestamp)
@ -257,6 +275,7 @@ export function registerPtyHandlers(
for (const { id } of killed) {
clearProviderPtyState(id)
ptyOwnership.delete(id)
markClaudePtyExited(id)
runtime?.onPtyExit(id, -1)
}
}
@ -283,6 +302,7 @@ export function registerPtyHandlers(
// if the remote SSH session is already gone.
void provider.shutdown(ptyId, false).catch(() => {})
clearProviderPtyState(ptyId)
markClaudePtyExited(ptyId)
runtime?.onPtyExit(ptyId, -1)
return true
}
@ -306,16 +326,46 @@ export function registerPtyHandlers(
}
) => {
const provider = getProvider(args.connectionId)
const result = await provider.spawn({
const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command)
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
}
const claudeAuth = isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth() : null
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
}
if (claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) {
throw new Error(
'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.'
)
}
const env = claudeAuth ? { ...args.env, ...claudeAuth.envPatch } : args.env
const envToDelete = claudeAuth?.stripAuthEnv
? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS']
: undefined
const spawnOptions: PtySpawnOptions = {
cols: args.cols,
rows: args.rows,
cwd: args.cwd,
env: args.env,
command: args.command,
worktreeId: args.worktreeId,
sessionId: args.sessionId
})
env
}
if (envToDelete) {
spawnOptions.envToDelete = envToDelete
}
if (args.command !== undefined) {
spawnOptions.command = args.command
}
if (args.worktreeId !== undefined) {
spawnOptions.worktreeId = args.worktreeId
}
if (args.sessionId !== undefined) {
spawnOptions.sessionId = args.sessionId
}
const result = await provider.spawn(spawnOptions)
ptyOwnership.set(result.id, args.connectionId ?? null)
if (isClaudeLaunch) {
markClaudePtySpawned(result.id)
}
return result
}
)
@ -359,6 +409,7 @@ export function registerPtyHandlers(
/* session already dead — cleanup below handles the rest */
} finally {
ptyOwnership.delete(args.id)
markClaudePtyExited(args.id)
}
})

View File

@ -16,6 +16,7 @@ const {
registerFilesystemHandlersMock,
registerRuntimeHandlersMock,
registerCodexAccountHandlersMock,
registerClaudeAccountHandlersMock,
registerClipboardHandlersMock,
registerUpdaterHandlersMock,
registerRateLimitHandlersMock,
@ -42,6 +43,7 @@ const {
registerFilesystemHandlersMock: vi.fn(),
registerRuntimeHandlersMock: vi.fn(),
registerCodexAccountHandlersMock: vi.fn(),
registerClaudeAccountHandlersMock: vi.fn(),
registerClipboardHandlersMock: vi.fn(),
registerUpdaterHandlersMock: vi.fn(),
registerRateLimitHandlersMock: vi.fn(),
@ -126,6 +128,10 @@ vi.mock('./codex-accounts', () => ({
registerCodexAccountHandlers: registerCodexAccountHandlersMock
}))
vi.mock('./claude-accounts', () => ({
registerClaudeAccountHandlers: registerClaudeAccountHandlersMock
}))
vi.mock('../window/attach-main-window-services', () => ({
registerClipboardHandlers: registerClipboardHandlersMock,
registerUpdaterHandlers: registerUpdaterHandlersMock
@ -164,6 +170,7 @@ describe('registerCoreHandlers', () => {
registerFilesystemHandlersMock.mockReset()
registerRuntimeHandlersMock.mockReset()
registerCodexAccountHandlersMock.mockReset()
registerClaudeAccountHandlersMock.mockReset()
registerClipboardHandlersMock.mockReset()
registerUpdaterHandlersMock.mockReset()
registerRateLimitHandlersMock.mockReset()
@ -183,6 +190,7 @@ describe('registerCoreHandlers', () => {
const claudeUsage = { marker: 'claudeUsage' }
const codexUsage = { marker: 'codexUsage' }
const codexAccounts = { marker: 'codexAccounts' }
const claudeAccounts = { marker: 'claudeAccounts' }
const rateLimits = { marker: 'rateLimits' }
registerCoreHandlers(
@ -192,12 +200,14 @@ describe('registerCoreHandlers', () => {
claudeUsage as never,
codexUsage as never,
codexAccounts as never,
claudeAccounts as never,
rateLimits as never
)
expect(registerClaudeUsageHandlersMock).toHaveBeenCalledWith(claudeUsage)
expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage)
expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith(codexAccounts)
expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts)
expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits)
expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats)
expect(registerLinearHandlersMock).toHaveBeenCalled()
@ -228,6 +238,7 @@ describe('registerCoreHandlers', () => {
const claudeUsage2 = { marker: 'claudeUsage2' }
const codexUsage2 = { marker: 'codexUsage2' }
const codexAccounts2 = { marker: 'codexAccounts2' }
const claudeAccounts2 = { marker: 'claudeAccounts2' }
const rateLimits2 = { marker: 'rateLimits2' }
registerCoreHandlers(
@ -237,6 +248,7 @@ describe('registerCoreHandlers', () => {
claudeUsage2 as never,
codexUsage2 as never,
codexAccounts2 as never,
claudeAccounts2 as never,
rateLimits2 as never,
42
)

View File

@ -24,6 +24,7 @@ import { browserSessionRegistry } from '../browser/browser-session-registry'
import { registerShellHandlers } from './shell'
import { registerUIHandlers } from './ui'
import { registerCodexAccountHandlers } from './codex-accounts'
import { registerClaudeAccountHandlers } from './claude-accounts'
import { warmSystemFontFamilies } from '../system-fonts'
import {
registerClipboardHandlers,
@ -33,6 +34,7 @@ import type { ClaudeUsageStore } from '../claude-usage/store'
import type { CodexUsageStore } from '../codex-usage/store'
import type { RateLimitService } from '../rate-limits/service'
import type { CodexAccountService } from '../codex-accounts/service'
import type { ClaudeAccountService } from '../claude-accounts/service'
let registered = false
@ -43,6 +45,7 @@ export function registerCoreHandlers(
claudeUsage: ClaudeUsageStore,
codexUsage: CodexUsageStore,
codexAccounts: CodexAccountService,
claudeAccounts: ClaudeAccountService,
rateLimits: RateLimitService,
mainWindowWebContentsId: number | null = null
): void {
@ -63,6 +66,7 @@ export function registerCoreHandlers(
registerClaudeUsageHandlers(claudeUsage)
registerCodexUsageHandlers(codexUsage)
registerCodexAccountHandlers(codexAccounts)
registerClaudeAccountHandlers(claudeAccounts)
registerRateLimitHandlers(rateLimits)
registerGitHubHandlers(store, stats)
registerLinearHandlers()

View File

@ -190,6 +190,9 @@ export class LocalPtyProvider implements IPtyProvider {
// fallback keeps tests and non-Electron runs working.
TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev'
} as Record<string, string>
for (const key of args.envToDelete ?? []) {
delete spawnEnv[key]
}
// Why: FORCE_HYPERLINK=1 is read by oh-my-zsh's supports_hyperlinks(), the
// Rust supports-hyperlinks crate, GNU coreutils, and other tooling.

View File

@ -17,6 +17,7 @@ export type PtySpawnOptions = {
rows: number
cwd?: string
env?: Record<string, string>
envToDelete?: string[]
command?: string
/** Orca worktree identity. When present, the local provider scopes shell
* history to this worktree so ArrowUp only surfaces local commands. */

View File

@ -5,6 +5,7 @@ import path from 'node:path'
import { net, session } from 'electron'
import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types'
import { fetchViaPty } from './claude-pty'
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
const OAUTH_BETA_HEADER = 'oauth-2025-04-20'
@ -130,8 +131,8 @@ async function readFromKeychain(): Promise<string | null> {
* Why: older Claude CLI versions store credentials in this plain JSON
* file. We keep it as a fallback for compatibility.
*/
async function readFromCredentialsFile(): Promise<string | null> {
const credPath = path.join(homedir(), '.claude', '.credentials.json')
async function readFromCredentialsFile(configDir?: string): Promise<string | null> {
const credPath = path.join(configDir ?? path.join(homedir(), '.claude'), '.credentials.json')
try {
const raw = await readFile(credPath, 'utf-8')
const parsed = JSON.parse(raw) as ClaudeCredentials
@ -157,7 +158,7 @@ async function readFromCredentialsFile(): Promise<string | null> {
* here those are API keys which return 401 on the OAuth usage endpoint.
* API-key users are served by the PTY fallback instead.
*/
async function readOAuthCredentials(): Promise<string | null> {
async function readOAuthCredentials(configDir?: string): Promise<string | null> {
// 1. macOS Keychain (Claude Max/Pro OAuth)
const fromKeychain = await readFromKeychain()
if (fromKeychain) {
@ -165,7 +166,7 @@ async function readOAuthCredentials(): Promise<string | null> {
}
// 2. Legacy credentials file
const fromFile = await readFromCredentialsFile()
const fromFile = await readFromCredentialsFile(configDir)
if (fromFile) {
return fromFile
}
@ -266,9 +267,13 @@ async function fetchViaOAuth(token: string): Promise<ProviderRateLimits> {
// Public API
// ---------------------------------------------------------------------------
export async function fetchClaudeRateLimits(): Promise<ProviderRateLimits> {
export async function fetchClaudeRateLimits(options?: {
authPreparation?: ClaudeRuntimeAuthPreparation
}): Promise<ProviderRateLimits> {
// Path A: try OAuth API if we have a genuine OAuth token
const oauthToken = await readOAuthCredentials()
const oauthToken = await readOAuthCredentials(
options?.authPreparation?.envPatch.CLAUDE_CONFIG_DIR
)
if (oauthToken) {
try {
return await fetchViaOAuth(oauthToken)
@ -282,7 +287,7 @@ export async function fetchClaudeRateLimits(): Promise<ProviderRateLimits> {
// `/usage` command is subscription-only, so there's no point
// attempting PTY for API key users.
try {
return await fetchViaPty()
return await fetchViaPty({ authPreparation: options?.authPreparation })
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
return {

View File

@ -1,5 +1,7 @@
import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types'
import { resolveClaudeCommand } from '../codex-cli/command'
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
import { applyClaudeEnvPatch } from '../claude-accounts/environment'
const PTY_TIMEOUT_MS = 25_000
@ -125,7 +127,9 @@ function describeClaudeUsageFailure(output: string): string {
return 'Claude usage is unavailable right now.'
}
export async function fetchViaPty(): Promise<ProviderRateLimits> {
export async function fetchViaPty(options?: {
authPreparation?: ClaudeRuntimeAuthPreparation
}): Promise<ProviderRateLimits> {
const pty = await import('node-pty')
return new Promise<ProviderRateLimits>((resolve) => {
@ -144,11 +148,17 @@ export async function fetchViaPty(): Promise<ProviderRateLimits> {
const spawnFile = isWin32 ? 'cmd.exe' : claudeCommand
const spawnArgs = isWin32 ? ['/c', claudeCommand] : []
const spawnEnv = applyClaudeEnvPatch(
{ ...process.env, TERM: 'xterm-256color' } as Record<string, string>,
options?.authPreparation?.envPatch ?? {},
{ stripAuthEnv: options?.authPreparation?.stripAuthEnv ?? false }
)
const term = pty.spawn(spawnFile, spawnArgs, {
name: 'xterm-256color',
cols: 120,
rows: 40,
env: { ...process.env, TERM: 'xterm-256color' }
env: spawnEnv
})
const termDisposables: { dispose: () => void }[] = []
const disposeTermListeners = (): void => {

View File

@ -5,6 +5,7 @@ import type { BrowserWindow } from 'electron'
import type { RateLimitState, ProviderRateLimits } from '../../shared/rate-limit-types'
import { fetchClaudeRateLimits } from './claude-fetcher'
import { fetchCodexRateLimits } from './codex-fetcher'
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
// Why: quota state does not need near-real-time polling, and a less aggressive
// default reduces avoidable Claude /usage pressure. We intentionally use a
@ -23,9 +24,12 @@ export class RateLimitService {
private isFetching = false
private fullFetchQueued = false
private codexOnlyFetchQueued = false
private claudeOnlyFetchQueued = false
private fetchIdleResolvers: (() => void)[] = []
private codexFetchGeneration = 0
private claudeFetchGeneration = 0
private codexHomePathResolver: (() => string | null) | null = null
private claudeAuthPreparationResolver: (() => Promise<ClaudeRuntimeAuthPreparation>) | null = null
constructor() {}
@ -33,6 +37,10 @@ export class RateLimitService {
this.codexHomePathResolver = resolver
}
setClaudeAuthPreparationResolver(resolver: () => Promise<ClaudeRuntimeAuthPreparation>): void {
this.claudeAuthPreparationResolver = resolver
}
attach(mainWindow: BrowserWindow): void {
this.detachWindowListeners?.()
this.mainWindow = mainWindow
@ -95,6 +103,16 @@ export class RateLimitService {
return this.state
}
async refreshForClaudeAccountChange(): Promise<RateLimitState> {
this.claudeFetchGeneration += 1
this.updateState({
...this.state,
claude: this.withFetchingStatus(null, 'claude')
})
await this.fetchClaudeOnly({ force: true })
return this.state
}
setPollingInterval(ms: number): void {
this.pollInterval = Math.max(30_000, ms)
if (this.timer) {
@ -171,6 +189,10 @@ export class RateLimitService {
this.codexOnlyFetchQueued = false
await this.runFetchCodexOnlyCycle()
}
if (this.claudeOnlyFetchQueued) {
this.claudeOnlyFetchQueued = false
await this.runFetchClaudeOnlyCycle()
}
}
} finally {
this.isFetching = false
@ -202,6 +224,45 @@ export class RateLimitService {
this.codexOnlyFetchQueued = false
shouldContinue = true
}
if (this.claudeOnlyFetchQueued) {
this.claudeOnlyFetchQueued = false
await this.runFetchClaudeOnlyCycle()
}
}
} finally {
this.isFetching = false
this.resolveFetchIdleWaiters()
}
}
private async fetchClaudeOnly(options?: { force?: boolean }): Promise<void> {
if (this.isFetching) {
if (options?.force) {
this.claudeOnlyFetchQueued = true
return this.waitForFetchIdle()
}
return
}
this.isFetching = true
try {
let shouldContinue = true
while (shouldContinue) {
await this.runFetchClaudeOnlyCycle()
shouldContinue = false
if (this.fullFetchQueued) {
this.fullFetchQueued = false
await this.runFetchAllCycle()
continue
}
if (this.claudeOnlyFetchQueued) {
this.claudeOnlyFetchQueued = false
shouldContinue = true
}
if (this.codexOnlyFetchQueued) {
this.codexOnlyFetchQueued = false
await this.runFetchCodexOnlyCycle()
}
}
} finally {
this.isFetching = false
@ -210,7 +271,12 @@ export class RateLimitService {
}
private waitForFetchIdle(): Promise<void> {
if (!this.isFetching && !this.fullFetchQueued && !this.codexOnlyFetchQueued) {
if (
!this.isFetching &&
!this.fullFetchQueued &&
!this.codexOnlyFetchQueued &&
!this.claudeOnlyFetchQueued
) {
return Promise.resolve()
}
// Why: explicit refresh callers need to await the queued follow-up cycle
@ -222,7 +288,12 @@ export class RateLimitService {
}
private resolveFetchIdleWaiters(): void {
if (this.isFetching || this.fullFetchQueued || this.codexOnlyFetchQueued) {
if (
this.isFetching ||
this.fullFetchQueued ||
this.codexOnlyFetchQueued ||
this.claudeOnlyFetchQueued
) {
return
}
const resolvers = this.fetchIdleResolvers
@ -250,6 +321,9 @@ export class RateLimitService {
}
private async runFetchAllCycle(): Promise<void> {
const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.()
const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system'
const claudeGeneration = this.claudeFetchGeneration
const codexHomePath = this.codexHomePathResolver?.() ?? null
const codexProvenance = codexHomePath ? `managed:${codexHomePath}` : 'system'
const codexGeneration = this.codexFetchGeneration
@ -264,7 +338,7 @@ export class RateLimitService {
})
const [claude, codex] = await Promise.all([
fetchClaudeRateLimits().catch(
fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation }).catch(
(err): ProviderRateLimits => ({
provider: 'claude',
session: null,
@ -287,16 +361,22 @@ export class RateLimitService {
])
const latestCodexHomePath = this.codexHomePathResolver?.() ?? null
const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.()
const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system'
const latestCodexProvenance = latestCodexHomePath ? `managed:${latestCodexHomePath}` : 'system'
const shouldApplyCodex =
codexGeneration === this.codexFetchGeneration && codexProvenance === latestCodexProvenance
const shouldApplyClaude =
claudeGeneration === this.claudeFetchGeneration && claudeProvenance === latestClaudeProvenance
// Why: account switches can race in-flight Codex fetches. Only apply a
// Codex result if both the selected-account provenance and the request
// generation still match, otherwise an old account could overwrite the
// newly selected account's quota state.
this.updateState({
claude: this.applyStalePolicy(claude, previousState.claude),
claude: shouldApplyClaude
? this.applyStalePolicy(claude, previousState.claude)
: this.state.claude,
codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) : this.state.codex
})
@ -338,6 +418,43 @@ export class RateLimitService {
this.lastFetchAt = Date.now()
}
private async runFetchClaudeOnlyCycle(): Promise<void> {
const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.()
const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system'
const claudeGeneration = this.claudeFetchGeneration
const previousState = this.state
this.updateState({
...previousState,
claude: this.withFetchingStatus(previousState.claude, 'claude')
})
const claude = await fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation }).catch(
(err): ProviderRateLimits => ({
provider: 'claude',
session: null,
weekly: null,
updatedAt: Date.now(),
error: err instanceof Error ? err.message : 'Unknown error',
status: 'error'
})
)
const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.()
const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system'
const shouldApplyClaude =
claudeGeneration === this.claudeFetchGeneration && claudeProvenance === latestClaudeProvenance
this.updateState({
...this.state,
claude: shouldApplyClaude
? this.applyStalePolicy(claude, previousState.claude)
: this.state.claude
})
this.lastFetchAt = Date.now()
}
private applyStalePolicy(
fresh: ProviderRateLimits,
previous: ProviderRateLimits | null

View File

@ -22,16 +22,24 @@ import {
} from '../updater'
import { scheduleHistoryGc } from '../terminal-history'
import { listRepoWorktrees } from '../repo-worktrees'
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
export function attachMainWindowServices(
mainWindow: BrowserWindow,
store: Store,
runtime: OrcaRuntimeService,
getSelectedCodexHomePath?: () => string | null
getSelectedCodexHomePath?: () => string | null,
prepareClaudeAuth?: () => Promise<ClaudeRuntimeAuthPreparation>
): void {
registerRepoHandlers(mainWindow, store)
registerWorktreeHandlers(mainWindow, store)
registerPtyHandlers(mainWindow, runtime, getSelectedCodexHomePath, () => store.getSettings())
registerPtyHandlers(
mainWindow,
runtime,
getSelectedCodexHomePath,
() => store.getSettings(),
prepareClaudeAuth
)
// Why: GC runs on a 10s delay so live worktree enumeration completes first.
// Uses git worktree list (not store.getWorktreeMeta) because untouched
// worktrees have no metadata entries — see design doc §7.6.

View File

@ -6,6 +6,7 @@ import type {
BrowserSessionProfile,
BrowserSessionProfileScope,
BrowserSessionProfileSource,
ClaudeRateLimitAccountsState,
CodexRateLimitAccountsState,
CreateWorktreeResult,
DirEntry,
@ -475,6 +476,13 @@ export type PreloadApi = {
remove: (args: { accountId: string }) => Promise<CodexRateLimitAccountsState>
select: (args: { accountId: string | null }) => Promise<CodexRateLimitAccountsState>
}
claudeAccounts: {
list: () => Promise<ClaudeRateLimitAccountsState>
add: () => Promise<ClaudeRateLimitAccountsState>
reauthenticate: (args: { accountId: string }) => Promise<ClaudeRateLimitAccountsState>
remove: (args: { accountId: string }) => Promise<ClaudeRateLimitAccountsState>
select: (args: { accountId: string | null }) => Promise<ClaudeRateLimitAccountsState>
}
cli: {
getInstallStatus: () => Promise<CliInstallStatus>
install: () => Promise<CliInstallStatus>

View File

@ -522,6 +522,17 @@ const api = {
ipcRenderer.invoke('codexAccounts:select', args)
},
claudeAccounts: {
list: (): Promise<unknown> => ipcRenderer.invoke('claudeAccounts:list'),
add: (): Promise<unknown> => ipcRenderer.invoke('claudeAccounts:add'),
reauthenticate: (args: { accountId: string }): Promise<unknown> =>
ipcRenderer.invoke('claudeAccounts:reauthenticate', args),
remove: (args: { accountId: string }): Promise<unknown> =>
ipcRenderer.invoke('claudeAccounts:remove', args),
select: (args: { accountId: string | null }): Promise<unknown> =>
ipcRenderer.invoke('claudeAccounts:select', args)
},
cli: {
getInstallStatus: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:getInstallStatus'),
install: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:install'),

View File

@ -2,7 +2,11 @@
splitting individual settings into separate files would scatter related controls without a
meaningful abstraction boundary. */
import { useEffect, useState } from 'react'
import type { CodexRateLimitAccountsState, GlobalSettings } from '../../../../shared/types'
import type {
ClaudeRateLimitAccountsState,
CodexRateLimitAccountsState,
GlobalSettings
} from '../../../../shared/types'
import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
@ -19,6 +23,7 @@ import {
} from '../../../../shared/constants'
import { clampNumber } from '@/lib/terminal-theme'
import {
GENERAL_CLAUDE_ACCOUNTS_SEARCH_ENTRIES,
GENERAL_CODEX_ACCOUNTS_SEARCH_ENTRIES,
GENERAL_CACHE_TIMER_SEARCH_ENTRIES,
GENERAL_CLI_SEARCH_ENTRIES,
@ -58,6 +63,16 @@ function getCodexAccountLabel(
return state.accounts.find((account) => account.id === accountId)?.email ?? 'Codex account'
}
function getClaudeAccountLabel(
state: ClaudeRateLimitAccountsState,
accountId: string | null | undefined
): string {
if (accountId == null) {
return 'System default'
}
return state.accounts.find((account) => account.id === accountId)?.email ?? 'Claude account'
}
function getCodexAccountErrorDescription(error: unknown): string {
const message = String((error as Error)?.message ?? error)
.replace(/^Error occurred in handler for 'codexAccounts:[^']+':\s*/i, '')
@ -92,6 +107,16 @@ function getCodexAccountErrorDescription(error: unknown): string {
return message || 'Codex sign-in failed. Please try again.'
}
function getClaudeAccountErrorDescription(error: unknown): string {
return (
String((error as Error)?.message ?? error)
.replace(/^Error occurred in handler for 'claudeAccounts:[^']+':\s*/i, '')
.replace(/^Error invoking remote method 'claudeAccounts:[^']+':\s*/i, '')
.replace(/^Error:\s*/i, '')
.trim() || 'Claude sign-in failed. Please try again.'
)
}
export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): React.JSX.Element {
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
const updateStatus = useAppStore((s) => s.updateStatus)
@ -107,7 +132,15 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
const [codexAction, setCodexAction] = useState<
'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}`
>('idle')
const [claudeAccounts, setClaudeAccounts] = useState<ClaudeRateLimitAccountsState>({
accounts: [],
activeAccountId: null
})
const [claudeAction, setClaudeAction] = useState<
'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}`
>('idle')
const [removeAccountId, setRemoveAccountId] = useState<string | null>(null)
const [removeClaudeAccountId, setRemoveClaudeAccountId] = useState<string | null>(null)
// Why: the star state is derived from gh, not from settings, so it does not
// live in the global settings store. 'hidden' covers the gh-unavailable and
// already-starred-on-a-previous-session cases so the section drops out for
@ -167,9 +200,9 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
const loadCodexAccounts = async (): Promise<void> => {
try {
const next = await window.api.codexAccounts.list()
const nextCodex = await window.api.codexAccounts.list()
if (!stale) {
setCodexAccounts(next)
setCodexAccounts(nextCodex)
}
} catch (error) {
if (!stale) {
@ -180,7 +213,23 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
}
}
const loadClaudeAccounts = async (): Promise<void> => {
try {
const nextClaude = await window.api.claudeAccounts.list()
if (!stale) {
setClaudeAccounts(nextClaude)
}
} catch (error) {
if (!stale) {
toast.error('Could not load Claude accounts.', {
description: String((error as Error)?.message ?? error)
})
}
}
}
void loadCodexAccounts()
void loadClaudeAccounts()
return () => {
stale = true
@ -229,6 +278,11 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
await fetchSettings()
}
const syncClaudeAccounts = async (next: ClaudeRateLimitAccountsState): Promise<void> => {
setClaudeAccounts(next)
await fetchSettings()
}
const formatAccountTimestamp = (timestamp: number): string => {
return new Date(timestamp).toLocaleString(undefined, {
month: 'short',
@ -269,6 +323,29 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
}
}
const runClaudeAccountAction = async (
action: typeof claudeAction,
operation: () => Promise<ClaudeRateLimitAccountsState>
): Promise<void> => {
const previousActiveAccountId = claudeAccounts.activeAccountId
setClaudeAction(action)
try {
const next = await operation()
await syncClaudeAccounts(next)
if (previousActiveAccountId !== next.activeAccountId || action === 'adding') {
toast.info('Claude account updated.', {
description: `${getClaudeAccountLabel(claudeAccounts, previousActiveAccountId)}${getClaudeAccountLabel(next, next.activeAccountId)}. Restart live Claude terminals before continuing old sessions.`
})
}
} catch (error) {
toast.error('Claude account update failed.', {
description: getClaudeAccountErrorDescription(error)
})
} finally {
setClaudeAction('idle')
}
}
const visibleSections = [
matchesSettingsSearch(searchQuery, GENERAL_WORKSPACE_SEARCH_ENTRIES) ? (
<section key="workspace" className="space-y-4">
@ -567,6 +644,168 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
)}
</section>
) : null,
matchesSettingsSearch(searchQuery, GENERAL_CLAUDE_ACCOUNTS_SEARCH_ENTRIES) ? (
<section key="claude-accounts" id="general-claude-accounts" className="space-y-4 scroll-mt-6">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Claude Accounts</h3>
<p className="text-xs text-muted-foreground">
Add and switch Claude Code accounts without moving chat sessions to account-specific
config directories.
</p>
</div>
<SearchableSetting
title="Claude Accounts"
description="Manage which Claude account Orca materializes into the shared Claude auth files."
keywords={['claude', 'account', 'rate limit', 'status bar', 'quota']}
className="space-y-3 px-1 py-2"
>
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<Label>Accounts</Label>
<p className="text-xs text-muted-foreground">
Orca swaps Claude auth only; config and chat history stay in the shared Claude root.
</p>
</div>
<Button
variant="outline"
size="xs"
onClick={() =>
void runClaudeAccountAction('adding', () => window.api.claudeAccounts.add())
}
disabled={claudeAction !== 'idle'}
className="gap-1.5"
>
{claudeAction === 'adding' ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Plus className="size-3" />
)}
Add Account
</Button>
</div>
<div className="space-y-2">
<button
type="button"
onClick={() =>
void runClaudeAccountAction('select:system', () =>
window.api.claudeAccounts.select({ accountId: null })
)
}
disabled={claudeAction !== 'idle'}
className={`flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2.5 text-left transition-colors ${
claudeAccounts.activeAccountId === null
? 'border-foreground/20 bg-accent/15'
: 'border-border/70 hover:border-border hover:bg-accent/8'
}`}
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">System default</span>
{claudeAccounts.activeAccountId === null ? (
<Badge
variant="outline"
className="h-4 shrink-0 rounded px-1.5 text-[10px] font-medium leading-none text-foreground/80"
>
Active
</Badge>
) : null}
</div>
<span className="truncate text-[11px] text-muted-foreground">
Use your current system Claude login.
</span>
</div>
</button>
{claudeAccounts.accounts.length === 0 ? (
<div className="rounded-md border border-dashed border-border/70 px-3 py-4 text-xs text-muted-foreground">
No managed Claude accounts yet. Orca will use your system default Claude login until
you add one here.
</div>
) : (
claudeAccounts.accounts.map((account) => {
const isActive = claudeAccounts.activeAccountId === account.id
const isReauthing = claudeAction === `reauth:${account.id}`
const isBusy = claudeAction !== 'idle'
return (
<button
key={account.id}
type="button"
onClick={() =>
void runClaudeAccountAction(`select:${account.id}`, () =>
window.api.claudeAccounts.select({ accountId: account.id })
)
}
disabled={isBusy}
className={`flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2.5 text-left transition-colors ${
isActive
? 'border-foreground/20 bg-accent/15'
: 'border-border/70 hover:border-border hover:bg-accent/8'
}`}
>
<div className="flex w-full items-center justify-between gap-3 max-md:flex-col max-md:items-start">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{account.email}</span>
{isActive ? (
<Badge
variant="outline"
className="h-4 shrink-0 rounded px-1.5 text-[10px] font-medium leading-none text-foreground/80"
>
Active
</Badge>
) : null}
</div>
<span className="truncate text-[11px] text-muted-foreground">
{account.organizationName
? `${account.organizationName} · ${formatAccountTimestamp(account.lastAuthenticatedAt)}`
: formatAccountTimestamp(account.lastAuthenticatedAt)}
</span>
</div>
<div className="flex shrink-0 items-center justify-end gap-1 max-md:w-full max-md:flex-wrap">
<Button
variant="ghost"
size="xs"
onClick={(event) => {
event.stopPropagation()
void runClaudeAccountAction(`reauth:${account.id}`, () =>
window.api.claudeAccounts.reauthenticate({ accountId: account.id })
)
}}
disabled={isBusy}
className="h-6 px-2 text-muted-foreground hover:text-foreground"
>
{isReauthing ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
Re-authenticate
</Button>
<Button
variant="ghost"
size="xs"
onClick={(event) => {
event.stopPropagation()
setRemoveClaudeAccountId(account.id)
}}
disabled={isBusy}
className="h-6 px-2 text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-3" />
Remove
</Button>
</div>
</div>
</button>
)
})
)}
</div>
</SearchableSetting>
</section>
) : null,
matchesSettingsSearch(searchQuery, GENERAL_CODEX_ACCOUNTS_SEARCH_ENTRIES) ? (
<section key="codex-accounts" id="general-codex-accounts" className="space-y-4 scroll-mt-6">
<div className="space-y-1">
@ -897,6 +1136,40 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={removeClaudeAccountId !== null}
onOpenChange={(open) => !open && setRemoveClaudeAccountId(null)}
>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Remove Claude Account?</DialogTitle>
<DialogDescription>
Orca will delete the managed Claude auth for this saved account. If it is currently
active, Orca falls back to the system default Claude login.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setRemoveClaudeAccountId(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
const accountId = removeClaudeAccountId
if (!accountId) {
return
}
setRemoveClaudeAccountId(null)
void runClaudeAccountAction(`remove:${accountId}`, () =>
window.api.claudeAccounts.remove({ accountId })
)
}}
>
Remove Account
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{visibleSections.map((section, index) => (
<div key={index} className="space-y-8">
{index > 0 ? <Separator /> : null}

View File

@ -65,6 +65,14 @@ export const GENERAL_CACHE_TIMER_SEARCH_ENTRIES: SettingsSearchEntry[] = [
}
]
export const GENERAL_CLAUDE_ACCOUNTS_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Claude Accounts',
description: 'Manage which Claude account Orca uses while preserving shared chat context.',
keywords: ['claude', 'account', 'switch', 'active', 'status bar', 'quota']
}
]
export const GENERAL_CODEX_ACCOUNTS_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Codex Accounts',
@ -82,7 +90,17 @@ export const GENERAL_AGENT_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Default Agent',
description: 'Pre-select an AI coding agent in the new-workspace composer.',
keywords: ['agent', 'default', 'claude', 'codex', 'opencode', 'pi', 'gemini', 'aider', 'copilot']
keywords: [
'agent',
'default',
'claude',
'codex',
'opencode',
'pi',
'gemini',
'aider',
'copilot'
]
}
]
@ -99,6 +117,7 @@ export const GENERAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
...GENERAL_EDITOR_SEARCH_ENTRIES,
...GENERAL_CLI_SEARCH_ENTRIES,
...GENERAL_CACHE_TIMER_SEARCH_ENTRIES,
...GENERAL_CLAUDE_ACCOUNTS_SEARCH_ENTRIES,
...GENERAL_CODEX_ACCOUNTS_SEARCH_ENTRIES,
...GENERAL_UPDATE_SEARCH_ENTRIES,
...GENERAL_SUPPORT_SEARCH_ENTRIES

View File

@ -14,7 +14,10 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useAppStore } from '../../store'
import type { CodexRateLimitAccountsState } from '../../../../shared/types'
import type {
ClaudeRateLimitAccountsState,
CodexRateLimitAccountsState
} from '../../../../shared/types'
import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types'
import { ProviderIcon, ProviderPanel } from './tooltip'
import { ClaudeIcon, OpenAIIcon } from './icons'
@ -33,6 +36,150 @@ function getCodexAccountLabel(
return state.accounts.find((account) => account.id === accountId)?.email ?? 'Codex account'
}
function ClaudeSwitcherMenu({
claude,
compact,
iconOnly
}: {
claude: ProviderRateLimits
compact: boolean
iconOnly: boolean
}): React.JSX.Element {
const [open, setOpen] = useState(false)
const [accountsExpanded, setAccountsExpanded] = useState(false)
const [accounts, setAccounts] = useState<ClaudeRateLimitAccountsState>({
accounts: [],
activeAccountId: null
})
const [isSwitching, setIsSwitching] = useState(false)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const fetchSettings = useAppStore((s) => s.fetchSettings)
const claudeAccountSyncKey = useAppStore((s) => {
const settings = s.settings
if (!settings) {
return 'no-settings'
}
return `${settings.activeClaudeManagedAccountId ?? 'system'}:${settings.claudeManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}`
})
const loadAccounts = useCallback(async () => {
const next = await window.api.claudeAccounts.list()
setAccounts(next)
}, [])
useEffect(() => {
void loadAccounts().catch((error) => {
console.error('Failed to load Claude accounts for status bar:', error)
})
}, [loadAccounts, open, claudeAccountSyncKey])
useEffect(() => {
if (!open) {
setAccountsExpanded(false)
}
}, [open])
const handleSelectAccount = async (accountId: string | null): Promise<void> => {
if (isSwitching) {
return
}
setIsSwitching(true)
try {
const next = await window.api.claudeAccounts.select({ accountId })
setAccounts(next)
await fetchSettings()
setAccountsExpanded(false)
} catch (error) {
console.error('Failed to switch Claude account from status bar:', error)
} finally {
setIsSwitching(false)
}
}
const activeAccountLabel =
accounts.activeAccountId === null
? 'System default'
: (accounts.accounts.find((account) => account.id === accounts.activeAccountId)?.email ??
'Managed')
const availableSwitchTargets = [
...(accounts.activeAccountId === null
? []
: [{ id: null as string | null, label: 'System default' }]),
...accounts.accounts
.filter((account) => account.id !== accounts.activeAccountId)
.map((account) => ({ id: account.id, label: account.email }))
]
return (
<ProviderDetailsMenu
provider={claude}
compact={compact}
iconOnly={iconOnly}
ariaLabel="Open Claude details and account switcher"
open={open}
onOpenChange={setOpen}
>
<DropdownMenuLabel>Claude Account</DropdownMenuLabel>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
setAccountsExpanded((prev) => !prev)
}}
>
<span className="max-w-[180px] truncate text-[12px] text-foreground">
{activeAccountLabel}
</span>
{accountsExpanded ? (
<ChevronDown className="ml-auto size-3.5 text-muted-foreground/85" />
) : (
<ChevronRight className="ml-auto size-3.5 text-muted-foreground/85" />
)}
</DropdownMenuItem>
{accountsExpanded ? (
<div className="px-1 pb-1">
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
Switch to
</div>
<div className="max-h-[220px] overflow-y-auto rounded-md border border-border/60 bg-accent/5 p-1">
{availableSwitchTargets.length === 0 ? (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">No other accounts</div>
) : null}
{availableSwitchTargets.map((target) => (
<DropdownMenuItem
key={target.id ?? 'system'}
disabled={isSwitching}
onSelect={(event) => {
event.preventDefault()
void handleSelectAccount(target.id)
}}
>
<span className="max-w-[220px] truncate">{target.label}</span>
</DropdownMenuItem>
))}
</div>
<div className="px-2 py-1.5 text-[10px] leading-4 text-muted-foreground">
Restart live Claude terminals before continuing old conversations after switching.
</div>
</div>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
openSettingsTarget({
pane: 'general',
repoId: null,
sectionId: 'general-claude-accounts'
})
openSettingsPage()
}}
>
Manage Accounts
</DropdownMenuItem>
</ProviderDetailsMenu>
)
}
// ---------------------------------------------------------------------------
// Mini progress bar (shows remaining capacity, grey)
// ---------------------------------------------------------------------------
@ -487,14 +634,7 @@ function StatusBarInner(): React.JSX.Element | null {
}}
>
<div className="flex items-center gap-3">
{showClaude && (
<ProviderDetailsMenu
provider={claude}
compact={compact}
iconOnly={iconOnly}
ariaLabel="Open Claude usage details"
/>
)}
{showClaude && <ClaudeSwitcherMenu claude={claude} compact={compact} iconOnly={iconOnly} />}
{showCodex && <CodexSwitcherMenu codex={codex} compact={compact} iconOnly={iconOnly} />}
{anyVisible && (
<Tooltip>

View File

@ -150,6 +150,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
promptCacheTtlMs: 300_000,
codexManagedAccounts: [],
activeCodexManagedAccountId: null,
claudeManagedAccounts: [],
activeClaudeManagedAccountId: null,
terminalScopeHistoryByWorktree: true,
defaultTuiAgent: null,
skipDeleteWorktreeConfirm: false,

View File

@ -653,6 +653,34 @@ export type CodexRateLimitAccountsState = {
activeAccountId: string | null
}
export type ClaudeManagedAccount = {
id: string
email: string
managedAuthPath: string
authMethod: 'subscription-oauth' | 'unknown'
organizationUuid?: string | null
organizationName?: string | null
createdAt: number
updatedAt: number
lastAuthenticatedAt: number
}
export type ClaudeManagedAccountSummary = {
id: string
email: string
authMethod: 'subscription-oauth' | 'unknown'
organizationUuid?: string | null
organizationName?: string | null
createdAt: number
updatedAt: number
lastAuthenticatedAt: number
}
export type ClaudeRateLimitAccountsState = {
accounts: ClaudeManagedAccountSummary[]
activeAccountId: string | null
}
/** All AI coding agents Orca knows how to launch. Used for the agent picker in the new-workspace
* flow and for the default-agent setting. Extend this union as new agents are added. */
export type TuiAgent =
@ -766,6 +794,11 @@ export type GlobalSettings = {
* analytics and external terminal sessions. */
codexManagedAccounts: CodexManagedAccount[]
activeCodexManagedAccountId: string | null
/** Why: Claude Code keeps conversations under one shared config root. Orca
* persists only per-account auth material here so switching accounts does
* not fork prior chat/session context the way CLAUDE_CONFIG_DIR swapping would. */
claudeManagedAccounts: ClaudeManagedAccount[]
activeClaudeManagedAccountId: string | null
/** When true, each worktree gets its own shell history file so ArrowUp
* does not surface commands from other worktrees. Defaults to true.
* Disable to revert to shared global shell history. */