Files
orca/src/main/runtime/runtime-account-controller.ts
T
Brennan Benson f812c67a05 feat(native-chat): Codex chats list and run the skills Codex loaded (#30937)
* feat(native-chat): Codex chats list and run the skills Codex loaded

A Codex chat's skill list now comes from Codex itself (`skills/list`), live
while the chat runs and refreshed when Codex says its skills changed, and
from a short-lived Codex check while the chat is at rest. Plugin skills keep
Codex's `plugin:skill` names, disabled skills drop out, and each skill file
is its own row. A skill picked from the list reaches Codex as that exact
skill, beside the unchanged `$name` text, on hosts that take it.

* test(native-chat): account for Codex's skill list call; send without a trailing argument

* fix: align skill probes with chat launches and preserve picked mentions

* fix: distinguish skill choices in phone accessibility labels

* fix: clear picked skill files when queued text changes

* fix(native-chat): refresh resting commands when launch settings change

* fix: keep WSL agent refresh on its execution host

* fix: preserve skill selection at read and send boundaries

* fix: narrow agent identity for selected skill notice

* fix: use checked agent predicate for skill notice label

* test: retain locale in composer translation mocks
2026-10-20 15:33:07 -07:00

295 lines
10 KiB
TypeScript

import type { ClaudeAccountService } from '../claude-accounts/service'
import { retireAtRestCommandContext } from '../native-chat/agent-session-wire/at-rest-command-context'
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.')
}
const previous = this.hostLaunchEnv
this.hostLaunchEnv = launchEnv
if (JSON.stringify(previous) !== JSON.stringify(launchEnv)) {
retireAtRestCommandContext()
}
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) {
const previous = this.hostLaunchEnv
this.hostLaunchEnv = saved
if (JSON.stringify(previous) !== JSON.stringify(saved)) {
retireAtRestCommandContext()
}
}
}
/** 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)
}
/** 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()
}
}
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
}
}