From f132a7dc79156d39df7e95db61c6a6f29ffd4c42 Mon Sep 17 00:00:00 2001 From: weishu Date: Tue, 23 Dec 2025 09:03:54 +0800 Subject: [PATCH] feat(codex): implement CLI argument passthrough for codex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collect unknown CLI arguments from index.ts and pass them through the execution chain (runCodex → loop → CodexSession → codexLocalLauncher → codexLocal), similar to how claude already works. This enables users to pass CLI arguments like --model and --sandbox to the underlying codex process. Filter out the 'resume' subcommand which is managed internally by hapi, while allowing other CLI arguments to pass through. Add warning log in remote mode when CLI args are ignored since remote mode uses message-based configuration instead. Include unit tests for the resume filtering logic. --- cli/src/codex/codexLocal.test.ts | 36 ++++++++++++++++++++++++++++ cli/src/codex/codexLocal.ts | 25 +++++++++++++++++++ cli/src/codex/codexLocalLauncher.ts | 3 ++- cli/src/codex/codexRemoteLauncher.ts | 6 +++++ cli/src/codex/loop.ts | 4 +++- cli/src/codex/runCodex.ts | 2 ++ cli/src/codex/session.ts | 5 ++++ cli/src/index.ts | 19 ++++++++++----- 8 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 cli/src/codex/codexLocal.test.ts 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')