Files
orca/src/main/runtime/runtime-account-controller.ts
T
Kelvin Amoaba 92fc8bc26d fix(accounts): restart running Claude sessions when the account is switched from the phone (#36933)
A switch over runtime RPC (the phone's Accounts screen) changed the
selection but never reached the window's running-session handling, which
only the status bar and Settings called after their own IPC switch. The
RPC path now reports the lane and outgoing/incoming account to the main
window, which runs the same handler. The web client handles its own
switches and opts out.
2026-11-01 08:00:08 +00:00

347 lines
12 KiB
TypeScript

import type { ClaudeAccountService } from '../claude-accounts/service'
import { hasAppEnvironment } from '../../shared/app-environment'
import { getManagedDataAccountService } from '../managed-data-accounts/service'
import type {
ClaudeAccountSignIn,
ClaudeRateLimitAccountsState,
ClaudeSignInRequest,
CodexRateLimitAccountsState,
ManagedDataAccountProvider,
ManagedDataAccountsState
} from '../../shared/managed-account-types'
import type {
CodexAccountService,
CodexResetCreditRejectedBeforeProviderReason
} from '../codex-accounts/service'
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
import type { RateLimitService } from '../rate-limits/service'
import type {
HostAccountSignInChannel,
HostAccountSignInProvider,
HostAccountSignInStatus
} from '../../shared/host-account-sign-in-types'
import type { CodexRateLimitResetOutcome, RateLimitState } from '../../shared/rate-limit-types'
import type { CodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope'
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
import {
getClaudeSelectionTargetForAccount,
getSelectedClaudeAccountIdForTarget,
type ClaudeAccountSelectionTarget
} from '../claude-accounts/runtime-selection'
import {
hostAccountsManagedByDesktopMessage,
type HostAccountLaunchEnv
} from '../../shared/host-account-launch-env'
import type { HostClaudeProfileRefresh } from './host-claude-profile-refresh'
import { HostAccountLaunchEnvState } from './host-account-launch-env-state'
import type { RuntimeNotifier } from './runtime-notifier-contract'
export type RuntimeAccountServices = {
claudeAccounts: ClaudeAccountService
codexAccounts: CodexAccountService
rateLimits: RateLimitService
}
export type AccountsSnapshot = {
opencode?: ManagedDataAccountsState
devin?: ManagedDataAccountsState
claude: ClaudeRateLimitAccountsState
codex: CodexRateLimitAccountsState
rateLimits: RateLimitState
}
export type CodexRateLimitResetRpcResult = {
scope: CodexResetCreditExpectedScope
snapshot: AccountsSnapshot
} & (
| { outcome: CodexRateLimitResetOutcome }
| {
status: 'rejectedBeforeProvider'
retryDisposition: 'discardAttempt'
reason: CodexResetCreditRejectedBeforeProviderReason
}
)
type ClaudeSelectionNotifier = Pick<RuntimeNotifier, 'claudeAccountSelected'>
export class RuntimeAccountController {
private services: RuntimeAccountServices | null = null
private commitMessageAgentEnvironment: CommitMessageAgentEnvironmentResolvers | null = null
private readonly hostAccounts = new HostAccountLaunchEnvState(() => this.services !== null)
constructor(private readonly getNotifier: () => ClaudeSelectionNotifier | null = () => null) {}
setServices(services: RuntimeAccountServices): void {
this.services = services
}
setCommitMessageAgentEnvironment(resolvers: CommitMessageAgentEnvironmentResolvers): void {
this.commitMessageAgentEnvironment = resolvers
}
getCommitMessageAgentEnvironment(): CommitMessageAgentEnvironmentResolvers | undefined {
return this.commitMessageAgentEnvironment ?? this.hostAccounts.commitMessageAgentEnvironment()
}
/** Refused where this host has account services of its own: they already decide every launch. */
setHostLaunchEnv(launchEnv: HostAccountLaunchEnv): void {
this.hostAccounts.setHostLaunchEnv(launchEnv)
}
/** orcad's store: starts from the last pushed env, so launches before a reconnect keep it. */
restoreHostLaunchEnv(
saved: HostAccountLaunchEnv | null,
persist: (launchEnv: HostAccountLaunchEnv) => void,
options: { expectDesktopPush?: boolean; refreshClaudeProfile?: HostClaudeProfileRefresh } = {}
): void {
this.hostAccounts.restoreHostLaunchEnv(saved, persist, options)
}
/** Before a launch for `agent` this host starts: the first account push, then a Claude refresh. */
prepareHostAgentLaunch(agent: string | null | undefined): Promise<void> {
return this.hostAccounts.prepareHostAgentLaunch(agent)
}
/** The desktop-selected account env for a process `agent` starts here; empty when none. */
hostLaunchEnvFor(agent: string | null | undefined): Readonly<Record<string, string>> {
return this.hostAccounts.hostLaunchEnvFor(agent)
}
/** Null where the desktop that owns this host's accounts polls usage instead (a WSL host). */
usageRateLimits(): RateLimitService | null {
return this.services?.rateLimits ?? null
}
/** Refuses where no poller would read a saved key, so a save is never reported for nothing. */
requireUsageRateLimits(): RateLimitService {
return this.requireServices().rateLimits
}
getClaudeConfigDirectory(target: ClaudeAccountSelectionTarget): string | null {
return this.services?.claudeAccounts.getRuntimeConfigDir(target) ?? null
}
getSnapshot(): AccountsSnapshot {
const { claudeAccounts, codexAccounts, rateLimits } = this.requireServices()
return {
...this.dataAccountsSnapshot(),
claude: claudeAccounts.listAccounts(),
codex: codexAccounts.listAccounts(),
rateLimits: rateLimits.getState()
}
}
getStreamSnapshot(): AccountsSnapshot {
const { claudeAccounts, codexAccounts, rateLimits } = this.requireServices()
return {
...this.streamDataAccountsSnapshot(),
claude: claudeAccounts.listAccounts(),
codex: codexAccounts.listAccounts(),
rateLimits: rateLimits.getState()
}
}
private streamDataAccountsSnapshot(): Pick<AccountsSnapshot, 'opencode' | 'devin'> {
const snapshot: Pick<AccountsSnapshot, 'opencode' | 'devin'> = {}
if (hasAppEnvironment()) {
const service = getManagedDataAccountService()
for (const provider of ['opencode', 'devin'] as const) {
try {
snapshot[provider] = service.list(provider)
} catch {
// Optional unavailable evidence must not swallow another provider's committed change.
}
}
}
return snapshot
}
dataAccountsSnapshot(): Pick<AccountsSnapshot, 'opencode' | 'devin'> {
if (!hasAppEnvironment()) {
return {}
}
const service = getManagedDataAccountService()
return { opencode: service.list('opencode'), devin: service.list('devin') }
}
addDataFromHome(
provider: ManagedDataAccountProvider,
sourceDataHome: string,
label: string
): Promise<ManagedDataAccountsState> {
return getManagedDataAccountService().add(provider, sourceDataHome, label)
}
selectData(
provider: ManagedDataAccountProvider,
accountId: string | null
): Promise<ManagedDataAccountsState> {
return getManagedDataAccountService().select(provider, accountId)
}
removeData(
provider: ManagedDataAccountProvider,
accountId: string
): Promise<ManagedDataAccountsState> {
return getManagedDataAccountService().remove(provider, accountId)
}
async refreshForMobile(): Promise<void> {
const { rateLimits } = this.requireServices()
await Promise.allSettled([
rateLimits.refresh(),
rateLimits.fetchInactiveClaudeAccountsOnOpen(),
rateLimits.fetchInactiveCodexAccountsOnOpen()
])
}
async refreshForMobileSubscriber(): Promise<void> {
const { rateLimits } = this.requireServices()
await Promise.allSettled([
rateLimits.refreshIfStale(),
rateLimits.fetchInactiveClaudeAccountsOnOpen(),
rateLimits.fetchInactiveCodexAccountsOnOpen()
])
}
/** Desktop switches go through IPC and handle their own sessions; this path tells the window. */
async selectClaude(
accountId: string | null,
options: { movesRunningSessions?: true } = {}
): Promise<ClaudeRateLimitAccountsState> {
const { claudeAccounts } = this.requireServices()
if (options.movesRunningSessions) {
return claudeAccounts.selectAccount(accountId)
}
const before = claudeAccounts.listAccounts()
const after = await claudeAccounts.selectAccount(accountId)
const account = before.accounts.find((candidate) => candidate.id === accountId)
const target: ClaudeAccountSelectionTarget = account
? getClaudeSelectionTargetForAccount(account)
: { runtime: 'host' }
this.getNotifier()?.claudeAccountSelected?.({
target,
before: { state: before, accountId: selectedClaudeAccountId(before, target) },
after: { state: after, accountId: selectedClaudeAccountId(after, target) }
})
return after
}
selectCodex(accountId: string | null): Promise<CodexRateLimitAccountsState> {
return this.requireServices().codexAccounts.selectAccount(accountId)
}
selectCodexForTarget(
accountId: string | null,
target: CodexAccountSelectionTarget
): Promise<CodexRateLimitAccountsState> {
return this.requireServices().codexAccounts.selectAccountForTarget(accountId, target)
}
async consumeCodexResetCredit(
idempotencyKey: string,
expectedScope: CodexResetCreditExpectedScope
): Promise<CodexRateLimitResetRpcResult> {
const { claudeAccounts, codexAccounts } = this.requireServices()
const result = await codexAccounts.consumeRateLimitResetCredit(idempotencyKey, expectedScope)
const snapshot = {
claude: claudeAccounts.listAccounts(),
codex: result.codex,
rateLimits: result.rateLimits
}
if ('status' in result) {
return {
status: result.status,
retryDisposition: result.retryDisposition,
reason: result.reason,
scope: result.scope,
snapshot
}
}
return { outcome: result.outcome, scope: result.scope, snapshot }
}
removeClaude(accountId: string): Promise<ClaudeRateLimitAccountsState> {
return this.requireServices().claudeAccounts.removeAccount(accountId)
}
beginClaudeSignIn(request: ClaudeSignInRequest): Promise<ClaudeAccountSignIn> {
return this.requireServices().claudeAccounts.beginSignIn(request)
}
finishClaudeSignIn(
signIn: Omit<ClaudeAccountSignIn, 'configDir'>
): Promise<ClaudeRateLimitAccountsState> {
return this.requireServices().claudeAccounts.finishSignIn(signIn)
}
cancelClaudeSignIn(signIn: Omit<ClaudeAccountSignIn, 'configDir'>): Promise<void> {
return this.requireServices().claudeAccounts.cancelSignIn(signIn)
}
/** Adds a host account whose login a client on another device finishes. */
async runHostSignIn(
provider: HostAccountSignInProvider,
channel: HostAccountSignInChannel
): Promise<Pick<HostAccountSignInStatus, 'claude' | 'codex'>> {
const { claudeAccounts, codexAccounts } = this.requireServices()
return provider === 'claude'
? { claude: await claudeAccounts.addAccountWithPastedCode(channel) }
: { codex: await codexAccounts.addAccountWithDeviceCode(channel) }
}
removeCodex(accountId: string): Promise<CodexRateLimitAccountsState> {
return this.requireServices().codexAccounts.removeAccount(accountId)
}
addCodexFromHome(
sourceHome: string,
target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }
): Promise<CodexRateLimitAccountsState> {
return this.requireServices().codexAccounts.addAccountFromHome(sourceHome, target)
}
onChanged(listener: (snapshot: AccountsSnapshot) => void): () => void {
const services = this.requireServices()
const unsubscribeData = hasAppEnvironment()
? getManagedDataAccountService().onChanged(() => listener(this.getStreamSnapshot()))
: () => {}
const unsubscribeUsage = services.rateLimits.onStateChange((rateLimits) => {
listener({
...this.streamDataAccountsSnapshot(),
claude: services.claudeAccounts.listAccounts(),
codex: services.codexAccounts.listAccounts(),
rateLimits
})
})
return () => {
unsubscribeData()
unsubscribeUsage()
}
}
private requireServices(): RuntimeAccountServices {
if (!this.services) {
throw new Error(
this.hostAccounts.current
? hostAccountsManagedByDesktopMessage(this.hostAccounts.current.wslDistro)
: 'Account services are not configured on this runtime'
)
}
return this.services
}
}
function selectedClaudeAccountId(
state: ClaudeRateLimitAccountsState,
target: ClaudeAccountSelectionTarget
): string | null {
return getSelectedClaudeAccountIdForTarget(
{
activeClaudeManagedAccountId: state.activeAccountId,
activeClaudeManagedAccountIdsByRuntime: state.activeAccountIdsByRuntime
},
target
)
}