diff --git a/app/src/ai/agent/conversation.rs b/app/src/ai/agent/conversation.rs index 05a2fbbe8..191d09da1 100644 --- a/app/src/ai/agent/conversation.rs +++ b/app/src/ai/agent/conversation.rs @@ -3516,6 +3516,7 @@ pub enum AIAgentHarness { Oz, ClaudeCode, Gemini, + Codex, Unknown, } diff --git a/app/src/ai/agent_sdk/driver/harness/codex.rs b/app/src/ai/agent_sdk/driver/harness/codex.rs new file mode 100644 index 000000000..1560dbede --- /dev/null +++ b/app/src/ai/agent_sdk/driver/harness/codex.rs @@ -0,0 +1,243 @@ +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use parking_lot::Mutex; +use tempfile::NamedTempFile; +use warp_cli::agent::Harness; +use warp_managed_secrets::ManagedSecretValue; +use warpui::{ModelHandle, ModelSpawner}; + +use crate::ai::agent::conversation::AIConversationId; +use crate::ai::ambient_agents::AmbientAgentTaskId; +use crate::server::server_api::harness_support::HarnessSupportClient; +use crate::server::server_api::ServerApi; +use crate::terminal::model::block::BlockId; +use crate::terminal::CLIAgent; + +use super::super::terminal::{CommandHandle, TerminalDriver}; +use super::super::{AgentDriver, AgentDriverError}; +use super::{write_temp_file, HarnessRunner, ResumePayload, SavePoint, ThirdPartyHarness}; + +pub(crate) struct CodexHarness; + +/// Format slug sent to the server when creating a Codex conversation. +const CODEX_CLI_FORMAT: &str = "codex_cli"; +/// Slash command Codex's TUI recognises as a graceful shutdown. +const CODEX_EXIT_COMMAND: &str = "/exit"; + +#[cfg_attr(not(target_family = "wasm"), async_trait)] +#[cfg_attr(target_family = "wasm", async_trait(?Send))] +impl ThirdPartyHarness for CodexHarness { + fn harness(&self) -> Harness { + Harness::Codex + } + + fn cli_agent(&self) -> CLIAgent { + CLIAgent::Codex + } + + fn install_docs_url(&self) -> Option<&'static str> { + Some("https://developers.openai.com/codex/cli") + } + + fn prepare_environment_config( + &self, + _working_dir: &Path, + system_prompt: Option<&str>, + _secrets: &HashMap, + ) -> Result<(), AgentDriverError> { + prepare_codex_environment_config(system_prompt).map_err(|error| { + AgentDriverError::HarnessConfigSetupFailed { + harness: self.cli_agent().command_prefix().to_owned(), + error, + } + }) + } + + fn build_runner( + &self, + prompt: &str, + system_prompt: Option<&str>, + _resumption_prompt: Option<&str>, + working_dir: &Path, + _task_id: Option, + server_api: Arc, + terminal_driver: ModelHandle, + _resume: Option, + ) -> Result, AgentDriverError> { + // TODO(REMOTE-1503): support resume for Codex. + let client: Arc = server_api; + Ok(Box::new(CodexHarnessRunner::new( + self.cli_agent().command_prefix(), + prompt, + system_prompt, + working_dir, + client, + terminal_driver, + )?)) + } +} + +/// Build the shell command that launches the Codex TUI. +/// +/// `--dangerously-bypass-approvals-and-sandbox` disables both the sandbox and approval +/// prompts so the agent can run autonomously. +fn codex_command(cli_name: &str, prompt_path: &str) -> String { + format!("{cli_name} --dangerously-bypass-approvals-and-sandbox \"$(cat '{prompt_path}')\"") +} + +enum CodexRunnerState { + Preexec, + Running { + conversation_id: AIConversationId, + block_id: BlockId, + }, +} + +struct CodexHarnessRunner { + command: String, + /// Held so the temp file is cleaned up when the runner is dropped. + _temp_prompt_file: NamedTempFile, + client: Arc, + terminal_driver: ModelHandle, + state: Mutex, +} + +impl CodexHarnessRunner { + fn new( + cli_command: &str, + prompt: &str, + _system_prompt: Option<&str>, + _working_dir: &Path, + client: Arc, + terminal_driver: ModelHandle, + ) -> Result { + let temp_file = write_temp_file("oz_prompt_", prompt)?; + let prompt_path = temp_file.path().display().to_string(); + + Ok(Self { + command: codex_command(cli_command, &prompt_path), + _temp_prompt_file: temp_file, + client, + terminal_driver, + state: Mutex::new(CodexRunnerState::Preexec), + }) + } +} + +#[cfg_attr(not(target_family = "wasm"), async_trait)] +#[cfg_attr(target_family = "wasm", async_trait(?Send))] +impl HarnessRunner for CodexHarnessRunner { + async fn start( + &self, + foreground: &ModelSpawner, + ) -> Result { + let conversation_id = self + .client + .create_external_conversation(CODEX_CLI_FORMAT) + .await + .map_err(|e| { + log::error!("Failed to create external conversation: {e}"); + AgentDriverError::ConfigBuildFailed(e) + })?; + log::info!("Created external conversation {conversation_id}"); + + let command = self.command.clone(); + let terminal_driver = self.terminal_driver.clone(); + let command_handle = foreground + .spawn(move |_, ctx| { + terminal_driver.update(ctx, |driver, ctx| driver.execute_command(&command, ctx)) + }) + .await?? + .await?; + + *self.state.lock() = CodexRunnerState::Running { + conversation_id, + block_id: command_handle.block_id().clone(), + }; + + Ok(command_handle) + } + + async fn exit(&self, foreground: &ModelSpawner) -> Result<()> { + log::info!("Sending /exit to Codex CLI"); + let terminal_driver = self.terminal_driver.clone(); + foreground + .spawn(move |_, ctx| { + terminal_driver.update(ctx, |driver, ctx| { + driver.send_text_to_cli(CODEX_EXIT_COMMAND.to_string(), ctx); + }); + }) + .await + .map_err(|_| anyhow::anyhow!("Agent driver dropped while sending /exit")) + } + + async fn save_conversation( + &self, + save_point: SavePoint, + foreground: &ModelSpawner, + ) -> Result<()> { + if matches!(save_point, SavePoint::Periodic) + && !super::has_running_cli_agent(&self.terminal_driver, foreground).await + { + log::debug!("Will not save conversation, Codex not in progress"); + return Ok(()); + } + + let (conversation_id, block_id) = match &*self.state.lock() { + CodexRunnerState::Preexec => { + log::warn!("save_conversation called before start"); + return Ok(()); + } + CodexRunnerState::Running { + conversation_id, + block_id, + } => (*conversation_id, block_id.clone()), + }; + + // TODO(REMOTE-1504) Also save the conversation transcript. + super::upload_current_block_snapshot( + foreground, + &self.terminal_driver, + self.client.as_ref(), + conversation_id, + block_id, + ) + .await + } +} + +const CODEX_CONFIG_DIR: &str = ".codex"; +const CODEX_AGENTS_OVERRIDE_FILE_NAME: &str = "AGENTS.override.md"; + +fn prepare_codex_environment_config(system_prompt: Option<&str>) -> Result<()> { + let Some(prompt) = system_prompt else { + return Ok(()); + }; + let home_dir = + dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?; + write_codex_agents_override(&home_dir.join(CODEX_CONFIG_DIR), prompt) +} + +fn write_codex_agents_override(codex_dir: &Path, system_prompt: &str) -> Result<()> { + fs::create_dir_all(codex_dir).with_context(|| { + format!( + "Failed to create Codex config dir at {}", + codex_dir.display() + ) + })?; + + // Note: this currently works because we are only doing this for cloud agents; if we enable + // this for local runs we'll want to make sure we don't clobber any existing file overrides. + let prompt_path = codex_dir.join(CODEX_AGENTS_OVERRIDE_FILE_NAME); + fs::write(&prompt_path, system_prompt).with_context(|| { + format!( + "Failed to write Codex system prompt to {}", + prompt_path.display() + ) + }) +} diff --git a/app/src/ai/agent_sdk/driver/harness/mod.rs b/app/src/ai/agent_sdk/driver/harness/mod.rs index 34816a361..cbb083520 100644 --- a/app/src/ai/agent_sdk/driver/harness/mod.rs +++ b/app/src/ai/agent_sdk/driver/harness/mod.rs @@ -36,11 +36,13 @@ use super::{ mod claude_code; pub(crate) mod claude_transcript; +mod codex; mod gemini; mod json_utils; pub(crate) use claude_code::ClaudeHarness; use claude_transcript::ClaudeResumeInfo; +use codex::CodexHarness; use gemini::GeminiHarness; /// Harness-agnostic payload describing how to resume an existing conversation. @@ -167,6 +169,7 @@ pub(crate) fn harness_kind(harness: Harness) -> Result Ok(HarnessKind::ThirdParty(Box::new(ClaudeHarness))), Harness::OpenCode => Ok(HarnessKind::Unsupported(Harness::OpenCode)), Harness::Gemini => Ok(HarnessKind::ThirdParty(Box::new(GeminiHarness))), + Harness::Codex => Ok(HarnessKind::ThirdParty(Box::new(CodexHarness))), Harness::Unknown => Err(AgentDriverError::InvalidRuntimeState), } } diff --git a/app/src/ai/agent_sdk/mod.rs b/app/src/ai/agent_sdk/mod.rs index 391e3c2de..54e693cbe 100644 --- a/app/src/ai/agent_sdk/mod.rs +++ b/app/src/ai/agent_sdk/mod.rs @@ -1389,6 +1389,7 @@ fn resolve_orchestration_harness_label() -> &'static str { Some(Harness::Claude) => "claude", Some(Harness::OpenCode) => "opencode", Some(Harness::Gemini) => "gemini", + Some(Harness::Codex) => "codex", Some(Harness::Unknown) | None => "unknown", } } diff --git a/app/src/ai/ambient_agents/task.rs b/app/src/ai/ambient_agents/task.rs index 375b1d48a..926add972 100644 --- a/app/src/ai/ambient_agents/task.rs +++ b/app/src/ai/ambient_agents/task.rs @@ -92,6 +92,7 @@ pub(crate) fn harness_from_name(name: &str) -> Harness { "claude" => Harness::Claude, "opencode" => Harness::OpenCode, "gemini" => Harness::Gemini, + "codex" => Harness::Codex, "oz" => Harness::Oz, other => { log::warn!("Unknown harness config name: {other:?}; treating as Unknown"); diff --git a/app/src/ai/blocklist/history_model/conversation_loader.rs b/app/src/ai/blocklist/history_model/conversation_loader.rs index 8a9221453..b029c88c9 100644 --- a/app/src/ai/blocklist/history_model/conversation_loader.rs +++ b/app/src/ai/blocklist/history_model/conversation_loader.rs @@ -135,7 +135,7 @@ pub async fn load_conversation_from_server( } } } - AIAgentHarness::ClaudeCode | AIAgentHarness::Gemini => { + AIAgentHarness::ClaudeCode | AIAgentHarness::Gemini | AIAgentHarness::Codex => { if !FeatureFlag::AgentHarness.is_enabled() { log::warn!("Ignoring non-Oz conversation {conversation_id}: AgentHarness flag is disabled"); return None; diff --git a/app/src/ai/harness_display.rs b/app/src/ai/harness_display.rs index db287b80e..8bc99de10 100644 --- a/app/src/ai/harness_display.rs +++ b/app/src/ai/harness_display.rs @@ -9,7 +9,7 @@ use warp_cli::agent::Harness; use crate::ai::agent::conversation::AIAgentHarness; use crate::ai::blocklist::CLAUDE_ORANGE; -use crate::terminal::cli_agent::GEMINI_BLUE; +use crate::terminal::cli_agent::{GEMINI_BLUE, OPENAI_COLOR}; use crate::ui_components::icons::Icon; /// User-visible display name for a [`Harness`]. @@ -19,6 +19,7 @@ pub fn display_name(harness: Harness) -> &'static str { Harness::Claude => "Claude Code", Harness::OpenCode => "OpenCode", Harness::Gemini => "Gemini CLI", + Harness::Codex => "Codex", Harness::Unknown => "Unknown", } } @@ -30,6 +31,7 @@ pub fn icon_for(harness: Harness) -> Icon { Harness::Claude => Icon::ClaudeLogo, Harness::OpenCode => Icon::OpenCodeLogo, Harness::Gemini => Icon::GeminiLogo, + Harness::Codex => Icon::OpenAILogo, Harness::Unknown => Icon::HelpCircle, } } @@ -42,6 +44,7 @@ pub fn brand_color(harness: Harness) -> Option { Harness::Claude => Some(CLAUDE_ORANGE), Harness::OpenCode => None, Harness::Gemini => Some(GEMINI_BLUE), + Harness::Codex => Some(OPENAI_COLOR), Harness::Unknown => None, } } @@ -54,6 +57,7 @@ impl From for Harness { AIAgentHarness::Oz => Harness::Oz, AIAgentHarness::ClaudeCode => Harness::Claude, AIAgentHarness::Gemini => Harness::Gemini, + AIAgentHarness::Codex => Harness::Codex, AIAgentHarness::Unknown => Harness::Unknown, } } diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs index cd6477e18..0540aa314 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -3779,6 +3779,7 @@ impl PaneGroup { let harness = match cli_conversation.metadata.harness { AIAgentHarness::ClaudeCode => Some(Harness::Claude), AIAgentHarness::Gemini => Some(Harness::Gemini), + AIAgentHarness::Codex => Some(Harness::Codex), AIAgentHarness::Oz => None, AIAgentHarness::Unknown => Some(Harness::Unknown), }; diff --git a/app/src/pane_group/pane/local_harness_launch.rs b/app/src/pane_group/pane/local_harness_launch.rs index b65844aa1..d1535e8c6 100644 --- a/app/src/pane_group/pane/local_harness_launch.rs +++ b/app/src/pane_group/pane/local_harness_launch.rs @@ -59,7 +59,9 @@ pub(super) fn build_local_opencode_child_command(prompt: &str) -> String { fn local_child_task_config(harness: Harness) -> Option { match harness { - Harness::Oz | Harness::OpenCode | Harness::Gemini | Harness::Unknown => None, + Harness::Oz | Harness::OpenCode | Harness::Gemini | Harness::Codex | Harness::Unknown => { + None + } Harness::Claude => Some(AgentConfigSnapshot { harness: Some(HarnessConfig::from_harness_type(harness)), ..Default::default() @@ -87,6 +89,7 @@ pub(super) async fn prepare_local_harness_child_launch( let command = match harness { Harness::Oz => unreachable!("normalize_local_child_harness filters out Oz"), Harness::Unknown => unreachable!("normalize_local_child_harness filters out Unknown"), + Harness::Codex => unreachable!("normalize_local_child_harness filters out Codex"), Harness::Claude => { let working_dir = startup_directory .or_else(|| std::env::current_dir().ok()) diff --git a/app/src/server/server_api/ai.rs b/app/src/server/server_api/ai.rs index 6e859d363..590798550 100644 --- a/app/src/server/server_api/ai.rs +++ b/app/src/server/server_api/ai.rs @@ -2267,6 +2267,7 @@ fn convert_harness(harness: warp_graphql::ai::AgentHarness) -> AIAgentHarness { warp_graphql::ai::AgentHarness::Oz => AIAgentHarness::Oz, warp_graphql::ai::AgentHarness::ClaudeCode => AIAgentHarness::ClaudeCode, warp_graphql::ai::AgentHarness::Gemini => AIAgentHarness::Gemini, + warp_graphql::ai::AgentHarness::Codex => AIAgentHarness::Codex, warp_graphql::ai::AgentHarness::Other(value) => { report_error!(anyhow!( "Invalid AgentHarness '{value}'. Make sure to update client GraphQL types!" diff --git a/app/src/terminal/cli_agent.rs b/app/src/terminal/cli_agent.rs index fd909f3d5..91d890c49 100644 --- a/app/src/terminal/cli_agent.rs +++ b/app/src/terminal/cli_agent.rs @@ -40,7 +40,7 @@ pub(crate) const GEMINI_BLUE: ColorU = ColorU { }; /// OpenAI brand color (dark gray/black) -const OPENAI_COLOR: ColorU = ColorU { +pub(crate) const OPENAI_COLOR: ColorU = ColorU { r: 0, g: 0, b: 0, diff --git a/app/src/terminal/view/ambient_agent/harness_selector.rs b/app/src/terminal/view/ambient_agent/harness_selector.rs index f7c86c7a0..627f88bec 100644 --- a/app/src/terminal/view/ambient_agent/harness_selector.rs +++ b/app/src/terminal/view/ambient_agent/harness_selector.rs @@ -234,6 +234,7 @@ fn build_menu_items( item_for(Harness::Oz), item_for(Harness::Claude), item_for(Harness::Gemini), + item_for(Harness::Codex), ] } diff --git a/app/src/terminal/view/ambient_agent/view_impl.rs b/app/src/terminal/view/ambient_agent/view_impl.rs index f8817c198..00b116302 100644 --- a/app/src/terminal/view/ambient_agent/view_impl.rs +++ b/app/src/terminal/view/ambient_agent/view_impl.rs @@ -488,6 +488,7 @@ impl TerminalView { Harness::Claude => matches!(cli_agent, CLIAgent::Claude), Harness::OpenCode => matches!(cli_agent, CLIAgent::OpenCode), Harness::Gemini => matches!(cli_agent, CLIAgent::Gemini), + Harness::Codex => matches!(cli_agent, CLIAgent::Codex), Harness::Unknown => false, } } diff --git a/crates/graphql/src/api/ai.rs b/crates/graphql/src/api/ai.rs index de88d3608..edf06bc71 100644 --- a/crates/graphql/src/api/ai.rs +++ b/crates/graphql/src/api/ai.rs @@ -123,6 +123,7 @@ pub enum AgentHarness { Oz, ClaudeCode, Gemini, + Codex, #[cynic(fallback)] Other(String), } diff --git a/crates/warp_cli/src/agent.rs b/crates/warp_cli/src/agent.rs index 647ba7632..3d4de67c1 100644 --- a/crates/warp_cli/src/agent.rs +++ b/crates/warp_cli/src/agent.rs @@ -134,6 +134,9 @@ pub enum Harness { /// Delegate to the `gemini` CLI. #[value(name = "gemini")] Gemini, + /// Delegate to the `codex` CLI. + #[value(name = "codex")] + Codex, /// A harness produced by a newer client/server that this client doesn't /// recognize. Surfaced via deserialization fallbacks (e.g. unknown GraphQL /// enum values, unknown `harness_type` strings); never selectable from the @@ -151,7 +154,8 @@ impl Harness { pub fn parse_local_child_harness(value: &str) -> Option { match Self::parse_orchestration_harness(value) { Some(harness @ (Self::Claude | Self::OpenCode)) => Some(harness), - Some(Self::Oz) | Some(Self::Gemini) | Some(Self::Unknown) | None => None, + Some(Self::Oz) | Some(Self::Gemini) | Some(Self::Codex) | Some(Self::Unknown) + | None => None, } } @@ -161,6 +165,7 @@ impl Harness { Self::Claude => "Claude Code", Self::OpenCode => "OpenCode", Self::Gemini => "Gemini CLI", + Self::Codex => "Codex", Self::Unknown => "Unknown", } } @@ -173,6 +178,7 @@ impl fmt::Display for Harness { Harness::Claude => "claude", Harness::OpenCode => "opencode", Harness::Gemini => "gemini", + Harness::Codex => "codex", Harness::Unknown => "unknown", }; f.write_str(name) diff --git a/crates/warp_cli/src/lib_tests.rs b/crates/warp_cli/src/lib_tests.rs index 69cbbc6ea..7ee73def0 100644 --- a/crates/warp_cli/src/lib_tests.rs +++ b/crates/warp_cli/src/lib_tests.rs @@ -1538,6 +1538,19 @@ fn harness_parse_local_child_harness_rejects_oz() { ); } +#[test] +fn harness_parse_orchestration_harness_accepts_codex() { + assert_eq!( + Harness::parse_orchestration_harness("codex"), + Some(Harness::Codex) + ); +} + +#[test] +fn harness_parse_local_child_harness_rejects_codex() { + assert_eq!(Harness::parse_local_child_harness("codex"), None); +} + #[test] fn agent_run_cloud_accepts_claude_auth_secret_with_harness() { let args = Args::try_parse_from([ diff --git a/crates/warp_graphql_schema/api/schema.graphql b/crates/warp_graphql_schema/api/schema.graphql index 8cbdf50c9..0d38ffe52 100644 --- a/crates/warp_graphql_schema/api/schema.graphql +++ b/crates/warp_graphql_schema/api/schema.graphql @@ -215,6 +215,7 @@ enum AdminEnablementSetting { """The harness that produced an agent conversation.""" enum AgentHarness { CLAUDE_CODE + CODEX GEMINI OZ }