mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
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.
347 lines
12 KiB
TypeScript
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
|
|
)
|
|
}
|