From 496ee9bd7ed8aaf9620328b056eae34e474aeee1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 03:29:36 -0700 Subject: [PATCH] feat(session-history): Resume in New CLI forks a native chat's conversation into the terminal (#26336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(session-history): resume a native chat's conversation in a new CLI session Agent Session History offered "Resume in New Chat" to move a terminal session into native chat, but nothing went the other way: once a native chat owned a conversation, its row only reopened the chat, and Orca refused any terminal resume of it so two programs never write one conversation. Rows a native chat owns (Claude, Codex) now offer "Resume in New CLI". It forks: the terminal runs `claude --resume --fork-session` or `codex fork `, so the CLI gets the full history under a new id and the chat is unchanged. The host's resume guard lets a Claude fork through, and the fork tab records no provider session, so a restart never re-enters the chat's conversation. "Resume in New Chat" is renamed "Resume in New Native Chat". * fix(session-history): size the Resume in New CLI tooltip to its text The tooltip balanced one wrapped string inside a box sized before balancing, leaving a gap beside its lines. It is now two block lines with no fixed width. * fix(session-history): run a CLI fork under the selected Codex account and tighten the fork guard - Resume in New CLI now asks the host for the same Codex account home plain Resume uses, so a fork no longer starts under whichever account's copy of the rollout sorted first. The preparation call carries an optional `fork` flag that skips only the owned-conversation refusal; plain resume still refuses. - An older paired host that refuses the fork now shows "Update Orca on the host that runs this chat to resume it in a new CLI." instead of a raw error code. - The host guard admits `--fork-session` only before a `--` terminator and never alongside `--session-id`, since either shape lets Claude write the owned conversation. - Tests for the click path, the menu item's eligibility, the fork home preparation, and the tightened guard. * fix(session-history): read a Codex rollout's identity from its first session record A fork of an older (non-paginated) Codex thread writes its own session record first and then copies the parent's history, the parent's session record included. The history scanner kept the last one it saw, so the fork's row took the parent's id, title, folder and branch: on a native chat's fork it looked like the chat itself, and Resume on it opened the chat instead of the fork. Codex itself treats the first session record as the thread's own and ignores later ones; the scanner now does the same, as Orca's duplicate-rollout check already did. * fix(session-history): set up status hooks in the Codex home a CLI fork keeps When Resume in New CLI can't move a Codex fork to the selected account (that account has no copy of the conversation yet, or the system default is selected), the fork runs in the account home the history row came from. A new Codex tab only gets Orca's status hooks in the selected account's home, and the fork carries no provider session, so nothing set up the hooks in that row home. In a home only native chat had used, the fork tab could show no working or idle status. The fork's home preparation now readies that home the same way a resumed session's home is readied (hook install or refresh, plus the daemon-socket guard). That code is moved into prepareCodexPinnedLaunchHome and shared by both paths. The fork tab still carries no provider session, so it never reopens the chat's conversation on restart. * fix(session-history): ready only a host-verified Codex home for a CLI fork The fork's hook setup ran in whatever Codex home the request named, so a paired client could have Orca write hook and config files into any directory. Plain Resume only readies a home it has verified: one this host lists for Codex sessions that actually holds the rollout. The fork now uses that same check, through a shared trustedCodexResumeHomes list, and readies the home only when the verified home is the one the fork will run in. With no Codex runtime home service it does nothing, as plain Resume does. A failed check or setup is logged and never stops the fork from launching. Tests cover the untrusted home, a missing runtime home service, a rollout on another host, a WSL home, a fork moved into the real home, and a failed setup. * fix(session-history): show the host-update message when the desktop wraps the fork refusal When the local preparation step for Resume in New CLI is refused, Electron hands the renderer the error as "Error invoking remote method '…': Error: agent_session_conflict". The fork's failure text matched only the bare code, so the toast showed that raw line instead of asking the user to update Orca on the host. It now unwraps the message with the shared IPC error helper before matching. --- .../session-scanner-codex-parser.test.ts | 72 ++++++++ .../ai-vault/session-scanner-codex-parser.ts | 4 +- .../structured-session-ownership.test.ts | 44 +++++ .../ai-vault/structured-session-ownership.ts | 20 ++- .../codex-ai-vault-session-resume.test.ts | 130 +++++++++++++-- .../codex/codex-ai-vault-session-resume.ts | 64 ++++++- src/main/codex/codex-session-resume-home.ts | 9 + src/main/runtime/rpc/methods/ai-vault.test.ts | 24 +++ src/main/runtime/rpc/methods/ai-vault.ts | 1 + .../startup/codex-session-resume-launch.ts | 61 ++++--- .../startup/main-process-runtime-service.ts | 4 +- src/main/startup/main-window-core-services.ts | 8 +- .../components/right-sidebar/AiVaultPanel.tsx | 1 + .../AiVaultSessionActionMenuItems.tsx | 25 ++- .../right-sidebar/AiVaultSessionDetails.tsx | 37 ++++- .../right-sidebar/AiVaultSessionRow.tsx | 5 + .../AiVaultSessionVirtualList.tsx | 3 + .../right-sidebar/AiVaultVirtualRow.test.tsx | 41 ++++- .../right-sidebar/AiVaultVirtualRow.tsx | 15 ++ .../SessionRowTrailingActions.tsx | 3 + .../ai-vault-session-cli-fork-copy.tsx | 24 +++ .../ai-vault-session-cli-fork.test.ts | 49 ++++++ .../ai-vault-session-cli-fork.ts | 31 ++++ .../ai-vault-session-launch-actions.test.ts | 156 ++++++++++++++++++ .../ai-vault-session-launch-actions.ts | 81 +++++++-- src/renderer/src/i18n/locales/en.json | 9 +- src/renderer/src/i18n/locales/es.json | 9 +- src/renderer/src/i18n/locales/fr.json | 9 +- src/renderer/src/i18n/locales/ja.json | 9 +- src/renderer/src/i18n/locales/ko.json | 9 +- src/renderer/src/i18n/locales/zh.json | 9 +- .../src/lib/ai-vault-resume-command.ts | 115 ++++--------- .../src/lib/ai-vault-resume-platform.ts | 54 ++++++ .../lib/ai-vault-session-fork-startup.test.ts | 85 ++++++++++ .../src/lib/ai-vault-session-fork-startup.ts | 64 +++++++ ...i-vault-session-resume-preparation.test.ts | 41 +++++ .../ai-vault-session-resume-preparation.ts | 21 ++- src/shared/agent-resume-argv.ts | 15 ++ src/shared/agent-session-resume.ts | 2 +- src/shared/ai-vault-resume-preparation.ts | 6 +- src/shared/rpc-contract/ai-vault-params.ts | 3 +- src/shared/tui-agent-fork-startup.test.ts | 56 +++++++ src/shared/tui-agent-resume-startup.ts | 7 +- 43 files changed, 1260 insertions(+), 175 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork-copy.tsx create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts create mode 100644 src/renderer/src/lib/ai-vault-resume-platform.ts create mode 100644 src/renderer/src/lib/ai-vault-session-fork-startup.test.ts create mode 100644 src/renderer/src/lib/ai-vault-session-fork-startup.ts create mode 100644 src/shared/tui-agent-fork-startup.test.ts 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 ? (