Files
orca/src/main/codex/codex-account-session-bridge.ts
T
Brennan Benson 9c5d827d6a fix(codex): keep history, restarts, and account identity across an account switch (#10770)
Fixes #10757. Switching Codex accounts broke three ways, all rooted in the
self-contained per-account CODEX_HOME from #9501.

HISTORY DISAPPEARED. Codex's own /resume picker only lists rollouts under the
launch CODEX_HOME, and nothing bridged history into a per-account home — only
the AI Vault's discovery scan knew about the other homes. Every other
Orca-visible home's rollouts are now hardlinked in, on selection and again at
launch, so one physical log is listed everywhere.

THE RESTART PANEL STUCK. A queued restart was only drained by a mounted
TerminalPane, but the prompt covered every stale pane in the worktree including
parked and cold-deferred tabs. Requesting a restart now answers the prompt
immediately while the pane keeps its pending restart, and a pane drains it when
its reconnected PTY binds.

PANES STAYED ON THE OLD ACCOUNT. CODEX_HOME is fixed in a shell's environment at
spawn and the daemon keeps those shells alive across app restarts, while the
restart notices are renderer state and are discarded. Each PTY's launch account
is now recorded on disk and compared against the current selection at startup.

Also merged in: #10802 (a dismissed notice no longer kills the pane's keyboard),
#10803 (the sweep arms on real PTY binds, and launcher Codex panes are no longer
filtered out by Windows deepest-process reporting), #10804 (a resume-pinned pane
now says which account it is on), #10870 (the restart card no longer parks focus
on its destructive Restart button), #10853 (the retry ladder is widened past the
Windows worst case).

Six independent reviews found real defects in every original PR, several of them
dead-keyboard bugs and three introduced by the fix for another defect in the same
loop. Live QA on macOS covered every PR; Windows was validated three times.

WINDOWS: pass 1 found two defects that made the stale-account fix a no-op there
(the sweep fired before any PTY was bound and never retried; launcher panes were
filtered out). Pass 3 at the merged head: the prompt appears on its own after a
restart — warm ~3.7-4.2s, cold ~21s needing rung 4, so #10853's widening was
load-bearing rather than precautionary; an ordinary sentence typed into a healthy
pane while another pane's card is up reaches that pane and kills nothing; a pane
running vim after exiting Codex gets no card, still none 45s later. auth.json
byte-identical across every pass.

KNOWN GAPS, stated rather than implied: #10804 is unverified on Windows
(auto-resume could not be manufactured there); cross-volume Windows is untested
and expected to yield no bridged history (EXDEV, and Codex ignores symlinked
rollouts); a cold-parked pane never binds so the sweep never covers it; the
subagent-deepest launcher shape could not be reproduced on Windows, so that
branch is fixture-verified only; WSL passed isolation but the resume mechanism is
host-lane only. A host-account switch also marks and mutes live SSH remote panes
— confirmed pre-existing on main by two independent QA runs — tracked separately
in #10992. Related pre-existing defect filed as #10863.
2026-07-27 17:53:41 -07:00

132 lines
4.4 KiB
TypeScript

import { existsSync, mkdirSync } from 'node:fs'
import { dirname, join, relative } from 'node:path'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { listCodexSessionRolloutFilesIncrementally } from './codex-session-file-listing'
import type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing'
import { linkCodexSessionFile } from './codex-session-link'
/**
* Bridges Codex history between Orca-managed Codex homes.
*
* Why: a managed account launches Codex against its own self-contained
* CODEX_HOME, and Codex's `/resume` picker only lists rollouts under that home.
* Without this, switching accounts hides every conversation the user recorded
* under a different account (or under their real ~/.codex). Rollouts are
* hardlinked, so each conversation stays one physical log no matter how many
* homes list it.
*/
export type CodexAccountSessionBridgeSummary = {
scannedFiles: number
linkedFiles: number
}
const backgroundBridgeTasksByTargetHome = new Map<string, Promise<void>>()
/**
* Starts one background bridge per target home, sharing in-flight work.
*/
export function startCodexAccountSessionBridgeInBackground(args: {
targetCodexHomePath: string
sourceCodexHomePaths: readonly string[]
options?: CodexSessionBridgeIncrementalOptions
}): Promise<void> {
const key = normalizeRuntimePathForComparison(args.targetCodexHomePath)
const inFlight = backgroundBridgeTasksByTargetHome.get(key)
if (inFlight) {
return inFlight
}
const task = bridgeCodexSessionsIntoAccountHome(args)
.catch((error: unknown) => {
console.warn('[codex-account-session-bridge] Background session bridge failed:', error)
})
.then(() => undefined)
backgroundBridgeTasksByTargetHome.set(key, task)
void task.finally(() => {
if (backgroundBridgeTasksByTargetHome.get(key) === task) {
backgroundBridgeTasksByTargetHome.delete(key)
}
})
return task
}
/**
* Mirrors every source home's rollouts into the target home's sessions tree.
*/
export async function bridgeCodexSessionsIntoAccountHome(args: {
targetCodexHomePath: string
sourceCodexHomePaths: readonly string[]
options?: CodexSessionBridgeIncrementalOptions
}): Promise<CodexAccountSessionBridgeSummary> {
const summary: CodexAccountSessionBridgeSummary = { scannedFiles: 0, linkedFiles: 0 }
const targetSessionsRoot = join(args.targetCodexHomePath, 'sessions')
for (const sourceHomePath of dedupeSourceHomes(
args.sourceCodexHomePaths,
args.targetCodexHomePath
)) {
const sourceSessionsRoot = join(sourceHomePath, 'sessions')
if (!existsSync(sourceSessionsRoot)) {
continue
}
for await (const sourceFilePath of listCodexSessionRolloutFilesIncrementally(
sourceSessionsRoot,
args.options ?? {}
)) {
summary.scannedFiles += 1
if (bridgeRolloutIntoAccountHome(sourceSessionsRoot, targetSessionsRoot, sourceFilePath)) {
summary.linkedFiles += 1
}
}
}
return summary
}
/**
* Links one rollout into the target sessions tree at the same relative path.
*/
function bridgeRolloutIntoAccountHome(
sourceSessionsRoot: string,
targetSessionsRoot: string,
sourceFilePath: string
): boolean {
const targetFilePath = join(targetSessionsRoot, relative(sourceSessionsRoot, sourceFilePath))
// Why: rollout names carry the session UUID, so an existing target path is the
// same conversation already bridged (often the same inode) — never a conflict.
if (existsSync(targetFilePath)) {
return false
}
try {
mkdirSync(dirname(targetFilePath), { recursive: true })
} catch (error) {
console.warn('[codex-account-session-bridge] Failed to create session directory:', error)
return false
}
return linkCodexSessionFile(sourceFilePath, targetFilePath)
}
/**
* Drops duplicate and self-referential sources so one launch links each home once.
*/
function dedupeSourceHomes(
sourceCodexHomePaths: readonly string[],
targetCodexHomePath: string
): string[] {
const seen = new Set([normalizeRuntimePathForComparison(targetCodexHomePath)])
const sources: string[] = []
for (const sourceHomePath of sourceCodexHomePaths) {
const key = normalizeRuntimePathForComparison(sourceHomePath)
if (seen.has(key)) {
continue
}
seen.add(key)
sources.push(sourceHomePath)
}
return sources
}
export const _internals = {
resetBackgroundBridgeTasks: (): void => {
backgroundBridgeTasksByTargetHome.clear()
}
}