Cargo.lock 🔗
@@ -12089,6 +12089,8 @@ dependencies = [
"aho-corasick",
"anyhow",
"askpass",
+ "async-compression",
+ "async-tar",
"async-trait",
"base64 0.22.1",
"buffer_diff",
Richard Feldman created
Cargo.lock | 2
crates/agent_servers/src/agent_servers.rs | 2
crates/agent_servers/src/codex.rs | 81 ++++++++++
crates/agent_servers/src/e2e_tests.rs | 1
crates/agent_ui/src/agent_configuration.rs | 11 +
crates/agent_ui/src/agent_panel.rs | 39 ++++
crates/agent_ui/src/agent_ui.rs | 3
crates/project/Cargo.toml | 2
crates/project/src/agent_server_store.rs | 159 +++++++++++++++++++++
crates/settings/src/settings_content/agent.rs | 1
10 files changed, 297 insertions(+), 4 deletions(-)
@@ -12089,6 +12089,8 @@ dependencies = [
"aho-corasick",
"anyhow",
"askpass",
+ "async-compression",
+ "async-tar",
"async-trait",
"base64 0.22.1",
"buffer_diff",
@@ -1,5 +1,6 @@
mod acp;
mod claude;
+mod codex;
mod custom;
mod gemini;
@@ -8,6 +9,7 @@ pub mod e2e_tests;
pub use claude::*;
use client::ProxySettings;
+pub use codex::*;
use collections::HashMap;
pub use custom::*;
use fs::Fs;
@@ -0,0 +1,81 @@
+use agent_client_protocol as acp;
+use std::rc::Rc;
+use std::{any::Any, path::Path};
+
+use crate::{AgentServer, AgentServerDelegate, load_proxy_env};
+use acp_thread::AgentConnection;
+use anyhow::{Context as _, Result};
+use gpui::{App, SharedString, Task};
+
+#[derive(Clone)]
+pub struct Codex;
+
+impl AgentServer for Codex {
+ fn telemetry_id(&self) -> &'static str {
+ "codex"
+ }
+
+ fn name(&self) -> SharedString {
+ "Codex".into()
+ }
+
+ fn logo(&self) -> ui::IconName {
+ // No dedicated Codex icon yet; use the generic AI icon.
+ ui::IconName::Ai
+ }
+
+ fn connect(
+ &self,
+ root_dir: Option<&Path>,
+ delegate: AgentServerDelegate,
+ cx: &mut App,
+ ) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
+ let name = self.name();
+ let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned());
+ let is_remote = delegate.project.read(cx).is_via_remote_server();
+ let store = delegate.store.downgrade();
+ let extra_env = load_proxy_env(cx);
+ // No modes for Codex (yet).
+ let default_mode = self.default_mode(cx);
+
+ cx.spawn(async move |cx| {
+ // Look up the external agent registered under the "codex" name.
+ // The AgentServerStore is responsible for:
+ // - Downloading the correct GitHub release tar.gz for the OS/arch
+ // - Extracting the binary
+ // - Returning an AgentServerCommand to launch the binary
+ // - Always reporting "no updates" for now
+ let (command, root_dir, login) = store
+ .update(cx, |store, cx| {
+ let agent = store
+ .get_external_agent(&"codex".into())
+ .context("Codex is not registered")?;
+ anyhow::Ok(agent.get_command(
+ root_dir.as_deref(),
+ extra_env,
+ delegate.status_tx,
+ // For now, Codex should report that there are no updates.
+ // The LocalCodex implementation in AgentServerStore should not send any updates.
+ delegate.new_version_available,
+ &mut cx.to_async(),
+ ))
+ })??
+ .await?;
+
+ let connection = crate::acp::connect(
+ name,
+ command,
+ root_dir.as_ref(),
+ default_mode,
+ is_remote,
+ cx,
+ )
+ .await?;
+ Ok((connection, login))
+ })
+ }
+
+ fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+ self
+ }
+}
@@ -483,6 +483,7 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
default_mode: None,
}),
gemini: Some(crate::gemini::tests::local_command().into()),
+ codex: None,
custom: collections::HashMap::default(),
},
cx,
@@ -26,7 +26,7 @@ use language_model::{
};
use notifications::status_toast::{StatusToast, ToastIcon};
use project::{
- agent_server_store::{AgentServerStore, CLAUDE_CODE_NAME, GEMINI_NAME},
+ agent_server_store::{AgentServerStore, CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME},
context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
};
use settings::{Settings, SettingsStore, update_settings_file};
@@ -1014,7 +1014,9 @@ impl AgentConfiguration {
.agent_server_store
.read(cx)
.external_agents()
- .filter(|name| name.0 != GEMINI_NAME && name.0 != CLAUDE_CODE_NAME)
+ .filter(|name| {
+ name.0 != GEMINI_NAME && name.0 != CLAUDE_CODE_NAME && name.0 != CODEX_NAME
+ })
.cloned()
.collect::<Vec<_>>();
@@ -1086,6 +1088,11 @@ impl AgentConfiguration {
IconName::AiClaude,
"Claude Code",
))
+ .child(Divider::horizontal().color(DividerColor::BorderFaded))
+ .child(self.render_agent_server(
+ IconName::Ai,
+ "Codex",
+ ))
.map(|mut parent| {
for agent in user_defined_agents {
parent = parent.child(Divider::horizontal().color(DividerColor::BorderFaded))
@@ -7,7 +7,7 @@ use acp_thread::AcpThread;
use agent2::{DbThreadMetadata, HistoryEntry};
use db::kvp::{Dismissable, KEY_VALUE_STORE};
use project::agent_server_store::{
- AgentServerCommand, AllAgentServersSettings, CLAUDE_CODE_NAME, GEMINI_NAME,
+ AgentServerCommand, AllAgentServersSettings, CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME,
};
use serde::{Deserialize, Serialize};
use settings::{
@@ -216,6 +216,7 @@ pub enum AgentType {
TextThread,
Gemini,
ClaudeCode,
+ Codex,
NativeAgent,
Custom {
name: SharedString,
@@ -230,6 +231,7 @@ impl AgentType {
Self::NativeAgent => "Agent 2".into(),
Self::Gemini => "Gemini CLI".into(),
Self::ClaudeCode => "Claude Code".into(),
+ Self::Codex => "Codex".into(),
Self::Custom { name, .. } => name.into(),
}
}
@@ -239,6 +241,7 @@ impl AgentType {
Self::Zed | Self::NativeAgent | Self::TextThread => None,
Self::Gemini => Some(IconName::AiGemini),
Self::ClaudeCode => Some(IconName::AiClaude),
+ Self::Codex => Some(IconName::Ai),
Self::Custom { .. } => Some(IconName::Terminal),
}
}
@@ -249,6 +252,7 @@ impl From<ExternalAgent> for AgentType {
match value {
ExternalAgent::Gemini => Self::Gemini,
ExternalAgent::ClaudeCode => Self::ClaudeCode,
+ ExternalAgent::Codex => Self::Codex,
ExternalAgent::Custom { name, command } => Self::Custom { name, command },
ExternalAgent::NativeAgent => Self::NativeAgent,
}
@@ -1427,6 +1431,11 @@ impl AgentPanel {
cx,
)
}
+ AgentType::Codex => {
+ self.selected_agent = AgentType::Codex;
+ self.serialize(cx);
+ self.external_thread(Some(crate::ExternalAgent::Codex), None, None, window, cx)
+ }
AgentType::Custom { name, command } => self.external_thread(
Some(crate::ExternalAgent::Custom { name, command }),
None,
@@ -1991,12 +2000,38 @@ impl AgentPanel {
}
}),
)
+ .item(
+ ContextMenuEntry::new("New Codex Thread")
+ .icon(IconName::Ai)
+ .disabled(is_via_collab)
+ .icon_color(Color::Muted)
+ .handler({
+ let workspace = workspace.clone();
+ move |window, cx| {
+ if let Some(workspace) = workspace.upgrade() {
+ workspace.update(cx, |workspace, cx| {
+ if let Some(panel) =
+ workspace.panel::<AgentPanel>(cx)
+ {
+ panel.update(cx, |panel, cx| {
+ panel.new_agent_thread(
+ AgentType::Codex,
+ window,
+ cx,
+ );
+ });
+ }
+ });
+ }
+ }
+ }),
+ )
.map(|mut menu| {
let agent_names = agent_server_store
.read(cx)
.external_agents()
.filter(|name| {
- name.0 != GEMINI_NAME && name.0 != CLAUDE_CODE_NAME
+ name.0 != GEMINI_NAME && name.0 != CLAUDE_CODE_NAME && name.0 != CODEX_NAME
})
.cloned()
.collect::<Vec<_>>();
@@ -167,6 +167,7 @@ enum ExternalAgent {
#[default]
Gemini,
ClaudeCode,
+ Codex,
NativeAgent,
Custom {
name: SharedString,
@@ -188,6 +189,7 @@ impl ExternalAgent {
Self::NativeAgent => "zed",
Self::Gemini => "gemini-cli",
Self::ClaudeCode => "claude-code",
+ Self::Codex => "codex",
Self::Custom { .. } => "custom",
}
}
@@ -200,6 +202,7 @@ impl ExternalAgent {
match self {
Self::Gemini => Rc::new(agent_servers::Gemini),
Self::ClaudeCode => Rc::new(agent_servers::ClaudeCode),
+ Self::Codex => Rc::new(agent_servers::Codex),
Self::NativeAgent => Rc::new(agent2::NativeAgentServer::new(fs, history)),
Self::Custom { name, command: _ } => {
Rc::new(agent_servers::CustomAgentServer::new(name.clone()))
@@ -30,6 +30,8 @@ test-support = [
aho-corasick.workspace = true
anyhow.workspace = true
askpass.workspace = true
+async-compression.workspace = true
+async-tar.workspace = true
async-trait.workspace = true
base64.workspace = true
buffer_diff.workspace = true
@@ -8,6 +8,7 @@ use std::{
};
use anyhow::{Context as _, Result, bail};
+use client::Client;
use collections::HashMap;
use fs::{Fs, RemoveOptions, RenameOptions};
use futures::StreamExt as _;
@@ -194,6 +195,28 @@ impl AgentServerStore {
.and_then(|settings| settings.custom_command()),
}),
);
+ self.external_agents.insert(
+ CODEX_NAME.into(),
+ Box::new(LocalCodex {
+ fs: fs.clone(),
+ project_environment: project_environment.clone(),
+ custom_command: new_settings
+ .codex
+ .clone()
+ .and_then(|settings| settings.custom_command()),
+ }),
+ );
+ self.external_agents.insert(
+ CODEX_NAME.into(),
+ Box::new(LocalCodex {
+ fs: fs.clone(),
+ project_environment: project_environment.clone(),
+ custom_command: new_settings
+ .codex
+ .clone()
+ .and_then(|settings| settings.custom_command()),
+ }),
+ );
self.external_agents
.extend(new_settings.custom.iter().map(|(name, settings)| {
(
@@ -275,6 +298,16 @@ impl AgentServerStore {
new_version_available_tx: None,
}) as Box<dyn ExternalAgentServer>,
),
+ (
+ CODEX_NAME.into(),
+ Box::new(RemoteExternalAgentServer {
+ project_id,
+ upstream_client: upstream_client.clone(),
+ name: CODEX_NAME.into(),
+ status_tx: None,
+ new_version_available_tx: None,
+ }) as Box<dyn ExternalAgentServer>,
+ ),
]
.into_iter()
.collect();
@@ -950,6 +983,129 @@ impl ExternalAgentServer for LocalClaudeCode {
}
}
+struct LocalCodex {
+ fs: Arc<dyn Fs>,
+ project_environment: Entity<ProjectEnvironment>,
+ custom_command: Option<AgentServerCommand>,
+}
+
+impl ExternalAgentServer for LocalCodex {
+ fn get_command(
+ &mut self,
+ root_dir: Option<&str>,
+ extra_env: HashMap<String, String>,
+ _status_tx: Option<watch::Sender<SharedString>>,
+ _new_version_available_tx: Option<watch::Sender<Option<String>>>,
+ cx: &mut AsyncApp,
+ ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
+ let fs = self.fs.clone();
+ let project_environment = self.project_environment.downgrade();
+ let custom_command = self.custom_command.clone();
+ let root_dir: Arc<Path> = root_dir
+ .map(|root_dir| Path::new(root_dir))
+ .unwrap_or(paths::home_dir())
+ .into();
+
+ cx.spawn(async move |cx| {
+ let mut env = project_environment
+ .update(cx, |project_environment, cx| {
+ project_environment.get_directory_environment(root_dir.clone(), cx)
+ })?
+ .await
+ .unwrap_or_default();
+
+ let (mut command, login) = if let Some(mut custom_command) = custom_command {
+ env.extend(custom_command.env.unwrap_or_default());
+ custom_command.env = Some(env);
+ (custom_command, None)
+ } else {
+ let dir = paths::data_dir().join("external_agents").join(CODEX_NAME);
+ fs.create_dir(&dir).await?;
+
+ // Find or install the latest Codex release (no update checks for now).
+ let http = cx.update(|cx| Client::global(cx).http_client())?;
+ let release = ::http_client::github::latest_github_release(
+ "zed-industries/codex-acp",
+ true,
+ false,
+ http.clone(),
+ )
+ .await
+ .context("fetching Codex latest release")?;
+
+ let version_dir = dir.join(&release.tag_name);
+ if !fs.is_dir(&version_dir).await {
+ // Determine the asset name based on CPU architecture.
+ let arch = if cfg!(target_arch = "x86_64") {
+ "x86_64"
+ } else if cfg!(target_arch = "aarch64") {
+ "aarch64"
+ } else {
+ std::env::consts::ARCH
+ };
+ let asset_name = format!("{arch}.tar.gz");
+ let asset_url = release
+ .assets
+ .iter()
+ .find(|a| a.name == asset_name)
+ .map(|a| a.browser_download_url.clone())
+ .context(format!(
+ "no asset named {asset_name} in release {}",
+ release.tag_name
+ ))?;
+
+ let http = http.clone();
+ let mut response = http
+ .get(&asset_url, Default::default(), true)
+ .await
+ .context("downloading Codex binary")?;
+ anyhow::ensure!(
+ response.status().is_success(),
+ "failed to download Codex release: {}",
+ response.status()
+ );
+
+ // Decompress and extract the tar.gz into the version directory.
+ let reader = futures::io::BufReader::new(response.body_mut());
+ let decoder = async_compression::futures::bufread::GzipDecoder::new(reader);
+ let mut archive = async_tar::Archive::new(decoder);
+ archive
+ .unpack(&version_dir)
+ .await
+ .context("extracting Codex binary")?;
+ }
+
+ let bin_name = if cfg!(windows) {
+ "codex-acp.exe"
+ } else {
+ "codex-acp"
+ };
+ let bin_path = version_dir.join(bin_name);
+ anyhow::ensure!(
+ fs.is_file(&bin_path).await,
+ "Missing Codex binary at {} after installation",
+ bin_path.to_string_lossy()
+ );
+
+ let mut cmd = AgentServerCommand {
+ path: bin_path,
+ args: Vec::new(),
+ env: None,
+ };
+ cmd.env = Some(env);
+ (cmd, None)
+ };
+
+ command.env.get_or_insert_default().extend(extra_env);
+ Ok((command, root_dir.to_string_lossy().into_owned(), login))
+ })
+ }
+
+ fn as_any_mut(&mut self) -> &mut dyn Any {
+ self
+ }
+}
+
struct LocalCustomAgent {
project_environment: Entity<ProjectEnvironment>,
command: AgentServerCommand,
@@ -991,11 +1147,13 @@ impl ExternalAgentServer for LocalCustomAgent {
pub const GEMINI_NAME: &'static str = "gemini";
pub const CLAUDE_CODE_NAME: &'static str = "claude";
+pub const CODEX_NAME: &'static str = "codex";
#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
pub struct AllAgentServersSettings {
pub gemini: Option<BuiltinAgentServerSettings>,
pub claude: Option<BuiltinAgentServerSettings>,
+ pub codex: Option<BuiltinAgentServerSettings>,
pub custom: HashMap<SharedString, CustomAgentServerSettings>,
}
#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
@@ -1070,6 +1228,7 @@ impl settings::Settings for AllAgentServersSettings {
Self {
gemini: agent_settings.gemini.map(Into::into),
claude: agent_settings.claude.map(Into::into),
+ codex: agent_settings.codex.map(Into::into),
custom: agent_settings
.custom
.into_iter()
@@ -282,6 +282,7 @@ impl From<&str> for LanguageModelProviderSetting {
pub struct AllAgentServersSettings {
pub gemini: Option<BuiltinAgentServerSettings>,
pub claude: Option<BuiltinAgentServerSettings>,
+ pub codex: Option<BuiltinAgentServerSettings>,
/// Custom agent servers configured by the user
#[serde(flatten)]