mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +00:00
* fix(accounts): launches a WSL host starts itself use the selected account The desktop now pushes the account env it selected for a WSL host's distro to that host's orcad (accounts.setHostLaunchEnv, capability-gated). orcad lays it over every terminal create (CLI, orchestration, automations, bare commands and presets), structured chats and AI commit text, as the desktop's own launches into the distro do. `orca account` on the host now points at the desktop that owns the accounts. * fix(accounts): cover splits, child agents and restarts; skip SSH workspaces Review follow-ups: terminal splits and structured chats' child agents take the host account env too; an SSH workspace's terminals never get distro paths; orcad saves the pushed env so a launch before the desktop reconnects keeps the account; a failed push retries unless a newer one supersedes it. * fix(accounts): retry a host account push when the host's capabilities are unknown * test(orcad): the startup mock restores host account env * refactor(accounts): serialize host account pushes with the shared keyed queue
276 lines
9.4 KiB
TypeScript
276 lines
9.4 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 { 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 type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection'
|
|
import {
|
|
hostAccountLaunchEnvFor,
|
|
hostAccountsManagedByDesktopMessage,
|
|
type HostAccountLaunchEnv
|
|
} from '../../shared/host-account-launch-env'
|
|
|
|
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
|
|
}
|
|
)
|
|
|
|
export class RuntimeAccountController {
|
|
private services: RuntimeAccountServices | null = null
|
|
private commitMessageAgentEnvironment: CommitMessageAgentEnvironmentResolvers | null = null
|
|
/** Pushed by the desktop that owns this host's accounts; held in memory, re-pushed on connect. */
|
|
private hostLaunchEnv: HostAccountLaunchEnv | null = null
|
|
private persistHostLaunchEnv: ((launchEnv: HostAccountLaunchEnv) => void) | null = null
|
|
|
|
setServices(services: RuntimeAccountServices): void {
|
|
this.services = services
|
|
}
|
|
|
|
setCommitMessageAgentEnvironment(resolvers: CommitMessageAgentEnvironmentResolvers): void {
|
|
this.commitMessageAgentEnvironment = resolvers
|
|
}
|
|
|
|
getCommitMessageAgentEnvironment(): CommitMessageAgentEnvironmentResolvers | undefined {
|
|
if (this.commitMessageAgentEnvironment) {
|
|
return this.commitMessageAgentEnvironment
|
|
}
|
|
const hostLaunchEnv = this.hostLaunchEnv
|
|
if (!hostLaunchEnv) {
|
|
return undefined
|
|
}
|
|
return {
|
|
prepareForCodexLaunch: () => hostLaunchEnv.terminal.CODEX_HOME ?? null,
|
|
prepareForClaudeLaunch: async () => ({
|
|
configDir: hostLaunchEnv.claude.CLAUDE_CONFIG_DIR ?? '',
|
|
envPatch: { ...hostLaunchEnv.claude },
|
|
provenance: 'desktop-host-launch-env'
|
|
})
|
|
}
|
|
}
|
|
|
|
/** Refused where this host has account services of its own: they already decide every launch. */
|
|
setHostLaunchEnv(launchEnv: HostAccountLaunchEnv): void {
|
|
if (this.services) {
|
|
throw new Error('This host manages its own provider accounts.')
|
|
}
|
|
this.hostLaunchEnv = launchEnv
|
|
try {
|
|
this.persistHostLaunchEnv?.(launchEnv)
|
|
} catch (error) {
|
|
// Why not refuse: the env already applies; only a restart before the next push loses it.
|
|
console.warn('[accounts] Could not save the host account env:', error)
|
|
}
|
|
}
|
|
|
|
/** orcad's store: starts from the last pushed env, so launches before a reconnect keep it. */
|
|
restoreHostLaunchEnv(
|
|
saved: HostAccountLaunchEnv | null,
|
|
persist: (launchEnv: HostAccountLaunchEnv) => void
|
|
): void {
|
|
this.persistHostLaunchEnv = persist
|
|
if (saved && !this.services) {
|
|
this.hostLaunchEnv = saved
|
|
}
|
|
}
|
|
|
|
/** The desktop-selected account env for a process `agent` starts here; empty when none. */
|
|
hostLaunchEnvFor(agent: string | null | undefined): Readonly<Record<string, string>> {
|
|
return hostAccountLaunchEnvFor(this.hostLaunchEnv, agent)
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
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()
|
|
])
|
|
}
|
|
|
|
selectClaude(accountId: string | null): Promise<ClaudeRateLimitAccountsState> {
|
|
return this.requireServices().claudeAccounts.selectAccount(accountId)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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.getSnapshot()))
|
|
: () => {}
|
|
const unsubscribeUsage = services.rateLimits.onStateChange((rateLimits) => {
|
|
listener({
|
|
...this.dataAccountsSnapshot(),
|
|
claude: services.claudeAccounts.listAccounts(),
|
|
codex: services.codexAccounts.listAccounts(),
|
|
rateLimits
|
|
})
|
|
})
|
|
return () => {
|
|
unsubscribeData()
|
|
unsubscribeUsage()
|
|
}
|
|
}
|
|
|
|
private requireServices(): RuntimeAccountServices {
|
|
if (!this.services) {
|
|
throw new Error(
|
|
this.hostLaunchEnv
|
|
? hostAccountsManagedByDesktopMessage(this.hostLaunchEnv.wslDistro)
|
|
: 'Account services are not configured on this runtime'
|
|
)
|
|
}
|
|
return this.services
|
|
}
|
|
}
|