diff --git a/app/chrome-extension/common/agent-models.ts b/app/chrome-extension/common/agent-models.ts index 20dfad7..4115e09 100644 --- a/app/chrome-extension/common/agent-models.ts +++ b/app/chrome-extension/common/agent-models.ts @@ -46,34 +46,98 @@ export const CLAUDE_MODELS: ModelDefinition[] = [ export const CLAUDE_DEFAULT_MODEL = 'claude-sonnet-4-5-20250929'; // ============================================================ -// Codex Models +// Codex Models (aligned with other/cweb) // ============================================================ export const CODEX_MODELS: ModelDefinition[] = [ { - id: 'o3', - name: 'o3', + id: 'gpt-5', + name: 'GPT-5', description: 'OpenAI flagship reasoning model', }, { - id: 'gpt-4.1', - name: 'GPT-4.1', - description: 'OpenAI GPT-4.1 model', - }, - { - id: 'o4-mini', - name: 'o4 Mini', - description: 'Fast and efficient model', - }, - { - id: 'claude-sonnet-4-5-20250929', - name: 'Claude Sonnet 4.5 (via Codex)', - description: 'Anthropic model via Codex', + id: 'gpt-4o', + name: 'GPT-4o', + description: 'General-purpose model with multimodal support', supportsImages: true, }, + { + id: 'gpt-4o-mini', + name: 'GPT-4o Mini', + description: 'Cost-efficient GPT-4o variant', + supportsImages: true, + }, + { + id: 'o1-preview', + name: 'o1 Preview', + description: 'OpenAI o1 preview model focused on agent use-cases', + }, + { + id: 'o1-mini', + name: 'o1 Mini', + description: 'Lightweight o1 model for faster iterations', + }, + { + id: 'o3', + name: 'o3', + description: 'OpenAI o3 reasoning model', + }, + { + id: 'claude-3.5-sonnet', + name: 'Claude 3.5 Sonnet (via Codex)', + description: 'Anthropic Claude via Codex router', + }, + { + id: 'claude-3-haiku', + name: 'Claude 3 Haiku (via Codex)', + description: 'Anthropic Haiku model routed through Codex', + }, ]; -export const CODEX_DEFAULT_MODEL = 'o3'; +export const CODEX_DEFAULT_MODEL = 'gpt-5'; + +// Codex model alias normalization (aligned with other/cweb) +const CODEX_ALIAS_MAP: Record = { + gpt5: 'gpt-5', + gpt_5: 'gpt-5', + 'gpt-5.0': 'gpt-5', + 'gpt-4o-mini-high': 'gpt-4o-mini', + 'gpt-4o-mini-low': 'gpt-4o-mini', + 'claude-sonnet-3.5': 'claude-3.5-sonnet', + 'claude35-sonnet': 'claude-3.5-sonnet', +}; + +const CODEX_KNOWN_IDS = new Set(CODEX_MODELS.map((model) => model.id)); + +/** + * Normalize a Codex model ID, handling aliases and falling back to default. + */ +export function normalizeCodexModelId(model?: string | null): string { + if (!model || typeof model !== 'string') { + return CODEX_DEFAULT_MODEL; + } + + const trimmed = model.trim(); + if (!trimmed) { + return CODEX_DEFAULT_MODEL; + } + + const lower = trimmed.toLowerCase(); + if (CODEX_ALIAS_MAP[lower]) { + return CODEX_ALIAS_MAP[lower]; + } + + if (CODEX_KNOWN_IDS.has(lower)) { + return lower; + } + + // If the exact casing exists, allow it + if (CODEX_KNOWN_IDS.has(trimmed)) { + return trimmed; + } + + return CODEX_DEFAULT_MODEL; +} // ============================================================ // Cursor Models diff --git a/app/native-server/src/agent/chat-service.ts b/app/native-server/src/agent/chat-service.ts index ee203d2..c8df961 100644 --- a/app/native-server/src/agent/chat-service.ts +++ b/app/native-server/src/agent/chat-service.ts @@ -271,6 +271,8 @@ export class AgentChatService { resumeClaudeSessionId: engineName === 'claude' ? resumeClaudeSessionId : undefined, // Pass useCcr flag for Claude Code Router support (ClaudeEngine only) useCcr: engineName === 'claude' ? projectUseCcr : undefined, + // Pass Codex-specific configuration (CodexEngine only) + codexConfig: engineName === 'codex' ? dbSession?.optionsConfig?.codexConfig : undefined, }; // Create abort controller for cancellation support diff --git a/app/native-server/src/agent/engines/codex.ts b/app/native-server/src/agent/engines/codex.ts index f925302..49fee8c 100644 --- a/app/native-server/src/agent/engines/codex.ts +++ b/app/native-server/src/agent/engines/codex.ts @@ -2,6 +2,11 @@ import { spawn } from 'node:child_process'; import readline from 'node:readline'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; +import { + CODEX_AUTO_INSTRUCTIONS, + DEFAULT_CODEX_CONFIG, + type CodexEngineConfig, +} from 'chrome-mcp-shared'; import type { AgentEngine, EngineExecutionContext, EngineInitOptions } from './types'; import type { AgentMessage, RealtimeEvent } from '../types'; import { AgentToolBridge } from '../tool-bridge'; @@ -41,7 +46,16 @@ export class CodexEngine implements AgentEngine { private static readonly MAX_STDERR_LINES = 200; async initializeAndRun(options: EngineInitOptions, ctx: EngineExecutionContext): Promise { - const { sessionId, instruction, model, projectRoot, requestId, signal, attachments } = options; + const { + sessionId, + instruction, + model, + projectRoot, + requestId, + signal, + attachments, + codexConfig, + } = options; const repoPath = this.resolveRepoPath(projectRoot); // Check if already aborted @@ -54,6 +68,22 @@ export class CodexEngine implements AgentEngine { throw new Error('CodexEngine: instruction must not be empty'); } + // Merge user config with defaults + const resolvedConfig: CodexEngineConfig = { + ...DEFAULT_CODEX_CONFIG, + ...(codexConfig ?? {}), + }; + + // Ensure autoInstructions has a value + if (!resolvedConfig.autoInstructions?.trim()) { + resolvedConfig.autoInstructions = CODEX_AUTO_INSTRUCTIONS; + } + + // Optionally append project context to the prompt + const prompt = resolvedConfig.appendProjectContext + ? await this.appendProjectContext(normalizedInstruction, repoPath) + : normalizedInstruction; + const executable = process.platform === 'win32' ? 'codex.cmd' : 'codex'; const args: string[] = [ 'exec', @@ -66,6 +96,9 @@ export class CodexEngine implements AgentEngine { repoPath, ]; + // Add Codex configuration arguments + args.push(...this.buildCodexConfigArgs(resolvedConfig)); + if (model && model.trim()) { args.push('--model', model.trim()); } @@ -86,7 +119,7 @@ export class CodexEngine implements AgentEngine { } } - args.push(normalizedInstruction); + args.push(prompt); // Use explicit Promise wrapping to ensure child process errors are properly rejected. return new Promise((resolve, reject) => { @@ -111,6 +144,8 @@ export class CodexEngine implements AgentEngine { let assistantMessageId: string | null = null; let assistantCreatedAt: string | null = null; const streamedToolHashes = new Set(); + const activeCommands = new Map(); + const thinkingSegments: string[] = []; /** * Cleanup and settle the promise (resolve or reject). @@ -188,9 +223,40 @@ export class CodexEngine implements AgentEngine { rl = readline.createInterface({ input: child.stdout }); + /** + * Build the assistant message payload, combining thinking and agent content. + */ + const buildAssistantPayload = (): string => { + const trimmedAssistant = assistantBuffer.trim(); + const thinkingContent = thinkingSegments + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0) + .map((segment) => `${segment}`) + .join('\n\n'); + + const parts: string[] = []; + if (thinkingContent) { + parts.push(thinkingContent); + } + if (trimmedAssistant) { + parts.push(trimmedAssistant); + } + return parts.join('\n\n').trim(); + }; + + /** + * Reset assistant buffers after emitting a final message. + */ + const resetAssistantBuffers = (): void => { + assistantBuffer = ''; + thinkingSegments.length = 0; + assistantMessageId = null; + assistantCreatedAt = null; + }; + // Helper: emit assistant message const emitAssistant = (isFinal: boolean): void => { - const content = assistantBuffer.trim(); + const content = buildAssistantPayload(); if (!content) return; if (!assistantMessageId) { @@ -251,7 +317,9 @@ export class CodexEngine implements AgentEngine { // Event handlers for specific item types const emitCommandStart = (item: Record): void => { + const id = this.pickFirstString(item.id) ?? randomUUID(); const command = this.pickFirstString(item.command); + activeCommands.set(id, { command }); dispatchToolMessage( command ? `Running: ${command}` : 'Running command', { @@ -266,7 +334,12 @@ export class CodexEngine implements AgentEngine { }; const emitCommandResult = (item: Record): void => { - const command = this.pickFirstString(item.command); + const id = this.pickFirstString(item.id); + const tracked = id ? activeCommands.get(id) : undefined; + if (id) { + activeCommands.delete(id); + } + const command = this.pickFirstString(item.command) ?? tracked?.command; const output = this.pickFirstString(item.aggregated_output) ?? ''; const exitCode = typeof item.exit_code === 'number' ? item.exit_code : undefined; const status = this.pickFirstString(item.status); @@ -347,12 +420,18 @@ export class CodexEngine implements AgentEngine { const record = delta as Record; const type = this.pickFirstString(record.type); - if (type === 'agent_message' || type === 'reasoning') { + if (type === 'agent_message') { const text = this.pickFirstString(record.text); if (text) { assistantBuffer += text; emitAssistant(false); } + } else if (type === 'reasoning') { + const text = this.pickFirstString(record.text); + if (text) { + thinkingSegments.push(text); + emitAssistant(false); + } } else if (type === 'todo_list') { emitTodoListUpdate(record, 'update'); } @@ -377,12 +456,21 @@ export class CodexEngine implements AgentEngine { const text = this.pickFirstString(record.text); if (text) assistantBuffer = text; emitAssistant(true); + resetAssistantBuffers(); + break; + } + case 'reasoning': { + const text = this.pickFirstString(record.text); + if (text) { + thinkingSegments.push(text); + emitAssistant(false); + } break; } default: { const text = this.pickFirstString(record.text); if (text) { - assistantBuffer += text; + thinkingSegments.push(text); emitAssistant(false); } break; @@ -411,12 +499,33 @@ export class CodexEngine implements AgentEngine { }, timeoutMs); timeoutHandle.unref?.(); - // Cleanup timeout when child closes - child.on('close', () => { + // Cleanup timeout and handle abnormal exit + child.on('close', (code: number | null, closeSignal: NodeJS.Signals | null) => { if (timeoutHandle) { clearTimeout(timeoutHandle); timeoutHandle = null; } + + // If already timed out, settled, or completed normally, do nothing + if (timedOut || settled || hasCompleted) { + return; + } + + // Build error detail from exit code and signal + const detailParts: string[] = []; + if (typeof code === 'number') { + detailParts.push(`exit code ${code}`); + } + if (closeSignal) { + detailParts.push(`signal ${closeSignal}`); + } + const detail = detailParts.length > 0 ? detailParts.join(', ') : 'unexpected shutdown'; + + // Emit final assistant message and mark as failed + emitAssistant(true); + resetAssistantBuffers(); + hasCompleted = true; + finish(new Error(`CodexEngine: process terminated (${detail})`)); }); // Main event processing loop (wrapped in IIFE to handle async properly) @@ -448,6 +557,9 @@ export class CodexEngine implements AgentEngine { case 'item.failed': { const item = (event as { item?: unknown }).item ?? null; handleItemCompleted(item); + // Flush assistant message before throwing (aligned with other/cweb) + emitAssistant(true); + resetAssistantBuffers(); const msg = (item && typeof item === 'object' && @@ -457,6 +569,9 @@ export class CodexEngine implements AgentEngine { throw new Error(msg); } case 'error': { + // Flush assistant message before throwing (aligned with other/cweb) + emitAssistant(true); + resetAssistantBuffers(); const msg = this.pickFirstString((event as { error?: unknown }).error) || this.pickFirstString((event as { message?: unknown }).message) || @@ -466,6 +581,8 @@ export class CodexEngine implements AgentEngine { throw new Error(msg); } case 'turn.completed': + emitAssistant(true); + resetAssistantBuffers(); hasCompleted = true; break; default: @@ -482,6 +599,7 @@ export class CodexEngine implements AgentEngine { // Emit final assistant message if not already completed if (!hasCompleted) { emitAssistant(true); + resetAssistantBuffers(); hasCompleted = true; } @@ -499,6 +617,61 @@ export class CodexEngine implements AgentEngine { return path.resolve(base); } + /** + * Append project context (file listing) to the prompt. + * Aligned with other/cweb implementation. + */ + private async appendProjectContext(baseInstruction: string, repoPath: string): Promise { + try { + const fs = await import('node:fs/promises'); + const entries = await fs.readdir(repoPath, { withFileTypes: true }); + const visible = entries + .filter((entry) => !entry.name.startsWith('.git') && entry.name !== 'AGENTS.md') + .map((entry) => entry.name); + + if (visible.length === 0) { + return `${baseInstruction} + + +This is an empty project directory. Work directly in the current folder without creating extra subdirectories. +`; + } + + return `${baseInstruction} + + +Current files in project directory: ${visible.sort().join(', ')} +Work directly in the current directory. Do not create subdirectories unless specifically requested. +`; + } catch (error) { + console.warn('[CodexEngine] Failed to append project context:', error); + return baseInstruction; + } + } + + /** + * Build Codex CLI configuration arguments from the resolved config. + * Aligned with other/cweb implementation for feature parity. + */ + private buildCodexConfigArgs(config: CodexEngineConfig): string[] { + const args: string[] = []; + + const pushConfig = (key: string, value: string | number | boolean): void => { + args.push('-c', `${key}=${String(value)}`); + }; + + pushConfig('include_apply_patch_tool', config.includeApplyPatchTool); + pushConfig('include_plan_tool', config.includePlanTool); + pushConfig('tools.web_search_request', config.enableWebSearch); + pushConfig('use_experimental_streamable_shell_tool', config.useStreamableShell); + pushConfig('sandbox_mode', config.sandboxMode); + pushConfig('max_turns', config.maxTurns); + pushConfig('max_thinking_tokens', config.maxThinkingTokens); + args.push('-c', `instructions=${JSON.stringify(config.autoInstructions)}`); + + return args; + } + /** * Write an attachment to a temporary file and return its path. */ @@ -530,6 +703,17 @@ export class CodexEngine implements AgentEngine { if (globalPath) { extraPaths.push(globalPath); } + // Enhanced Windows PATH handling (aligned with other/cweb) + if (process.platform === 'win32') { + const appData = process.env.APPDATA; + const localApp = process.env.LOCALAPPDATA; + if (appData) { + extraPaths.push(path.join(appData, 'npm')); + } + if (localApp) { + extraPaths.push(path.join(localApp, 'Programs', 'nodejs')); + } + } if (extraPaths.length > 0) { const currentPath = env.PATH || env.Path || ''; env.PATH = [...extraPaths, currentPath].filter(Boolean).join(path.delimiter); diff --git a/app/native-server/src/agent/engines/types.ts b/app/native-server/src/agent/engines/types.ts index e214427..7b54a63 100644 --- a/app/native-server/src/agent/engines/types.ts +++ b/app/native-server/src/agent/engines/types.ts @@ -1,4 +1,5 @@ import type { AgentAttachment, RealtimeEvent } from '../types'; +import type { CodexEngineConfig } from 'chrome-mcp-shared'; export interface EngineInitOptions { sessionId: string; @@ -50,6 +51,11 @@ export interface EngineInitOptions { * Only applicable to ClaudeEngine; when true, CCR will be auto-detected. */ useCcr?: boolean; + /** + * Optional Codex-specific configuration overrides. + * Only applicable to CodexEngine; merged with DEFAULT_CODEX_CONFIG. + */ + codexConfig?: Partial; } /** diff --git a/app/native-server/src/agent/session-service.ts b/app/native-server/src/agent/session-service.ts index f840d14..66a6f0d 100644 --- a/app/native-server/src/agent/session-service.ts +++ b/app/native-server/src/agent/session-service.ts @@ -44,6 +44,11 @@ export interface SessionOptionsConfig { enableFileCheckpointing?: boolean; sandbox?: Record; env?: Record; + /** + * Optional Codex-specific configuration overrides. + * Only applicable when using CodexEngine. + */ + codexConfig?: Partial; } /** diff --git a/packages/shared/src/agent-types.ts b/packages/shared/src/agent-types.ts index d0d0316..503b25a 100644 --- a/packages/shared/src/agent-types.ts +++ b/packages/shared/src/agent-types.ts @@ -183,6 +183,11 @@ export interface AgentSessionOptionsConfig { enableFileCheckpointing?: boolean; sandbox?: Record; env?: Record; + /** + * Optional Codex-specific configuration overrides. + * Only applicable when using CodexEngine. + */ + codexConfig?: Partial; } /** @@ -268,3 +273,64 @@ export interface AgentStoredMessage { createdAt?: string; requestId?: string; } + +// ============================================================ +// Codex Engine Configuration +// ============================================================ + +/** + * Sandbox mode for Codex CLI execution. + */ +export type CodexSandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'; + +/** + * Configuration options for Codex Engine. + * These can be overridden per-session via session settings. + */ +export interface CodexEngineConfig { + /** Enable apply_patch tool for file modifications. Default: true */ + includeApplyPatchTool: boolean; + /** Enable plan tool for task planning. Default: true */ + includePlanTool: boolean; + /** Enable web search capability. Default: true */ + enableWebSearch: boolean; + /** Use experimental streamable shell tool. Default: true */ + useStreamableShell: boolean; + /** Sandbox mode for command execution. Default: 'danger-full-access' */ + sandboxMode: CodexSandboxMode; + /** Maximum number of turns. Default: 20 */ + maxTurns: number; + /** Maximum thinking tokens. Default: 4096 */ + maxThinkingTokens: number; + /** Auto instructions for autonomous behavior. Default: AUTO_INSTRUCTIONS */ + autoInstructions: string; + /** Append project context (file listing) to prompt. Default: true */ + appendProjectContext: boolean; +} + +/** + * Default auto instructions for Codex to act autonomously. + * Aligned with other/cweb implementation. + */ +export const CODEX_AUTO_INSTRUCTIONS = `Act autonomously without asking for confirmations. +Use apply_patch to create and modify files directly in the current working directory (do not create subdirectories unless the user explicitly requests it). +Use exec_command to run, build, and test as needed. +You have full permissions. Keep taking concrete actions until the task is complete. +Respect the existing project structure when creating or modifying files. +Prefer concise status updates over questions.`; + +/** + * Default configuration for Codex Engine. + * Aligned with other/cweb implementation for feature parity. + */ +export const DEFAULT_CODEX_CONFIG: CodexEngineConfig = { + includeApplyPatchTool: true, + includePlanTool: true, + enableWebSearch: true, + useStreamableShell: true, + sandboxMode: 'danger-full-access', + maxTurns: 20, + maxThinkingTokens: 4096, + autoInstructions: CODEX_AUTO_INSTRUCTIONS, + appendProjectContext: true, +};