diff --git a/src/main/ai-vault/session-scanner-codex-discovery.ts b/src/main/ai-vault/session-scanner-codex-discovery.ts new file mode 100644 index 00000000000..8aadf78fb72 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-discovery.ts @@ -0,0 +1,40 @@ +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { + CODEX_SESSION_ROLLOUT_EXTENSIONS, + isCodexSessionRolloutPath +} from './session-scanner-codex-paths' +import { discoverFiles } from './session-scanner-discovery' +import type { SessionFileDiscovery } from './session-scanner-types' + +export function codexSessionDiscoveries( + rootDirs: readonly string[], + limit: number, + issues: AiVaultScanIssue[] +): Promise[] { + return rootDirs.map((rootDir) => + discoverFiles({ + rootDir, + limit, + agent: 'codex', + issues, + extensions: [...CODEX_SESSION_ROLLOUT_EXTENSIONS], + // Why: `.zst` alone is too broad; only Codex `.jsonl.zst` rollouts qualify. + filePredicate: isCodexSessionRolloutPath, + // Why: sibling duplicates must not consume the recency limit, and the + // writable plain rollout remains authoritative even when zstd is newer. + candidatePathSelector: preferPlainCodexRolloutSiblings + }) + ) +} + +function preferPlainCodexRolloutSiblings(paths: readonly string[]): string[] { + const byPlainPath = new Map() + for (const path of paths) { + const plainPath = path.endsWith('.jsonl.zst') ? path.slice(0, -'.zst'.length) : path + const existing = byPlainPath.get(plainPath) + if (!existing || path.endsWith('.jsonl')) { + byPlainPath.set(plainPath, path) + } + } + return [...byPlainPath.values()] +} 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 f48d58f495a..eeb342b295a 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.test.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { parseCodexSessionFile } from './session-scanner-codex-parser' +import { parseCodexSessionContent, parseCodexSessionFile } from './session-scanner-codex-parser' let tempRoots: string[] = [] @@ -16,6 +16,74 @@ function jsonLines(records: unknown[]): string { } describe('parseCodexSessionFile', () => { + it('parses paginated item_completed turn items for preview and title', async () => { + const session = await parseCodexSessionContent({ + file: { + path: '/tmp/rollout-paginated.jsonl', + mtimeMs: 1, + modifiedAt: '2026-06-18T10:00:00.000Z' + }, + content: jsonLines([ + { + timestamp: '2026-06-18T10:00:00.000Z', + type: 'session_meta', + payload: { + id: 'paginated-session', + cwd: '/repo/app', + history_mode: 'paginated' + } + }, + { + timestamp: '2026-06-18T10:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Paginated user prompt' }] + } + }, + { + timestamp: '2026-06-18T10:00:01.000Z', + type: 'event_msg', + payload: { + type: 'item_completed', + item: { + type: 'user_message', + id: 'item-user-1', + content: [{ type: 'text', text: 'Paginated user prompt' }] + } + } + }, + { + timestamp: '2026-06-18T10:00:02.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Paginated assistant reply' }] + } + }, + { + timestamp: '2026-06-18T10:00:02.000Z', + type: 'event_msg', + payload: { + type: 'item_completed', + item: { + type: 'AgentMessage', + id: 'item-agent-1', + content: [{ type: 'text', text: 'Paginated assistant reply' }] + } + } + } + ]) + }) + + expect(session?.sessionId).toBe('paginated-session') + expect(session?.title).toBe('Paginated user prompt') + expect(session?.messageCount).toBe(2) + expect(session?.previewMessages?.map((message) => message.role)).toEqual(['user', 'assistant']) + }) + it('does not double-count usage when token count formats switch', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-token-switch-')) 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 5127720b141..c8e8e620559 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -1,35 +1,22 @@ -import { createReadStream } from 'node:fs' -import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index' import type { ExecutionHostId } from '../../shared/execution-host' import { - addPreviewContent, cloneSessionAccumulator, createAccumulator, - finalizeSession, - sessionIdFromFileName, - updateTimeline + finalizeSession } from './session-scanner-accumulator' +import { codexRolloutBaseName } from './session-scanner-codex-paths' +import { + consumeCodexRecordLine, + type CodexSessionParseState +} from './session-scanner-codex-record-consume' +import { iterateCodexRolloutLines } from './session-scanner-codex-rollout-read' import type { - CodexUsageSnapshot, FileWithMtime, ResumableParseFinalizeOptions, - ResumableSessionParseState, - SessionAccumulator + ResumableSessionParseState } from './session-scanner-types' -import { - addCodexUsage, - asRecord, - extractContentText, - extractGitBranch, - extractModel, - extractString, - normalizeCodexUsage, - normalizeTitleText, - parseJsonObject, - subtractCodexUsage -} from './session-scanner-values' export async function parseCodexSessionFile( file: FileWithMtime, @@ -37,19 +24,19 @@ export async function parseCodexSessionFile( codexHome: string | null = null, executionHostId?: ExecutionHostId ): Promise { - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - - return parseCodexSessionLines({ - file, - lines, - platform, - codexHome, - executionHostId, - titleReader: (sessionId) => readCodexSessionIndexTitle(file.path, codexHome, sessionId) - }) + const lines = iterateCodexRolloutLines(file.path) + try { + return await parseCodexSessionLines({ + file, + lines, + platform, + codexHome, + executionHostId, + titleReader: (sessionId) => readCodexSessionIndexTitle(file.path, codexHome, sessionId) + }) + } finally { + lines.close() + } } export async function parseCodexSessionContent(args: { @@ -72,23 +59,14 @@ export async function parseCodexSessionContent(args: { }) } -type CodexSessionParseState = { - accumulator: SessionAccumulator - previousTotals: CodexUsageSnapshot | null - rejectedWorkerSession: boolean - sawSessionMeta: boolean - // Which source set the current title; an index-file title outranks the raw - // first user prompt, so finalize must know whether 'meta' already won. - titleSource: 'meta' | 'user' | null -} - function createCodexParseState(file: FileWithMtime): CodexSessionParseState { return { accumulator: createAccumulator({ agent: 'codex', file, - sessionId: sessionIdFromFileName(file.path) + sessionId: sessionIdFromCodexRolloutPath(file.path) }), + historyMode: null, previousTotals: null, rejectedWorkerSession: false, sawSessionMeta: false, @@ -96,6 +74,12 @@ function createCodexParseState(file: FileWithMtime): CodexSessionParseState { } } +function sessionIdFromCodexRolloutPath(filePath: string): string { + const baseName = codexRolloutBaseName(filePath) + const match = baseName.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) + return match?.[0] ?? baseName +} + function cloneCodexParseState(state: CodexSessionParseState): CodexSessionParseState { return { // previousTotals snapshots are replaced, never mutated, so sharing is safe. @@ -104,124 +88,6 @@ function cloneCodexParseState(state: CodexSessionParseState): CodexSessionParseS } } -function consumeCodexRecordLine(state: CodexSessionParseState, line: string): void { - if (state.rejectedWorkerSession) { - return - } - const record = parseJsonObject(line) - if (!record) { - return - } - const { accumulator } = state - - updateTimeline(accumulator, extractString(record.timestamp)) - - const payload = asRecord(record.payload) - if (record.type === 'session_meta' && payload) { - if (isCodexWorkerSession(payload)) { - // Why: Codex writes internal worker/sub-agent transcripts into the same - // history tree; AI Vault should show user-started sessions only. - state.rejectedWorkerSession = true - return - } - state.sawSessionMeta = true - const sessionId = extractString(payload.id) - if (sessionId) { - accumulator.sessionId = sessionId - } - const metadataTitle = extractCodexSessionMetadataTitle(payload) - if (metadataTitle) { - accumulator.title = metadataTitle - state.titleSource = 'meta' - } - const cwd = extractString(payload.cwd) - if (cwd) { - accumulator.cwd = cwd - } - accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch - return - } - - if (record.type === 'turn_context' && payload) { - const cwd = extractString(payload.cwd) - if (cwd) { - accumulator.cwd = cwd - } - const model = extractModel(payload) - if (model) { - accumulator.model = model - } - return - } - - if (!payload) { - return - } - - if (record.type === 'response_item' && payload.type === 'message') { - accumulator.messageCount++ - if (payload.role === 'user' && !accumulator.title) { - accumulator.title = extractContentText(payload.content) - state.titleSource = accumulator.title ? 'user' : state.titleSource - } - addPreviewContent( - accumulator, - payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown', - payload.content, - record.timestamp - ) - return - } - - if (record.type !== 'event_msg') { - return - } - - if (payload.type === 'user_message') { - accumulator.messageCount++ - if (!accumulator.title) { - accumulator.title = extractContentText(payload.message) - state.titleSource = accumulator.title ? 'user' : state.titleSource - } - addPreviewContent(accumulator, 'user', payload.message, record.timestamp) - return - } - - if (payload.type === 'agent_message') { - accumulator.messageCount++ - addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp) - return - } - - if (payload.type !== 'token_count') { - return - } - - const info = asRecord(payload.info) - if (!info) { - return - } - const totalUsage = normalizeCodexUsage(info.total_token_usage) - const lastUsage = normalizeCodexUsage(info.last_token_usage) - let delta: CodexUsageSnapshot | null = null - if (totalUsage) { - delta = subtractCodexUsage(totalUsage, state.previousTotals) - state.previousTotals = totalUsage - } else if (lastUsage) { - delta = lastUsage - state.previousTotals = state.previousTotals - ? addCodexUsage(state.previousTotals, lastUsage) - : lastUsage - } - if (delta) { - accumulator.totalTokens += delta.totalTokens - } - const model = extractModel(payload) - if (model) { - accumulator.model = model - } -} - async function finalizeCodexParseState( state: CodexSessionParseState, platform: NodeJS.Platform, @@ -303,25 +169,3 @@ async function parseCodexSessionLines(args: { executionHostPlatform: args.executionHostPlatform }) } - -function extractCodexThreadSource(payload: Record): string | null { - return extractString(payload.thread_source) ?? extractString(payload.threadSource) -} - -function isCodexWorkerSession(payload: Record): boolean { - const threadSource = extractCodexThreadSource(payload) - if (threadSource) { - return threadSource.toLowerCase() !== 'user' - } - - const source = asRecord(payload.source) - return Boolean(asRecord(source?.subagent)) -} - -function extractCodexSessionMetadataTitle(payload: Record): string | null { - return ( - normalizeTitleText(extractString(payload.title) ?? '') ?? - normalizeTitleText(extractString(payload.thread_name) ?? '') ?? - normalizeTitleText(extractString(payload.threadName) ?? '') - ) -} diff --git a/src/main/ai-vault/session-scanner-codex-paths.test.ts b/src/main/ai-vault/session-scanner-codex-paths.test.ts new file mode 100644 index 00000000000..606e417379d --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-paths.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { codexHomeForSessionsDir } from './session-scanner-codex-paths' + +describe('codexHomeForSessionsDir', () => { + it('keeps the default Codex home explicit for authority-bound resume', () => { + expect(codexHomeForSessionsDir('/home/ada/.codex/sessions')).toBe('/home/ada/.codex') + }) + + it('keeps a managed Codex home explicit', () => { + expect(codexHomeForSessionsDir('/orca/runtime/home/sessions')).toBe('/orca/runtime/home') + }) +}) diff --git a/src/main/ai-vault/session-scanner-codex-paths.ts b/src/main/ai-vault/session-scanner-codex-paths.ts index 584833dc54c..62f68fb74b3 100644 --- a/src/main/ai-vault/session-scanner-codex-paths.ts +++ b/src/main/ai-vault/session-scanner-codex-paths.ts @@ -1,11 +1,10 @@ -import { dirname, resolve } from 'node:path' +import { basename, dirname, resolve } from 'node:path' -export function codexHomeForSessionsDir( - sessionsDir: string, - defaultCodexHomeDir: string -): string | null { - const codexHome = dirname(sessionsDir) - return codexHome === defaultCodexHomeDir ? null : codexHome +/** Extensions accepted by Codex session discovery (plain + cold zstd). */ +export const CODEX_SESSION_ROLLOUT_EXTENSIONS = ['.jsonl', '.zst'] as const + +export function codexHomeForSessionsDir(sessionsDir: string): string { + return dirname(sessionsDir) } export function uniqueCodexSessionsDirs(paths: readonly string[]): string[] { @@ -25,3 +24,29 @@ export function uniqueCodexSessionsDirs(paths: readonly string[]): string[] { } return unique } + +/** True for Codex rollout logs: `*.jsonl` or cold-compressed `*.jsonl.zst`. */ +export function isCodexSessionRolloutFileName(fileName: string): boolean { + return fileName.endsWith('.jsonl') || fileName.endsWith('.jsonl.zst') +} + +export function isCodexSessionRolloutPath(filePath: string): boolean { + return isCodexSessionRolloutFileName(basename(filePath)) +} + +/** True when the rollout was cold-compressed by Codex (`*.jsonl.zst`). */ +export function isCodexCompressedRolloutPath(filePath: string): boolean { + return filePath.endsWith('.jsonl.zst') +} + +/** Basename without rollout suffixes so UUID extraction also works for cold sessions. */ +export function codexRolloutBaseName(filePath: string): string { + const name = basename(filePath) + if (name.endsWith('.jsonl.zst')) { + return name.slice(0, -'.jsonl.zst'.length) + } + if (name.endsWith('.jsonl')) { + return name.slice(0, -'.jsonl'.length) + } + return name +} diff --git a/src/main/ai-vault/session-scanner-codex-record-consume.ts b/src/main/ai-vault/session-scanner-codex-record-consume.ts new file mode 100644 index 00000000000..e9871dabbbe --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-record-consume.ts @@ -0,0 +1,210 @@ +import { addPreviewContent, updateTimeline } from './session-scanner-accumulator' +import type { CodexUsageSnapshot, SessionAccumulator } from './session-scanner-types' +import { + addCodexUsage, + asRecord, + extractContentText, + extractGitBranch, + extractModel, + extractString, + normalizeCodexUsage, + normalizeTitleText, + parseJsonObject, + subtractCodexUsage +} from './session-scanner-values' + +export type CodexSessionParseState = { + accumulator: SessionAccumulator + historyMode: string | null + previousTotals: CodexUsageSnapshot | null + rejectedWorkerSession: boolean + sawSessionMeta: boolean + // An index/metadata title outranks the first user prompt at finalize time. + titleSource: 'meta' | 'user' | null +} + +export function consumeCodexRecordLine(state: CodexSessionParseState, line: string): void { + if (state.rejectedWorkerSession) { + return + } + const record = parseJsonObject(line) + if (!record) { + return + } + const { accumulator } = state + + updateTimeline(accumulator, extractString(record.timestamp)) + + const payload = asRecord(record.payload) + if (record.type === 'session_meta' && payload) { + if (isCodexWorkerSession(payload)) { + // Why: Codex writes worker transcripts into the same tree; AI Vault only + // lists user-started sessions. + state.rejectedWorkerSession = true + return + } + state.sawSessionMeta = true + state.historyMode = extractString(payload.history_mode) ?? extractString(payload.historyMode) + const sessionId = extractString(payload.id) + if (sessionId) { + accumulator.sessionId = sessionId + } + const metadataTitle = extractCodexSessionMetadataTitle(payload) + if (metadataTitle) { + accumulator.title = metadataTitle + state.titleSource = 'meta' + } + const cwd = extractString(payload.cwd) + if (cwd) { + accumulator.cwd = cwd + } + accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch + return + } + + if (record.type === 'turn_context' && payload) { + const cwd = extractString(payload.cwd) + if (cwd) { + accumulator.cwd = cwd + } + const model = extractModel(payload) + if (model) { + accumulator.model = model + } + return + } + + if (!payload) { + return + } + + if ( + record.type === 'response_item' && + payload.type === 'message' && + state.historyMode !== 'paginated' + ) { + accumulator.messageCount++ + if (payload.role === 'user' && !accumulator.title) { + accumulator.title = extractContentText(payload.content) + state.titleSource = accumulator.title ? 'user' : state.titleSource + } + addPreviewContent( + accumulator, + payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown', + payload.content, + record.timestamp + ) + return + } + + if (record.type !== 'event_msg') { + return + } + + if (payload.type === 'user_message') { + accumulator.messageCount++ + if (!accumulator.title) { + accumulator.title = extractContentText(payload.message) + state.titleSource = accumulator.title ? 'user' : state.titleSource + } + addPreviewContent(accumulator, 'user', payload.message, record.timestamp) + return + } + + if (payload.type === 'agent_message') { + accumulator.messageCount++ + addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp) + return + } + + // Why: paginated history persists TurnItems instead of the legacy event + // message variants. + if (payload.type === 'item_completed') { + consumeCodexCompletedTurnItem(state, payload, record.timestamp) + return + } + + if (payload.type !== 'token_count') { + return + } + + const info = asRecord(payload.info) + if (!info) { + return + } + const totalUsage = normalizeCodexUsage(info.total_token_usage) + const lastUsage = normalizeCodexUsage(info.last_token_usage) + let delta: CodexUsageSnapshot | null = null + if (totalUsage) { + delta = subtractCodexUsage(totalUsage, state.previousTotals) + state.previousTotals = totalUsage + } else if (lastUsage) { + delta = lastUsage + state.previousTotals = state.previousTotals + ? addCodexUsage(state.previousTotals, lastUsage) + : lastUsage + } + if (delta) { + accumulator.totalTokens += delta.totalTokens + } + const model = extractModel(payload) + if (model) { + accumulator.model = model + } +} + +function consumeCodexCompletedTurnItem( + state: CodexSessionParseState, + payload: Record, + timestamp: unknown +): void { + const item = asRecord(payload.item) + if (!item) { + return + } + const itemType = normalizeCodexTurnItemType(item.type) + const { accumulator } = state + if (itemType === 'user_message') { + const text = extractContentText(item.content) + accumulator.messageCount++ + if (!accumulator.title && text) { + accumulator.title = text + state.titleSource = 'user' + } + addPreviewContent(accumulator, 'user', item.content, timestamp) + } else if (itemType === 'agent_message') { + accumulator.messageCount++ + addPreviewContent(accumulator, 'assistant', item.content, timestamp) + } +} + +function normalizeCodexTurnItemType(value: unknown): string | null { + const raw = extractString(value) + if (!raw) { + return null + } + return raw + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') + .toLowerCase() +} + +function extractCodexThreadSource(payload: Record): string | null { + return extractString(payload.thread_source) ?? extractString(payload.threadSource) +} + +function isCodexWorkerSession(payload: Record): boolean { + const threadSource = extractCodexThreadSource(payload) + if (threadSource) { + return threadSource.toLowerCase() !== 'user' + } + return Boolean(asRecord(asRecord(payload.source)?.subagent)) +} + +function extractCodexSessionMetadataTitle(payload: Record): string | null { + return ( + normalizeTitleText(extractString(payload.title) ?? '') ?? + normalizeTitleText(extractString(payload.thread_name) ?? '') ?? + normalizeTitleText(extractString(payload.threadName) ?? '') + ) +} diff --git a/src/main/ai-vault/session-scanner-codex-rollout-read.ts b/src/main/ai-vault/session-scanner-codex-rollout-read.ts new file mode 100644 index 00000000000..5c600647ace --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-rollout-read.ts @@ -0,0 +1,46 @@ +import { createReadStream } from 'node:fs' +import { createInterface } from 'node:readline' +import { compose, type Readable } from 'node:stream' +import { createZstdDecompress, type ZstdDecompress } from 'node:zlib' +import { isCodexCompressedRolloutPath } from './session-scanner-codex-paths' + +/** Opens a UTF-8 stream for either a plain or cold-compressed Codex rollout. */ +export function openCodexRolloutStream(filePath: string): Readable { + const raw = createReadStream(filePath) + if (!isCodexCompressedRolloutPath(filePath)) { + return raw + } + + // Why: Codex cold-compresses rollouts to `.jsonl.zst`; readers must accept + // both forms without loading the entire transcript into memory. + let decoder: ZstdDecompress + try { + decoder = createZstdDecompress() + } catch (error) { + raw.destroy() + throw new Error(`Zstd decompression is unavailable; cannot read ${filePath}`, { + cause: error + }) + } + // `compose` owns the complete source→decoder pipeline: source errors reach + // readers, and destroying the returned stream tears down both resources. + return compose(raw, decoder) +} + +/** Async lines plus an explicit close hook for early worker-session rejection. */ +export function iterateCodexRolloutLines( + filePath: string +): AsyncIterable & { close: () => void } { + const input = openCodexRolloutStream(filePath) + const lines = createInterface({ input, crlfDelay: Infinity }) + return { + [Symbol.asyncIterator]: () => lines[Symbol.asyncIterator](), + close: () => { + lines.close() + // Early worker-session rejection intentionally aborts the composed zstd + // pipeline; consume that close-only AbortError after parsing has stopped. + input.once('error', () => undefined) + input.destroy() + } + } +} diff --git a/src/main/ai-vault/session-scanner-codex-zstd.test.ts b/src/main/ai-vault/session-scanner-codex-zstd.test.ts new file mode 100644 index 00000000000..a9bd7fbbc85 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-zstd.test.ts @@ -0,0 +1,197 @@ +import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { zstdCompressSync } from 'node:zlib' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { parseCodexSessionFile } from './session-scanner-codex-parser' +import { openCodexRolloutStream } from './session-scanner-codex-rollout-read' +import { resetSessionParseCacheForTests } from './session-scanner-parse-cache' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' + +let tempRoots: string[] = [] + +afterEach(async () => { + resetSessionParseCacheForTests() + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('Codex cold-compressed rollout scanning', () => { + it('discovers and fully reparses updated .jsonl.zst sessions', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-zst-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = '019f0000-1111-7222-8333-444444444444' + const sessionPath = join( + roots.codexSessionsDir, + '2026', + '06', + '18', + `rollout-2026-06-18T10-00-00-${sessionId}.jsonl.zst` + ) + await mkdir(dirname(sessionPath), { recursive: true }) + + const records = [ + { + timestamp: '2026-06-18T10:00:00.000Z', + type: 'session_meta', + payload: { id: sessionId, cwd: '/repo/app' } + }, + { + timestamp: '2026-06-18T10:00:01.000Z', + type: 'event_msg', + payload: { + type: 'item_completed', + item: { + type: 'user_message', + id: 'u1', + content: [{ type: 'text', text: 'Compressed rollout prompt' }] + } + } + } + ] + await writeFile(sessionPath, zstdCompressSync(Buffer.from(jsonLines(records), 'utf-8'))) + + const first = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + expect(first.issues).toEqual([]) + expect(first.sessions).toHaveLength(1) + expect(first.sessions[0]).toMatchObject({ + sessionId, + title: 'Compressed rollout prompt', + messageCount: 1 + }) + + records.push({ + timestamp: '2026-06-18T10:00:02.000Z', + type: 'event_msg', + payload: { + type: 'item_completed', + item: { + type: 'agent_message', + id: 'a1', + content: [{ type: 'text', text: 'Compressed reply' }] + } + } + }) + await writeFile(sessionPath, zstdCompressSync(Buffer.from(jsonLines(records), 'utf-8'))) + + const second = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + expect(second.issues).toEqual([]) + expect(second.sessions[0]?.messageCount).toBe(2) + }) + + it('prefers a plain rollout over its compressed sibling', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-zst-sibling-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = '019f0000-2222-7333-8444-555555555555' + const basePath = join( + roots.codexSessionsDir, + '2026', + '06', + '18', + `rollout-2026-06-18T10-00-00-${sessionId}.jsonl` + ) + await mkdir(dirname(basePath), { recursive: true }) + const records = [ + { + timestamp: '2026-06-18T10:00:00.000Z', + type: 'session_meta', + payload: { id: sessionId, cwd: '/repo/app' } + } + ] + const content = jsonLines(records) + await writeFile(`${basePath}.zst`, zstdCompressSync(Buffer.from(content, 'utf-8'))) + await writeFile(basePath, content) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]?.filePath).toBe(basePath) + }) + + it('selects plain siblings before applying the per-agent discovery limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-zst-limit-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = '019f0000-3333-7444-8555-666666666666' + const basePath = join( + roots.codexSessionsDir, + '2026', + '06', + '18', + `rollout-2026-06-18T10-00-00-${sessionId}.jsonl` + ) + await mkdir(dirname(basePath), { recursive: true }) + const content = jsonLines([ + { + timestamp: '2026-06-18T10:00:00.000Z', + type: 'session_meta', + payload: { id: sessionId, cwd: '/repo/app' } + } + ]) + await writeFile(basePath, content) + await writeFile(`${basePath}.zst`, zstdCompressSync(Buffer.from(content, 'utf-8'))) + await utimes(basePath, new Date('2026-06-18T10:00:00.000Z'), new Date('2026-06-18T10:00:00.000Z')) + await utimes( + `${basePath}.zst`, + new Date('2026-06-18T10:00:01.000Z'), + new Date('2026-06-18T10:00:01.000Z') + ) + + const result = await scanAiVaultSessions({ + ...roots, + platform: 'darwin', + limitPerAgent: 1 + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]?.filePath).toBe(basePath) + }) + + it('propagates missing and corrupt compressed rollout errors', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-zst-errors-')) + tempRoots.push(root) + const missingPath = join(root, 'missing.jsonl.zst') + await expect( + parseCodexSessionFile({ + path: missingPath, + mtimeMs: 0, + modifiedAt: new Date(0).toISOString() + }) + ).rejects.toThrow() + + const corruptPath = join(root, 'corrupt.jsonl.zst') + await writeFile(corruptPath, 'not-zstd-data', 'utf-8') + await expect( + parseCodexSessionFile({ + path: corruptPath, + mtimeMs: 0, + modifiedAt: new Date(0).toISOString() + }) + ).rejects.toThrow() + }) + + it('closes the complete compressed stream when a reader stops early', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-zst-close-')) + tempRoots.push(root) + const sessionPath = join(root, 'rollout.jsonl.zst') + const content = `${JSON.stringify({ type: 'session_meta', payload: { id: 'session' } })}\n`.repeat( + 10_000 + ) + await writeFile(sessionPath, zstdCompressSync(Buffer.from(content, 'utf-8'))) + + const stream = openCodexRolloutStream(sessionPath) + const closed = new Promise((resolve) => stream.once('close', resolve)) + // `compose().destroy()` reports an expected AbortError while closing the + // owned pipeline; a consumer may intentionally stop before EOF. + stream.once('error', () => undefined) + stream.destroy() + await closed + + expect(stream.destroyed).toBe(true) + }) +}) diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index c176fb3c6e5..49f7b475117 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -12,12 +12,14 @@ export async function discoverFiles(args: { extensions: string[] filePredicate?: (path: string) => boolean directoryPredicate?: (name: string) => boolean + candidatePathSelector?: (paths: readonly string[]) => string[] }): Promise { - const paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, { + const discoveredPaths = await walkSessionFiles(args.rootDir, args.agent, args.issues, { extensions: new Set(args.extensions), filePredicate: args.filePredicate, directoryPredicate: args.directoryPredicate }) + const paths = args.candidatePathSelector?.(discoveredPaths) ?? discoveredPaths const files: FileWithMtime[] = [] for (const path of paths) { try { diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 70d0d311d6b..8b51bf5265d 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -50,7 +50,11 @@ function resumableStateFactoryFor( case 'claude': return () => createClaudeSessionResumeState(candidate.file) case 'codex': - return () => createCodexSessionResumeState(candidate.file, candidate.codexHome) + // Why: byte offsets only describe plain append-only JSONL. Cold zstd + // sessions must be decompressed from the beginning after each change. + return candidate.file.path.endsWith('.jsonl.zst') + ? null + : () => createCodexSessionResumeState(candidate.file, candidate.codexHome) case 'cursor': return () => createCursorSessionResumeState(candidate.file) case 'copilot': diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index f77404bfc5a..d6e2801d148 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -1,6 +1,7 @@ import { homedir } from 'node:os' import { basename, join } from 'node:path' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { codexSessionDiscoveries } from './session-scanner-codex-discovery' import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths' import { SUBAGENT_DIR_NAME } from './session-scanner-subagent-transcripts' import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discovery' @@ -74,7 +75,7 @@ export async function discoverAiVaultSessionSources(args: { // and the SQLite scanner (1.17.x); dedup by sessionId happens inside. ...opencodeDiscoveries(options, wslHomeDirs, limitPerAgent, issues), ...claudeDiscoveries(options, wslHomeDirs, limitPerAgent, issues), - ...codexDiscoveries(codexSessionsDirs, limitPerAgent, issues), + ...codexSessionDiscoveries(codexSessionsDirs, limitPerAgent, issues), ...standardDiscoveries(options, wslHomeDirs, limitPerAgent, issues), openClawDiscovery(options, wslHomeDirs, limitPerAgent, issues), ...droidDiscoveries(options, wslHomeDirs, limitPerAgent, issues), @@ -107,16 +108,6 @@ function claudeDiscoveries( ) } -function codexDiscoveries( - rootDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return rootDirs.map((rootDir) => - discoverFiles({ rootDir, limit, agent: 'codex', issues, extensions: ['.jsonl'] }) - ) -} - function standardDiscoveries( options: AiVaultScanOptions, wslHomeDirs: readonly string[], diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 5ba2a95d503..05eb07f473d 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -13,10 +13,7 @@ import { type SessionParseStats } from './session-scanner-parse-cache' import { discoverInScopeClaudeFiles } from './session-scanner-scope-discovery' -import { - DEFAULT_CODEX_HOME_DIR, - discoverAiVaultSessionSources -} from './session-scanner-source-discovery' +import { discoverAiVaultSessionSources } from './session-scanner-source-discovery' import type { AiVaultScanOptions, SessionFileCandidate, @@ -63,9 +60,7 @@ export async function scanAiVaultSessions( agent: discovery.agent, file, codexHome: - discovery.agent === 'codex' - ? codexHomeForSessionsDir(discovery.rootDir, DEFAULT_CODEX_HOME_DIR) - : null + discovery.agent === 'codex' ? codexHomeForSessionsDir(discovery.rootDir) : null }) ) ) diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 501229444dc..91d6f267f3f 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -8,6 +8,7 @@ import { mkdirSync, readlinkSync, readFileSync, + renameSync, rmSync, statSync, symlinkSync, @@ -484,17 +485,14 @@ describe('CodexRuntimeHomeService', () => { } }) - it('starts WSL session bridging after materializing the WSL launch home', async () => { + it('materializes the WSL launch home without copying session files', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) - vi.doMock('../codex/wsl-codex-session-bridge', () => ({ - startWslCodexSessionBridgeInBackground - })) const wslHome = join(testState.userDataDir, 'wsl-home') vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome + getWslHome: () => wslHome, + listWslDistros: () => ['Ubuntu'] })) const wslSystemHomePath = join(wslHome, '.codex') mkdirSync(wslSystemHomePath, { recursive: true }) @@ -521,17 +519,11 @@ describe('CodexRuntimeHomeService', () => { expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( wslRuntimeHomePath ) - expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1) - expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ - distro: 'Ubuntu', - systemCodexHomePath: wslSystemHomePath, - managedCodexHomePath: wslRuntimeHomePath - }) + expect(existsSync(join(wslRuntimeHomePath, 'sessions'))).toBe(false) const runtimeAgentsPath = join(wslRuntimeHomePath, 'AGENTS.md') expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('# WSL instructions\n') expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false) } finally { - vi.doUnmock('../codex/wsl-codex-session-bridge') vi.doUnmock('../wsl') if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -542,13 +534,11 @@ describe('CodexRuntimeHomeService', () => { it('promotes WSL in-Codex setting changes on the next Codex launch', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - vi.doMock('../codex/wsl-codex-session-bridge', () => ({ - startWslCodexSessionBridgeInBackground: vi.fn(() => Promise.resolve()) - })) const wslHome = join(testState.userDataDir, 'wsl-home') vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome + getWslHome: () => wslHome, + listWslDistros: () => ['Ubuntu'] })) const store = createStore( createSettings({ @@ -599,7 +589,6 @@ describe('CodexRuntimeHomeService', () => { // Baseline advances so the promoted value is not re-promoted forever. expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"o4\\""') } finally { - vi.doUnmock('../codex/wsl-codex-session-bridge') vi.doUnmock('../wsl') if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -607,17 +596,14 @@ describe('CodexRuntimeHomeService', () => { } }) - it('bridges WSL history from a configured per-distro source-home override', async () => { + it('exposes a configured WSL source as a host-readable authority home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) - vi.doMock('../codex/wsl-codex-session-bridge', () => ({ - startWslCodexSessionBridgeInBackground - })) const wslHome = join(testState.userDataDir, 'wsl-home') vi.doMock('../wsl', () => ({ getDefaultWslDistro: () => 'Ubuntu', - getWslHome: () => wslHome + getWslHome: () => wslHome, + listWslDistros: () => ['Ubuntu'] })) const store = createStore( createSettings({ @@ -631,25 +617,10 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' + expect(service.getAiVaultCodexHomePaths()).toContain( + '\\\\wsl.localhost\\Ubuntu\\home\\me\\.config\\codex' ) - - service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - - expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1) - expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ - distro: 'Ubuntu', - systemCodexHomePath: '/home/me/.config/codex', - managedCodexHomePath: wslRuntimeHomePath - }) } finally { - vi.doUnmock('../codex/wsl-codex-session-bridge') vi.doUnmock('../wsl') if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -657,13 +628,9 @@ describe('CodexRuntimeHomeService', () => { } }) - it('starts WSL session bridging for the distro used by the materialized runtime home', async () => { + it('materializes the runtime home for the selected WSL distro', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) - vi.doMock('../codex/wsl-codex-session-bridge', () => ({ - startWslCodexSessionBridgeInBackground - })) const wslHome = join(testState.userDataDir, 'debian-wsl-home') const wslRuntimeHomePath = join( wslHome, @@ -721,13 +688,7 @@ describe('CodexRuntimeHomeService', () => { expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: null })).toBe( wslRuntimeHomePath ) - expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ - distro: 'Debian', - systemCodexHomePath: join(wslHome, '.codex'), - managedCodexHomePath: wslRuntimeHomePath - }) } finally { - vi.doUnmock('../codex/wsl-codex-session-bridge') vi.doUnmock('../wsl') vi.doUnmock('../../shared/wsl-paths') if (originalPlatform) { @@ -1355,7 +1316,7 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeProfilePath, 'utf-8')).toBe('profile\n') }) - it('starts the system Codex session bridge without replacing runtime sessions', async () => { + it('keeps system and runtime Codex session namespaces independent', async () => { const systemMissingRuntimeSessionPath = join( getSystemCodexHomePath(), 'sessions', @@ -1364,38 +1325,13 @@ describe('CodexRuntimeHomeService', () => { '26', 'rollout-old.jsonl' ) - const systemConflictSessionPath = join( - getSystemCodexHomePath(), - 'sessions', - '2026', - '05', - '26', - 'rollout-conflict.jsonl' - ) - const runtimeConflictSessionPath = join( - getRuntimeCodexHomePath(), - 'sessions', - '2026', - '05', - '26', - 'rollout-conflict.jsonl' - ) mkdirSync(join(getSystemCodexHomePath(), 'sessions', '2026', '05', '26'), { recursive: true }) - mkdirSync(join(getRuntimeCodexHomePath(), 'sessions', '2026', '05', '26'), { - recursive: true - }) writeFileSync(systemMissingRuntimeSessionPath, '{"id":"old"}\n', 'utf-8') - writeFileSync(systemConflictSessionPath, '{"id":"system-conflict"}\n', 'utf-8') - writeFileSync(runtimeConflictSessionPath, '{"id":"runtime-conflict"}\n', 'utf-8') - writeFileSync(join(getSystemCodexHomePath(), 'state_5.sqlite'), 'sqlite\n', 'utf-8') const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const { startSystemCodexSessionBridgeInBackground } = - await import('../codex/codex-session-bridge') const service = new CodexRuntimeHomeService(store as never) service.prepareForCodexLaunch() - await startSystemCodexSessionBridgeInBackground() const runtimeMissingSessionPath = join( getRuntimeCodexHomePath(), @@ -1405,10 +1341,18 @@ describe('CodexRuntimeHomeService', () => { '26', 'rollout-old.jsonl' ) - expect(readFileSync(runtimeMissingSessionPath, 'utf-8')).toBe('{"id":"old"}\n') - expectResourceLinkedOrCopied(runtimeMissingSessionPath, systemMissingRuntimeSessionPath) - expect(readFileSync(runtimeConflictSessionPath, 'utf-8')).toBe('{"id":"runtime-conflict"}\n') - expect(existsSync(join(getRuntimeCodexHomePath(), 'state_5.sqlite'))).toBe(false) + // Why: Codex compression and resume replace .jsonl/.jsonl.zst directory + // entries, so one rollout must never be writable through two homes. + expect(existsSync(runtimeMissingSessionPath)).toBe(false) + expect(readFileSync(systemMissingRuntimeSessionPath, 'utf-8')).toBe('{"id":"old"}\n') + + const compressedSystemSessionPath = `${systemMissingRuntimeSessionPath}.zst` + renameSync(systemMissingRuntimeSessionPath, compressedSystemSessionPath) + service.prepareForCodexLaunch() + + expect(existsSync(runtimeMissingSessionPath)).toBe(false) + expect(existsSync(`${runtimeMissingSessionPath}.zst`)).toBe(false) + expect(existsSync(compressedSystemSessionPath)).toBe(true) }) it('does not replace runtime-owned Codex files while linking user resources', async () => { diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 29e181bd775..bf82b13d0f1 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -41,12 +41,7 @@ import { syncCodexGlobalInstructionsIntoManagedHome, syncSystemCodexResourcesIntoManagedHome } from '../codex/codex-home-paths' -import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge' -import { - resolveHostCodexSessionSourceHome, - resolveWslCodexSessionSourceHome -} from '../codex/codex-session-source-home' -import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' +import { resolveCodexSessionAuthorityHomes } from '../codex/codex-session-authority-homes' import { prepareSystemConfigForFreshRuntimeMirror, syncSystemConfigIntoManagedCodexHome @@ -59,7 +54,7 @@ import { setSelectedCodexAccountIdForTarget, type CodexAccountSelectionTarget } from './runtime-selection' -import { getDefaultWslDistro, getWslHome } from '../wsl' +import { getDefaultWslDistro, getWslHome, listWslDistros } from '../wsl' type CodexAuthIdentity = { email: string | null @@ -135,61 +130,42 @@ export class CodexRuntimeHomeService { /** * Materializes the runtime home needed before launching the CLI. * - * Historical session bridging is requested in the background so launch setup - * returns as soon as the active runtime home is ready. + * Session history stays in its authoritative CODEX_HOME; launch preparation + * only materializes account, config, hook, and resource state. */ prepareForCodexLaunch(target?: CodexAccountSelectionTarget): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget) this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, syncedRuntimeHomePath) - const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) - this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) - return runtimeHomePath + return syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) } this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() - // Why: historical Codex sessions can be large; bridge them after launch - // setup so starting a fresh Codex TUI never waits on a full tree walk. - void startSystemCodexSessionBridgeInBackground( - {}, - resolveHostCodexSessionSourceHome(this.store.getSettings()) - ) return this.getRuntimeHomePath() } - private startWslSessionBridgeForLaunch( - target: CodexAccountSelectionTarget, - runtimeHomePath: string | null - ): void { - if (process.platform !== 'win32' || !runtimeHomePath) { - return - } - const runtimeHomeWsl = parseWslUncPath(runtimeHomePath) - const distro = target.wslDistro?.trim() || runtimeHomeWsl?.distro || getDefaultWslDistro() - if (!distro) { - return - } - // Why: history-only override lets custom-CODEX_HOME users bridge from their - // real home; falls back to /.codex, which auth/config still use. - const systemCodexHomePath = - resolveWslCodexSessionSourceHome(this.store.getSettings(), distro) ?? - this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - if (!systemCodexHomePath || systemCodexHomePath === runtimeHomePath) { - return - } - // Why: WSL history must be hardlinked inside the distro; host-side links - // cannot bridge Windows and WSL filesystems in a resume-visible way. - void startWslCodexSessionBridgeInBackground({ - distro, - systemCodexHomePath, - managedCodexHomePath: runtimeHomePath + getAiVaultCodexHomePaths(): string[] { + return resolveCodexSessionAuthorityHomes({ + runtimeHomePath: this.getRuntimeHomePath(), + systemHomePath: getSystemCodexHomePath(), + settings: this.store.getSettings(), + platform: process.platform, + wslSystemHomes: this.getWslSystemCodexHomes() }) } - getHostRuntimeHomePath(): string { - return this.getRuntimeHomePath() + private getWslSystemCodexHomes(): ReadonlyMap { + if (process.platform !== 'win32') { + return new Map() + } + return new Map( + listWslDistros().flatMap((distro) => { + const home = getWslHome(distro) + return home ? [[distro, pathWin32.join(home, '.codex')] as const] : [] + }) + ) } syncActiveWslSelectionsBeforeRestart(): void { diff --git a/src/main/codex-usage/scanner-large-directory.test.ts b/src/main/codex-usage/scanner-large-directory.test.ts index 84191bc5518..8154c0a7d13 100644 --- a/src/main/codex-usage/scanner-large-directory.test.ts +++ b/src/main/codex-usage/scanner-large-directory.test.ts @@ -3,13 +3,11 @@ import type { Dirent, Stats } from 'node:fs' import type * as FsPromises from 'node:fs/promises' import { join } from 'node:path' -const { getLegacyCopiedCodexSessionBridgeScanPreferenceMock, readdirMock, statMock } = vi.hoisted( - () => ({ - getLegacyCopiedCodexSessionBridgeScanPreferenceMock: vi.fn(), - readdirMock: vi.fn<(dirPath: string) => Promise>(), - statMock: vi.fn<(filePath: string) => Promise>() - }) -) +const { getLegacyCopiedCodexSessionScanPreferenceMock, readdirMock, statMock } = vi.hoisted(() => ({ + getLegacyCopiedCodexSessionScanPreferenceMock: vi.fn(), + readdirMock: vi.fn<(dirPath: string) => Promise>(), + statMock: vi.fn<(filePath: string) => Promise>() +})) vi.mock('fs/promises', async () => { const actual = await vi.importActual('fs/promises') @@ -31,9 +29,8 @@ vi.mock('../codex/codex-home-paths', () => ({ getSystemCodexHomePath: () => join(FAKE_ROOT, 'system') })) -vi.mock('../codex/codex-session-bridge', () => ({ - getLegacyCopiedCodexSessionBridgeScanPreference: - getLegacyCopiedCodexSessionBridgeScanPreferenceMock +vi.mock('../codex/codex-legacy-session-copy', () => ({ + getLegacyCopiedCodexSessionScanPreference: getLegacyCopiedCodexSessionScanPreferenceMock })) function dirent(name: string, kind: 'directory' | 'file'): Dirent { @@ -50,8 +47,8 @@ const largeSessionEntries = Array.from({ length: FILE_COUNT }, (_, index) => describe('listCodexSessionFiles large directories', () => { beforeEach(() => { - getLegacyCopiedCodexSessionBridgeScanPreferenceMock.mockReset() - getLegacyCopiedCodexSessionBridgeScanPreferenceMock.mockReturnValue(null) + getLegacyCopiedCodexSessionScanPreferenceMock.mockReset() + getLegacyCopiedCodexSessionScanPreferenceMock.mockReturnValue(null) readdirMock.mockReset() statMock.mockReset() }) @@ -80,6 +77,6 @@ describe('listCodexSessionFiles large directories', () => { const { listCodexSessionFiles } = await import('./scanner') await expect(listCodexSessionFiles()).resolves.toHaveLength(FILE_COUNT) - expect(getLegacyCopiedCodexSessionBridgeScanPreferenceMock).not.toHaveBeenCalled() + expect(getLegacyCopiedCodexSessionScanPreferenceMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/codex-usage/scanner.ts b/src/main/codex-usage/scanner.ts index 97e8f6f7b09..35dfabd1892 100644 --- a/src/main/codex-usage/scanner.ts +++ b/src/main/codex-usage/scanner.ts @@ -6,7 +6,7 @@ import { createInterface } from 'node:readline' import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' -import { getLegacyCopiedCodexSessionBridgeScanPreference } from '../codex/codex-session-bridge' +import { getLegacyCopiedCodexSessionScanPreference } from '../codex/codex-legacy-session-copy' import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer' import type { CodexUsageAttributedEvent, @@ -163,7 +163,7 @@ async function dedupeCodexSessionFileAliases( const excludedAliases = new Set() if (hasLegacyBridgeMarkers) { for (const [index, filePath] of files.entries()) { - const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath) + const legacyCopyBridge = getLegacyCopiedCodexSessionScanPreference(filePath) if ((index + 1) % YIELD_EVERY_DISCOVERY_ENTRIES === 0) { await yieldToEventLoop() } @@ -223,7 +223,7 @@ function getLegacySourceSkipBytesByPath( return sourceSkipBytesByPath } for (const filePath of files) { - const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath) + const legacyCopyBridge = getLegacyCopiedCodexSessionScanPreference(filePath) if (!legacyCopyBridge || legacyCopyBridge.sourceSkipBytes === null) { continue } diff --git a/src/main/codex/codex-legacy-session-copy.ts b/src/main/codex/codex-legacy-session-copy.ts new file mode 100644 index 00000000000..be5dd5bb79f --- /dev/null +++ b/src/main/codex/codex-legacy-session-copy.ts @@ -0,0 +1,92 @@ +import { lstatSync, readFileSync } from 'node:fs' +import { isAbsolute, join, relative, sep } from 'node:path' +import { getOrcaManagedCodexHomePath } from './codex-home-paths' + +type LegacyCopiedSessionMarker = { + sourcePath: string + sourceSize: number + sourceMtimeMs: number + targetSize: number + targetMtimeMs: number +} + +export type LegacyCopiedCodexSessionScanPreference = { + sourcePath: string + preferManagedCopy: boolean + sourceSkipBytes: number | null +} + +/** Read-only compatibility for copies created by older Orca releases. */ +export function getLegacyCopiedCodexSessionScanPreference( + sessionFilePath: string +): LegacyCopiedCodexSessionScanPreference | null { + const managedSessionsRoot = join(getOrcaManagedCodexHomePath(), 'sessions') + const relativePath = relative(managedSessionsRoot, sessionFilePath) + if ( + relativePath === '' || + relativePath === '..' || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ) { + return null + } + const marker = readLegacyCopiedSessionMarker(relativePath) + if (!marker) { + return null + } + + let targetMatchesMarker = false + let sourceMatchesMarker = false + try { + targetMatchesMarker = fileStatsMatchMarker(lstatSync(sessionFilePath), marker, 'target') + } catch {} + try { + sourceMatchesMarker = fileStatsMatchMarker(lstatSync(marker.sourcePath), marker, 'source') + } catch {} + + return { + sourcePath: marker.sourcePath, + // Why: keep legacy prefix copies readable without creating or refreshing + // any writable alias between the two authority homes. + preferManagedCopy: !targetMatchesMarker || sourceMatchesMarker, + sourceSkipBytes: !targetMatchesMarker && !sourceMatchesMarker ? marker.sourceSize : null + } +} + +function getLegacySessionCopyMarkerPath(relativePath: string): string { + return join(getOrcaManagedCodexHomePath(), '.orca-session-copies', `${relativePath}.json`) +} + +function readLegacyCopiedSessionMarker(relativePath: string): LegacyCopiedSessionMarker | null { + try { + const parsed: unknown = JSON.parse( + readFileSync(getLegacySessionCopyMarkerPath(relativePath), 'utf-8') + ) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null + } + const marker = parsed as Record + if ( + typeof marker.sourcePath !== 'string' || + typeof marker.sourceSize !== 'number' || + typeof marker.sourceMtimeMs !== 'number' || + typeof marker.targetSize !== 'number' || + typeof marker.targetMtimeMs !== 'number' + ) { + return null + } + return marker as LegacyCopiedSessionMarker + } catch { + return null + } +} + +function fileStatsMatchMarker( + stat: { size: number; mtimeMs: number }, + marker: LegacyCopiedSessionMarker, + kind: 'source' | 'target' +): boolean { + const expectedSize = kind === 'source' ? marker.sourceSize : marker.targetSize + const expectedMtimeMs = kind === 'source' ? marker.sourceMtimeMs : marker.targetMtimeMs + return stat.size === expectedSize && stat.mtimeMs === expectedMtimeMs +} diff --git a/src/main/codex/codex-session-authority-homes.test.ts b/src/main/codex/codex-session-authority-homes.test.ts new file mode 100644 index 00000000000..895f3f48a89 --- /dev/null +++ b/src/main/codex/codex-session-authority-homes.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { resolveCodexSessionAuthorityHomes } from './codex-session-authority-homes' + +describe('resolveCodexSessionAuthorityHomes', () => { + it('keeps the runtime and system homes as separate authoritative namespaces', () => { + expect( + resolveCodexSessionAuthorityHomes({ + runtimeHomePath: '/orca/runtime/home', + systemHomePath: '/home/ada/.codex', + settings: {}, + platform: 'darwin' + }) + ).toEqual(['/orca/runtime/home', '/home/ada/.codex']) + }) + + it('uses the configured host session home instead of the default system home', () => { + expect( + resolveCodexSessionAuthorityHomes({ + runtimeHomePath: '/orca/runtime/home', + systemHomePath: '/home/ada/.codex', + settings: { codexSessionSourceHome: { host: ' /mnt/history/codex ' } }, + platform: 'linux' + }) + ).toEqual(['/orca/runtime/home', '/mnt/history/codex']) + }) + + it('maps configured WSL session homes to host-readable UNC authorities', () => { + expect( + resolveCodexSessionAuthorityHomes({ + runtimeHomePath: 'C:\\Users\\ada\\AppData\\Roaming\\orca\\codex-runtime-home\\home', + systemHomePath: 'C:\\Users\\ada\\.codex', + settings: { + codexSessionSourceHome: { + wsl: { Ubuntu: ' /home/ada/.config/codex ', Debian: 'relative/path' } + } + }, + platform: 'win32' + }) + ).toEqual([ + 'C:\\Users\\ada\\AppData\\Roaming\\orca\\codex-runtime-home\\home', + 'C:\\Users\\ada\\.codex', + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.config\\codex' + ]) + }) + + it('includes default WSL homes without mirroring their sessions', () => { + expect( + resolveCodexSessionAuthorityHomes({ + runtimeHomePath: 'C:\\Orca\\codex-runtime-home\\home', + systemHomePath: 'C:\\Users\\ada\\.codex', + settings: {}, + platform: 'win32', + wslSystemHomes: new Map([['Ubuntu', '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex']]) + }) + ).toContain('\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex') + }) +}) diff --git a/src/main/codex/codex-session-authority-homes.ts b/src/main/codex/codex-session-authority-homes.ts new file mode 100644 index 00000000000..704463b620e --- /dev/null +++ b/src/main/codex/codex-session-authority-homes.ts @@ -0,0 +1,43 @@ +import { posix, win32 } from 'node:path' +import type { GlobalSettings } from '../../shared/types' +import { + resolveHostCodexSessionSourceHome, + resolveWslCodexSessionSourceHome +} from './codex-session-source-home' + +export function resolveCodexSessionAuthorityHomes(args: { + runtimeHomePath: string + systemHomePath: string + settings: Pick + platform: NodeJS.Platform + wslSystemHomes?: ReadonlyMap +}): string[] { + const hostSourceHome = resolveHostCodexSessionSourceHome(args.settings) ?? args.systemHomePath + const homes = [args.runtimeHomePath, hostSourceHome] + if (args.platform === 'win32') { + const distros = new Set([ + ...Object.keys(args.settings.codexSessionSourceHome?.wsl ?? {}), + ...(args.wslSystemHomes?.keys() ?? []) + ]) + for (const distro of distros) { + const sourceHome = resolveWslCodexSessionSourceHome(args.settings, distro) + const hostPath = sourceHome + ? wslLinuxPathToUnc(sourceHome, distro) + : args.wslSystemHomes?.get(distro) + if (hostPath) { + homes.push(hostPath) + } + } + } + return homes.filter((home, index) => homes.indexOf(home) === index) +} + +function wslLinuxPathToUnc(linuxPath: string, distro: string): string | null { + const normalizedDistro = distro.trim() + if (!normalizedDistro || !posix.isAbsolute(linuxPath)) { + return null + } + // Why: scanners run in the Windows host process, while resume converts this + // authority path back to Linux through parseWslUncPath. + return win32.join('\\\\wsl.localhost', normalizedDistro, ...linuxPath.split('/').filter(Boolean)) +} diff --git a/src/main/codex/codex-session-bridge.test.ts b/src/main/codex/codex-session-bridge.test.ts deleted file mode 100644 index ed3c838ea7c..00000000000 --- a/src/main/codex/codex-session-bridge.test.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - existsSync, - lstatSync, - mkdtempSync, - mkdirSync, - readFileSync, - readlinkSync, - rmSync, - symlinkSync, - writeFileSync -} from 'node:fs' -import type * as NodeFs from 'node:fs' -import type * as NodeOs from 'node:os' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' - -const { homedirMock } = vi.hoisted(() => ({ - homedirMock: vi.fn<() => string>() -})) - -const { fsMockState } = vi.hoisted(() => ({ - fsMockState: { - failLink: false, - failSymlink: false, - fakeSymlinks: new Map() - } -})) - -function isWindowsSymlinkPrivilegeError(error: unknown): boolean { - if (process.platform !== 'win32' || !(error instanceof Error)) { - return false - } - const errorWithCode = error as Error & { code?: string } - return errorWithCode.code === 'EPERM' || errorWithCode.code === 'EACCES' -} - -vi.mock('node:fs', async () => { - const actual = await vi.importActual('node:fs') - return { - ...actual, - linkSync: (...args: Parameters) => { - if (fsMockState.failLink) { - throw new Error('hardlink disabled for test') - } - return actual.linkSync(...args) - }, - lstatSync: ((path: Parameters[0]) => { - const stat = actual.lstatSync(path) - if (!fsMockState.fakeSymlinks.has(String(path))) { - return stat - } - // Why: Windows often disallows file symlink creation outside Developer - // Mode; tests simulate the link metadata while keeping a real path. - return { ...stat, isSymbolicLink: () => true } - }) as typeof actual.lstatSync, - readlinkSync: ((path: Parameters[0]) => { - const fakeTarget = fsMockState.fakeSymlinks.get(String(path)) - if (fakeTarget !== undefined) { - return fakeTarget - } - return actual.readlinkSync(path) - }) as typeof actual.readlinkSync, - renameSync: (...args: Parameters) => { - const [oldPath, newPath] = args - const fakeTarget = fsMockState.fakeSymlinks.get(String(oldPath)) - const result = actual.renameSync(...args) - if (fakeTarget !== undefined) { - fsMockState.fakeSymlinks.delete(String(oldPath)) - fsMockState.fakeSymlinks.set(String(newPath), fakeTarget) - } else { - fsMockState.fakeSymlinks.delete(String(newPath)) - } - return result - }, - rmSync: (...args: Parameters) => { - fsMockState.fakeSymlinks.delete(String(args[0])) - return actual.rmSync(...args) - }, - symlinkSync: (...args: Parameters) => { - if (fsMockState.failSymlink) { - throw new Error('symlink disabled for test') - } - try { - return actual.symlinkSync(...args) - } catch (error) { - if (!isWindowsSymlinkPrivilegeError(error)) { - throw error - } - const [target, path] = args - fsMockState.fakeSymlinks.set(String(path), String(target)) - actual.writeFileSync(path, '', 'utf-8') - } - } - } -}) - -vi.mock('node:os', async () => { - const actual = await vi.importActual('node:os') - return { - ...actual, - homedir: homedirMock - } -}) - -import { - syncSystemCodexSessionsIntoManagedHome, - syncSystemCodexSessionsIntoManagedHomeIncrementally -} from './codex-session-bridge' - -let fakeHomeDir: string -let userDataDir: string -let previousUserDataPath: string | undefined - -function getSystemCodexHomePath(): string { - return join(fakeHomeDir, '.codex') -} - -function getRuntimeCodexHomePath(): string { - return join(userDataDir, 'codex-runtime-home', 'home') -} - -function normalizeLinkTarget(linkTarget: string): string { - return process.platform === 'win32' - ? linkTarget.replace(/^\\\\\?\\/, '').toLowerCase() - : linkTarget -} - -function expectResourceLinked(targetPath: string, sourcePath: string): void { - if (lstatSync(targetPath).isSymbolicLink()) { - expect(normalizeLinkTarget(readlinkSync(targetPath))).toBe(normalizeLinkTarget(sourcePath)) - return - } - expect(lstatSync(targetPath).ino).toBe(lstatSync(sourcePath).ino) -} - -function writeLegacyCopyMarker(relativePath: string, sourcePath: string, targetPath: string): void { - const sourceStat = lstatSync(sourcePath) - const targetStat = lstatSync(targetPath) - const markerPath = join(getRuntimeCodexHomePath(), '.orca-session-copies', `${relativePath}.json`) - mkdirSync(dirname(markerPath), { recursive: true }) - writeFileSync( - markerPath, - `${JSON.stringify( - { - sourcePath, - sourceSize: sourceStat.size, - sourceMtimeMs: sourceStat.mtimeMs, - targetSize: targetStat.size, - targetMtimeMs: targetStat.mtimeMs - }, - null, - 2 - )}\n`, - 'utf-8' - ) -} - -beforeEach(() => { - fsMockState.failLink = false - fsMockState.failSymlink = false - fsMockState.fakeSymlinks.clear() - fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-session-home-')) - userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-session-user-data-')) - previousUserDataPath = process.env.ORCA_USER_DATA_PATH - process.env.ORCA_USER_DATA_PATH = userDataDir - homedirMock.mockReturnValue(fakeHomeDir) - mkdirSync(getSystemCodexHomePath(), { recursive: true }) -}) - -afterEach(() => { - rmSync(fakeHomeDir, { recursive: true, force: true }) - rmSync(userDataDir, { recursive: true, force: true }) - if (previousUserDataPath === undefined) { - delete process.env.ORCA_USER_DATA_PATH - } else { - process.env.ORCA_USER_DATA_PATH = previousUserDataPath - } - vi.clearAllMocks() -}) - -describe('syncSystemCodexSessionsIntoManagedHome', () => { - it('bridges system Codex session jsonl files into the managed runtime home', () => { - const systemSessionPath = join( - getSystemCodexHomePath(), - 'sessions', - '2026', - '05', - '26', - 'rollout-old.jsonl' - ) - mkdirSync(dirname(systemSessionPath), { recursive: true }) - writeFileSync(systemSessionPath, '{"type":"session_meta","id":"old"}\n', 'utf-8') - writeFileSync( - join(getSystemCodexHomePath(), 'sessions', '2026', '05', '26', 'scratch.txt'), - 'not a session\n', - 'utf-8' - ) - - syncSystemCodexSessionsIntoManagedHome() - - const runtimeSessionPath = join( - getRuntimeCodexHomePath(), - 'sessions', - '2026', - '05', - '26', - 'rollout-old.jsonl' - ) - expect(readFileSync(runtimeSessionPath, 'utf-8')).toBe('{"type":"session_meta","id":"old"}\n') - expect(lstatSync(runtimeSessionPath).isSymbolicLink()).toBe(false) - expectResourceLinked(runtimeSessionPath, systemSessionPath) - expect( - existsSync(join(getRuntimeCodexHomePath(), 'sessions', '2026', '05', '26', 'scratch.txt')) - ).toBe(false) - }) - - it('bridges from a custom source home override instead of ~/.codex', () => { - // Why: users with a custom CODEX_HOME point history discovery at that - // folder; the default ~/.codex must be ignored when an override is given. - const customSourceHome = join(fakeHomeDir, 'custom-codex') - const customSessionPath = join( - customSourceHome, - 'sessions', - '2026', - '05', - '26', - 'rollout-custom.jsonl' - ) - mkdirSync(dirname(customSessionPath), { recursive: true }) - writeFileSync(customSessionPath, '{"id":"custom"}\n', 'utf-8') - - // A session under the default ~/.codex should NOT be bridged when overridden. - const defaultSessionPath = join( - getSystemCodexHomePath(), - 'sessions', - '2026', - '05', - '26', - 'rollout-default.jsonl' - ) - mkdirSync(dirname(defaultSessionPath), { recursive: true }) - writeFileSync(defaultSessionPath, '{"id":"default"}\n', 'utf-8') - - syncSystemCodexSessionsIntoManagedHome(customSourceHome) - - const bridgedCustomPath = join( - getRuntimeCodexHomePath(), - 'sessions', - '2026', - '05', - '26', - 'rollout-custom.jsonl' - ) - expect(readFileSync(bridgedCustomPath, 'utf-8')).toBe('{"id":"custom"}\n') - expect( - existsSync( - join(getRuntimeCodexHomePath(), 'sessions', '2026', '05', '26', 'rollout-default.jsonl') - ) - ).toBe(false) - }) - - it('falls back to symlinks when hardlinks are unavailable', () => { - fsMockState.failLink = true - const systemSessionPath = join( - getSystemCodexHomePath(), - 'sessions', - '2026', - '05', - '26', - 'rollout-symlink-fallback.jsonl' - ) - mkdirSync(dirname(systemSessionPath), { recursive: true }) - writeFileSync(systemSessionPath, '{"id":"system"}\n', 'utf-8') - - syncSystemCodexSessionsIntoManagedHome() - - const runtimeSessionPath = join( - getRuntimeCodexHomePath(), - 'sessions', - '2026', - '05', - '26', - 'rollout-symlink-fallback.jsonl' - ) - expect(lstatSync(runtimeSessionPath).isSymbolicLink()).toBe(true) - expect(normalizeLinkTarget(readlinkSync(runtimeSessionPath))).toBe( - normalizeLinkTarget(systemSessionPath) - ) - }) - - it('does not overwrite runtime-owned session files', () => { - const relativeSessionPath = join('sessions', '2026', '05', '26', 'rollout-conflict.jsonl') - const systemSessionPath = join(getSystemCodexHomePath(), relativeSessionPath) - const runtimeSessionPath = join(getRuntimeCodexHomePath(), relativeSessionPath) - mkdirSync(dirname(systemSessionPath), { recursive: true }) - mkdirSync(dirname(runtimeSessionPath), { recursive: true }) - writeFileSync(systemSessionPath, '{"id":"system"}\n', 'utf-8') - writeFileSync(runtimeSessionPath, '{"id":"runtime"}\n', 'utf-8') - - syncSystemCodexSessionsIntoManagedHome() - - expect(readFileSync(runtimeSessionPath, 'utf-8')).toBe('{"id":"runtime"}\n') - }) - - it('replaces existing symlink bridges with hardlinks', () => { - const relativeSessionPath = join('sessions', '2026', '05', '26', 'rollout-symlink.jsonl') - const systemSessionPath = join(getSystemCodexHomePath(), relativeSessionPath) - const runtimeSessionPath = join(getRuntimeCodexHomePath(), relativeSessionPath) - mkdirSync(dirname(systemSessionPath), { recursive: true }) - mkdirSync(dirname(runtimeSessionPath), { recursive: true }) - writeFileSync(systemSessionPath, '{"id":"system"}\n', 'utf-8') - symlinkSync( - systemSessionPath, - runtimeSessionPath, - process.platform === 'win32' ? 'file' : undefined - ) - - syncSystemCodexSessionsIntoManagedHome() - - expect(lstatSync(runtimeSessionPath).isSymbolicLink()).toBe(false) - expectResourceLinked(runtimeSessionPath, systemSessionPath) - }) - - it('does not create independent session copies when file links are unavailable', () => { - fsMockState.failLink = true - fsMockState.failSymlink = true - const systemSessionPath = join( - getSystemCodexHomePath(), - 'sessions', - '2026', - '05', - '26', - 'rollout-unlinked.jsonl' - ) - mkdirSync(dirname(systemSessionPath), { recursive: true }) - writeFileSync(systemSessionPath, '{"id":"system"}\n', 'utf-8') - - syncSystemCodexSessionsIntoManagedHome() - - expect( - existsSync( - join(getRuntimeCodexHomePath(), 'sessions', '2026', '05', '26', 'rollout-unlinked.jsonl') - ) - ).toBe(false) - }) - - it('replaces unchanged legacy copied sessions with links', () => { - const relativeSessionPath = join('2026', '05', '26', 'rollout-legacy.jsonl') - const systemSessionPath = join(getSystemCodexHomePath(), 'sessions', relativeSessionPath) - const runtimeSessionPath = join(getRuntimeCodexHomePath(), 'sessions', relativeSessionPath) - mkdirSync(dirname(systemSessionPath), { recursive: true }) - mkdirSync(dirname(runtimeSessionPath), { recursive: true }) - writeFileSync(systemSessionPath, '{"id":"legacy"}\n', 'utf-8') - writeFileSync(runtimeSessionPath, '{"id":"legacy"}\n', 'utf-8') - writeLegacyCopyMarker(relativeSessionPath, systemSessionPath, runtimeSessionPath) - - syncSystemCodexSessionsIntoManagedHome() - - expect(readFileSync(runtimeSessionPath, 'utf-8')).toBe('{"id":"legacy"}\n') - expectResourceLinked(runtimeSessionPath, systemSessionPath) - }) - - it('preserves unchanged legacy copied sessions when relinking fails', () => { - const relativeSessionPath = join('2026', '05', '26', 'rollout-legacy-unlinked.jsonl') - const systemSessionPath = join(getSystemCodexHomePath(), 'sessions', relativeSessionPath) - const runtimeSessionPath = join(getRuntimeCodexHomePath(), 'sessions', relativeSessionPath) - mkdirSync(dirname(systemSessionPath), { recursive: true }) - mkdirSync(dirname(runtimeSessionPath), { recursive: true }) - writeFileSync(systemSessionPath, '{"id":"legacy"}\n', 'utf-8') - writeFileSync(runtimeSessionPath, '{"id":"legacy"}\n', 'utf-8') - writeLegacyCopyMarker(relativeSessionPath, systemSessionPath, runtimeSessionPath) - fsMockState.failLink = true - fsMockState.failSymlink = true - - syncSystemCodexSessionsIntoManagedHome() - - expect(lstatSync(runtimeSessionPath).isSymbolicLink()).toBe(false) - expect(readFileSync(runtimeSessionPath, 'utf-8')).toBe('{"id":"legacy"}\n') - }) - - it('incrementally bridges session files without requiring the synchronous launch path', async () => { - const systemSessionRoot = join(getSystemCodexHomePath(), 'sessions', '2026', '06', '18') - mkdirSync(systemSessionRoot, { recursive: true }) - for (let index = 0; index < 5; index += 1) { - writeFileSync( - join(systemSessionRoot, `rollout-incremental-${index}.jsonl`), - `{"id":"incremental-${index}"}\n`, - 'utf-8' - ) - } - - const summary = await syncSystemCodexSessionsIntoManagedHomeIncrementally({ - batchSize: 2, - yieldMs: 0 - }) - - expect(summary).toEqual({ scannedFiles: 5, linkedFiles: 5 }) - for (let index = 0; index < 5; index += 1) { - const systemSessionPath = join(systemSessionRoot, `rollout-incremental-${index}.jsonl`) - const runtimeSessionPath = join( - getRuntimeCodexHomePath(), - 'sessions', - '2026', - '06', - '18', - `rollout-incremental-${index}.jsonl` - ) - expectResourceLinked(runtimeSessionPath, systemSessionPath) - } - }) -}) diff --git a/src/main/codex/codex-session-bridge.ts b/src/main/codex/codex-session-bridge.ts deleted file mode 100644 index e546ce9d8f8..00000000000 --- a/src/main/codex/codex-session-bridge.ts +++ /dev/null @@ -1,376 +0,0 @@ -import { - existsSync, - linkSync, - lstatSync, - mkdirSync, - readFileSync, - readlinkSync, - renameSync, - rmSync, - symlinkSync -} from 'node:fs' -import { dirname, isAbsolute, join, relative, sep } from 'node:path' -import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' -import { - listCodexSessionJsonlFiles, - listCodexSessionJsonlFilesIncrementally -} from './codex-session-file-listing' -import type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing' - -export type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing' - -type LegacyCopiedSessionMarker = { - sourcePath: string - sourceSize: number - sourceMtimeMs: number - targetSize: number - targetMtimeMs: number -} - -export type LegacyCopiedCodexSessionBridgeScanPreference = { - sourcePath: string - preferManagedCopy: boolean - sourceSkipBytes: number | null -} - -export type CodexSessionBridgeSummary = { - scannedFiles: number - linkedFiles: number -} - -let backgroundSessionBridgeTask: Promise | null = null - -/** - * Synchronously mirrors system session files into the managed runtime home. - * - * `sourceCodexHomePath` overrides the default ~/.codex history source for users - * who run Codex with a custom CODEX_HOME; it only affects history discovery. - */ -export function syncSystemCodexSessionsIntoManagedHome(sourceCodexHomePath?: string): void { - const systemSessionsRoot = join(sourceCodexHomePath || getSystemCodexHomePath(), 'sessions') - if (!existsSync(systemSessionsRoot)) { - return - } - - const managedSessionsRoot = join(getOrcaManagedCodexHomePath(), 'sessions') - for (const systemSessionFilePath of listCodexSessionJsonlFiles(systemSessionsRoot)) { - bridgeSystemCodexSessionFile(systemSessionsRoot, managedSessionsRoot, systemSessionFilePath) - } -} - -/** - * Starts a single background bridge task for historical system sessions. - * - * Concurrent callers share the same in-flight task so launch code can request - * background bridging without starting duplicate directory walks. - */ -export function startSystemCodexSessionBridgeInBackground( - options: CodexSessionBridgeIncrementalOptions = {}, - sourceCodexHomePath?: string -): Promise { - if (backgroundSessionBridgeTask) { - return backgroundSessionBridgeTask - } - const task = syncSystemCodexSessionsIntoManagedHomeIncrementally(options, sourceCodexHomePath) - .catch((error: unknown) => { - console.warn('[codex-session-bridge] Background session bridge failed:', error) - }) - .then(() => undefined) - backgroundSessionBridgeTask = task - void task.finally(() => { - if (backgroundSessionBridgeTask === task) { - backgroundSessionBridgeTask = null - } - }) - return task -} - -/** - * Incrementally mirrors system session files into the managed runtime home. - * - * Returns scan/link counts for tests and diagnostics while keeping each file - * bridge operation equivalent to the synchronous path. - */ -export async function syncSystemCodexSessionsIntoManagedHomeIncrementally( - options: CodexSessionBridgeIncrementalOptions = {}, - sourceCodexHomePath?: string -): Promise { - const systemSessionsRoot = join(sourceCodexHomePath || getSystemCodexHomePath(), 'sessions') - if (!existsSync(systemSessionsRoot)) { - return { scannedFiles: 0, linkedFiles: 0 } - } - - const managedSessionsRoot = join(getOrcaManagedCodexHomePath(), 'sessions') - const summary: CodexSessionBridgeSummary = { scannedFiles: 0, linkedFiles: 0 } - for await (const systemSessionFilePath of listCodexSessionJsonlFilesIncrementally( - systemSessionsRoot, - options - )) { - summary.scannedFiles += 1 - if ( - bridgeSystemCodexSessionFile(systemSessionsRoot, managedSessionsRoot, systemSessionFilePath) - ) { - summary.linkedFiles += 1 - } - } - return summary -} - -/** - * Bridges one system session file into the managed sessions tree. - * - * Existing managed files are migrated when possible; missing files are linked - * and counted as newly available to the managed runtime home. - */ -function bridgeSystemCodexSessionFile( - systemSessionsRoot: string, - managedSessionsRoot: string, - systemSessionFilePath: string -): boolean { - const relativePath = relative(systemSessionsRoot, systemSessionFilePath) - const managedSessionFilePath = join(managedSessionsRoot, relativePath) - if (existsSync(managedSessionFilePath)) { - if ( - replaceSymlinkSessionBridgeWithHardlink( - systemSessionFilePath, - managedSessionFilePath, - relativePath - ) - ) { - return true - } - migrateLegacyCopiedSessionBridge(systemSessionFilePath, managedSessionFilePath, relativePath) - return false - } - mkdirSync(dirname(managedSessionFilePath), { recursive: true }) - return linkSystemCodexSessionFile(systemSessionFilePath, managedSessionFilePath, relativePath) -} - -/** - * Links a source session file and clears any stale copied-session marker. - */ -function linkSystemCodexSessionFile( - sourcePath: string, - targetPath: string, - relativePath: string -): boolean { - const linked = tryLinkSystemCodexSessionFile(sourcePath, targetPath) - if (linked) { - clearLegacyCopiedSessionMarker(relativePath) - } - return linked -} - -/** - * Attempts to link a session file with hardlink first and symlink fallback. - */ -function tryLinkSystemCodexSessionFile(sourcePath: string, targetPath: string): boolean { - if (tryHardlinkSystemCodexSessionFile(sourcePath, targetPath)) { - return true - } - try { - // Why fallback: hardlinks keep sessions visible to Codex resume, but can - // fail across volumes. A symlink is still better than a diverging copy. - symlinkSync(sourcePath, targetPath, process.platform === 'win32' ? 'file' : undefined) - return true - } catch (error) { - console.warn('[codex-session-bridge] Failed to link system Codex session:', sourcePath, error) - } - return false -} - -/** - * Attempts a hardlink so resume sees one physical JSONL session log. - */ -function tryHardlinkSystemCodexSessionFile(sourcePath: string, targetPath: string): boolean { - try { - // Why: Codex resume ignores symlinked JSONL sessions, while a hardlink - // preserves one physical log without copy divergence. - linkSync(sourcePath, targetPath) - return true - } catch { - return false - } -} - -/** - * Replaces an older symlink bridge with a hardlink when the target still points - * at the expected source session. - */ -function replaceSymlinkSessionBridgeWithHardlink( - sourcePath: string, - targetPath: string, - relativePath: string -): boolean { - let replacementPath: string | null = null - try { - const targetStat = lstatSync(targetPath) - if (!targetStat.isSymbolicLink()) { - return false - } - const linkTarget = readlinkSync(targetPath) - const absoluteLinkTarget = isAbsolute(linkTarget) - ? linkTarget - : join(dirname(targetPath), linkTarget) - if (absoluteLinkTarget !== sourcePath) { - return false - } - - replacementPath = `${targetPath}.orca-link-${process.pid}-${Date.now()}` - if (!tryHardlinkSystemCodexSessionFile(sourcePath, replacementPath)) { - return false - } - rmSync(targetPath, { force: true }) - renameSync(replacementPath, targetPath) - clearLegacyCopiedSessionMarker(relativePath) - return true - } catch (error) { - console.warn( - '[codex-session-bridge] Failed to replace symlinked Codex session bridge:', - sourcePath, - error - ) - if (replacementPath) { - rmSync(replacementPath, { force: true }) - } - } - return false -} - -/** - * Migrates a legacy copied bridge to a linked bridge when the copied file still - * matches its marker. - */ -function migrateLegacyCopiedSessionBridge( - sourcePath: string, - targetPath: string, - relativePath: string -): void { - const marker = readLegacyCopiedSessionMarker(relativePath) - if (!marker || marker.sourcePath !== sourcePath) { - return - } - let replacementPath: string | null = null - try { - const targetStat = lstatSync(targetPath) - if (targetStat.isSymbolicLink()) { - clearLegacyCopiedSessionMarker(relativePath) - return - } - if (!fileStatsMatchMarker(targetStat, marker, 'target')) { - return - } - replacementPath = `${targetPath}.orca-link-${process.pid}-${Date.now()}` - if (!tryLinkSystemCodexSessionFile(sourcePath, replacementPath)) { - return - } - rmSync(targetPath, { force: true }) - renameSync(replacementPath, targetPath) - clearLegacyCopiedSessionMarker(relativePath) - } catch (error) { - console.warn( - '[codex-session-bridge] Failed to migrate copied system Codex session:', - sourcePath, - error - ) - if (replacementPath) { - rmSync(replacementPath, { force: true }) - } - } -} - -/** - * Resolves how scanners should treat a legacy copied session bridge. - * - * The result keeps resume scans coherent until the copied bridge is migrated to - * a hardlink or symlink. - */ -export function getLegacyCopiedCodexSessionBridgeScanPreference( - sessionFilePath: string -): LegacyCopiedCodexSessionBridgeScanPreference | null { - const managedSessionsRoot = join(getOrcaManagedCodexHomePath(), 'sessions') - const relativePath = relative(managedSessionsRoot, sessionFilePath) - if ( - relativePath === '' || - relativePath === '..' || - relativePath.startsWith(`..${sep}`) || - isAbsolute(relativePath) - ) { - return null - } - const marker = readLegacyCopiedSessionMarker(relativePath) - if (!marker) { - return null - } - - let targetMatchesMarker = false - let sourceMatchesMarker = false - try { - targetMatchesMarker = fileStatsMatchMarker(lstatSync(sessionFilePath), marker, 'target') - } catch {} - try { - sourceMatchesMarker = fileStatsMatchMarker(lstatSync(marker.sourcePath), marker, 'source') - } catch {} - - return { - sourcePath: marker.sourcePath, - // Why: legacy copied bridges share a prefix with the source. Scanner must - // choose one full log until the bridge can be replaced with a real link. - preferManagedCopy: !targetMatchesMarker || sourceMatchesMarker, - sourceSkipBytes: !targetMatchesMarker && !sourceMatchesMarker ? marker.sourceSize : null - } -} - -/** - * Returns the marker path for a legacy copied session bridge. - */ -function getLegacySessionCopyMarkerPath(relativePath: string): string { - return join(getOrcaManagedCodexHomePath(), '.orca-session-copies', `${relativePath}.json`) -} - -/** - * Reads and validates the marker for a legacy copied session bridge. - */ -function readLegacyCopiedSessionMarker(relativePath: string): LegacyCopiedSessionMarker | null { - try { - const parsed: unknown = JSON.parse( - readFileSync(getLegacySessionCopyMarkerPath(relativePath), 'utf-8') - ) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return null - } - const marker = parsed as Record - if ( - typeof marker.sourcePath !== 'string' || - typeof marker.sourceSize !== 'number' || - typeof marker.sourceMtimeMs !== 'number' || - typeof marker.targetSize !== 'number' || - typeof marker.targetMtimeMs !== 'number' - ) { - return null - } - return marker as LegacyCopiedSessionMarker - } catch { - return null - } -} - -/** - * Checks whether source or target file stats still match a legacy bridge marker. - */ -function fileStatsMatchMarker( - stat: { size: number; mtimeMs: number }, - marker: LegacyCopiedSessionMarker, - kind: 'source' | 'target' -): boolean { - const expectedSize = kind === 'source' ? marker.sourceSize : marker.targetSize - const expectedMtimeMs = kind === 'source' ? marker.sourceMtimeMs : marker.targetMtimeMs - return stat.size === expectedSize && stat.mtimeMs === expectedMtimeMs -} - -/** - * Removes the marker after a copied session bridge has been migrated or retired. - */ -function clearLegacyCopiedSessionMarker(relativePath: string): void { - rmSync(getLegacySessionCopyMarkerPath(relativePath), { force: true }) -} diff --git a/src/main/codex/codex-session-file-listing.ts b/src/main/codex/codex-session-file-listing.ts deleted file mode 100644 index c6eeb9b1f77..00000000000 --- a/src/main/codex/codex-session-file-listing.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { readdirSync } from 'node:fs' -import { opendir } from 'node:fs/promises' -import { join } from 'node:path' - -export type CodexSessionBridgeIncrementalOptions = { - /** Directory entries to process before yielding back to the event loop. */ - batchSize?: number - /** Delay after each processed batch; zero still yields on a timer turn. */ - yieldMs?: number -} - -const INCREMENTAL_BRIDGE_BATCH_SIZE = 64 -const INCREMENTAL_BRIDGE_YIELD_MS = 10 - -/** - * Recursively lists session JSONL files below a root directory. - * - * This synchronous variant preserves the historical bridge behavior for callers - * that run outside the CLI launch path. - */ -export function listCodexSessionJsonlFiles(rootPath: string): string[] { - const files: string[] = [] - try { - for (const entry of readdirSync(rootPath, { withFileTypes: true })) { - const childPath = join(rootPath, entry.name) - if (entry.isDirectory()) { - appendSessionFilePaths(files, listCodexSessionJsonlFiles(childPath)) - continue - } - if (entry.isFile() && entry.name.endsWith('.jsonl')) { - files.push(childPath) - } - } - } catch (error) { - console.warn('[codex-session-bridge] Failed to list system Codex sessions:', error) - } - return files.sort() -} - -/** - * Appends session paths without spreading large arrays into a single call. - */ -function appendSessionFilePaths(target: string[], source: readonly string[]): void { - // Why: existing Codex homes can accumulate enough nested sessions to exceed - // V8's argument limit if child arrays are spread into push(). - for (const filePath of source) { - target.push(filePath) - } -} - -/** - * Yields session JSONL files incrementally while walking a directory tree. - * - * The generator yields control between batches so large history directories do - * not monopolize startup work. - */ -export async function* listCodexSessionJsonlFilesIncrementally( - rootPath: string, - options: CodexSessionBridgeIncrementalOptions -): AsyncGenerator { - const batchSize = Math.max(1, options.batchSize ?? INCREMENTAL_BRIDGE_BATCH_SIZE) - const yieldMs = Math.max(0, options.yieldMs ?? INCREMENTAL_BRIDGE_YIELD_MS) - const pendingDirectories = [rootPath] - let entriesSinceYield = 0 - - while (pendingDirectories.length > 0) { - const currentDirectory = pendingDirectories.pop() - if (!currentDirectory) { - continue - } - try { - const directory = await opendir(currentDirectory) - for await (const entry of directory) { - const childPath = join(currentDirectory, entry.name) - if (entry.isDirectory()) { - pendingDirectories.push(childPath) - } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { - yield childPath - } - entriesSinceYield += 1 - if (entriesSinceYield >= batchSize) { - entriesSinceYield = 0 - await delayIncrementalBridge(yieldMs) - } - } - } catch (error) { - console.warn('[codex-session-bridge] Failed to list system Codex sessions:', error) - } - } -} - -/** - * Defers incremental bridge work to a later timer turn. - */ -function delayIncrementalBridge(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms) - }) -} diff --git a/src/main/codex/codex-session-source-home.ts b/src/main/codex/codex-session-source-home.ts index 0060051e924..2dce7632bd4 100644 --- a/src/main/codex/codex-session-source-home.ts +++ b/src/main/codex/codex-session-source-home.ts @@ -3,11 +3,9 @@ import type { GlobalSettings } from '../../shared/types' /** * Resolves the user-configured Codex *session history* source home, if any. * - * Why: Orca relocates CODEX_HOME to a managed home, then bridges history from - * the user's real Codex home so /resume finds it. That source defaults to - * ~/.codex, but users who run Codex with a custom CODEX_HOME need to point - * history discovery at that folder. This override affects history only; auth, - * config, and hooks continue to read from ~/.codex. + * Why: session discovery and resume must use the rollout's authoritative + * CODEX_HOME. This override identifies that home without mirroring writable + * session files into Orca's managed runtime home. */ /** Host override; returns undefined to keep the default ~/.codex source. */ diff --git a/src/main/codex/wsl-codex-session-bridge.test.ts b/src/main/codex/wsl-codex-session-bridge.test.ts deleted file mode 100644 index c4218913a5b..00000000000 --- a/src/main/codex/wsl-codex-session-bridge.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { ChildProcess } from 'node:child_process' - -const { execFileMock } = vi.hoisted(() => ({ - execFileMock: vi.fn() -})) - -vi.mock('node:child_process', () => ({ - execFile: execFileMock -})) - -import { - buildWslCodexSessionBridgeShellCommand, - resolveWslCodexSessionBridgeLinuxPaths, - startWslCodexSessionBridgeInBackground, - syncWslCodexSessionsIntoManagedHome -} from './wsl-codex-session-bridge' - -function mockExecFileSuccess(stdout = '{"scannedFiles":2,"linkedFiles":1}\n'): void { - execFileMock.mockImplementation( - ( - _command: string, - _args: string[], - _options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void - ): ChildProcess => { - callback(null, stdout, '') - return {} as ChildProcess - } - ) -} - -beforeEach(() => { - execFileMock.mockReset() -}) - -describe('syncWslCodexSessionsIntoManagedHome', () => { - it('runs a WSL hardlink bridge from the WSL system sessions into the managed home', async () => { - mockExecFileSuccess() - - const summary = await syncWslCodexSessionsIntoManagedHome({ - distro: 'Ubuntu', - systemCodexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', - managedCodexHomePath: - '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' - }) - - expect(summary).toEqual({ scannedFiles: 2, linkedFiles: 1 }) - expect(execFileMock).toHaveBeenCalledTimes(1) - const firstCall = execFileMock.mock.calls[0] - expect(firstCall).toBeDefined() - const [command, args, options] = firstCall as [ - string, - string[], - { timeout?: number; windowsHide?: boolean } - ] - expect(command).toBe('wsl.exe') - expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--', 'bash', '-lc']) - expect(args).toHaveLength(6) - expect(options.timeout).toBe(30_000) - expect(options.windowsHide).toBe(true) - - const shellCommand = args[5] - expect(shellCommand).toContain("source_sessions_root='/home/alice/.codex/sessions'") - expect(shellCommand).toContain( - "managed_sessions_root='/home/alice/.local/share/orca/codex-runtime-home/home/sessions'" - ) - expect(shellCommand).toContain(`find "\\$source_sessions_root" -type f -name '*.jsonl' -print0`) - expect(shellCommand).toContain('ln -- "\\$source_file" "\\$target_file"') - expect(shellCommand).toContain('if [ -e "\\$target_file" ] || [ -L "\\$target_file" ]; then') - expect(shellCommand).not.toContain('ln -s') - expect(shellCommand).not.toContain('cp ') - expect(shellCommand).not.toContain('sqlite') - }) - - it('does not invoke WSL when paths are not resolvable inside the distro', async () => { - const summary = await syncWslCodexSessionsIntoManagedHome({ - distro: 'Ubuntu', - systemCodexHomePath: 'C:\\Users\\alice\\.codex', - managedCodexHomePath: 'C:\\Users\\alice\\AppData\\Roaming\\orca\\codex-runtime-home\\home' - }) - - expect(summary).toEqual({ scannedFiles: 0, linkedFiles: 0 }) - expect(execFileMock).not.toHaveBeenCalled() - }) - - it('parses the summary after WSL profile stdout', async () => { - mockExecFileSuccess('Welcome to Ubuntu\nprofile output\n{"scannedFiles":4,"linkedFiles":3}\n') - - const summary = await syncWslCodexSessionsIntoManagedHome({ - distro: 'Ubuntu', - systemCodexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', - managedCodexHomePath: - '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' - }) - - expect(summary).toEqual({ scannedFiles: 4, linkedFiles: 3 }) - }) - - it('coalesces duplicate background bridges for the same WSL target', async () => { - mockExecFileSuccess() - const target = { - distro: 'Ubuntu', - systemCodexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', - managedCodexHomePath: - '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' - } - - const firstTask = startWslCodexSessionBridgeInBackground(target) - const secondTask = startWslCodexSessionBridgeInBackground(target) - - expect(firstTask).toBe(secondTask) - await firstTask - expect(execFileMock).toHaveBeenCalledTimes(1) - }) -}) - -describe('resolveWslCodexSessionBridgeLinuxPaths', () => { - it('requires both homes to belong to the requested distro', () => { - expect( - resolveWslCodexSessionBridgeLinuxPaths({ - distro: 'Ubuntu', - systemCodexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', - managedCodexHomePath: - '\\\\wsl.localhost\\Debian\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' - }) - ).toBeNull() - }) - - it('accepts Linux paths for direct script construction tests', () => { - expect( - resolveWslCodexSessionBridgeLinuxPaths({ - distro: 'Ubuntu', - systemCodexHomePath: '/home/alice/.codex', - managedCodexHomePath: '/home/alice/.local/share/orca/codex-runtime-home/home' - }) - ).toEqual({ - systemSessionsRoot: '/home/alice/.codex/sessions', - managedSessionsRoot: '/home/alice/.local/share/orca/codex-runtime-home/home/sessions' - }) - }) -}) - -describe('buildWslCodexSessionBridgeShellCommand', () => { - it('only targets JSONL session files under sessions', () => { - const shellCommand = buildWslCodexSessionBridgeShellCommand({ - systemSessionsRoot: "/home/alice/.codex/sessions with 'quote'", - managedSessionsRoot: '/home/alice/.local/share/orca/codex-runtime-home/home/sessions' - }) - - expect(shellCommand).toContain( - `source_sessions_root='/home/alice/.codex/sessions with '\\''quote'\\'''` - ) - expect(shellCommand).toContain(`-name '*.jsonl'`) - expect(shellCommand).not.toContain('.sqlite') - }) - - it('escapes Linux-side shell variable expansion for wsl.exe argv', () => { - const shellCommand = buildWslCodexSessionBridgeShellCommand({ - systemSessionsRoot: '/home/alice/.codex/sessions', - managedSessionsRoot: '/home/alice/.local/share/orca/codex-runtime-home/home/sessions' - }) - - expect(shellCommand).toContain('\\$source_sessions_root') - expect(shellCommand).toContain('\\$source_file') - expect(shellCommand).toContain('\\$((scanned_files + 1))') - }) -}) diff --git a/src/main/codex/wsl-codex-session-bridge.ts b/src/main/codex/wsl-codex-session-bridge.ts deleted file mode 100644 index e83abfadd95..00000000000 --- a/src/main/codex/wsl-codex-session-bridge.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { execFile } from 'node:child_process' -import { posix as pathPosix } from 'node:path' -import { escapeWslShCommandForWindows } from '../../shared/wsl-login-shell-command' -import { parseWslUncPath } from '../../shared/wsl-paths' - -export type WslCodexSessionBridgeTarget = { - distro: string - systemCodexHomePath: string - managedCodexHomePath: string -} - -export type WslCodexSessionBridgeLinuxPaths = { - systemSessionsRoot: string - managedSessionsRoot: string -} - -export type WslCodexSessionBridgeSummary = { - scannedFiles: number - linkedFiles: number -} - -const emptySummary: WslCodexSessionBridgeSummary = { scannedFiles: 0, linkedFiles: 0 } -const backgroundWslSessionBridgeTasks = new Map>() -const WSL_SESSION_BRIDGE_TIMEOUT_MS = 30_000 - -export function startWslCodexSessionBridgeInBackground( - target: WslCodexSessionBridgeTarget -): Promise { - const taskKey = getWslSessionBridgeTaskKey(target) - const existingTask = backgroundWslSessionBridgeTasks.get(taskKey) - if (existingTask) { - return existingTask - } - - const task = syncWslCodexSessionsIntoManagedHome(target) - .catch((error: unknown) => { - console.warn('[codex-session-bridge] Background WSL session bridge failed:', error) - }) - .then(() => undefined) - backgroundWslSessionBridgeTasks.set(taskKey, task) - void task.finally(() => { - if (backgroundWslSessionBridgeTasks.get(taskKey) === task) { - backgroundWslSessionBridgeTasks.delete(taskKey) - } - }) - return task -} - -export async function syncWslCodexSessionsIntoManagedHome( - target: WslCodexSessionBridgeTarget -): Promise { - const paths = resolveWslCodexSessionBridgeLinuxPaths(target) - if (!paths) { - return emptySummary - } - - const stdout = await execFileUtf8('wsl.exe', [ - '-d', - target.distro, - '--', - 'bash', - '-lc', - buildWslCodexSessionBridgeShellCommand(paths) - ]) - return parseWslSessionBridgeSummary(stdout) -} - -export function resolveWslCodexSessionBridgeLinuxPaths( - target: WslCodexSessionBridgeTarget -): WslCodexSessionBridgeLinuxPaths | null { - const systemHomePath = getLinuxPathForWslDistro(target.systemCodexHomePath, target.distro) - const managedHomePath = getLinuxPathForWslDistro(target.managedCodexHomePath, target.distro) - if (!systemHomePath || !managedHomePath) { - return null - } - - return { - systemSessionsRoot: joinLinuxPath(systemHomePath, 'sessions'), - managedSessionsRoot: joinLinuxPath(managedHomePath, 'sessions') - } -} - -export function buildWslCodexSessionBridgeShellCommand( - paths: WslCodexSessionBridgeLinuxPaths -): string { - const shellCommand = [ - 'set -u', - `source_sessions_root=${quoteBashString(paths.systemSessionsRoot)}`, - `managed_sessions_root=${quoteBashString(paths.managedSessionsRoot)}`, - 'scanned_files=0', - 'linked_files=0', - 'if [ ! -d "$source_sessions_root" ]; then', - ` printf '{"scannedFiles":0,"linkedFiles":0}\\n'`, - ' exit 0', - 'fi', - "while IFS= read -r -d '' source_file; do", - ' scanned_files=$((scanned_files + 1))', - ' relative_path=${source_file#"$source_sessions_root"/}', - ' target_file="$managed_sessions_root/$relative_path"', - ' if [ -e "$target_file" ] || [ -L "$target_file" ]; then', - ' continue', - ' fi', - ' target_dir=${target_file%/*}', - ' mkdir -p -- "$target_dir" || continue', - // Why: Codex resume ignores symlinked JSONL, so WSL links must be - // Linux hardlinks created inside the distro filesystem. - ' if ln -- "$source_file" "$target_file"; then', - ' linked_files=$((linked_files + 1))', - ' fi', - `done < <(find "$source_sessions_root" -type f -name '*.jsonl' -print0 2>/dev/null)`, - `printf '{"scannedFiles":%s,"linkedFiles":%s}\\n' "$scanned_files" "$linked_files"` - ].join('\n') - return escapeWslShCommandForWindows(shellCommand) -} - -function getWslSessionBridgeTaskKey(target: WslCodexSessionBridgeTarget): string { - return [target.distro, target.systemCodexHomePath, target.managedCodexHomePath].join('\0') -} - -function getLinuxPathForWslDistro(path: string, distro: string): string | null { - const wslPath = parseWslUncPath(path) - if (wslPath) { - return wslDistroNamesMatch(wslPath.distro, distro) ? wslPath.linuxPath : null - } - return path.startsWith('/') ? path : null -} - -function wslDistroNamesMatch(left: string, right: string): boolean { - return left.toLowerCase() === right.toLowerCase() -} - -function joinLinuxPath(basePath: string, ...segments: string[]): string { - return pathPosix.join(basePath, ...segments) -} - -function quoteBashString(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'` -} - -function execFileUtf8(command: string, args: string[]): Promise { - return new Promise((resolve, reject) => { - execFile( - command, - args, - { - encoding: 'utf-8', - maxBuffer: 1024 * 1024, - timeout: WSL_SESSION_BRIDGE_TIMEOUT_MS, - windowsHide: true - }, - (error, stdout) => { - if (error) { - reject(error) - return - } - resolve(stdout) - } - ) - }) -} - -function parseWslSessionBridgeSummary(stdout: string): WslCodexSessionBridgeSummary { - try { - // Why: login/profile scripts may write stdout before the bridge summary. - const summaryLine = - stdout - .split(/\r?\n/) - .findLast((line) => line.trim().length > 0) - ?.trim() ?? '' - const parsed: unknown = JSON.parse(summaryLine) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return emptySummary - } - const summary = parsed as Record - if (typeof summary.scannedFiles !== 'number' || typeof summary.linkedFiles !== 'number') { - return emptySummary - } - return { - scannedFiles: summary.scannedFiles, - linkedFiles: summary.linkedFiles - } - } catch { - return emptySummary - } -} diff --git a/src/main/index.ts b/src/main/index.ts index 8bda7328eda..642f709138c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -980,7 +980,7 @@ function openMainWindow(): BrowserWindow { keybindings, { getAdditionalAiVaultCodexHomePaths: () => - codexRuntimeHome ? [codexRuntimeHome.getHostRuntimeHomePath()] : [], + codexRuntimeHome ? codexRuntimeHome.getAiVaultCodexHomePaths() : [], onBeforeRelaunch: async () => { isQuitting = true desktopRelayService?.fenceAndCloseNow() @@ -1837,7 +1837,7 @@ app.whenReady().then(async () => { // aiVault.listSessions RPC includes managed-Codex sessions on remote/SSH // hosts; the window-only registerCoreHandlers path never runs under serve. getAdditionalAiVaultCodexHomePaths: () => - codexRuntimeHome ? [codexRuntimeHome.getHostRuntimeHomePath()] : [], + codexRuntimeHome ? codexRuntimeHome.getAiVaultCodexHomePaths() : [], buildAgentHookPtyEnv: () => isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {} }) diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts index 5dd3cbc8dab..26e7e147629 100644 --- a/src/main/native-chat/session-file-resolver.test.ts +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -124,6 +124,26 @@ describe('resolveSessionFilePath', () => { expect(resolved).toBe(target) }) + it('matches cold-compressed Codex rollouts and prefers a plain sibling', async () => { + const root = await makeRoot('orca-native-chat-resolve-codex-zst-') + const codexSessionsDir = join(root, 'codex-sessions') + const dayDir = join(codexSessionsDir, '2026', '06', '04') + await mkdir(dayDir, { recursive: true }) + const compressed = join(dayDir, 'rollout-2026-06-04T10-00-00-dual-session.jsonl.zst') + await writeFile(compressed, 'zstd') + + await expect( + resolveSessionFilePath('codex', 'dual-session', { codexSessionsDirs: [codexSessionsDir] }) + ).resolves.toBe(compressed) + + const plain = join(dayDir, 'rollout-2026-06-04T10-00-00-dual-session.jsonl') + await writeFile(plain, '{}\n') + + await expect( + resolveSessionFilePath('codex', 'dual-session', { codexSessionsDirs: [codexSessionsDir] }) + ).resolves.toBe(plain) + }) + it('resolves a rollout from the orca-managed Codex home (ORCA_USER_DATA_PATH)', async () => { // Orca launches Codex with its own managed CODEX_HOME, so rollout files land // under /codex-runtime-home/home/sessions, NOT ~/.codex/sessions. @@ -196,9 +216,68 @@ describe('resolveSessionFilePath', () => { const resolved = await resolveSessionFilePath('claude', 'hook-session-id', { claudeProjectsDir, - transcriptPath: realFile + transcriptPath: realFile, + requireTranscriptPathInAgentRoots: true }) - expect(resolved).toBe(realFile) + expect(resolved).toBe(await realpath(realFile)) + }) + + it('rejects a client transcriptPath outside the agent transcript roots', async () => { + const root = await makeRoot('orca-native-chat-resolve-contained-') + const claudeProjectsDir = join(root, 'claude-projects') + const projectDir = join(claudeProjectsDir, '-Users-ada-repo') + await mkdir(projectDir, { recursive: true }) + const fallback = join(projectDir, 'hook-session-id.jsonl') + const outside = join(root, 'outside.jsonl') + await writeFile(fallback, '{}\n') + await writeFile(outside, '{"secret":true}\n') + + await expect( + resolveSessionFilePath('claude', 'hook-session-id', { + claudeProjectsDir, + transcriptPath: outside, + requireTranscriptPathInAgentRoots: true + }) + ).resolves.toBe(fallback) + }) + + it('rejects a client transcriptPath that escapes through a directory symlink', async () => { + const root = await makeRoot('orca-native-chat-resolve-symlink-') + const claudeProjectsDir = join(root, 'claude-projects') + const outsideDir = join(root, 'outside') + await mkdir(claudeProjectsDir, { recursive: true }) + await mkdir(outsideDir, { recursive: true }) + await writeFile(join(outsideDir, 'secret.jsonl'), '{"secret":true}\n') + const linkedDir = join(claudeProjectsDir, 'linked-project') + await symlink(outsideDir, linkedDir, process.platform === 'win32' ? 'junction' : 'dir') + + await expect( + resolveSessionFilePath('claude', 'missing', { + claudeProjectsDir, + transcriptPath: join(linkedDir, 'secret.jsonl'), + requireTranscriptPathInAgentRoots: true + }) + ).resolves.toBeNull() + }) + + it('accepts a hook-reported .jsonl.zst path only for Codex', async () => { + const root = await makeRoot('orca-native-chat-resolve-hook-zst-') + const compressed = join(root, 'rollout-hook-session.jsonl.zst') + await writeFile(compressed, 'zstd') + + await expect( + resolveSessionFilePath('codex', 'hook-session', { + codexSessionsDirs: [root], + transcriptPath: compressed, + requireTranscriptPathInAgentRoots: true + }) + ).resolves.toBe(await realpath(compressed)) + await expect( + resolveSessionFilePath('claude', 'hook-session', { + claudeProjectsDir: join(root, 'empty'), + transcriptPath: compressed + }) + ).resolves.toBeNull() }) it('falls back to the id glob when the hook transcriptPath does not exist', async () => { diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index adfa3856f0e..793a00c2780 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -1,7 +1,13 @@ import { existsSync } from 'node:fs' +import { realpath } from 'node:fs/promises' import { homedir } from 'node:os' -import { basename, extname, join } from 'node:path' +import { basename, extname, isAbsolute, join, relative, sep } from 'node:path' import type { AgentType } from '../../shared/native-chat-types' +import { + CODEX_SESSION_ROLLOUT_EXTENSIONS, + codexRolloutBaseName, + isCodexSessionRolloutPath +} from '../ai-vault/session-scanner-codex-paths' import { walkSessionFiles } from '../ai-vault/session-scanner-discovery' import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths' import { @@ -49,6 +55,51 @@ export type ResolveSessionFileOptions = { * directly — recent Claude Code names the transcript with a UUID that differs * from the hook session_id, so the id-based glob below would miss it. */ transcriptPath?: string + /** Require a client-provided transcript path to resolve inside this agent's + * transcript roots. Runtime RPC enables this for untrusted paired clients. */ + requireTranscriptPathInAgentRoots?: boolean +} + +function agentTranscriptRoots(agent: AgentType, options: ResolveSessionFileOptions): string[] { + if (agent === 'claude') { + return [options.claudeProjectsDir ?? claudeProjectsDir()] + } + if (agent === 'codex') { + return options.codexSessionsDirs ?? codexSessionsDirs() + } + if (agent === 'grok') { + return [options.grokSessionsDir ?? grokSessionsDir()] + } + return [] +} + +async function resolveContainedPath( + filePath: string, + roots: readonly string[] +): Promise { + let resolvedFile: string + try { + resolvedFile = await realpath(filePath) + } catch { + return null + } + + for (const root of roots) { + try { + const resolvedRoot = await realpath(root) + const relativePath = relative(resolvedRoot, resolvedFile) + if ( + relativePath === '' || + (!isAbsolute(relativePath) && relativePath !== '..' && !relativePath.startsWith(`..${sep}`)) + ) { + // Return the canonical path so a symlink cannot be swapped after validation. + return resolvedFile + } + } catch { + // Missing roots cannot contain an existing transcript; try the next root. + } + } + return null } /** @@ -71,8 +122,21 @@ export async function resolveSessionFilePath( // stale/remote path falls through to the id-based search rather than returning // a non-existent file. const hookPath = options.transcriptPath?.trim() - if (hookPath && extname(hookPath) === '.jsonl' && existsSync(hookPath)) { - return hookPath + const hookPathIsTranscript = + extname(hookPath ?? '') === '.jsonl' || + (agent === 'codex' && Boolean(hookPath && isCodexSessionRolloutPath(hookPath))) + if (hookPath && hookPathIsTranscript) { + if (options.requireTranscriptPathInAgentRoots) { + const containedPath = await resolveContainedPath( + hookPath, + agentTranscriptRoots(agent, options) + ) + if (containedPath) { + return containedPath + } + } else if (existsSync(hookPath)) { + return hookPath + } } const trimmedId = sessionId.trim() @@ -108,22 +172,25 @@ async function resolveCodexSessionFile( sessionId: string, sessionsDirs: string[] ): Promise { - // Codex rollout file names embed the session id (rollout--.jsonl), so - // match the id as a suffix of the file's base name rather than an exact name. + // Codex rollout file names embed the session id. Prefer plain `.jsonl` when + // both it and a cold `.jsonl.zst` sibling exist. // Search each candidate root (managed home first) and stop at the first match. for (const sessionsDir of sessionsDirs) { if (!existsSync(sessionsDir)) { continue } const files = await walkSessionFiles(sessionsDir, 'codex', [], { - extensions: new Set(['.jsonl']), + extensions: new Set(CODEX_SESSION_ROLLOUT_EXTENSIONS), filePredicate: (path) => { - const name = basename(path, extname(path)) + if (!isCodexSessionRolloutPath(path)) { + return false + } + const name = codexRolloutBaseName(path) return name === sessionId || name.endsWith(`-${sessionId}`) } }) - if (files[0]) { - return files[0] + if (files.length > 0) { + return files.find((path) => path.endsWith('.jsonl')) ?? files[0] ?? null } } return null diff --git a/src/main/native-chat/transcript-codex-turn-items.ts b/src/main/native-chat/transcript-codex-turn-items.ts new file mode 100644 index 00000000000..bdb25f5d123 --- /dev/null +++ b/src/main/native-chat/transcript-codex-turn-items.ts @@ -0,0 +1,138 @@ +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { asRecord, extractString } from '../ai-vault/session-scanner-values' +import { claudeContentBlocks } from './transcript-record-blocks' + +/** Maps a paginated Codex TurnItem into Native Chat's transcript model. */ +export function codexTurnItem( + item: Record, + id: string, + timestamp: number | null +): NativeChatMessage | null { + const itemType = normalizeCodexTurnItemType(item.type) + if (itemType === 'user_message') { + const text = codexTurnItemText(item.content) + return text + ? { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } + : null + } + if (itemType === 'agent_message') { + const blocks = claudeContentBlocks(item.content) + const fallbackText = blocks.length === 0 ? codexTurnItemText(item.content) : null + if (blocks.length === 0 && !fallbackText) { + return null + } + return { + id, + role: 'assistant', + blocks: blocks.length > 0 ? blocks : [{ type: 'text', text: fallbackText! }], + timestamp, + source: 'transcript' + } + } + if (itemType === 'reasoning') { + const text = codexReasoningItemText(item) + return text + ? { + id, + role: 'reasoning', + blocks: [{ type: 'text', text }], + timestamp, + source: 'transcript' + } + : null + } + if (itemType === 'command_execution') { + return { + id, + role: 'assistant', + blocks: [ + { + type: 'tool-call', + name: 'command_execution', + input: { + command: item.command, + cwd: item.cwd, + status: item.status, + exit_code: item.exit_code + } + } + ], + timestamp, + source: 'transcript' + } + } + if (itemType === 'dynamic_tool_call' || itemType === 'mcp_tool_call') { + const name = + extractString(item.tool) ?? + extractString(item.name) ?? + (itemType === 'mcp_tool_call' ? 'mcp_tool' : 'tool') + return { + id, + role: 'assistant', + blocks: [{ type: 'tool-call', name, input: item.arguments ?? item.input ?? item }], + timestamp, + source: 'transcript' + } + } + return null +} + +function codexTurnItemText(content: unknown): string | null { + if (typeof content === 'string') { + return extractString(content) + } + if (!Array.isArray(content)) { + const record = asRecord(content) + return extractString(record?.text) ?? extractString(record?.message) + } + const parts: string[] = [] + for (const entry of content) { + if (typeof entry === 'string') { + if (entry.trim()) { + parts.push(entry) + } + continue + } + const record = asRecord(entry) + const text = extractString(record?.text) ?? extractString(record?.content) + if (text) { + parts.push(text) + } + } + return parts.length > 0 ? parts.join('') : null +} + +function codexReasoningItemText(item: Record): string | null { + if (Array.isArray(item.summary_text)) { + const parts = item.summary_text + .map((entry) => extractString(entry)) + .filter((entry): entry is string => Boolean(entry)) + if (parts.length > 0) { + return parts.join('\n') + } + } + if (Array.isArray(item.summary)) { + const parts: string[] = [] + for (const entry of item.summary) { + const text = extractString(asRecord(entry)?.text) ?? extractString(entry) + if (text) { + parts.push(text) + } + } + if (parts.length > 0) { + return parts.join('\n') + } + } + return extractString(item.text) +} + +function normalizeCodexTurnItemType(value: unknown): string | null { + const raw = extractString(value) + if (!raw) { + return null + } + return raw + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') + .toLowerCase() +} diff --git a/src/main/native-chat/transcript-line-decoders-codex.ts b/src/main/native-chat/transcript-line-decoders-codex.ts index 64d04fa5ae3..bb94b21e034 100644 --- a/src/main/native-chat/transcript-line-decoders-codex.ts +++ b/src/main/native-chat/transcript-line-decoders-codex.ts @@ -8,29 +8,89 @@ import { timestampMs } from '../ai-vault/session-scanner-values' import { claudeContentBlocks, toolResultOutput } from './transcript-record-blocks' +import { codexTurnItem } from './transcript-codex-turn-items' export function decodeCodexTranscriptLine( line: string, fallbackId: string ): NativeChatMessage | null { + return createCodexTranscriptLineDecoder()(line, fallbackId) +} + +/** Keeps the rollout's history mode and adjacent cross-format duplicate state. */ +export function createCodexTranscriptLineDecoder(): ( + line: string, + fallbackId: string +) => NativeChatMessage | null { + let historyMode: string | null = null + let lastEmitted: { source: 'response-item' | 'turn-item'; fingerprint: string } | null = null + + return (line, fallbackId) => { + const decoded = decodeCodexRecord(line, fallbackId, historyMode) + if (decoded.historyMode !== undefined) { + historyMode = decoded.historyMode + } + if (!decoded.message || !decoded.source) { + return null + } + const fingerprint = messageFingerprint(decoded.message) + if ( + lastEmitted && + lastEmitted.source !== decoded.source && + lastEmitted.fingerprint === fingerprint + ) { + return null + } + lastEmitted = { source: decoded.source, fingerprint } + return decoded.message + } +} + +function decodeCodexRecord( + line: string, + fallbackId: string, + historyMode: string | null +): { + message: NativeChatMessage | null + source?: 'response-item' | 'turn-item' + historyMode?: string | null +} { const record = parseJsonObject(line) if (!record) { - return null + return { message: null } } const payload = asRecord(record.payload) if (!payload) { - return null + return { message: null } + } + if (record.type === 'session_meta') { + return { + message: null, + historyMode: extractString(payload.history_mode) ?? extractString(payload.historyMode) ?? null + } } const timestamp = parseTimestamp(record.timestamp) const baseId = extractString(payload.id) ?? fallbackId if (record.type === 'response_item') { - return codexResponseItem(payload, baseId, timestamp) + // Why: paginated rollouts persist the same logical item in both formats; + // TurnItems are canonical and carry stable ids, so rendering both duplicates turns. + return { + message: historyMode === 'paginated' ? null : codexResponseItem(payload, baseId, timestamp), + source: 'response-item' + } } if (record.type === 'event_msg') { - return codexEventMessage(payload, baseId, timestamp) + return { + message: codexEventMessage(payload, baseId, timestamp), + source: payload.type === 'item_completed' ? 'turn-item' : undefined + } } - return null + return { message: null } +} + +function messageFingerprint(message: NativeChatMessage): string { + return JSON.stringify([message.role, message.blocks]) } function codexResponseItem( @@ -99,6 +159,12 @@ function codexEventMessage( ? { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } : null } + // Why: paginated history emits completed TurnItems instead of the legacy + // user_message/agent_message event variants. + if (payload.type === 'item_completed') { + const item = asRecord(payload.item) + return item ? codexTurnItem(item, extractString(item.id) ?? id, timestamp) : null + } return null } diff --git a/src/main/native-chat/transcript-line-decoders.ts b/src/main/native-chat/transcript-line-decoders.ts index 67b1aaf76aa..96e882c83ee 100644 --- a/src/main/native-chat/transcript-line-decoders.ts +++ b/src/main/native-chat/transcript-line-decoders.ts @@ -1,14 +1,17 @@ // Per-line record→NativeChatMessage decoders, shared by the full transcript // reader (transcript-reader.ts) and the live tailer (transcript-watch.ts) so -// both paths apply identical record-shape mapping. Each decoder is stateless: -// it takes a single JSONL line plus a stable fallback id and returns one message -// or null (unknown/empty records are skipped, never thrown — plan KTD risk: -// schema drift). `fallbackId` is used only when the record carries no intrinsic -// id; the caller supplies a value unique per line. +// both paths apply identical record-shape mapping. A decoder takes one JSONL +// line plus a stable fallback id and returns one message or null (unknown/empty +// records are skipped, never thrown — plan KTD risk: schema drift). Codex also +// exposes a per-stream factory because pagination mode is declared in session +// metadata. `fallbackId` is used only when a record has no intrinsic id. // // Why: agent-specific decoders live in dedicated modules so this barrel stays // under the max-lines limit while callers keep a single import path. export { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude' -export { decodeCodexTranscriptLine } from './transcript-line-decoders-codex' +export { + createCodexTranscriptLineDecoder, + decodeCodexTranscriptLine +} from './transcript-line-decoders-codex' export { decodeGrokTranscriptLine } from './transcript-line-decoders-grok' diff --git a/src/main/native-chat/transcript-read-cache.test.ts b/src/main/native-chat/transcript-read-cache.test.ts index 0d61f25f48e..8c4689e1075 100644 --- a/src/main/native-chat/transcript-read-cache.test.ts +++ b/src/main/native-chat/transcript-read-cache.test.ts @@ -1,6 +1,7 @@ import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { zstdCompressSync } from 'node:zlib' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as TranscriptReader from './transcript-reader' @@ -210,13 +211,36 @@ describe('readNativeChatTranscriptCached', () => { expect(readSpy).toHaveBeenCalledTimes(5) }) - it('keeps a single active transcript larger than the whole budget cached', async () => { - // A lone entry over budget must NOT be dropped, else every read re-parses. + it('does not retain a single transcript larger than the whole cache budget', async () => { setNativeChatTranscriptCacheMaxBytesForTests(1024) const big = await seedBigFile('huge', 8 * 1024) await readNativeChatTranscriptCached('claude', 'huge', big) await readNativeChatTranscriptCached('claude', 'huge', big) - expect(readSpy).toHaveBeenCalledTimes(1) + expect(readSpy).toHaveBeenCalledTimes(2) + }) + + it('does not use compressed file bytes as the cache weight for an unbounded parse', async () => { + setNativeChatTranscriptCacheMaxBytesForTests(1024) + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-cache-zst-')) + tempRoots.push(root) + const filePath = join(root, 'rollout-session.jsonl.zst') + const record = { + type: 'event_msg', + payload: { + type: 'item_completed', + item: { + type: 'user_message', + id: 'u-zst', + content: [{ type: 'text', text: 'x'.repeat(8 * 1024) }] + } + } + } + await writeFile(filePath, zstdCompressSync(Buffer.from(JSON.stringify(record), 'utf-8'))) + + await readNativeChatTranscriptCached('codex', 'zst', filePath) + await readNativeChatTranscriptCached('codex', 'zst', filePath) + + expect(readSpy).toHaveBeenCalledTimes(2) }) it('does not evict small entries under the default budget (no regression for typical use)', async () => { diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts index 8943c223dd2..c7806e0d3cf 100644 --- a/src/main/native-chat/transcript-read-cache.ts +++ b/src/main/native-chat/transcript-read-cache.ts @@ -1,7 +1,9 @@ import { stat } from 'node:fs/promises' import type { AgentType } from '../../shared/native-chat-types' +import { isCodexCompressedRolloutPath } from '../ai-vault/session-scanner-codex-paths' import { resolveSessionFilePath } from './session-file-resolver' import { readNativeChatTranscript, type ReadTranscriptResult } from './transcript-reader' +import type { TranscriptDecodeLimits } from './transcript-stream-lines' // Why: both the desktop IPC handler and the runtime RPC handler read the same // host-filesystem transcript, so a single process-global cache keyed by the @@ -13,16 +15,15 @@ import { readNativeChatTranscript, type ReadTranscriptResult } from './transcrip // share one sessionId yet resolve to DIFFERENT files (the same session resumed // into a second worktree, which writes a new transcript file), and a // sessionId-only key let one worktree's cached parse be served to another when -// their file mtimes momentarily coincided (#7326). The cache stores ONE -// canonical, unwindowed parse; windowing and per-surface truncation stay in the -// callers so the same parse is reused across all `limit` values and every client kind. +// their file mtimes momentarily coincided (#7326). Desktop entries store a +// canonical unwindowed parse; paired clients use a separate fixed-size tail +// entry that is reused across requested windows. type CachedTranscript = { result: ReadTranscriptResult /** mtime of the resolved file when cached; a newer mtime invalidates it. */ mtimeMs: number - /** On-disk byte size of the resolved file — a cheap, monotonic proxy for this - * entry's parsed memory footprint, used to bound the cache by total bytes. */ + /** Conservative weight for this entry's parsed memory footprint. */ bytes: number } @@ -33,12 +34,10 @@ const cache = new Map() // the oldest entry (a simple LRU once re-inserts bump recency; see setCached). const MAX_CACHE_ENTRIES = 50 // Why: a heavy Claude/Codex coding session's JSONL is routinely tens of MB (tool -// results embed whole file contents, command output, and diffs), and each cached -// entry is the full unwindowed parse. The count cap alone let 50 such entries -// retain multiple GB in the one process that now serves desktop + every paired -// web/mobile client. Bound total cached file bytes too; we always keep the most- -// recent entry (see setCached) so an active transcript is never re-parsed on -// every read, which caps the regression to extra re-parses only past this budget. +// results embed whole file contents, command output, and diffs). The count cap +// alone let 50 such entries retain multiple GB in the process serving desktop +// and paired clients. Bound total cache weight too; entries over budget are read +// successfully but not retained. const MAX_CACHE_BYTES = 128 * 1024 * 1024 // Overridable only from tests so the byte-eviction path can be exercised without // writing hundreds of MB of fixtures; production always uses MAX_CACHE_BYTES. @@ -47,15 +46,18 @@ let maxCacheBytes = MAX_CACHE_BYTES function setCached(key: string, value: CachedTranscript): void { // Re-insert moves the key to the most-recent position for LRU eviction. cache.delete(key) + // Why: retaining one entry past the budget defeats the RSS guard entirely. + // Oversized reads still succeed; they are simply re-read instead of cached. + if (!Number.isFinite(value.bytes) || value.bytes > maxCacheBytes) { + return + } cache.set(key, value) let totalBytes = 0 for (const entry of cache.values()) { totalBytes += entry.bytes } - // Evict oldest until within BOTH caps, but never drop the most-recent entry - // (cache.size > 1): a single active transcript larger than the whole budget - // must stay cached or every read would re-parse the full file. - while (cache.size > 1 && (cache.size > MAX_CACHE_ENTRIES || totalBytes > maxCacheBytes)) { + // Evict oldest until within both caps. + while (cache.size > 0 && (cache.size > MAX_CACHE_ENTRIES || totalBytes > maxCacheBytes)) { const oldest = cache.keys().next().value if (oldest === undefined) { break @@ -65,8 +67,15 @@ function setCached(key: string, value: CachedTranscript): void { } } -function cacheKey(agent: AgentType, filePath: string): string { - return `${agent}:${filePath}` +function cacheKey( + agent: AgentType, + filePath: string, + limits: TranscriptDecodeLimits | undefined +): string { + const mode = limits + ? `${limits.maxDecodedBytes ?? ''}:${limits.maxLineBytes ?? ''}:${limits.maxMessages ?? ''}` + : 'full' + return `${agent}:${filePath}:${mode}` } async function fileStat(filePath: string): Promise<{ mtimeMs: number; bytes: number }> { @@ -79,25 +88,32 @@ async function fileStat(filePath: string): Promise<{ mtimeMs: number; bytes: num } /** - * Read the full transcript for an agent + session, returning the cached parse on - * an mtime hit and re-reading (and re-caching) when the file changed. Returns the - * canonical, unwindowed result; callers apply their own windowing/truncation. + * Read a transcript for an agent + session, returning the policy-specific cached + * parse on an mtime hit and re-reading when the file changed. Desktop callers + * omit limits; paired clients cache one fixed-size tail for subsequent windows. */ export async function readNativeChatTranscriptCached( agent: AgentType, sessionId: string, /** Hook-reported authoritative transcript path, preferred over the id glob. */ - transcriptPath?: string + transcriptPath?: string, + options: { + requireTranscriptPathInAgentRoots?: boolean + limits?: TranscriptDecodeLimits + } = {} ): Promise { - const filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath }) + const filePath = await resolveSessionFilePath(agent, sessionId, { + transcriptPath, + requireTranscriptPathInAgentRoots: options.requireTranscriptPathInAgentRoots + }) if (!filePath) { // Not cached (see below): a not-yet-flushed transcript should be re-checked // on the next call, not pinned as a settled miss (#8401). return { error: `No transcript found for ${agent} session ${sessionId}`, notFound: true } } - const key = cacheKey(agent, filePath) - const { mtimeMs, bytes } = await fileStat(filePath) + const key = cacheKey(agent, filePath, options.limits) + const { mtimeMs, bytes: onDiskBytes } = await fileStat(filePath) const cached = cache.get(key) if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs) { // Bump recency so a frequently-read session survives eviction. @@ -105,9 +121,18 @@ export async function readNativeChatTranscriptCached( return cached.result } - const result = await readNativeChatTranscript(agent, sessionId, { filePath }) + const result = await readNativeChatTranscript(agent, sessionId, { + filePath, + limits: options.limits + }) if (Number.isFinite(mtimeMs)) { - setCached(key, { result, mtimeMs, bytes }) + // A compressed file's disk size says nothing about retained decoded data. + // A bounded parse can safely use its decoded cap as an upper-bound weight; + // an unbounded compressed parse is deliberately not cached. + const cacheBytes = isCodexCompressedRolloutPath(filePath) + ? (options.limits?.maxDecodedBytes ?? Number.POSITIVE_INFINITY) + : onDiskBytes + setCached(key, { result, mtimeMs, bytes: Math.max(onDiskBytes, cacheBytes) }) } return result } diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts index cd87f2bdd59..edf25fd1710 100644 --- a/src/main/native-chat/transcript-reader.test.ts +++ b/src/main/native-chat/transcript-reader.test.ts @@ -1,6 +1,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { zstdCompressSync } from 'node:zlib' import { afterEach, describe, expect, it } from 'vitest' import { readNativeChatTranscript } from './transcript-reader' @@ -173,6 +174,305 @@ describe('readNativeChatTranscript (claude)', () => { }) describe('readNativeChatTranscript (codex)', () => { + it('maps paginated item_completed turn items into chat messages', async () => { + const filePath = await writeFixture('orca-native-chat-codex-paginated-', [ + { + type: 'session_meta', + timestamp: '2026-06-01T10:00:00.000Z', + payload: { id: 'codex-paginated', history_mode: 'paginated' } + }, + { + type: 'response_item', + timestamp: '2026-06-01T10:00:01.000Z', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Paginated hello' }] + } + }, + { + type: 'event_msg', + timestamp: '2026-06-01T10:00:01.000Z', + payload: { + type: 'item_completed', + item: { + type: 'UserMessage', + id: 'user-1', + content: [{ type: 'text', text: 'Paginated hello' }] + } + } + }, + { + type: 'response_item', + timestamp: '2026-06-01T10:00:04.000Z', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Done.' }] + } + }, + { + type: 'event_msg', + timestamp: '2026-06-01T10:00:02.000Z', + payload: { + type: 'item_completed', + item: { + type: 'reasoning', + id: 'reason-1', + summary_text: ['Thinking about the answer'] + } + } + }, + { + type: 'event_msg', + timestamp: '2026-06-01T10:00:03.000Z', + payload: { + type: 'item_completed', + item: { + type: 'command_execution', + id: 'cmd-1', + command: ['bash', '-lc', 'ls'], + cwd: '/repo', + status: 'completed', + exit_code: 0 + } + } + }, + { + type: 'event_msg', + timestamp: '2026-06-01T10:00:04.000Z', + payload: { + type: 'item_completed', + item: { + type: 'agent_message', + id: 'agent-1', + content: [{ type: 'text', text: 'Done.' }] + } + } + } + ]) + + const result = await readNativeChatTranscript('codex', 'codex-paginated', { filePath }) + if (!('messages' in result)) { + throw new Error('expected messages') + } + + expect(result.messages.map((message) => message.role)).toEqual([ + 'user', + 'reasoning', + 'assistant', + 'assistant' + ]) + expect(result.messages[0]?.blocks[0]).toEqual({ type: 'text', text: 'Paginated hello' }) + expect(result.messages[1]?.blocks[0]).toEqual({ + type: 'text', + text: 'Thinking about the answer' + }) + expect(result.messages[2]?.blocks[0]).toEqual({ + type: 'tool-call', + name: 'command_execution', + input: { + command: ['bash', '-lc', 'ls'], + cwd: '/repo', + status: 'completed', + exit_code: 0 + } + }) + expect(result.messages[3]?.blocks[0]).toEqual({ type: 'text', text: 'Done.' }) + expect(result.messages.map((message) => message.id)).toEqual([ + 'user-1', + 'reason-1', + 'cmd-1', + 'agent-1' + ]) + }) + + it('deduplicates adjacent response and TurnItem messages when a tail omits session_meta', async () => { + const filePath = await writeFixture('orca-native-chat-codex-tail-dedupe-', [ + { + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Same completed answer' }] + } + }, + { + type: 'event_msg', + payload: { + type: 'item_completed', + item: { + type: 'agent_message', + id: 'canonical-answer', + content: [{ type: 'text', text: 'Same completed answer' }] + } + } + } + ]) + + const result = await readNativeChatTranscript('codex', 'session', { filePath }) + if (!('messages' in result)) { + throw new Error(`expected messages, got ${result.error}`) + } + expect(result.messages).toHaveLength(1) + expect(result.messages[0]?.blocks[0]).toEqual({ + type: 'text', + text: 'Same completed answer' + }) + }) + + it('reads cold-compressed .jsonl.zst transcripts', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-codex-zst-')) + tempRoots.push(root) + const filePath = join(root, 'rollout-session.jsonl.zst') + const plain = jsonLines([ + { + type: 'event_msg', + timestamp: '2026-06-01T10:00:01.000Z', + payload: { + type: 'item_completed', + item: { + type: 'user_message', + id: 'u1', + content: [{ type: 'text', text: 'From zst' }] + } + } + }, + { + type: 'event_msg', + timestamp: '2026-06-01T10:00:02.000Z', + payload: { + type: 'item_completed', + item: { + type: 'agent_message', + id: 'a1', + content: [{ type: 'text', text: 'Compressed reply' }] + } + } + } + ]) + await writeFile(filePath, zstdCompressSync(Buffer.from(plain, 'utf-8'))) + + const result = await readNativeChatTranscript('codex', 'session', { filePath }) + if (!('messages' in result)) { + throw new Error('expected messages') + } + expect(result.messages.map((message) => message.role)).toEqual(['user', 'assistant']) + expect(result.messages[0]?.blocks[0]).toEqual({ type: 'text', text: 'From zst' }) + }) + + it('returns a newline-aligned compressed tail within the decompressed byte window', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-codex-zst-byte-limit-')) + tempRoots.push(root) + const filePath = join(root, 'rollout-session.jsonl.zst') + const plain = jsonLines( + Array.from({ length: 20 }, (_unused, index) => ({ + type: 'event_msg', + payload: { + type: 'item_completed', + item: { + type: 'user_message', + id: `u${index}`, + content: [{ type: 'text', text: 'x'.repeat(100) }] + } + } + })) + ) + await writeFile(filePath, zstdCompressSync(Buffer.from(plain, 'utf-8'))) + + const result = await readNativeChatTranscript('codex', 'session', { + filePath, + limits: { maxDecodedBytes: 512 } + }) + + if (!('messages' in result)) { + throw new Error(`expected messages, got ${result.error}`) + } + expect(result.messages.map((message) => message.id)).toEqual(['u18', 'u19']) + }) + + it('returns a newline-aligned plain tail instead of failing on an oversized transcript', async () => { + const filePath = await writeFixture( + 'orca-native-chat-codex-plain-tail-', + Array.from({ length: 20 }, (_unused, index) => ({ + type: 'event_msg', + payload: { + type: 'item_completed', + item: { + type: 'user_message', + id: `plain-${index}`, + content: [{ type: 'text', text: 'x'.repeat(100) }] + } + } + })) + ) + + const result = await readNativeChatTranscript('codex', 'session', { + filePath, + limits: { maxDecodedBytes: 512 } + }) + + if (!('messages' in result)) { + throw new Error(`expected messages, got ${result.error}`) + } + expect(result.messages.map((message) => message.id)).toEqual(['plain-18', 'plain-19']) + }) + + it('rejects a decompressed transcript line over the per-line byte limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-codex-zst-line-limit-')) + tempRoots.push(root) + const filePath = join(root, 'rollout-session.jsonl.zst') + const plain = jsonLines([ + { + type: 'event_msg', + payload: { + type: 'item_completed', + item: { + type: 'user_message', + id: 'u1', + content: [{ type: 'text', text: 'x'.repeat(1024) }] + } + } + } + ]) + await writeFile(filePath, zstdCompressSync(Buffer.from(plain, 'utf-8'))) + + const result = await readNativeChatTranscript('codex', 'session', { + filePath, + limits: { maxDecodedBytes: 4096, maxLineBytes: 256 } + }) + + expect(result).toEqual({ error: expect.stringContaining('line byte limit') }) + }) + + it('keeps only the newest messages while decoding a bounded transcript window', async () => { + const filePath = await writeFixture( + 'orca-native-chat-codex-bounded-', + [1, 2, 3, 4].map((index) => ({ + type: 'event_msg', + timestamp: `2026-06-01T10:00:0${index}.000Z`, + payload: { + type: 'item_completed', + item: { + type: 'user_message', + id: `u${index}`, + content: [{ type: 'text', text: `message-${index}` }] + } + } + })) + ) + + const result = await readNativeChatTranscript('codex', 'session', { + filePath, + limits: { maxMessages: 2 } + }) + + if (!('messages' in result)) { + throw new Error(`expected messages, got ${result.error}`) + } + expect(result.messages.map((message) => message.id)).toEqual(['u3', 'u4']) + }) + it('maps tool calls and results to tool-call/tool-result blocks', async () => { const filePath = await writeFixture('orca-native-chat-codex-', [ { diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index e2ea727c809..15f27a9a1ee 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -1,13 +1,19 @@ import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { Readable } from 'node:stream' import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import { isCodexCompressedRolloutPath } from '../ai-vault/session-scanner-codex-paths' +import { openCodexRolloutStream } from '../ai-vault/session-scanner-codex-rollout-read' import { errorMessage } from '../ai-vault/session-scanner-values' import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' import { decodeClaudeTranscriptLine, - decodeCodexTranscriptLine, + createCodexTranscriptLineDecoder, decodeGrokTranscriptLine } from './transcript-line-decoders' import { decodeTranscriptStream } from './transcript-stream-lines' +import type { TranscriptDecodeLimits } from './transcript-stream-lines' +import { newlineAlignedTailStart, readStreamTail } from './transcript-tail-window' export type ReadTranscriptResult = | { messages: NativeChatMessage[] } @@ -18,14 +24,17 @@ export type ReadTranscriptResult = export type ReadTranscriptOptions = ResolveSessionFileOptions & { /** Resolve directly to this file, skipping path discovery (used by tests). */ filePath?: string + /** Optional streaming limits for remote/windowed readers. Omitted for the + * desktop full-history contract. */ + limits?: TranscriptDecodeLimits } /** - * Read the ENTIRE Claude/Codex JSONL transcript for an agent + session id into - * the NativeChatMessage model. Unlike the AI-Vault preview scan, this applies - * NO message cap. Unknown record types are skipped rather than throwing, so a - * single malformed/unrecognized line cannot fail the whole read. The per-line - * record-to-message mapping is shared with the live tailer. + * Read a Claude/Codex JSONL transcript for an agent + session id into the + * NativeChatMessage model. Desktop callers omit limits and retain full history; + * remote callers provide streaming limits. Unknown record types are skipped + * rather than failing the whole read. The per-line mapping is shared with the + * live tailer. */ export async function readNativeChatTranscript( agent: AgentType, @@ -38,13 +47,17 @@ export async function readNativeChatTranscript( } try { if (agent === 'claude') { - return { messages: await readTranscript(filePath, decodeClaudeTranscriptLine) } + return { + messages: await readTranscript(filePath, decodeClaudeTranscriptLine, options.limits) + } } if (agent === 'codex') { - return { messages: await readTranscript(filePath, decodeCodexTranscriptLine) } + return { + messages: await readTranscript(filePath, createCodexTranscriptLineDecoder(), options.limits) + } } if (agent === 'grok') { - return { messages: await readTranscript(filePath, decodeGrokTranscriptLine) } + return { messages: await readTranscript(filePath, decodeGrokTranscriptLine, options.limits) } } return { error: `Unsupported agent for native chat transcript: ${agent}` } } catch (err) { @@ -59,9 +72,28 @@ export async function readNativeChatTranscript( async function readTranscript( filePath: string, - decode: (line: string, fallbackId: string) => NativeChatMessage | null + decode: (line: string, fallbackId: string) => NativeChatMessage | null, + limits?: TranscriptDecodeLimits ): Promise { - const stream = createReadStream(filePath, { encoding: 'utf-8' }) - const { messages } = await decodeTranscriptStream(stream, filePath, 0, decode, true) + // Why: Codex cold-compresses older rollouts; other agents remain plain JSONL. + // Keep plain reads as bytes so malformed UTF-8 cannot distort safety limits. + const compressed = isCodexCompressedRolloutPath(filePath) + let start = 0 + let stream: Readable + if (compressed && limits?.maxDecodedBytes !== undefined) { + const tail = await readStreamTail(openCodexRolloutStream(filePath), limits.maxDecodedBytes) + start = tail.decodedStart + stream = Readable.from(tail.bytes) + } else if (compressed) { + stream = openCodexRolloutStream(filePath) + } else { + const end = (await stat(filePath)).size + start = + limits?.maxDecodedBytes === undefined + ? 0 + : await newlineAlignedTailStart(filePath, end, limits.maxDecodedBytes) + stream = createReadStream(filePath, { start }) + } + const { messages } = await decodeTranscriptStream(stream, filePath, start, decode, true, limits) return messages } diff --git a/src/main/native-chat/transcript-stream-lines.test.ts b/src/main/native-chat/transcript-stream-lines.test.ts index 2bfa6f6f9d2..de6793a7f7d 100644 --- a/src/main/native-chat/transcript-stream-lines.test.ts +++ b/src/main/native-chat/transcript-stream-lines.test.ts @@ -11,6 +11,74 @@ const decode = (line: string, id: string) => ({ }) describe('decodeTranscriptStream', () => { + it.each([ + ['emoji', '🙂', 2], + ['CJK character', '汉', 1] + ])('preserves a split %s across buffer chunks', async (_name, character, splitOffset) => { + const prefix = '{"value":"' + const line = `${prefix}${character}"}` + const encoded = Buffer.from(`${line}\n`, 'utf8') + const splitIndex = Buffer.byteLength(prefix, 'utf8') + splitOffset + + const result = await decodeTranscriptStream( + Readable.from([encoded.subarray(0, splitIndex), encoded.subarray(splitIndex)]), + '/chat.jsonl', + 0, + decode, + true, + { maxDecodedBytes: encoded.length, maxLineBytes: Buffer.byteLength(line, 'utf8') } + ) + + expect(result.messages[0]?.blocks).toEqual([{ type: 'text', text: line }]) + expect(result.consumedBytes).toBe(encoded.length) + }) + + it('flushes an incomplete UTF-8 tail without advancing past its raw bytes', async () => { + const encoded = Buffer.concat([Buffer.from('partial:'), Buffer.from([0xf0, 0x9f])]) + + const result = await decodeTranscriptStream( + Readable.from([encoded]), + '/chat.jsonl', + 0, + decode, + true + ) + + expect(result.messages[0]?.blocks).toEqual([{ type: 'text', text: 'partial:\uFFFD' }]) + expect(result.consumedBytes).toBe(encoded.length) + }) + + it('uses raw byte offsets after malformed UTF-8 before a partial next line', async () => { + const complete = Buffer.from([0xff, 0x0a]) + const partial = Buffer.from('x') + + const result = await decodeTranscriptStream( + Readable.from([complete, partial]), + '/chat.jsonl', + 0, + decode, + false + ) + + expect(result.messages[0]?.blocks).toEqual([{ type: 'text', text: '\uFFFD' }]) + expect(result.consumedBytes).toBe(complete.length) + }) + + it('preserves a surrogate pair split across string chunks', async () => { + const line = '{"value":"🙂"}' + + const result = await decodeTranscriptStream( + Readable.from(['{"value":"\ud83d', '\ude42"}\n']), + '/chat.jsonl', + 0, + decode, + true + ) + + expect(result.messages[0]?.blocks).toEqual([{ type: 'text', text: line }]) + expect(result.consumedBytes).toBe(Buffer.byteLength(`${line}\n`, 'utf8')) + }) + it('uses identical absolute byte ids for full and incremental reads', async () => { const prefix = '{"first":"é"}\r\n' const appended = '{"second":true}\n' diff --git a/src/main/native-chat/transcript-stream-lines.ts b/src/main/native-chat/transcript-stream-lines.ts index a764941ad30..5ca47054445 100644 --- a/src/main/native-chat/transcript-stream-lines.ts +++ b/src/main/native-chat/transcript-stream-lines.ts @@ -1,47 +1,133 @@ import type { Readable } from 'node:stream' +import { StringDecoder } from 'node:string_decoder' import type { NativeChatMessage } from '../../shared/native-chat-types' import { transcriptFallbackId } from './transcript-fallback-id' type TranscriptDecoder = (line: string, fallbackId: string) => NativeChatMessage | null +export type TranscriptDecodeLimits = { + /** Maximum UTF-8 bytes accepted from the decoded stream. */ + maxDecodedBytes?: number + /** Maximum UTF-8 bytes accepted in one JSONL record. */ + maxLineBytes?: number + /** Keep only the newest decoded messages while continuing to scan the stream. */ + maxMessages?: number +} + +export class TranscriptDecodeLimitError extends Error { + constructor(kind: 'decoded byte' | 'line byte', limit: number) { + super(`Transcript ${kind} limit exceeded (${limit} bytes)`) + this.name = 'TranscriptDecodeLimitError' + } +} + export async function decodeTranscriptStream( stream: Readable, filePath: string, start: number, decode: TranscriptDecoder, - includeTrailingLine: boolean + includeTrailingLine: boolean, + limits: TranscriptDecodeLimits = {} ): Promise<{ messages: NativeChatMessage[]; consumedBytes: number }> { const messages: NativeChatMessage[] = [] let pending = '' + let pendingBytes = 0 + let decodedBytes = 0 let consumedBytes = 0 + let oldestMessageIndex = 0 + let pendingHighSurrogate = '' + const utf8Decoder = new StringDecoder('utf8') + + assertPositiveLimit(limits.maxDecodedBytes, 'decoded byte') + assertPositiveLimit(limits.maxLineBytes, 'line byte') + assertPositiveLimit(limits.maxMessages, 'message') for await (const chunk of stream) { - pending += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8') - let newlineIndex = pending.indexOf('\n') - while (newlineIndex !== -1) { - const segment = pending.slice(0, newlineIndex + 1) - decodeLine(segment.slice(0, -1), consumedBytes) - consumedBytes += Buffer.byteLength(segment, 'utf8') - pending = pending.slice(newlineIndex + 1) - newlineIndex = pending.indexOf('\n') + if (typeof chunk === 'string') { + let text = pendingHighSurrogate + chunk + pendingHighSurrogate = '' + const lastCodeUnit = text.charCodeAt(text.length - 1) + if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) { + pendingHighSurrogate = text.slice(-1) + text = text.slice(0, -1) + } + consumeTranscriptBytes(Buffer.from(text, 'utf8')) + continue + } + if (pendingHighSurrogate) { + consumeTranscriptBytes(Buffer.from(pendingHighSurrogate, 'utf8')) + pendingHighSurrogate = '' } + consumeTranscriptBytes(Buffer.from(chunk)) + } + if (pendingHighSurrogate) { + consumeTranscriptBytes(Buffer.from(pendingHighSurrogate, 'utf8')) } + pending += utf8Decoder.end() if (includeTrailingLine && pending.length > 0) { - decodeLine(pending, consumedBytes) - consumedBytes += Buffer.byteLength(pending, 'utf8') + enforceLineLimit(pendingBytes) + decodeLine(pending, consumedBytes, pendingBytes) + consumedBytes += pendingBytes } - return { messages, consumedBytes } + const orderedMessages = + oldestMessageIndex === 0 + ? messages + : messages.slice(oldestMessageIndex).concat(messages.slice(0, oldestMessageIndex)) + return { messages: orderedMessages, consumedBytes } + + function consumeTranscriptBytes(bytes: Buffer): void { + decodedBytes += bytes.length + if (limits.maxDecodedBytes !== undefined && decodedBytes > limits.maxDecodedBytes) { + throw new TranscriptDecodeLimitError('decoded byte', limits.maxDecodedBytes) + } + let chunkOffset = 0 + let newlineIndex = bytes.indexOf(0x0a) + while (newlineIndex !== -1) { + const segment = bytes.subarray(chunkOffset, newlineIndex + 1) + pending += utf8Decoder.write(segment) + pendingBytes += segment.length + decodeLine(pending.slice(0, -1), consumedBytes, pendingBytes - 1) + consumedBytes += pendingBytes + pending = '' + pendingBytes = 0 + chunkOffset = newlineIndex + 1 + newlineIndex = bytes.indexOf(0x0a, chunkOffset) + } + const trailing = bytes.subarray(chunkOffset) + pending += utf8Decoder.write(trailing) + pendingBytes += trailing.length + enforceLineLimit(pendingBytes) + } - function decodeLine(rawLine: string, relativeOffset: number): void { - const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine + function decodeLine(rawLine: string, relativeOffset: number, rawLineBytes: number): void { + const hasCarriageReturn = rawLine.endsWith('\r') + const line = hasCarriageReturn ? rawLine.slice(0, -1) : rawLine if (!line) { return } + enforceLineLimit(rawLineBytes - (hasCarriageReturn ? 1 : 0)) const message = decode(line, transcriptFallbackId(filePath, start + relativeOffset)) if (message) { - messages.push(message) + if (limits.maxMessages === undefined || messages.length < limits.maxMessages) { + messages.push(message) + } else { + messages[oldestMessageIndex] = message + oldestMessageIndex = (oldestMessageIndex + 1) % limits.maxMessages + } + } + } + + function enforceLineLimit(bytes: number): void { + if (limits.maxLineBytes !== undefined && bytes > limits.maxLineBytes) { + throw new TranscriptDecodeLimitError('line byte', limits.maxLineBytes) } } } + +function assertPositiveLimit(value: number | undefined, name: string): void { + if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) { + throw new Error(`Transcript ${name} limit must be a positive safe integer`) + } +} diff --git a/src/main/native-chat/transcript-tail-window.ts b/src/main/native-chat/transcript-tail-window.ts new file mode 100644 index 00000000000..5bb0480179b --- /dev/null +++ b/src/main/native-chat/transcript-tail-window.ts @@ -0,0 +1,76 @@ +import { open } from 'node:fs/promises' +import type { Readable } from 'node:stream' + +const NEWLINE = 0x0a +const SEARCH_CHUNK_BYTES = 64 * 1024 + +/** Finds the first complete JSONL record inside the newest byte window. */ +export async function newlineAlignedTailStart( + filePath: string, + end: number, + maxBytes: number +): Promise { + const lowerBound = Math.max(0, end - maxBytes) + if (lowerBound === 0) { + return 0 + } + const handle = await open(filePath, 'r') + try { + const buffer = Buffer.allocUnsafe(Math.min(SEARCH_CHUNK_BYTES, end - lowerBound)) + let position = lowerBound + while (position < end) { + const length = Math.min(buffer.length, end - position) + const { bytesRead } = await handle.read(buffer, 0, length, position) + if (bytesRead === 0) { + return end + } + const newline = buffer.subarray(0, bytesRead).indexOf(NEWLINE) + if (newline !== -1) { + return position + newline + 1 + } + position += bytesRead + } + return end + } finally { + await handle.close() + } +} + +/** Retains a bounded decoded suffix while a compressed rollout is streamed. */ +export async function readStreamTail( + stream: Readable, + maxBytes: number +): Promise<{ bytes: Buffer; decodedStart: number }> { + const tail = Buffer.allocUnsafe(maxBytes) + let retained = 0 + let decodedBytes = 0 + + for await (const rawChunk of stream) { + const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk) + decodedBytes += chunk.length + if (chunk.length >= maxBytes) { + chunk.copy(tail, 0, chunk.length - maxBytes) + retained = maxBytes + continue + } + const overflow = Math.max(0, retained + chunk.length - maxBytes) + if (overflow > 0) { + tail.copyWithin(0, overflow, retained) + retained -= overflow + } + chunk.copy(tail, retained) + retained += chunk.length + } + + let bytes = tail.subarray(0, retained) + let decodedStart = decodedBytes - retained + if (decodedStart > 0) { + const newline = bytes.indexOf(NEWLINE) + if (newline === -1) { + return { bytes: Buffer.alloc(0), decodedStart: decodedBytes } + } + bytes = bytes.subarray(newline + 1) + decodedStart += newline + 1 + } + return { bytes, decodedStart } +} diff --git a/src/main/native-chat/transcript-watch.test.ts b/src/main/native-chat/transcript-watch.test.ts index 4ff621d05bc..c4d4cb070f3 100644 --- a/src/main/native-chat/transcript-watch.test.ts +++ b/src/main/native-chat/transcript-watch.test.ts @@ -16,7 +16,7 @@ afterEach(async () => { tempRoots = [] }) -async function tempFile(initial: string): Promise { +async function tempFile(initial: string | Uint8Array): Promise { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-')) tempRoots.push(root) const filePath = join(root, 'rollout.jsonl') @@ -130,6 +130,59 @@ describe('subscribeNativeChatTranscript', () => { expect(seen.some((m) => m.id === 'a-2')).toBe(true) }) + it('skips oversized initial history once and continues tailing later appends', async () => { + const filePath = await tempFile(claudeLine('u-large', 'user', 'x'.repeat(1024))) + const seen: NativeChatMessage[] = [] + let appendedDuringRead = false + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5, + limits: { maxDecodedBytes: 256, maxLineBytes: 2048, maxMessages: 40 }, + afterReadSnapshotForTests: async () => { + if (appendedDuringRead) { + return + } + appendedDuringRead = true + await appendFile(filePath, claudeLine('a-after-limit', 'assistant', 'still live')) + // Let the watcher turn this append into pendingReadRequested while the + // oversized seed read still owns the drain. + await new Promise((resolve) => setTimeout(resolve, 20)) + } + }) + + await waitFor(() => seen.some((message) => message.id === 'a-after-limit'), 500) + + sub.unsubscribe() + expect(seen.some((message) => message.id === 'u-large')).toBe(false) + }) + + it('seeds a newline-aligned tail when bounded history exceeds the remote window', async () => { + const initial = Array.from({ length: 8 }, (_unused, index) => + claudeLine(`u-${index}`, 'user', `message-${index}-${'x'.repeat(80)}`) + ).join('') + const filePath = await tempFile(initial) + const seen: NativeChatMessage[] = [] + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5, + limits: { maxDecodedBytes: 512, maxLineBytes: 256, maxMessages: 40 } + }) + + await waitFor(() => seen.some((message) => message.id === 'u-7')) + sub.unsubscribe() + + expect(seen.some((message) => message.id === 'u-0')).toBe(false) + expect(seen.at(-1)?.id).toBe('u-7') + }) + it('releases the watcher on unsubscribe (no leak)', async () => { const filePath = await tempFile(claudeLine('u-1', 'user', 'hi')) const before = getActiveNativeChatWatcherCount() @@ -204,6 +257,36 @@ describe('subscribeNativeChatTranscript', () => { expect(seen.filter((m) => m.id === 'a-partial')).toHaveLength(1) }) + it('keeps malformed UTF-8 from advancing into the next partial record', async () => { + const seed = claudeLine('u-seed', 'user', 'before malformed offset check') + const filePath = await tempFile( + Buffer.concat([Buffer.from([0xff, 0x0a]), Buffer.from(seed, 'utf8')]) + ) + const seen: NativeChatMessage[] = [] + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5 + }) + + await waitFor(() => seen.some((message) => message.id === 'u-seed')) + + const line = claudeLine('a-after-malformed', 'assistant', 'offset stayed aligned') + const splitAt = Math.floor(line.length / 2) + await appendFile(filePath, line.slice(0, splitAt)) + await new Promise((resolve) => setTimeout(resolve, 40)) + expect(seen.some((message) => message.id === 'a-after-malformed')).toBe(false) + + await appendFile(filePath, line.slice(splitAt)) + await waitFor(() => seen.some((message) => message.id === 'a-after-malformed')) + + sub.unsubscribe() + expect(seen.filter((message) => message.id === 'a-after-malformed')).toHaveLength(1) + }) + it('survives file replacement / rotation (offset reset on shrink)', async () => { const filePath = await tempFile( claudeLine('u-1', 'user', 'old') + claudeLine('a-1', 'assistant', 'old-reply') diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index 1f55c33124a..b10716db4d7 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -14,10 +14,12 @@ import type { AgentType, NativeChatMessage } from '../../shared/native-chat-type import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' import { decodeClaudeTranscriptLine, - decodeCodexTranscriptLine, + createCodexTranscriptLineDecoder, decodeGrokTranscriptLine } from './transcript-line-decoders' -import { decodeTranscriptStream } from './transcript-stream-lines' +import { decodeTranscriptStream, TranscriptDecodeLimitError } from './transcript-stream-lines' +import type { TranscriptDecodeLimits } from './transcript-stream-lines' +import { newlineAlignedTailStart } from './transcript-tail-window' export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { agent: AgentType @@ -33,6 +35,10 @@ export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { * don't wait out the production backoff. Production ignores this and backs * off from 500ms to a 5s cap. */ resolvePollIntervalMs?: number + /** Optional streaming limits for paired-client subscriptions. */ + limits?: TranscriptDecodeLimits + /** Test-only synchronization point after a read's EOF snapshot is fixed. */ + afterReadSnapshotForTests?: (end: number) => Promise } export type NativeChatTranscriptSubscription = { @@ -42,8 +48,8 @@ export type NativeChatTranscriptSubscription = { // Why: a single watch event can fire several times for one append; we read from // the last byte offset so re-entrant reads never re-emit prior messages. Each -// decoder is stateless per-line, so tailing reuses the same record→message -// mapping the full reader uses. +// subscription owns one decoder instance, so provider-specific stream state +// (such as Codex pagination mode) remains consistent across drain passes. const DEFAULT_DEBOUNCE_MS = 40 // Why: process-wide count of live FSWatchers opened by this module. The U4 leak @@ -63,7 +69,7 @@ function lineDecoderForAgent( return decodeClaudeTranscriptLine } if (agent === 'codex') { - return decodeCodexTranscriptLine + return createCodexTranscriptLineDecoder() } if (agent === 'grok') { return decodeGrokTranscriptLine @@ -89,9 +95,10 @@ async function fileSize(filePath: string): Promise { async function readAppendedMessages( filePath: string, start: number, - decode: (line: string, fallbackId: string) => NativeChatMessage | null + end: number, + decode: (line: string, fallbackId: string) => NativeChatMessage | null, + limits?: TranscriptDecodeLimits ): Promise<{ messages: NativeChatMessage[]; consumedTo: number }> { - const end = await fileSize(filePath) if (end <= start) { // File shrank (rotation/replacement) or unchanged — caller resets offset. return { messages: [], consumedTo: end } @@ -99,8 +106,9 @@ async function readAppendedMessages( const handle = await open(filePath, 'r') try { + // Why: decoding here would turn malformed bytes into larger replacement + // sequences and advance the persisted file offset past unread records. const stream = handle.createReadStream({ - encoding: 'utf-8', start, end: end - 1, autoClose: false @@ -110,7 +118,8 @@ async function readAppendedMessages( filePath, start, decode, - false + false, + limits ) return { messages, consumedTo: start + consumedBytes } } finally { @@ -128,14 +137,16 @@ function installTranscriptWatcher( filePath: string, decode: (line: string, fallbackId: string) => NativeChatMessage | null, onAppend: (messages: NativeChatMessage[]) => void, - debounceMs?: number + debounceMs?: number, + limits?: TranscriptDecodeLimits, + afterReadSnapshotForTests?: (end: number) => Promise ): NativeChatTranscriptSubscription | null { - // Why: seed the offset at 0 so the FIRST drain re-reads the whole file. This - // closes the read/subscribe race — a turn appended between the caller's - // readSession EOF and the watcher install is still emitted. Re-emitted lines - // collapse by deterministic id in the assembler (no dup, no drop). Subsequent - // drains use the incremental offset so the full re-read happens only once. + // Why: the FIRST drain re-reads the full local transcript or the bounded + // newline-aligned remote tail. This closes the read/subscribe race — a turn + // appended between readSession EOF and watcher install is still emitted. + // Later drains use the incremental offset, so the seed read happens once. let offset = 0 + let initialDrain = true let closed = false let reading = false let pendingReadRequested = false @@ -155,18 +166,39 @@ function installTranscriptWatcher( try { do { pendingReadRequested = false + let attemptedEnd = offset try { const currentSize = await fileSize(filePath) if (currentSize < offset) { // Rotation/replacement/truncation: re-read from the top. offset = 0 + initialDrain = true } - const { messages, consumedTo } = await readAppendedMessages(filePath, offset, decode) + if (initialDrain && limits?.maxDecodedBytes !== undefined) { + offset = await newlineAlignedTailStart(filePath, currentSize, limits.maxDecodedBytes) + } + initialDrain = false + attemptedEnd = currentSize + await afterReadSnapshotForTests?.(currentSize) + const { messages, consumedTo } = await readAppendedMessages( + filePath, + offset, + currentSize, + decode, + limits + ) offset = consumedTo if (!closed && messages.length > 0) { onAppend(messages) } - } catch { + } catch (error) { + if (error instanceof TranscriptDecodeLimitError) { + // Why: retrying an oversized seed from byte zero on every append + // creates a permanent decode loop. Skip that history once, then + // continue tailing records appended after this read snapshot. + offset = attemptedEnd + continue + } // Why: a transient read failure (EACCES/EIO/ENOENT during rotation) // must not leave the subscription permanently deaf. Stop this drain; // the finally resets `reading` so a later fs event re-arms the read. @@ -232,7 +264,14 @@ async function attemptInstall( if (!filePath) { return null } - return installTranscriptWatcher(filePath, decode, args.onAppend, args.debounceMs) + return installTranscriptWatcher( + filePath, + decode, + args.onAppend, + args.debounceMs, + args.limits, + args.afterReadSnapshotForTests + ) } // Why: Claude Code (and other agents) can take from ~3s to minutes to flush a diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts index 720ce127d2c..a59d7ebb30b 100644 --- a/src/main/runtime/rpc/methods/native-chat.test.ts +++ b/src/main/runtime/rpc/methods/native-chat.test.ts @@ -5,9 +5,19 @@ import type { RpcContext } from '../core' // Stub the shared cache so the handler returns a deterministic transcript with // one oversized tool-result block; the test then asserts clip behavior per client. const OVERSIZED = 'x'.repeat(5000) -const cachedResult = vi.hoisted(() => ({ value: { messages: [] as NativeChatMessage[] } })) +const { cachedResult, cachedReadSpy, subscribeSpy } = vi.hoisted(() => ({ + cachedResult: { value: { messages: [] as NativeChatMessage[] } }, + cachedReadSpy: vi.fn(), + subscribeSpy: vi.fn((_args: unknown) => Promise.resolve({ unsubscribe: vi.fn() })) +})) vi.mock('../../../native-chat/transcript-read-cache', () => ({ - readNativeChatTranscriptCached: () => Promise.resolve(cachedResult.value) + readNativeChatTranscriptCached: (...args: unknown[]) => { + cachedReadSpy(...args) + return Promise.resolve(cachedResult.value) + } +})) +vi.mock('../../../native-chat/transcript-watch', () => ({ + subscribeNativeChatTranscript: (args: unknown) => subscribeSpy(args) })) import { NATIVE_CHAT_METHODS } from './native-chat' @@ -30,6 +40,22 @@ function readSessionHandler(): (params: unknown, ctx: RpcContext) => Promise Promise } +function subscribeHandler(): ( + params: unknown, + ctx: RpcContext, + emit: (event: unknown) => void +) => Promise { + const method = NATIVE_CHAT_METHODS.find((m) => m.name === 'nativeChat.subscribe') + if (!method) { + throw new Error('subscribe method not registered') + } + return method.handler as ( + params: unknown, + ctx: RpcContext, + emit: (event: unknown) => void + ) => Promise +} + function ctxWith(clientKind: RpcContext['clientKind']): RpcContext { return { runtime: {} as RpcContext['runtime'], clientKind } } @@ -41,6 +67,55 @@ function firstOutput(result: unknown): string { } describe('nativeChat.readSession clientKind truncation gating', () => { + it('requests a root-contained, bounded parse before applying the client window', async () => { + cachedResult.value = { messages: [] } + cachedReadSpy.mockClear() + + await readSessionHandler()( + { + agent: 'codex', + sessionId: 's', + limit: 40, + transcriptPath: '/outside/rollout.jsonl.zst' + }, + ctxWith('runtime') + ) + + expect(cachedReadSpy).toHaveBeenCalledWith( + 'codex', + 's', + '/outside/rollout.jsonl.zst', + expect.objectContaining({ + requireTranscriptPathInAgentRoots: true, + limits: expect.objectContaining({ maxMessages: 2000 }) + }) + ) + }) + + it('applies the same path and stream limits to paired-client subscriptions', async () => { + subscribeSpy.mockClear() + const runtime = { + registerSubscriptionCleanup: vi.fn() + } as unknown as RpcContext['runtime'] + + await subscribeHandler()( + { + agent: 'codex', + sessionId: 's', + transcriptPath: '/outside/rollout.jsonl.zst' + }, + { runtime, connectionId: 'connection-1', clientKind: 'runtime' }, + vi.fn() + ) + + expect(subscribeSpy).toHaveBeenCalledWith( + expect.objectContaining({ + requireTranscriptPathInAgentRoots: true, + limits: expect.objectContaining({ maxMessages: 2000 }) + }) + ) + }) + it('clips oversized tool output for mobile clients', async () => { cachedResult.value = { messages: [makeMessage(OVERSIZED)] } const result = await readSessionHandler()( diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 05558c8b76c..5b76a2747c4 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -55,6 +55,10 @@ const NativeChatUnsubscribe = z.object({ // older history as the user scrolls back. const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40 const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000 +// Why: paired clients must never make the host retain or decode an unbounded +// transcript. The desktop IPC path remains uncapped for full-history access. +const REMOTE_TRANSCRIPT_MAX_DECODED_BYTES = 64 * 1024 * 1024 +const REMOTE_TRANSCRIPT_MAX_LINE_BYTES = 8 * 1024 * 1024 // Why: a single tool result (a big file read, a long diff) can be hundreds of KB. // The mobile view only previews block bodies, so truncate them on the wire to // keep the payload small; the marker tells the user content was clipped. @@ -116,7 +120,17 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ const result = await readNativeChatTranscriptCached( params.agent, params.sessionId, - params.transcriptPath + params.transcriptPath, + { + requireTranscriptPathInAgentRoots: true, + // Parse once into the largest supported tail so every smaller page + // reuses the same bounded cache entry. + limits: { + maxDecodedBytes: REMOTE_TRANSCRIPT_MAX_DECODED_BYTES, + maxLineBytes: REMOTE_TRANSCRIPT_MAX_LINE_BYTES, + maxMessages: MOBILE_NATIVE_CHAT_MAX_WINDOW + } + } ) // Window to the conversation tail (all clients); clip blocks for mobile only. return 'messages' in result @@ -130,9 +144,9 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ handler: async (params, { runtime, connectionId, clientKind }, emit) => { let closed = false let unsubscribe = (): void => {} - // Why: the subscriber seeds its read offset at 0, so the first drain emits - // the whole transcript and later drains emit only appended turns. The first - // batch is windowed to the tail (a full transcript would freeze mobile); + // Why: the subscriber seeds from a bounded, newline-aligned transcript + // tail and later drains emit only appended turns. The first batch is + // windowed again by message count for a fast paired-client snapshot; // later incremental batches are smaller than the window so they pass through. // Clients merge by message id, so the initial windowed batch doubles as the // snapshot. Keyed by the client-supplied subscriptionId when present so @@ -157,6 +171,12 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ agent: params.agent, sessionId: params.sessionId, transcriptPath: params.transcriptPath, + requireTranscriptPathInAgentRoots: true, + limits: { + maxDecodedBytes: REMOTE_TRANSCRIPT_MAX_DECODED_BYTES, + maxLineBytes: REMOTE_TRANSCRIPT_MAX_LINE_BYTES, + maxMessages: MOBILE_NATIVE_CHAT_MAX_WINDOW + }, onAppend: (messages) => { if (closed) { return diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts index 499d2b1610c..b9e377d4982 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts @@ -144,6 +144,27 @@ describe('filterSkillSuggestions', () => { ]) expect(filterSkillSuggestions(skills, 'h')).toEqual([]) }) + + it('dedupes same-name skills and prefers the repository source', () => { + const skills = [ + skill({ + id: 'home-review', + name: 'review', + sourceKind: 'home', + skillFilePath: '/Users/test/.agents/skills/review/SKILL.md' + }), + skill({ + id: 'repo-review', + name: 'Review', + sourceKind: 'repo', + skillFilePath: '/repo/.agents/skills/review/SKILL.md' + }) + ] + + const filtered = filterSkillSuggestions(skills, 'rev') + expect(filtered).toHaveLength(1) + expect(filtered[0]?.skillFilePath).toBe('/repo/.agents/skills/review/SKILL.md') + }) }) describe('history recall', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.ts b/src/renderer/src/components/native-chat/native-chat-composer-state.ts index 9871bba1b95..8c8d65d681c 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-state.ts @@ -75,16 +75,39 @@ export function filterSkillSuggestions( ): DiscoveredSkill[] { const normalized = query.toLowerCase() const installed = skills.filter((skill) => skill.installed) - if (normalized === '') { - return installed.slice(0, 12) + const matched = + normalized === '' + ? installed + : installed.filter((skill) => { + const name = skill.name.toLowerCase() + const dirName = skill.directoryPath.split(/[\\/]/).findLast(Boolean)?.toLowerCase() + return name.startsWith(normalized) || dirName?.startsWith(normalized) + }) + return dedupeSkillSuggestions(matched).slice(0, 12) +} + +const SKILL_SOURCE_PRIORITY: Record = { + repo: 0, + home: 1, + plugin: 2, + bundled: 3 +} + +function dedupeSkillSuggestions(skills: readonly DiscoveredSkill[]): DiscoveredSkill[] { + // Why: PTY fallback inserts only `$name`; prefer the repository definition + // when multiple roots expose an otherwise ambiguous name. + const byName = new Map() + for (const skill of skills) { + const key = skill.name.toLowerCase() + const existing = byName.get(key) + if ( + !existing || + SKILL_SOURCE_PRIORITY[skill.sourceKind] < SKILL_SOURCE_PRIORITY[existing.sourceKind] + ) { + byName.set(key, skill) + } } - return installed - .filter((skill) => { - const name = skill.name.toLowerCase() - const dirName = skill.directoryPath.split(/[\\/]/).findLast(Boolean)?.toLowerCase() - return name.startsWith(normalized) || dirName?.startsWith(normalized) - }) - .slice(0, 12) + return [...byName.values()] } export type HistoryState = { 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 ab2e04d1783..c17fecf72b7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -897,6 +897,7 @@ describe('connectPanePty', () => { afterEach(() => { vi.useRealTimers() + vi.restoreAllMocks() if (originalRequestAnimationFrame) { globalThis.requestAnimationFrame = originalRequestAnimationFrame } else { @@ -16543,6 +16544,9 @@ describe('connectPanePty', () => { const transport = createMockTransport('pty-replaced-codex') transportFactoryQueue.push(transport) vi.useFakeTimers() + // Why: this assertion places the hook update between two active-cadence + // inspections, so pin the coordinator's poll jitter like its unit tests do. + vi.spyOn(Math, 'random').mockReturnValue(0.5) const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) getForegroundProcess.mockResolvedValue('codex') const paneKey = makePaneKey('tab-1', LEAF_1) diff --git a/src/shared/native-chat-slash-commands.test.ts b/src/shared/native-chat-slash-commands.test.ts index bd9f7984551..740275bdcef 100644 --- a/src/shared/native-chat-slash-commands.test.ts +++ b/src/shared/native-chat-slash-commands.test.ts @@ -15,6 +15,26 @@ describe('getAgentSlashCommands', () => { expect(names).toContain('diff') }) + it('includes current Codex TUI commands and aliases in presentation order', () => { + const names = getAgentSlashCommands('codex').map((command) => command.name) + for (const expected of [ + 'setup-default-sandbox', + 'sandbox-add-read-dir', + 'btw', + 'debug-config', + 'apps', + 'quit', + 'pet', + 'clean' + ]) { + expect(names).toContain(expected) + } + expect(names.indexOf('vim')).toBeLessThan(names.indexOf('setup-default-sandbox')) + expect(names.indexOf('side')).toBeLessThan(names.indexOf('btw')) + expect(names.indexOf('mcp')).toBeLessThan(names.indexOf('apps')) + expect(names.indexOf('quit')).toBeLessThan(names.indexOf('exit')) + }) + it('returns Claude commands for claude (no Codex-only /model)', () => { const names = getAgentSlashCommands('claude').map((c) => c.name) expect(names).toContain('clear') diff --git a/src/shared/native-chat-slash-commands.ts b/src/shared/native-chat-slash-commands.ts index 949567134b0..006917e68cf 100644 --- a/src/shared/native-chat-slash-commands.ts +++ b/src/shared/native-chat-slash-commands.ts @@ -30,12 +30,19 @@ const CLAUDE_COMMANDS: readonly SlashCommandSuggestion[] = [ { name: 'help', description: 'Show available commands' } ] +// Why: order mirrors Codex's TUI presentation order rather than alphabetical +// order, so Native Chat suggestions remain familiar. const CODEX_COMMANDS: readonly SlashCommandSuggestion[] = [ { name: 'model', description: 'Choose the model and reasoning effort' }, { name: 'ide', description: 'Include IDE context' }, { name: 'permissions', description: 'Choose what Codex is allowed to do' }, { name: 'keymap', description: 'Remap TUI shortcuts' }, { name: 'vim', description: 'Toggle Vim mode' }, + { name: 'setup-default-sandbox', description: 'Set up the elevated agent sandbox' }, + { + name: 'sandbox-add-read-dir', + description: 'Let the sandbox read another directory (Windows)' + }, { name: 'experimental', description: 'Toggle experimental features' }, { name: 'approve', description: 'Approve one auto-review retry' }, { name: 'memories', description: 'Configure memory use' }, @@ -56,23 +63,29 @@ const CODEX_COMMANDS: readonly SlashCommandSuggestion[] = [ { name: 'goal', description: 'Set or view the goal' }, { name: 'agent', description: 'Switch the active agent thread' }, { name: 'side', description: 'Start a side conversation' }, + { name: 'btw', description: 'Start a side conversation (alias of /side)' }, { name: 'copy', description: 'Copy the last response as markdown' }, { name: 'raw', description: 'Toggle raw scrollback mode' }, { name: 'diff', description: 'Show the working diff' }, { name: 'mention', description: 'Mention a file' }, { name: 'status', description: 'Show session configuration and usage' }, { name: 'usage', description: 'View account usage' }, + { name: 'debug-config', description: 'Show config layers and requirement sources' }, { name: 'title', description: 'Configure the terminal title' }, { name: 'statusline', description: 'Configure the status line' }, { name: 'theme', description: 'Choose a syntax highlighting theme' }, { name: 'pets', description: 'Choose or hide the terminal pet' }, + { name: 'pet', description: 'Alias for /pets' }, { name: 'mcp', description: 'List configured MCP tools' }, + { name: 'apps', description: 'Manage apps' }, { name: 'plugins', description: 'Browse plugins' }, { name: 'logout', description: 'Log out of Codex' }, + { name: 'quit', description: 'Exit Codex' }, { name: 'exit', description: 'Exit Codex' }, { name: 'feedback', description: 'Send logs to maintainers' }, { name: 'ps', description: 'List background terminals' }, { name: 'stop', description: 'Stop all background terminals' }, + { name: 'clean', description: 'Alias for /stop' }, { name: 'clear', description: 'Clear the terminal and start a new chat' }, { name: 'personality', description: 'Choose a communication style' }, { name: 'subagents', description: 'Switch the active agent thread' } diff --git a/src/shared/types.ts b/src/shared/types.ts index 6c1ebaf8cc8..aee5cac1f5a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2843,10 +2843,9 @@ export type GlobalSettings = { geminiCliOAuthEnabled: boolean /** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */ agentCmdOverrides: Partial> - /** Why: Orca bridges Codex session history from the user's real Codex home into - * its managed home so /resume finds it, but defaults to ~/.codex. Users who run - * Codex with a custom CODEX_HOME can point history discovery at that folder here. - * History-only: this does not change which account/config/hooks Orca uses. */ + /** Why: historical Codex sessions remain in their original authoritative home. + * Users with a custom CODEX_HOME can point discovery and resume at that home + * without mirroring writable rollout files into Orca's managed home. */ codexSessionSourceHome?: { /** Absolute host path; empty/undefined falls back to ~/.codex. */ host?: string