diff --git a/cli/src/codex/codexLocal.test.ts b/cli/src/codex/codexLocal.test.ts new file mode 100644 index 00000000..3e718c9c --- /dev/null +++ b/cli/src/codex/codexLocal.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { filterResumeSubcommand } from './codexLocal'; + +describe('filterResumeSubcommand', () => { + it('returns empty array unchanged', () => { + expect(filterResumeSubcommand([])).toEqual([]); + }); + + it('passes through args when first arg is not resume', () => { + expect(filterResumeSubcommand(['--model', 'gpt-4'])).toEqual(['--model', 'gpt-4']); + expect(filterResumeSubcommand(['--sandbox', 'read-only'])).toEqual(['--sandbox', 'read-only']); + }); + + it('filters resume subcommand with session ID', () => { + expect(filterResumeSubcommand(['resume', 'abc-123'])).toEqual([]); + expect(filterResumeSubcommand(['resume', 'abc-123', '--model', 'gpt-4'])) + .toEqual(['--model', 'gpt-4']); + }); + + it('filters resume subcommand without session ID', () => { + expect(filterResumeSubcommand(['resume'])).toEqual([]); + expect(filterResumeSubcommand(['resume', '--model', 'gpt-4'])) + .toEqual(['--model', 'gpt-4']); + }); + + it('does not filter resume when it appears as flag value', () => { + // --name resume should pass through (resume is value, not subcommand) + expect(filterResumeSubcommand(['--name', 'resume'])).toEqual(['--name', 'resume']); + }); + + it('does not filter resume in middle of args', () => { + // If resume appears after flags, it's not the subcommand position + expect(filterResumeSubcommand(['--model', 'gpt-4', 'resume', '123'])) + .toEqual(['--model', 'gpt-4', 'resume', '123']); + }); +}); diff --git a/cli/src/codex/codexLocal.ts b/cli/src/codex/codexLocal.ts index 15a2cf15..24a51039 100644 --- a/cli/src/codex/codexLocal.ts +++ b/cli/src/codex/codexLocal.ts @@ -2,6 +2,25 @@ import { spawn } from 'node:child_process'; import { logger } from '@/ui/logger'; import { restoreTerminalState } from '@/ui/terminalState'; +/** + * Filter out 'resume' subcommand which is managed internally by hapi. + * Codex CLI format is `codex resume `, so subcommand is always first. + */ +export function filterResumeSubcommand(args: string[]): string[] { + if (args.length === 0 || args[0] !== 'resume') { + return args; + } + + // First arg is 'resume', filter it and optional session ID + if (args.length > 1 && !args[1].startsWith('-')) { + logger.debug(`[CodexLocal] Filtered 'resume ${args[1]}' - session managed by hapi`); + return args.slice(2); + } + + logger.debug(`[CodexLocal] Filtered 'resume' - session managed by hapi`); + return args.slice(1); +} + export async function codexLocal(opts: { abort: AbortSignal; sessionId: string | null; @@ -9,6 +28,7 @@ export async function codexLocal(opts: { model?: string; sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access'; onSessionFound: (id: string) => void; + codexArgs?: string[]; }): Promise { const args: string[] = []; @@ -25,6 +45,11 @@ export async function codexLocal(opts: { args.push('--sandbox', opts.sandbox); } + if (opts.codexArgs) { + const safeArgs = filterResumeSubcommand(opts.codexArgs); + args.push(...safeArgs); + } + logger.debug(`[CodexLocal] Spawning codex with args: ${JSON.stringify(args)}`); process.stdin.pause(); diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index 53d6cb91..8363653e 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -78,7 +78,8 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch path: session.path, sessionId: session.sessionId, onSessionFound: handleSessionFound, - abort: processAbortController.signal + abort: processAbortController.signal, + codexArgs: session.codexArgs }); if (!exitReason) { diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 947e7f05..46d53ac7 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -22,6 +22,12 @@ import type { EnhancedMode } from './loop'; import { restoreTerminalState } from '@/ui/terminalState'; export async function codexRemoteLauncher(session: CodexSession): Promise<'switch' | 'exit'> { + // Warn if CLI args were passed that won't apply in remote mode + if (session.codexArgs && session.codexArgs.length > 0) { + logger.debug(`[codex-remote] Warning: CLI args [${session.codexArgs.join(', ')}] are ignored in remote mode. ` + + `Remote mode uses message-based configuration (model/sandbox set via web interface).`); + } + const hasTTY = process.stdout.isTTY && process.stdin.isTTY; const messageBuffer = new MessageBuffer(); let inkInstance: any = null; diff --git a/cli/src/codex/loop.ts b/cli/src/codex/loop.ts index ad83c7c1..878423ff 100644 --- a/cli/src/codex/loop.ts +++ b/cli/src/codex/loop.ts @@ -20,6 +20,7 @@ interface LoopOptions { messageQueue: MessageQueue2; session: ApiSessionClient; api: ApiClient; + codexArgs?: string[]; onSessionReady?: (session: CodexSession) => void; } @@ -33,7 +34,8 @@ export async function loop(opts: LoopOptions): Promise { logPath, messageQueue: opts.messageQueue, onModeChange: opts.onModeChange, - mode: opts.startingMode ?? 'local' + mode: opts.startingMode ?? 'local', + codexArgs: opts.codexArgs }); if (opts.onSessionReady) { diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index f62898fb..aef6344c 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -22,6 +22,7 @@ export { emitReadyIfIdle } from './utils/emitReadyIfIdle'; export async function runCodex(opts: { startedBy?: 'daemon' | 'terminal'; + codexArgs?: string[]; }): Promise { const workingDirectory = process.cwd(); const sessionTag = randomUUID(); @@ -188,6 +189,7 @@ export async function runCodex(opts: { messageQueue, api, session, + codexArgs: opts.codexArgs, onModeChange: (newMode) => { session.sendSessionEvent({ type: 'switch', mode: newMode }); session.updateAgentState((currentState) => ({ diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index ef9477f8..a15e3282 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -4,6 +4,8 @@ import { AgentSessionBase } from '@/agent/sessionBase'; import type { EnhancedMode } from './loop'; export class CodexSession extends AgentSessionBase { + readonly codexArgs?: string[]; + constructor(opts: { api: ApiClient; client: ApiSessionClient; @@ -13,6 +15,7 @@ export class CodexSession extends AgentSessionBase { messageQueue: MessageQueue2; onModeChange: (mode: 'local' | 'remote') => void; mode?: 'local' | 'remote'; + codexArgs?: string[]; }) { super({ api: opts.api, @@ -30,6 +33,8 @@ export class CodexSession extends AgentSessionBase { codexSessionId: sessionId }) }); + + this.codexArgs = opts.codexArgs; } sendCodexMessage = (message: unknown): void => { diff --git a/cli/src/index.ts b/cli/src/index.ts index 16a74712..cf3c6636 100755 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -125,18 +125,25 @@ import { withBunRuntimeEnv } from './utils/bunRuntime' // Handle codex command try { const { runCodex } = await import('@/codex/runCodex'); - - // Parse startedBy argument - let startedBy: 'daemon' | 'terminal' | undefined = undefined; + + // Parse known arguments and collect unknown ones for passthrough + const options: { startedBy?: 'daemon' | 'terminal'; codexArgs?: string[] } = {}; + const unknownArgs: string[] = []; for (let i = 1; i < args.length; i++) { - if (args[i] === '--started-by') { - startedBy = args[++i] as 'daemon' | 'terminal'; + const arg = args[i]; + if (arg === '--started-by') { + options.startedBy = args[++i] as 'daemon' | 'terminal'; + } else { + unknownArgs.push(arg); } } + if (unknownArgs.length > 0) { + options.codexArgs = unknownArgs; + } await initializeToken(); await authAndSetupMachineIfNeeded(); - await runCodex({ startedBy }); + await runCodex(options); // Do not force exit here; allow instrumentation to show lingering handles } catch (error) { console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error')