From a7bf398f3de5ade1c53d640b4dcecd2987c996ed Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:51:11 -0700 Subject: [PATCH] Fix Codex automatic resume across account switches (#9696) * fix(codex): preserve session home on automatic resume * fix(codex): keep automatic resume homes launch-authoritative * fix(codex): resume compressed rollout representations * fix(codex): keep resume migration best effort * fix(codex): reject stale automatic resume provenance * fix(codex): enforce real home on automatic resume * fix(codex): keep automatic resume home authoritative --- .../codex/codex-legacy-session-resume.test.ts | 21 +- src/main/codex/codex-legacy-session-resume.ts | 2 +- src/main/codex/codex-session-file-listing.ts | 30 ++- .../codex/codex-session-resume-home.test.ts | 226 ++++++++++++++++++ src/main/codex/codex-session-resume-home.ts | 135 +++++++++++ src/main/index.ts | 86 ++++++- src/main/ipc/pty.test.ts | 202 ++++++++++++++++ src/main/ipc/pty.ts | 111 +++++++-- src/main/providers/local-pty-provider.ts | 2 + src/main/providers/types.ts | 2 + src/main/runtime/orca-runtime.ts | 11 +- src/main/runtime/rpc/methods/terminal.ts | 10 + .../window/attach-main-window-services.ts | 9 +- src/preload/api-types.ts | 7 +- src/preload/index.ts | 8 +- .../terminal-pane/pty-connection-types.ts | 6 +- .../terminal-pane/pty-connection.test.ts | 11 +- .../terminal-pane/pty-connection.ts | 15 ++ .../terminal-pane/pty-transport-types.ts | 7 +- .../terminal-pane/pty-transport.test.ts | 15 ++ .../components/terminal-pane/pty-transport.ts | 6 + .../remote-runtime-pty-transport.test.ts | 12 +- .../remote-runtime-pty-transport.ts | 5 + .../use-terminal-pane-lifecycle.ts | 7 +- src/renderer/src/hooks/useIpcEvents.ts | 5 + src/shared/runtime-types.ts | 6 +- 26 files changed, 919 insertions(+), 38 deletions(-) create mode 100644 src/main/codex/codex-session-resume-home.test.ts create mode 100644 src/main/codex/codex-session-resume-home.ts diff --git a/src/main/codex/codex-legacy-session-resume.test.ts b/src/main/codex/codex-legacy-session-resume.test.ts index c69d4e4ed39..852da6b2c91 100644 --- a/src/main/codex/codex-legacy-session-resume.test.ts +++ b/src/main/codex/codex-legacy-session-resume.test.ts @@ -9,7 +9,7 @@ import { writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { prepareLegacySharedCodexSessionResume } from './codex-legacy-session-resume' describe('prepareLegacySharedCodexSessionResume', () => { @@ -47,6 +47,16 @@ describe('prepareLegacySharedCodexSessionResume', () => { expect(statSync(targetPath).ino).toBe(statSync(rolloutPath).ino) }) + it('materializes a compressed legacy rollout without changing its representation', async () => { + const compressedPath = `${rolloutPath}.zst` + rmSync(rolloutPath) + rolloutPath = compressedPath + writeFileSync(rolloutPath, 'compressed-rollout', 'utf-8') + + await expect(prepare()).resolves.toEqual({ useRealCodexHome: true }) + expect(readFileSync(targetRolloutPath(), 'utf-8')).toBe('compressed-rollout') + }) + it('coalesces concurrent resumes of the same legacy rollout', async () => { const [first, second] = await Promise.all([prepare(), prepare()]) @@ -129,14 +139,7 @@ describe('prepareLegacySharedCodexSessionResume', () => { } function targetRolloutPath(): string { - return join( - systemHome, - 'sessions', - '2026', - '07', - '20', - 'rollout-2026-07-20T12-00-00-session.jsonl' - ) + return join(systemHome, 'sessions', '2026', '07', '20', basename(rolloutPath)) } }) diff --git a/src/main/codex/codex-legacy-session-resume.ts b/src/main/codex/codex-legacy-session-resume.ts index c2febd6d772..3976b4a0d8d 100644 --- a/src/main/codex/codex-legacy-session-resume.ts +++ b/src/main/codex/codex-legacy-session-resume.ts @@ -162,7 +162,7 @@ function isLegacyRolloutRelativePath(relativePath: string): boolean { /^\d{4}$/.test(parts[0] ?? '') && /^\d{2}$/.test(parts[1] ?? '') && /^\d{2}$/.test(parts[2] ?? '') && - /^rollout-.+\.jsonl$/.test(parts[3] ?? '') + /^rollout-.+\.jsonl(?:\.zst)?$/.test(parts[3] ?? '') ) } diff --git a/src/main/codex/codex-session-file-listing.ts b/src/main/codex/codex-session-file-listing.ts index 6532f6ee5c9..8b0a8dd6f2e 100644 --- a/src/main/codex/codex-session-file-listing.ts +++ b/src/main/codex/codex-session-file-listing.ts @@ -58,6 +58,34 @@ export async function* listCodexSessionJsonlFilesIncrementally( rootPath: string, options: CodexSessionBridgeIncrementalOptions, onDirectoryError?: (directoryPath: string, error: unknown) => void | Promise +): AsyncGenerator { + yield* listCodexSessionFilesIncrementally( + rootPath, + options, + (fileName) => fileName.endsWith('.jsonl'), + onDirectoryError + ) +} + +/** Yields both physical representations that current Codex can resume. */ +export async function* listCodexSessionRolloutFilesIncrementally( + rootPath: string, + options: CodexSessionBridgeIncrementalOptions, + onDirectoryError?: (directoryPath: string, error: unknown) => void | Promise +): AsyncGenerator { + yield* listCodexSessionFilesIncrementally( + rootPath, + options, + (fileName) => fileName.endsWith('.jsonl') || fileName.endsWith('.jsonl.zst'), + onDirectoryError + ) +} + +async function* listCodexSessionFilesIncrementally( + rootPath: string, + options: CodexSessionBridgeIncrementalOptions, + isSessionFile: (fileName: string) => boolean, + onDirectoryError?: (directoryPath: string, error: unknown) => void | Promise ): AsyncGenerator { const batchSize = Math.max(1, options.batchSize ?? INCREMENTAL_BRIDGE_BATCH_SIZE) const yieldMs = Math.max(0, options.yieldMs ?? INCREMENTAL_BRIDGE_YIELD_MS) @@ -75,7 +103,7 @@ export async function* listCodexSessionJsonlFilesIncrementally( const childPath = join(currentDirectory, entry.name) if (entry.isDirectory()) { pendingDirectories.push(childPath) - } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + } else if (entry.isFile() && isSessionFile(entry.name)) { yield childPath } entriesSinceYield += 1 diff --git a/src/main/codex/codex-session-resume-home.test.ts b/src/main/codex/codex-session-resume-home.test.ts new file mode 100644 index 00000000000..4f55dec236d --- /dev/null +++ b/src/main/codex/codex-session-resume-home.test.ts @@ -0,0 +1,226 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + findTrustedCodexSessionResume, + resolveTrustedCodexSessionResumeHome +} from './codex-session-resume-home' + +const tempRoots: string[] = [] + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('resolveTrustedCodexSessionResumeHome', () => { + it('returns the trusted home containing a persisted rollout', () => { + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl', + trustedCodexHomes: ['/managed/account/home', '/Users/example/.codex'], + fileIsRegular: () => true + }) + ).toBe('/Users/example/.codex') + }) + + it('accepts Windows paths case-insensitively', () => { + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: 'C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-a.jsonl', + trustedCodexHomes: ['c:\\users\\example\\.codex'], + fileIsRegular: () => true + }) + ).toBe('c:\\users\\example\\.codex') + }) + + it('rejects paths outside trusted homes or outside the rollout layout', () => { + const fileIsRegular = vi.fn((): boolean => true) + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: '/tmp/sessions/2026/07/20/rollout-a.jsonl', + trustedCodexHomes: ['/Users/example/.codex'], + fileIsRegular + }) + ).toBeNull() + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: '/Users/example/.codex/sessions/index.jsonl', + trustedCodexHomes: ['/Users/example/.codex'], + fileIsRegular + }) + ).toBeNull() + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: + '/Users/example/.codex/sessions/2026/07/20/rollout-a/../../../../outside.jsonl', + trustedCodexHomes: ['/Users/example/.codex'], + fileIsRegular + }) + ).toBeNull() + expect(fileIsRegular).not.toHaveBeenCalled() + }) + + it('rejects a trusted-looking path when the rollout no longer exists', () => { + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-a.jsonl', + trustedCodexHomes: ['/Users/example/.codex'], + fileIsRegular: () => false + }) + ).toBeNull() + }) + + it('requires the transcript provenance to name a regular rollout file', () => { + const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-home-')) + tempRoots.push(homePath) + const rolloutDirectory = join(homePath, 'sessions', '2026', '07', '20', 'rollout-a.jsonl') + mkdirSync(rolloutDirectory, { recursive: true }) + + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: rolloutDirectory, + trustedCodexHomes: [homePath] + }) + ).toBeNull() + + const rolloutFile = join(homePath, 'sessions', '2026', '07', '20', 'rollout-b.jsonl') + writeFileSync(rolloutFile, '{}\n') + expect( + resolveTrustedCodexSessionResumeHome({ + transcriptPath: rolloutFile, + trustedCodexHomes: [homePath] + }) + ).toBe(homePath) + }) + + it('follows Codex when a persisted plain rollout was compressed in place', async () => { + const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-home-')) + tempRoots.push(homePath) + const plainPath = join( + homePath, + 'sessions', + '2026', + '07', + '20', + 'rollout-2026-07-20T12-00-00-session.jsonl' + ) + const compressedPath = `${plainPath}.zst` + mkdirSync(join(plainPath, '..'), { recursive: true }) + writeFileSync(compressedPath, 'compressed-rollout') + + await expect( + findTrustedCodexSessionResume({ + sessionId: 'session-a', + transcriptPath: plainPath, + trustedCodexHomes: [homePath] + }) + ).resolves.toEqual({ homePath, transcriptPath: compressedPath }) + + writeFileSync(plainPath, 'active-rollout') + await expect( + findTrustedCodexSessionResume({ + sessionId: 'session-a', + transcriptPath: compressedPath, + trustedCodexHomes: [homePath] + }) + ).resolves.toEqual({ homePath, transcriptPath: plainPath }) + }) + + it('finds compressed rollouts for legacy records without transcript provenance', async () => { + const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-home-')) + tempRoots.push(homePath) + const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11' + const compressedPath = join( + homePath, + 'sessions', + '2026', + '07', + '20', + `rollout-2026-07-20T12-00-00-${sessionId}.jsonl.zst` + ) + mkdirSync(join(compressedPath, '..'), { recursive: true }) + writeFileSync(compressedPath, 'compressed-rollout') + + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes: [homePath] + }) + ).resolves.toEqual({ homePath, transcriptPath: compressedPath }) + }) + + it('finds older saved sessions by id when transcript provenance is absent', async () => { + const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11' + const rolloutPath = `/managed/account/home/sessions/2026/07/20/rollout-2026-07-20T15-50-19-${sessionId}.jsonl` + const listSessionFiles = async function* (sessionsRoot: string): AsyncIterable { + if (sessionsRoot === '/managed/account/home/sessions') { + yield `/managed/account/home/sessions/misplaced-${sessionId}.jsonl` + yield rolloutPath + } + } + + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes: ['/Users/example/.codex', '/managed/account/home'], + listSessionFiles + }) + ).resolves.toEqual({ homePath: '/managed/account/home', transcriptPath: rolloutPath }) + }) + + it('does not scan session trees when exact transcript provenance is valid', async () => { + const transcriptPath = + '/managed/account/home/sessions/2026/07/20/rollout-2026-07-20-session.jsonl' + const listSessionFiles = vi.fn((): AsyncIterable => { + throw new Error('must not scan') + }) + + await expect( + findTrustedCodexSessionResume({ + sessionId: 'session-a', + transcriptPath, + trustedCodexHomes: ['/managed/account/home'], + fileIsRegular: () => true, + listSessionFiles + }) + ).resolves.toEqual({ homePath: '/managed/account/home', transcriptPath }) + expect(listSessionFiles).not.toHaveBeenCalled() + }) + + it('does not replace rejected transcript provenance with a same-id rollout from another home', async () => { + const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11' + const listSessionFiles = vi.fn((): AsyncIterable => { + throw new Error('must not scan') + }) + + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: `/managed/origin/home/sessions/2026/07/20/rollout-${sessionId}.jsonl`, + trustedCodexHomes: ['/managed/origin/home', '/managed/other/home'], + fileIsRegular: () => false, + listSessionFiles + }) + ).resolves.toBeNull() + expect(listSessionFiles).not.toHaveBeenCalled() + }) + + it('does not scan homes for an untrusted legacy session id shape', async () => { + const listSessionFiles = (): AsyncIterable => { + throw new Error('must not scan') + } + await expect( + findTrustedCodexSessionResume({ + sessionId: '../session', + transcriptPath: undefined, + trustedCodexHomes: ['/Users/example/.codex'], + listSessionFiles + }) + ).resolves.toBeNull() + }) +}) diff --git a/src/main/codex/codex-session-resume-home.ts b/src/main/codex/codex-session-resume-home.ts new file mode 100644 index 00000000000..aebaf6a157c --- /dev/null +++ b/src/main/codex/codex-session-resume-home.ts @@ -0,0 +1,135 @@ +import { existsSync, lstatSync } from 'node:fs' +import { join } from 'node:path' +import { + getRuntimePathBasename, + normalizeRuntimePathForComparison, + relativePathInsideRoot +} from '../../shared/cross-platform-path' +import { listCodexSessionRolloutFilesIncrementally } from './codex-session-file-listing' + +// Why: only Codex's dated rollout layout may establish account-home provenance; nested/misplaced JSONL must not select credentials. +const ROLLOUT_RELATIVE_PATH = /^\d{4}\/\d{2}\/\d{2}\/rollout-[^/]+\.jsonl(?:\.zst)?$/ + +function isCodexRolloutInsideSessionsRoot(sessionsRoot: string, filePath: string): boolean { + const relativePath = relativePathInsideRoot(sessionsRoot, filePath) + return Boolean(relativePath && ROLLOUT_RELATIVE_PATH.test(relativePath.replace(/\\/g, '/'))) +} + +function isRegularFile(filePath: string): boolean { + try { + return lstatSync(filePath).isFile() + } catch { + return false + } +} + +function resolveExistingRolloutPath( + transcriptPath: string, + fileIsRegular: (filePath: string) => boolean +): string | null { + const plainPath = transcriptPath.endsWith('.jsonl.zst') + ? transcriptPath.slice(0, -'.zst'.length) + : transcriptPath.endsWith('.jsonl') + ? transcriptPath + : null + if (!plainPath) { + return fileIsRegular(transcriptPath) ? transcriptPath : null + } + if (fileIsRegular(plainPath)) { + return plainPath + } + const compressedPath = `${plainPath}.zst` + return fileIsRegular(compressedPath) ? compressedPath : null +} + +function resolveTrustedCodexSessionResume(args: { + transcriptPath: string | undefined + trustedCodexHomes: readonly string[] + fileIsRegular?: (filePath: string) => boolean +}): { homePath: string; transcriptPath: string } | null { + const persistedPath = args.transcriptPath?.trim() + if (!persistedPath) { + return null + } + + for (const homePath of args.trustedCodexHomes) { + const sessionsRoot = join(homePath, 'sessions') + if (!isCodexRolloutInsideSessionsRoot(sessionsRoot, persistedPath)) { + continue + } + const transcriptPath = resolveExistingRolloutPath( + persistedPath, + args.fileIsRegular ?? isRegularFile + ) + if (transcriptPath) { + return { homePath, transcriptPath } + } + } + return null +} + +export function resolveTrustedCodexSessionResumeHome(args: { + transcriptPath: string | undefined + trustedCodexHomes: readonly string[] + fileIsRegular?: (filePath: string) => boolean +}): string | null { + return resolveTrustedCodexSessionResume(args)?.homePath ?? null +} + +export async function findTrustedCodexSessionResume(args: { + sessionId: string + transcriptPath: string | undefined + trustedCodexHomes: readonly string[] + fileIsRegular?: (filePath: string) => boolean + listSessionFiles?: (sessionsRoot: string) => AsyncIterable +}): Promise<{ homePath: string; transcriptPath: string } | null> { + const directSession = resolveTrustedCodexSessionResume(args) + if (directSession) { + return directSession + } + if (args.transcriptPath?.trim()) { + // Why: stale/rejected provenance must not select a same-id rollout under different account credentials; scanning is legacy-only. + return null + } + if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(args.sessionId)) { + return null + } + + const listSessionFiles = + args.listSessionFiles ?? + ((sessionsRoot: string) => + listCodexSessionRolloutFilesIncrementally(sessionsRoot, { batchSize: 64, yieldMs: 0 })) + const expectedSuffix = `-${args.sessionId}.jsonl`.toLowerCase() + const seenHomes = new Set() + for (const homePath of args.trustedCodexHomes) { + const comparisonHome = normalizeRuntimePathForComparison(homePath) + if (seenHomes.has(comparisonHome)) { + continue + } + seenHomes.add(comparisonHome) + const sessionsRoot = join(homePath, 'sessions') + if (!args.listSessionFiles && !existsSync(sessionsRoot)) { + continue + } + for await (const filePath of listSessionFiles(sessionsRoot)) { + const plainFilePath = filePath.endsWith('.jsonl.zst') + ? filePath.slice(0, -'.zst'.length) + : filePath + // Why: the directory entry already proves the compressed file exists; only probe its preferred plain sibling. + const preferredFilePath = + plainFilePath !== filePath && (args.fileIsRegular ?? isRegularFile)(plainFilePath) + ? plainFilePath + : filePath + const plainFileName = getRuntimePathBasename(preferredFilePath) + .toLowerCase() + .replace(/\.zst$/, '') + if ( + isCodexRolloutInsideSessionsRoot(sessionsRoot, preferredFilePath) && + plainFileName.endsWith(expectedSuffix) + ) { + return { homePath, transcriptPath: preferredFilePath } + } + } + } + return null +} diff --git a/src/main/index.ts b/src/main/index.ts index 23d89720adb..704a4182e9c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -147,6 +147,10 @@ import { startCodexSessionIndexHealInBackground } from './codex/codex-session-in import { createCodexSessionMigrationScheduler } from './codex/codex-session-migration-scheduler' import { prepareLegacySharedCodexSessionResume } from './codex/codex-legacy-session-resume' import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home' +import { findTrustedCodexSessionResume } from './codex/codex-session-resume-home' +import { getSystemCodexHomePath } from './codex/codex-home-paths' +import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' +import type { AgentProviderSessionMetadata } from '../shared/agent-session-resume' import { getDefaultWslDistro } from './wsl' import { ClaudeAccountService } from './claude-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' @@ -784,6 +788,84 @@ function prepareCodexRuntimeHomeForLaunch( return runtimeHomePath } +async function prepareCodexSessionResumeForLaunch(args: { + providerSession: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string +}): Promise<{ codexHomePath: string | null } | null> { + if (args.target.runtime === 'wsl' || !codexRuntimeHome || !store) { + return null + } + const systemHomePath = getSystemCodexHomePath() + // Why: codexSessionSourceHome is import-only; treating it as CODEX_HOME would mutate history sources and bypass account auth. + const trustedHomes = [ + systemHomePath, + ...codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() + ] + const sessionSource = await findTrustedCodexSessionResume({ + sessionId: args.providerSession.id, + transcriptPath: args.providerSession.transcriptPath, + trustedCodexHomes: trustedHomes + }) + if (!sessionSource) { + if (args.providerSession.transcriptPath) { + throw new Error( + 'Orca could not verify the originating Codex session file, so automatic resume was stopped to avoid using a different account.' + ) + } + return null + } + + let migrated = { useRealCodexHome: false } + try { + migrated = await prepareLegacySharedCodexSessionResume( + { + agent: 'codex', + executionHostId: 'local', + filePath: sessionSource.transcriptPath, + codexHome: sessionSource.homePath + }, + { + isHostSystemDefaultRealHome: () => codexRuntimeHome!.isHostSystemDefaultRealHome(), + systemCodexHomePath: systemHomePath + } + ) + } catch (error) { + // Why: migration is a compatibility repair; its failure must not prevent the PTY from resuming from its trusted origin home. + console.warn( + '[codex-session-resume] Legacy rollout migration failed; using origin home:', + error + ) + } + const resumeHome = migrated.useRealCodexHome ? systemHomePath : sessionSource.homePath + + if (args.workspacePath) { + try { + markCodexProjectTrusted(args.workspacePath) + } catch (error) { + console.warn('[codex-project-trust] failed to pre-mark resumed workspace:', error) + } + } + const isSystemHome = + normalizeRuntimePathForComparison(resumeHome) === + normalizeRuntimePathForComparison(systemHomePath) + const hooksEnabled = isAgentStatusHooksEnabled(store.getSettings()) + try { + if (isSystemHome) { + ensureRealHomeCodexHookState({ hooksEnabled, userDataPath: app.getPath('userData') }) + } else if (hooksEnabled) { + codexHookService.install(resumeHome) + } else { + codexHookService.refreshRuntimeUserHooks(resumeHome) + } + } catch (error) { + // Why: hook repair is best-effort; session provenance must still win over the currently selected home. + console.warn('[codex-hook-service] failed to prepare automatic resume home:', error) + } + return { codexHomePath: resumeHome } +} + // Why: restore the window the close handler may have hidden to tray, or reopen it (dock-reactivation style) if fully torn down. function showMainWindowFromTray(): void { if (mainWindow && !mainWindow.isDestroyed()) { @@ -1050,6 +1132,7 @@ function openMainWindow(): BrowserWindow { prepareCodexRuntimeHomeForLaunch, (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), { + prepareCodexSessionResume: prepareCodexSessionResumeForLaunch, awaitLocalPtyStartup: () => localPtyStartupReady, awaitLocalPtyProviderStartup: () => localPtyProviderStartupReady, onBeforeRendererReload: ({ ignoreCache, webContentsId }) => { @@ -2195,7 +2278,8 @@ app.whenReady().then(async () => { prepareCodexRuntimeHomeForLaunch, () => store!.getSettings(), (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), - store + store, + prepareCodexSessionResumeForLaunch ) // Why: headless servers can't mount panes; use offscreen WebContents, gated on a real display so browser.headless.v1 stays honest. if (headlessBrowserDisplayAvailable) { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 5d147a9eebd..6fb5ebaa802 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -931,6 +931,111 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME) }) + it('resumes an automatic Codex session from its prepared originating home', async () => { + const selectedHome = vi.fn(() => '/managed/current/home') + const prepareResume = vi.fn(async () => ({ codexHomePath: '/managed/origin/home' })) + registerPtyHandlers( + mainWindow as never, + undefined, + selectedHome, + undefined, + undefined, + undefined, + { prepareCodexSessionResume: prepareResume } + ) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'codex resume session-a', + launchAgent: 'codex', + resumeProviderSession: { + key: 'session_id', + id: 'session-a', + transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl' + } + }) + + const env = spawnMock.mock.calls.at(-1)![2].env as Record + expect(prepareResume).toHaveBeenCalledWith( + expect.objectContaining({ + providerSession: expect.objectContaining({ id: 'session-a' }), + target: { runtime: 'host' } + }) + ) + expect(selectedHome).not.toHaveBeenCalled() + expect(env.CODEX_HOME).toBe('/managed/origin/home') + expect(env.ORCA_CODEX_HOME).toBe('/managed/origin/home') + }) + + it('overrides an unmarked custom home when the resumed session originated in real home', async () => { + const selectedHome = vi.fn(() => '/managed/current/home') + const systemHome = '/Users/example/.codex' + registerPtyHandlers( + mainWindow as never, + undefined, + selectedHome, + undefined, + undefined, + undefined, + { prepareCodexSessionResume: async () => ({ codexHomePath: systemHome }) } + ) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'codex resume session-a', + env: { CODEX_HOME: '/custom/codex', REMOVE_ME: 'stale' }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'REMOVE_ME'], + launchAgent: 'codex', + resumeProviderSession: { + key: 'session_id', + id: 'session-a', + transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-a.jsonl' + } + }) + + const env = spawnMock.mock.calls.at(-1)![2].env as Record + expect(selectedHome).not.toHaveBeenCalled() + expect(env.CODEX_HOME).toBe(systemHome) + expect(env.ORCA_CODEX_HOME).toBe(systemHome) + expect(env.REMOVE_ME).toBeUndefined() + }) + + it('does not fall back to the selected account when automatic resume provenance is rejected', async () => { + const selectedHome = vi.fn(() => '/managed/current/home') + registerPtyHandlers( + mainWindow as never, + undefined, + selectedHome, + undefined, + undefined, + undefined, + { + prepareCodexSessionResume: async () => { + throw new Error('origin unavailable') + } + } + ) + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'codex resume session-a', + launchAgent: 'codex', + resumeProviderSession: { + key: 'session_id', + id: 'session-a', + transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl' + } + }) + ).rejects.toThrow('origin unavailable') + + expect(selectedHome).not.toHaveBeenCalled() + expect(spawnMock).not.toHaveBeenCalled() + }) + it('prepares Codex launch state for the workspace before spawning an interactive tab', async () => { const workspacePath = '/repo/worktrees/new-feature' const resolveHome = vi.fn( @@ -1729,6 +1834,103 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME) }) + it('overrides an unmarked custom home for an authoritative daemon resume', async () => { + const daemonSpawn = setupDaemonAdapter() + const selectedHome = vi.fn(() => '/managed/current/home') + const systemHome = '/Users/example/.codex' + handlers.clear() + registerPtyHandlers( + mainWindow as never, + undefined, + selectedHome, + undefined, + undefined, + undefined, + { prepareCodexSessionResume: async () => ({ codexHomePath: systemHome }) } + ) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'codex resume session-a', + env: { CODEX_HOME: '/custom/codex', REMOVE_ME: 'stale' }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'REMOVE_ME'], + launchAgent: 'codex', + resumeProviderSession: { + key: 'session_id', + id: 'session-a', + transcriptPath: `${systemHome}/sessions/2026/07/20/rollout-a.jsonl` + } + }) + + const env = daemonSpawn.mock.calls.at(-1)![0].env + expect(selectedHome).not.toHaveBeenCalled() + expect(env.CODEX_HOME).toBe(systemHome) + expect(env.ORCA_CODEX_HOME).toBe(systemHome) + expect(env.REMOVE_ME).toBeUndefined() + }) + + it('keeps the authoritative home for runtime-created daemon resumes', async () => { + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + command: string + env: Record + envToDelete: string[] + launchAgent: 'codex' + resumeProviderSession: { + key: 'session_id' + id: string + transcriptPath: string + } + }): Promise<{ id: string }> + } + const daemonSpawn = setupDaemonAdapter() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + const systemHome = '/Users/example/.codex' + handlers.clear() + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + undefined, + { prepareCodexSessionResume: async () => ({ codexHomePath: systemHome }) } + ) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + await controller.spawn({ + cols: 80, + rows: 24, + command: 'codex resume session-a', + env: { CODEX_HOME: '/custom/codex', REMOVE_ME: 'stale' }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME', 'REMOVE_ME'], + launchAgent: 'codex', + resumeProviderSession: { + key: 'session_id', + id: 'session-a', + transcriptPath: `${systemHome}/sessions/2026/07/20/rollout-a.jsonl` + } + }) + + const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall + expect(spawnOptions.env.CODEX_HOME).toBe(systemHome) + expect(spawnOptions.env.ORCA_CODEX_HOME).toBe(systemHome) + expect(spawnOptions.env.REMOVE_ME).toBeUndefined() + expect(spawnOptions.envToDelete ?? []).not.toContain('CODEX_HOME') + expect(spawnOptions.envToDelete ?? []).not.toContain('ORCA_CODEX_HOME') + expect(spawnOptions.envToDelete).toContain('REMOVE_ME') + }) + it('prepares Codex project trust before a daemon-backed interactive launch', async () => { const workspacePath = '/repo/worktrees/new-feature' const resolveHome = vi.fn( diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index c14ba872cf6..e6b0ee07f11 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -32,7 +32,11 @@ import { } from '../../shared/pty-delivery-diagnostics' import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' import { isTuiAgent } from '../../shared/tui-agent-config' -import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' +import { + normalizeAgentProviderSession, + type AgentProviderSessionMetadata, + type SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import { isWslShellName, @@ -624,6 +628,12 @@ export type GetSelectedCodexHomePath = ( launchEnv?: NodeJS.ProcessEnv, launchContext?: { workspacePath?: string; launchAgent?: TuiAgent } ) => string | null +export type PrepareCodexSessionResume = (args: { + providerSession: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string +}) => Promise<{ codexHomePath: string | null } | null> type PrepareClaudeAuth = ( target?: ClaudeAccountSelectionTarget ) => Promise @@ -747,6 +757,12 @@ function mergePtyEnvDeletions( return Array.from(new Set([...(existingKeys ?? []), ...additionalKeys])) } +function removeCodexHomeDeletionRequests(keys: string[] | undefined): string[] | undefined { + // Why: resume provenance is launch-authoritative; late deletions must not fall back to the current account. + const filtered = keys?.filter((key) => key !== 'CODEX_HOME' && key !== 'ORCA_CODEX_HOME') + return filtered?.length ? filtered : undefined +} + function getInheritedAgentHookEnvKeysToDelete( spawnEnv: Record | undefined ): string[] { @@ -1381,6 +1397,7 @@ export function registerPtyHandlers( prepareClaudeAuth?: PrepareClaudeAuth, store?: Store, options?: { + prepareCodexSessionResume?: PrepareCodexSessionResume awaitLocalPtyStartup?: () => Promise awaitLocalPtyProviderStartup?: () => Promise // Why: returns true once for the crash-recovery reload so its did-finish-load skips the orphan sweep and keeps live PTYs (#5787). @@ -1453,10 +1470,12 @@ export function registerPtyHandlers( : { runtime: 'host' } const selectedCodexHomePath = getCompatibleSelectedCodexHomePath( codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: ctx?.cwd, - launchAgent: ctx?.launchAgent - }) ?? null + ctx?.codexHomePathOverride + ? ctx.codexHomePathOverride.value + : (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { + workspacePath: ctx?.cwd, + launchAgent: ctx?.launchAgent + }) ?? null) ) const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath const env = buildPtyHostEnv(id, baseEnv, { @@ -2699,6 +2718,29 @@ export function registerPtyHandlers( } } + const prepareCodexResumeHome = (args: { + connectionId?: string | null + launchAgent?: TuiAgent + providerSession?: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string + }): Promise<{ codexHomePath: string | null } | null> | null => { + if (args.connectionId || args.launchAgent !== 'codex' || !options?.prepareCodexSessionResume) { + return null + } + const providerSession = normalizeAgentProviderSession(args.providerSession) + if (!providerSession) { + return null + } + return options.prepareCodexSessionResume({ + providerSession, + target: args.target, + launchEnv: args.launchEnv, + workspacePath: args.workspacePath + }) + } + // Why: route through getProviderForPty() so CLI commands work for remote PTYs too; localProvider would silently fail for them. runtime?.setPtyController({ spawn: async (args) => { @@ -2729,6 +2771,15 @@ export function registerPtyHandlers( cwd, terminalRuntimeOptions.terminalWindowsWslDistro ?? null ) + const codexResumePreparation = prepareCodexResumeHome({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + providerSession: args.resumeProviderSession, + target: codexSelectionTarget, + launchEnv: args.env, + workspacePath: cwd + }) + const codexResumeHome = codexResumePreparation ? await codexResumePreparation : null const claudeAuth = isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(codexSelectionTarget) : null if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { @@ -2798,10 +2849,12 @@ export function registerPtyHandlers( const selectedCodexHomePath = isDaemonHostSpawn ? getCompatibleSelectedCodexHomePath( codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, env, { - workspacePath: cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null + codexResumeHome + ? codexResumeHome.codexHomePath + : (getSelectedCodexHomePath?.(codexSelectionTarget, env, { + workspacePath: cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null) ) : null const skipCodexHomeEnv = @@ -2849,6 +2902,9 @@ export function registerPtyHandlers( env, ...(isMintedSessionId ? { isNewSession: true } : {}) } + if (!isDaemonHostSpawn && codexResumeHome) { + spawnOptions.codexHomePathOverride = { value: codexResumeHome.codexHomePath } + } spawnOptions.envToDelete = mergePtyEnvDeletions( mergePtyEnvDeletions(authEnvToDelete, args.envToDelete ?? []), isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : [] @@ -2865,6 +2921,9 @@ export function registerPtyHandlers( 'ORCA_CODEX_HOME' ]) } + if (codexResumeHome?.codexHomePath) { + spawnOptions.envToDelete = removeCodexHomeDeletionRequests(spawnOptions.envToDelete) + } deleteRequestedEnvKeys(env, spawnOptions.envToDelete) promoteAgentTeamsShimPath(env, requestedAgentTeamsPath) if (args.command !== undefined) { @@ -3466,6 +3525,7 @@ export function registerPtyHandlers( command?: string commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery connectionId?: string | null @@ -3686,13 +3746,24 @@ export function registerPtyHandlers( cwd, terminalRuntimeOptions.terminalWindowsWslDistro ?? null ) + const codexResumePreparation = prepareCodexResumeHome({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + providerSession: args.resumeProviderSession, + target: codexSelectionTarget, + launchEnv: baseEnv, + workspacePath: cwd + }) + const codexResumeHome = codexResumePreparation ? await codexResumePreparation : null const selectedCodexHomePath = isDaemonHostSpawn ? getCompatibleSelectedCodexHomePath( codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null + codexResumeHome + ? codexResumeHome.codexHomePath + : (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { + workspacePath: cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null) ) : null const skipCodexHomeEnv = @@ -3754,7 +3825,7 @@ export function registerPtyHandlers( const envToDelete = claudeAuth?.stripAuthEnv ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] : undefined - const combinedEnvToDelete = mergePtyEnvDeletions( + let combinedEnvToDelete = mergePtyEnvDeletions( mergePtyEnvDeletions( mergePtyEnvDeletions( mergePtyEnvDeletions( @@ -3769,6 +3840,9 @@ export function registerPtyHandlers( // main cannot safely decide ownership for a process it may not parent. stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : [] ) + if (codexResumeHome?.codexHomePath) { + combinedEnvToDelete = removeCodexHomeDeletionRequests(combinedEnvToDelete) + } deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete) promoteAgentTeamsShimPath(spawnEnv, requestedAgentTeamsPath) const spawnOptions: PtySpawnOptions = { @@ -3778,6 +3852,9 @@ export function registerPtyHandlers( env: spawnEnv, ...(isMintedSessionId ? { isNewSession: true } : {}) } + if (!isDaemonHostSpawn && codexResumeHome) { + spawnOptions.codexHomePathOverride = { value: codexResumeHome.codexHomePath } + } if (combinedEnvToDelete) { spawnOptions.envToDelete = combinedEnvToDelete } @@ -4888,7 +4965,8 @@ export function registerHeadlessPtyRuntime( getSelectedCodexHomePath?: GetSelectedCodexHomePath, getSettings?: () => GlobalSettings, prepareClaudeAuth?: PrepareClaudeAuth, - store?: Store + store?: Store, + prepareCodexSessionResume?: PrepareCodexSessionResume ): void { // Why: headless `orca serve` has no renderer window but still needs the same PTY handlers so remote clients can drive terminals. const headlessWindow = { @@ -4905,7 +4983,8 @@ export function registerHeadlessPtyRuntime( getSelectedCodexHomePath, getSettings, prepareClaudeAuth, - store + store, + { prepareCodexSessionResume } ) } diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 9eff8feeacf..bdbcae7f266 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -471,6 +471,7 @@ export type LocalPtyProviderOptions = { ctx?: { command?: string launchAgent?: PtySpawnOptions['launchAgent'] + codexHomePathOverride?: PtySpawnOptions['codexHomePathOverride'] cwd?: string shellPath?: string isWsl?: boolean @@ -673,6 +674,7 @@ export class LocalPtyProvider implements IPtyProvider { ? this.opts.buildSpawnEnv(id, spawnEnv, { command: args.command, launchAgent: args.launchAgent, + codexHomePathOverride: args.codexHomePathOverride, cwd, shellPath, isWsl: isWslShell, diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 6523736fd2b..fca74501c10 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -56,6 +56,8 @@ export type PtySpawnOptions = { cwd?: string env?: Record envToDelete?: string[] + /** Main-validated home provenance for an automatic Codex session resume. */ + codexHomePathOverride?: { value: string | null } command?: string commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: StartupCommandDelivery diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c7271fa7f74..33c8085e64e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -151,7 +151,10 @@ import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' -import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events' import type { SshConnectionState } from '../../shared/ssh-types' @@ -1075,6 +1078,7 @@ type TerminalCreateOptions = { env?: Record envToDelete?: string[] launchConfig?: WorktreeStartupLaunch['launchConfig'] + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' @@ -1268,6 +1272,7 @@ type RuntimePtyController = { startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] env?: Record envToDelete?: string[] + resumeProviderSession?: AgentProviderSessionMetadata telemetry?: WorktreeStartupLaunch['telemetry'] connectionId?: string | null worktreeId?: string @@ -19205,6 +19210,7 @@ export class OrcaRuntimeService { launchOpts.envToDelete, agentTeamsPlan?.envToDelete ), + resumeProviderSession: launchOpts.resumeProviderSession, telemetry: launchOpts.telemetry, connectionId: workspace.connectionId, worktreeId: workspace.id, @@ -19348,6 +19354,9 @@ export class OrcaRuntimeService { cwd, ...(launchOpts.env ? { env: launchOpts.env } : {}), ...(launchOpts.launchConfig ? { launchConfig: launchOpts.launchConfig } : {}), + ...(launchOpts.resumeProviderSession + ? { resumeProviderSession: launchOpts.resumeProviderSession } + : {}), ...(launchOpts.launchToken ? { launchToken: launchOpts.launchToken } : {}), ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 4be8d98c94d..62a48271e13 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -913,6 +913,13 @@ const TerminalCreateParams = z.object({ agentEnv: z.record(z.string(), z.string()) }) .optional(), + resumeProviderSession: z + .object({ + key: z.enum(['session_id', 'conversation_id']), + id: z.string().min(1).max(512), + transcriptPath: z.string().min(1).max(32_768).optional() + }) + .optional(), launchToken: OptionalString, launchAgent: z.string().refine(isTuiAgent).optional(), title: OptionalString, @@ -1348,6 +1355,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ env: params.env, envToDelete: params.envToDelete, ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), + ...(params.resumeProviderSession + ? { resumeProviderSession: params.resumeProviderSession } + : {}), ...(params.launchToken ? { launchToken: params.launchToken } : {}), ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), title: params.title, diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 55f1265ea48..39ea7ff2a6a 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -12,7 +12,12 @@ import type { import { registerRepoHandlers } from '../ipc/repos' import { registerWorktreeHandlers } from '../ipc/worktrees' import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup' -import { getLocalPtyProvider, registerPtyHandlers, type GetSelectedCodexHomePath } from '../ipc/pty' +import { + getLocalPtyProvider, + registerPtyHandlers, + type GetSelectedCodexHomePath, + type PrepareCodexSessionResume +} from '../ipc/pty' import { registerDaemonManagementHandlers } from '../ipc/pty-management' import { registerSshHandlers } from '../ipc/ssh' import { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' @@ -70,6 +75,7 @@ export function attachMainWindowServices( target?: ClaudeAccountSelectionTarget ) => Promise, options?: { + prepareCodexSessionResume?: PrepareCodexSessionResume awaitLocalPtyStartup?: () => Promise awaitLocalPtyProviderStartup?: () => Promise onBeforeRendererReload?: (args: { webContentsId: number; ignoreCache: boolean }) => void @@ -93,6 +99,7 @@ export function attachMainWindowServices( prepareClaudeAuth, store, { + prepareCodexSessionResume: options?.prepareCodexSessionResume, awaitLocalPtyStartup: options?.awaitLocalPtyStartup, awaitLocalPtyProviderStartup: options?.awaitLocalPtyProviderStartup, isRecoveryReloadInFlight: options?.isRecoveryReloadInFlight diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index d85797fdc1d..b031ff3aff4 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -63,7 +63,10 @@ import type { TaskSourceContext } from '../shared/task-source-context' import type { LinearIssueAttributeFilter } from '../shared/linear-issue-attribute-filter' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../shared/codex-startup-delivery' -import type { SleepingAgentLaunchConfig } from '../shared/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../shared/agent-session-resume' import type { LocalhostWorktreeLabelResult, LocalhostWorktreeLabelRoute @@ -1290,6 +1293,7 @@ export type PreloadApi = { envToDelete?: string[] command?: string launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery @@ -2878,6 +2882,7 @@ export type PreloadApi = { cwd?: string env?: Record launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' diff --git a/src/preload/index.ts b/src/preload/index.ts index 04181c3b812..eed0de1c6d5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -14,7 +14,10 @@ import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../shared/codex-startup-delivery' -import type { SleepingAgentLaunchConfig } from '../shared/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../shared/agent-session-resume' import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' import type { @@ -797,6 +800,7 @@ const api = { envToDelete?: string[] command?: string launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery @@ -3487,6 +3491,7 @@ const api = { cwd?: string env?: Record launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' @@ -3510,6 +3515,7 @@ const api = { cwd?: string env?: Record launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index e783514977a..f03f009b56d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -6,7 +6,10 @@ import type { EventProps } from '../../../../shared/telemetry-events' import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import type { SetupSplitDirection, TuiAgent } from '../../../../shared/types' -import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../../../shared/agent-session-resume' import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' export type PtyConnectionDeps = { @@ -22,6 +25,7 @@ export type PtyConnectionDeps = { env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent draftPrompt?: string diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 7900adf3fe5..b3b5383ee28 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -7444,7 +7444,11 @@ describe('connectPanePty', () => { updatedAt: 1, stateStartedAt: 1, stateHistory: [], - providerSession: { key: 'session_id', id: 'codex-session-1' } + providerSession: { + key: 'session_id', + id: 'codex-session-1', + transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl' + } } } } as StoreState @@ -7470,6 +7474,11 @@ describe('connectPanePty', () => { expect.objectContaining({ sessionId: 'lost-pty', command: "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-1'", + resumeProviderSession: { + key: 'session_id', + id: 'codex-session-1', + transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl' + }, env: expect.objectContaining({ ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: 'tab-1', diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 78b96cdafc9..4889b090b3d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -235,6 +235,7 @@ import { agentProviderSessionsEqual, isResumableTuiAgent, normalizeAgentProviderSession, + type AgentProviderSessionMetadata, type ResumableTuiAgent, type SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' @@ -481,6 +482,7 @@ type FreshSpawnOptions = { type ColdRestoreAgentResumeStartup = PendingStartupCommand & { agent: ResumableTuiAgent + resumeProviderSession: AgentProviderSessionMetadata launchConfig: NonNullable>['launchConfig'] launchToken: string useLiveEntry: boolean @@ -3348,6 +3350,9 @@ export function connectPanePty( ...(projectRuntime ? { projectRuntime } : {}), ...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}), ...(paneStartup?.launchConfig ? { launchConfig: paneStartup.launchConfig } : {}), + ...(paneStartup?.resumeProviderSession + ? { resumeProviderSession: paneStartup.resumeProviderSession } + : {}), ...(launchToken ? { launchToken } : {}), ...(paneStartup?.launchAgent ? { launchAgent: paneStartup.launchAgent } : {}), ...(paneStartup?.telemetry ? { telemetry: paneStartup.telemetry } : {}), @@ -4487,6 +4492,7 @@ export function connectPanePty( ORCA_AGENT_LAUNCH_TOKEN: coldRestoreLaunchToken }, launchConfig: startupPlan.launchConfig, + resumeProviderSession: providerSession, launchToken: coldRestoreLaunchToken, useLiveEntry: Boolean(useLiveEntry), hasSleepingRecord: Boolean(sleepingRecord), @@ -4700,6 +4706,9 @@ export function connectPanePty( ? { env: mergeStartupEnvWithPaneIdentity(startupOverride.env) } : {}), ...(coldRestoreOverride ? { launchConfig: coldRestoreOverride.launchConfig } : {}), + ...(coldRestoreOverride + ? { resumeProviderSession: coldRestoreOverride.resumeProviderSession } + : {}), ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), @@ -7552,6 +7561,9 @@ export function connectPanePty( ...(coldRestoreStartup?.launchConfig ? { launchConfig: coldRestoreStartup.launchConfig } : {}), + ...(coldRestoreStartup?.resumeProviderSession + ? { resumeProviderSession: coldRestoreStartup.resumeProviderSession } + : {}), ...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}), @@ -7763,6 +7775,9 @@ export function connectPanePty( ...(coldRestoreStartup?.launchConfig ? { launchConfig: coldRestoreStartup.launchConfig } : {}), + ...(coldRestoreStartup?.resumeProviderSession + ? { resumeProviderSession: coldRestoreStartup.resumeProviderSession } + : {}), ...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index b1cda244784..4507b91e185 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -1,5 +1,8 @@ import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' -import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../../../shared/agent-session-resume' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import type { EventProps } from '../../../../shared/telemetry-events' @@ -86,6 +89,7 @@ export type PtyTransport = { env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery @@ -144,6 +148,7 @@ export type IpcPtyTransportOptions = { envToDelete?: string[] command?: string launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index a585199df49..3482e156b13 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -93,6 +93,21 @@ describe('createIpcPtyTransport', () => { ) }) + it('forwards automatic resume provenance to the PTY spawn', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + const resumeProviderSession = { + key: 'session_id' as const, + id: 'session-a', + transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-a.jsonl' + } + const transport = createIpcPtyTransport({ resumeProviderSession }) + + await transport.connect({ url: '', callbacks: {} }) + + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ resumeProviderSession })) + }) + it('leaves the transport silently unbound after a failed connect — sendInput drops with no write IPC (frozen-terminal repro)', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawn = window.api.pty.spawn as unknown as ReturnType diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index e40c5d9103c..8e1a0380a58 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -468,6 +468,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra envToDelete, command, launchConfig, + resumeProviderSession, launchToken, launchAgent, startupCommandDelivery, @@ -686,6 +687,11 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra ...((options.launchConfig ?? launchConfig) ? { launchConfig: options.launchConfig ?? launchConfig } : {}), + ...((options.resumeProviderSession ?? resumeProviderSession) + ? { + resumeProviderSession: options.resumeProviderSession ?? resumeProviderSession + } + : {}), ...((options.launchToken ?? launchToken) ? { launchToken: options.launchToken ?? launchToken } : {}), diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index ea7e6dae470..c867696b444 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -1180,6 +1180,11 @@ describe('createRemoteRuntimePtyTransport', () => { }, launchToken: 'fresh-token', launchAgent: 'codex', + resumeProviderSession: { + key: 'session_id', + id: 'session-1', + transcriptPath: '/home/example/.codex/sessions/2026/07/20/rollout-a.jsonl' + }, callbacks: {} }) @@ -1195,7 +1200,12 @@ describe('createRemoteRuntimePtyTransport', () => { agentEnv: { CODEX_PROFILE: 'captured' } }, launchToken: 'fresh-token', - launchAgent: 'codex' + launchAgent: 'codex', + resumeProviderSession: { + key: 'session_id', + id: 'session-1', + transcriptPath: '/home/example/.codex/sessions/2026/07/20/rollout-a.jsonl' + } }) }) ) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index f5edb5ab6d0..744c7a3701c 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -68,6 +68,7 @@ export function createRemoteRuntimePtyTransport( env, envToDelete, launchConfig, + resumeProviderSession, launchToken, launchAgent, worktreeId, @@ -778,6 +779,7 @@ export function createRemoteRuntimePtyTransport( const envToSend = options.env ?? env const envToDeleteToSend = options.envToDelete ?? envToDelete const launchConfigToSend = options.launchConfig ?? launchConfig + const resumeProviderSessionToSend = options.resumeProviderSession ?? resumeProviderSession const launchTokenToSend = options.launchToken ?? launchToken const launchAgentToSend = options.launchAgent ?? launchAgent const created = await callRuntime<{ terminal: RuntimeTerminalCreate }>('terminal.create', { @@ -789,6 +791,9 @@ export function createRemoteRuntimePtyTransport( ...(envToSend !== undefined ? { env: envToSend } : {}), ...(envToDeleteToSend !== undefined ? { envToDelete: envToDeleteToSend } : {}), ...(launchConfigToSend !== undefined ? { launchConfig: launchConfigToSend } : {}), + ...(resumeProviderSessionToSend !== undefined + ? { resumeProviderSession: resumeProviderSessionToSend } + : {}), ...(launchTokenToSend !== undefined ? { launchToken: launchTokenToSend } : {}), ...(launchAgentToSend !== undefined ? { launchAgent: launchAgentToSend } : {}), tabId, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 05ddb589fc1..453cb1d23dd 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -50,7 +50,10 @@ import type { import type { TerminalPaneSplitSource } from '../../../../shared/feature-education-telemetry' import type { EventProps } from '../../../../shared/telemetry-events' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' -import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../../../shared/agent-session-resume' import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts' import { buildFontFamily, @@ -209,7 +212,9 @@ type UseTerminalPaneLifecycleDeps = { delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery env?: Record + envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent draftPrompt?: string diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 5d02b630ce6..7ad315bbbfe 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1387,6 +1387,7 @@ export function useIpcEvents(): void { cwd, env, launchConfig, + resumeProviderSession, launchToken, launchAgent, viewMode, @@ -1572,6 +1573,7 @@ export function useIpcEvents(): void { command, ...(env ? { env } : {}), ...(launchConfig ? { launchConfig } : {}), + ...(resumeProviderSession ? { resumeProviderSession } : {}), ...(launchToken ? { launchToken } : {}), ...(launchAgent ? { launchAgent } : {}) }) @@ -1723,6 +1725,9 @@ export function useIpcEvents(): void { ...(data.env ? { env: data.env } : {}), ...(data.envToDelete ? { envToDelete: data.envToDelete } : {}), ...(data.launchConfig ? { launchConfig: data.launchConfig } : {}), + ...(data.resumeProviderSession + ? { resumeProviderSession: data.resumeProviderSession } + : {}), ...(data.launchToken ? { launchToken: data.launchToken } : {}), ...(data.launchAgent ? { launchAgent: data.launchAgent } : {}), ...(data.startupCommandDelivery diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 5c8dfb9cf9d..02822507eec 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -32,7 +32,10 @@ import type { } from './mobile-markdown-document' import type { RuntimeCapability } from './protocol-version' import type { RemoteRuntimeSharedConnectionDiagnostics } from './remote-runtime-shared-control-types' -import type { SleepingAgentLaunchConfig } from './agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from './agent-session-resume' import type { StartupCommandDelivery } from './codex-startup-delivery' export type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult } @@ -501,6 +504,7 @@ type RuntimeTerminalCreateBaseRequestPayload = { env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat'