diff --git a/src/main/ai-vault/session-scanner-codex-parser.test.ts b/src/main/ai-vault/session-scanner-codex-parser.test.ts index 2025a1e89dc..396e43c1b44 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.test.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.test.ts @@ -108,6 +108,78 @@ describe('parseCodexSessionFile', () => { }) }) + // A fork of a legacy-history thread copies the parent's session_meta in after its own. + it("keeps a fork's own session_meta when its parent's follows it", async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-fork-')) + tempRoots.push(root) + const sessionPath = join(root, 'sessions', '2026', '10', '07', 'rollout-fork.jsonl') + await mkdir(dirname(sessionPath), { recursive: true }) + + await writeFile( + sessionPath, + jsonLines([ + { + timestamp: '2026-10-07T10:00:00.000Z', + type: 'session_meta', + payload: { + id: 'fork-session', + forked_from_id: 'parent-session', + cwd: '/repo/fork', + git: { branch: 'fork-branch' } + } + }, + { + timestamp: '2026-10-07T09:00:00.000Z', + type: 'session_meta', + payload: { + id: 'parent-session', + title: 'Parent chat', + history_mode: 'paginated', + cwd: '/repo/parent', + git: { branch: 'parent-branch' } + } + }, + { + timestamp: '2026-10-07T09:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'Copied prompt' }] + } + }, + { + timestamp: '2026-10-07T09:00:02.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'Copied reply' }] + } + } + ]) + ) + + const sessionStat = await stat(sessionPath) + const session = await parseCodexSessionFile( + { + path: sessionPath, + mtimeMs: sessionStat.mtimeMs, + modifiedAt: sessionStat.mtime.toISOString() + }, + 'darwin', + root + ) + + expect(session).toMatchObject({ + sessionId: 'fork-session', + cwd: '/repo/fork', + branch: 'fork-branch', + title: 'Copied prompt', + messageCount: 2 + }) + }) + it('uses user-message events instead of later injected user-role records', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-last-prompt-')) tempRoots.push(root) diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts index f1be610f566..b5e9714ea97 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -142,7 +142,9 @@ function consumeCodexRecordLine(state: CodexSessionParseState, line: string): vo updateTimeline(accumulator, extractString(record.timestamp)) const payload = asRecord(record.payload) - if (record.type === 'session_meta' && payload) { + // Codex keeps the first session_meta canonical; a fork copies its parent's in after it, and + // such a later one is ignored below like any other record type this parser does not read. + if (record.type === 'session_meta' && payload && !state.sawSessionMeta) { state.nonUserOrigin = readCodexNonUserOrigin(payload) if (state.nonUserOrigin) { return diff --git a/src/main/ai-vault/structured-session-ownership.test.ts b/src/main/ai-vault/structured-session-ownership.test.ts index f708023c087..3307032a340 100644 --- a/src/main/ai-vault/structured-session-ownership.test.ts +++ b/src/main/ai-vault/structured-session-ownership.test.ts @@ -165,6 +165,50 @@ describe('structured AI Vault ownership', () => { ) ).resolves.toBeUndefined() }) + + // A fork writes a new conversation, so "Resume in New CLI" on a chat-owned row must pass. + it.each([ + { + provider: 'claude' as const, + command: `claude '--resume' '${PROVIDER_SESSION}' '--fork-session'` + }, + { provider: 'claude' as const, command: `claude --continue --fork-session` }, + { provider: 'codex' as const, command: `CODEX_HOME=/h codex 'fork' '${PROVIDER_SESSION}'` } + ])('allows a fork of the owned session: $command', async ({ provider, command }) => { + installOwnership({ provider }) + + await expect( + assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined) + ).resolves.toBeUndefined() + }) + + it.each([ + // After `--` the flag is prompt text, so Claude resumes the owned session as a writer. + `claude --resume ${PROVIDER_SESSION} -- --fork-session`, + // `--session-id` makes the fork write under the id it names. + `claude --resume ${PROVIDER_SESSION} --fork-session --session-id ${PROVIDER_SESSION}`, + `claude --resume ${PROVIDER_SESSION} --fork-session --session-id=${PROVIDER_SESSION}` + ])('refuses a fork flag that does not make a fork: %s', async (command) => { + installOwnership({ provider: 'claude' }) + + await expect( + assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined) + ).rejects.toThrow('agent_session_conflict') + }) + + it('prepares a fork of the owned session but still refuses a resume of it', () => { + installOwnership() + const args = { + agent: 'codex' as const, + sessionId: PROVIDER_SESSION, + filePath: `/sessions/rollout-${PROVIDER_SESSION}.jsonl`, + codexHome: null, + executionHostId: 'local' as const + } + + expect(() => assertLegacyAiVaultResumeAllowed({ ...args, fork: true })).not.toThrow() + expect(() => assertLegacyAiVaultResumeAllowed(args)).toThrow('agent_session_conflict') + }) }) function installOwnership(overrides: Partial = {}): void { diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts index 5a24b325430..7f4d81a4b9e 100644 --- a/src/main/ai-vault/structured-session-ownership.ts +++ b/src/main/ai-vault/structured-session-ownership.ts @@ -99,7 +99,8 @@ function ownershipLookup(): } export function assertLegacyAiVaultResumeAllowed(args: AiVaultPrepareSessionResumeArgs): void { - const ownership = findResumeOwnership(args) + // A fork writes a new conversation, and preparing its home never writes this one. + const ownership = args.fork ? null : findResumeOwnership(args) if (ownership) { refuseLegacyWriter(ownership) } @@ -202,6 +203,10 @@ function parseResumeInvocation(command: string): ResumeInvocation | null { } const provider = /codex(?:\.exe)?$/i.test(normalized[executableIndex]!) ? 'codex' : 'claude' const args = normalized.slice(executableIndex + 1) + // `codex fork` already falls through below: it carries no `resume` marker. + if (provider === 'claude' && isClaudeForkInvocation(args)) { + return null + } // `--continue`/`-c` resume the most recent session and never take an id, so a // following token is a prompt, not a target — they are always target-less. const targetlessFlags = provider === 'codex' ? [] : ['--continue', '-c'] @@ -230,6 +235,19 @@ function parseResumeInvocation(command: string): ResumeInvocation | null { } } +/** A fork reads the conversation and writes a new one, so it is no second writer. Not when + * `--session-id` names the id it writes under, and nothing after `--` is an option. */ +function isClaudeForkInvocation(args: readonly string[]): boolean { + const terminatorIndex = args.indexOf('--') + const options = (terminatorIndex === -1 ? args : args.slice(0, terminatorIndex)).map((token) => + token.toLowerCase() + ) + return ( + options.includes('--fork-session') && + !options.some((token) => token === '--session-id' || token.startsWith('--session-id=')) + ) +} + function refuseLegacyWriter(ownership: StructuredProviderSessionOwnership): never { throw new Error( agentSessionLeaseAdmitsWriter(ownership.lease) diff --git a/src/main/codex/codex-ai-vault-session-resume.test.ts b/src/main/codex/codex-ai-vault-session-resume.test.ts index 97fa5738e7c..b68e672c053 100644 --- a/src/main/codex/codex-ai-vault-session-resume.test.ts +++ b/src/main/codex/codex-ai-vault-session-resume.test.ts @@ -4,6 +4,15 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' import { prepareCodexAiVaultSessionResume } from './codex-ai-vault-session-resume' +import { getOrcaManagedCodexHomePath } from './codex-home-paths' + +const ROLLOUT_RELATIVE_PATH = join( + 'sessions', + '2026', + '08', + '17', + 'rollout-2026-08-17T10-00-00-session.jsonl' +) // Why: session backfill resolves Orca's managed Codex home from userData, which otherwise resolves to the live one. let userDataRoot: string @@ -12,6 +21,7 @@ beforeEach(() => { vi.stubEnv('ORCA_USER_DATA_PATH', userDataRoot) }) afterEach(() => { + vi.restoreAllMocks() vi.unstubAllEnvs() rmSync(userDataRoot, { recursive: true, force: true }) }) @@ -21,20 +31,15 @@ describe('prepareCodexAiVaultSessionResume', () => { let peerHome: string let selectedHome: string let peerRolloutPath: string + let preparePinnedLaunchHome: ReturnType Promise>> beforeEach(() => { + preparePinnedLaunchHome = vi.fn(async () => undefined) root = mkdtempSync(join(tmpdir(), 'orca-codex-ai-vault-resume-')) peerHome = join(root, 'codex-accounts', 'account-a', 'home') selectedHome = join(root, 'codex-accounts', 'account-b', 'home') - const relativeRolloutPath = join( - 'sessions', - '2026', - '08', - '17', - 'rollout-2026-08-17T10-00-00-session.jsonl' - ) - peerRolloutPath = join(peerHome, relativeRolloutPath) - const selectedRolloutPath = join(selectedHome, relativeRolloutPath) + peerRolloutPath = join(peerHome, ROLLOUT_RELATIVE_PATH) + const selectedRolloutPath = join(selectedHome, ROLLOUT_RELATIVE_PATH) mkdirSync(dirname(peerRolloutPath), { recursive: true }) mkdirSync(dirname(selectedRolloutPath), { recursive: true }) writeFileSync(peerRolloutPath, '{"type":"session_meta"}\n', 'utf-8') @@ -67,8 +72,109 @@ describe('prepareCodexAiVaultSessionResume', () => { it('preserves deliberate no-selection behavior for a proven-untrusted home', async () => { await expect(prepare(() => null)).resolves.toEqual({ useRealCodexHome: false }) + expect(preparePinnedLaunchHome).not.toHaveBeenCalled() }) + // A fork tab has no provider session, so no pane spawn readies the home it keeps. + it('readies the row home for a fork that keeps it', async () => { + await expect(prepareFork()).resolves.toEqual({ useRealCodexHome: false }) + expect(preparePinnedLaunchHome).toHaveBeenCalledExactlyOnceWith(peerHome) + }) + + it('leaves a repinned fork to the pane spawn, which readies the selected home', async () => { + await expect(prepareFork({ resolveSelectedHome: () => selectedHome })).resolves.toEqual({ + useRealCodexHome: false, + substituteCodexHome: selectedHome + }) + expect(preparePinnedLaunchHome).not.toHaveBeenCalled() + }) + + it('readies no home for a fork from a home this host does not list', async () => { + await expect(prepareFork({ discoveryHomes: [selectedHome] })).resolves.toEqual({ + useRealCodexHome: false + }) + expect(preparePinnedLaunchHome).not.toHaveBeenCalled() + }) + + it.each([ + ['no runtime home service', { runtimeHome: null }], + ['a rollout on another host', { executionHostId: 'runtime:paired' }], + ['a WSL home', { codexHome: String.raw`\\wsl.localhost\Ubuntu\home\u\.codex` }] + ] as const)('never looks for a fork home with %s', async (_name, overrides) => { + const discovery = vi.fn(() => [peerHome, selectedHome]) + + await expect(prepareFork({ ...overrides, discovery })).resolves.toEqual({ + useRealCodexHome: false + }) + expect(discovery).not.toHaveBeenCalled() + expect(preparePinnedLaunchHome).not.toHaveBeenCalled() + }) + + it('leaves a fork moved into the real home to the pane spawn', async () => { + const legacyHome = getOrcaManagedCodexHomePath() + const legacyRolloutPath = join(legacyHome, ROLLOUT_RELATIVE_PATH) + mkdirSync(dirname(legacyRolloutPath), { recursive: true }) + writeFileSync(legacyRolloutPath, '{"type":"session_meta"}\n', 'utf-8') + + await expect( + prepareFork({ + codexHome: legacyHome, + filePath: legacyRolloutPath, + isSystemDefaultRealHome: () => true, + discoveryHomes: [legacyHome] + }) + ).resolves.toEqual({ useRealCodexHome: true }) + expect(preparePinnedLaunchHome).not.toHaveBeenCalled() + }) + + it('still answers a fork when readying its home fails', async () => { + preparePinnedLaunchHome.mockRejectedValue(new Error('hooks file unreadable')) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(prepareFork()).resolves.toEqual({ useRealCodexHome: false }) + expect(preparePinnedLaunchHome).toHaveBeenCalledOnce() + }) + + function prepareFork( + overrides: { + resolveSelectedHome?: () => string | null + isSystemDefaultRealHome?: () => boolean + discoveryHomes?: readonly string[] + discovery?: () => string[] + runtimeHome?: null + executionHostId?: string + codexHome?: string + filePath?: string + } = {} + ) { + const runtimeHome = + overrides.runtimeHome === null + ? null + : { + isHostSystemDefaultRealHomeSelected: overrides.isSystemDefaultRealHome ?? (() => false), + resolveSelectedHostAccountCodexHomePathForResume: + overrides.resolveSelectedHome ?? (() => null), + getHostCodexHomePathsForSessionDiscovery: + overrides.discovery ?? + (() => [...(overrides.discoveryHomes ?? [peerHome, selectedHome])]) + } + return prepareCodexAiVaultSessionResume( + { + agent: 'codex', + filePath: overrides.filePath ?? peerRolloutPath, + codexHome: overrides.codexHome ?? peerHome, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the test feeds host ids the RPC may receive; the function only compares them. + executionHostId: (overrides.executionHostId ?? 'local') as 'local', + fork: true + }, + { + runtimeHome, + systemCodexHomePath: join(root, 'system-codex-home'), + preparePinnedLaunchHome + } + ) + } + function prepare( resolveSelectedHome: () => string | null, isSystemDefaultRealHome: () => boolean = () => false @@ -83,9 +189,11 @@ describe('prepareCodexAiVaultSessionResume', () => { { runtimeHome: { isHostSystemDefaultRealHomeSelected: isSystemDefaultRealHome, - resolveSelectedHostAccountCodexHomePathForResume: resolveSelectedHome + resolveSelectedHostAccountCodexHomePathForResume: resolveSelectedHome, + getHostCodexHomePathsForSessionDiscovery: () => [peerHome, selectedHome] }, - systemCodexHomePath: join(root, 'system-codex-home') + systemCodexHomePath: join(root, 'system-codex-home'), + preparePinnedLaunchHome } ) } diff --git a/src/main/codex/codex-ai-vault-session-resume.ts b/src/main/codex/codex-ai-vault-session-resume.ts index e2932e029af..3a4201060dc 100644 --- a/src/main/codex/codex-ai-vault-session-resume.ts +++ b/src/main/codex/codex-ai-vault-session-resume.ts @@ -2,27 +2,77 @@ import type { AiVaultPrepareSessionResumeArgs, AiVaultPrepareSessionResumeResult } from '../../shared/ai-vault-resume-preparation' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { parseWslUncPath } from '../../shared/wsl-paths' import type { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service' +import { getSystemCodexHomePath } from './codex-home-paths' import { prepareLegacySharedCodexSessionResume } from './codex-legacy-session-resume' +import { + resolveTrustedCodexSessionResumeHome, + trustedCodexResumeHomes +} from './codex-session-resume-home' type CodexAiVaultRuntimeHome = Pick< CodexRuntimeHomeService, - 'isHostSystemDefaultRealHomeSelected' | 'resolveSelectedHostAccountCodexHomePathForResume' + | 'isHostSystemDefaultRealHomeSelected' + | 'resolveSelectedHostAccountCodexHomePathForResume' + | 'getHostCodexHomePathsForSessionDiscovery' > +type CodexAiVaultResumeOptions = { + runtimeHome: CodexAiVaultRuntimeHome | null + systemCodexHomePath: string | undefined + /** Readies a home a pane will pin as CODEX_HOME instead of the selected account's. */ + preparePinnedLaunchHome: (home: string) => Promise +} + /** Keeps window and serve AI Vault resumes behind the same refusing account-home gate. */ -export function prepareCodexAiVaultSessionResume( +export async function prepareCodexAiVaultSessionResume( args: AiVaultPrepareSessionResumeArgs, - options: { - runtimeHome: CodexAiVaultRuntimeHome | null - systemCodexHomePath: string | undefined - } + options: CodexAiVaultResumeOptions ): Promise { - return prepareLegacySharedCodexSessionResume(args, { + const result = await prepareLegacySharedCodexSessionResume(args, { isHostSystemDefaultRealHomeSelected: () => options.runtimeHome?.isHostSystemDefaultRealHomeSelected() === true, getSelectedHostAccountCodexHomePath: () => options.runtimeHome?.resolveSelectedHostAccountCodexHomePathForResume() ?? null, systemCodexHomePath: options.systemCodexHomePath }) + // Why: a fork carries no provider session, so the pane spawn never readies the home a resume + // would; when it keeps the row's own home, that home needs the same hooks here. + if (args.fork && !result.useRealCodexHome && !result.substituteCodexHome) { + try { + await readyForkLaunchHome(args, options) + } catch (error) { + // Why: hook setup is best-effort; the fork still launches without it. + console.warn('[codex-ai-vault-resume] could not ready the fork launch home:', error) + } + } + return result +} + +async function readyForkLaunchHome( + args: AiVaultPrepareSessionResumeArgs, + options: CodexAiVaultResumeOptions +): Promise { + if ( + !options.runtimeHome || + !args.codexHome || + args.executionHostId !== LOCAL_EXECUTION_HOST_ID || + parseWslUncPath(args.codexHome) !== null + ) { + return + } + // Only a home this host lists that holds this rollout: the proof a resume needs before it pins one. + const home = resolveTrustedCodexSessionResumeHome({ + transcriptPath: args.filePath, + trustedCodexHomes: trustedCodexResumeHomes(options.runtimeHome, getSystemCodexHomePath()) + }) + if ( + home && + normalizeRuntimePathForComparison(home) === normalizeRuntimePathForComparison(args.codexHome) + ) { + await options.preparePinnedLaunchHome(home) + } } diff --git a/src/main/codex/codex-session-resume-home.ts b/src/main/codex/codex-session-resume-home.ts index 86186333347..a9845840b64 100644 --- a/src/main/codex/codex-session-resume-home.ts +++ b/src/main/codex/codex-session-resume-home.ts @@ -100,6 +100,15 @@ function resolveTrustedCodexSessionResume(args: { return null } +/** The homes this host itself lists for Codex sessions: the only ones a pane may pin as CODEX_HOME. */ +export function trustedCodexResumeHomes( + runtimeHome: { getHostCodexHomePathsForSessionDiscovery(): string[] }, + systemHomePath: string +): string[] { + // Why: codexSessionSourceHome is import-only; treating it as CODEX_HOME would mutate history sources and bypass account auth. + return [systemHomePath, ...runtimeHome.getHostCodexHomePathsForSessionDiscovery()] +} + export function resolveTrustedCodexSessionResumeHome(args: { transcriptPath: string | undefined trustedCodexHomes: readonly string[] diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index 2a179b847e2..17b2a4c3967 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -226,6 +226,30 @@ describe('aiVault.prepareSessionResume', () => { executionHostId: 'local' }) }) + + it('carries a fork request through to the host preparation', async () => { + const prepareAiVaultSessionResume = vi.fn().mockResolvedValue({ useRealCodexHome: false }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only these runtime members. + const runtime = { + getRuntimeId: () => 'test-runtime', + ensureStructuredAgentSessionHost: vi.fn(async () => undefined), + prepareAiVaultSessionResume + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) + + await dispatcher.dispatch( + makeRequest('aiVault.prepareSessionResume', { + agent: 'codex', + filePath: '/managed/sessions/rollout-a.jsonl', + codexHome: '/managed', + fork: true + }) + ) + + expect(prepareAiVaultSessionResume).toHaveBeenCalledWith( + expect.objectContaining({ fork: true, executionHostId: 'local' }) + ) + }) }) // Session history and terminal resume are not chats: a process whose chats are refused still diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index 2b4c0601990..af6ff78b23a 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -112,6 +112,7 @@ export const AI_VAULT_METHODS = [ const args: AiVaultPrepareSessionResumeArgs = { agent: params.agent, ...(params.sessionId ? { sessionId: params.sessionId } : {}), + ...(params.fork ? { fork: true } : {}), filePath: params.filePath, codexHome: params.codexHome, // Why: the RPC executes on the transcript-owning host; never let a diff --git a/src/main/startup/codex-session-resume-launch.ts b/src/main/startup/codex-session-resume-launch.ts index e6ac35b04ff..cd7a6619851 100644 --- a/src/main/startup/codex-session-resume-launch.ts +++ b/src/main/startup/codex-session-resume-launch.ts @@ -1,6 +1,9 @@ import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' -import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home' +import { + trustedCodexResumeHomes, + type CodexSessionResumePreparation +} from '../codex/codex-session-resume-home' import { prepareCodexSessionResume } from '../codex/codex-session-resume-preparation' import { prepareCodexAccountRestartResume, @@ -27,8 +30,7 @@ export async function prepareCodexSessionResumeForLaunch(args: { 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, ...runtimeHome.getHostCodexHomePathsForSessionDiscovery()] + const trustedHomes = trustedCodexResumeHomes(runtimeHome, systemHomePath) // Why: resolved eagerly, once, before any ranking or provenance match. The // marker read used to be deferred into the ranking thunk so a // provenance-present resume never paid for it, but that optimisation let an @@ -87,29 +89,7 @@ export async function prepareCodexSessionResumeForLaunch(args: { : migrated.useRealCodexHome ? systemHomePath : sessionSource.homePath - const isSystemHome = - normalizeRuntimePathForComparison(resumeHome) === - normalizeRuntimePathForComparison(systemHomePath) - const isHooksEnabled = (): boolean => - isAgentStatusHooksEnabledForAgent(store.getSettings(), 'codex') - const hooksEnabled = isHooksEnabled() - try { - if (isSystemHome) { - // Why bounded: the resume waits only briefly for a reconcile; it runs on ~/.codex whatever the selection. - await reconcileCodexHooksForLaunch() - } else if (hooksEnabled) { - await codexHookService.installForLaunchPrep(resumeHome, true, isHooksEnabled) - } else { - await codexHookService.refreshRuntimeUserHooksForLaunchPrep(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) - } - if (!isSystemHome) { - // Why: this pins the resumed pane's CODEX_HOME, and hook repair above can skip or fail before its config mirror applies the daemon guard. - ensureCodexDaemonSocketGuard(resumeHome) - } + await prepareCodexPinnedLaunchHome(resumeHome, systemHomePath) return resumeHome } }) @@ -122,3 +102,32 @@ export async function prepareCodexSessionResumeForLaunch(args: { } : preparation } + +/** Hook repair and the daemon-socket guard for a pane that pins `home` as its CODEX_HOME rather + * than launching in the selected account's home, which the pane spawn prepares itself. */ +export async function prepareCodexPinnedLaunchHome( + home: string, + systemHomePath: string = getSystemCodexHomePath() +): Promise { + const isSystemHome = + normalizeRuntimePathForComparison(home) === normalizeRuntimePathForComparison(systemHomePath) + const isHooksEnabled = (): boolean => + isAgentStatusHooksEnabledForAgent(state.store?.getSettings(), 'codex') + try { + if (isSystemHome) { + // Why bounded: the resume waits only briefly for a reconcile; it runs on ~/.codex whatever the selection. + await reconcileCodexHooksForLaunch() + } else if (isHooksEnabled()) { + await codexHookService.installForLaunchPrep(home, true, isHooksEnabled) + } else { + await codexHookService.refreshRuntimeUserHooksForLaunchPrep(home) + } + } catch (error) { + // Why: hook repair is best-effort; it must not stop the pane from launching in this home. + console.warn('[codex-hook-service] failed to prepare automatic resume home:', error) + } + if (!isSystemHome) { + // Why: this pins the pane's CODEX_HOME, and hook repair above can skip or fail before its config mirror applies the daemon guard. + ensureCodexDaemonSocketGuard(home) + } +} diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 31f5986b019..5639540083a 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -13,6 +13,7 @@ import { browserManager } from '../browser/browser-manager' import { loadAgentSessionClaimSigner } from '../runtime/agent-session-claim-identity' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { prepareCodexAiVaultSessionResume } from '../codex/codex-ai-vault-session-resume' +import { prepareCodexPinnedLaunchHome } from './codex-session-resume-launch' import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { getDaemonProvider } from '../daemon/daemon-init' @@ -139,7 +140,8 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { prepareAiVaultSessionResume: (args) => prepareCodexAiVaultSessionResume(args, { runtimeHome: state.codexRuntimeHome, - systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()) + systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()), + preparePinnedLaunchHome: (home) => prepareCodexPinnedLaunchHome(home) }), prepareCodexStructuredLaunch: ({ launchEnv }) => prepareCodexRuntimeHomeForLaunch(undefined, launchEnv), diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index a68a2461a51..96c8c65c18e 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -13,7 +13,10 @@ import { handlePtyExit } from './main-process-pty-startup' import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' -import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' +import { + prepareCodexPinnedLaunchHome, + prepareCodexSessionResumeForLaunch +} from './codex-session-resume-launch' import { isRecoveryReloadInFlight } from './main-window-lifecycle-flags' import { RELAY_HOST_CLOSE_REASON } from '../../shared/relay-host-close-reason' @@ -82,7 +85,8 @@ export function attachMainWindowCoreServices( prepareAiVaultSessionResume: (args) => prepareCodexAiVaultSessionResume(args, { runtimeHome: codexRuntimeHome, - systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()) + systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()), + preparePinnedLaunchHome: (home) => prepareCodexPinnedLaunchHome(home) }), onBeforeRelaunch: async () => { state.isQuitting = true diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 9e56bc0a5a0..fdf095aec30 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -376,6 +376,7 @@ export default function AiVaultPanel(): React.JSX.Element { getSessionResumeInChat={getSessionResumeInChat} onContinueInNewSession={launchActions.handleContinueInNewSession} onResumeInNewChat={launchActions.handleResumeInNewChat} + onResumeInNewCli={launchActions.handleResumeInNewCli} onCopyResume={(session, worktreeId) => void launchActions.copyResumeCommand(session, worktreeId) } diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx index 776da3fac81..5f0b6f40337 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx @@ -7,12 +7,14 @@ import { MessagesSquare, PanelTopOpen, Play, + SquareTerminal, Trash2 } from 'lucide-react' import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' +import { ResumeInNewCliTooltipText } from './ai-vault-session-cli-fork-copy' export function SessionActionMenuItems({ menuKind = 'dropdown', @@ -22,6 +24,7 @@ export function SessionActionMenuItems({ onResume, onContinueInNewSession, onResumeInNewChat, + onResumeInNewCli, onJumpToOriginalPane, showJumpToWorktree, onJumpToWorktree, @@ -41,6 +44,8 @@ export function SessionActionMenuItems({ onResume: () => void onContinueInNewSession?: () => void onResumeInNewChat?: () => void + // Offered only on rows native chat owns, where it forks: see ai-vault-session-cli-fork. + onResumeInNewCli?: () => void onJumpToOriginalPane?: () => void showJumpToWorktree: boolean onJumpToWorktree?: () => void @@ -104,11 +109,27 @@ export function SessionActionMenuItems({ {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewChat', - 'Resume in New Chat' + 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewNativeChat', + 'Resume in New Native Chat' )} ) : null} + {onResumeInNewCli ? ( + + + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewCli', + 'Resume in New CLI' + )} + + + + + + + ) : null} {onContinueInNewSession ? ( diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx index fd7a8394f74..04e308d8bac 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -7,7 +7,8 @@ import { MessageSquare, MessageSquarePlus, MessagesSquare, - Play + Play, + SquareTerminal } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -19,6 +20,7 @@ import { } from '../../../../shared/ai-vault-types' import { translate } from '@/i18n/i18n' import { FirstPromptCard } from './ai-vault-first-prompt-card' +import { ResumeInNewCliTooltipText } from './ai-vault-session-cli-fork-copy' import { sessionDetailConversationTurns, sessionPromptPreview } from './ai-vault-session-display' import { SessionSubagentsSection } from './AiVaultSessionSubagents' import { SessionUnsavedConversationNotice } from './AiVaultSessionUnsavedNotice' @@ -41,6 +43,7 @@ export function SessionInlineDetails({ subagentResume, onContinueInNewSession, onResumeInNewChat, + onResumeInNewCli, onOpenLog }: { id: string @@ -56,6 +59,7 @@ export function SessionInlineDetails({ subagentResume?: AiVaultSubagentResumeActions onContinueInNewSession?: () => void onResumeInNewChat?: () => void + onResumeInNewCli?: () => void onOpenLog?: () => void }): React.JSX.Element { // A zero-turn transcript would resume into an empty conversation, so the plain @@ -86,6 +90,7 @@ export function SessionInlineDetails({ showResumeInNewTab || onContinueInNewSession || onResumeInNewChat || + onResumeInNewCli || onOpenLog ? (
{showResumeInWorktree ? ( @@ -146,11 +151,37 @@ export function SessionInlineDetails({ > {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewChat', - 'Resume in New Chat' + 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewNativeChat', + 'Resume in New Native Chat' )} ) : null} + {onResumeInNewCli ? ( + + + + + + + + + ) : null} {onContinueInNewSession ? (