From ba1a37f6c9e68515f12f458a75d2c3364fabdea8 Mon Sep 17 00:00:00 2001 From: "Bing.Z" Date: Fri, 10 Jul 2026 00:07:24 +0800 Subject: [PATCH 1/7] fix(codex): improve CLI compatibility across hooks, sessions, and launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raise Orca's Codex integration to match current openai/codex behavior after a full source-level comparison (hooks, startup, CODEX_HOME, rollouts, TUI UX, usage). Status & hooks: - SessionStart no longer flashes working; SubagentStart/Stop installed and mapped - Remote managed hooks prepend like local; PostToolUse tool_response + better previews - Interrupt markers clear sticky working without inventing StopFailure Startup & drafts: - Composer-ready requires Ask Codex placeholder (not bare › during hooks review) - Drop dead Codex --prefill myth; Windows argv budget falls back to paste-submit - Optional -i/--image on startup plans; safer post-paste submit delay Auth, sessions, native chat: - Force cli_auth_credentials_store=file on managed/runtime homes - Decode item_completed TurnItems; read .jsonl.zst; hardlink→copy bridge UX / usage / config: - Native Action Required title mapping; slash catalog refresh; skill path plumbing - Multi-bucket rate limits; config base URL for reset credits; path rewrite keys - Profile-v2 overlay mirror; WSL host-home strip messaging --- config/tsconfig.cli.json | 2 + .../session-scanner-codex-item-completed.ts | 53 +++++ .../session-scanner-codex-parser.test.ts | 112 ++++++++- .../ai-vault/session-scanner-codex-parser.ts | 219 +++-------------- .../ai-vault/session-scanner-codex-paths.ts | 34 ++- .../session-scanner-codex-record-consume.ts | 178 ++++++++++++++ .../session-scanner-codex-rollout-read.ts | 47 ++++ .../ai-vault/session-scanner-parse-cache.ts | 6 +- .../session-scanner-source-discovery.ts | 17 +- .../runtime-home-service.test.ts | 31 +-- src/main/codex-accounts/service.test.ts | 75 +++++- src/main/codex-accounts/service.ts | 8 +- .../codex-config-hooks-feature-normalize.ts | 65 ++++++ src/main/codex/codex-config-mirror.test.ts | 100 ++++++++ src/main/codex/codex-config-mirror.ts | 100 ++++---- .../codex-config-path-reference-rewrite.ts | 6 +- src/main/codex/codex-hook-identity.ts | 4 + .../codex-profile-v2-config-overlay-mirror.ts | 88 +++++++ src/main/codex/codex-session-bridge.test.ts | 53 +++-- src/main/codex/codex-session-bridge.ts | 149 ++++-------- src/main/codex/codex-session-copy-markers.ts | 86 +++++++ src/main/codex/codex-session-file-listing.ts | 8 +- src/main/codex/config-toml-trust.ts | 6 + src/main/codex/hook-service.test.ts | 114 +++++++++ src/main/codex/hook-service.ts | 18 +- .../codex/wsl-codex-session-bridge.test.ts | 8 +- src/main/codex/wsl-codex-session-bridge.ts | 7 +- src/main/daemon/pty-subprocess.test.ts | 5 +- src/main/ipc/pty.test.ts | 7 +- .../native-chat/session-file-resolver.test.ts | 30 +++ src/main/native-chat/session-file-resolver.ts | 29 ++- .../transcript-codex-turn-items.ts | 151 ++++++++++++ .../native-chat/transcript-line-decoders.ts | 10 + .../native-chat/transcript-reader.test.ts | 142 +++++++++++ src/main/native-chat/transcript-reader.ts | 28 ++- src/main/pty/codex-home-wsl-env.test.ts | 18 +- src/main/pty/codex-home-wsl-env.ts | 32 +++ src/main/rate-limits/codex-auth-presence.ts | 7 +- .../codex-fetcher-auth-errors.test.ts | 11 +- .../codex-fetcher-pty-settle.test.ts | 11 +- src/main/rate-limits/codex-fetcher.test.ts | 221 +++++++++++++++++- src/main/rate-limits/codex-fetcher.ts | 167 +++++++++++-- src/main/runtime/orca-runtime.test.ts | 12 +- src/relay/pty-handler.test.ts | 12 +- .../native-chat/NativeChatComposer.tsx | 4 +- .../native-chat-composer-state.test.ts | 53 +++++ .../native-chat/native-chat-composer-state.ts | 69 ++++-- .../use-native-chat-composer-keydown.ts | 4 +- .../codex-session-source-home-control.tsx | 2 +- ...-approval-notification-suppression.test.ts | 22 ++ ...-auto-approval-notification-suppression.ts | 8 +- .../terminal-pane/pty-connection.test.ts | 16 +- .../src/lib/agent-paste-draft.test.ts | 17 +- src/renderer/src/lib/agent-paste-draft.ts | 6 +- .../src/lib/agent-startup-delayed-delivery.ts | 19 +- src/renderer/src/lib/agent-status.test.ts | 15 ++ .../launch-agent-background-session.test.ts | 15 +- src/renderer/src/lib/new-workspace.ts | 45 ++-- .../src/lib/tui-agent-startup.test.ts | 37 +++ src/shared/agent-detection.test.ts | 16 ++ src/shared/agent-detection.ts | 2 + src/shared/agent-hook-listener.test.ts | 203 ++++++++++++++++ src/shared/agent-hook-listener.ts | 104 +++++++-- src/shared/codex-startup-delivery.test.ts | 53 +++-- src/shared/codex-startup-delivery.ts | 84 +------ src/shared/draft-paste-ready-scanner.test.ts | 34 ++- src/shared/draft-paste-ready-scanner.ts | 70 ++++-- src/shared/native-chat-slash-commands.test.ts | 25 ++ src/shared/native-chat-slash-commands.ts | 15 ++ src/shared/rate-limit-types.ts | 2 +- src/shared/terminal-title-status.ts | 18 ++ src/shared/tui-agent-config.ts | 6 +- src/shared/tui-agent-draft-launch.ts | 117 ++++++++++ src/shared/tui-agent-permissions.test.ts | 20 ++ src/shared/tui-agent-permissions.ts | 30 ++- src/shared/tui-agent-startup-codex-launch.ts | 55 +++++ src/shared/tui-agent-startup.test.ts | 35 +++ src/shared/tui-agent-startup.ts | 149 ++++-------- 78 files changed, 3100 insertions(+), 757 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-codex-item-completed.ts create mode 100644 src/main/ai-vault/session-scanner-codex-record-consume.ts create mode 100644 src/main/ai-vault/session-scanner-codex-rollout-read.ts create mode 100644 src/main/codex/codex-config-hooks-feature-normalize.ts create mode 100644 src/main/codex/codex-profile-v2-config-overlay-mirror.ts create mode 100644 src/main/codex/codex-session-copy-markers.ts create mode 100644 src/main/native-chat/transcript-codex-turn-items.ts create mode 100644 src/shared/tui-agent-draft-launch.ts create mode 100644 src/shared/tui-agent-startup-codex-launch.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 8d92fa208ca..a04e47c2ee0 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -10,10 +10,12 @@ "../src/main/antigravity/hook-service.ts", "../src/main/claude/hook-settings.ts", "../src/main/claude/hook-service.ts", + "../src/main/codex/codex-config-hooks-feature-normalize.ts", "../src/main/codex/codex-config-mirror.ts", "../src/main/codex/codex-config-path-reference-rewrite.ts", "../src/main/codex/codex-home-paths.ts", "../src/main/codex/codex-hook-identity.ts", + "../src/main/codex/codex-profile-v2-config-overlay-mirror.ts", "../src/main/codex/codex-wsl-hook-install-plan.ts", "../src/main/codex/config-settings-promotion.ts", "../src/main/codex/config-toml-line-scan.ts", diff --git a/src/main/ai-vault/session-scanner-codex-item-completed.ts b/src/main/ai-vault/session-scanner-codex-item-completed.ts new file mode 100644 index 00000000000..88a9e1aa300 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-item-completed.ts @@ -0,0 +1,53 @@ +// AI Vault handling for Codex Paginated-history `item_completed` TurnItems. +// Kept separate from the main line fold so session-scanner-codex-parser stays +// under the max-lines budget while still counting messages for previews. + +import { addPreviewContent } from './session-scanner-accumulator' +import type { SessionAccumulator } from './session-scanner-types' +import { asRecord, extractContentText, extractString } from './session-scanner-values' + +export type CodexItemCompletedState = { + accumulator: SessionAccumulator + titleSource: 'meta' | 'user' | null +} + +export function consumeCodexItemCompleted( + state: CodexItemCompletedState, + 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) + return + } + + if (itemType === 'agent_message') { + accumulator.messageCount++ + addPreviewContent(accumulator, 'assistant', item.content, timestamp) + } +} + +/** Normalize TurnItem wire tags (snake_case or PascalCase) to snake_case. */ +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/ai-vault/session-scanner-codex-parser.test.ts b/src/main/ai-vault/session-scanner-codex-parser.test.ts index f48d58f495a..5411bccf85b 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.test.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.test.ts @@ -1,8 +1,9 @@ import { mkdir, mkdtemp, rm, stat, 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 { parseCodexSessionFile } from './session-scanner-codex-parser' +import { parseCodexSessionContent, parseCodexSessionFile } from './session-scanner-codex-parser' let tempRoots: string[] = [] @@ -16,6 +17,115 @@ function jsonLines(records: unknown[]): string { } describe('parseCodexSessionFile', () => { + it('parses Paginated item_completed TurnItems 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: 'event_msg', + payload: { + type: 'item_completed', + thread_id: 'paginated-session', + turn_id: 'turn-1', + completed_at_ms: 1, + item: { + type: 'user_message', + id: 'item-user-1', + content: [{ type: 'text', text: 'Paginated user prompt' }] + } + } + }, + { + timestamp: '2026-06-18T10:00:02.000Z', + type: 'event_msg', + payload: { + type: 'item_completed', + thread_id: 'paginated-session', + turn_id: 'turn-1', + completed_at_ms: 2, + item: { + type: 'agent_message', + 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('reads cold-compressed .jsonl.zst rollouts', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-zst-')) + tempRoots.push(root) + const sessionId = '019f0000-1111-7222-8333-444444444444' + const sessionPath = join( + root, + 'sessions', + '2026', + '06', + '18', + `rollout-2026-06-18T10-00-00-${sessionId}.jsonl.zst` + ) + await mkdir(dirname(sessionPath), { recursive: true }) + const plain = jsonLines([ + { + 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(plain, 'utf-8'))) + + const sessionStat = await stat(sessionPath) + const session = await parseCodexSessionFile( + { + path: sessionPath, + mtimeMs: sessionStat.mtimeMs, + modifiedAt: sessionStat.mtime.toISOString() + }, + 'darwin', + root + ) + + expect(session?.sessionId).toBe(sessionId) + expect(session?.title).toBe('Compressed rollout prompt') + expect(session?.messageCount).toBe(1) + }) + 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..73277310b3d 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 { readCodexSessionIndexTitle } from './session-scanner-codex-title-index' 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,22 +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) + // Why: cold `*.jsonl.zst` basenames keep a `.jsonl` middle suffix; strip + // rollout extensions before UUID extraction. + sessionId: sessionIdFromCodexRolloutPath(file.path) }), previousTotals: null, rejectedWorkerSession: false, @@ -96,6 +75,14 @@ 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 +91,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 +172,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.ts b/src/main/ai-vault/session-scanner-codex-paths.ts index 584833dc54c..57933638610 100644 --- a/src/main/ai-vault/session-scanner-codex-paths.ts +++ b/src/main/ai-vault/session-scanner-codex-paths.ts @@ -1,4 +1,7 @@ -import { dirname, resolve } from 'node:path' +import { basename, dirname, resolve } from 'node:path' + +/** Extensions accepted by Codex session discovery (plain + cold zstd). */ +export const CODEX_SESSION_ROLLOUT_EXTENSIONS = ['.jsonl', '.zst'] as const export function codexHomeForSessionsDir( sessionsDir: string, @@ -25,3 +28,32 @@ export function uniqueCodexSessionsDirs(paths: readonly string[]): string[] { } return unique } + +/** True for Codex rollout logs: `*.jsonl` or cold-compressed sibling `*.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 (`.jsonl` / `.jsonl.zst`) so UUID extraction + * still works for compressed 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..fd14537ee79 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-record-consume.ts @@ -0,0 +1,178 @@ +// Codex rollout JSONL line fold for AI Vault (session_meta / turn_context / +// response_item / event_msg / item_completed / token_count). + +import { + addPreviewContent, + updateTimeline +} from './session-scanner-accumulator' +import { consumeCodexItemCompleted } from './session-scanner-codex-item-completed' +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 + 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 +} + +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 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 + } + + // Why: Paginated history_mode persists TurnItems via item_completed instead of + // legacy user_message/agent_message event_msg variants. + if (payload.type === 'item_completed') { + consumeCodexItemCompleted(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 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-rollout-read.ts b/src/main/ai-vault/session-scanner-codex-rollout-read.ts new file mode 100644 index 00000000000..f8f85cfbdb6 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-rollout-read.ts @@ -0,0 +1,47 @@ +import { createReadStream } from 'node:fs' +import { createInterface } from 'node:readline' +import type { Readable } from 'node:stream' +import { createZstdDecompress, type ZstdDecompress } from 'node:zlib' +import { isCodexCompressedRolloutPath } from './session-scanner-codex-paths' + +/** + * Opens a UTF-8 line stream for a Codex rollout, transparently decompressing + * cold `*.jsonl.zst` files via Node's built-in zstd support (Node 22.15+ / 24). + */ +export function openCodexRolloutLineStream(filePath: string): Readable { + const raw = createReadStream(filePath) + if (!isCodexCompressedRolloutPath(filePath)) { + return raw + } + // Why: Codex cold-compresses rollouts to sibling .jsonl.zst; readers must + // accept both representations like Codex's open_rollout_line_reader. + let decoder: ZstdDecompress + try { + decoder = createZstdDecompress() + } catch (error) { + raw.destroy() + throw new Error( + `Zstd decompression is unavailable in this Node runtime; cannot read ${filePath}`, + { cause: error } + ) + } + return raw.pipe(decoder) +} + +/** Async-iterable of UTF-8 lines from a plain or zstd Codex rollout. */ +export function iterateCodexRolloutLines( + filePath: string +): AsyncIterable & { close: () => void } { + const input = openCodexRolloutLineStream(filePath) + const lines = createInterface({ + input, + crlfDelay: Infinity + }) + return { + [Symbol.asyncIterator]: () => lines[Symbol.asyncIterator](), + close: () => { + lines.close() + input.destroy() + } + } +} diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 70d0d311d6b..e101af489ff 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-offset resume is only valid on plain append-only JSONL; + // cold `.jsonl.zst` files need full decompress via parseCodexSessionFile. + 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 acf573f4964..c520d497ada 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -1,7 +1,11 @@ import { homedir } from 'node:os' import { basename, join } from 'node:path' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' -import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths' +import { + CODEX_SESSION_ROLLOUT_EXTENSIONS, + isCodexSessionRolloutPath, + uniqueCodexSessionsDirs +} from './session-scanner-codex-paths' import { SUBAGENT_DIR_NAME } from './session-scanner-subagent-transcripts' import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discovery' import { droidDiscoveries, kimiDiscoveries } from './session-scanner-droid-kimi-sources' @@ -115,7 +119,16 @@ function codexDiscoveries( issues: AiVaultScanIssue[] ): Promise[] { return rootDirs.map((rootDir) => - discoverFiles({ rootDir, limit, agent: 'codex', issues, extensions: ['.jsonl'] }) + discoverFiles({ + rootDir, + limit, + agent: 'codex', + issues, + // Why: Codex cold-compresses rollouts to sibling `*.jsonl.zst`; walk must + // accept both so vault history is not empty for archived threads. + extensions: [...CODEX_SESSION_ROLLOUT_EXTENSIONS], + filePredicate: isCodexSessionRolloutPath + }) ) } diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 9447e81ef75..586914e8e06 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1320,7 +1320,7 @@ describe('CodexRuntimeHomeService', () => { service.prepareForCodexLaunch() expect(readFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'utf-8')).toBe( - 'model = "second"\n' + 'cli_auth_credentials_store = "file"\nmodel = "second"\n' ) }) @@ -1615,6 +1615,7 @@ describe('CodexRuntimeHomeService', () => { ) const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml') const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8') + expect(runtimeConfig).toContain('cli_auth_credentials_store = "file"') expect(runtimeConfig).toContain( `model_instructions_file = '${join(systemCodexHomePath, 'instructions.md')}'` ) @@ -1638,18 +1639,22 @@ describe('CodexRuntimeHomeService', () => { // Why: real UNC sources cannot back live fs operations in tests, so pin // the UNC -> Linux-side anchor translation on the extracted seed function. - expect( - prepareWslRuntimeSeedConfig( - 'model_instructions_file = "instructions.md"\n', - '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' - ) - ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'") - expect( - prepareWslRuntimeSeedConfig( - 'model_instructions_file = "instructions.md"\n', - '\\\\wsl$\\Ubuntu\\home\\alice\\.codex' - ) - ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'") + const seededLocalhost = prepareWslRuntimeSeedConfig( + 'model_instructions_file = "instructions.md"\n', + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' + ) + expect(seededLocalhost).toContain('cli_auth_credentials_store = "file"') + expect(seededLocalhost).toContain( + "model_instructions_file = '/home/alice/.codex/instructions.md'" + ) + const seededWslDollar = prepareWslRuntimeSeedConfig( + 'model_instructions_file = "instructions.md"\n', + '\\\\wsl$\\Ubuntu\\home\\alice\\.codex' + ) + expect(seededWslDollar).toContain('cli_auth_credentials_store = "file"') + expect(seededWslDollar).toContain( + "model_instructions_file = '/home/alice/.codex/instructions.md'" + ) }) it('switches WSL accounts by rewriting one stable WSL runtime home', async () => { diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index abcafe1b438..534d24ab6cf 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -42,6 +42,11 @@ function decodeEncodedWslBashCommand(command: string): string { return encoded ? Buffer.from(encoded, 'base64').toString('utf8') : command } +// Why: managed homes always force file-backed auth for multi-account vaults. +function withForcedFileAuthStore(config: string): string { + return `cli_auth_credentials_store = "file"\n${config}` +} + function createSettings(overrides: Partial = {}): GlobalSettings { const appFontFamily = overrides.appFontFamily ?? 'Geist' const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true @@ -274,12 +279,59 @@ describe('CodexAccountService config sync', () => { const { CodexAccountService } = await import('./service') new CodexAccountService(store as never, rateLimits as never, runtimeHome as never) - expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe(canonicalConfig) + expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe( + withForcedFileAuthStore(canonicalConfig) + ) expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe( '{"account":"managed"}\n' ) }) + it('forces file auth credentials store when canonical config prefers keyring', async () => { + const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') + writeFileSync( + canonicalConfigPath, + 'approval_policy = "never"\ncli_auth_credentials_store = "keyring"\n', + 'utf-8' + ) + const managedHomePath = createManagedHome( + testState.userDataDir, + 'account-1', + 'approval_policy = "on-request"\n', + '{"account":"managed"}\n' + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'user@example.com', + managedHomePath, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const rateLimits = createRateLimits() + const runtimeHome = createRuntimeHome() + + const { CodexAccountService } = await import('./service') + new CodexAccountService(store as never, rateLimits as never, runtimeHome as never) + + const managedConfig = readFileSync(join(managedHomePath, 'config.toml'), 'utf-8') + expect(managedConfig).toContain('cli_auth_credentials_store = "file"') + expect(managedConfig).not.toContain('cli_auth_credentials_store = "keyring"') + expect(managedConfig).toContain('approval_policy = "never"') + expect(readFileSync(canonicalConfigPath, 'utf-8')).toContain( + 'cli_auth_credentials_store = "keyring"' + ) + }) + it('rewrites relative path config values when syncing into managed homes', async () => { const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') writeFileSync( @@ -317,6 +369,7 @@ describe('CodexAccountService config sync', () => { new CodexAccountService(store as never, rateLimits as never, runtimeHome as never) const managedConfig = readFileSync(join(managedHomePath, 'config.toml'), 'utf-8') + expect(managedConfig).toContain('cli_auth_credentials_store = "file"') expect(managedConfig).toContain( `model_instructions_file = '${join(testState.fakeHomeDir, '.codex', 'instructions.md')}'` ) @@ -330,7 +383,7 @@ describe('CodexAccountService config sync', () => { const managedHomePath = createManagedHome( testState.userDataDir, 'account-1', - canonicalConfig, + withForcedFileAuthStore(canonicalConfig), '{"account":"managed"}\n' ) const managedConfigPath = join(managedHomePath, 'config.toml') @@ -455,7 +508,9 @@ describe('CodexAccountService config sync', () => { await service.selectAccount('account-1') - expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe(canonicalConfig) + expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe( + withForcedFileAuthStore(canonicalConfig) + ) expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledTimes(1) expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(1) }) @@ -518,7 +573,9 @@ describe('CodexAccountService config sync', () => { const loginHome = options.env.CODEX_HOME expect(loginHome).toBeTruthy() - expect(readFileSync(join(loginHome!, 'config.toml'), 'utf-8')).toBe(canonicalConfig) + expect(readFileSync(join(loginHome!, 'config.toml'), 'utf-8')).toBe( + withForcedFileAuthStore(canonicalConfig) + ) const payload = Buffer.from(JSON.stringify({ email: 'user@example.com' })).toString( 'base64url' @@ -577,7 +634,9 @@ describe('CodexAccountService config sync', () => { const loginHome = options.env.CODEX_HOME expect(loginHome).toBeTruthy() expect(readFileSync(join(loginHome!, '.orca-managed-home'), 'utf-8')).toBe('account-1\n') - expect(readFileSync(join(loginHome!, 'config.toml'), 'utf-8')).toBe(canonicalConfig) + expect(readFileSync(join(loginHome!, 'config.toml'), 'utf-8')).toBe( + withForcedFileAuthStore(canonicalConfig) + ) const child = new EventEmitter() as EventEmitter & { stdout: PassThrough @@ -784,8 +843,10 @@ describe('CodexAccountService config sync', () => { // Why: codex login runs inside WSL, so the rewritten path must be the // Linux-side ~/.codex, not a Windows UNC path. expect(readFileSync(join(wslManagedHomePath, 'config.toml'), 'utf-8')).toBe( - 'sandbox_mode = "danger-full-access"\n' + - "model_instructions_file = '/home/alice/.codex/instructions.md'\n" + withForcedFileAuthStore( + 'sandbox_mode = "danger-full-access"\n' + + "model_instructions_file = '/home/alice/.codex/instructions.md'\n" + ) ) const child = new EventEmitter() as EventEmitter & { stdout: PassThrough diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 2a0fb54bdc4..41b27ece963 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -15,6 +15,7 @@ import type { } from '../../shared/types' import type { CodexRuntimeHomeService } from './runtime-home-service' import { writeFileAtomically } from './fs-utils' +import { forceFileAuthCredentialsStore } from '../codex/codex-config-mirror' import { rewriteRelativePathConfigValues } from '../codex/codex-config-path-reference-rewrite' import { resolveCodexCommand } from '../codex-cli/command' import type { Store } from '../persistence' @@ -517,16 +518,19 @@ export class CodexAccountService { } private writeManagedConfig(managedHomePath: string, contents: string): void { + // Why: multi-account vault requires file-backed auth.json in each managed + // home; never mirror the user's keyring/auto credential store preference. + const nextContents = forceFileAuthCredentialsStore(contents) const configPath = join(managedHomePath, 'config.toml') try { - if (existsSync(configPath) && readFileSync(configPath, 'utf-8') === contents) { + if (existsSync(configPath) && readFileSync(configPath, 'utf-8') === nextContents) { return } } catch { // Why: read errors should not make a stale config look current; the // atomic write path owns Windows ACL repair and persistent error surfacing. } - writeFileAtomically(configPath, contents) + writeFileAtomically(configPath, nextContents) } private getManagedAccountsRoot(): string { diff --git a/src/main/codex/codex-config-hooks-feature-normalize.ts b/src/main/codex/codex-config-hooks-feature-normalize.ts new file mode 100644 index 00000000000..1cccc2414fa --- /dev/null +++ b/src/main/codex/codex-config-hooks-feature-normalize.ts @@ -0,0 +1,65 @@ +// Why: Codex 0.133 renames features.codex_hooks → features.hooks. Mirror into +// Orca's runtime config using the new key without rewriting the user's real config. +export function normalizeDeprecatedCodexHookFeatureFlag(config: string): string { + if (!config.includes('codex_hooks')) { + return config + } + + const lines = config.split('\n') + const featureSections: { start: number; end: number }[] = [] + let featureStart: number | null = null + + for (let index = 0; index <= lines.length; index += 1) { + const line = lines[index] + // Why: CRLF configs keep a trailing \r after the split, so header anchors + // must tolerate it or Windows-shaped configs skip normalization entirely. + const isHeader = line === undefined || /^[ \t]*\[[^\]]+\][ \t]*(?:#.*)?\r?$/.test(line) + if (!isHeader) { + continue + } + + if (featureStart !== null) { + featureSections.push({ start: featureStart, end: index }) + featureStart = null + } + if (line !== undefined && /^[ \t]*\[features\][ \t]*(?:#.*)?\r?$/.test(line)) { + featureStart = index + } + } + + for (const section of featureSections.toReversed()) { + normalizeFeatureSectionLines(lines, section.start + 1, section.end) + } + return lines.join('\n') +} + +function normalizeFeatureSectionLines(lines: string[], start: number, end: number): void { + const deprecatedIndexes: number[] = [] + let hasHooksKey = false + for (let index = start; index < end; index += 1) { + const line = lines[index] ?? '' + if (/^[ \t]*hooks[ \t]*=/.test(line)) { + hasHooksKey = true + } + if (/^[ \t]*codex_hooks[ \t]*=/.test(line)) { + deprecatedIndexes.push(index) + } + } + if (deprecatedIndexes.length === 0) { + return + } + + if (!hasHooksKey) { + const firstDeprecatedIndex = deprecatedIndexes.shift() + if (firstDeprecatedIndex !== undefined) { + lines[firstDeprecatedIndex] = lines[firstDeprecatedIndex]!.replace( + /^([ \t]*)codex_hooks([ \t]*=)/, + '$1hooks$2' + ) + } + } + + for (const index of deprecatedIndexes.toReversed()) { + lines.splice(index, 1) + } +} diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index 885e2030548..47d810295bb 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -24,6 +24,7 @@ vi.mock('node:os', async () => { }) import { + forceFileAuthCredentialsStore, prepareSystemConfigForFreshRuntimeMirror, resolveCodexConfigMirrorSourceDirectory, syncSystemConfigIntoManagedCodexHome @@ -92,11 +93,47 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { syncSystemConfigIntoManagedCodexHome() const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('cli_auth_credentials_store = "file"') expect(runtimeConfig).toContain('model = "system-model"') expect(runtimeConfig).toContain('[projects."/repo"]') expect(runtimeConfig).not.toContain('[hooks.state."system-hooks:stop:0:0"]') }) + it('forces file auth credentials store even when system config prefers keyring', () => { + writeFileSync( + getSystemConfigPath(), + ['model = "system-model"', 'cli_auth_credentials_store = "keyring"', ''].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('cli_auth_credentials_store = "file"') + expect(runtimeConfig).not.toContain('cli_auth_credentials_store = "keyring"') + expect(runtimeConfig).toContain('model = "system-model"') + expect(readFileSync(getSystemConfigPath(), 'utf-8')).toContain( + 'cli_auth_credentials_store = "keyring"' + ) + }) + + it('rewrites an existing runtime keyring store setting on merge', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + ['model = "runtime-model"', 'cli_auth_credentials_store = "auto"', ''].join('\n'), + 'utf-8' + ) + writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain('cli_auth_credentials_store = "file"') + expect(runtimeConfig).not.toContain('cli_auth_credentials_store = "auto"') + expect(runtimeConfig).toContain('model = "system-model"') + }) + it('normalizes deprecated codex_hooks feature flag only in runtime config', () => { writeFileSync( getSystemConfigPath(), @@ -171,12 +208,18 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { writeFileSync( getSystemConfigPath(), [ + 'js_repl_node_path = "bin/node"', + '', '[profiles.fast]', 'model_catalog_json = "catalogs/fast.json"', + 'js_repl_node_path = "bin/profile-node"', '', '[debug.config_lockfile]', 'load_path = "locks/config.lock.toml"', 'export_dir = "locks"', + '', + '[otel.exporter.tls]', + 'ca-certificate = "certs/ca.pem"', '' ].join('\n'), 'utf-8' @@ -185,13 +228,34 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { syncSystemConfigIntoManagedCodexHome() const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig).toContain( + `js_repl_node_path = '${join(getSystemCodexHomePath(), 'bin', 'node')}'` + ) expect(runtimeConfig).toContain( `model_catalog_json = '${join(getSystemCodexHomePath(), 'catalogs', 'fast.json')}'` ) + expect(runtimeConfig).toContain( + `js_repl_node_path = '${join(getSystemCodexHomePath(), 'bin', 'profile-node')}'` + ) expect(runtimeConfig).toContain( `load_path = '${join(getSystemCodexHomePath(), 'locks', 'config.lock.toml')}'` ) expect(runtimeConfig).toContain(`export_dir = '${join(getSystemCodexHomePath(), 'locks')}'`) + expect(runtimeConfig).toContain( + `ca-certificate = '${join(getSystemCodexHomePath(), 'certs', 'ca.pem')}'` + ) + }) + + it('links free-standing profile-v2 *.config.toml overlays into the runtime home', () => { + writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8') + const systemOverlayPath = join(getSystemCodexHomePath(), 'work.config.toml') + writeFileSync(systemOverlayPath, 'model = "work-profile"\n', 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + + const runtimeOverlayPath = join(userDataDir, 'codex-runtime-home', 'home', 'work.config.toml') + expect(existsSync(runtimeOverlayPath)).toBe(true) + expect(readFileSync(runtimeOverlayPath, 'utf-8')).toBe('model = "work-profile"\n') }) it('does not treat lines inside multiline arrays as headers or path keys', () => { @@ -437,6 +501,7 @@ describe('prepareSystemConfigForFreshRuntimeMirror', () => { // Why: WSL configs are consumed inside the distro, so rewrites must use // posix join semantics regardless of the host platform. + expect(prepared).toContain('cli_auth_credentials_store = "file"') expect(prepared).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'") expect(prepared).toContain('hooks = true') expect(prepared).not.toContain('codex_hooks') @@ -444,3 +509,38 @@ describe('prepareSystemConfigForFreshRuntimeMirror', () => { expect(prepared).not.toContain('[hooks.state."system-hooks:stop:0:0"]') }) }) + +describe('forceFileAuthCredentialsStore', () => { + it('inserts the file store setting when missing', () => { + expect(forceFileAuthCredentialsStore('model = "gpt"\n')).toBe( + 'cli_auth_credentials_store = "file"\nmodel = "gpt"\n' + ) + }) + + it('overrides keyring and auto modes in the root table only', () => { + const input = [ + 'cli_auth_credentials_store = "keyring"', + 'model = "gpt"', + '', + '[features]', + 'hooks = true', + '' + ].join('\n') + + expect(forceFileAuthCredentialsStore(input)).toBe( + [ + 'cli_auth_credentials_store = "file"', + 'model = "gpt"', + '', + '[features]', + 'hooks = true', + '' + ].join('\n') + ) + }) + + it('is idempotent when the file store is already set', () => { + const input = 'cli_auth_credentials_store = "file"\nmodel = "gpt"\n' + expect(forceFileAuthCredentialsStore(input)).toBe(input) + }) +}) diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 422b0e79280..609d86d9fd4 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -2,8 +2,10 @@ import { existsSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' +import { normalizeDeprecatedCodexHookFeatureFlag } from './codex-config-hooks-feature-normalize' import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite' import { parseWslUncPath } from '../../shared/wsl-paths' +import { syncSystemProfileV2ConfigOverlaysIntoManagedHome } from './codex-profile-v2-config-overlay-mirror' import { promoteCodexRuntimeSettingsToSystem, snapshotCodexRuntimeSettingsBaseline, @@ -16,6 +18,13 @@ import { updateTomlLineScanState } from './config-toml-line-scan' +// Why: multi-account vault assumes auth.json; keyring/auto stores credentials +// outside managed homes and breaks account switching / presence detection. +const CLI_AUTH_CREDENTIALS_STORE_FILE_LINE = 'cli_auth_credentials_store = "file"' +const CLI_AUTH_CREDENTIALS_STORE_KEY_RE = /^[ \t]*cli_auth_credentials_store[ \t]*=/ +const CLI_AUTH_CREDENTIALS_STORE_FILE_RE = + /^[ \t]*cli_auth_credentials_store[ \t]*=[ \t]*(?:"file"|'file')[ \t\r]*(?:#.*)?$/ + export function syncSystemConfigIntoManagedCodexHome( homes: CodexSettingsPromotionHomes = { runtimeHomePath: getOrcaManagedCodexHomePath(), @@ -49,6 +58,9 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe({ const runtimeConfigPath = join(runtimeHomePath, 'config.toml') const systemConfigExists = existsSync(systemConfigPath) const runtimeConfigExists = existsSync(runtimeConfigPath) + // Why: profile-v2 overlays (`*.config.toml`) are sibling files Codex loads by + // name; link them even when config.toml is missing so --profile-v2 still works. + syncSystemProfileV2ConfigOverlaysIntoManagedHome() if (!systemConfigExists && !runtimeConfigExists) { return } @@ -65,7 +77,9 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe({ const systemConfig = prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir) const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8') - const mergedConfig = mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig) + const mergedConfig = forceFileAuthCredentialsStore( + mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig) + ) if (mergedConfig !== runtimeConfig) { writeFileAtomically(runtimeConfigPath, mergedConfig) } @@ -90,73 +104,47 @@ export function prepareSystemConfigForFreshRuntimeMirror( config: string, systemConfigDir: string ): string { - return stripRuntimeOwnedTomlSections(prepareSystemConfigForRuntimeMirror(config, systemConfigDir)) + return forceFileAuthCredentialsStore( + stripRuntimeOwnedTomlSections(prepareSystemConfigForRuntimeMirror(config, systemConfigDir)) + ) } -function normalizeDeprecatedCodexHookFeatureFlag(config: string): string { - if (!config.includes('codex_hooks')) { - return config - } - +// Why: Orca multi-account vaults read/write CODEX_HOME/auth.json; force file +// store so mirrored keyring/auto mode cannot hide credentials from the vault. +export function forceFileAuthCredentialsStore(config: string): string { const lines = config.split('\n') - const featureSections: { start: number; end: number }[] = [] - let featureStart: number | null = null - - for (let index = 0; index <= lines.length; index += 1) { - const line = lines[index] - // Why: CRLF configs keep a trailing \r after the split, so header anchors - // must tolerate it or Windows-shaped configs skip normalization entirely. - const isHeader = line === undefined || /^[ \t]*\[[^\]]+\][ \t]*(?:#.*)?\r?$/.test(line) - if (!isHeader) { - continue - } - - if (featureStart !== null) { - featureSections.push({ start: featureStart, end: index }) - featureStart = null - } - if (line !== undefined && /^[ \t]*\[features\][ \t]*(?:#.*)?\r?$/.test(line)) { - featureStart = index - } - } - - for (const section of featureSections.toReversed()) { - normalizeFeatureSectionLines(lines, section.start + 1, section.end) - } - return lines.join('\n') -} + let scanState = createTomlLineScanState() + let existingKeyIndex: number | null = null -function normalizeFeatureSectionLines(lines: string[], start: number, end: number): void { - const deprecatedIndexes: number[] = [] - let hasHooksKey = false - for (let index = start; index < end; index += 1) { + for (let index = 0; index < lines.length; index += 1) { const line = lines[index] ?? '' - if (/^[ \t]*hooks[ \t]*=/.test(line)) { - hasHooksKey = true - } - if (/^[ \t]*codex_hooks[ \t]*=/.test(line)) { - deprecatedIndexes.push(index) + if (isTomlStructuralLine(scanState)) { + if (getTomlTableHeader(line)) { + break + } + if (CLI_AUTH_CREDENTIALS_STORE_KEY_RE.test(line)) { + existingKeyIndex = index + break + } } - } - if (deprecatedIndexes.length === 0) { - return + scanState = updateTomlLineScanState(scanState, line) } - if (!hasHooksKey) { - const firstDeprecatedIndex = deprecatedIndexes.shift() - if (firstDeprecatedIndex !== undefined) { - // Why: Codex 0.133 warns on the old key. Mirror into Orca's runtime - // config using the new key without rewriting the user's real config. - lines[firstDeprecatedIndex] = lines[firstDeprecatedIndex]!.replace( - /^([ \t]*)codex_hooks([ \t]*=)/, - '$1hooks$2' - ) + if (existingKeyIndex !== null) { + const existingLine = lines[existingKeyIndex] ?? '' + if (CLI_AUTH_CREDENTIALS_STORE_FILE_RE.test(existingLine)) { + return config } + const indent = /^[ \t]*/.exec(existingLine)?.[0] ?? '' + const lineEnding = existingLine.endsWith('\r') ? '\r' : '' + lines[existingKeyIndex] = `${indent}${CLI_AUTH_CREDENTIALS_STORE_FILE_LINE}${lineEnding}` + return lines.join('\n') } - for (const index of deprecatedIndexes.toReversed()) { - lines.splice(index, 1) + if (config.length === 0) { + return `${CLI_AUTH_CREDENTIALS_STORE_FILE_LINE}\n` } + return `${CLI_AUTH_CREDENTIALS_STORE_FILE_LINE}\n${config}` } function mergeSystemCodexConfigIntoRuntime(runtimeConfig: string, systemConfig: string): string { diff --git a/src/main/codex/codex-config-path-reference-rewrite.ts b/src/main/codex/codex-config-path-reference-rewrite.ts index 94e29db3d8f..70af08f4719 100644 --- a/src/main/codex/codex-config-path-reference-rewrite.ts +++ b/src/main/codex/codex-config-path-reference-rewrite.ts @@ -10,11 +10,13 @@ import { // values against the defining config.toml's directory (= CODEX_HOME for the // user config). experimental_instructions_file only exists in older Codex // releases; keeping it is harmless since Codex ignores unknown keys. +// js_repl_node_path is deprecated-but-still-typed AbsolutePathBuf. const EXACT_PATH_CONFIG_KEYS = new Set([ 'debug.config_lockfile.export_dir', 'debug.config_lockfile.load_path', 'experimental_compact_prompt_file', 'experimental_instructions_file', + 'js_repl_node_path', 'log_dir', 'model_catalog_json', 'model_instructions_file', @@ -92,9 +94,11 @@ function isPathConfigKey(tablePath: string, key: string): boolean { return ( /^agents\..+\.config_file$/.test(fullPath) || /^model_providers\..+\.auth\.cwd$/.test(fullPath) || + // Why: OTEL TLS material is AbsolutePathBuf with kebab-case serde keys. + /^otel\..+\.(?:ca-certificate|client-certificate|client-private-key)$/.test(fullPath) || // Why: profiles mirror the top-level file settings that Codex reads (and // can abort on) during config load. - /^profiles\..+\.(?:experimental_compact_prompt_file|model_catalog_json|model_instructions_file)$/.test( + /^profiles\..+\.(?:experimental_compact_prompt_file|js_repl_node_path|model_catalog_json|model_instructions_file)$/.test( fullPath ) ) diff --git a/src/main/codex/codex-hook-identity.ts b/src/main/codex/codex-hook-identity.ts index cc78a9acb76..9eef40e098a 100644 --- a/src/main/codex/codex-hook-identity.ts +++ b/src/main/codex/codex-hook-identity.ts @@ -11,6 +11,8 @@ export const CODEX_HOOK_EVENT_LABEL: Record = { PreToolUse: 'pre_tool_use', PermissionRequest: 'permission_request', PostToolUse: 'post_tool_use', + SubagentStart: 'subagent_start', + SubagentStop: 'subagent_stop', Stop: 'stop', PreCompact: 'pre_compact', PostCompact: 'post_compact' @@ -22,6 +24,8 @@ export const CODEX_EVENT_NAME_BY_LABEL: Record = { pre_tool_use: 'PreToolUse', permission_request: 'PermissionRequest', post_tool_use: 'PostToolUse', + subagent_start: 'SubagentStart', + subagent_stop: 'SubagentStop', stop: 'Stop', pre_compact: 'PreCompact', post_compact: 'PostCompact' diff --git a/src/main/codex/codex-profile-v2-config-overlay-mirror.ts b/src/main/codex/codex-profile-v2-config-overlay-mirror.ts new file mode 100644 index 00000000000..4f302d9d26a --- /dev/null +++ b/src/main/codex/codex-profile-v2-config-overlay-mirror.ts @@ -0,0 +1,88 @@ +import { + cpSync, + existsSync, + lstatSync, + readdirSync, + readlinkSync, + rmSync, + symlinkSync, + unlinkSync +} from 'node:fs' +import { join } from 'node:path' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' + +function isProfileV2ConfigOverlayName(fileName: string): boolean { + return fileName.endsWith('.config.toml') && fileName !== 'config.toml' +} + +// Why: Codex profile-v2 loads `${CODEX_HOME}/.config.toml` when selected. +// Resource linking covers the profile-v2/ directory, not free-standing overlays. +export function syncSystemProfileV2ConfigOverlaysIntoManagedHome(): void { + const systemHomePath = getSystemCodexHomePath() + const managedHomePath = getOrcaManagedCodexHomePath() + let entries: string[] + try { + entries = readdirSync(systemHomePath) + } catch { + return + } + for (const fileName of entries) { + if (!isProfileV2ConfigOverlayName(fileName)) { + continue + } + linkSystemProfileV2ConfigOverlay(systemHomePath, managedHomePath, fileName) + } +} + +function linkSystemProfileV2ConfigOverlay( + systemHomePath: string, + managedHomePath: string, + fileName: string +): void { + const sourcePath = join(systemHomePath, fileName) + const targetPath = join(managedHomePath, fileName) + if (!existsSync(sourcePath)) { + return + } + try { + if ( + lstatSync(targetPath).isSymbolicLink() && + profileOverlayLinkTargetsMatch(readlinkSync(targetPath), sourcePath) + ) { + return + } + } catch { + // Target missing or unreadable — create below. + } + if (existsSync(targetPath)) { + try { + if (!lstatSync(targetPath).isSymbolicLink()) { + // Why: leave non-link runtime files alone; they may be user-edited copies. + return + } + unlinkSync(targetPath) + } catch { + return + } + } + try { + symlinkSync(sourcePath, targetPath) + } catch { + try { + rmSync(targetPath, { force: true }) + cpSync(sourcePath, targetPath, { force: false, errorOnExist: true }) + } catch (error) { + console.warn('[codex-config] Failed to link profile-v2 config overlay:', fileName, error) + } + } +} + +function profileOverlayLinkTargetsMatch(actualTarget: string, expectedTarget: string): boolean { + if (process.platform !== 'win32') { + return actualTarget === expectedTarget + } + return ( + actualTarget.replace(/^\\\\\?\\/, '').toLowerCase() === + expectedTarget.replace(/^\\\\\?\\/, '').toLowerCase() + ) +} diff --git a/src/main/codex/codex-session-bridge.test.ts b/src/main/codex/codex-session-bridge.test.ts index ed3c838ea7c..dd9015a8050 100644 --- a/src/main/codex/codex-session-bridge.test.ts +++ b/src/main/codex/codex-session-bridge.test.ts @@ -260,7 +260,9 @@ describe('syncSystemCodexSessionsIntoManagedHome', () => { ).toBe(false) }) - it('falls back to symlinks when hardlinks are unavailable', () => { + it('falls back to copy-sync when hardlinks are unavailable', () => { + // Why: Codex resume ignores symlinks; cross-volume hardlink failure must + // produce a regular-file copy, not a symlink. fsMockState.failLink = true const systemSessionPath = join( getSystemCodexHomePath(), @@ -268,7 +270,7 @@ describe('syncSystemCodexSessionsIntoManagedHome', () => { '2026', '05', '26', - 'rollout-symlink-fallback.jsonl' + 'rollout-copy-fallback.jsonl' ) mkdirSync(dirname(systemSessionPath), { recursive: true }) writeFileSync(systemSessionPath, '{"id":"system"}\n', 'utf-8') @@ -281,12 +283,23 @@ describe('syncSystemCodexSessionsIntoManagedHome', () => { '2026', '05', '26', - 'rollout-symlink-fallback.jsonl' - ) - expect(lstatSync(runtimeSessionPath).isSymbolicLink()).toBe(true) - expect(normalizeLinkTarget(readlinkSync(runtimeSessionPath))).toBe( - normalizeLinkTarget(systemSessionPath) + 'rollout-copy-fallback.jsonl' ) + expect(lstatSync(runtimeSessionPath).isSymbolicLink()).toBe(false) + expect(readFileSync(runtimeSessionPath, 'utf-8')).toBe('{"id":"system"}\n') + expect(lstatSync(runtimeSessionPath).ino).not.toBe(lstatSync(systemSessionPath).ino) + expect( + existsSync( + join( + getRuntimeCodexHomePath(), + '.orca-session-copies', + '2026', + '05', + '26', + 'rollout-copy-fallback.jsonl.json' + ) + ) + ).toBe(true) }) it('does not overwrite runtime-owned session files', () => { @@ -322,27 +335,30 @@ describe('syncSystemCodexSessionsIntoManagedHome', () => { expectResourceLinked(runtimeSessionPath, systemSessionPath) }) - it('does not create independent session copies when file links are unavailable', () => { - fsMockState.failLink = true - fsMockState.failSymlink = true + it('bridges cold-compressed .jsonl.zst session files', () => { const systemSessionPath = join( getSystemCodexHomePath(), 'sessions', '2026', '05', '26', - 'rollout-unlinked.jsonl' + 'rollout-cold.jsonl.zst' ) mkdirSync(dirname(systemSessionPath), { recursive: true }) - writeFileSync(systemSessionPath, '{"id":"system"}\n', 'utf-8') + writeFileSync(systemSessionPath, 'fake-zstd-bytes', 'utf-8') syncSystemCodexSessionsIntoManagedHome() - expect( - existsSync( - join(getRuntimeCodexHomePath(), 'sessions', '2026', '05', '26', 'rollout-unlinked.jsonl') - ) - ).toBe(false) + const runtimeSessionPath = join( + getRuntimeCodexHomePath(), + 'sessions', + '2026', + '05', + '26', + 'rollout-cold.jsonl.zst' + ) + expect(readFileSync(runtimeSessionPath, 'utf-8')).toBe('fake-zstd-bytes') + expectResourceLinked(runtimeSessionPath, systemSessionPath) }) it('replaces unchanged legacy copied sessions with links', () => { @@ -361,7 +377,7 @@ describe('syncSystemCodexSessionsIntoManagedHome', () => { expectResourceLinked(runtimeSessionPath, systemSessionPath) }) - it('preserves unchanged legacy copied sessions when relinking fails', () => { + it('preserves unchanged legacy copied sessions when hardlink migration fails', () => { const relativeSessionPath = join('2026', '05', '26', 'rollout-legacy-unlinked.jsonl') const systemSessionPath = join(getSystemCodexHomePath(), 'sessions', relativeSessionPath) const runtimeSessionPath = join(getRuntimeCodexHomePath(), 'sessions', relativeSessionPath) @@ -371,7 +387,6 @@ describe('syncSystemCodexSessionsIntoManagedHome', () => { writeFileSync(runtimeSessionPath, '{"id":"legacy"}\n', 'utf-8') writeLegacyCopyMarker(relativeSessionPath, systemSessionPath, runtimeSessionPath) fsMockState.failLink = true - fsMockState.failSymlink = true syncSystemCodexSessionsIntoManagedHome() diff --git a/src/main/codex/codex-session-bridge.ts b/src/main/codex/codex-session-bridge.ts index e546ce9d8f8..a432065c069 100644 --- a/src/main/codex/codex-session-bridge.ts +++ b/src/main/codex/codex-session-bridge.ts @@ -1,13 +1,12 @@ import { + copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, - readFileSync, readlinkSync, renameSync, - rmSync, - symlinkSync + rmSync } from 'node:fs' import { dirname, isAbsolute, join, relative, sep } from 'node:path' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' @@ -16,17 +15,15 @@ import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' import type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing' +import { + clearLegacyCopiedSessionMarker, + fileStatsMatchMarker, + readLegacyCopiedSessionMarker, + writeLegacyCopiedSessionMarker +} from './codex-session-copy-markers' 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 @@ -147,36 +144,20 @@ function bridgeSystemCodexSessionFile( } /** - * Links a source session file and clears any stale copied-session marker. + * Links a source session file (hardlink preferred) or copy-syncs across volumes. */ 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)) { + clearLegacyCopiedSessionMarker(relativePath) 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 + // Why: Codex resume ignores symlinks; cross-volume hardlink failure must + // copy so the managed home still has a resume-visible regular file. + return tryCopySystemCodexSessionFile(sourcePath, targetPath, relativePath) } /** @@ -194,8 +175,27 @@ function tryHardlinkSystemCodexSessionFile(sourcePath: string, targetPath: strin } /** - * Replaces an older symlink bridge with a hardlink when the target still points - * at the expected source session. + * Copies a session file and records a marker so later scans can keep the copy + * coherent until a hardlink migration succeeds. + */ +function tryCopySystemCodexSessionFile( + sourcePath: string, + targetPath: string, + relativePath: string +): boolean { + try { + copyFileSync(sourcePath, targetPath) + writeLegacyCopiedSessionMarker(relativePath, sourcePath, targetPath) + return true + } catch (error) { + console.warn('[codex-session-bridge] Failed to copy system Codex session:', sourcePath, error) + return false + } +} + +/** + * Replaces an older symlink bridge with a hardlink (or copy) so Codex resume + * can see a regular file. Codex FS scans ignore symlinks. */ function replaceSymlinkSessionBridgeWithHardlink( sourcePath: string, @@ -217,13 +217,20 @@ function replaceSymlinkSessionBridgeWithHardlink( } replacementPath = `${targetPath}.orca-link-${process.pid}-${Date.now()}` - if (!tryHardlinkSystemCodexSessionFile(sourcePath, replacementPath)) { - return false + if (tryHardlinkSystemCodexSessionFile(sourcePath, replacementPath)) { + rmSync(targetPath, { force: true }) + renameSync(replacementPath, targetPath) + clearLegacyCopiedSessionMarker(relativePath) + return true } - rmSync(targetPath, { force: true }) - renameSync(replacementPath, targetPath) - clearLegacyCopiedSessionMarker(relativePath) - return true + if (tryCopySystemCodexSessionFile(sourcePath, replacementPath, relativePath)) { + rmSync(targetPath, { force: true }) + renameSync(replacementPath, targetPath) + // Marker was written against the temp path; rewrite for the final target. + writeLegacyCopiedSessionMarker(relativePath, sourcePath, targetPath) + return true + } + return false } catch (error) { console.warn( '[codex-session-bridge] Failed to replace symlinked Codex session bridge:', @@ -238,8 +245,8 @@ function replaceSymlinkSessionBridgeWithHardlink( } /** - * Migrates a legacy copied bridge to a linked bridge when the copied file still - * matches its marker. + * Migrates a legacy copied bridge to a hardlink when the copied file still + * matches its marker. Leaves the copy in place when hardlink is unavailable. */ function migrateLegacyCopiedSessionBridge( sourcePath: string, @@ -261,7 +268,7 @@ function migrateLegacyCopiedSessionBridge( return } replacementPath = `${targetPath}.orca-link-${process.pid}-${Date.now()}` - if (!tryLinkSystemCodexSessionFile(sourcePath, replacementPath)) { + if (!tryHardlinkSystemCodexSessionFile(sourcePath, replacementPath)) { return } rmSync(targetPath, { force: true }) @@ -283,7 +290,7 @@ function migrateLegacyCopiedSessionBridge( * 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. + * a hardlink. */ export function getLegacyCopiedCodexSessionBridgeScanPreference( sessionFilePath: string @@ -320,57 +327,3 @@ export function getLegacyCopiedCodexSessionBridgeScanPreference( 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-copy-markers.ts b/src/main/codex/codex-session-copy-markers.ts new file mode 100644 index 00000000000..d197f29954d --- /dev/null +++ b/src/main/codex/codex-session-copy-markers.ts @@ -0,0 +1,86 @@ +import { lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { getOrcaManagedCodexHomePath } from './codex-home-paths' + +export type LegacyCopiedSessionMarker = { + sourcePath: string + sourceSize: number + sourceMtimeMs: number + targetSize: number + targetMtimeMs: number +} + +/** Marker path for a legacy/copy-sync session bridge under the managed home. */ +export function getLegacySessionCopyMarkerPath(relativePath: string): string { + return join(getOrcaManagedCodexHomePath(), '.orca-session-copies', `${relativePath}.json`) +} + +/** Reads and validates the marker for a copy-sync session bridge. */ +export 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 + } +} + +/** Whether source or target file stats still match a copy-sync bridge marker. */ +export 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 copy bridge has been migrated or retired. */ +export function clearLegacyCopiedSessionMarker(relativePath: string): void { + rmSync(getLegacySessionCopyMarkerPath(relativePath), { force: true }) +} + +/** Writes a marker capturing source/target size+mtime for a copy-sync bridge. */ +export function writeLegacyCopiedSessionMarker( + relativePath: string, + sourcePath: string, + targetPath: string +): void { + const sourceStat = lstatSync(sourcePath) + const targetStat = lstatSync(targetPath) + const markerPath = getLegacySessionCopyMarkerPath(relativePath) + mkdirSync(dirname(markerPath), { recursive: true }) + writeFileSync( + markerPath, + `${JSON.stringify( + { + sourcePath, + sourceSize: sourceStat.size, + sourceMtimeMs: sourceStat.mtimeMs, + targetSize: targetStat.size, + targetMtimeMs: targetStat.mtimeMs + } satisfies LegacyCopiedSessionMarker, + null, + 2 + )}\n`, + 'utf-8' + ) +} diff --git a/src/main/codex/codex-session-file-listing.ts b/src/main/codex/codex-session-file-listing.ts index c6eeb9b1f77..aa8d5d1c9f9 100644 --- a/src/main/codex/codex-session-file-listing.ts +++ b/src/main/codex/codex-session-file-listing.ts @@ -1,6 +1,7 @@ import { readdirSync } from 'node:fs' import { opendir } from 'node:fs/promises' import { join } from 'node:path' +import { isCodexSessionRolloutFileName } from '../ai-vault/session-scanner-codex-paths' export type CodexSessionBridgeIncrementalOptions = { /** Directory entries to process before yielding back to the event loop. */ @@ -13,7 +14,8 @@ const INCREMENTAL_BRIDGE_BATCH_SIZE = 64 const INCREMENTAL_BRIDGE_YIELD_MS = 10 /** - * Recursively lists session JSONL files below a root directory. + * Recursively lists session rollout files below a root directory (`*.jsonl` and + * cold-compressed `*.jsonl.zst`). * * This synchronous variant preserves the historical bridge behavior for callers * that run outside the CLI launch path. @@ -27,7 +29,7 @@ export function listCodexSessionJsonlFiles(rootPath: string): string[] { appendSessionFilePaths(files, listCodexSessionJsonlFiles(childPath)) continue } - if (entry.isFile() && entry.name.endsWith('.jsonl')) { + if (entry.isFile() && isCodexSessionRolloutFileName(entry.name)) { files.push(childPath) } } @@ -74,7 +76,7 @@ export async function* listCodexSessionJsonlFilesIncrementally( const childPath = join(currentDirectory, entry.name) if (entry.isDirectory()) { pendingDirectories.push(childPath) - } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + } else if (entry.isFile() && isCodexSessionRolloutFileName(entry.name)) { yield childPath } entriesSinceYield += 1 diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index 54dc00bc83d..4c4e0237e70 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -33,6 +33,8 @@ export type CodexEventLabel = | 'post_compact' | 'session_start' | 'user_prompt_submit' + | 'subagent_start' + | 'subagent_stop' | 'stop' export type CodexTrustEntry = { @@ -129,6 +131,8 @@ function matcherPatternForEvent( case 'pre_compact': case 'post_compact': case 'session_start': + case 'subagent_start': + case 'subagent_stop': return matcher } } @@ -265,6 +269,8 @@ function isCodexEventLabel(value: string): value is CodexEventLabel { value === 'post_compact' || value === 'session_start' || value === 'user_prompt_submit' || + value === 'subagent_start' || + value === 'subagent_stop' || value === 'stop' ) } diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index 526a64d99ab..9e4da821058 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -124,6 +124,8 @@ function localManagedCodexEvents(): string[] { 'PreToolUse', 'SessionStart', 'Stop', + 'SubagentStart', + 'SubagentStop', 'UserPromptSubmit' ] } @@ -156,6 +158,118 @@ describe('CodexHookService', () => { expect(trustConfig).toContain('model = "gpt-5.2-codex"') expect(trustConfig).toContain('approval_policy = "on-request"') expect(trustConfig).toContain(':permission_request:0:0') + expect(trustConfig).toContain(':subagent_start:0:0') + expect(trustConfig).toContain(':subagent_stop:0:0') + expect( + isCodexManagedCommand(hooksConfig.hooks.SubagentStart?.[0]?.hooks?.[0]?.command) + ).toBe(true) + expect( + isCodexManagedCommand(hooksConfig.hooks.SubagentStop?.[0]?.hooks?.[0]?.command) + ).toBe(true) + }) + + it('prepends managed remote hooks ahead of existing user hooks (groupIndex 0)', async () => { + // Why: remote install used to append managed hooks, leaving slow user hooks + // at groupIndex 0 and delaying status updates relative to local install. + const files = new Map([ + [ + '/home/dev/.codex/hooks.json', + `${JSON.stringify({ + hooks: { + Stop: [ + { + hooks: [{ type: 'command', command: 'echo user-stop-hook' }] + } + ] + } + })}\n` + ] + ]) + const modes = new Map() + const dirs = new Set(['/']) + const noEntry = (path: string): { code: number; message: string } => ({ + code: 2, + message: `ENOENT ${path}` + }) + const sftp = { + readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => { + const value = files.get(path) + if (value === undefined) { + cb(noEntry(path)) + return + } + cb(null, value) + }, + writeFile: ( + path: string, + content: string, + options: string | { mode?: number }, + cb: (err: unknown) => void + ): void => { + files.set(path, content) + if (typeof options !== 'string' && options.mode !== undefined) { + modes.set(path, options.mode) + } + cb(null) + }, + rename: (src: string, dst: string, cb: (err: unknown) => void): void => { + const value = files.get(src) + if (value === undefined) { + cb(noEntry(src)) + return + } + files.set(dst, value) + files.delete(src) + const mode = modes.get(src) + if (mode !== undefined) { + modes.set(dst, mode) + modes.delete(src) + } + cb(null) + }, + unlink: (path: string, cb: (err: unknown) => void): void => { + files.delete(path) + modes.delete(path) + cb(null) + }, + chmod: (path: string, mode: number, cb: (err: unknown) => void): void => { + modes.set(path, mode) + cb(null) + }, + stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => { + if (!files.has(path)) { + cb(noEntry(path)) + return + } + cb(null, { mode: modes.get(path) ?? 0o100644 }) + }, + readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => { + if (dirs.has(path)) { + cb(null, []) + return + } + cb(noEntry(path)) + }, + mkdir: (path: string, cb: (err: unknown) => void): void => { + dirs.add(path) + cb(null) + } + } + + const status = await new CodexHookService().installRemote(sftp as never, '/home/dev') + + expect(status.state).toBe('installed') + const hooks = JSON.parse(files.get('/home/dev/.codex/hooks.json')!) as { + hooks: Record + } + expect(hooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toContain('codex-hook.sh') + expect(hooks.hooks.Stop?.[1]?.hooks?.[0]?.command).toBe('echo user-stop-hook') + expect(hooks.hooks.SubagentStart?.[0]?.hooks?.[0]?.command).toContain('codex-hook.sh') + expect(hooks.hooks.SubagentStop?.[0]?.hooks?.[0]?.command).toContain('codex-hook.sh') + const toml = files.get('/home/dev/.codex/config.toml') ?? '' + expect(toml).toContain(':stop:0:0') + expect(toml).toContain(':subagent_start:0:0') + expect(toml).toContain(':subagent_stop:0:0') }) it('drops plugin manager metadata from runtime hooks.json during install', () => { diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 66b2b65b077..4281b87070b 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -61,15 +61,18 @@ import { // Why: PreToolUse/PostToolUse give the dashboard a live readout of the // in-flight tool (name + input preview) between UserPromptSubmit and Stop. -// PermissionRequest is the human-input boundary: the managed script exits -// without a decision so Codex still shows its normal approval UI, while Orca -// can flip the pane to the red waiting state. +// SubagentStart/SubagentStop keep the parent turn visible while a subagent +// runs — SubagentStop must not look like root Stop. PermissionRequest is the +// human-input boundary: the managed script exits without a decision so Codex +// still shows its normal approval UI, while Orca can flip the pane to waiting. const CODEX_EVENTS = [ 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest', 'PostToolUse', + 'SubagentStart', + 'SubagentStop', 'Stop' ] as const @@ -95,6 +98,8 @@ const CODEX_EVENT_LABEL: Record<(typeof CODEX_EVENTS)[number], CodexEventLabel> PreToolUse: CODEX_HOOK_EVENT_LABEL.PreToolUse!, PermissionRequest: CODEX_HOOK_EVENT_LABEL.PermissionRequest!, PostToolUse: CODEX_HOOK_EVENT_LABEL.PostToolUse!, + SubagentStart: CODEX_HOOK_EVENT_LABEL.SubagentStart!, + SubagentStop: CODEX_HOOK_EVENT_LABEL.SubagentStop!, Stop: CODEX_HOOK_EVENT_LABEL.Stop! } @@ -1240,11 +1245,14 @@ export class CodexHookService { const definition: HookDefinition = { hooks: [buildManagedCommandHook(command)] } - nextHooks[eventName] = [...cleaned, definition] + // Why: prepend managed hooks (groupIndex 0) so a slow user PostToolUse/ + // Stop hook cannot leave the sidebar sticky while Codex is still running + // other handlers — same ordering as local install(). + nextHooks[eventName] = [definition, ...cleaned] trustEntries.push({ sourcePath: remoteConfigPath, eventLabel: CODEX_EVENT_LABEL[eventName], - groupIndex: cleaned.length, + groupIndex: 0, handlerIndex: 0, command, timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS diff --git a/src/main/codex/wsl-codex-session-bridge.test.ts b/src/main/codex/wsl-codex-session-bridge.test.ts index c4218913a5b..4f1be6cdba1 100644 --- a/src/main/codex/wsl-codex-session-bridge.test.ts +++ b/src/main/codex/wsl-codex-session-bridge.test.ts @@ -65,11 +65,13 @@ describe('syncWslCodexSessionsIntoManagedHome', () => { 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( + `find "\\$source_sessions_root" -type f \\( -name '*.jsonl' -o -name '*.jsonl.zst' \\) -print0` + ) expect(shellCommand).toContain('ln -- "\\$source_file" "\\$target_file"') + expect(shellCommand).toContain('cp -p -- "\\$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') }) @@ -152,6 +154,8 @@ describe('buildWslCodexSessionBridgeShellCommand', () => { `source_sessions_root='/home/alice/.codex/sessions with '\\''quote'\\'''` ) expect(shellCommand).toContain(`-name '*.jsonl'`) + expect(shellCommand).toContain(`-name '*.jsonl.zst'`) + expect(shellCommand).toContain('cp -p --') expect(shellCommand).not.toContain('.sqlite') }) diff --git a/src/main/codex/wsl-codex-session-bridge.ts b/src/main/codex/wsl-codex-session-bridge.ts index e83abfadd95..fd6a47d0f8d 100644 --- a/src/main/codex/wsl-codex-session-bridge.ts +++ b/src/main/codex/wsl-codex-session-bridge.ts @@ -103,11 +103,14 @@ export function buildWslCodexSessionBridgeShellCommand( ' 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. + // Linux hardlinks created inside the distro filesystem. Cross-filesystem + // hardlink failure falls back to cp -p (regular file), never symlink. ' if ln -- "$source_file" "$target_file"; then', ' linked_files=$((linked_files + 1))', + ' elif cp -p -- "$source_file" "$target_file"; then', + ' linked_files=$((linked_files + 1))', ' fi', - `done < <(find "$source_sessions_root" -type f -name '*.jsonl' -print0 2>/dev/null)`, + `done < <(find "$source_sessions_root" -type f \\( -name '*.jsonl' -o -name '*.jsonl.zst' \\) -print0 2>/dev/null)`, `printf '{"scannedFiles":%s,"linkedFiles":%s}\\n' "$scanned_files" "$linked_files"` ].join('\n') return escapeWslShCommandForWindows(shellCommand) diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index ad8f14cb068..8afc0a4d659 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -1449,7 +1449,7 @@ describe('createPtySubprocess', () => { expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('1') }) - it('uses shell-ready wrapper for Codex native prefill flags', () => { + it('uses shell-ready wrapper for Codex positional PROMPT when plan opts in', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) const platform = Object.getOwnPropertyDescriptor(process, 'platform') @@ -1461,7 +1461,8 @@ describe('createPtySubprocess', () => { cols: 80, rows: 24, cwd: '/repo', - command: "codex --prefill 'linked issue context'", + command: "codex 'linked issue context'", + startupCommandDelivery: 'shell-ready', env: { SHELL: '/bin/zsh' } }) } finally { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 9b4216d924d..0ddbc92b299 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -6761,7 +6761,7 @@ describe('registerPtyHandlers', () => { } ) - posixOnlyIt('waits for shell-ready when Codex uses the native prefill flag', async () => { + posixOnlyIt('waits for shell-ready when Codex positional PROMPT opts into shell-ready', async () => { vi.useFakeTimers() const mockProc = createMockProc() spawnMock.mockReturnValue(mockProc.proc) @@ -6772,7 +6772,8 @@ describe('registerPtyHandlers', () => { cols: 80, rows: 24, cwd: '/tmp', - command: "codex --prefill 'linked issue context'" + command: "codex 'linked issue context'", + startupCommandDelivery: 'shell-ready' }) const [, , options] = spawnMock.mock.calls[0]! @@ -6783,7 +6784,7 @@ describe('registerPtyHandlers', () => { await Promise.resolve() vi.runAllTimers() await Promise.resolve() - expect(mockProc.proc.write).toHaveBeenCalledWith("codex --prefill 'linked issue context'\n") + expect(mockProc.proc.write).toHaveBeenCalledWith("codex 'linked issue context'\n") } finally { vi.useRealTimers() } diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts index 66d9ca47bc8..84319ec9f54 100644 --- a/src/main/native-chat/session-file-resolver.test.ts +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -52,6 +52,36 @@ describe('resolveSessionFilePath', () => { expect(resolved).toBe(target) }) + it('matches cold-compressed Codex .jsonl.zst rollouts by session id', 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 target = join(dayDir, 'rollout-2026-06-04T10-00-00-zst-session.jsonl.zst') + await writeFile(target, 'zstd') + + const resolved = await resolveSessionFilePath('codex', 'zst-session', { + codexSessionsDirs: [codexSessionsDir] + }) + expect(resolved).toBe(target) + }) + + it('prefers plain .jsonl over sibling .jsonl.zst for the same session', async () => { + const root = await makeRoot('orca-native-chat-resolve-codex-prefer-plain-') + const codexSessionsDir = join(root, 'codex-sessions') + const dayDir = join(codexSessionsDir, '2026', '06', '04') + await mkdir(dayDir, { recursive: true }) + const plain = join(dayDir, 'rollout-2026-06-04T10-00-00-dual-session.jsonl') + const compressed = join(dayDir, 'rollout-2026-06-04T10-00-00-dual-session.jsonl.zst') + await writeFile(plain, '{}\n') + await writeFile(compressed, 'zstd') + + const resolved = await resolveSessionFilePath('codex', 'dual-session', { + codexSessionsDirs: [codexSessionsDir] + }) + expect(resolved).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. diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 3b2cda9965a..2f02de7faae 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -2,6 +2,11 @@ import { existsSync } from 'node:fs' import { homedir } from 'node:os' import { basename, extname, join } 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' @@ -61,7 +66,11 @@ 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)) { + if ( + hookPath && + (extname(hookPath) === '.jsonl' || isCodexSessionRolloutPath(hookPath)) && + existsSync(hookPath) + ) { return hookPath } @@ -95,23 +104,29 @@ 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 (rollout--.jsonl or + // .jsonl.zst), so match the id as a suffix of the rollout base name. // Search each candidate root (managed home first) and stop at the first match. + // Prefer plain `.jsonl` over cold `.jsonl.zst` when both exist for the same id. 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) { + continue } + const plain = files.find((path) => path.endsWith('.jsonl') && !path.endsWith('.jsonl.zst')) + return plain ?? 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..4e95356a057 --- /dev/null +++ b/src/main/native-chat/transcript-codex-turn-items.ts @@ -0,0 +1,151 @@ +// Maps Codex Paginated-history TurnItems (item_completed payloads) into +// NativeChatMessage values. Shared by transcript-line-decoders so Legacy and +// Paginated rollout shapes stay in one decoder path. + +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { asRecord, extractString } from '../ai-vault/session-scanner-values' +import { claudeContentBlocks } from './transcript-record-blocks' + +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) + if (blocks.length === 0) { + const text = codexTurnItemText(item.content) + if (!text) { + return null + } + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp, + source: 'transcript' + } + } + return { id, role: 'assistant', blocks, 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)) { + return extractString(asRecord(content)?.text) ?? extractString(asRecord(content)?.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 { + const summary = item.summary_text + if (Array.isArray(summary)) { + const parts = summary + .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) +} + +/** Normalize TurnItem wire tags (snake_case or PascalCase) to snake_case. */ +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.ts b/src/main/native-chat/transcript-line-decoders.ts index 1a428cbb98a..81f0fd313c5 100644 --- a/src/main/native-chat/transcript-line-decoders.ts +++ b/src/main/native-chat/transcript-line-decoders.ts @@ -14,6 +14,7 @@ import { timestampMs } from '../ai-vault/session-scanner-values' import { claudeContentBlocks, toolResultOutput } from './transcript-record-blocks' +import { codexTurnItem } from './transcript-codex-turn-items' export function decodeClaudeTranscriptLine( line: string, @@ -145,6 +146,15 @@ function codexEventMessage( ? { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } : null } + // Why: Paginated history_mode writes TurnItems under item_completed instead of + // legacy user_message/agent_message event_msg records. + if (payload.type === 'item_completed') { + const item = asRecord(payload.item) + if (!item) { + return null + } + return codexTurnItem(item, extractString(item.id) ?? id, timestamp) + } return null } diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts index 09c922ca347..90e79420091 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' @@ -105,6 +106,147 @@ describe('readNativeChatTranscript (claude)', () => { }) describe('readNativeChatTranscript (codex)', () => { + it('maps Paginated item_completed TurnItems 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', cwd: '/repo', history_mode: 'paginated' } + }, + { + type: 'event_msg', + timestamp: '2026-06-01T10:00:01.000Z', + payload: { + type: 'item_completed', + thread_id: 'codex-paginated', + turn_id: 'turn-1', + completed_at_ms: 1, + item: { + type: 'user_message', + id: 'user-1', + content: [{ type: 'text', text: 'Paginated hello' }] + } + } + }, + { + type: 'event_msg', + timestamp: '2026-06-01T10:00:02.000Z', + payload: { + type: 'item_completed', + thread_id: 'codex-paginated', + turn_id: 'turn-1', + completed_at_ms: 2, + 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', + thread_id: 'codex-paginated', + turn_id: 'turn-1', + completed_at_ms: 3, + 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', + thread_id: 'codex-paginated', + turn_id: 'turn-1', + completed_at_ms: 4, + 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.' }) + }) + + it('reads cold-compressed .jsonl.zst codex 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('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 c0b5f64bb68..5ba84534f67 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -1,6 +1,8 @@ import { createReadStream } from 'node:fs' import { createInterface } from 'node:readline' import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import { isCodexCompressedRolloutPath } from '../ai-vault/session-scanner-codex-paths' +import { openCodexRolloutLineStream } 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 } from './transcript-line-decoders' @@ -46,21 +48,29 @@ async function readTranscript( filePath: string, decode: (line: string, fallbackId: string) => NativeChatMessage | null ): Promise { + const input = isCodexCompressedRolloutPath(filePath) + ? openCodexRolloutLineStream(filePath) + : createReadStream(filePath, { encoding: 'utf-8' }) const reader = createInterface({ - input: createReadStream(filePath, { encoding: 'utf-8' }), + input, crlfDelay: Infinity }) const messages: NativeChatMessage[] = [] let index = 0 - for await (const line of reader) { - // Why: fallback id embeds start offset 0 so it matches the live tailer's id - // for the same record (the tailer's first drain reads from offset 0 too). - // Records that re-emit then collapse by id in the assembler — no dup, no drop. - const message = decode(line, `${filePath}:0:${index}`) - if (message) { - messages.push(message) + try { + for await (const line of reader) { + // Why: fallback id embeds start offset 0 so it matches the live tailer's id + // for the same record (the tailer's first drain reads from offset 0 too). + // Records that re-emit then collapse by id in the assembler — no dup, no drop. + const message = decode(line, `${filePath}:0:${index}`) + if (message) { + messages.push(message) + } + index++ } - index++ + } finally { + reader.close() + input.destroy() } return messages } diff --git a/src/main/pty/codex-home-wsl-env.test.ts b/src/main/pty/codex-home-wsl-env.test.ts index 1077ea9855e..8b374cec9e8 100644 --- a/src/main/pty/codex-home-wsl-env.test.ts +++ b/src/main/pty/codex-home-wsl-env.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from './codex-home-wsl-env' +import { + getHostCodexHomeStrippedForWslMessage, + isHostCodexHomeForWsl, + isWslCodexHomeForHost, + shouldStripHostCodexHomeForWslShell +} from './codex-home-wsl-env' describe('isHostCodexHomeForWsl', () => { it('matches Windows paths that WSL Codex cannot use as CODEX_HOME', () => { @@ -20,4 +25,15 @@ describe('isHostCodexHomeForWsl', () => { expect(isWslCodexHomeForHost('C:\\Users\\jin\\.codex')).toBe(false) expect(isWslCodexHomeForHost(undefined)).toBe(false) }) + + it('documents strip behavior when host CODEX_HOME reaches a WSL shell', () => { + // Why: host-managed account selection must not leak into WSL; callers strip + // CODEX_HOME so the distro uses Linux ~/.codex or a WSL-managed account. + expect(shouldStripHostCodexHomeForWslShell('C:\\Users\\jin\\.orca\\codex-accounts\\a\\home')).toBe( + true + ) + expect(shouldStripHostCodexHomeForWslShell('/home/jin/.codex')).toBe(false) + expect(getHostCodexHomeStrippedForWslMessage()).toMatch(/WSL terminals use the distro Codex home/) + expect(getHostCodexHomeStrippedForWslMessage()).toMatch(/not the host Windows CODEX_HOME/) + }) }) diff --git a/src/main/pty/codex-home-wsl-env.ts b/src/main/pty/codex-home-wsl-env.ts index f61272de8c3..4fb7553251e 100644 --- a/src/main/pty/codex-home-wsl-env.ts +++ b/src/main/pty/codex-home-wsl-env.ts @@ -14,6 +14,14 @@ export function wslCodexRuntimeHomeForGuestHome(guestHome: string): string { return `${home}/${WSL_CODEX_RUNTIME_HOME_SEGMENTS.join('/')}` } +/** + * Host Windows CODEX_HOME paths cannot be consumed by Codex inside WSL. + * + * When a WSL shell inherits a host-managed CODEX_HOME (drive letter or UNC + * that is not a same-distro WSL path), callers must strip CODEX_HOME and + * ORCA_CODEX_HOME so the distro falls back to Linux ~/.codex or a + * WSL-managed account home — not the host account selection. + */ export function isHostCodexHomeForWsl(value: string | undefined): boolean { const trimmed = value?.trim() if (!trimmed) { @@ -22,6 +30,11 @@ export function isHostCodexHomeForWsl(value: string | undefined): boolean { return /^[A-Za-z]:(?:[\\/]|$)/.test(trimmed) || trimmed.startsWith('\\\\') } +/** + * Linux CODEX_HOME paths cannot be consumed by host Windows Codex. + * Callers launching a non-WSL shell should strip these so Windows Codex does + * not inherit a path it cannot open. + */ export function isWslCodexHomeForHost(value: string | undefined): boolean { const trimmed = value?.trim() if (!trimmed) { @@ -29,3 +42,22 @@ export function isWslCodexHomeForHost(value: string | undefined): boolean { } return trimmed.startsWith('/') } + +/** True when a WSL shell must drop the current CODEX_HOME host path. */ +export function shouldStripHostCodexHomeForWslShell( + codexHome: string | undefined +): boolean { + return isHostCodexHomeForWsl(codexHome) +} + +/** + * User-facing explanation for host vs WSL Codex home separation. + * Prefer surfacing this near account/runtime pickers when host CODEX_HOME is + * not injected into WSL terminals. + */ +export function getHostCodexHomeStrippedForWslMessage(): string { + return ( + 'WSL terminals use the distro Codex home (~/.codex or a WSL-managed account), ' + + 'not the host Windows CODEX_HOME.' + ) +} diff --git a/src/main/rate-limits/codex-auth-presence.ts b/src/main/rate-limits/codex-auth-presence.ts index 8ebe81bd9be..417c4319d8c 100644 --- a/src/main/rate-limits/codex-auth-presence.ts +++ b/src/main/rate-limits/codex-auth-presence.ts @@ -41,9 +41,10 @@ function getAuthPresenceProbe(authPath: string): SharedAuthFilesystemOperation ({ childSpawnMock: vi.fn(), @@ -48,9 +48,18 @@ describe('fetchCodexRateLimits auth errors', () => { beforeEach(() => { vi.useFakeTimers() vi.clearAllMocks() + // Why: after PTY/RPC success the fetcher may probe real WHAM reset-credits + // via global fetch; isolate that under fake timers so local auth.json cannot + // hang the suite. + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))) resolveCodexCommandMock.mockReturnValue('codex') }) + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + it('returns Codex RPC auth refresh errors without masking them behind PTY fallback', async () => { const rpcChild = makeRpcChild() const authError = diff --git a/src/main/rate-limits/codex-fetcher-pty-settle.test.ts b/src/main/rate-limits/codex-fetcher-pty-settle.test.ts index 969ba02ce75..53de8b9e742 100644 --- a/src/main/rate-limits/codex-fetcher-pty-settle.test.ts +++ b/src/main/rate-limits/codex-fetcher-pty-settle.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { childSpawnMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({ childSpawnMock: vi.fn(), @@ -33,9 +33,18 @@ describe('fetchCodexRateLimits PTY settle timers', () => { beforeEach(() => { vi.useFakeTimers() vi.clearAllMocks() + // Why: after PTY success the fetcher may probe real WHAM reset-credits via + // global fetch; isolate that under fake timers so local auth.json cannot + // hang the suite. + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))) resolveCodexCommandMock.mockReturnValue('codex') }) + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + it('coalesces the PTY fallback status settle timer while output keeps streaming', async () => { const ptyHandlers: { onData?: (data: string) => void } = {} diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index a2031ac9941..5ffc2aa6099 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -31,7 +31,11 @@ vi.mock('./codex-auth-presence', () => ({ codexAuthExists: vi.fn(() => true) })) -import { fetchCodexRateLimits } from './codex-fetcher' +import { + buildCodexRateLimitResetCreditsUrl, + fetchCodexRateLimits, + normalizeCodexBackendBaseUrl +} from './codex-fetcher' import { codexAuthExists } from './codex-auth-presence' import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup' @@ -72,6 +76,21 @@ function makePtyTerm() { } } +describe('normalizeCodexBackendBaseUrl', () => { + it('defaults to ChatGPT backend-api and normalizes bare chatgpt hosts', () => { + expect(normalizeCodexBackendBaseUrl(null)).toBe('https://chatgpt.com/backend-api') + expect(normalizeCodexBackendBaseUrl('https://chatgpt.com/')).toBe( + 'https://chatgpt.com/backend-api' + ) + expect(normalizeCodexBackendBaseUrl('https://api.example.com/v1/')).toBe( + 'https://api.example.com/v1' + ) + expect(buildCodexRateLimitResetCreditsUrl('https://api.example.com')).toBe( + 'https://api.example.com/api/codex/rate-limit-reset-credits' + ) + }) +}) + describe('fetchCodexRateLimits', () => { beforeEach(() => { vi.useFakeTimers() @@ -350,17 +369,91 @@ describe('fetchCodexRateLimits', () => { expect(result.weekly?.windowMinutes).toBe(10080) }) + it('surfaces additional rateLimitsByLimitId buckets alongside preferred session/weekly', async () => { + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + rpcChild.stdin.write.mockImplementation((line: string) => { + const msg = JSON.parse(line) as { id?: number; method?: string } + if (msg.method === 'initialize') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) + ) + }, 0) + } + if (msg.method === 'account/rateLimits/read') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + jsonrpc: '2.0', + id: msg.id, + result: { + rateLimits: { + limitId: 'codex', + primary: { usedPercent: 10, windowDurationMins: 299 }, + secondary: { usedPercent: 20, windowDurationMins: 10079 } + }, + rateLimitsByLimitId: { + codex: { + limitId: 'codex', + limitName: 'Codex', + primary: { usedPercent: 10 }, + secondary: { usedPercent: 20 } + }, + codex_other: { + limitId: 'codex_other', + limitName: 'Codex other', + primary: { usedPercent: 40 }, + secondary: { usedPercent: 55 } + } + } + } + })}\n` + ) + ) + }, 0) + } + }) + + const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + const result = await resultPromise + + expect(result).toMatchObject({ + provider: 'codex', + session: { usedPercent: 10, windowMinutes: 300 }, + weekly: { usedPercent: 20, windowMinutes: 10080 }, + status: 'ok' + }) + expect(result.buckets).toEqual([ + expect.objectContaining({ name: 'Codex', usedPercent: 10, windowMinutes: 300 }), + expect.objectContaining({ name: 'Codex other', usedPercent: 40, windowMinutes: 300 }), + expect.objectContaining({ + name: 'Codex other weekly', + usedPercent: 55, + windowMinutes: 10080 + }) + ]) + }) + it('fills reset-credit count from the backend when the installed app-server omits it', async () => { const rpcChild = makeRpcChild() childSpawnMock.mockReturnValue(rpcChild) - readFileMock.mockResolvedValue( - JSON.stringify({ + readFileMock.mockImplementation(async (path: string) => { + if (String(path).endsWith('config.toml')) { + return 'model = "gpt-5"\n' + } + return JSON.stringify({ tokens: { access_token: 'access-token', account_id: 'account-id' } }) - ) + }) vi.mocked(fetch).mockResolvedValue({ ok: true, json: async () => ({ @@ -458,6 +551,126 @@ describe('fetchCodexRateLimits', () => { ) }) + it('uses chatgpt_base_url from config for reset-credit backend requests', async () => { + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + readFileMock.mockImplementation(async (path: string) => { + if (String(path).endsWith('config.toml')) { + return 'chatgpt_base_url = "https://chatgpt.com"\n' + } + return JSON.stringify({ + tokens: { + access_token: 'access-token', + account_id: 'account-id' + } + }) + }) + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ available_count: 1 }) + } as Response) + rpcChild.stdin.write.mockImplementation((line: string) => { + const msg = JSON.parse(line) as { id?: number; method?: string } + if (msg.method === 'initialize') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) + ) + }, 0) + } + if (msg.method === 'account/rateLimits/read') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + jsonrpc: '2.0', + id: msg.id, + result: { + rateLimits: { primary: { usedPercent: 3 } } + } + })}\n` + ) + ) + }, 0) + } + }) + + const resultPromise = fetchCodexRateLimits({ + codexHomePath: '/managed/codex-home', + allowPtyFallback: false + }) + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + await resultPromise + + expect(fetch).toHaveBeenCalledWith( + 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', + expect.anything() + ) + }) + + it('uses Codex API path style when chatgpt_base_url is a non-ChatGPT backend', async () => { + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + readFileMock.mockImplementation(async (path: string) => { + if (String(path).endsWith('config.toml')) { + return 'chatgpt_base_url = "https://api.example.com/v1"\n' + } + return JSON.stringify({ + tokens: { + access_token: 'access-token', + account_id: 'account-id' + } + }) + }) + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ available_count: 1 }) + } as Response) + rpcChild.stdin.write.mockImplementation((line: string) => { + const msg = JSON.parse(line) as { id?: number; method?: string } + if (msg.method === 'initialize') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) + ) + }, 0) + } + if (msg.method === 'account/rateLimits/read') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + jsonrpc: '2.0', + id: msg.id, + result: { + rateLimits: { primary: { usedPercent: 3 } } + } + })}\n` + ) + ) + }, 0) + } + }) + + const resultPromise = fetchCodexRateLimits({ + codexHomePath: '/managed/codex-home', + allowPtyFallback: false + }) + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + await resultPromise + + expect(fetch).toHaveBeenCalledWith( + 'https://api.example.com/v1/api/codex/rate-limit-reset-credits', + expect.anything() + ) + }) + it('uses reset-credit count from newer app-server responses without backend fallback', async () => { const rpcChild = makeRpcChild() childSpawnMock.mockReturnValue(rpcChild) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 8a3ed94bdff..c26f26598f2 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -81,6 +81,8 @@ type RpcRateLimitsResult = { // The actual response shape is `{ rateLimits: { primary, secondary, ... } }`. type RpcRateLimitsResponse = { rateLimits?: RpcRateLimitsResult + // Why: multi-meter plans return several snapshots keyed by limit_id. + rateLimitsByLimitId?: Record | null rateLimitResetCredits?: { availableCount?: number totalEarnedCount?: number @@ -376,7 +378,8 @@ async function fetchBackendRateLimitResetCredits( } // Why: published Codex 0.140 can read windows through app-server but strips // reset-credit metadata that the backend already returns. - const response = await fetch('https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', { + const baseUrl = await resolveCodexBackendBaseUrl(options?.codexHomePath) + const response = await fetch(buildCodexRateLimitResetCreditsUrl(baseUrl), { ...auth, signal }) @@ -435,18 +438,16 @@ export async function consumeCodexRateLimitResetCredit(options: { if (!auth) { throw new Error('Codex not signed in') } - const response = await fetch( - 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume', - { - method: 'POST', - headers: { - ...auth.headers, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ redeem_request_id: options.idempotencyKey }), - signal - } - ) + const baseUrl = await resolveCodexBackendBaseUrl(options.codexHomePath) + const response = await fetch(buildCodexRateLimitResetCreditsConsumeUrl(baseUrl), { + method: 'POST', + headers: { + ...auth.headers, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ redeem_request_id: options.idempotencyKey }), + signal + }) if (!response.ok) { throw new Error(`Codex reset failed: HTTP ${response.status}`) } @@ -491,6 +492,141 @@ function mapRpcWindow( } } +function parseTopLevelTomlStringKey(config: string, key: string): string | null { + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const keyPattern = new RegExp( + `^[ \\t]*${escapedKey}[ \\t]*=[ \\t]*(?:"([^"]*)"|'([^']*)')` + ) + for (const line of config.split('\n')) { + if (/^[ \t]*\[/.test(line)) { + break + } + const match = keyPattern.exec(line) + if (match) { + return match[1] ?? match[2] ?? null + } + } + return null +} + +// Why: Codex backend-client normalizes ChatGPT hostnames onto /backend-api so +// WHAM paths resolve; custom bases use the /api/codex path style instead. +export function normalizeCodexBackendBaseUrl(raw: string | null | undefined): string { + let base = (raw?.trim() || DEFAULT_CHATGPT_BACKEND_BASE_URL).replace(/\/+$/, '') + if ( + (base.startsWith('https://chatgpt.com') || base.startsWith('https://chat.openai.com')) && + !base.includes('/backend-api') + ) { + base = `${base}/backend-api` + } + return base +} + +function isChatGptApiBaseUrl(baseUrl: string): boolean { + return baseUrl.includes('/backend-api') +} + +export function buildCodexRateLimitResetCreditsUrl(baseUrl: string): string { + const normalized = normalizeCodexBackendBaseUrl(baseUrl) + return isChatGptApiBaseUrl(normalized) + ? `${normalized}/wham/rate-limit-reset-credits` + : `${normalized}/api/codex/rate-limit-reset-credits` +} + +export function buildCodexRateLimitResetCreditsConsumeUrl(baseUrl: string): string { + return `${buildCodexRateLimitResetCreditsUrl(baseUrl)}/consume` +} + +async function readCodexChatGptBaseUrl(codexHomePath?: string | null): Promise { + try { + const config = await readFile(join(getCodexHomePath(codexHomePath), 'config.toml'), 'utf8') + return parseTopLevelTomlStringKey(config, 'chatgpt_base_url') + } catch { + return null + } +} + +async function resolveCodexBackendBaseUrl(codexHomePath?: string | null): Promise { + const fromConfig = await readCodexChatGptBaseUrl(codexHomePath) + return normalizeCodexBackendBaseUrl(fromConfig) +} + +function preferredRpcRateLimitSnapshot( + wrapper: RpcRateLimitsResponse | undefined +): { id: string | null; snapshot: RpcRateLimitSnapshot | undefined } { + const byId = wrapper?.rateLimitsByLimitId + if (byId) { + if (byId.codex) { + return { id: 'codex', snapshot: byId.codex } + } + for (const [id, snapshot] of Object.entries(byId)) { + if (snapshot) { + return { id, snapshot } + } + } + } + return { id: null, snapshot: wrapper?.rateLimits } +} + +function limitSnapshotDisplayName(id: string, snapshot: RpcRateLimitSnapshot): string { + const named = snapshot.limitName?.trim() || snapshot.limitId?.trim() || id + return named === 'codex' ? 'Session' : named +} + +// Why: multi-meter plans expose additional limit_ids beyond the primary codex +// snapshot. Surface them as named buckets while keeping session/weekly for the +// preferred (usually "codex") pair used by compact status-bar UI. +function mapRpcRateLimitsPayload(wrapper: RpcRateLimitsResponse | undefined): { + session: RateLimitWindow | null + weekly: RateLimitWindow | null + buckets?: RateLimitBucket[] +} { + const preferred = preferredRpcRateLimitSnapshot(wrapper) + const session = mapRpcWindow(preferred.snapshot?.primary, 300) + const weekly = mapRpcWindow(preferred.snapshot?.secondary, 10080) + const byId = wrapper?.rateLimitsByLimitId + if (!byId) { + return { session, weekly } + } + + const entries = Object.entries(byId).filter( + (entry): entry is [string, RpcRateLimitSnapshot] => entry[1] != null + ) + const preferredId = preferred.id ?? 'codex' + const hasAdditional = entries.some(([id]) => id !== preferredId) + if (!hasAdditional) { + return { session, weekly } + } + + const buckets: RateLimitBucket[] = [] + for (const [id, snapshot] of entries) { + const name = limitSnapshotDisplayName(id, snapshot) + const primary = mapRpcWindow(snapshot.primary, 300) + if (primary) { + buckets.push({ name, ...primary }) + } + if (id === preferredId) { + // Preferred secondary is already exposed as weekly. + continue + } + const secondary = mapRpcWindow(snapshot.secondary, 10080) + if (secondary) { + buckets.push({ name: `${name} weekly`, ...secondary }) + } + } + + return { + session, + weekly, + ...(buckets.length > 0 ? { buckets } : {}) + } +} + +// --------------------------------------------------------------------------- +// RPC fetch — spawn `codex -s read-only -a untrusted app-server` +// --------------------------------------------------------------------------- + + function mapBackendUsageWindow( raw: BackendRateLimitWindow | null | undefined, fallbackWindowMinutes: number @@ -723,9 +859,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise { ) expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'codex' }) - runtime.onPtyData('pty-startup-draft', '\x1b[?2004h›', Date.now()) + runtime.onPtyData( + 'pty-startup-draft', + '\x1b[?2004h\x1b[1m›\x1b[0m Ask Codex to do anything', + Date.now() + ) await vi.waitFor(() => { expect(write).toHaveBeenCalledWith('pty-startup-draft', `\x1b[200~${draftUrl}\x1b[201~`) }) @@ -24074,7 +24078,11 @@ describe('OrcaRuntimeService', () => { ) expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'codex' }) - runtime.onPtyData('pty-explicit-draft', '\x1b[?2004h›', Date.now()) + runtime.onPtyData( + 'pty-explicit-draft', + '\x1b[?2004h\x1b[1m›\x1b[0m Ask Codex to do anything', + Date.now() + ) await vi.waitFor(() => { expect(write).toHaveBeenCalledWith('pty-explicit-draft', `\x1b[200~${draftUrl}\x1b[201~`) }) diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 7d5198e87a9..3aeafeccbbc 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -387,18 +387,19 @@ describe('PtyHandler', () => { ) it.skipIf(process.platform === 'win32')( - 'emits shell-ready markers for renderer-delivered Codex native prefill commands', + 'emits shell-ready markers for renderer-delivered Codex positional PROMPT commands', async () => { const oldShell = process.env.SHELL const oldHome = process.env.HOME - const homeDir = mkdtempSync(join(tmpdir(), 'relay-codex-prefill-spawn-')) + const homeDir = mkdtempSync(join(tmpdir(), 'relay-codex-prompt-spawn-')) process.env.SHELL = '/bin/bash' process.env.HOME = homeDir try { await dispatcher.callRequest('pty.spawn', { env: { HOME: homeDir }, - command: "codex --prefill 'linked issue context'" + command: "codex 'linked issue context'", + startupCommandDelivery: 'shell-ready' }) } finally { if (oldShell === undefined) { @@ -472,10 +473,11 @@ describe('PtyHandler', () => { await dispatcher.callRequest('pty.spawn', { env: { HOME: homeDir, - [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex --prefill 'linked issue context'" + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex 'linked issue context'" }, command: 'bash -lc wait-for-setup-wrapper', - commandDelivery: 'provider' + commandDelivery: 'provider', + startupCommandDelivery: 'shell-ready' }) } finally { if (oldShell === undefined) { diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index d85a3a59bbe..ccaaa7a6a0a 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -432,7 +432,9 @@ export const NativeChatComposer = forwardRef { - const result = applySkillSuggestion(draft, caret, skill.name) + // Why: PTY insert remains `$name`; skillFilePath is retained on the + // result for future app-server Skill { name, path } turns. + const result = applySkillSuggestion(draft, caret, skill.name, skill.skillFilePath) setDraft(result.draft) setCaret(result.caret) setActiveSuggestion(0) 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..c09087beb75 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 @@ -3,6 +3,7 @@ import { applyMentionSuggestion, applySkillSuggestion, applySlashSuggestion, + dedupeSkillSuggestionsByName, deriveComposerAutocomplete, EMPTY_HISTORY, filterSkillSuggestions, @@ -144,6 +145,47 @@ describe('filterSkillSuggestions', () => { ]) expect(filterSkillSuggestions(skills, 'h')).toEqual([]) }) + + it('dedupes same-name skills preferring repo over home for PTY $name insert', () => { + 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('dedupeSkillSuggestionsByName', () => { + it('keeps the higher-priority source when names collide', () => { + const result = dedupeSkillSuggestionsByName([ + skill({ + id: 'plugin', + name: 'demo', + sourceKind: 'plugin', + skillFilePath: '/plugin/demo/SKILL.md' + }), + skill({ + id: 'home', + name: 'demo', + sourceKind: 'home', + skillFilePath: '/home/demo/SKILL.md' + }) + ]) + expect(result).toHaveLength(1) + expect(result[0]?.sourceKind).toBe('home') + }) }) describe('history recall', () => { @@ -210,4 +252,15 @@ describe('apply suggestions', () => { expect(result.draft).toBe('use $typescript now') expect(result.caret).toBe('use $typescript '.length) }) + + it('applySkillSuggestion keeps $name text and threads skillFilePath for later use', () => { + const result = applySkillSuggestion( + 'use $typ', + 8, + 'typescript', + '/repo/.agents/skills/typescript/SKILL.md' + ) + expect(result.draft).toBe('use $typescript ') + expect(result.skillFilePath).toBe('/repo/.agents/skills/typescript/SKILL.md') + }) }) 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..a327e5dc05b 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 @@ -69,22 +69,53 @@ export function deriveComposerAutocomplete( return { mode: 'none' } } +// Why: PTY insert is `$name` only (no structured Skill path yet). Prefer +// repo-scoped skills when the same name appears from multiple roots so the +// text fallback is less ambiguous. Source priority mirrors Codex discovery +// (repo before home/plugin/bundled). App-server Skill{name,path} is deferred. +const SKILL_SOURCE_KIND_PRIORITY: Record = { + repo: 0, + home: 1, + plugin: 2, + bundled: 3 +} + +function skillSuggestionPriority(skill: DiscoveredSkill): number { + return SKILL_SOURCE_KIND_PRIORITY[skill.sourceKind] ?? 99 +} + +/** Dedupe same-name installed skills, keeping the highest-priority source. */ +export function dedupeSkillSuggestionsByName( + skills: readonly DiscoveredSkill[] +): DiscoveredSkill[] { + const byName = new Map() + for (const skill of skills) { + const key = skill.name.toLowerCase() + const existing = byName.get(key) + if (!existing || skillSuggestionPriority(skill) < skillSuggestionPriority(existing)) { + byName.set(key, skill) + } + } + return Array.from(byName.values()) +} + export function filterSkillSuggestions( skills: readonly DiscoveredSkill[], query: string ): DiscoveredSkill[] { const normalized = query.toLowerCase() const installed = skills.filter((skill) => skill.installed) - if (normalized === '') { - return installed.slice(0, 12) - } - 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) + 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) + }) + // Why: keep skillFilePath on the retained row for future app-server structured + // insert; PTY mode still inserts `$name` via applySkillSuggestion. + return dedupeSkillSuggestionsByName(matched).slice(0, 12) } export type HistoryState = { @@ -169,19 +200,31 @@ export function applyMentionSuggestion( return { draft: nextBefore + after, caret: nextBefore.length } } +/** + * Insert `$name` for PTY/TUI text fallback. + * Why: structured Skill { name, path } needs app-server turns; until then keep + * the text form. Optional skillFilePath is threaded through so callers can + * retain path metadata without changing the inserted draft. + */ export function applySkillSuggestion( draft: string, caret: number, - skillName: string -): { draft: string; caret: number } { + skillName: string, + skillFilePath?: string | null +): { draft: string; caret: number; skillFilePath?: string } { const before = draft.slice(0, caret) const after = draft.slice(caret) const match = before.match(/(^|\s)\$(\S*)$/) if (!match) { - return { draft, caret } + return skillFilePath ? { draft, caret, skillFilePath } : { draft, caret } } const tokenStart = before.length - match[2].length - 1 // -1 for the '$' + // Why: PTY mode only accepts text `$name`; path is retained for future + // app-server structured Skill insert, not embedded in the draft. const insertion = `$${skillName} ` const nextBefore = before.slice(0, tokenStart) + insertion + if (skillFilePath) { + return { draft: nextBefore + after, caret: nextBefore.length, skillFilePath } + } return { draft: nextBefore + after, caret: nextBefore.length } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts index ac28aae3482..06ecd6e60af 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts @@ -165,7 +165,9 @@ function applySkill({ setCaret: Dispatch> setActiveSuggestion: Dispatch> }): void { - const result = applySkillSuggestion(draft, caret, skill.name) + // Why: PTY insert remains `$name`; skillFilePath is retained on the result + // for future app-server Skill { name, path } turns. + const result = applySkillSuggestion(draft, caret, skill.name, skill.skillFilePath) setDraft(result.draft) setCaret(result.caret) setActiveSuggestion(0) diff --git a/src/renderer/src/components/settings/codex-session-source-home-control.tsx b/src/renderer/src/components/settings/codex-session-source-home-control.tsx index 6a49a7b6042..4525bd172fa 100644 --- a/src/renderer/src/components/settings/codex-session-source-home-control.tsx +++ b/src/renderer/src/components/settings/codex-session-source-home-control.tsx @@ -125,7 +125,7 @@ export function AgentSessionSourceHomeInput({ {translate( 'auto.components.settings.AgentsPane.codexSessionSourceTooltip', - 'Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex.' + 'Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex. On Windows, host and WSL Codex homes are separate — WSL terminals use the distro ~/.codex (or a WSL-managed account), not the host CODEX_HOME.' )} diff --git a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts index 43dd1c9cb2b..09d1923a4c7 100644 --- a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts +++ b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts @@ -237,4 +237,26 @@ describe('Codex auto-approval status suppression', () => { }) ).toBe(false) }) + + it('suppresses Codex native Action Required OSC titles when launch is yolo', () => { + registerCodexLaunchConfig({ + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + launchToken + }) + + expect( + shouldSuppressCodexAutoApprovalSyntheticTitle('[ ! ] Action Required | my-project', { + paneKey, + tabId: 'tab-1', + launchToken + }) + ).toBe(true) + expect( + shouldSuppressCodexAutoApprovalSyntheticTitle('[ . ] Action Required | my-project', { + paneKey, + tabId: 'tab-1', + launchToken + }) + ).toBe(true) + }) }) diff --git a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts index dadede174df..e397ad372aa 100644 --- a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts +++ b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts @@ -1,3 +1,4 @@ +import { isCodexNativeActionRequiredTitle } from '../../../../shared/agent-detection' import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' import { getSyntheticAgentTitleProfile } from '../../../../shared/synthetic-agent-title' import { resolveTuiAgentPermissionMode } from '../../../../shared/tui-agent-permissions' @@ -60,7 +61,12 @@ export function shouldSuppressCodexAutoApprovalSyntheticTitle( title: string, context: CodexAutoApprovalStatusContext ): boolean { - if (title !== getSyntheticAgentTitleProfile('codex')?.permissionLabel) { + // Why: YOLO sessions can surface either Orca's synthetic permission title + // or Codex's native `[ ! ] Action Required | project` OSC form — suppress both. + const isPermissionTitle = + title === getSyntheticAgentTitleProfile('codex')?.permissionLabel || + isCodexNativeActionRequiredTitle(title) + if (!isPermissionTitle) { return false } 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 9368f810cbf..432d9ed8bc7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -4503,7 +4503,9 @@ describe('connectPanePty', () => { await flushAsyncTicks() expect(capturedDataCallback.current).not.toBeNull() - capturedDataCallback.current?.('\x1b[?2004h\x1b[2K› ') + capturedDataCallback.current?.( + '\x1b[?2004h\x1b[2K\x1b[1m›\x1b[0m Ask Codex to do anything' + ) await flushAsyncTicks() expect(window.api.pty.writeAccepted).toHaveBeenCalledWith( @@ -4669,7 +4671,7 @@ describe('connectPanePty', () => { } }) - it('waits for shell-ready for SSH Codex native prefill commands without an explicit hint', async () => { + it('waits for shell-ready for SSH Codex positional PROMPT when plan opts in', async () => { const pendingTimeouts: (() => void)[] = [] const originalSetTimeout = globalThis.setTimeout globalThis.setTimeout = vi.fn((fn: () => void) => { @@ -4704,7 +4706,10 @@ describe('connectPanePty', () => { const pane = createPane(1) const manager = createManager(1) const deps = createDeps({ - startup: { command: "codex --prefill 'linked issue context'" } + startup: { + command: "codex 'linked issue context'", + startupCommandDelivery: 'shell-ready' + } }) connectPanePty(pane as never, manager as never, deps as never) @@ -4719,7 +4724,7 @@ describe('connectPanePty', () => { fn() } - expect(transport.sendInput).toHaveBeenCalledWith("codex --prefill 'linked issue context'\r") + expect(transport.sendInput).toHaveBeenCalledWith("codex 'linked issue context'\r") } finally { globalThis.setTimeout = originalSetTimeout } @@ -4763,8 +4768,9 @@ describe('connectPanePty', () => { const deps = createDeps({ startup: { command: wrapperCommand, + startupCommandDelivery: 'shell-ready', env: { - [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex --prefill 'linked issue context'" + [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex 'linked issue context'" } } }) diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index 318823c6ab6..59d892b5c1a 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -96,7 +96,7 @@ describe('pasteDraftWhenAgentReady', () => { vi.useRealTimers() }) - it('pastes into Codex as soon as its composer prompt renders after bracketed paste is enabled', async () => { + it('pastes into Codex only after glyph and idle placeholder render after bracketed paste', async () => { const promise = pasteDraftWhenAgentReady({ tabId: 'tab-1', content: ISSUE_URL, @@ -112,6 +112,11 @@ describe('pasteDraftWhenAgentReady', () => { await flushMicrotasks() expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() + // Why: bare `›` after 2004 (hooks-review/onboarding) must not unlock paste. + testState.ptyObserver?.('›') + await flushMicrotasks() + expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() + testState.ptyObserver?.(CODEX_COMPOSER_PROMPT_RENDER) await expect(promise).resolves.toBe(true) @@ -123,7 +128,7 @@ describe('pasteDraftWhenAgentReady', () => { expect(vi.getTimerCount()).toBe(0) }) - it('detects the Codex composer prompt inside a large first render chunk', async () => { + it('detects the Codex idle composer prompt inside a large first render chunk', async () => { const promise = pasteDraftWhenAgentReady({ tabId: 'tab-1', content: ISSUE_URL, @@ -351,7 +356,7 @@ describe('pasteDraftWhenAgentReady', () => { 'pty-1', PASTED_ISSUE_URL ) - await vi.advanceTimersByTimeAsync(49) + await vi.advanceTimersByTimeAsync(499) expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(1) await expect(promise).resolves.toBe(true) @@ -532,7 +537,7 @@ describe('pasteDraftWhenAgentReady', () => { ) await flushMicrotasks() - await vi.advanceTimersByTimeAsync(49) + await vi.advanceTimersByTimeAsync(499) expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(1) @@ -558,7 +563,7 @@ describe('pasteDraftWhenAgentReady', () => { }) await flushMicrotasks() - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(500) await expect(promise).resolves.toBe(true) expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith( @@ -599,7 +604,7 @@ describe('pasteDraftWhenAgentReady', () => { expect((call[2] as string).length).toBeLessThanOrEqual(AGENT_DRAFT_PASTE_CHUNK_MAX_BYTES) } - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(500) await expect(promise).resolves.toBe(true) expect(testState.sendRuntimePtyInputVerified).toHaveBeenLastCalledWith({}, 'pty-1', '\r') diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index e9246310881..affbd148d9f 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -17,6 +17,7 @@ import { sendAgentDraftPasteContent } from './agent-draft-paste-content' import { agentDeliversDraftViaNativePrefill } from './agent-native-draft-prefill' import { waitForAgentDraftInputReady } from './agent-draft-readiness' import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' +import { AGENT_PROMPT_SUBMIT_DELAY_MS } from '../../../shared/agent-prompt-injection' export { AGENT_DRAFT_PASTE_CHUNK_MAX_BYTES, AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES, @@ -32,7 +33,10 @@ export { // line-edit shortcuts. Callers choose whether to append Enter after the paste. export const BRACKETED_PASTE_BEGIN = BRACKETED_PASTE_START export { BRACKETED_PASTE_END } -export const POST_PASTE_SUBMIT_DELAY_MS = 50 +// Why: Codex/Claude need a render turn after bracketed-paste end before Enter +// is accepted as submit (not paste content). Keep the draft-submit path aligned +// with the shared agent-prompt-injection gap (≥500ms). +export const POST_PASTE_SUBMIT_DELAY_MS = AGENT_PROMPT_SUBMIT_DELAY_MS export function sanitizeBracketedPasteContent(content: string): string { return sanitizeTerminalPasteText(content) diff --git a/src/renderer/src/lib/agent-startup-delayed-delivery.ts b/src/renderer/src/lib/agent-startup-delayed-delivery.ts index 5ca13944b32..26b2d3d7298 100644 --- a/src/renderer/src/lib/agent-startup-delayed-delivery.ts +++ b/src/renderer/src/lib/agent-startup-delayed-delivery.ts @@ -16,7 +16,7 @@ type PendingAgentStartupDelivery = { tabId: string launchToken: string startup: AgentStartupPlan - deliver: (tabId: string, ptyId: string, startup: AgentStartupPlan) => Promise + deliver: (tabId: string, ptyId: string, startup: AgentStartupPlan) => Promise } const pendingAgentStartupDeliveries = new Map() @@ -172,11 +172,20 @@ function flushPendingAgentStartupDeliveries(): void { } // Why: once the launch-bound PTY exists, the bounded readiness/paste path // owns success or failure. Consume before awaiting so store churn cannot - // duplicate a linked-work-item draft. + // duplicate a linked-work-item draft; release on failed paste so Codex + // (and other cautious agents) can retry when readiness was premature. if (beginAgentStartupDeliveryAttempt(delivery)) { - void delivery.deliver(tabId, ptyId, delivery.startup).catch((error) => { - console.warn('Queued agent startup delivery failed', error) - }) + void delivery + .deliver(tabId, ptyId, delivery.startup) + .then((delivered) => { + if (delivered === false) { + releaseAgentStartupDeliveryAttempt(delivery) + } + }) + .catch((error) => { + releaseAgentStartupDeliveryAttempt(delivery) + console.warn('Queued agent startup delivery failed', error) + }) } } stopPendingAgentStartupSubscriptionIfIdle() diff --git a/src/renderer/src/lib/agent-status.test.ts b/src/renderer/src/lib/agent-status.test.ts index 820741031d7..428ee9dc277 100644 --- a/src/renderer/src/lib/agent-status.test.ts +++ b/src/renderer/src/lib/agent-status.test.ts @@ -84,6 +84,21 @@ describe('detectAgentStatusFromTitle', () => { expect(detectAgentStatusFromTitle('Claude Code - action required')).toBe('permission') }) + // Why: default Codex OSC titles omit the app name. Permission frames use a + // fixed `[ ! ] Action Required` / `[ . ] Action Required` prefix that must + // classify as permission without a "codex" token (process ownership supplies + // identity). Braille working frames still work via the spinner path. + it('detects Codex native Action Required titles without a codex token', () => { + expect(detectAgentStatusFromTitle('[ ! ] Action Required | my-project')).toBe('permission') + expect(detectAgentStatusFromTitle('[ . ] Action Required | my-project')).toBe('permission') + expect(detectAgentStatusFromTitle('[!] Action Required | project')).toBe('permission') + expect(detectAgentStatusFromTitle('Action Required | project')).toBeNull() + }) + + it('still classifies Codex braille working titles without a codex token', () => { + expect(detectAgentStatusFromTitle('⠋ my-project')).toBe('working') + }) + it('detects "permission" keyword with agent name', () => { expect(detectAgentStatusFromTitle('codex - permission needed')).toBe('permission') }) diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 2a089f8411c..39a30da1b97 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -524,12 +524,14 @@ describe('launchAgentBackgroundSession', () => { } }) - it('waits for shell-ready for SSH background Codex native prefill commands without a hint', async () => { + it('keeps empty Codex override launches on the fast SSH path (no mythical --prefill)', async () => { + // Why: Codex has no --prefill flag. Empty-prompt launches use the override + // command as-is without shell-ready unless the startup plan opts in. vi.useFakeTimers() try { state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] state.settings = { - agentCmdOverrides: { codex: "codex --prefill 'draft from override'" }, + agentCmdOverrides: { codex: 'codex --model gpt-5' }, activeRuntimeEnvironmentId: null } const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') @@ -542,22 +544,17 @@ describe('launchAgentBackgroundSession', () => { expect(mockSpawn.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ - command: - "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'" + command: "codex --model gpt-5 '--dangerously-bypass-approvals-and-sandbox'" }) ) expect(mockSpawn.mock.calls[0]?.[0]).not.toHaveProperty('startupCommandDelivery') const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void dataSidecar('user@remote repo % ') vi.advanceTimersByTime(50) - expect(mockWrite).not.toHaveBeenCalled() - - dataSidecar('\x1b]777;orca-shell-ready\x07user@remote repo % ') - vi.advanceTimersByTime(50) expect(mockWrite).toHaveBeenCalledWith( 'pty-1', - "codex --prefill 'draft from override' '--dangerously-bypass-approvals-and-sandbox'\r" + "codex --model gpt-5 '--dangerously-bypass-approvals-and-sandbox'\r" ) } finally { vi.useRealTimers() diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index 149c0e7708f..0b4130ecda9 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -10,6 +10,7 @@ import { beginAgentStartupDeliveryAttempt, getAgentStartupTabPtyId, queuePendingAgentStartupDelivery, + releaseAgentStartupDeliveryAttempt, resolveAgentStartupTabId } from '@/lib/agent-startup-delayed-delivery' import type { FolderWorkspaceLinkedTask, OrcaHooks, TaskViewPresetId } from '../../../shared/types' @@ -287,7 +288,13 @@ export async function ensureAgentStartupInTerminal(args: { } if (beginAgentStartupDeliveryAttempt({ worktreeId, tabId, launchToken })) { - await deliverAgentStartupToTerminal(tabId, ptyId, startup) + const delivered = await deliverAgentStartupToTerminal(tabId, ptyId, startup) + if (!delivered) { + // Why: Codex readiness can fire too early (hooks-review/onboarding) and + // drop the paste. Release the consume guard so a later mount/retry can + // re-attempt instead of treating a failed paste as final delivery. + releaseAgentStartupDeliveryAttempt({ worktreeId, tabId, launchToken }) + } } } @@ -295,35 +302,39 @@ async function deliverAgentStartupToTerminal( tabId: string, ptyId: string, startup: AgentStartupPlan -): Promise { +): Promise { const draftPrompt = startup.draftPrompt ?? null const runtimeSettings = getSettingsForAgentTabRuntimeOwner(tabId) + let delivered = true // Why: followupPrompt is the legacy path for stdin-after-start agents // (aider, goose, etc.) that need their initial prompt typed into the live // session and submitted. Wait until the agent owns the PTY before writing. if (startup.followupPrompt) { - await sendFollowupPromptWhenAgentReady({ - ptyId, - expectedProcess: startup.expectedProcess, - prompt: startup.followupPrompt, - settings: runtimeSettings - }) + delivered = + (await sendFollowupPromptWhenAgentReady({ + ptyId, + expectedProcess: startup.expectedProcess, + prompt: startup.followupPrompt, + settings: runtimeSettings + })) && delivered } // Why: draftPrompt uses bracketed-paste so the URL lands atomically in the // agent's input buffer (no per-char echo, no auto-submit). Shared with the // launch-work-item-direct flow so both behave identically. if (draftPrompt) { - await pasteDraftToAgentPtyWhenReady({ - tabId, - ptyId, - content: draftPrompt, - agent: startup.agent, - // Why: startup.draftPrompt is only attached after native draft launch - // planning is unavailable, so this paste is the first delivery attempt. - forcePaste: true - }) + delivered = + (await pasteDraftToAgentPtyWhenReady({ + tabId, + ptyId, + content: draftPrompt, + agent: startup.agent, + // Why: startup.draftPrompt is only attached after native draft launch + // planning is unavailable, so this paste is the first delivery attempt. + forcePaste: true + })) && delivered } + return delivered } function ensureStartupLaunchToken(startup: AgentStartupPlan): string { diff --git a/src/renderer/src/lib/tui-agent-startup.test.ts b/src/renderer/src/lib/tui-agent-startup.test.ts index 1feb1e44c7a..0dc828a5309 100644 --- a/src/renderer/src/lib/tui-agent-startup.test.ts +++ b/src/renderer/src/lib/tui-agent-startup.test.ts @@ -233,6 +233,43 @@ describe('buildAgentStartupPlan', () => { ).toBeNull() }) + it('launches Codex with shell-ready positional PROMPT (no mythical --prefill)', () => { + expect( + buildAgentStartupPlan({ + agent: 'codex', + prompt: 'Fix the bug', + cmdOverrides: {}, + platform: 'darwin' + }) + ).toEqual({ + agent: 'codex', + launchCommand: "codex 'Fix the bug'", + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: emptyLaunchConfig('codex'), + startupCommandDelivery: 'shell-ready' + }) + }) + + it('appends Codex -i image paths when provided', () => { + expect( + buildAgentStartupPlan({ + agent: 'codex', + prompt: 'look at this', + cmdOverrides: {}, + platform: 'darwin', + imagePaths: ['/tmp/shot.png'] + }) + ).toEqual({ + agent: 'codex', + launchCommand: "codex -i '/tmp/shot.png' 'look at this'", + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: emptyLaunchConfig('codex'), + startupCommandDelivery: 'shell-ready' + }) + }) + it('uses -i flag for copilot to start an interactive session with initial prompt', () => { expect( buildAgentStartupPlan({ diff --git a/src/shared/agent-detection.test.ts b/src/shared/agent-detection.test.ts index 28c18b4d16e..d28287de4f4 100644 --- a/src/shared/agent-detection.test.ts +++ b/src/shared/agent-detection.test.ts @@ -65,6 +65,22 @@ describe('OSC title extraction', () => { }) }) +describe('Codex native Action Required titles', () => { + it.each([ + ['[ ! ] Action Required | my-project', 'permission'], + ['[ . ] Action Required | my-project', 'permission'], + ['[!] Action Required', 'permission'], + ['⠋ my-project', 'working'] + ] as const)('classifies %s as %s without a codex token', (title, expectedStatus) => { + expect(detectAgentStatusFromTitle(title)).toBe(expectedStatus) + }) + + it('does not treat bare Action Required prose as permission', () => { + expect(detectAgentStatusFromTitle('Action Required | project')).toBeNull() + expect(detectAgentStatusFromTitle('please take action required now')).toBeNull() + }) +}) + describe('MiMo title detection', () => { it.each([ ['MiMo Code', 'idle'], diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index 90e72a93d90..03b37691ba2 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -26,8 +26,10 @@ export { } from './terminal-title-agent-type' export type { AgentStatus } from './terminal-title-status' export { + CODEX_NATIVE_ACTION_REQUIRED_TITLE_RE, createAgentStatusTracker, detectAgentStatusFromTitle, + isCodexNativeActionRequiredTitle, STRONG_IDLE_KEYWORDS_RE, STRONG_WORKING_KEYWORDS_RE } from './terminal-title-status' diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 3d4baeac3c4..c56376a1bb7 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -294,6 +294,209 @@ describe('shared agent-hook-listener', () => { ) }) + it('does not treat Codex SessionStart as working', () => { + const started = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'SessionStart', source: 'startup' } + }, + 'production' + ) + // Why: SessionStart fires when the TUI opens/resumes while still idle. + expect(started).toBeNull() + }) + + it('keeps Codex SubagentStart/SubagentStop as working and only root Stop as done', () => { + normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'review this PR' } + }, + 'production' + ) + const subStart = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'SubagentStart', + agent_id: 'agent-1', + agent_type: 'explorer' + } + }, + 'production' + ) + const subStop = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'SubagentStop', + agent_id: 'agent-1', + agent_type: 'explorer', + last_assistant_message: 'Found 3 call sites' + } + }, + 'production' + ) + const stop = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'Stop', + last_assistant_message: 'PR review complete' + } + }, + 'production' + ) + + expect(subStart?.payload).toMatchObject({ + state: 'working', + agentType: 'codex', + toolName: 'explorer', + prompt: 'review this PR' + }) + expect(subStart?.toolAgentId).toBe('agent-1') + expect(subStart?.toolAgentType).toBe('explorer') + expect(subStop?.payload).toMatchObject({ + state: 'working', + agentType: 'codex', + lastAssistantMessage: 'Found 3 call sites', + prompt: 'review this PR' + }) + expect(stop?.payload).toMatchObject({ + state: 'done', + agentType: 'codex', + lastAssistantMessage: 'PR review complete' + }) + }) + + it('promotes Codex PostToolUse tool_response into lastAssistantMessage', () => { + const event = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'ls' }, + tool_response: 'README.md\nsrc\n' + } + }, + 'production' + ) + expect(event?.payload).toMatchObject({ + state: 'working', + toolName: 'Bash', + toolInput: 'ls', + // Why: agent status normalizer trims trailing whitespace while keeping + // interior newlines for multi-line tool output. + lastAssistantMessage: 'README.md\nsrc' + }) + }) + + it('previews Codex apply_patch command and spawn_agent prompt tool inputs', () => { + const patch = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PreToolUse', + tool_name: 'apply_patch', + tool_input: { command: '*** Begin Patch\n*** Update File: a.ts\n*** End Patch' } + } + }, + 'production' + ) + const spawn = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PreToolUse', + tool_name: 'spawn_agent', + tool_input: { agent_type: 'explorer', prompt: 'find auth handlers' } + } + }, + 'production' + ) + expect(patch?.payload.toolInput).toContain('Begin Patch') + expect(spawn?.payload.toolInput).toBe('find auth handlers') + }) + + it('clears sticky Codex working when a tool event carries an interrupt marker', () => { + normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'run tests' } + }, + 'production' + ) + const interrupted = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'npm test' }, + is_interrupt: true + } + }, + 'production' + ) + expect(interrupted?.payload).toMatchObject({ + state: 'done', + agentType: 'codex', + interrupted: true, + prompt: 'run tests' + }) + }) + + it('marks Codex Stop with is_interrupt as interrupted done', () => { + normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'say hi' } + }, + 'production' + ) + const stop = normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'Stop', + is_interrupt: true, + last_assistant_message: 'partial' + } + }, + 'production' + ) + expect(stop?.payload).toMatchObject({ + state: 'done', + interrupted: true, + lastAssistantMessage: 'partial' + }) + }) + it('clears interactivePrompt on the next tool event after AskUserQuestion', () => { normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 1619e4bd7ec..4c952a1a30a 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -472,7 +472,7 @@ const TOOL_INPUT_KEYS_BY_TOOL: Record = { Execute: ['command'], MultiEdit: ['file_path', 'filePath', 'path'], NotebookEdit: ['file_path', 'filePath', 'path'], - Bash: ['command'], + Bash: ['command', 'cmd'], Glob: ['pattern'], Grep: ['pattern'], WebFetch: ['url'], @@ -493,7 +493,11 @@ const TOOL_INPUT_KEYS_BY_TOOL: Record = { shell_command: ['cmd', 'command'], run_terminal_cmd: ['command'], execute_code: ['code', 'command', 'cmd'], - apply_patch: ['path', 'file_path'], + // Why: Codex apply_patch sends the patch body in `command` (same field as + // Bash); path/file_path cover alternate file-edit shapes. + apply_patch: ['command', 'path', 'file_path'], + // Why: Codex spawn_agent previews the subagent task/type in the status row. + spawn_agent: ['prompt', 'agent_type', 'description', 'name'], view_image: ['path', 'file_path'], AskUser: ['question', 'prompt', 'message'], ask_user: ['question', 'prompt', 'message'], @@ -1268,7 +1272,7 @@ function extractCodexToolFields( deriveToolInputPreview(toolName, hookPayload.tool_input) ?? deriveToolInputPreview(toolName, hookPayload.input) ?? deriveToolInputPreview(toolName, hookPayload.arguments) - return toolUpdate( + const update = toolUpdate( { toolName, toolInput, @@ -1276,8 +1280,22 @@ function extractCodexToolFields( }, { hasToolInputField: hasAnyOwnField(hookPayload, ['tool_input', 'input', 'arguments']) } ) + if (eventName === 'PostToolUse') { + // Why: mirror Claude — Codex PostToolUse carries tool_response text that + // is the best live assistant preview until Stop's last_assistant_message. + const responseText = extractToolResponseText(hookPayload.tool_response) + if (responseText) { + update.lastAssistantMessage = responseText + } + } + return update } - if (eventName === 'Stop') { + if (eventName === 'SubagentStart') { + // Why: annotate the status row with the subagent type when the payload has it. + const agentType = readString(hookPayload, 'agent_type') + return agentType ? toolUpdate({ toolName: agentType }) : {} + } + if (eventName === 'SubagentStop' || eventName === 'Stop') { const message = readString(hookPayload, 'last_assistant_message') if (message) { return { lastAssistantMessage: message } @@ -2026,7 +2044,9 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { case 'kimi': return eventName === 'UserPromptSubmit' case 'codex': - return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' + // Why: SessionStart only resets idle session caches (see normalizeCodexEvent); + // UserPromptSubmit is the real new-turn boundary. + return eventName === 'UserPromptSubmit' case 'gemini': return eventName === 'BeforeAgent' case 'antigravity': @@ -2635,6 +2655,27 @@ function hasExplicitPromptForSource( return eventName === 'agent.start' && promptText.length > 0 } +function codexPayloadIndicatesTurnEnded(hookPayload: Record): boolean { + // Why: Codex has no StopFailure. Only clear sticky working on explicit + // interrupt/abort markers (not bare tool errors — those are mid-turn). + // Pane process death still clears via agent-hooks clearPaneState on PTY exit. + if (hookPayload['is_interrupt'] === true || hookPayload['interrupted'] === true) { + return true + } + const stopReason = readFirstString(hookPayload, ['stop_reason', 'stopReason']) + if (!stopReason) { + return false + } + const lower = stopReason.toLowerCase() + return ( + lower.includes('interrupt') || + lower.includes('abort') || + lower.includes('cancel') || + lower === 'error' || + lower.includes('failed') + ) +} + function normalizeCodexEvent( state: HookListenerState, eventName: unknown, @@ -2642,17 +2683,41 @@ function normalizeCodexEvent( paneKey: string, hookPayload: Record ): ParsedAgentStatusPayload | null { - const stateName = - eventName === 'SessionStart' || + if (eventName === 'SessionStart') { + // Why: Codex emits SessionStart on TUI open/resume/clear while still idle. + // Mapping it to working flashed "Codex - Running" before any user prompt. + clearPaneTurnCacheState(state, paneKey) + return null + } + + // Why: SubagentStop is not root Stop — the parent turn continues after the + // child finishes. Only root Stop (or an explicit interrupt/error marker) + // ends the visible working state. + let stateName: 'working' | 'waiting' | 'done' | null = null + if ( eventName === 'UserPromptSubmit' || eventName === 'PreToolUse' || - eventName === 'PostToolUse' - ? 'working' - : eventName === 'PermissionRequest' - ? 'waiting' - : eventName === 'Stop' - ? 'done' - : null + eventName === 'PostToolUse' || + eventName === 'SubagentStart' || + eventName === 'SubagentStop' + ) { + stateName = 'working' + } else if (eventName === 'PermissionRequest') { + stateName = 'waiting' + } else if (eventName === 'Stop') { + stateName = 'done' + } + + // Why: without StopFailure, recover sticky working when an already-received + // Codex event carries terminal error/interrupt fields (e.g. aborted tool turn). + if ( + stateName === 'working' && + eventName !== 'UserPromptSubmit' && + eventName !== 'SubagentStart' && + codexPayloadIndicatesTurnEnded(hookPayload) + ) { + stateName = 'done' + } if (!stateName) { return null @@ -2665,6 +2730,14 @@ function normalizeCodexEvent( { resetOnNewTurn: isNewTurnEvent('codex', eventName) } ) + const interrupted = + eventName === 'Stop' && + (hookPayload['is_interrupt'] === true || hookPayload['interrupted'] === true) + ? true + : stateName === 'done' && codexPayloadIndicatesTurnEnded(hookPayload) + ? true + : undefined + return parseAgentStatusPayload( JSON.stringify({ state: stateName, @@ -2675,7 +2748,8 @@ function normalizeCodexEvent( toolName: snapshot.toolName, toolInput: snapshot.toolInput, interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage + lastAssistantMessage: snapshot.lastAssistantMessage, + interrupted }) ) } diff --git a/src/shared/codex-startup-delivery.test.ts b/src/shared/codex-startup-delivery.test.ts index e7788f778fe..ce8e7a06134 100644 --- a/src/shared/codex-startup-delivery.test.ts +++ b/src/shared/codex-startup-delivery.test.ts @@ -1,28 +1,45 @@ import { describe, expect, it } from 'vitest' -import { hasCodexNativeDraftFlag } from './codex-startup-delivery' +import { shouldUseShellReadyStartupDelivery } from './codex-startup-delivery' -describe('hasCodexNativeDraftFlag', () => { - it('matches Codex --prefill option tokens', () => { - expect(hasCodexNativeDraftFlag("codex --prefill 'linked issue context'")).toBe(true) - expect(hasCodexNativeDraftFlag("codex --model gpt-5 --prefill 'draft'")).toBe(true) +describe('shouldUseShellReadyStartupDelivery', () => { + it('honors explicit shell-ready startup plans', () => { + expect( + shouldUseShellReadyStartupDelivery({ + command: "codex 'fix it'", + startupCommandDelivery: 'shell-ready' + }) + ).toBe(true) }) - it('matches Codex --prefill=value option tokens', () => { - expect(hasCodexNativeDraftFlag('codex --prefill=review')).toBe(true) - expect(hasCodexNativeDraftFlag("codex --prefill='linked issue context'")).toBe(true) + it('stays on the fast path without an explicit shell-ready hint', () => { + expect(shouldUseShellReadyStartupDelivery({ command: 'codex' })).toBe(false) + expect( + shouldUseShellReadyStartupDelivery({ + command: "codex 'please compare --prefill behavior'" + }) + ).toBe(false) }) - it('does not match quoted prompt text mentioning prefill', () => { - expect(hasCodexNativeDraftFlag("codex 'please compare --prefill behavior'")).toBe(false) - expect(hasCodexNativeDraftFlag("codex '--prefill=not-an-option'")).toBe(false) + it('does not treat mythical Codex --prefill tokens as native draft delivery', () => { + // Why: Codex has no --prefill flag. Old detection forced shell-ready for + // fake argv that would never be produced by buildAgentStartupPlan. + expect( + shouldUseShellReadyStartupDelivery({ + command: "codex --prefill 'linked issue context'" + }) + ).toBe(false) + expect( + shouldUseShellReadyStartupDelivery({ + command: 'codex --prefill=review' + }) + ).toBe(false) }) - it('does not match non-Codex commands', () => { - expect(hasCodexNativeDraftFlag("claude --prefill 'review this'")).toBe(false) - }) - - it('leaves plain Codex and normal Codex arguments on the fast path', () => { - expect(hasCodexNativeDraftFlag('codex')).toBe(false) - expect(hasCodexNativeDraftFlag('codex --model gpt-5')).toBe(false) + it('does not force shell-ready for Claude --prefill (native Claude path)', () => { + expect( + shouldUseShellReadyStartupDelivery({ + command: "claude --prefill 'review this'" + }) + ).toBe(false) }) }) diff --git a/src/shared/codex-startup-delivery.ts b/src/shared/codex-startup-delivery.ts index a45337dd0be..6404f887082 100644 --- a/src/shared/codex-startup-delivery.ts +++ b/src/shared/codex-startup-delivery.ts @@ -1,82 +1,14 @@ -import { recognizeAgentProcessFromCommandLine } from './agent-process-recognition' - export type StartupCommandDelivery = 'fast' | 'shell-ready' -type CommandToken = { - value: string - startsQuoted: boolean -} - -function tokenizeCommandWithQuoteMetadata(command: string): CommandToken[] { - const tokens: CommandToken[] = [] - let current = '' - let inToken = false - let startsQuoted = false - let quote: '"' | "'" | null = null - let escaped = false - - for (let index = 0; index < command.length; index += 1) { - const char = command[index] - if (escaped) { - current += char - escaped = false - continue - } - if (char === '\\' && quote !== "'") { - const next = command[index + 1] - if (next && (/\s/.test(next) || next === '"' || next === "'" || next === '\\')) { - escaped = true - inToken = true - continue - } - } - if ((char === '"' || char === "'") && quote === null) { - if (!inToken) { - startsQuoted = true - } - quote = char - inToken = true - continue - } - if (quote === char) { - quote = null - continue - } - if (/\s/.test(char) && quote === null) { - if (inToken) { - tokens.push({ value: current, startsQuoted }) - current = '' - inToken = false - startsQuoted = false - } - continue - } - current += char - inToken = true - } - - if (inToken) { - tokens.push({ value: current, startsQuoted }) - } - return tokens -} - -export function hasCodexNativeDraftFlag(command: string | null | undefined): boolean { - if (recognizeAgentProcessFromCommandLine(command)?.agent !== 'codex' || !command) { - return false - } - const tokens = tokenizeCommandWithQuoteMetadata(command) - return tokens.some( - (token, index) => - index > 0 && - !token.startsQuoted && - (token.value === '--prefill' || token.value.startsWith('--prefill=')) - ) -} - +/** + * Shell-ready delivery is opted in explicitly by the startup plan (e.g. Codex + * positional PROMPT on argv). Codex has no native draft-prefill flag — earlier + * `--prefill` detection was a myth and must not force shell-ready. + */ export function shouldUseShellReadyStartupDelivery(args: { - command: string | null | undefined + command?: string | null | undefined startupCommandDelivery?: StartupCommandDelivery }): boolean { - return args.startupCommandDelivery === 'shell-ready' || hasCodexNativeDraftFlag(args.command) + void args.command + return args.startupCommandDelivery === 'shell-ready' } diff --git a/src/shared/draft-paste-ready-scanner.test.ts b/src/shared/draft-paste-ready-scanner.test.ts index 3af02f06795..dd11e108d94 100644 --- a/src/shared/draft-paste-ready-scanner.test.ts +++ b/src/shared/draft-paste-ready-scanner.test.ts @@ -4,7 +4,9 @@ import { createDraftPasteReadyScanner } from './draft-paste-ready-scanner' const DECSET_BRACKETED_PASTE = '\x1b[?2004h' const SHOW_CURSOR = '\x1b[?25h' const HIDE_CURSOR = '\x1b[?25l' -const CODEX_PROMPT = '\x1b[1m›\x1b[0m Ask Codex to do anything' +const CODEX_GLYPH = '›' +const CODEX_PLACEHOLDER = 'Ask Codex to do anything' +const CODEX_PROMPT = `\x1b[1m${CODEX_GLYPH}\x1b[0m ${CODEX_PLACEHOLDER}` describe('createDraftPasteReadyScanner', () => { describe('render-cursor-after-bracketed-paste (opencode / mimo-code)', () => { @@ -92,21 +94,37 @@ describe('createDraftPasteReadyScanner', () => { }) }) - describe('codex-composer-prompt (unchanged behavior)', () => { - it('is ready on the composer glyph after bracketed paste and never arms the quiet timer', () => { + describe('codex-composer-prompt (multi-signal gate)', () => { + it('is ready only when glyph and idle placeholder both render after bracketed paste', () => { const scanner = createDraftPasteReadyScanner('codex-composer-prompt') expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({ ready: false, armQuietTimer: false }) - expect(scanner.observe(CODEX_PROMPT)).toEqual({ ready: true, armQuietTimer: false }) + // Why: bare `›` during hooks-review/onboarding must not unlock paste. + expect(scanner.observe(CODEX_GLYPH)).toEqual({ ready: false, armQuietTimer: false }) + expect(scanner.observe(CODEX_PLACEHOLDER)).toEqual({ ready: true, armQuietTimer: false }) }) - it('detects the composer glyph inside a large first render chunk', () => { + it('detects the full idle composer prompt inside a large first render chunk', () => { const scanner = createDraftPasteReadyScanner('codex-composer-prompt') - expect(scanner.observe(`${DECSET_BRACKETED_PASTE}${CODEX_PROMPT}${'x'.repeat(900)}`)).toEqual( - { ready: true, armQuietTimer: false } - ) + expect(scanner.observe(`${DECSET_BRACKETED_PASTE}${CODEX_PROMPT}${'x'.repeat(900)}`)).toEqual({ + ready: true, + armQuietTimer: false + }) + }) + + it('does not fire on glyph alone or placeholder alone', () => { + const glyphOnly = createDraftPasteReadyScanner('codex-composer-prompt') + glyphOnly.observe(DECSET_BRACKETED_PASTE) + expect(glyphOnly.observe(CODEX_GLYPH)).toEqual({ ready: false, armQuietTimer: false }) + + const placeholderOnly = createDraftPasteReadyScanner('codex-composer-prompt') + placeholderOnly.observe(DECSET_BRACKETED_PASTE) + expect(placeholderOnly.observe(CODEX_PLACEHOLDER)).toEqual({ + ready: false, + armQuietTimer: false + }) }) it('never arms the quiet-window fallback', () => { diff --git a/src/shared/draft-paste-ready-scanner.ts b/src/shared/draft-paste-ready-scanner.ts index da491dc2d93..f0f7418c79f 100644 --- a/src/shared/draft-paste-ready-scanner.ts +++ b/src/shared/draft-paste-ready-scanner.ts @@ -4,7 +4,11 @@ import type { DraftPasteReadySignal } from './tui-agent-config' // actually mounted/focused. These markers let the scanner detect the real // "input is ready" moment per agent instead of guessing from output silence. const DECSET_BRACKETED_PASTE = '\x1b[?2004h' -const CODEX_COMPOSER_PROMPT = '›' +// Why: bare `›` alone is too weak — Codex can emit it during hooks-review / +// onboarding frames before the real idle composer is focused. Require the +// idle placeholder text chat_composer.rs paints with the prompt glyph. +const CODEX_COMPOSER_GLYPH = '›' +const CODEX_COMPOSER_PLACEHOLDER = 'Ask Codex' // Why: opencode emits the DECTCEM show-cursor only once the composer row is // mounted and the text cursor is placed in it — a "composer ready" signal, // analogous to Codex's prompt glyph. It fires ~2s after bracketed paste is @@ -28,8 +32,9 @@ export type DraftPasteReadyScanResult = { * and return types differ. * * Per agent signal: - * - `codex-composer-prompt`: ready when the `›` glyph renders after DECSET - * 2004; never arms the quiet window (`armQuietTimer` stays false). + * - `codex-composer-prompt`: ready when both the `›` glyph and the idle + * "Ask Codex" placeholder render after DECSET 2004; never arms the quiet + * window (`armQuietTimer` stays false). * - `render-cursor-after-bracketed-paste`: ready when DECTCEM show-cursor * (`\x1b[?25h`) renders after DECSET 2004. Like Codex it does NOT arm the * quiet window: opencode stays silent for ~1.5-2s between enabling @@ -49,13 +54,23 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) let recent = '' let postHandshakeRecent = '' let saw2004 = false + let sawCodexGlyph = false + let sawCodexPlaceholder = false - const signalMarker = - readySignal === 'codex-composer-prompt' - ? CODEX_COMPOSER_PROMPT - : readySignal === 'render-cursor-after-bracketed-paste' - ? DECTCEM_SHOW_CURSOR - : null + const usesCursorMarker = readySignal === 'render-cursor-after-bracketed-paste' + const usesCodexComposer = readySignal === 'codex-composer-prompt' + const usesMarker = usesCursorMarker || usesCodexComposer + + const codexComposerReady = (): boolean => sawCodexGlyph && sawCodexPlaceholder + + const observeCodexMarkers = (chunk: string): void => { + if (!sawCodexGlyph && chunk.includes(CODEX_COMPOSER_GLYPH)) { + sawCodexGlyph = true + } + if (!sawCodexPlaceholder && chunk.includes(CODEX_COMPOSER_PLACEHOLDER)) { + sawCodexPlaceholder = true + } + } return { observe(data: string): DraftPasteReadyScanResult { @@ -68,27 +83,40 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) } saw2004 = true const postHandshakeChunk = combined.slice(markerIndex + DECSET_BRACKETED_PASTE.length) - if (signalMarker !== null && postHandshakeChunk.includes(signalMarker)) { + if (usesCodexComposer) { + observeCodexMarkers(postHandshakeChunk) + if (codexComposerReady()) { + return { ready: true, armQuietTimer: false } + } + } else if (usesCursorMarker && postHandshakeChunk.includes(DECTCEM_SHOW_CURSOR)) { return { ready: true, armQuietTimer: false } } postHandshakeRecent = postHandshakeChunk.slice(-512) } else { - if ( - signalMarker !== null && - (data.includes(signalMarker) || (postHandshakeRecent + data).includes(signalMarker)) + if (usesCodexComposer) { + observeCodexMarkers(data) + observeCodexMarkers(postHandshakeRecent + data) + if (codexComposerReady()) { + return { ready: true, armQuietTimer: false } + } + } else if ( + usesCursorMarker && + (data.includes(DECTCEM_SHOW_CURSOR) || + (postHandshakeRecent + data).includes(DECTCEM_SHOW_CURSOR)) ) { return { ready: true, armQuietTimer: false } } postHandshakeRecent = (postHandshakeRecent + data).slice(-512) } - // Why: marker-based signals (Codex glyph, opencode show-cursor) must NOT - // arm the quiet window. opencode goes silent for ~1.5-2s between enabling - // bracketed paste and mounting its composer, so a quiet window would fire - // during that gap — before the composer exists — and pre-empt the marker. - // These signals wait for their marker, bounded only by the caller's hard - // timeout (and the caller's best-effort process-ownership paste after it). - // Only the default signal, which has no marker, uses the quiet window. - return { ready: false, armQuietTimer: signalMarker === null && saw2004 } + // Why: marker-based signals (Codex glyph+placeholder, opencode show-cursor) + // must NOT arm the quiet window. opencode goes silent for ~1.5-2s between + // enabling bracketed paste and mounting its composer, so a quiet window + // would fire during that gap — before the composer exists — and pre-empt + // the marker. These signals wait for their marker, bounded only by the + // caller's hard timeout (and the caller's best-effort process-ownership + // paste after it). Only the default signal, which has no marker, uses the + // quiet window. + return { ready: false, armQuietTimer: !usesMarker && saw2004 } } } } diff --git a/src/shared/native-chat-slash-commands.test.ts b/src/shared/native-chat-slash-commands.test.ts index bd9f7984551..647f8255cb8 100644 --- a/src/shared/native-chat-slash-commands.test.ts +++ b/src/shared/native-chat-slash-commands.test.ts @@ -15,6 +15,31 @@ describe('getAgentSlashCommands', () => { expect(names).toContain('diff') }) + it('includes recently added Codex TUI commands and aliases', () => { + const names = getAgentSlashCommands('codex').map((c) => c.name) + for (const expected of [ + 'setup-default-sandbox', + 'sandbox-add-read-dir', + 'btw', + 'debug-config', + 'apps', + 'quit', + 'pet', + 'clean' + ]) { + expect(names).toContain(expected) + } + }) + + it('keeps Codex slash order roughly aligned with TUI presentation order', () => { + const names = getAgentSlashCommands('codex').map((c) => c.name) + expect(names.indexOf('vim')).toBeLessThan(names.indexOf('setup-default-sandbox')) + expect(names.indexOf('side')).toBeLessThan(names.indexOf('btw')) + expect(names.indexOf('usage')).toBeLessThan(names.indexOf('debug-config')) + 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..993b313b310 100644 --- a/src/shared/native-chat-slash-commands.ts +++ b/src/shared/native-chat-slash-commands.ts @@ -30,12 +30,21 @@ const CLAUDE_COMMANDS: readonly SlashCommandSuggestion[] = [ { name: 'help', description: 'Show available commands' } ] +// Why: order mirrors Codex SlashCommand enum presentation order (do not +// alpha-sort). Keep in sync with codex-rs/tui/src/slash_command.rs. 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 elevated agent sandbox' }, + // Why: Windows-only in Codex is_visible; always listed so non-Windows users + // can still discover/type it (TUI rejects when unsupported). + { + name: 'sandbox-add-read-dir', + description: 'Let sandbox read a directory (Windows; /sandbox-add-read-dir )' + }, { name: 'experimental', description: 'Toggle experimental features' }, { name: 'approve', description: 'Approve one auto-review retry' }, { name: 'memories', description: 'Configure memory use' }, @@ -56,23 +65,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/rate-limit-types.ts b/src/shared/rate-limit-types.ts index d19699dee01..715e8aafbbc 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -53,7 +53,7 @@ export type ProviderRateLimits = { fableWeekly?: RateLimitWindow | null /** 30-day monthly window (OpenCode Go only), null if not available. */ monthly?: RateLimitWindow | null - /** Named per-model buckets (Gemini only). */ + /** Named extra windows (Gemini models; Codex multi-limit `rateLimitsByLimitId`). */ buckets?: RateLimitBucket[] /** Available earned Codex rate-limit reset credits, if reported. */ rateLimitResetCredits?: { diff --git a/src/shared/terminal-title-status.ts b/src/shared/terminal-title-status.ts index 5d8cd0a45f2..d0da7e9545c 100644 --- a/src/shared/terminal-title-status.ts +++ b/src/shared/terminal-title-status.ts @@ -135,6 +135,18 @@ export function createAgentStatusTracker( // stomp the synthesized state back to idle. const CURSOR_NATIVE_TITLE_LOWER = 'cursor agent' +// Why: default Codex OSC titles omit the app name (activity + project only). +// Permission frames use a fixed bracket prefix, e.g. +// `[ ! ] Action Required | project` (and the blink variant `[ . ]`). +// Match this Codex-native form without requiring a "codex" token so +// title-only status paths (and process-owned codex sessions) still map to +// waiting/permission. See TERMINAL_TITLE_ACTION_REQUIRED_PREFIX in Codex TUI. +export const CODEX_NATIVE_ACTION_REQUIRED_TITLE_RE = /\[\s*[!.]\s*\]\s*Action Required/i + +export function isCodexNativeActionRequiredTitle(title: string): boolean { + return CODEX_NATIVE_ACTION_REQUIRED_TITLE_RE.test(title) +} + export function detectAgentStatusFromTitle(title: string): AgentStatus | null { if (!title) { return null @@ -150,6 +162,12 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null { return null } + // Why: Codex default permission titles have no agent-name token; classify + // the fixed Action Required prefix before agent-name-gated branches below. + if (isCodexNativeActionRequiredTitle(title)) { + return 'permission' + } + // Gemini CLI symbols are the most specific and should take precedence. if (title.includes(GEMINI_PERMISSION)) { return 'permission' diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index fc06ac4912b..2ddec446770 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -57,9 +57,9 @@ export type TuiAgentConfig = { preflightTrust?: 'cursor' | 'copilot' | 'codex' /** Why: most TUIs need both bracketed-paste enablement and a quiet render * window before pasted bytes reliably land in the composer. Codex can use - * a stronger signal from its own renderer: chat_composer.rs writes the - * `›` prompt only when the composer row exists, so Orca can paste as soon - * as that prompt appears after bracketed paste is enabled. */ + * a stronger multi-signal gate from its own renderer: after bracketed paste + * is enabled, wait for both the `›` glyph and the idle "Ask Codex" + * placeholder (bare `›` alone can fire during hooks-review/onboarding). */ draftPasteReadySignal?: DraftPasteReadySignal } diff --git a/src/shared/tui-agent-draft-launch.ts b/src/shared/tui-agent-draft-launch.ts new file mode 100644 index 00000000000..d20d9f62bbd --- /dev/null +++ b/src/shared/tui-agent-draft-launch.ts @@ -0,0 +1,117 @@ +import type { SleepingAgentLaunchConfig } from './agent-session-resume' +import type { StartupCommandDelivery } from './codex-startup-delivery' +import type { TuiAgent } from './types' +import { TUI_AGENT_CONFIG } from './tui-agent-config' +import { inlineCommandFitsPlatform } from './tui-agent-startup-codex-launch' +import { + clearEnvCommand, + commandSeparator, + planAgentCliArgsSuffix, + quoteStartupArg, + resolveStartupShell, + type AgentStartupShell +} from './tui-agent-startup-shell' +import { getTuiAgentLaunchCommand } from './tui-agent-config' + +export type AgentDraftLaunchPlan = { + agent: TuiAgent + launchCommand: string + expectedProcess: string + launchConfig: SleepingAgentLaunchConfig + env?: Record + startupCommandDelivery?: StartupCommandDelivery +} + +function resolveBaseCommand(args: { + agent: TuiAgent + cmdOverrides: Partial> + platform: NodeJS.Platform + shell: AgentStartupShell + agentArgs?: string | null + isRemote?: boolean +}): { ok: true; command: string } | { ok: false; error: string } { + const override = args.cmdOverrides[args.agent] + const command = + override || + getTuiAgentLaunchCommand(TUI_AGENT_CONFIG[args.agent], args.platform, { + isRemote: args.isRemote + }) + const suffix = planAgentCliArgsSuffix(args.agentArgs, args.shell) + if (!suffix.ok) { + return suffix + } + // Why: Codex status hooks live in Orca's runtime CODEX_HOME; adding + // --profile-v2 makes Codex load a second hook representation and warn. + return { ok: true, command: suffix.suffix ? `${command} ${suffix.suffix}` : command } +} + +function buildSleepingAgentLaunchConfig(args: { + agentCommand?: string | null + agentArgs?: string | null + agentEnv?: Record | null +}): SleepingAgentLaunchConfig { + return { + ...(args.agentCommand?.trim() ? { agentCommand: args.agentCommand } : {}), + agentArgs: args.agentArgs ?? '', + agentEnv: args.agentEnv ? { ...args.agentEnv } : {} + } +} + +export function buildAgentDraftLaunchPlan(args: { + agent: TuiAgent + draft: string + cmdOverrides: Partial> + platform: NodeJS.Platform + shell?: AgentStartupShell + agentArgs?: string | null + agentEnv?: Record | null + /** Why: see buildAgentStartupPlan — remote launches use the plain `orca` shim. */ + isRemote?: boolean +}): AgentDraftLaunchPlan | null { + const { agent, draft, cmdOverrides, platform } = args + const shell = resolveStartupShell(platform, args.shell) + const config = TUI_AGENT_CONFIG[agent] + const trimmed = draft.trim() + if (!trimmed) { + return null + } + const baseCommand = resolveBaseCommand({ + agent, + cmdOverrides, + platform, + shell, + agentArgs: args.agentArgs, + isRemote: args.isRemote + }) + if (!baseCommand.ok) { + return null + } + const launchConfig = buildSleepingAgentLaunchConfig({ + ...args, + agentCommand: baseCommand.command + }) + let plan: AgentDraftLaunchPlan | null = null + if (config.draftPromptFlag) { + const quoted = quoteStartupArg(trimmed, shell) + plan = { + agent, + launchCommand: `${baseCommand.command} ${config.draftPromptFlag} ${quoted}`, + expectedProcess: config.expectedProcess, + launchConfig, + ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) + } + } else if (config.draftPromptEnvVar) { + const clearVar = clearEnvCommand(config.draftPromptEnvVar, shell) + plan = { + agent, + launchCommand: `${baseCommand.command}${commandSeparator(shell)}${clearVar}`, + expectedProcess: config.expectedProcess, + launchConfig, + env: { ...args.agentEnv, [config.draftPromptEnvVar]: trimmed } + } + } + if (!plan || !inlineCommandFitsPlatform(plan.launchCommand, plan.env, platform)) { + return null + } + return plan +} diff --git a/src/shared/tui-agent-permissions.test.ts b/src/shared/tui-agent-permissions.test.ts index e1fa10e1993..e814de9bd6b 100644 --- a/src/shared/tui-agent-permissions.test.ts +++ b/src/shared/tui-agent-permissions.test.ts @@ -76,6 +76,26 @@ describe('tui agent permissions', () => { ).toBe('mixed') }) + it('treats Codex YOLO bypass plus extra flags as yolo', () => { + expect( + resolveTuiAgentPermissionMode({ + agent: 'codex', + agentArgs: `${YOLO_TUI_AGENT_ARGS.codex} --profile work`, + agentEnv: {} + }) + ).toBe('yolo') + }) + + it('does not treat a partial YOLO flag substring as yolo', () => { + expect( + resolveTuiAgentPermissionMode({ + agent: 'codex', + agentArgs: '--dangerously-bypass-approvals-and-sandbox-extra', + agentEnv: {} + }) + ).toBe('mixed') + }) + it('resolves env-driven yolo launches', () => { expect( resolveTuiAgentPermissionMode({ diff --git a/src/shared/tui-agent-permissions.ts b/src/shared/tui-agent-permissions.ts index 9833bfca193..54912132f8c 100644 --- a/src/shared/tui-agent-permissions.ts +++ b/src/shared/tui-agent-permissions.ts @@ -54,11 +54,39 @@ function sameEnv( return leftEntries.every(([name, value]) => right?.[name] === value) } +/** + * Why: launch configs often append profile/model flags alongside YOLO bypass. + * Exact string equality would misclassify those as `mixed` and re-enable + * permission noise. Treat the bypass flag as yolo when present as a whole + * argv token (or the full args string when the YOLO constant is multi-token). + */ +function argsContainYoloFlag(args: string, yoloArgs: string): boolean { + if (!yoloArgs) { + return false + } + if (args === yoloArgs) { + return true + } + const tokens = args.split(/\s+/).filter(Boolean) + const yoloTokens = yoloArgs.split(/\s+/).filter(Boolean) + if (yoloTokens.length === 1) { + return tokens.includes(yoloTokens[0]!) + } + // Multi-token YOLO constants (e.g. `--auto-approve true`) must appear as a + // contiguous token sequence so partial flag matches stay non-yolo. + for (let i = 0; i <= tokens.length - yoloTokens.length; i++) { + if (yoloTokens.every((token, offset) => tokens[i + offset] === token)) { + return true + } + } + return false +} + function resolveAgentPermissionMode(args: string, yoloArgs: string): AgentPermissionMode { if (!args) { return 'manual' } - return args === yoloArgs ? 'yolo' : 'mixed' + return argsContainYoloFlag(args, yoloArgs) ? 'yolo' : 'mixed' } function resolveAgentEnvPermissionMode( diff --git a/src/shared/tui-agent-startup-codex-launch.ts b/src/shared/tui-agent-startup-codex-launch.ts new file mode 100644 index 00000000000..96cce1fcf85 --- /dev/null +++ b/src/shared/tui-agent-startup-codex-launch.ts @@ -0,0 +1,55 @@ +import { + quoteStartupArg, + type AgentStartupShell +} from './tui-agent-startup-shell' + +// Why: Windows CreateProcess/env blocks have tight length ceilings. Large +// generated prompts/drafts should use the existing post-ready paste fallback. +export const WIN32_INLINE_DRAFT_LIMIT_CHARS = 24_000 + +export function normalizeStartupImagePaths( + imagePaths: readonly string[] | null | undefined +): string[] { + if (!imagePaths || imagePaths.length === 0) { + return [] + } + const seen = new Set() + const normalized: string[] = [] + for (const raw of imagePaths) { + const pathValue = raw.trim() + if (!pathValue || seen.has(pathValue)) { + continue + } + seen.add(pathValue) + normalized.push(pathValue) + } + return normalized +} + +/** Why: Codex accepts repeatable `-i/--image PATH` for startup image attachments. */ +export function appendCodexImageArgs( + command: string, + imagePaths: readonly string[], + shell: AgentStartupShell +): string { + if (imagePaths.length === 0) { + return command + } + const flags = imagePaths.map((pathValue) => `-i ${quoteStartupArg(pathValue, shell)}`).join(' ') + return `${command} ${flags}` +} + +export function inlineCommandFitsPlatform( + launchCommand: string, + env: Record | undefined, + platform: NodeJS.Platform +): boolean { + if (platform !== 'win32') { + return true + } + const envChars = Object.entries(env ?? {}).reduce( + (total, [key, value]) => total + key.length + value.length, + 0 + ) + return launchCommand.length + envChars <= WIN32_INLINE_DRAFT_LIMIT_CHARS +} diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 5aef452c833..8f7ae594df9 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -61,6 +61,41 @@ describe('tui agent startup plans', () => { expect(plan?.startupCommandDelivery).toBe('shell-ready') }) + it('wires repeatable Codex -i image args before the positional PROMPT', () => { + const plan = buildAgentStartupPlan({ + agent: 'codex', + prompt: 'describe these', + cmdOverrides: {}, + platform: 'linux', + imagePaths: ['/tmp/a.png', '/tmp/b with spaces.png', '/tmp/a.png', ' '] + }) + + expect(plan?.launchCommand).toBe( + "codex -i '/tmp/a.png' -i '/tmp/b with spaces.png' 'describe these'" + ) + expect(plan?.startupCommandDelivery).toBe('shell-ready') + expect(plan?.followupPrompt).toBeNull() + }) + + it('falls back to empty Codex launch + followup paste-submit when win32 argv is oversized', () => { + const hugePrompt = 'x'.repeat(25_000) + const plan = buildAgentStartupPlan({ + agent: 'codex', + prompt: hugePrompt, + cmdOverrides: {}, + platform: 'win32' + }) + + expect(plan).toEqual({ + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: hugePrompt, + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} } + }) + expect(plan?.startupCommandDelivery).toBeUndefined() + }) + it('keeps plain empty Codex startup on the fast delivery path', () => { const plan = buildAgentStartupPlan({ agent: 'codex', diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 30ca71132e5..f60a3169654 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -6,18 +6,23 @@ import { type SleepingAgentLaunchConfig } from './agent-session-resume' import { - clearEnvCommand, - commandSeparator, planAgentCliArgsSuffix, quoteStartupArg, resolveStartupShell, type AgentStartupShell } from './tui-agent-startup-shell' +import { + appendCodexImageArgs, + inlineCommandFitsPlatform, + normalizeStartupImagePaths +} from './tui-agent-startup-codex-launch' import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from './tui-agent-config' import type { StartupCommandDelivery } from './codex-startup-delivery' import type { TuiAgent } from './types' - -const WIN32_INLINE_DRAFT_LIMIT_CHARS = 24_000 +import { + buildAgentDraftLaunchPlan, + type AgentDraftLaunchPlan +} from './tui-agent-draft-launch' export type AgentStartupPlan = { agent: TuiAgent @@ -80,6 +85,11 @@ export function buildAgentStartupPlan(args: { /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must be skipped for remote launches. */ isRemote?: boolean + /** + * Optional image paths for agents that accept launch-time image flags. + * Today only Codex wires these as repeatable `-i PATH` args. + */ + imagePaths?: readonly string[] | null }): AgentStartupPlan | null { const { agent, prompt, cmdOverrides, platform, allowEmptyPromptLaunch = false } = args const shell = resolveStartupShell(platform, args.shell) @@ -96,10 +106,16 @@ export function buildAgentStartupPlan(args: { if (!baseCommand.ok) { return null } + const codexImagePaths = agent === 'codex' ? normalizeStartupImagePaths(args.imagePaths) : [] + const launchBaseCommand = + agent === 'codex' + ? appendCodexImageArgs(baseCommand.command, codexImagePaths, shell) + : baseCommand.command const launchConfig = buildSleepingAgentLaunchConfig({ ...args, agentCommand: baseCommand.command }) + const env = args.agentEnv ? { ...args.agentEnv } : undefined if (!trimmedPrompt) { if (!allowEmptyPromptLaunch) { @@ -107,68 +123,82 @@ export function buildAgentStartupPlan(args: { } return { agent, - launchCommand: baseCommand.command, + launchCommand: launchBaseCommand, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) + ...(env ? { env } : {}) } } const quotedPrompt = quoteStartupArg(trimmedPrompt, shell) if (config.promptInjectionMode === 'argv') { + const inlineLaunchCommand = `${launchBaseCommand} ${quotedPrompt}` + // Why: Windows argv/env budgets reject oversized CreateProcess command lines. + // Fall back to empty launch + post-ready paste-submit (followup) so the + // prompt still lands without truncating argv. + if (!inlineCommandFitsPlatform(inlineLaunchCommand, env, platform)) { + return { + agent, + launchCommand: launchBaseCommand, + expectedProcess: config.expectedProcess, + followupPrompt: trimmedPrompt, + launchConfig, + ...(env ? { env } : {}) + } + } return { agent, - launchCommand: `${baseCommand.command} ${quotedPrompt}`, + launchCommand: inlineLaunchCommand, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, ...(agent === 'codex' ? { startupCommandDelivery: 'shell-ready' as const } : {}), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) + ...(env ? { env } : {}) } } if (config.promptInjectionMode === 'flag-prompt') { return { agent, - launchCommand: `${baseCommand.command} --prompt ${quotedPrompt}`, + launchCommand: `${launchBaseCommand} --prompt ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) + ...(env ? { env } : {}) } } if (config.promptInjectionMode === 'flag-prompt-interactive') { return { agent, - launchCommand: `${baseCommand.command} --prompt-interactive ${quotedPrompt}`, + launchCommand: `${launchBaseCommand} --prompt-interactive ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) + ...(env ? { env } : {}) } } if (config.promptInjectionMode === 'flag-interactive') { return { agent, - launchCommand: `${baseCommand.command} -i ${quotedPrompt}`, + launchCommand: `${launchBaseCommand} -i ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) + ...(env ? { env } : {}) } } return { agent, - launchCommand: baseCommand.command, + launchCommand: launchBaseCommand, expectedProcess: config.expectedProcess, followupPrompt: trimmedPrompt, launchConfig, - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) + ...(env ? { env } : {}) } } @@ -223,91 +253,8 @@ export function buildAgentResumeStartupPlan(args: { } } -export type AgentDraftLaunchPlan = { - agent: TuiAgent - launchCommand: string - expectedProcess: string - launchConfig: SleepingAgentLaunchConfig - env?: Record - startupCommandDelivery?: StartupCommandDelivery -} - -function inlineDraftPlanFitsPlatform( - plan: AgentDraftLaunchPlan, - platform: NodeJS.Platform -): boolean { - if (platform !== 'win32') { - return true - } - const envChars = Object.entries(plan.env ?? {}).reduce( - (total, [key, value]) => total + key.length + value.length, - 0 - ) - // Why: Windows CreateProcess/env blocks have tight length ceilings. Large - // generated drafts should use the existing post-ready paste fallback. - return plan.launchCommand.length + envChars <= WIN32_INLINE_DRAFT_LIMIT_CHARS -} - -export function buildAgentDraftLaunchPlan(args: { - agent: TuiAgent - draft: string - cmdOverrides: Partial> - platform: NodeJS.Platform - shell?: AgentStartupShell - agentArgs?: string | null - agentEnv?: Record | null - /** Why: see buildAgentStartupPlan — remote launches use the plain `orca` shim. */ - isRemote?: boolean -}): AgentDraftLaunchPlan | null { - const { agent, draft, cmdOverrides, platform } = args - const shell = resolveStartupShell(platform, args.shell) - const config = TUI_AGENT_CONFIG[agent] - const trimmed = draft.trim() - if (!trimmed) { - return null - } - const baseCommand = resolveBaseCommand({ - agent, - cmdOverrides, - platform, - shell, - agentArgs: args.agentArgs, - isRemote: args.isRemote - }) - if (!baseCommand.ok) { - return null - } - const launchConfig = buildSleepingAgentLaunchConfig({ - ...args, - agentCommand: baseCommand.command - }) - let plan: AgentDraftLaunchPlan | null = null - if (config.draftPromptFlag) { - const quoted = quoteStartupArg(trimmed, shell) - plan = { - agent, - launchCommand: `${baseCommand.command} ${config.draftPromptFlag} ${quoted}`, - expectedProcess: config.expectedProcess, - launchConfig, - // Why: native draft flags carry user text on argv and must survive rc-file startup. - ...(agent === 'codex' ? { startupCommandDelivery: 'shell-ready' as const } : {}), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) - } - } else if (config.draftPromptEnvVar) { - const clearVar = clearEnvCommand(config.draftPromptEnvVar, shell) - plan = { - agent, - launchCommand: `${baseCommand.command}${commandSeparator(shell)}${clearVar}`, - expectedProcess: config.expectedProcess, - launchConfig, - env: { ...args.agentEnv, [config.draftPromptEnvVar]: trimmed } - } - } - if (!plan || !inlineDraftPlanFitsPlatform(plan, platform)) { - return null - } - return plan -} +export type { AgentDraftLaunchPlan } +export { buildAgentDraftLaunchPlan } export { isShellProcess } export { From ea73d75d6a7cdc8faf87a729d3052f8aec5338b2 Mon Sep 17 00:00:00 2001 From: BingZ Date: Fri, 10 Jul 2026 00:23:11 +0800 Subject: [PATCH 2/7] fix(codex): address CodeRabbit on WSL copy markers and launch limits - Write .orca-session-copies markers after WSL cp -p session bridge fallback - Ignore whitespace-only YOLO arg constants (avoid vacuous every()) - Use a tighter inline draft limit when Windows startup shell is cmd.exe --- src/main/codex/wsl-codex-session-bridge.test.ts | 1 + src/main/codex/wsl-codex-session-bridge.ts | 10 ++++++++++ src/shared/tui-agent-draft-launch.ts | 2 +- src/shared/tui-agent-permissions.ts | 5 +++++ src/shared/tui-agent-startup-codex-launch.ts | 10 ++++++++-- src/shared/tui-agent-startup.ts | 2 +- 6 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/main/codex/wsl-codex-session-bridge.test.ts b/src/main/codex/wsl-codex-session-bridge.test.ts index 4f1be6cdba1..17de4b4c2f1 100644 --- a/src/main/codex/wsl-codex-session-bridge.test.ts +++ b/src/main/codex/wsl-codex-session-bridge.test.ts @@ -156,6 +156,7 @@ describe('buildWslCodexSessionBridgeShellCommand', () => { expect(shellCommand).toContain(`-name '*.jsonl'`) expect(shellCommand).toContain(`-name '*.jsonl.zst'`) expect(shellCommand).toContain('cp -p --') + expect(shellCommand).toContain('.orca-session-copies') expect(shellCommand).not.toContain('.sqlite') }) diff --git a/src/main/codex/wsl-codex-session-bridge.ts b/src/main/codex/wsl-codex-session-bridge.ts index fd6a47d0f8d..af1cd9fce6c 100644 --- a/src/main/codex/wsl-codex-session-bridge.ts +++ b/src/main/codex/wsl-codex-session-bridge.ts @@ -109,6 +109,16 @@ export function buildWslCodexSessionBridgeShellCommand( ' linked_files=$((linked_files + 1))', ' elif cp -p -- "$source_file" "$target_file"; then', ' linked_files=$((linked_files + 1))', + // Why: hardlinks share an inode so usage de-dupes automatically; cp creates + // a second file the scanner would count twice unless we leave the same + // .orca-session-copies marker the Windows bridge writes after copyFileSync. + ' marker_file="$managed_sessions_root/../.orca-session-copies/${relative_path}.json"', + ' mkdir -p -- "$(dirname -- "$marker_file")" 2>/dev/null || true', + ' source_size=$(stat -c %s -- "$source_file" 2>/dev/null || printf 0)', + ' target_size=$(stat -c %s -- "$target_file" 2>/dev/null || printf 0)', + ' source_mtime_ms=$(($(stat -c %Y -- "$source_file" 2>/dev/null || printf 0) * 1000))', + ' target_mtime_ms=$(($(stat -c %Y -- "$target_file" 2>/dev/null || printf 0) * 1000))', + ` printf '{"sourcePath":"%s","sourceSize":%s,"sourceMtimeMs":%s,"targetSize":%s,"targetMtimeMs":%s}\\n' "$source_file" "$source_size" "$source_mtime_ms" "$target_size" "$target_mtime_ms" >"$marker_file" 2>/dev/null || true`, ' fi', `done < <(find "$source_sessions_root" -type f \\( -name '*.jsonl' -o -name '*.jsonl.zst' \\) -print0 2>/dev/null)`, `printf '{"scannedFiles":%s,"linkedFiles":%s}\\n' "$scanned_files" "$linked_files"` diff --git a/src/shared/tui-agent-draft-launch.ts b/src/shared/tui-agent-draft-launch.ts index d20d9f62bbd..d66232b4cd2 100644 --- a/src/shared/tui-agent-draft-launch.ts +++ b/src/shared/tui-agent-draft-launch.ts @@ -110,7 +110,7 @@ export function buildAgentDraftLaunchPlan(args: { env: { ...args.agentEnv, [config.draftPromptEnvVar]: trimmed } } } - if (!plan || !inlineCommandFitsPlatform(plan.launchCommand, plan.env, platform)) { + if (!plan || !inlineCommandFitsPlatform(plan.launchCommand, plan.env, platform, shell)) { return null } return plan diff --git a/src/shared/tui-agent-permissions.ts b/src/shared/tui-agent-permissions.ts index 54912132f8c..9813cb37244 100644 --- a/src/shared/tui-agent-permissions.ts +++ b/src/shared/tui-agent-permissions.ts @@ -69,6 +69,11 @@ function argsContainYoloFlag(args: string, yoloArgs: string): boolean { } const tokens = args.split(/\s+/).filter(Boolean) const yoloTokens = yoloArgs.split(/\s+/).filter(Boolean) + // Why: whitespace-only yoloArgs is truthy but yields zero tokens; [].every + // is vacuously true and would mark any non-empty args as YOLO. + if (yoloTokens.length === 0) { + return false + } if (yoloTokens.length === 1) { return tokens.includes(yoloTokens[0]!) } diff --git a/src/shared/tui-agent-startup-codex-launch.ts b/src/shared/tui-agent-startup-codex-launch.ts index 96cce1fcf85..8f3f959f8eb 100644 --- a/src/shared/tui-agent-startup-codex-launch.ts +++ b/src/shared/tui-agent-startup-codex-launch.ts @@ -6,6 +6,9 @@ import { // Why: Windows CreateProcess/env blocks have tight length ceilings. Large // generated prompts/drafts should use the existing post-ready paste fallback. export const WIN32_INLINE_DRAFT_LIMIT_CHARS = 24_000 +// Why: cmd.exe CreateProcess command-line max is 8191 chars; leave headroom for +// the shell wrapper when terminalWindowsShell resolves to cmd. +export const WIN32_CMD_INLINE_DRAFT_LIMIT_CHARS = 7_500 export function normalizeStartupImagePaths( imagePaths: readonly string[] | null | undefined @@ -42,7 +45,8 @@ export function appendCodexImageArgs( export function inlineCommandFitsPlatform( launchCommand: string, env: Record | undefined, - platform: NodeJS.Platform + platform: NodeJS.Platform, + shell?: AgentStartupShell ): boolean { if (platform !== 'win32') { return true @@ -51,5 +55,7 @@ export function inlineCommandFitsPlatform( (total, [key, value]) => total + key.length + value.length, 0 ) - return launchCommand.length + envChars <= WIN32_INLINE_DRAFT_LIMIT_CHARS + const limit = + shell === 'cmd' ? WIN32_CMD_INLINE_DRAFT_LIMIT_CHARS : WIN32_INLINE_DRAFT_LIMIT_CHARS + return launchCommand.length + envChars <= limit } diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index f60a3169654..2e404e3bd3e 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -138,7 +138,7 @@ export function buildAgentStartupPlan(args: { // Why: Windows argv/env budgets reject oversized CreateProcess command lines. // Fall back to empty launch + post-ready paste-submit (followup) so the // prompt still lands without truncating argv. - if (!inlineCommandFitsPlatform(inlineLaunchCommand, env, platform)) { + if (!inlineCommandFitsPlatform(inlineLaunchCommand, env, platform, shell)) { return { agent, launchCommand: launchBaseCommand, From b8c8da457557552c115a67c3d81e1033c6871671 Mon Sep 17 00:00:00 2001 From: BingZ Date: Fri, 10 Jul 2026 03:35:54 +0800 Subject: [PATCH 3/7] fix(codex): skip preferred rate-limit snapshot in buckets session/weekly already surface the preferred primary/secondary; including them in buckets duplicated the same meter in tooltip/status-bar UI. --- src/main/rate-limits/codex-fetcher.test.ts | 2 +- src/main/rate-limits/codex-fetcher.ts | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index 5ffc2aa6099..256c94ed5fa 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -429,8 +429,8 @@ describe('fetchCodexRateLimits', () => { weekly: { usedPercent: 20, windowMinutes: 10080 }, status: 'ok' }) + // Preferred codex meters stay on session/weekly only — buckets lists extras. expect(result.buckets).toEqual([ - expect.objectContaining({ name: 'Codex', usedPercent: 10, windowMinutes: 300 }), expect.objectContaining({ name: 'Codex other', usedPercent: 40, windowMinutes: 300 }), expect.objectContaining({ name: 'Codex other weekly', diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index c26f26598f2..6a2ecc2fb73 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -600,15 +600,16 @@ function mapRpcRateLimitsPayload(wrapper: RpcRateLimitsResponse | undefined): { const buckets: RateLimitBucket[] = [] for (const [id, snapshot] of entries) { + // Why: session/weekly already carry the preferred primary/secondary; putting + // them in buckets too duplicates the same meter in tooltip/status-bar UI. + if (id === preferredId) { + continue + } const name = limitSnapshotDisplayName(id, snapshot) const primary = mapRpcWindow(snapshot.primary, 300) if (primary) { buckets.push({ name, ...primary }) } - if (id === preferredId) { - // Preferred secondary is already exposed as weekly. - continue - } const secondary = mapRpcWindow(snapshot.secondary, 10080) if (secondary) { buckets.push({ name: `${name} weekly`, ...secondary }) From e35e353a075fe61d3c31cc60047c4a28d9019f26 Mon Sep 17 00:00:00 2001 From: BingZ Date: Fri, 10 Jul 2026 08:36:24 +0800 Subject: [PATCH 4/7] fix(codex): address CodeRabbit on config TOML, markers, and rate-limits - Recognize quoted cli_auth_credentials_store keys - Compare copy-marker mtimes at second precision (WSL stat) - Scan top-level TOML keys with structural line state - Route ChatGPT backends via URL host parsing, not path includes - Prefer wrapper.rateLimits over first by-id snapshot --- src/main/codex/codex-config-mirror.test.ts | 10 +++ src/main/codex/codex-config-mirror.ts | 7 +- src/main/codex/codex-session-copy-markers.ts | 7 +- src/main/rate-limits/codex-fetcher.test.ts | 7 ++ src/main/rate-limits/codex-fetcher.ts | 83 +++++++++++++++----- 5 files changed, 92 insertions(+), 22 deletions(-) diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index 47d810295bb..0137fd7251e 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -543,4 +543,14 @@ describe('forceFileAuthCredentialsStore', () => { const input = 'cli_auth_credentials_store = "file"\nmodel = "gpt"\n' expect(forceFileAuthCredentialsStore(input)).toBe(input) }) + + it('recognizes quoted TOML keys and does not duplicate the setting', () => { + const input = '"cli_auth_credentials_store" = "keyring"\nmodel = "gpt"\n' + expect(forceFileAuthCredentialsStore(input)).toBe( + 'cli_auth_credentials_store = "file"\nmodel = "gpt"\n' + ) + expect(forceFileAuthCredentialsStore(input).match(/cli_auth_credentials_store/g)).toHaveLength( + 1 + ) + }) }) diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 609d86d9fd4..aa9de8008c0 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -21,9 +21,12 @@ import { // Why: multi-account vault assumes auth.json; keyring/auto stores credentials // outside managed homes and breaks account switching / presence detection. const CLI_AUTH_CREDENTIALS_STORE_FILE_LINE = 'cli_auth_credentials_store = "file"' -const CLI_AUTH_CREDENTIALS_STORE_KEY_RE = /^[ \t]*cli_auth_credentials_store[ \t]*=/ +// Why: bare and quoted TOML keys are both valid; only matching bare keys left +// duplicates when the system config used "cli_auth_credentials_store". +const CLI_AUTH_CREDENTIALS_STORE_KEY_RE = + /^[ \t]*(?:"cli_auth_credentials_store"|'cli_auth_credentials_store'|cli_auth_credentials_store)[ \t]*=/ const CLI_AUTH_CREDENTIALS_STORE_FILE_RE = - /^[ \t]*cli_auth_credentials_store[ \t]*=[ \t]*(?:"file"|'file')[ \t\r]*(?:#.*)?$/ + /^[ \t]*(?:"cli_auth_credentials_store"|'cli_auth_credentials_store'|cli_auth_credentials_store)[ \t]*=[ \t]*(?:"file"|'file')[ \t\r]*(?:#.*)?$/ export function syncSystemConfigIntoManagedCodexHome( homes: CodexSettingsPromotionHomes = { diff --git a/src/main/codex/codex-session-copy-markers.ts b/src/main/codex/codex-session-copy-markers.ts index d197f29954d..7eeb1b7dada 100644 --- a/src/main/codex/codex-session-copy-markers.ts +++ b/src/main/codex/codex-session-copy-markers.ts @@ -50,7 +50,12 @@ export function fileStatsMatchMarker( ): boolean { const expectedSize = kind === 'source' ? marker.sourceSize : marker.targetSize const expectedMtimeMs = kind === 'source' ? marker.sourceMtimeMs : marker.targetMtimeMs - return stat.size === expectedSize && stat.mtimeMs === expectedMtimeMs + // Why: WSL `stat -c %Y` is second-precision (*1000); Node lstat is ms. Floor + // both sides so copy markers still match after cp -p on Linux. + return ( + stat.size === expectedSize && + Math.floor(stat.mtimeMs / 1000) === Math.floor(expectedMtimeMs / 1000) + ) } /** Removes the marker after a copy bridge has been migrated or retired. */ diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index 256c94ed5fa..96a535018a8 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -82,12 +82,19 @@ describe('normalizeCodexBackendBaseUrl', () => { expect(normalizeCodexBackendBaseUrl('https://chatgpt.com/')).toBe( 'https://chatgpt.com/backend-api' ) + expect(normalizeCodexBackendBaseUrl('https://ChatGPT.com/')).toBe( + 'https://chatgpt.com/backend-api' + ) expect(normalizeCodexBackendBaseUrl('https://api.example.com/v1/')).toBe( 'https://api.example.com/v1' ) expect(buildCodexRateLimitResetCreditsUrl('https://api.example.com')).toBe( 'https://api.example.com/api/codex/rate-limit-reset-credits' ) + // Custom bases that contain "/backend-api" must not use the WHAM route. + expect( + buildCodexRateLimitResetCreditsUrl('https://api.example.com/backend-api') + ).toBe('https://api.example.com/backend-api/api/codex/rate-limit-reset-credits') }) }) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 6a2ecc2fb73..c50cf303465 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -29,6 +29,12 @@ import { createAuthFilesystemOperation, type SharedAuthFilesystemOperation } from './auth-filesystem-operation' +import { + createTomlLineScanState, + getTomlTableHeader, + isTomlStructuralLine, + updateTomlLineScanState +} from '../codex/config-toml-line-scan' const RPC_TIMEOUT_MS = 10_000 const WSL_RPC_TIMEOUT_MS = 25_000 @@ -494,36 +500,69 @@ function mapRpcWindow( function parseTopLevelTomlStringKey(config: string, key: string): string | null { const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + // Why: quoted keys and values are valid TOML; bare-only matching misses them. const keyPattern = new RegExp( - `^[ \\t]*${escapedKey}[ \\t]*=[ \\t]*(?:"([^"]*)"|'([^']*)')` + `^[ \\t]*(?:"${escapedKey}"|'${escapedKey}'|${escapedKey})[ \\t]*=[ \\t]*(?:"([^"]*)"|'([^']*)')` ) + // Why: multiline strings can contain `key = "..."` or `[` text; only match + // structural root lines via the shared TOML line scanner. + let scanState = createTomlLineScanState() for (const line of config.split('\n')) { - if (/^[ \t]*\[/.test(line)) { - break - } - const match = keyPattern.exec(line) - if (match) { - return match[1] ?? match[2] ?? null + if (isTomlStructuralLine(scanState)) { + if (getTomlTableHeader(line)) { + break + } + const match = keyPattern.exec(line) + if (match) { + return match[1] ?? match[2] ?? null + } } + scanState = updateTomlLineScanState(scanState, line) } return null } +const OFFICIAL_CHATGPT_API_HOSTS = new Set([ + 'chatgpt.com', + 'www.chatgpt.com', + 'chat.openai.com', + 'www.chat.openai.com' +]) + +function isOfficialChatGptApiHost(hostname: string): boolean { + return OFFICIAL_CHATGPT_API_HOSTS.has(hostname.toLowerCase()) +} + +function pathHasBackendApiSuffix(pathname: string): boolean { + const normalized = pathname.replace(/\/+$/, '') || '/' + return normalized === '/backend-api' || normalized.endsWith('/backend-api') +} + // Why: Codex backend-client normalizes ChatGPT hostnames onto /backend-api so // WHAM paths resolve; custom bases use the /api/codex path style instead. export function normalizeCodexBackendBaseUrl(raw: string | null | undefined): string { - let base = (raw?.trim() || DEFAULT_CHATGPT_BACKEND_BASE_URL).replace(/\/+$/, '') - if ( - (base.startsWith('https://chatgpt.com') || base.startsWith('https://chat.openai.com')) && - !base.includes('/backend-api') - ) { - base = `${base}/backend-api` + const trimmed = (raw?.trim() || DEFAULT_CHATGPT_BACKEND_BASE_URL).replace(/\/+$/, '') + try { + const url = new URL(trimmed) + if (isOfficialChatGptApiHost(url.hostname) && !pathHasBackendApiSuffix(url.pathname)) { + const path = url.pathname.replace(/\/+$/, '') + return `${url.origin}${path === '' || path === '/' ? '' : path}/backend-api` + } + return `${url.origin}${url.pathname.replace(/\/+$/, '') || ''}`.replace(/\/+$/, '') || url.origin + } catch { + return trimmed } - return base } +// Why: only official ChatGPT hosts use the WHAM route; a custom base that +// happens to contain "/backend-api" in the path must stay on /api/codex. function isChatGptApiBaseUrl(baseUrl: string): boolean { - return baseUrl.includes('/backend-api') + try { + const url = new URL(baseUrl) + return isOfficialChatGptApiHost(url.hostname) && pathHasBackendApiSuffix(url.pathname) + } catch { + return false + } } export function buildCodexRateLimitResetCreditsUrl(baseUrl: string): string { @@ -554,18 +593,24 @@ async function resolveCodexBackendBaseUrl(codexHomePath?: string | null): Promis function preferredRpcRateLimitSnapshot( wrapper: RpcRateLimitsResponse | undefined ): { id: string | null; snapshot: RpcRateLimitSnapshot | undefined } { + // Why: rateLimits is the declared preferred meter; scanning by-id first can + // swap session/weekly onto a non-primary limit on multi-meter plans. + if (wrapper?.rateLimits) { + const limitId = wrapper.rateLimits.limitId?.trim() || null + return { id: limitId ?? 'codex', snapshot: wrapper.rateLimits } + } const byId = wrapper?.rateLimitsByLimitId + if (byId?.codex) { + return { id: 'codex', snapshot: byId.codex } + } if (byId) { - if (byId.codex) { - return { id: 'codex', snapshot: byId.codex } - } for (const [id, snapshot] of Object.entries(byId)) { if (snapshot) { return { id, snapshot } } } } - return { id: null, snapshot: wrapper?.rateLimits } + return { id: null, snapshot: undefined } } function limitSnapshotDisplayName(id: string, snapshot: RpcRateLimitSnapshot): string { From 5e321b478c7d2b04722a4be705dc48efe7e5e45d Mon Sep 17 00:00:00 2001 From: bbingz Date: Fri, 10 Jul 2026 09:24:06 +0800 Subject: [PATCH 5/7] fix(codex): refresh copy bridges, requeue prompts, rewrite profile overlays - Re-sync managed session copies when system source grows after hardlink fail - Re-queue startup prompt delivery after failed paste (not just clear guard) - Mirror profile-v2 *.config.toml with relative path rewrite into managed home - Infer additional rate-limit bucket windows from API remaining minutes --- src/main/codex/codex-config-mirror.test.ts | 26 +- .../codex-profile-v2-config-overlay-mirror.ts | 85 +++--- src/main/codex/codex-session-bridge-link.ts | 133 ++++++++++ src/main/codex/codex-session-bridge.test.ts | 20 ++ src/main/codex/codex-session-bridge.ts | 106 ++------ .../rate-limits/codex-fetcher-buckets.test.ts | 244 ++++++++++++++++++ src/main/rate-limits/codex-fetcher.test.ts | 120 +-------- src/main/rate-limits/codex-fetcher.ts | 43 ++- .../src/lib/agent-startup-delayed-delivery.ts | 22 +- 9 files changed, 541 insertions(+), 258 deletions(-) create mode 100644 src/main/codex/codex-session-bridge-link.ts create mode 100644 src/main/rate-limits/codex-fetcher-buckets.test.ts diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index 0137fd7251e..b590587904b 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -1,5 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + lstatSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import type * as NodeOs from 'node:os' import { join } from 'node:path' @@ -246,7 +254,7 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { ) }) - it('links free-standing profile-v2 *.config.toml overlays into the runtime home', () => { + it('mirrors free-standing profile-v2 *.config.toml overlays into the runtime home', () => { writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8') const systemOverlayPath = join(getSystemCodexHomePath(), 'work.config.toml') writeFileSync(systemOverlayPath, 'model = "work-profile"\n', 'utf-8') @@ -255,9 +263,23 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { const runtimeOverlayPath = join(userDataDir, 'codex-runtime-home', 'home', 'work.config.toml') expect(existsSync(runtimeOverlayPath)).toBe(true) + expect(lstatSync(runtimeOverlayPath).isSymbolicLink()).toBe(false) expect(readFileSync(runtimeOverlayPath, 'utf-8')).toBe('model = "work-profile"\n') }) + it('rewrites relative path keys in profile-v2 overlays against system CODEX_HOME', () => { + writeFileSync(getSystemConfigPath(), 'model = "system-model"\n', 'utf-8') + const systemOverlayPath = join(getSystemCodexHomePath(), 'work.config.toml') + writeFileSync(systemOverlayPath, 'log_dir = "logs"\nmodel = "work-profile"\n', 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + + const runtimeOverlayPath = join(userDataDir, 'codex-runtime-home', 'home', 'work.config.toml') + const overlay = readFileSync(runtimeOverlayPath, 'utf-8') + expect(overlay).toContain(`log_dir = '${join(getSystemCodexHomePath(), 'logs')}'`) + expect(overlay).toContain('model = "work-profile"') + }) + it('does not treat lines inside multiline arrays as headers or path keys', () => { writeFileSync( getSystemConfigPath(), diff --git a/src/main/codex/codex-profile-v2-config-overlay-mirror.ts b/src/main/codex/codex-profile-v2-config-overlay-mirror.ts index 4f302d9d26a..cc765aeaa1f 100644 --- a/src/main/codex/codex-profile-v2-config-overlay-mirror.ts +++ b/src/main/codex/codex-profile-v2-config-overlay-mirror.ts @@ -1,15 +1,8 @@ -import { - cpSync, - existsSync, - lstatSync, - readdirSync, - readlinkSync, - rmSync, - symlinkSync, - unlinkSync -} from 'node:fs' +import { existsSync, lstatSync, readdirSync, readFileSync, rmSync, unlinkSync } from 'node:fs' import { join } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' +import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite' function isProfileV2ConfigOverlayName(fileName: string): boolean { return fileName.endsWith('.config.toml') && fileName !== 'config.toml' @@ -17,6 +10,9 @@ function isProfileV2ConfigOverlayName(fileName: string): boolean { // Why: Codex profile-v2 loads `${CODEX_HOME}/.config.toml` when selected. // Resource linking covers the profile-v2/ directory, not free-standing overlays. +// Relative AbsolutePathBuf values must be rewritten against system CODEX_HOME +// the same way the main config.toml mirror does — symlink/copy alone leaves +// paths resolving from the managed home. export function syncSystemProfileV2ConfigOverlaysIntoManagedHome(): void { const systemHomePath = getSystemCodexHomePath() const managedHomePath = getOrcaManagedCodexHomePath() @@ -30,11 +26,11 @@ export function syncSystemProfileV2ConfigOverlaysIntoManagedHome(): void { if (!isProfileV2ConfigOverlayName(fileName)) { continue } - linkSystemProfileV2ConfigOverlay(systemHomePath, managedHomePath, fileName) + mirrorSystemProfileV2ConfigOverlay(systemHomePath, managedHomePath, fileName) } } -function linkSystemProfileV2ConfigOverlay( +function mirrorSystemProfileV2ConfigOverlay( systemHomePath: string, managedHomePath: string, fileName: string @@ -44,45 +40,44 @@ function linkSystemProfileV2ConfigOverlay( if (!existsSync(sourcePath)) { return } + + let raw: string try { - if ( - lstatSync(targetPath).isSymbolicLink() && - profileOverlayLinkTargetsMatch(readlinkSync(targetPath), sourcePath) - ) { - return - } - } catch { - // Target missing or unreadable — create below. + raw = readFileSync(sourcePath, 'utf-8') + } catch (error) { + console.warn('[codex-config] Failed to read profile-v2 config overlay:', fileName, error) + return } - if (existsSync(targetPath)) { - try { - if (!lstatSync(targetPath).isSymbolicLink()) { - // Why: leave non-link runtime files alone; they may be user-edited copies. - return + + // Why: rewrite relative path keys against the *system* home so assets stay + // reachable after the overlay lives under managed CODEX_HOME. + const rewritten = rewriteRelativePathConfigValues(raw, systemHomePath) + + try { + if (existsSync(targetPath)) { + const targetStat = lstatSync(targetPath) + if (targetStat.isSymbolicLink()) { + // Why: a bare symlink keeps relative paths resolving against managed + // home (or whatever Codex canonicalizes to). Replace with a rewritten + // regular file. + unlinkSync(targetPath) + } else { + try { + if (readFileSync(targetPath, 'utf-8') === rewritten) { + return + } + } catch { + // Rewrite below. + } } - unlinkSync(targetPath) - } catch { - return } - } - try { - symlinkSync(sourcePath, targetPath) - } catch { + writeFileAtomically(targetPath, rewritten) + } catch (error) { + console.warn('[codex-config] Failed to mirror profile-v2 config overlay:', fileName, error) try { rmSync(targetPath, { force: true }) - cpSync(sourcePath, targetPath, { force: false, errorOnExist: true }) - } catch (error) { - console.warn('[codex-config] Failed to link profile-v2 config overlay:', fileName, error) + } catch { + // best-effort cleanup } } } - -function profileOverlayLinkTargetsMatch(actualTarget: string, expectedTarget: string): boolean { - if (process.platform !== 'win32') { - return actualTarget === expectedTarget - } - return ( - actualTarget.replace(/^\\\\\?\\/, '').toLowerCase() === - expectedTarget.replace(/^\\\\\?\\/, '').toLowerCase() - ) -} diff --git a/src/main/codex/codex-session-bridge-link.ts b/src/main/codex/codex-session-bridge-link.ts new file mode 100644 index 00000000000..cbfc13e9074 --- /dev/null +++ b/src/main/codex/codex-session-bridge-link.ts @@ -0,0 +1,133 @@ +import { copyFileSync, linkSync, lstatSync, renameSync, rmSync } from 'node:fs' +import { + clearLegacyCopiedSessionMarker, + fileStatsMatchMarker, + readLegacyCopiedSessionMarker, + writeLegacyCopiedSessionMarker +} from './codex-session-copy-markers' + +/** + * Attempts a hardlink so resume sees one physical JSONL session log. + */ +export 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 + } +} + +/** + * Copies a session file and records a marker so later scans can keep the copy + * coherent until a hardlink migration succeeds. + */ +export function tryCopySystemCodexSessionFile( + sourcePath: string, + targetPath: string, + relativePath: string +): boolean { + try { + copyFileSync(sourcePath, targetPath) + writeLegacyCopiedSessionMarker(relativePath, sourcePath, targetPath) + return true + } catch (error) { + console.warn('[codex-session-bridge] Failed to copy system Codex session:', sourcePath, error) + return false + } +} + +/** + * Migrates a legacy copied bridge to a hardlink when the copied file still + * matches its marker. Leaves the copy in place when hardlink is unavailable. + * Returns true when the target is now a hardlink. + */ +export function migrateLegacyCopiedSessionBridge( + sourcePath: string, + targetPath: string, + relativePath: string +): boolean { + const marker = readLegacyCopiedSessionMarker(relativePath) + if (!marker || marker.sourcePath !== sourcePath) { + return false + } + let replacementPath: string | null = null + try { + const targetStat = lstatSync(targetPath) + if (targetStat.isSymbolicLink()) { + clearLegacyCopiedSessionMarker(relativePath) + return false + } + if (!fileStatsMatchMarker(targetStat, marker, 'target')) { + 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 migrate copied system Codex session:', + sourcePath, + error + ) + if (replacementPath) { + rmSync(replacementPath, { force: true }) + } + } + return false +} + +/** + * Re-copies a managed session when the system source has grown past the + * copy-sync marker. Prefer hardlink if it becomes available on a later pass. + */ +export function refreshCopiedSessionBridgeIfSourceGrew( + sourcePath: string, + targetPath: string, + relativePath: string +): boolean { + const marker = readLegacyCopiedSessionMarker(relativePath) + if (!marker || marker.sourcePath !== sourcePath) { + return false + } + try { + const targetStat = lstatSync(targetPath) + if (targetStat.isSymbolicLink() || targetStat.isDirectory()) { + return false + } + const sourceStat = lstatSync(sourcePath) + // Source still matches marker → copy is current; nothing to refresh. + if (fileStatsMatchMarker(sourceStat, marker, 'source')) { + return false + } + // Prefer hardlink when the filesystem now allows it (same volume later). + const replacementPath = `${targetPath}.orca-link-${process.pid}-${Date.now()}` + if (tryHardlinkSystemCodexSessionFile(sourcePath, replacementPath)) { + rmSync(targetPath, { force: true }) + renameSync(replacementPath, targetPath) + clearLegacyCopiedSessionMarker(relativePath) + return true + } + if (tryCopySystemCodexSessionFile(sourcePath, replacementPath, relativePath)) { + rmSync(targetPath, { force: true }) + renameSync(replacementPath, targetPath) + writeLegacyCopiedSessionMarker(relativePath, sourcePath, targetPath) + return true + } + rmSync(replacementPath, { force: true }) + } catch (error) { + console.warn( + '[codex-session-bridge] Failed to refresh copied system Codex session:', + sourcePath, + error + ) + } + return false +} diff --git a/src/main/codex/codex-session-bridge.test.ts b/src/main/codex/codex-session-bridge.test.ts index dd9015a8050..e4df33560c8 100644 --- a/src/main/codex/codex-session-bridge.test.ts +++ b/src/main/codex/codex-session-bridge.test.ts @@ -394,6 +394,26 @@ describe('syncSystemCodexSessionsIntoManagedHome', () => { expect(readFileSync(runtimeSessionPath, 'utf-8')).toBe('{"id":"legacy"}\n') }) + it('refreshes a copied session when the system source appends after hardlink failure', () => { + // Why: cross-volume copy is a snapshot; Codex may keep writing the system + // log. Re-sync so resume does not stay on a truncated managed copy. + fsMockState.failLink = true + const relativeSessionPath = join('2026', '05', '26', 'rollout-copy-append.jsonl') + const systemSessionPath = join(getSystemCodexHomePath(), 'sessions', relativeSessionPath) + const runtimeSessionPath = join(getRuntimeCodexHomePath(), 'sessions', relativeSessionPath) + mkdirSync(dirname(systemSessionPath), { recursive: true }) + writeFileSync(systemSessionPath, '{"id":"line-1"}\n', 'utf-8') + + syncSystemCodexSessionsIntoManagedHome() + expect(readFileSync(runtimeSessionPath, 'utf-8')).toBe('{"id":"line-1"}\n') + + writeFileSync(systemSessionPath, '{"id":"line-1"}\n{"id":"line-2"}\n', 'utf-8') + syncSystemCodexSessionsIntoManagedHome() + + expect(lstatSync(runtimeSessionPath).isSymbolicLink()).toBe(false) + expect(readFileSync(runtimeSessionPath, 'utf-8')).toBe('{"id":"line-1"}\n{"id":"line-2"}\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 }) diff --git a/src/main/codex/codex-session-bridge.ts b/src/main/codex/codex-session-bridge.ts index a432065c069..89f5c2c24e6 100644 --- a/src/main/codex/codex-session-bridge.ts +++ b/src/main/codex/codex-session-bridge.ts @@ -1,13 +1,4 @@ -import { - copyFileSync, - existsSync, - linkSync, - lstatSync, - mkdirSync, - readlinkSync, - renameSync, - rmSync -} from 'node:fs' +import { existsSync, lstatSync, mkdirSync, readlinkSync, renameSync, rmSync } from 'node:fs' import { dirname, isAbsolute, join, relative, sep } from 'node:path' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { @@ -21,6 +12,12 @@ import { readLegacyCopiedSessionMarker, writeLegacyCopiedSessionMarker } from './codex-session-copy-markers' +import { + migrateLegacyCopiedSessionBridge, + refreshCopiedSessionBridgeIfSourceGrew, + tryCopySystemCodexSessionFile, + tryHardlinkSystemCodexSessionFile +} from './codex-session-bridge-link' export type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing' @@ -136,8 +133,18 @@ function bridgeSystemCodexSessionFile( ) { return true } - migrateLegacyCopiedSessionBridge(systemSessionFilePath, managedSessionFilePath, relativePath) - return false + if ( + migrateLegacyCopiedSessionBridge(systemSessionFilePath, managedSessionFilePath, relativePath) + ) { + return true + } + // Why: hardlink failure leaves a copy. When the system session appends, + // refresh the managed copy so Codex resume is not stuck on a truncated log. + return refreshCopiedSessionBridgeIfSourceGrew( + systemSessionFilePath, + managedSessionFilePath, + relativePath + ) } mkdirSync(dirname(managedSessionFilePath), { recursive: true }) return linkSystemCodexSessionFile(systemSessionFilePath, managedSessionFilePath, relativePath) @@ -160,39 +167,6 @@ function linkSystemCodexSessionFile( return tryCopySystemCodexSessionFile(sourcePath, targetPath, relativePath) } -/** - * 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 - } -} - -/** - * Copies a session file and records a marker so later scans can keep the copy - * coherent until a hardlink migration succeeds. - */ -function tryCopySystemCodexSessionFile( - sourcePath: string, - targetPath: string, - relativePath: string -): boolean { - try { - copyFileSync(sourcePath, targetPath) - writeLegacyCopiedSessionMarker(relativePath, sourcePath, targetPath) - return true - } catch (error) { - console.warn('[codex-session-bridge] Failed to copy system Codex session:', sourcePath, error) - return false - } -} - /** * Replaces an older symlink bridge with a hardlink (or copy) so Codex resume * can see a regular file. Codex FS scans ignore symlinks. @@ -244,48 +218,6 @@ function replaceSymlinkSessionBridgeWithHardlink( return false } -/** - * Migrates a legacy copied bridge to a hardlink when the copied file still - * matches its marker. Leaves the copy in place when hardlink is unavailable. - */ -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 (!tryHardlinkSystemCodexSessionFile(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. * diff --git a/src/main/rate-limits/codex-fetcher-buckets.test.ts b/src/main/rate-limits/codex-fetcher-buckets.test.ts new file mode 100644 index 00000000000..b378e34019a --- /dev/null +++ b/src/main/rate-limits/codex-fetcher-buckets.test.ts @@ -0,0 +1,244 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { childSpawnMock, readFileMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({ + childSpawnMock: vi.fn(), + readFileMock: vi.fn(), + resolveCodexCommandMock: vi.fn(), + ptySpawnMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + spawn: childSpawnMock +})) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock +})) + +vi.mock('../codex-cli/command', () => ({ + resolveCodexCommand: resolveCodexCommandMock +})) + +vi.mock('node-pty', () => ({ + spawn: ptySpawnMock +})) + +vi.mock('./codex-auth-presence', () => ({ + codexAuthExists: vi.fn(() => true) +})) + +import { fetchCodexRateLimits } from './codex-fetcher' +import { codexAuthExists } from './codex-auth-presence' + +function makeRpcChild() { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter + stdin: { write: ReturnType } + kill: ReturnType + } + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.stdin = { write: vi.fn() } + child.kill = vi.fn() + return child +} + +// Why: split from codex-fetcher.test.ts so that suite stays under max-lines +// while covering multi-meter window mapping thoroughly. +describe('fetchCodexRateLimits multi-meter windows', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + resolveCodexCommandMock.mockReturnValue('codex') + vi.mocked(codexAuthExists).mockReturnValue(true) + readFileMock.mockRejectedValue(new Error('no auth fixture')) + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('normalizes Codex RPC remaining-minute windows to fixed display durations', async () => { + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + rpcChild.stdin.write.mockImplementation((line: string) => { + const msg = JSON.parse(line) as { id?: number; method?: string } + if (msg.method === 'initialize') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) + ) + }, 0) + } + if (msg.method === 'account/rateLimits/read') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + jsonrpc: '2.0', + id: msg.id, + result: { + rateLimits: { + primary: { usedPercent: 0, windowDurationMins: 299 }, + secondary: { usedPercent: 0, windowDurationMins: 10079 } + } + } + })}\n` + ) + ) + }, 0) + } + }) + + const resultPromise = fetchCodexRateLimits() + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + const result = await resultPromise + + expect(result.session?.windowMinutes).toBe(300) + expect(result.weekly?.windowMinutes).toBe(10080) + }) + + it('surfaces additional rateLimitsByLimitId buckets alongside preferred session/weekly', async () => { + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + rpcChild.stdin.write.mockImplementation((line: string) => { + const msg = JSON.parse(line) as { id?: number; method?: string } + if (msg.method === 'initialize') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) + ) + }, 0) + } + if (msg.method === 'account/rateLimits/read') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + jsonrpc: '2.0', + id: msg.id, + result: { + rateLimits: { + limitId: 'codex', + primary: { usedPercent: 10, windowDurationMins: 299 }, + secondary: { usedPercent: 20, windowDurationMins: 10079 } + }, + rateLimitsByLimitId: { + codex: { + limitId: 'codex', + limitName: 'Codex', + primary: { usedPercent: 10 }, + secondary: { usedPercent: 20 } + }, + codex_other: { + limitId: 'codex_other', + limitName: 'Codex other', + primary: { usedPercent: 40 }, + secondary: { usedPercent: 55 } + } + } + } + })}\n` + ) + ) + }, 0) + } + }) + + const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + const result = await resultPromise + + expect(result).toMatchObject({ + provider: 'codex', + session: { usedPercent: 10, windowMinutes: 300 }, + weekly: { usedPercent: 20, windowMinutes: 10080 }, + status: 'ok' + }) + // Preferred codex meters stay on session/weekly only — buckets lists extras. + expect(result.buckets).toEqual([ + expect.objectContaining({ name: 'Codex other', usedPercent: 40, windowMinutes: 300 }), + expect.objectContaining({ + name: 'Codex other weekly', + usedPercent: 55, + windowMinutes: 10080 + }) + ]) + }) + + it('infers additional bucket windows from windowDurationMins instead of forcing 5h/weekly', async () => { + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + rpcChild.stdin.write.mockImplementation((line: string) => { + const msg = JSON.parse(line) as { id?: number; method?: string } + if (msg.method === 'initialize') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) + ) + }, 0) + } + if (msg.method === 'account/rateLimits/read') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + jsonrpc: '2.0', + id: msg.id, + result: { + rateLimits: { + limitId: 'codex', + primary: { usedPercent: 10, windowDurationMins: 299 }, + secondary: { usedPercent: 20, windowDurationMins: 10079 } + }, + rateLimitsByLimitId: { + codex: { + limitId: 'codex', + primary: { usedPercent: 10 }, + secondary: { usedPercent: 20 } + }, + short_meter: { + limitId: 'short_meter', + limitName: 'Short', + // Why: remaining ~50 minutes of a 1h window — must not become 5h. + primary: { usedPercent: 40, windowDurationMins: 50 }, + secondary: { usedPercent: 10, windowDurationMins: 1400 } + } + } + } + })}\n` + ) + ) + }, 0) + } + }) + + const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + const result = await resultPromise + + expect(result.session?.windowMinutes).toBe(300) + expect(result.weekly?.windowMinutes).toBe(10080) + expect(result.buckets).toEqual([ + expect.objectContaining({ name: 'Short', usedPercent: 40, windowMinutes: 60 }), + expect.objectContaining({ + name: 'Short weekly', + usedPercent: 10, + windowMinutes: 1440 + }) + ]) + }) +}) diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index 96a535018a8..d979724fadd 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -92,9 +92,9 @@ describe('normalizeCodexBackendBaseUrl', () => { 'https://api.example.com/api/codex/rate-limit-reset-credits' ) // Custom bases that contain "/backend-api" must not use the WHAM route. - expect( - buildCodexRateLimitResetCreditsUrl('https://api.example.com/backend-api') - ).toBe('https://api.example.com/backend-api/api/codex/rate-limit-reset-credits') + expect(buildCodexRateLimitResetCreditsUrl('https://api.example.com/backend-api')).toBe( + 'https://api.example.com/backend-api/api/codex/rate-limit-reset-credits' + ) }) }) @@ -333,120 +333,6 @@ describe('fetchCodexRateLimits', () => { expect(ptySpawnMock).not.toHaveBeenCalled() }) - it('normalizes Codex RPC remaining-minute windows to fixed display durations', async () => { - const rpcChild = makeRpcChild() - childSpawnMock.mockReturnValue(rpcChild) - rpcChild.stdin.write.mockImplementation((line: string) => { - const msg = JSON.parse(line) as { id?: number; method?: string } - if (msg.method === 'initialize') { - setTimeout(() => { - rpcChild.stdout.emit( - 'data', - Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) - ) - }, 0) - } - if (msg.method === 'account/rateLimits/read') { - setTimeout(() => { - rpcChild.stdout.emit( - 'data', - Buffer.from( - `${JSON.stringify({ - jsonrpc: '2.0', - id: msg.id, - result: { - rateLimits: { - primary: { usedPercent: 0, windowDurationMins: 299 }, - secondary: { usedPercent: 0, windowDurationMins: 10079 } - } - } - })}\n` - ) - ) - }, 0) - } - }) - - const resultPromise = fetchCodexRateLimits() - await vi.advanceTimersByTimeAsync(1) - await vi.advanceTimersByTimeAsync(1) - const result = await resultPromise - - expect(result.session?.windowMinutes).toBe(300) - expect(result.weekly?.windowMinutes).toBe(10080) - }) - - it('surfaces additional rateLimitsByLimitId buckets alongside preferred session/weekly', async () => { - const rpcChild = makeRpcChild() - childSpawnMock.mockReturnValue(rpcChild) - rpcChild.stdin.write.mockImplementation((line: string) => { - const msg = JSON.parse(line) as { id?: number; method?: string } - if (msg.method === 'initialize') { - setTimeout(() => { - rpcChild.stdout.emit( - 'data', - Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) - ) - }, 0) - } - if (msg.method === 'account/rateLimits/read') { - setTimeout(() => { - rpcChild.stdout.emit( - 'data', - Buffer.from( - `${JSON.stringify({ - jsonrpc: '2.0', - id: msg.id, - result: { - rateLimits: { - limitId: 'codex', - primary: { usedPercent: 10, windowDurationMins: 299 }, - secondary: { usedPercent: 20, windowDurationMins: 10079 } - }, - rateLimitsByLimitId: { - codex: { - limitId: 'codex', - limitName: 'Codex', - primary: { usedPercent: 10 }, - secondary: { usedPercent: 20 } - }, - codex_other: { - limitId: 'codex_other', - limitName: 'Codex other', - primary: { usedPercent: 40 }, - secondary: { usedPercent: 55 } - } - } - } - })}\n` - ) - ) - }, 0) - } - }) - - const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) - await vi.advanceTimersByTimeAsync(1) - await vi.advanceTimersByTimeAsync(1) - const result = await resultPromise - - expect(result).toMatchObject({ - provider: 'codex', - session: { usedPercent: 10, windowMinutes: 300 }, - weekly: { usedPercent: 20, windowMinutes: 10080 }, - status: 'ok' - }) - // Preferred codex meters stay on session/weekly only — buckets lists extras. - expect(result.buckets).toEqual([ - expect.objectContaining({ name: 'Codex other', usedPercent: 40, windowMinutes: 300 }), - expect.objectContaining({ - name: 'Codex other weekly', - usedPercent: 55, - windowMinutes: 10080 - }) - ]) - }) - it('fills reset-credit count from the backend when the installed app-server omits it', async () => { const rpcChild = makeRpcChild() childSpawnMock.mockReturnValue(rpcChild) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index c50cf303465..e1102ba28f0 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -548,7 +548,9 @@ export function normalizeCodexBackendBaseUrl(raw: string | null | undefined): st const path = url.pathname.replace(/\/+$/, '') return `${url.origin}${path === '' || path === '/' ? '' : path}/backend-api` } - return `${url.origin}${url.pathname.replace(/\/+$/, '') || ''}`.replace(/\/+$/, '') || url.origin + return ( + `${url.origin}${url.pathname.replace(/\/+$/, '') || ''}`.replace(/\/+$/, '') || url.origin + ) } catch { return trimmed } @@ -590,6 +592,32 @@ async function resolveCodexBackendBaseUrl(codexHomePath?: string | null): Promis return normalizeCodexBackendBaseUrl(fromConfig) } + +// Why: Codex reports remaining minutes in `windowDurationMins` for known +// primary/secondary meters. Snap that remaining value up to the smallest +// standard window that can contain it so additional limit_ids are not all +// forced to 5h/weekly when the API says otherwise. +const STANDARD_WINDOW_MINUTES = [5, 15, 60, 300, 1440, 10080, 43200] as const + +function inferAdditionalWindowMinutes( + reportedRemainingMins: number | undefined, + fallback: number +): number { + if ( + typeof reportedRemainingMins !== 'number' || + !Number.isFinite(reportedRemainingMins) || + reportedRemainingMins < 0 + ) { + return fallback + } + for (const window of STANDARD_WINDOW_MINUTES) { + if (reportedRemainingMins <= window) { + return window + } + } + return Math.max(fallback, Math.ceil(reportedRemainingMins)) +} + function preferredRpcRateLimitSnapshot( wrapper: RpcRateLimitsResponse | undefined ): { id: string | null; snapshot: RpcRateLimitSnapshot | undefined } { @@ -651,11 +679,20 @@ function mapRpcRateLimitsPayload(wrapper: RpcRateLimitsResponse | undefined): { continue } const name = limitSnapshotDisplayName(id, snapshot) - const primary = mapRpcWindow(snapshot.primary, 300) + // Why: additional meters are not guaranteed to be 5h/weekly. Prefer the + // API's windowDurationMins (snapped to a standard window) over forcing the + // preferred-plan labels onto every limit_id. + const primary = mapRpcWindow( + snapshot.primary, + inferAdditionalWindowMinutes(snapshot.primary?.windowDurationMins, 300) + ) if (primary) { buckets.push({ name, ...primary }) } - const secondary = mapRpcWindow(snapshot.secondary, 10080) + const secondary = mapRpcWindow( + snapshot.secondary, + inferAdditionalWindowMinutes(snapshot.secondary?.windowDurationMins, 10080) + ) if (secondary) { buckets.push({ name: `${name} weekly`, ...secondary }) } diff --git a/src/renderer/src/lib/agent-startup-delayed-delivery.ts b/src/renderer/src/lib/agent-startup-delayed-delivery.ts index 26b2d3d7298..8d639ddab0a 100644 --- a/src/renderer/src/lib/agent-startup-delayed-delivery.ts +++ b/src/renderer/src/lib/agent-startup-delayed-delivery.ts @@ -147,6 +147,19 @@ export function releaseAgentStartupDeliveryAttempt(args: { releaseAgentStartupDeliveryConsumed(deliveryKey(args)) } +function requeueFailedAgentStartupDelivery( + key: string, + delivery: PendingAgentStartupDelivery +): void { + releaseAgentStartupDeliveryAttempt(delivery) + // Why: beginAgentStartupDeliveryAttempt removed the entry; put it back so + // the next store subscription / readiness path can try again. + if (!isAgentStartupDeliveryConsumed(key)) { + pendingAgentStartupDeliveries.set(key, delivery) + ensurePendingAgentStartupSubscription() + } +} + function flushPendingAgentStartupDeliveries(): void { const state = useAppStore.getState() for (const [key, delivery] of pendingAgentStartupDeliveries) { @@ -172,18 +185,19 @@ function flushPendingAgentStartupDeliveries(): void { } // Why: once the launch-bound PTY exists, the bounded readiness/paste path // owns success or failure. Consume before awaiting so store churn cannot - // duplicate a linked-work-item draft; release on failed paste so Codex - // (and other cautious agents) can retry when readiness was premature. + // duplicate a linked-work-item draft; on failed paste release the guard + // *and* re-queue so a later readiness tick can retry (release alone left + // nothing in the pending map and the prompt was silently dropped). if (beginAgentStartupDeliveryAttempt(delivery)) { void delivery .deliver(tabId, ptyId, delivery.startup) .then((delivered) => { if (delivered === false) { - releaseAgentStartupDeliveryAttempt(delivery) + requeueFailedAgentStartupDelivery(key, delivery) } }) .catch((error) => { - releaseAgentStartupDeliveryAttempt(delivery) + requeueFailedAgentStartupDelivery(key, delivery) console.warn('Queued agent startup delivery failed', error) }) } From cffc558c78932fe2b7c754dbf4305b2e45f1e8e2 Mon Sep 17 00:00:00 2001 From: bbingz Date: Fri, 10 Jul 2026 16:50:02 +0800 Subject: [PATCH 6/7] fix(codex): restore DEFAULT_CHATGPT_BACKEND_BASE_URL after rebase Rebase conflict resolution dropped the ChatGPT backend default constant used by normalizeCodexBackendBaseUrl, breaking reset-credit URL tests. --- src/main/rate-limits/codex-fetcher.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index e1102ba28f0..bccca7dcb79 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -522,6 +522,8 @@ function parseTopLevelTomlStringKey(config: string, key: string): string | null return null } +const DEFAULT_CHATGPT_BACKEND_BASE_URL = 'https://chatgpt.com/backend-api' + const OFFICIAL_CHATGPT_API_HOSTS = new Set([ 'chatgpt.com', 'www.chatgpt.com', @@ -592,7 +594,6 @@ async function resolveCodexBackendBaseUrl(codexHomePath?: string | null): Promis return normalizeCodexBackendBaseUrl(fromConfig) } - // Why: Codex reports remaining minutes in `windowDurationMins` for known // primary/secondary meters. Snap that remaining value up to the smallest // standard window that can contain it so additional limit_ids are not all @@ -618,9 +619,10 @@ function inferAdditionalWindowMinutes( return Math.max(fallback, Math.ceil(reportedRemainingMins)) } -function preferredRpcRateLimitSnapshot( - wrapper: RpcRateLimitsResponse | undefined -): { id: string | null; snapshot: RpcRateLimitSnapshot | undefined } { +function preferredRpcRateLimitSnapshot(wrapper: RpcRateLimitsResponse | undefined): { + id: string | null + snapshot: RpcRateLimitSnapshot | undefined +} { // Why: rateLimits is the declared preferred meter; scanning by-id first can // swap session/weekly onto a non-primary limit on multi-meter plans. if (wrapper?.rateLimits) { @@ -709,7 +711,6 @@ function mapRpcRateLimitsPayload(wrapper: RpcRateLimitsResponse | undefined): { // RPC fetch — spawn `codex -s read-only -a untrusted app-server` // --------------------------------------------------------------------------- - function mapBackendUsageWindow( raw: BackendRateLimitWindow | null | undefined, fallbackWindowMinutes: number From bea7270c1272e873b54a7f958f29d1d1314aa482 Mon Sep 17 00:00:00 2001 From: BingZ Date: Fri, 10 Jul 2026 18:21:08 +0800 Subject: [PATCH 7/7] fix(codex): align compatibility stack with current contracts --- src/main/codex-accounts/runtime-home-service.test.ts | 4 +++- src/main/rate-limits/codex-fetcher-buckets.test.ts | 2 +- src/main/rate-limits/codex-fetcher.ts | 8 +++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 586914e8e06..2c27e0cf151 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -578,7 +578,9 @@ describe('CodexRuntimeHomeService', () => { const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml') writeFileSync(wslSystemConfigPath, 'model = "outside-edit"\n', 'utf-8') service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "outside-edit"\n') + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe( + 'cli_auth_credentials_store = "file"\nmodel = "outside-edit"\n' + ) expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"outside-edit\\""') // Codex now persists a /model change inside Orca's reconciled runtime. diff --git a/src/main/rate-limits/codex-fetcher-buckets.test.ts b/src/main/rate-limits/codex-fetcher-buckets.test.ts index b378e34019a..ac09b0b354d 100644 --- a/src/main/rate-limits/codex-fetcher-buckets.test.ts +++ b/src/main/rate-limits/codex-fetcher-buckets.test.ts @@ -52,7 +52,7 @@ describe('fetchCodexRateLimits multi-meter windows', () => { vi.useFakeTimers() vi.clearAllMocks() resolveCodexCommandMock.mockReturnValue('codex') - vi.mocked(codexAuthExists).mockReturnValue(true) + vi.mocked(codexAuthExists).mockResolvedValue(true) readFileMock.mockRejectedValue(new Error('no auth fixture')) vi.stubGlobal('fetch', vi.fn()) }) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index bccca7dcb79..511591cc571 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -4,6 +4,7 @@ differences and ensure account-scoped env handling stays identical. */ import type { CodexRateLimitResetOutcome, ProviderRateLimits, + RateLimitBucket, RateLimitWindow } from '../../shared/rate-limit-types' import { spawn } from 'node:child_process' @@ -83,10 +84,15 @@ type RpcRateLimitsResult = { secondary?: RpcRateWindow } +type RpcRateLimitSnapshot = RpcRateLimitsResult & { + limitId?: string + limitName?: string +} + // Why: the Codex app-server wraps rate limit data inside a `rateLimits` key. // The actual response shape is `{ rateLimits: { primary, secondary, ... } }`. type RpcRateLimitsResponse = { - rateLimits?: RpcRateLimitsResult + rateLimits?: RpcRateLimitSnapshot // Why: multi-meter plans return several snapshots keyed by limit_id. rateLimitsByLimitId?: Record | null rateLimitResetCredits?: {