fix(native-chat): move open Codex chats to the account you switch to (#40148)

A Codex chat launched codex app-server on the account home saved when the
chat was created, and nothing compared it with the selection, so after a
switch every message kept going to the old account. A chat that had hit
its usage limit kept failing with "Codex hit its usage limit" even though
the new account had quota.

Codex chats now follow the selection the way Claude chats do: the launch
prepares the selected account's home, the started process reports that
home, and the host replaces an idle chat whose account no longer matches
before its next message. Codex resumes a thread only from a rollout in its
own CODEX_HOME, so the launch links the thread's rollout into the new home
first. The chat's usage card reads the account its next turn runs on.

(cherry picked from commit 80a9ed69f0)
This commit is contained in:
Kelvin Amoaba
2026-11-08 21:17:30 -08:00
committed by scan
parent 2a06f6abf9
commit 86f5ea3781
16 changed files with 207 additions and 21 deletions
@@ -14,11 +14,11 @@ Accounts are optional: without any, Orca uses your normal Codex login (**System
## Swap accounts
Click the Codex chip in the status bar to open the account switcher, or pick an account in Settings. Any new Codex session launched after that uses it. Sessions already running keep their original account until restarted.
Click the Codex chip in the status bar to open the account switcher, or pick an account in Settings. Any new Codex session launched after that uses it, and open Codex chats move to it before their next message (a reply already running finishes first). Terminal sessions already running keep their original account until restarted.
## Running accounts side by side
Because each added account has its own isolated home, two Codex sessions can run under different accounts at the same time: start one, switch accounts, start the next. Each session keeps the account it launched with. Usage still counts against each account's own plan; Orca does not pool quota across accounts.
Because each added account has its own isolated home, two Codex terminal sessions can run under different accounts at the same time: start one, switch accounts, start the next. Each terminal session keeps the account it launched with; chats always follow the selected account. Usage still counts against each account's own plan; Orca does not pool quota across accounts.
## System default
@@ -31,7 +31,7 @@ For managed Codex accounts, Orca mirrors settings from your real `~/.codex/confi
## Rules & gotchas
- Swapping does not re-authenticate; Orca launches new sessions in the selected account's home.
- Existing Codex processes keep their current account until restart.
- Existing Codex terminal processes keep their current account until restart. Chats switch on their next message.
- Usage readouts in the status bar follow the currently-active account.
- The restart chip preserves the active account at the time of restart.
- Accounts belong to the machine that runs the agent. On a [Remote Orca Server](/docs/remote-servers), add them on that server.
+1 -1
View File
@@ -20,7 +20,7 @@ Many Codex users run multiple accounts to stretch rate limits. Orca's account sw
## System default vs extra accounts
**System default** uses your real `~/.codex` login (the same home a bare `codex` outside Orca would use). Extra accounts Orca manages get their own home under Orca's account data so credentials and rollouts stay isolated. New Codex launches follow the active account; already-running sessions keep the home they started with until you restart them.
**System default** uses your real `~/.codex` login (the same home a bare `codex` outside Orca would use). Extra accounts Orca manages get their own home under Orca's account data so credentials and rollouts stay isolated. New Codex launches follow the active account. Open Codex chats move to it before their next message; already-running terminal sessions keep the home they started with until you restart them.
## Wrapper scripts that set `CODEX_HOME`
@@ -0,0 +1,39 @@
import { realpath } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { resolveSessionFilePath } from '../../../native-chat/session-file-resolver'
import { getSystemCodexHomePath } from '../../configuration/homes/codex-home-paths'
import { prepareCodexAccountRestartResume } from './codex-legacy-session-resume'
/** Codex resumes a thread only from a rollout in its own CODEX_HOME, and the background history
* bridge may not have linked it yet, so a chat moving accounts brings its rollout along first. */
export async function moveCodexThreadIntoAccountHome(args: {
threadId: string
/** Where the chat last ran; searched before every other known Codex home. */
fromHome: string
toHome: string
}): Promise<void> {
const inTarget = await resolveSessionFilePath('codex', args.threadId, {
codexSessionsDirs: [join(args.toHome, 'sessions')],
strictErrors: true
})
if (inTarget) {
return
}
const found =
(await resolveSessionFilePath('codex', args.threadId, {
codexSessionsDirs: [join(args.fromHome, 'sessions')]
})) ?? (await resolveSessionFilePath('codex', args.threadId))
if (!found) {
// Never saved; Codex's own resume refusal decides what happens next.
return
}
// A bridged symlink names its source; the file itself is what gets linked or copied.
const transcriptPath = await realpath(found)
await prepareCodexAccountRestartResume({
// <home>/sessions/<yyyy>/<mm>/<dd>/rollout-*.jsonl
sourceHome: resolve(transcriptPath, '../../../../..'),
transcriptPath,
targetHome: args.toHome,
systemCodexHomePath: getSystemCodexHomePath()
})
}
@@ -1,4 +1,5 @@
import type { AgentSessionAccountKind } from '../../../../shared/agent-session/agent-session-availability'
import { codexLaunchedAccountHome } from '../launch/codex-structured-launch-resolution'
import {
CODEX_STRUCTURED_HANDLE_NAMESPACE,
isAgentSessionProviderHandleInNamespace
@@ -279,7 +280,7 @@ export async function acquireCodexStructuredSession(input: {
},
openInput
)
return { process, acquisitionGeneration }
return { process, acquisitionGeneration, ...codexLaunchedAccountHome(launch) }
} catch (error) {
acquisitions.endings.retire(sessionId, acquisitionGeneration)
if (sessions.get(sessionId)?.connection !== acquisition.connection) {
@@ -1,3 +1,6 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { agentSessionRecordFixture } from '../../../native-chat/agent-session-record-test-fixture'
import { createCodexStructuredLaunchResolver } from '../../../runtime/agent-session/launch/structured-agent-launch-composition.test-support'
import { describe, expect, it, vi } from 'vitest'
@@ -128,4 +131,61 @@ describe('codex structured launch resolution', () => {
})({ identity: IDENTITY })
).rejects.toThrow('workspace-1 is gone')
})
it('resumes a chat on the account selected after it was created, bringing its thread along', async () => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-chat-account-move-'))
vi.stubEnv('ORCA_USER_DATA_PATH', join(root, 'user-data'))
try {
const threadId = '019a1a83-f690-7c82-a482-31195524a7d8'
const rollout = join(
'sessions',
'2026',
'11',
'04',
`rollout-2026-11-04T20-28-46-${threadId}.jsonl`
)
const oldHome = join(root, 'account-a')
const newHome = join(root, 'account-b')
mkdirSync(join(oldHome, 'sessions', '2026', '11', '04'), { recursive: true })
writeFileSync(join(oldHome, rollout), '{"type":"session_meta"}\n')
const prepareLaunchAccountHome = vi.fn(async () => ({
variable: 'CODEX_HOME' as const,
path: newHome
}))
const launch = await createCodexStructuredLaunchResolver({
store: {
getRecord: () =>
record({
accountHome: { variable: 'CODEX_HOME', path: oldHome },
providerHandleChain: [
{
linkId: 'link-1',
handle: codexProviderHandle(threadId),
origin: 'created',
mintedAtFence: 1,
observedAt: 1
}
]
}),
pinLaunchDirectory: vi.fn()
},
resolveWorkspacePath: async (id) => `/repos/${id}`,
resolveCommand: () => ({ command: '/usr/local/bin/codex', prefixArgs: [] }),
resolveLaunchArgs: () => [],
prepareLaunchAccountHome
})({ identity: IDENTITY })
expect(prepareLaunchAccountHome).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: SESSION_ID }),
'/repos/workspace-1'
)
expect(launch.codexHome).toBe(newHome)
expect(launch.env.CODEX_HOME).toBe(newHome)
expect(launch.resumeThreadId).toBe(threadId)
expect(readFileSync(join(newHome, rollout), 'utf8')).toBe('{"type":"session_meta"}\n')
} finally {
vi.unstubAllEnvs()
rmSync(root, { recursive: true, force: true })
}
})
})
@@ -8,15 +8,23 @@ import {
} from '../../../native-chat/structured-agent-command-resolution'
// How a durable session record becomes a Codex process launch.
//
// Every input is read back from the record the store already made durable, not
// from the call that triggered spawn. A client that attaches twice must
// land in the same working directory under the same account home, and a resume
// must name the thread this session actually proved — never one a caller asks
// for, which is how a resume becomes a fork wearing a resume's name.
// Every input except the account is read back from the record the store already made durable,
// not from the call that triggered spawn. A client that attaches twice must land in the same
// working directory, and a resume must name the thread this session actually proved — never one
// a caller asks for, which is how a resume becomes a fork wearing a resume's name. The account
// follows the selection, as Claude chats do.
import { requireLegacyAgentSessionAccountHome } from '../../../../shared/agent-session/lifecycle/agent-session-account-home'
import {
agentSessionAccountHome,
agentSessionAccountHomesEqual,
requireLegacyAgentSessionAccountHome,
type AgentSessionAccountHome
} from '../../../../shared/agent-session/lifecycle/agent-session-account-home'
import type { AgentSessionRecord } from '../../../../shared/agent-session/lifecycle/agent-session-record'
import { moveCodexThreadIntoAccountHome } from '../../history/resume/codex-thread-account-move'
import { agentSessionProviderHandleChainHead } from '../../../../shared/agent-session/lifecycle/agent-session-provider-handle'
import { resolveCodexCommand } from '../../../codex-cli/command'
import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition'
import type { StructuredAgentLaunchPartResolver } from '../../../runtime/agent-session/launch/structured-agent-launch-composition'
import type { CodexStructuredLaunch } from '../session-lifecycle/codex-structured-session-state'
import { codexStructuredPermissionPolicy } from '../../../../shared/codex-permission-policy'
@@ -29,6 +37,11 @@ import {
export type CodexStructuredLaunchPartDeps = {
resolveLaunchArgs: () => Promise<string[]> | string[]
/** The selected account's home, prepared for this start in `launchDirectory`; null keeps the record's. */
prepareLaunchAccountHome?: (
record: AgentSessionRecord,
launchDirectory: string
) => Promise<AgentSessionAccountHome | null>
resolveCommand?: (options?: {
pathEnv?: string | null
homePath?: string
@@ -98,13 +111,22 @@ async function readCodexStructuredInvocation(
return { ...invocation, environment }
}
/** Derive resume identity and account home from the durable record. */
/** The host compares this with the selection to move an idle chat to a newly picked account. */
export function codexLaunchedAccountHome(launch: Pick<CodexStructuredLaunch, 'codexHome'>): {
accountHome?: AgentSessionAccountHome
} {
return launch.codexHome
? { accountHome: agentSessionAccountHome(CODEX_STRUCTURED_AGENT, launch.codexHome) }
: {}
}
/** Derive resume identity from the durable record and the account from the selection. */
export function codexStructuredLaunchPart(
deps: CodexStructuredLaunchPartDeps
): StructuredAgentLaunchPartResolver<CodexStructuredLaunch> {
return async (basis) => {
const { record } = basis
const accountHome = requireLegacyAgentSessionAccountHome(record.accountHome)
const recordHome = requireLegacyAgentSessionAccountHome(record.accountHome)
const { command, prefixArgs } = await resolveCodexStructuredInvocation({
...(deps.resolveCommand ? { resolveCommand: deps.resolveCommand } : {}),
refreshEnvironmentAfterMiss: basis.refreshAfterMissingProgram,
@@ -118,7 +140,22 @@ export function codexStructuredLaunchPart(
// Match the first turn's model to avoid duplicate prompt injection on a model switch.
const model = record.options?.model
const visuals = await basis.visuals()
await basis.launchDirectory()
const launchDirectory = await basis.launchDirectory()
const selectedHome = await deps.prepareLaunchAccountHome?.(record, launchDirectory)
const accountHome = selectedHome
? requireLegacyAgentSessionAccountHome(selectedHome)
: recordHome
if (
resumeThreadId &&
selectedHome &&
!agentSessionAccountHomesEqual(selectedHome, record.accountHome)
) {
await moveCodexThreadIntoAccountHome({
threadId: resumeThreadId,
fromHome: recordHome.path,
toHome: accountHome.path
})
}
return {
launch: {
command,
@@ -1,5 +1,6 @@
import { requireLegacyAgentSessionAccountHome } from '../../../../shared/agent-session/lifecycle/agent-session-account-home'
import type { CodexAppServerRpc } from '../../app-server/codex-app-server-session'
import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path'
import {
resolveCodexHomeProcessLockKey,
withCodexHomeProcessLock
@@ -34,15 +35,23 @@ async function readUsage(
return { account, rateLimits: mapCodexRpcRateLimits((limits ?? {}) as RpcRateLimitsResponse) }
}
/** Live chats read on their own connection; a chat at rest reads its pinned home through a
* short-lived app-server launched exactly as the chat is, without resuming the thread. */
/** A chat running on `record.accountHome` reads on its own connection; otherwise that home is read
* through a short-lived app-server launched as the chat is, without resuming the thread. */
export function readCodexSessionAccountUsage(
sessions: Map<string, CodexSession>,
readInvocation: () => CodexProviderInvocationDeps | undefined
): NonNullable<StructuredAgentSessionAdapter['readAccountUsage']> {
return async (record, signal) => {
const connection = sessions.get(record.sessionId)?.connection
if (connection && !connection.closed) {
const codexHome = requireLegacyAgentSessionAccountHome(record.accountHome).path
const live = sessions.get(record.sessionId)
const connection = live?.connection
if (
connection &&
!connection.closed &&
live.codexHome &&
normalizeRuntimePathForComparison(live.codexHome) ===
normalizeRuntimePathForComparison(codexHome)
) {
const usage = await readUsage((method, params, options) =>
connection.request(method, params, options)
)
@@ -52,7 +61,6 @@ export function readCodexSessionAccountUsage(
if (!invocation) {
throw new Error('Codex usage invocation is not configured')
}
const codexHome = requireLegacyAgentSessionAccountHome(record.accountHome).path
return withCodexHomeProcessLock(resolveCodexHomeProcessLockKey(codexHome), async () => {
signal.throwIfAborted()
return runCodexProviderAppServer(
@@ -160,6 +160,7 @@ export async function openCodexStructuredSession(
const session: CodexSession = {
events: input.events,
account: start.account,
codexHome: launch.codexHome,
connection,
...codexSessionLifecycle(input.fence, generation),
threadId: opened.threadId,
@@ -136,6 +136,8 @@ export type CodexStructuredSessionAdapterDeps = {
export type CodexSession = {
events?: StructuredAgentSessionEventSink
account?: AgentSessionAccountKind
/** CODEX_HOME this app-server launched under. */
codexHome?: string | null
connection: CodexAppServerConnection
ended: boolean
/** First observed child exit survives rejected settlement admission. */
@@ -126,6 +126,14 @@ export abstract class RuntimeStructuredAgentHost extends OrcaRuntimeWithStartTui
OrcaRuntimeWithGetStructuredAgentSessionCreateSupport['resolveStructuredAgentLaunchAccountHome']
>
abstract prepareStructuredAgentLaunchAccountHome(
...args: Parameters<
OrcaRuntimeWithGetStructuredAgentSessionCreateSupport['prepareStructuredAgentLaunchAccountHome']
>
): ReturnType<
OrcaRuntimeWithGetStructuredAgentSessionCreateSupport['prepareStructuredAgentLaunchAccountHome']
>
protected abstract assertStructuredChatAcquireAdmissible(
record: AgentSessionRecord
): Promise<void>
@@ -148,6 +148,7 @@ export const STRUCTURED_AGENT_RUNTIME_CATALOG: readonly StructuredAgentRuntimeCa
modelCatalog: codexModelCatalogDiscovery,
...CODEX_CLI_CONVERSATIONS,
supportsLocation: (location) => supportsCodexStructuredLocation(location),
launchFollowsSelection: () => true,
resolveAccountHome: async (request, services) =>
agentSessionAccountHome(
CODEX_STRUCTURED_AGENT,
@@ -64,6 +64,9 @@ function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredA
structuredAgentLaunchSources(context),
codexStructuredLaunchPart({
resolveLaunchArgs: () => deps.resolveLaunchArgs('codex'),
...(deps.prepareLaunchAccountHome
? { prepareLaunchAccountHome: deps.prepareLaunchAccountHome }
: {}),
...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {})
})
),
@@ -2,6 +2,7 @@ import type { StructuredAgentSessionHostDeps } from '../../../native-chat/agent-
import type { AgentSessionAccountKind } from '../../../../shared/agent-session/agent-session-availability'
import type { StructuredAgentSessionTransportOverrides } from '../structured-agent-session-transport-overrides'
import type { AgentSessionRecord } from '../../../../shared/agent-session/lifecycle/agent-session-record'
import type { AgentSessionAccountHome } from '../../../../shared/agent-session/lifecycle/agent-session-account-home'
import type { StructuredAttentionMobileDelivery } from '../structured-agent-session-mobile-attention'
import type {
StructuredAgentCommand,
@@ -86,4 +87,9 @@ export type StructuredAgentSessionRuntimeDeps = StructuredAgentSessionTransportO
resolveAgentAccountHome?: RuntimeAgentAccountHomeResolver
/** See `StructuredAgentSessionHostDeps.launchAccountHome`. */
launchAccountHome?: StructuredAgentSessionHostDeps['launchAccountHome']
/** That account home, prepared for a start of `record` in `launchDirectory`. */
prepareLaunchAccountHome?: (
record: AgentSessionRecord,
launchDirectory: string
) => Promise<AgentSessionAccountHome | null>
} & Pick<StructuredAgentSessionHostDeps, 'onRequestFailure' | 'statusSink'>
@@ -82,10 +82,13 @@ export const STRUCTURED_AGENT_SESSION_ACCOUNT_USAGE_METHODS = [
params: OptionsParams,
handler: async ({ sessionId }, ctx): Promise<AgentSessionAccountUsage> => {
const host = await requireInstalledStructuredHost(ctx, sessionId)
const record = host.deps.store.getRecord(sessionId)
if (!record || !supportsSessionAccountUsage(record)) {
const saved = host.deps.store.getRecord(sessionId)
if (!saved || !supportsSessionAccountUsage(saved)) {
return { status: 'failed' }
}
// The account the chat's next turn runs on, which a switch moves it to.
const accountHome = await host.deps.launchAccountHome?.(saved).catch(() => null)
const record = accountHome ? { ...saved, accountHome } : saved
const abort = new AbortController()
let timer: ReturnType<typeof setTimeout> | undefined
try {
@@ -69,4 +69,19 @@ export abstract class OrcaRuntimeWithStructuredSessionAccountHome extends OrcaRu
const launchEnv = this.structuredAgentLaunchEnv(provider, this.requireStore().getSettings())
return (await read?.({ launchEnv, location })) ?? null
}
/** The same account, prepared for a start in `launchDirectory` as a create prepares it. */
async prepareStructuredAgentLaunchAccountHome(
record: AgentSessionRecord,
launchDirectory: string
): Promise<AgentSessionAccountHome | null> {
const { provider, location } = record
if (!structuredAgentRuntimeMetadata(provider)?.launchFollowsSelection?.()) {
return null
}
const prepare = this.structuredAgentAccountHomeResolver(provider, '', 'launch', launchDirectory)
await this.accounts.prepareHostAgentLaunch(provider)
const launchEnv = this.structuredAgentLaunchEnv(provider, this.requireStore().getSettings())
return (await prepare?.({ launchEnv, location })) ?? null
}
}
@@ -271,6 +271,8 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends RuntimeChatWorkerEven
preModeChatGetsFullAccess(agent, this.requireStore().getSettings(), process.platform),
launchAccountHome: (record, purpose) =>
this.resolveStructuredAgentLaunchAccountHome(record, purpose),
prepareLaunchAccountHome: (record, launchDirectory) =>
this.prepareStructuredAgentLaunchAccountHome(record, launchDirectory),
onRequestFailure: (failure) => this.onStructuredRequestFailureForErrorMail(failure),
// Structured chat has no CLI hooks; this projection drives first-work renaming.
onSessionStatusChanged: (summary, options) => {