fix(codex): carry /goal state and resume the session across account-switch restarts

An account-switch restart always launched a brand-new codex thread, which
stranded the pane's /goal: Codex keys thread_goals by thread id inside the
launch CODEX_HOME's sqlite, so after the switch the goal was doubly
unreachable (new thread, other home).

Main now prepares the restart: it resolves the pane's launch home from the
pane-account registry, probes the app-server goal RPCs behind a capability
cache, hardlinks the thread's rollout into the new home ahead of the
background bridge, proves it resumable there via thread/read, and carries the
goal row with thread/goal/get -> thread/goal/set. Only then does the renderer
swap the restart startup to `codex resume <thread-id>`. Codex's sqlite is
never touched directly - the app-server owns the DB and its WAL.

Every uncertain path - unrecorded panes, WSL/SSH/remote lanes, custom
CODEX_HOME overrides, older codex CLIs without the goal RPCs, unresolvable
rollouts, any RPC failure - degrades to exactly today's fresh `codex`
startup, because resuming the wrong session is worse than losing the goal.
This commit is contained in:
Brennan Benson
2026-08-02 15:43:12 -07:00
parent 99b94a38eb
commit b2f6b795e8
11 changed files with 1187 additions and 6 deletions
+6
View File
@@ -1371,6 +1371,12 @@ export class CodexAccountService {
return root
}
/** Root that owns every managed account home; lets callers validate a pane's
* recorded launch home without reaching into private state. */
getManagedAccountsRootPath(): string {
return this.getManagedAccountsRoot()
}
private ensureManagedHomeForReauthentication(account: CodexManagedAccount): string {
const wslInfo = parseWslUncPath(account.managedHomePath)
if (wslInfo && process.platform === 'win32') {
@@ -5,6 +5,7 @@ import { join } from 'node:path'
import {
_internals,
bridgeCodexSessionsIntoAccountHome,
ensureCodexRolloutBridgedIntoAccountHome,
startCodexAccountSessionBridgeInBackground
} from './codex-account-session-bridge'
@@ -123,6 +124,91 @@ describe('bridgeCodexSessionsIntoAccountHome', () => {
})
})
describe('ensureCodexRolloutBridgedIntoAccountHome', () => {
const THREAD_ID = '123e4567-e89b-42d3-a456-426614174000'
const THREAD_ROLLOUT = join('2026', '07', '20', `rollout-2026-07-20T10-00-00-${THREAD_ID}.jsonl`)
it('links the located thread rollout into the target home at the same relative path', async () => {
const sourceHome = join(workspaceRoot, 'old-account')
const targetHome = join(workspaceRoot, 'new-account')
const sourcePath = writeRollout(sourceHome, THREAD_ROLLOUT, 'thread\n')
const targetPath = await ensureCodexRolloutBridgedIntoAccountHome({
threadId: THREAD_ID,
sourceCodexHomePath: sourceHome,
targetCodexHomePath: targetHome
})
expect(targetPath).toBe(rolloutPath(targetHome, THREAD_ROLLOUT))
// Why one inode: the resumed session appends to the same physical log.
expect(statSync(targetPath!).ino).toBe(statSync(sourcePath).ino)
})
it('accepts the hook-reported transcript path when it lives under the source home', async () => {
const sourceHome = join(workspaceRoot, 'old-account')
const targetHome = join(workspaceRoot, 'new-account')
const sourcePath = writeRollout(sourceHome, THREAD_ROLLOUT, 'thread\n')
const targetPath = await ensureCodexRolloutBridgedIntoAccountHome({
threadId: THREAD_ID,
transcriptPath: sourcePath,
sourceCodexHomePath: sourceHome,
targetCodexHomePath: targetHome
})
expect(targetPath).toBe(rolloutPath(targetHome, THREAD_ROLLOUT))
})
it('returns the existing target without relinking when the background bridge won', async () => {
const sourceHome = join(workspaceRoot, 'old-account')
const targetHome = join(workspaceRoot, 'new-account')
writeRollout(sourceHome, THREAD_ROLLOUT, 'thread\n')
writeRollout(targetHome, THREAD_ROLLOUT, 'already bridged\n')
const targetPath = await ensureCodexRolloutBridgedIntoAccountHome({
threadId: THREAD_ID,
sourceCodexHomePath: sourceHome,
targetCodexHomePath: targetHome
})
expect(targetPath).toBe(rolloutPath(targetHome, THREAD_ROLLOUT))
expect(readFileSync(targetPath!, 'utf-8')).toBe('already bridged\n')
})
it('declines when the thread has no rollout under the source home', async () => {
const sourceHome = join(workspaceRoot, 'old-account')
const targetHome = join(workspaceRoot, 'new-account')
writeRollout(sourceHome, ROLLOUT_A, 'someone else\n')
const targetPath = await ensureCodexRolloutBridgedIntoAccountHome({
threadId: THREAD_ID,
sourceCodexHomePath: sourceHome,
targetCodexHomePath: targetHome
})
expect(targetPath).toBeNull()
})
it('declines when the reported transcript path lies outside the source home', async () => {
const sourceHome = join(workspaceRoot, 'old-account')
const foreignHome = join(workspaceRoot, 'foreign')
const targetHome = join(workspaceRoot, 'new-account')
// Why: a same-id rollout also exists under the source home, but rejected
// provenance must not fall back to the id scan and resume it anyway.
writeRollout(sourceHome, THREAD_ROLLOUT, 'thread\n')
const foreignPath = writeRollout(foreignHome, THREAD_ROLLOUT, 'foreign\n')
const targetPath = await ensureCodexRolloutBridgedIntoAccountHome({
threadId: THREAD_ID,
transcriptPath: foreignPath,
sourceCodexHomePath: sourceHome,
targetCodexHomePath: targetHome
})
expect(targetPath).toBeNull()
})
})
describe('startCodexAccountSessionBridgeInBackground', () => {
it('shares one in-flight task per target home', () => {
const systemHome = join(workspaceRoot, 'system')
@@ -4,6 +4,7 @@ import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-p
import { listCodexSessionRolloutFilesIncrementally } from './codex-session-file-listing'
import type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing'
import { linkCodexSessionFile } from './codex-session-link'
import { findTrustedCodexSessionResume } from './codex-session-resume-home'
/**
* Bridges Codex history between Orca-managed Codex homes.
@@ -81,6 +82,56 @@ export async function bridgeCodexSessionsIntoAccountHome(args: {
return summary
}
/**
* Bridges ONE thread's rollout into the target home ahead of `codex resume`.
*
* Why: the whole-tree bridge above runs in the background, and an account-switch
* restart must not race it — `codex resume` fails outright when the rollout is
* not yet linked under the launch home. Locating the rollout under the SOURCE
* home is also the attribution check: a thread that does not resolve there
* cannot be this pane's, so the caller drops the resume. Returns the target
* rollout path, or null when the thread cannot be bridged.
*/
export async function ensureCodexRolloutBridgedIntoAccountHome(args: {
threadId: string
transcriptPath?: string
sourceCodexHomePath: string
targetCodexHomePath: string
}): Promise<string | null> {
const located = await findTrustedCodexSessionResume({
sessionId: args.threadId,
transcriptPath: args.transcriptPath,
trustedCodexHomes: [args.sourceCodexHomePath],
getSelectedAccountCodexHome: () => null,
systemCodexHomePath: null,
sharedRuntimeCodexHomePath: null
})
if (!located) {
return null
}
const sourceSessionsRoot = join(args.sourceCodexHomePath, 'sessions')
const targetSessionsRoot = join(args.targetCodexHomePath, 'sessions')
const targetFilePath = join(
targetSessionsRoot,
relative(sourceSessionsRoot, located.transcriptPath)
)
if (existsSync(targetFilePath)) {
return targetFilePath
}
try {
mkdirSync(dirname(targetFilePath), { recursive: true })
} catch (error) {
console.warn('[codex-account-session-bridge] Failed to create session directory:', error)
return null
}
if (linkCodexSessionFile(located.transcriptPath, targetFilePath)) {
return targetFilePath
}
// Why: the background bridge may have linked the same rollout between the
// existence check and the link attempt; a lost race is still a success.
return existsSync(targetFilePath) ? targetFilePath : null
}
/**
* Links one rollout into the target sessions tree at the same relative path.
*/
@@ -0,0 +1,403 @@
import { describe, expect, it } from 'vitest'
import type { CodexManagedAccount } from '../../shared/types'
import { CodexAppServerCapabilityCache } from './codex-app-server-capability-cache'
import {
CodexAppServerUnsupportedError,
type CodexAppServerInvocation,
type CodexAppServerRpc,
type runCodexAppServerSession
} from './codex-app-server-session'
import {
CODEX_GOAL_RPC_HOST_KEY,
prepareCodexAccountSwitchResume,
resolveCodexAccountSwitchHomes,
type CodexAccountSwitchHomes,
type PrepareCodexAccountSwitchResumeDeps
} from './codex-account-switch-resume'
import type { CodexPaneAccountRecord } from './codex-pane-account-registry'
const THREAD_ID = '123e4567-e89b-42d3-a456-426614174000'
const OLD_HOME = '/managed/account-a/home'
const NEW_HOME = '/managed/account-b/home'
const SYSTEM_HOME = '/users/dev/.codex'
const SHARED_HOME = '/orca/codex-runtime-home'
const ACCOUNTS_ROOT = '/managed'
function managedAccount(overrides: Partial<CodexManagedAccount> = {}): CodexManagedAccount {
return {
id: 'account-a',
email: 'a@example.com',
managedHomePath: OLD_HOME,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1,
...overrides
}
}
function hostRecord(overrides: Partial<CodexPaneAccountRecord> = {}): CodexPaneAccountRecord {
return { selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home', ...overrides }
}
function resolveHomes(args: {
record: CodexPaneAccountRecord | null
accounts?: CodexManagedAccount[]
selectedHostAccountCodexHomePath?: string | null
hostSystemDefaultRealHome?: boolean
assertOwnedManagedHome?: () => string
}): ReturnType<typeof resolveCodexAccountSwitchHomes> {
return resolveCodexAccountSwitchHomes({
record: args.record,
settings: { codexManagedAccounts: args.accounts ?? [managedAccount()] },
managedAccountsRoot: ACCOUNTS_ROOT,
systemCodexHomePath: SYSTEM_HOME,
sharedRuntimeCodexHomePath: SHARED_HOME,
selectedHostAccountCodexHomePath:
args.selectedHostAccountCodexHomePath === undefined
? NEW_HOME
: args.selectedHostAccountCodexHomePath,
hostSystemDefaultRealHome: args.hostSystemDefaultRealHome ?? false,
assertOwnedManagedHome: args.assertOwnedManagedHome ?? (() => OLD_HOME)
})
}
describe('resolveCodexAccountSwitchHomes', () => {
it('resolves account-home to new-selection homes for a recorded host pane', () => {
expect(resolveHomes({ record: hostRecord() })).toEqual({
outcome: 'homes',
homes: { oldCodexHomePath: OLD_HOME, newCodexHomePath: NEW_HOME }
})
})
it('resolves a real-home launch and a system-default new selection', () => {
expect(
resolveHomes({
record: hostRecord({ accountId: null, homeRoute: 'real-home' }),
selectedHostAccountCodexHomePath: null,
hostSystemDefaultRealHome: true
})
).toEqual({
outcome: 'homes',
homes: { oldCodexHomePath: SYSTEM_HOME, newCodexHomePath: SYSTEM_HOME }
})
})
it('resolves the legacy shared mirror on both sides when no lane routes elsewhere', () => {
expect(
resolveHomes({
record: hostRecord({ homeRoute: 'shared-home' }),
selectedHostAccountCodexHomePath: null
})
).toEqual({
outcome: 'homes',
homes: { oldCodexHomePath: SHARED_HOME, newCodexHomePath: SHARED_HOME }
})
})
it.each([
['unrecorded pane', null, 'pane-launch-unrecorded'],
['WSL lane', hostRecord({ selectionKey: 'wsl:Ubuntu' }), 'non-host-lane'],
[
'shell-startup home override',
hostRecord({
shellStartupHomeOverride: { home: '/users/dev', codexHome: '/custom' }
}),
'custom-home-override'
],
[
'environment home override',
hostRecord({ environmentHomeOverride: { codexHome: '/custom' } }),
'custom-home-override'
],
['custom-home route', hostRecord({ homeRoute: 'custom-home' }), 'unattributable-home-route'],
['wsl-home route', hostRecord({ homeRoute: 'wsl-home' }), 'unattributable-home-route'],
[
'record without route provenance',
hostRecord({ homeRoute: undefined }),
'unattributable-home-route'
],
['removed launch account', hostRecord({ accountId: 'gone' }), 'launch-account-removed']
] as const)('declines with fresh on %s', (_label, record, reason) => {
expect(resolveHomes({ record: record as CodexPaneAccountRecord | null })).toEqual({
outcome: 'fresh',
reason
})
})
it('declines when a WSL account owns the recorded account id', () => {
expect(
resolveHomes({
record: hostRecord(),
accounts: [managedAccount({ managedHomeRuntime: 'wsl', wslDistro: 'Ubuntu' })]
})
).toEqual({ outcome: 'fresh', reason: 'launch-account-removed' })
})
it('declines when the recorded managed home fails the ownership check', () => {
expect(
resolveHomes({
record: hostRecord(),
assertOwnedManagedHome: () => {
throw new Error('escapes the managed root')
}
})
).toEqual({ outcome: 'fresh', reason: 'untrusted-managed-home' })
})
})
type RecordedCall = { home: string; method: string; params: Record<string, unknown> | undefined }
function createFakeSessionRunner(script: (call: RecordedCall) => unknown): {
runner: typeof runCodexAppServerSession
calls: RecordedCall[]
} {
const calls: RecordedCall[] = []
const runner = (async <T>(
invocation: CodexAppServerInvocation,
body: (rpc: CodexAppServerRpc) => Promise<T>
): Promise<T> => {
const home = invocation.env?.CODEX_HOME ?? ''
return body({
request: async (method, params) => {
const call = { home, method, params }
calls.push(call)
return script(call)
},
notify: () => {}
})
}) as typeof runCodexAppServerSession
return { runner, calls }
}
const CROSS_HOME: CodexAccountSwitchHomes = {
oldCodexHomePath: OLD_HOME,
newCodexHomePath: NEW_HOME
}
function prepareDeps(args: {
script: (call: RecordedCall) => unknown
homes?: CodexAccountSwitchHomes
bridged?: boolean
cache?: CodexAppServerCapabilityCache
nowMs?: () => number
}): {
deps: PrepareCodexAccountSwitchResumeDeps
calls: RecordedCall[]
bridgedHomes: CodexAccountSwitchHomes[]
} {
const { runner, calls } = createFakeSessionRunner(args.script)
const bridgedHomes: CodexAccountSwitchHomes[] = []
return {
deps: {
resolveHomes: () => ({ outcome: 'homes', homes: args.homes ?? CROSS_HOME }),
ensureRolloutBridged: async (homes) => {
bridgedHomes.push(homes)
return args.bridged ?? true
},
runAppServerSession: runner,
buildInvocation: (codexHomePath, timeoutMs) => ({
command: 'codex',
args: ['app-server'],
env: { CODEX_HOME: codexHomePath },
timeoutMs
}),
capabilityCache: args.cache ?? new CodexAppServerCapabilityCache(),
...(args.nowMs ? { nowMs: args.nowMs } : {})
},
calls,
bridgedHomes
}
}
const GOAL = {
threadId: THREAD_ID,
objective: 'ship the fix',
status: 'usageLimited',
tokenBudget: 250_000,
tokensUsed: 1000,
timeUsedSeconds: 60,
createdAt: 1,
updatedAt: 2
}
describe('prepareCodexAccountSwitchResume', () => {
it('reads the goal from the old home and writes it after thread/read in the new home', async () => {
const { deps, calls, bridgedHomes } = prepareDeps({
script: (call) => (call.method === 'thread/goal/get' ? { goal: GOAL } : {})
})
const decision = await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)
expect(decision).toEqual({ outcome: 'resume', threadId: THREAD_ID })
expect(bridgedHomes).toEqual([CROSS_HOME])
expect(calls).toEqual([
{ home: OLD_HOME, method: 'thread/goal/get', params: { threadId: THREAD_ID } },
{ home: NEW_HOME, method: 'thread/read', params: { threadId: THREAD_ID } },
{
home: NEW_HOME,
method: 'thread/goal/set',
params: {
threadId: THREAD_ID,
objective: 'ship the fix',
status: 'usageLimited',
tokenBudget: 250_000
}
}
])
})
it('still resumes a goalless thread, validating it via thread/read alone', async () => {
const { deps, calls } = prepareDeps({
script: (call) => (call.method === 'thread/goal/get' ? { goal: null } : {})
})
const decision = await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)
expect(decision).toEqual({ outcome: 'resume', threadId: THREAD_ID })
expect(calls.map((call) => call.method)).toEqual(['thread/goal/get', 'thread/read'])
})
it('normalizes a missing token budget to null for the write', async () => {
const { deps, calls } = prepareDeps({
script: (call) =>
call.method === 'thread/goal/get' ? { goal: { ...GOAL, tokenBudget: undefined } } : {}
})
await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)
expect(calls.at(-1)?.params).toMatchObject({ tokenBudget: null })
})
it('skips the bridge entirely when the launch home is also the new home', async () => {
const { deps, calls, bridgedHomes } = prepareDeps({
script: () => ({ goal: GOAL }),
homes: { oldCodexHomePath: OLD_HOME, newCodexHomePath: OLD_HOME }
})
const decision = await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)
// Why: one home means one goals DB and one rollout tree; resume carries both.
expect(decision).toEqual({ outcome: 'resume', threadId: THREAD_ID })
expect(bridgedHomes).toEqual([])
expect(calls.map((call) => call.method)).toEqual(['thread/goal/get'])
})
it('declines a thread id that is not a bare rollout UUID without touching anything', async () => {
const { deps, calls } = prepareDeps({ script: () => ({}) })
const decision = await prepareCodexAccountSwitchResume({ threadId: '$(rm -rf ~); echo' }, deps)
expect(decision).toEqual({ outcome: 'fresh', reason: 'invalid-thread-id' })
expect(calls).toEqual([])
})
it('passes a homes decline through untouched', async () => {
const { deps, calls } = prepareDeps({ script: () => ({}) })
deps.resolveHomes = () => ({ outcome: 'fresh', reason: 'non-host-lane' })
expect(await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)).toEqual({
outcome: 'fresh',
reason: 'non-host-lane'
})
expect(calls).toEqual([])
})
it('declines when the rollout cannot be bridged, before any new-home RPC', async () => {
const { deps, calls } = prepareDeps({
script: () => ({ goal: GOAL }),
bridged: false
})
const decision = await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)
expect(decision).toEqual({ outcome: 'fresh', reason: 'rollout-not-bridged' })
expect(calls.map((call) => call.method)).toEqual(['thread/goal/get'])
})
it.each([
['thread/read', 'thread/read'],
['thread/goal/set', 'thread/goal/set']
])('declines when %s fails in the new home', async (_label, failingMethod) => {
const { deps } = prepareDeps({
script: (call) => {
if (call.method === failingMethod) {
throw new Error(`codex app-server ${failingMethod} failed: boom`)
}
return call.method === 'thread/goal/get' ? { goal: GOAL } : {}
}
})
expect(await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)).toEqual({
outcome: 'fresh',
reason: 'goal-write-failed'
})
})
it('declines on an unrecognizable goal payload instead of guessing', async () => {
const { deps } = prepareDeps({
script: () => ({ goal: { status: 'usageLimited' } })
})
expect(await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)).toEqual({
outcome: 'fresh',
reason: 'goal-shape-unexpected'
})
})
it('treats a transient read failure as fresh without poisoning the capability', async () => {
const cache = new CodexAppServerCapabilityCache()
const { deps } = prepareDeps({
script: () => {
throw new Error('spawn ENOENT')
},
cache
})
expect(await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)).toEqual({
outcome: 'fresh',
reason: 'goal-read-failed'
})
expect(cache.shouldTry(CODEX_GOAL_RPC_HOST_KEY)).toBe(true)
})
describe('capability gate', () => {
it('remembers an unsupported CLI and stops probing until the retry interval', async () => {
const cache = new CodexAppServerCapabilityCache()
let now = 1_000
const { deps, calls } = prepareDeps({
script: () => {
throw new CodexAppServerUnsupportedError('thread/goal/get: method not found')
},
cache,
nowMs: () => now
})
expect(await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)).toEqual({
outcome: 'fresh',
reason: 'goal-rpc-unsupported'
})
expect(calls).toHaveLength(1)
// Second attempt inside the interval never spawns an app-server.
expect(await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)).toEqual({
outcome: 'fresh',
reason: 'goal-rpc-unsupported'
})
expect(calls).toHaveLength(1)
// Why: an in-place codex upgrade must self-heal after the interval.
now += 31 * 60_000
await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)
expect(calls).toHaveLength(2)
})
it('marks the capability supported after a successful goal read', async () => {
const cache = new CodexAppServerCapabilityCache()
const { deps } = prepareDeps({ script: () => ({ goal: null }), cache })
await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)
expect(cache.isKnownSupported(CODEX_GOAL_RPC_HOST_KEY)).toBe(true)
})
})
})
@@ -0,0 +1,334 @@
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import type { GlobalSettings } from '../../shared/types'
import { assertOwnedHostCodexManagedHomePath } from '../codex-accounts/host-codex-managed-home-ownership'
import { getCodexSelectionTargetForAccount } from '../codex-accounts/runtime-selection'
import { resolveCodexCommand } from '../codex-cli/command'
import { getSpawnArgsForWindows } from '../win32-utils'
import { ensureCodexRolloutBridgedIntoAccountHome } from './codex-account-session-bridge'
import { CodexAppServerCapabilityCache } from './codex-app-server-capability-cache'
import {
isCodexAppServerUnsupportedError,
runCodexAppServerSession,
type CodexAppServerInvocation
} from './codex-app-server-session'
import { getSystemCodexHomePath, resolveOrcaManagedCodexHomePath } from './codex-home-paths'
import { getCodexPaneAccount, type CodexPaneAccountRecord } from './codex-pane-account-registry'
// Why: an account-switch restart used to always start a brand-new Codex thread,
// which stranded the pane's /goal — Codex keys thread_goals by thread id inside
// the launch CODEX_HOME's sqlite, so the goal became doubly unreachable (new
// thread, other home). This module decides whether the restarted pane may
// `codex resume` its live thread instead, and carries the goal row across homes
// through the sanctioned app-server RPCs (thread/goal/get → thread/goal/set).
// Orca never reads or writes Codex's sqlite itself: the app-server owns the DB
// and its WAL. Any doubt — unrecorded pane, non-host lane, custom home, missing
// rollout, old CLI without the goal RPCs — degrades to today's fresh `codex`
// startup, because resuming the wrong session is worse than losing the goal.
const CODEX_THREAD_ID_PATTERN = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i
const GOAL_READ_TIMEOUT_MS = 10_000
const GOAL_WRITE_TIMEOUT_MS = 15_000
/** Goal RPCs are probed on the native host only; WSL/remote lanes never reach
* the probe (their panes degrade to a fresh startup in resolveHomes). */
export const CODEX_GOAL_RPC_HOST_KEY = 'native' as const
/** Separate cache from the trust-grant pair: the goal RPCs shipped later, so a
* CLI can support one surface and not the other. */
export const codexGoalRpcCapabilityCache = new CodexAppServerCapabilityCache()
export type CodexAccountSwitchResumeDecision =
| { outcome: 'resume'; threadId: string }
| { outcome: 'fresh'; reason: string }
export type CodexAccountSwitchHomes = {
oldCodexHomePath: string
newCodexHomePath: string
}
export type CodexAccountSwitchHomesDecision =
| { outcome: 'homes'; homes: CodexAccountSwitchHomes }
| { outcome: 'fresh'; reason: string }
type CodexThreadGoalSnapshot = {
objective: string
status: string
tokenBudget: number | null
}
/**
* Resolves the CODEX_HOME the pane launched under and the one the restart will
* inject, from the pane-account registry record and the current host selection.
*
* Declines (fresh) whenever either home cannot be named with certainty: the
* record is what the spawn actually resolved, so nothing here re-derives from
* live settings except the new selection itself.
*/
export function resolveCodexAccountSwitchHomes(args: {
record: CodexPaneAccountRecord | null
settings: Pick<GlobalSettings, 'codexManagedAccounts'>
managedAccountsRoot: string
systemCodexHomePath: string
sharedRuntimeCodexHomePath: string
selectedHostAccountCodexHomePath: string | null
hostSystemDefaultRealHome: boolean
assertOwnedManagedHome?: typeof assertOwnedHostCodexManagedHomePath
}): CodexAccountSwitchHomesDecision {
const record = args.record
if (!record) {
return { outcome: 'fresh', reason: 'pane-launch-unrecorded' }
}
if (record.selectionKey !== 'host') {
return { outcome: 'fresh', reason: 'non-host-lane' }
}
// Why: an observed override means the shell, not Orca, picked the launch
// home — the same shell can redirect the restarted spawn too, so neither
// side of the bridge can be predicted.
if (record.shellStartupHomeOverride || record.environmentHomeOverride) {
return { outcome: 'fresh', reason: 'custom-home-override' }
}
const oldHome = resolveRecordedLaunchHome(args, record)
if (oldHome.outcome === 'fresh') {
return oldHome
}
const newCodexHomePath =
args.selectedHostAccountCodexHomePath ??
(args.hostSystemDefaultRealHome ? args.systemCodexHomePath : args.sharedRuntimeCodexHomePath)
return {
outcome: 'homes',
homes: { oldCodexHomePath: oldHome.path, newCodexHomePath }
}
}
function resolveRecordedLaunchHome(
args: {
settings: Pick<GlobalSettings, 'codexManagedAccounts'>
managedAccountsRoot: string
systemCodexHomePath: string
sharedRuntimeCodexHomePath: string
assertOwnedManagedHome?: typeof assertOwnedHostCodexManagedHomePath
},
record: CodexPaneAccountRecord
): { outcome: 'home'; path: string } | { outcome: 'fresh'; reason: string } {
switch (record.homeRoute) {
case 'real-home':
return { outcome: 'home', path: args.systemCodexHomePath }
case 'shared-home':
return { outcome: 'home', path: args.sharedRuntimeCodexHomePath }
case 'account-home': {
const account = args.settings.codexManagedAccounts?.find(
(candidate) =>
candidate.id === record.accountId &&
getCodexSelectionTargetForAccount(candidate).runtime === 'host'
)
if (!account) {
return { outcome: 'fresh', reason: 'launch-account-removed' }
}
try {
;(args.assertOwnedManagedHome ?? assertOwnedHostCodexManagedHomePath)({
candidatePath: account.managedHomePath,
managedAccountsRoot: args.managedAccountsRoot,
systemCodexHomePath: args.systemCodexHomePath,
expectedAccountId: account.id
})
} catch {
return { outcome: 'fresh', reason: 'untrusted-managed-home' }
}
return { outcome: 'home', path: account.managedHomePath }
}
// Why: a custom CODEX_HOME lives outside Orca's management, and a record
// without route provenance predates it entirely — neither names a home.
case 'custom-home':
case 'wsl-home':
case undefined:
return { outcome: 'fresh', reason: 'unattributable-home-route' }
}
}
export type PrepareCodexAccountSwitchResumeDeps = {
resolveHomes: () => CodexAccountSwitchHomesDecision
/** Guarantees the thread's rollout is linked under the new home before Orca
* commits to `codex resume`; false drops the resume. */
ensureRolloutBridged: (homes: CodexAccountSwitchHomes) => Promise<boolean>
runAppServerSession?: typeof runCodexAppServerSession
buildInvocation?: (codexHomePath: string, timeoutMs: number) => CodexAppServerInvocation
capabilityCache?: CodexAppServerCapabilityCache
nowMs?: () => number
}
/**
* Decides whether an account-switch restart may resume the pane's thread, and
* performs the goal bridge as part of deciding: `resume` is only answered once
* the rollout is present in the new home, `thread/read` proved it resumable
* there, and any goal row was written through `thread/goal/set`.
*/
export async function prepareCodexAccountSwitchResume(
request: { threadId: string },
deps: PrepareCodexAccountSwitchResumeDeps
): Promise<CodexAccountSwitchResumeDecision> {
const { threadId } = request
if (!CODEX_THREAD_ID_PATTERN.test(threadId)) {
return { outcome: 'fresh', reason: 'invalid-thread-id' }
}
const homesDecision = deps.resolveHomes()
if (homesDecision.outcome === 'fresh') {
return homesDecision
}
const { homes } = homesDecision
const cache = deps.capabilityCache ?? codexGoalRpcCapabilityCache
const nowMs = deps.nowMs ?? ((): number => Date.now())
if (
!cache.isKnownSupported(CODEX_GOAL_RPC_HOST_KEY) &&
!cache.shouldTry(CODEX_GOAL_RPC_HOST_KEY, nowMs())
) {
return { outcome: 'fresh', reason: 'goal-rpc-unsupported' }
}
const runSession = deps.runAppServerSession ?? runCodexAppServerSession
const buildInvocation = deps.buildInvocation ?? buildCodexGoalRpcInvocation
let goal: CodexThreadGoalSnapshot | null
try {
// Why: the get doubles as the capability probe — a method-not-found reply or
// a missing app-server subcommand is the only signal that marks unsupported.
const result = await runSession(
buildInvocation(homes.oldCodexHomePath, GOAL_READ_TIMEOUT_MS),
(rpc) => rpc.request('thread/goal/get', { threadId })
)
const parsed = parseThreadGoalSnapshot(result)
if (parsed.outcome === 'malformed') {
return { outcome: 'fresh', reason: 'goal-shape-unexpected' }
}
goal = parsed.goal
cache.rememberSupported(CODEX_GOAL_RPC_HOST_KEY)
} catch (error) {
if (isCodexAppServerUnsupportedError(error)) {
cache.rememberUnsupported(CODEX_GOAL_RPC_HOST_KEY, nowMs())
return { outcome: 'fresh', reason: 'goal-rpc-unsupported' }
}
return { outcome: 'fresh', reason: 'goal-read-failed' }
}
if (
normalizeRuntimePathForComparison(homes.oldCodexHomePath) ===
normalizeRuntimePathForComparison(homes.newCodexHomePath)
) {
// Same home keeps the same goals DB and rollout tree; resume alone carries both.
return { outcome: 'resume', threadId }
}
if (!(await deps.ensureRolloutBridged(homes))) {
return { outcome: 'fresh', reason: 'rollout-not-bridged' }
}
try {
await runSession(
buildInvocation(homes.newCodexHomePath, GOAL_WRITE_TIMEOUT_MS),
async (rpc) => {
// Why: thread/read is Codex's sanctioned lazy indexing — it parses the
// bridged rollout and upserts the thread row, proving the thread is
// resumable in this home before Orca commits the pane to `codex resume`.
await rpc.request('thread/read', { threadId })
if (goal) {
await rpc.request('thread/goal/set', {
threadId,
objective: goal.objective,
status: goal.status,
tokenBudget: goal.tokenBudget
})
}
}
)
} catch (error) {
if (isCodexAppServerUnsupportedError(error)) {
cache.rememberUnsupported(CODEX_GOAL_RPC_HOST_KEY, nowMs())
}
console.warn('[codex-account-switch-resume] Goal bridge into new home failed:', error)
return { outcome: 'fresh', reason: 'goal-write-failed' }
}
return { outcome: 'resume', threadId }
}
/** Tolerates future status values on purpose: both homes run the same codex
* binary, so whatever the old home reported the new home can store. */
function parseThreadGoalSnapshot(
result: unknown
): { outcome: 'parsed'; goal: CodexThreadGoalSnapshot | null } | { outcome: 'malformed' } {
if (!result || typeof result !== 'object') {
return { outcome: 'malformed' }
}
const goal = (result as { goal?: unknown }).goal
if (goal === null || goal === undefined) {
return { outcome: 'parsed', goal: null }
}
if (typeof goal !== 'object') {
return { outcome: 'malformed' }
}
const { objective, status, tokenBudget } = goal as Record<string, unknown>
if (typeof objective !== 'string' || typeof status !== 'string' || status.length === 0) {
return { outcome: 'malformed' }
}
return {
outcome: 'parsed',
goal: {
objective,
status,
tokenBudget:
typeof tokenBudget === 'number' && Number.isFinite(tokenBudget) ? tokenBudget : null
}
}
}
function buildCodexGoalRpcInvocation(
codexHomePath: string,
timeoutMs: number
): CodexAppServerInvocation {
const command = resolveCodexCommand()
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, ['app-server'])
return {
command: spawnCmd,
args: spawnArgs,
// Why: pin the bridged home explicitly — nested Orca launches can inherit a
// managed CODEX_HOME, which would read or write the wrong goals DB.
env: { CODEX_HOME: codexHomePath },
timeoutMs
}
}
/**
* IPC-facing wiring: assembles the real registry, home and bridge dependencies
* for one pane's restart. Kept apart from the decision logic so tests can fake
* every seam.
*/
export async function prepareCodexAccountSwitchResumeForPane(args: {
ptyId: string
threadId: string
transcriptPath?: string
settings: GlobalSettings
managedAccountsRoot: string
selectedHostAccountCodexHomePath: string | null
hostSystemDefaultRealHome: boolean
}): Promise<CodexAccountSwitchResumeDecision> {
return prepareCodexAccountSwitchResume(
{ threadId: args.threadId },
{
resolveHomes: () =>
resolveCodexAccountSwitchHomes({
record: getCodexPaneAccount(args.ptyId),
settings: args.settings,
managedAccountsRoot: args.managedAccountsRoot,
systemCodexHomePath: getSystemCodexHomePath(),
sharedRuntimeCodexHomePath: resolveOrcaManagedCodexHomePath(),
selectedHostAccountCodexHomePath: args.selectedHostAccountCodexHomePath,
hostSystemDefaultRealHome: args.hostSystemDefaultRealHome
}),
ensureRolloutBridged: async (homes) =>
(await ensureCodexRolloutBridgedIntoAccountHome({
threadId: args.threadId,
...(args.transcriptPath ? { transcriptPath: args.transcriptPath } : {}),
sourceCodexHomePath: homes.oldCodexHomePath,
targetCodexHomePath: homes.newCodexHomePath
})) !== null
}
)
}
+30
View File
@@ -1,6 +1,7 @@
import { ipcMain } from 'electron'
import type { CodexAccountAddTarget, CodexAccountService } from '../codex-accounts/service'
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
import { prepareCodexAccountSwitchResumeForPane } from '../codex/codex-account-switch-resume'
import { listRecordedCodexPaneLanes } from '../codex/codex-pane-account-registry'
import { forgetStaleCodexPanes, listStaleCodexPanes } from '../codex/codex-stale-pane-accounts'
import type { GlobalSettings } from '../../shared/types'
@@ -34,6 +35,35 @@ export function registerCodexAccountHandlers(
}
forgetStaleCodexPanes(args.ptyIds.filter((ptyId): ptyId is string => typeof ptyId === 'string'))
})
ipcMain.handle(
'codexAccounts:prepareAccountSwitchResume',
(_event, args: { ptyId?: unknown; threadId?: unknown; transcriptPath?: unknown }) => {
const settings = getSettings?.()
if (
!settings ||
typeof args?.ptyId !== 'string' ||
typeof args.threadId !== 'string' ||
(args.transcriptPath !== undefined && typeof args.transcriptPath !== 'string')
) {
return { outcome: 'fresh', reason: 'invalid-request' }
}
return prepareCodexAccountSwitchResumeForPane({
ptyId: args.ptyId,
threadId: args.threadId,
...(typeof args.transcriptPath === 'string' ? { transcriptPath: args.transcriptPath } : {}),
settings,
managedAccountsRoot: codexAccounts.getManagedAccountsRootPath(),
selectedHostAccountCodexHomePath:
codexAccounts.runtimeHomeService.getSelectedHostAccountCodexHomePath(),
hostSystemDefaultRealHome: codexAccounts.runtimeHomeService.isHostSystemDefaultRealHome()
}).catch((error: unknown) => {
// Why: a failed preparation must degrade to today's fresh startup, never
// break the restart that is about to run.
console.warn('[codex-accounts] Account-switch resume preparation failed:', error)
return { outcome: 'fresh', reason: 'preparation-failed' }
})
}
)
ipcMain.handle('codexAccounts:list', () => codexAccounts.listAccounts())
ipcMain.handle('codexAccounts:add', (_event, args?: CodexAccountAddTarget) =>
codexAccounts.addAccount(args)
+8
View File
@@ -2395,6 +2395,14 @@ export type PreloadApi = {
listRecordedPaneLanes: (args: { ptyIds: string[] }) => Promise<Record<string, string>>
/** Drops launch records so a dismissed prompt stays dismissed across restarts. */
forgetStalePanes: (args: { ptyIds: string[] }) => Promise<void>
/** Decides whether an account-switch restart may `codex resume` the pane's
* thread. Main bridges the rollout and the /goal row into the new home
* before answering `resume`; every failure degrades to `fresh`. */
prepareAccountSwitchResume: (args: {
ptyId: string
threadId: string
transcriptPath?: string
}) => Promise<{ outcome: 'resume'; threadId: string } | { outcome: 'fresh'; reason: string }>
}
claudeAccounts: {
list: () => Promise<ClaudeRateLimitAccountsState>
+7 -1
View File
@@ -2054,7 +2054,13 @@ const api = {
listRecordedPaneLanes: (args: { ptyIds: string[] }): Promise<Record<string, string>> =>
ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args),
forgetStalePanes: (args: { ptyIds: string[] }): Promise<void> =>
ipcRenderer.invoke('codexAccounts:forgetStalePanes', args)
ipcRenderer.invoke('codexAccounts:forgetStalePanes', args),
prepareAccountSwitchResume: (args: {
ptyId: string
threadId: string
transcriptPath?: string
}): Promise<{ outcome: 'resume'; threadId: string } | { outcome: 'fresh'; reason: string }> =>
ipcRenderer.invoke('codexAccounts:prepareAccountSwitchResume', args)
},
claudeAccounts: {
@@ -144,7 +144,11 @@ import {
readPrimarySelectionText
} from '@/lib/primary-selection'
import { APP_MENU_PASTE_EVENT } from '@/lib/app-menu-paste'
import { CODEX_ACCOUNT_RESTART_STARTUP } from '@/lib/codex-session-restart'
import {
CODEX_ACCOUNT_RESTART_STARTUP,
resolveCodexAccountRestartStartup,
type CodexAccountRestartStartup
} from '@/lib/codex-session-restart'
import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag'
import { isTerminalSessionStateSaveFailure } from '../../../../shared/terminal-session-state-save-failure'
import { isTerminalZeroDimensionsDiagnostic } from '../../../../shared/terminal-zero-dimensions-diagnostic'
@@ -321,6 +325,8 @@ function TerminalPane(
const paneKittyKeyboardModesRef = useRef<Map<number, TerminalKittyKeyboardModeTracker>>(new Map())
const paneLastThemeModeRef = useRef<Map<number, 'dark' | 'light'>>(new Map())
const panePtyBindingsRef = useRef<Map<number, IDisposable>>(new Map())
// Why: an account-switch restart awaits resume preparation before teardown; a second click mid-await must not tear the same pane down twice.
const restartingCodexPaneIdsRef = useRef<Set<number>>(new Set())
// Why: panes replaying recorded PTY bytes; while non-zero, pty-connection drops xterm onData so auto-replies don't leak to the shell. See replay-guard.ts.
const replayingPanesRef = useRef<Map<number, number>>(new Map())
const isActiveRef = useRef(isActive)
@@ -1614,17 +1620,40 @@ function TerminalPane(
}, [])
const handleRestartCodexPane = useCallback(
(paneId: number) => {
async (paneId: number) => {
const manager = managerRef.current
const pane = manager?.getPanes().find((candidate) => candidate.id === paneId)
if (!manager || !pane) {
if (!manager || !pane || restartingCodexPaneIdsRef.current.has(paneId)) {
return
}
restartingCodexPaneIdsRef.current.add(paneId)
const transport = paneTransportsRef.current.get(paneId)
const panePtyBinding = panePtyBindingsRef.current.get(paneId)
const existingPtyId = transport?.getPtyId()
// Why before teardown: resume preparation reads the live session's /goal
// through the old home's app-server and bridges it into the new home; a
// fresh decision leaves the restart byte-identical to today's relaunch.
let startup: CodexAccountRestartStartup = CODEX_ACCOUNT_RESTART_STARTUP
try {
if (existingPtyId) {
startup = await resolveCodexAccountRestartStartup({
ptyId: existingPtyId,
paneKey: makePaneKey(tabId, pane.leafId)
})
}
} finally {
// Why release here: everything after the await is synchronous, so no
// second restart can interleave before this one finishes.
restartingCodexPaneIdsRef.current.delete(paneId)
}
if (paneTransportsRef.current.get(paneId) !== transport) {
// Why: the pane reconnected while preparation ran; a second teardown
// would kill the replacement PTY.
return
}
if (existingPtyId) {
suppressPtyExit(existingPtyId)
clearCodexRestartNotice(existingPtyId)
@@ -1644,7 +1673,7 @@ function TerminalPane(
tabId,
worktreeId,
cwd,
startup: CODEX_ACCOUNT_RESTART_STARTUP,
startup,
paneTransportsRef,
paneMode2031Ref,
paneKittyKeyboardModesRef,
@@ -1723,7 +1752,7 @@ function TerminalPane(
}
// Why: the status-bar switcher requests a global Codex restart, but execution stays pane-scoped so a split tab doesn't lose unrelated non-Codex panes.
if (consumePendingCodexPaneRestart(ptyId)) {
handleRestartCodexPane(pane.id)
void handleRestartCodexPane(pane.id)
}
}
}, [
@@ -0,0 +1,173 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
import {
CODEX_ACCOUNT_RESTART_STARTUP,
resolveCodexAccountRestartStartup
} from './codex-session-restart'
describe('resolveCodexAccountRestartStartup', () => {
const originalWindow = (globalThis as { window?: typeof window }).window
const PANE_KEY = 'tab-1:leaf-1'
const THREAD_ID = '123e4567-e89b-42d3-a456-426614174000'
const prepareAccountSwitchResume = vi.fn()
function seedCodexStatus(overrides: Partial<AgentStatusEntry> = {}): void {
useAppStore.setState({
agentStatusByPaneKey: {
[PANE_KEY]: {
state: 'working',
prompt: 'ship it',
updatedAt: 1,
stateStartedAt: 1,
paneKey: PANE_KEY,
stateHistory: [],
agentType: 'codex',
providerSession: { key: 'session_id', id: THREAD_ID },
...overrides
} as AgentStatusEntry
}
})
}
beforeEach(() => {
prepareAccountSwitchResume.mockReset()
prepareAccountSwitchResume.mockResolvedValue({ outcome: 'resume', threadId: THREAD_ID })
useAppStore.setState({ agentStatusByPaneKey: {} })
;(globalThis as { window: typeof window }).window = {
...originalWindow,
api: {
...originalWindow?.api,
codexAccounts: {
...originalWindow?.api?.codexAccounts,
prepareAccountSwitchResume
}
}
} as unknown as typeof window
})
afterEach(() => {
useAppStore.setState({ agentStatusByPaneKey: {} })
if (originalWindow) {
;(globalThis as { window: typeof window }).window = originalWindow
} else {
delete (globalThis as { window?: typeof window }).window
}
})
it('resumes the pane thread once main confirms the bridge', async () => {
seedCodexStatus({
providerSession: {
key: 'session_id',
id: THREAD_ID,
transcriptPath: '/old/home/sessions/2026/07/20/rollout-x.jsonl'
}
})
const startup = await resolveCodexAccountRestartStartup({ ptyId: 'pty-1', paneKey: PANE_KEY })
expect(startup).toEqual({
command: `codex resume ${THREAD_ID}`,
startupCommandDelivery: 'shell-ready'
})
expect(prepareAccountSwitchResume).toHaveBeenCalledWith({
ptyId: 'pty-1',
threadId: THREAD_ID,
transcriptPath: '/old/home/sessions/2026/07/20/rollout-x.jsonl'
})
})
it('omits an absent transcript path from the preparation request', async () => {
seedCodexStatus()
await resolveCodexAccountRestartStartup({ ptyId: 'pty-1', paneKey: PANE_KEY })
expect(prepareAccountSwitchResume).toHaveBeenCalledWith({
ptyId: 'pty-1',
threadId: THREAD_ID
})
})
// Why toEqual against the literal: the fallback contract is BYTE-identical to
// today's fresh startup — plain `codex`, delivered once the shell is ready.
const TODAYS_STARTUP = { command: 'codex', startupCommandDelivery: 'shell-ready' }
it.each([
['the pane has no agent status', (): void => {}],
['the pane runs a different agent', (): void => seedCodexStatus({ agentType: 'claude' })],
[
'the status came over a remote connection',
(): void => seedCodexStatus({ connectionId: 'conn-1' })
],
['the session has no provider id', (): void => seedCodexStatus({ providerSession: undefined })],
[
'the provider id is not a session id',
(): void =>
seedCodexStatus({
providerSession: { key: 'conversation_id', id: THREAD_ID }
})
],
[
'the session id is not a bare rollout UUID',
(): void => seedCodexStatus({ providerSession: { key: 'session_id', id: 'nested); rm -rf' } })
]
])("falls back to today's exact startup when %s", async (_label, seed) => {
seed()
const startup = await resolveCodexAccountRestartStartup({ ptyId: 'pty-1', paneKey: PANE_KEY })
expect(startup).toEqual(TODAYS_STARTUP)
expect(startup).toBe(CODEX_ACCOUNT_RESTART_STARTUP)
expect(prepareAccountSwitchResume).not.toHaveBeenCalled()
})
it('never asks main about a pane on another machine', async () => {
seedCodexStatus()
const startup = await resolveCodexAccountRestartStartup({
ptyId: 'ssh:my-box@@pty-7',
paneKey: PANE_KEY
})
expect(startup).toEqual(TODAYS_STARTUP)
expect(prepareAccountSwitchResume).not.toHaveBeenCalled()
})
it('falls back when the preload predates the preparation handler', async () => {
seedCodexStatus()
;(
window.api.codexAccounts as unknown as { prepareAccountSwitchResume?: unknown }
).prepareAccountSwitchResume = undefined
expect(await resolveCodexAccountRestartStartup({ ptyId: 'pty-1', paneKey: PANE_KEY })).toEqual(
TODAYS_STARTUP
)
})
it('falls back when the preparation IPC rejects', async () => {
seedCodexStatus()
prepareAccountSwitchResume.mockRejectedValue(new Error('no handler'))
expect(await resolveCodexAccountRestartStartup({ ptyId: 'pty-1', paneKey: PANE_KEY })).toEqual(
TODAYS_STARTUP
)
})
it('falls back when main declines the resume', async () => {
seedCodexStatus()
prepareAccountSwitchResume.mockResolvedValue({ outcome: 'fresh', reason: 'non-host-lane' })
expect(await resolveCodexAccountRestartStartup({ ptyId: 'pty-1', paneKey: PANE_KEY })).toEqual(
TODAYS_STARTUP
)
})
it('falls back when main answers for a different thread', async () => {
seedCodexStatus()
prepareAccountSwitchResume.mockResolvedValue({ outcome: 'resume', threadId: 'other-thread' })
expect(await resolveCodexAccountRestartStartup({ ptyId: 'pty-1', paneKey: PANE_KEY })).toEqual(
TODAYS_STARTUP
)
})
})
@@ -27,6 +27,61 @@ export const CODEX_ACCOUNT_RESTART_STARTUP = {
startupCommandDelivery: 'shell-ready'
} as const
export type CodexAccountRestartStartup = {
command: string
startupCommandDelivery: 'shell-ready'
}
const CODEX_THREAD_ID_PATTERN = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i
/**
* Chooses the startup command for a Codex account-switch restart.
*
* Resumes the pane's live thread (`codex resume <id>`) only when main confirms
* the whole bridge: thread attribution to the pane's launch home, the rollout
* linked into the new home, and the /goal row carried over via the app-server
* goal RPCs. Every other path — older codex CLIs, WSL/remote panes, unknown
* sessions, an older preload, any IPC failure — yields exactly today's fresh
* startup, because resuming the wrong session is worse than losing the goal.
*/
export async function resolveCodexAccountRestartStartup(args: {
ptyId: string
paneKey: string
}): Promise<CodexAccountRestartStartup> {
if (isForeignMachineCodexPtyId(args.ptyId)) {
return CODEX_ACCOUNT_RESTART_STARTUP
}
const entry = useAppStore.getState().agentStatusByPaneKey[args.paneKey]
const threadId = entry?.providerSession?.id
if (
entry?.agentType !== 'codex' ||
entry.connectionId != null ||
entry.providerSession?.key !== 'session_id' ||
!threadId ||
// Why the strict match: the id is embedded in a shell command; only a bare
// rollout UUID may ever pass, and main re-validates it independently.
!CODEX_THREAD_ID_PATTERN.test(threadId)
) {
return CODEX_ACCOUNT_RESTART_STARTUP
}
const prepareAccountSwitchResume = window.api.codexAccounts.prepareAccountSwitchResume
// Why the shape check: a preload older than this handler has no such method,
// and that must read as "resume unavailable", not as a restart failure.
if (typeof prepareAccountSwitchResume !== 'function') {
return CODEX_ACCOUNT_RESTART_STARTUP
}
const transcriptPath = entry.providerSession.transcriptPath?.trim()
const decision = await prepareAccountSwitchResume({
ptyId: args.ptyId,
threadId,
...(transcriptPath ? { transcriptPath } : {})
}).catch(() => null)
if (decision?.outcome !== 'resume' || decision.threadId !== threadId) {
return CODEX_ACCOUNT_RESTART_STARTUP
}
return { command: `codex resume ${threadId}`, startupCommandDelivery: 'shell-ready' }
}
export type CodexPaneScanResult = {
ptyId: string
/** The pane may be shown a restart prompt (see isCodexRestartEligiblePane). */