ACP over SSH (#37725)

Cole Miller and maan2003 created

This PR adds support for using external agents in SSH projects via ACP,
including automatic installation of Gemini CLI and Claude Code,
authentication with API keys (for Gemini) and CLI login, and custom
agents from user configuration.

Co-authored-by: maan2003 <manmeetmann2003@gmail.com>

Release Notes:

- agent: Gemini CLI, Claude Code, and custom external agents can now be
used in SSH projects.

---------

Co-authored-by: maan2003 <manmeetmann2003@gmail.com>

Change summary

Cargo.lock                                   |    9 
crates/acp_thread/src/acp_thread.rs          |    2 
crates/agent2/src/native_agent_server.rs     |   14 
crates/agent_servers/Cargo.toml              |    6 
crates/agent_servers/src/acp.rs              |   75 
crates/agent_servers/src/agent_servers.rs    |  337 ------
crates/agent_servers/src/claude.rs           |  117 -
crates/agent_servers/src/custom.rs           |   46 
crates/agent_servers/src/e2e_tests.rs        |   20 
crates/agent_servers/src/gemini.rs           |  164 --
crates/agent_servers/src/settings.rs         |  110 --
crates/agent_ui/src/acp/message_editor.rs    |   11 
crates/agent_ui/src/acp/thread_view.rs       |  155 +-
crates/agent_ui/src/agent_configuration.rs   |   34 
crates/agent_ui/src/agent_panel.rs           |   52 
crates/agent_ui/src/agent_ui.rs              |   17 
crates/project/Cargo.toml                    |    2 
crates/project/src/agent_server_store.rs     | 1091 ++++++++++++++++++++++
crates/project/src/project.rs                |   27 
crates/proto/proto/ai.proto                  |   33 
crates/proto/proto/debugger.proto            |    9 
crates/proto/proto/task.proto                |    8 
crates/proto/proto/zed.proto                 |   10 
crates/proto/src/proto.rs                    |   12 
crates/remote_server/src/headless_project.rs |   14 
crates/zed/Cargo.toml                        |    1 
crates/zed/src/main.rs                       |    1 
27 files changed, 1,538 insertions(+), 839 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -308,22 +308,18 @@ dependencies = [
  "libc",
  "log",
  "nix 0.29.0",
- "node_runtime",
- "paths",
  "project",
  "reqwest_client",
- "schemars",
- "semver",
  "serde",
  "serde_json",
  "settings",
  "smol",
+ "task",
  "tempfile",
  "thiserror 2.0.12",
  "ui",
  "util",
  "watch",
- "which 6.0.3",
  "workspace-hack",
 ]
 
@@ -12605,6 +12601,7 @@ dependencies = [
  "remote",
  "rpc",
  "schemars",
+ "semver",
  "serde",
  "serde_json",
  "settings",
@@ -12624,6 +12621,7 @@ dependencies = [
  "unindent",
  "url",
  "util",
+ "watch",
  "which 6.0.3",
  "workspace-hack",
  "worktree",
@@ -20367,7 +20365,6 @@ dependencies = [
  "acp_tools",
  "activity_indicator",
  "agent",
- "agent_servers",
  "agent_settings",
  "agent_ui",
  "anyhow",

crates/acp_thread/src/acp_thread.rs πŸ”—

@@ -2758,7 +2758,7 @@ mod tests {
         }));
 
         let thread = cx
-            .update(|cx| connection.new_thread(project, Path::new("/test"), cx))
+            .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
             .await
             .unwrap();
 

crates/agent2/src/native_agent_server.rs πŸ”—

@@ -35,10 +35,15 @@ impl AgentServer for NativeAgentServer {
 
     fn connect(
         &self,
-        _root_dir: &Path,
+        _root_dir: Option<&Path>,
         delegate: AgentServerDelegate,
         cx: &mut App,
-    ) -> Task<Result<Rc<dyn acp_thread::AgentConnection>>> {
+    ) -> Task<
+        Result<(
+            Rc<dyn acp_thread::AgentConnection>,
+            Option<task::SpawnInTerminal>,
+        )>,
+    > {
         log::debug!(
             "NativeAgentServer::connect called for path: {:?}",
             _root_dir
@@ -60,7 +65,10 @@ impl AgentServer for NativeAgentServer {
             let connection = NativeAgentConnection(agent);
             log::debug!("NativeAgentServer connection established successfully");
 
-            Ok(Rc::new(connection) as Rc<dyn acp_thread::AgentConnection>)
+            Ok((
+                Rc::new(connection) as Rc<dyn acp_thread::AgentConnection>,
+                None,
+            ))
         })
     }
 

crates/agent_servers/Cargo.toml πŸ”—

@@ -35,22 +35,18 @@ language.workspace = true
 language_model.workspace = true
 language_models.workspace = true
 log.workspace = true
-node_runtime.workspace = true
-paths.workspace = true
 project.workspace = true
 reqwest_client = { workspace = true, optional = true }
-schemars.workspace = true
-semver.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true
 smol.workspace = true
+task.workspace = true
 tempfile.workspace = true
 thiserror.workspace = true
 ui.workspace = true
 util.workspace = true
 watch.workspace = true
-which.workspace = true
 workspace-hack.workspace = true
 
 [target.'cfg(unix)'.dependencies]

crates/agent_servers/src/acp.rs πŸ”—

@@ -1,4 +1,3 @@
-use crate::AgentServerCommand;
 use acp_thread::AgentConnection;
 use acp_tools::AcpConnectionRegistry;
 use action_log::ActionLog;
@@ -8,8 +7,10 @@ use collections::HashMap;
 use futures::AsyncBufReadExt as _;
 use futures::io::BufReader;
 use project::Project;
+use project::agent_server_store::AgentServerCommand;
 use serde::Deserialize;
 
+use std::path::PathBuf;
 use std::{any::Any, cell::RefCell};
 use std::{path::Path, rc::Rc};
 use thiserror::Error;
@@ -29,6 +30,7 @@ pub struct AcpConnection {
     sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
     auth_methods: Vec<acp::AuthMethod>,
     agent_capabilities: acp::AgentCapabilities,
+    root_dir: PathBuf,
     _io_task: Task<Result<()>>,
     _wait_task: Task<Result<()>>,
     _stderr_task: Task<Result<()>>,
@@ -43,9 +45,10 @@ pub async fn connect(
     server_name: SharedString,
     command: AgentServerCommand,
     root_dir: &Path,
+    is_remote: bool,
     cx: &mut AsyncApp,
 ) -> Result<Rc<dyn AgentConnection>> {
-    let conn = AcpConnection::stdio(server_name, command.clone(), root_dir, cx).await?;
+    let conn = AcpConnection::stdio(server_name, command.clone(), root_dir, is_remote, cx).await?;
     Ok(Rc::new(conn) as _)
 }
 
@@ -56,17 +59,21 @@ impl AcpConnection {
         server_name: SharedString,
         command: AgentServerCommand,
         root_dir: &Path,
+        is_remote: bool,
         cx: &mut AsyncApp,
     ) -> Result<Self> {
-        let mut child = util::command::new_smol_command(command.path)
+        let mut child = util::command::new_smol_command(command.path);
+        child
             .args(command.args.iter().map(|arg| arg.as_str()))
             .envs(command.env.iter().flatten())
-            .current_dir(root_dir)
             .stdin(std::process::Stdio::piped())
             .stdout(std::process::Stdio::piped())
             .stderr(std::process::Stdio::piped())
-            .kill_on_drop(true)
-            .spawn()?;
+            .kill_on_drop(true);
+        if !is_remote {
+            child.current_dir(root_dir);
+        }
+        let mut child = child.spawn()?;
 
         let stdout = child.stdout.take().context("Failed to take stdout")?;
         let stdin = child.stdin.take().context("Failed to take stdin")?;
@@ -145,6 +152,7 @@ impl AcpConnection {
 
         Ok(Self {
             auth_methods: response.auth_methods,
+            root_dir: root_dir.to_owned(),
             connection,
             server_name,
             sessions,
@@ -158,6 +166,10 @@ impl AcpConnection {
     pub fn prompt_capabilities(&self) -> &acp::PromptCapabilities {
         &self.agent_capabilities.prompt_capabilities
     }
+
+    pub fn root_dir(&self) -> &Path {
+        &self.root_dir
+    }
 }
 
 impl AgentConnection for AcpConnection {
@@ -171,29 +183,36 @@ impl AgentConnection for AcpConnection {
         let sessions = self.sessions.clone();
         let cwd = cwd.to_path_buf();
         let context_server_store = project.read(cx).context_server_store().read(cx);
-        let mcp_servers = context_server_store
-            .configured_server_ids()
-            .iter()
-            .filter_map(|id| {
-                let configuration = context_server_store.configuration_for_server(id)?;
-                let command = configuration.command();
-                Some(acp::McpServer {
-                    name: id.0.to_string(),
-                    command: command.path.clone(),
-                    args: command.args.clone(),
-                    env: if let Some(env) = command.env.as_ref() {
-                        env.iter()
-                            .map(|(name, value)| acp::EnvVariable {
-                                name: name.clone(),
-                                value: value.clone(),
-                            })
-                            .collect()
-                    } else {
-                        vec![]
-                    },
+        let mcp_servers = if project.read(cx).is_local() {
+            context_server_store
+                .configured_server_ids()
+                .iter()
+                .filter_map(|id| {
+                    let configuration = context_server_store.configuration_for_server(id)?;
+                    let command = configuration.command();
+                    Some(acp::McpServer {
+                        name: id.0.to_string(),
+                        command: command.path.clone(),
+                        args: command.args.clone(),
+                        env: if let Some(env) = command.env.as_ref() {
+                            env.iter()
+                                .map(|(name, value)| acp::EnvVariable {
+                                    name: name.clone(),
+                                    value: value.clone(),
+                                })
+                                .collect()
+                        } else {
+                            vec![]
+                        },
+                    })
                 })
-            })
-            .collect();
+                .collect()
+        } else {
+            // In SSH projects, the external agent is running on the remote
+            // machine, and currently we only run MCP servers on the local
+            // machine. So don't pass any MCP servers to the agent in that case.
+            Vec::new()
+        };
 
         cx.spawn(async move |cx| {
             let response = conn

crates/agent_servers/src/agent_servers.rs πŸ”—

@@ -2,47 +2,25 @@ mod acp;
 mod claude;
 mod custom;
 mod gemini;
-mod settings;
 
 #[cfg(any(test, feature = "test-support"))]
 pub mod e2e_tests;
 
-use anyhow::Context as _;
 pub use claude::*;
 pub use custom::*;
-use fs::Fs;
-use fs::RemoveOptions;
-use fs::RenameOptions;
-use futures::StreamExt as _;
 pub use gemini::*;
-use gpui::AppContext;
-use node_runtime::NodeRuntime;
-pub use settings::*;
+use project::agent_server_store::AgentServerStore;
 
 use acp_thread::AgentConnection;
-use acp_thread::LoadError;
 use anyhow::Result;
-use anyhow::anyhow;
-use collections::HashMap;
-use gpui::{App, AsyncApp, Entity, SharedString, Task};
+use gpui::{App, Entity, SharedString, Task};
 use project::Project;
-use schemars::JsonSchema;
-use semver::Version;
-use serde::{Deserialize, Serialize};
-use std::str::FromStr as _;
-use std::{
-    any::Any,
-    path::{Path, PathBuf},
-    rc::Rc,
-    sync::Arc,
-};
-use util::ResultExt as _;
+use std::{any::Any, path::Path, rc::Rc};
 
-pub fn init(cx: &mut App) {
-    settings::init(cx);
-}
+pub use acp::AcpConnection;
 
 pub struct AgentServerDelegate {
+    store: Entity<AgentServerStore>,
     project: Entity<Project>,
     status_tx: Option<watch::Sender<SharedString>>,
     new_version_available: Option<watch::Sender<Option<String>>>,
@@ -50,11 +28,13 @@ pub struct AgentServerDelegate {
 
 impl AgentServerDelegate {
     pub fn new(
+        store: Entity<AgentServerStore>,
         project: Entity<Project>,
         status_tx: Option<watch::Sender<SharedString>>,
         new_version_tx: Option<watch::Sender<Option<String>>>,
     ) -> Self {
         Self {
+            store,
             project,
             status_tx,
             new_version_available: new_version_tx,
@@ -64,188 +44,6 @@ impl AgentServerDelegate {
     pub fn project(&self) -> &Entity<Project> {
         &self.project
     }
-
-    fn get_or_npm_install_builtin_agent(
-        self,
-        binary_name: SharedString,
-        package_name: SharedString,
-        entrypoint_path: PathBuf,
-        ignore_system_version: bool,
-        minimum_version: Option<Version>,
-        cx: &mut App,
-    ) -> Task<Result<AgentServerCommand>> {
-        let project = self.project;
-        let fs = project.read(cx).fs().clone();
-        let Some(node_runtime) = project.read(cx).node_runtime().cloned() else {
-            return Task::ready(Err(anyhow!(
-                "External agents are not yet available in remote projects."
-            )));
-        };
-        let status_tx = self.status_tx;
-        let new_version_available = self.new_version_available;
-
-        cx.spawn(async move |cx| {
-            if !ignore_system_version {
-                if let Some(bin) = find_bin_in_path(binary_name.clone(), &project, cx).await {
-                    return Ok(AgentServerCommand {
-                        path: bin,
-                        args: Vec::new(),
-                        env: Default::default(),
-                    });
-                }
-            }
-
-            cx.spawn(async move |cx| {
-                let node_path = node_runtime.binary_path().await?;
-                let dir = paths::data_dir()
-                    .join("external_agents")
-                    .join(binary_name.as_str());
-                fs.create_dir(&dir).await?;
-
-                let mut stream = fs.read_dir(&dir).await?;
-                let mut versions = Vec::new();
-                let mut to_delete = Vec::new();
-                while let Some(entry) = stream.next().await {
-                    let Ok(entry) = entry else { continue };
-                    let Some(file_name) = entry.file_name() else {
-                        continue;
-                    };
-
-                    if let Some(name) = file_name.to_str()
-                        && let Some(version) = semver::Version::from_str(name).ok()
-                        && fs
-                            .is_file(&dir.join(file_name).join(&entrypoint_path))
-                            .await
-                    {
-                        versions.push((version, file_name.to_owned()));
-                    } else {
-                        to_delete.push(file_name.to_owned())
-                    }
-                }
-
-                versions.sort();
-                let newest_version = if let Some((version, file_name)) = versions.last().cloned()
-                    && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
-                {
-                    versions.pop();
-                    Some(file_name)
-                } else {
-                    None
-                };
-                log::debug!("existing version of {package_name}: {newest_version:?}");
-                to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
-
-                cx.background_spawn({
-                    let fs = fs.clone();
-                    let dir = dir.clone();
-                    async move {
-                        for file_name in to_delete {
-                            fs.remove_dir(
-                                &dir.join(file_name),
-                                RemoveOptions {
-                                    recursive: true,
-                                    ignore_if_not_exists: false,
-                                },
-                            )
-                            .await
-                            .ok();
-                        }
-                    }
-                })
-                .detach();
-
-                let version = if let Some(file_name) = newest_version {
-                    cx.background_spawn({
-                        let file_name = file_name.clone();
-                        let dir = dir.clone();
-                        let fs = fs.clone();
-                        async move {
-                            let latest_version =
-                                node_runtime.npm_package_latest_version(&package_name).await;
-                            if let Ok(latest_version) = latest_version
-                                && &latest_version != &file_name.to_string_lossy()
-                            {
-                                Self::download_latest_version(
-                                    fs,
-                                    dir.clone(),
-                                    node_runtime,
-                                    package_name,
-                                )
-                                .await
-                                .log_err();
-                                if let Some(mut new_version_available) = new_version_available {
-                                    new_version_available.send(Some(latest_version)).ok();
-                                }
-                            }
-                        }
-                    })
-                    .detach();
-                    file_name
-                } else {
-                    if let Some(mut status_tx) = status_tx {
-                        status_tx.send("Installing…".into()).ok();
-                    }
-                    let dir = dir.clone();
-                    cx.background_spawn(Self::download_latest_version(
-                        fs.clone(),
-                        dir.clone(),
-                        node_runtime,
-                        package_name,
-                    ))
-                    .await?
-                    .into()
-                };
-
-                let agent_server_path = dir.join(version).join(entrypoint_path);
-                let agent_server_path_exists = fs.is_file(&agent_server_path).await;
-                anyhow::ensure!(
-                    agent_server_path_exists,
-                    "Missing entrypoint path {} after installation",
-                    agent_server_path.to_string_lossy()
-                );
-
-                anyhow::Ok(AgentServerCommand {
-                    path: node_path,
-                    args: vec![agent_server_path.to_string_lossy().to_string()],
-                    env: Default::default(),
-                })
-            })
-            .await
-            .map_err(|e| LoadError::FailedToInstall(e.to_string().into()).into())
-        })
-    }
-
-    async fn download_latest_version(
-        fs: Arc<dyn Fs>,
-        dir: PathBuf,
-        node_runtime: NodeRuntime,
-        package_name: SharedString,
-    ) -> Result<String> {
-        log::debug!("downloading latest version of {package_name}");
-
-        let tmp_dir = tempfile::tempdir_in(&dir)?;
-
-        node_runtime
-            .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
-            .await?;
-
-        let version = node_runtime
-            .npm_package_installed_version(tmp_dir.path(), &package_name)
-            .await?
-            .context("expected package to be installed")?;
-
-        fs.rename(
-            &tmp_dir.keep(),
-            &dir.join(&version),
-            RenameOptions {
-                ignore_if_exists: true,
-                overwrite: false,
-            },
-        )
-        .await?;
-
-        anyhow::Ok(version)
-    }
 }
 
 pub trait AgentServer: Send {
@@ -255,10 +53,10 @@ pub trait AgentServer: Send {
 
     fn connect(
         &self,
-        root_dir: &Path,
+        root_dir: Option<&Path>,
         delegate: AgentServerDelegate,
         cx: &mut App,
-    ) -> Task<Result<Rc<dyn AgentConnection>>>;
+    ) -> Task<Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>>;
 
     fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
 }
@@ -268,120 +66,3 @@ impl dyn AgentServer {
         self.into_any().downcast().ok()
     }
 }
-
-impl std::fmt::Debug for AgentServerCommand {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        let filtered_env = self.env.as_ref().map(|env| {
-            env.iter()
-                .map(|(k, v)| {
-                    (
-                        k,
-                        if util::redact::should_redact(k) {
-                            "[REDACTED]"
-                        } else {
-                            v
-                        },
-                    )
-                })
-                .collect::<Vec<_>>()
-        });
-
-        f.debug_struct("AgentServerCommand")
-            .field("path", &self.path)
-            .field("args", &self.args)
-            .field("env", &filtered_env)
-            .finish()
-    }
-}
-
-#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
-pub struct AgentServerCommand {
-    #[serde(rename = "command")]
-    pub path: PathBuf,
-    #[serde(default)]
-    pub args: Vec<String>,
-    pub env: Option<HashMap<String, String>>,
-}
-
-impl AgentServerCommand {
-    pub async fn resolve(
-        path_bin_name: &'static str,
-        extra_args: &[&'static str],
-        fallback_path: Option<&Path>,
-        settings: Option<BuiltinAgentServerSettings>,
-        project: &Entity<Project>,
-        cx: &mut AsyncApp,
-    ) -> Option<Self> {
-        if let Some(settings) = settings
-            && let Some(command) = settings.custom_command()
-        {
-            Some(command)
-        } else {
-            match find_bin_in_path(path_bin_name.into(), project, cx).await {
-                Some(path) => Some(Self {
-                    path,
-                    args: extra_args.iter().map(|arg| arg.to_string()).collect(),
-                    env: None,
-                }),
-                None => fallback_path.and_then(|path| {
-                    if path.exists() {
-                        Some(Self {
-                            path: path.to_path_buf(),
-                            args: extra_args.iter().map(|arg| arg.to_string()).collect(),
-                            env: None,
-                        })
-                    } else {
-                        None
-                    }
-                }),
-            }
-        }
-    }
-}
-
-async fn find_bin_in_path(
-    bin_name: SharedString,
-    project: &Entity<Project>,
-    cx: &mut AsyncApp,
-) -> Option<PathBuf> {
-    let (env_task, root_dir) = project
-        .update(cx, |project, cx| {
-            let worktree = project.visible_worktrees(cx).next();
-            match worktree {
-                Some(worktree) => {
-                    let env_task = project.environment().update(cx, |env, cx| {
-                        env.get_worktree_environment(worktree.clone(), cx)
-                    });
-
-                    let path = worktree.read(cx).abs_path();
-                    (env_task, path)
-                }
-                None => {
-                    let path: Arc<Path> = paths::home_dir().as_path().into();
-                    let env_task = project.environment().update(cx, |env, cx| {
-                        env.get_directory_environment(path.clone(), cx)
-                    });
-                    (env_task, path)
-                }
-            }
-        })
-        .log_err()?;
-
-    cx.background_executor()
-        .spawn(async move {
-            let which_result = if cfg!(windows) {
-                which::which(bin_name.as_str())
-            } else {
-                let env = env_task.await.unwrap_or_default();
-                let shell_path = env.get("PATH").cloned();
-                which::which_in(bin_name.as_str(), shell_path.as_ref(), root_dir.as_ref())
-            };
-
-            if let Err(which::Error::CannotFindBinaryPath) = which_result {
-                return None;
-            }
-
-            which_result.log_err()
-        })
-        .await
-}

crates/agent_servers/src/claude.rs πŸ”—

@@ -1,60 +1,22 @@
-use settings::SettingsStore;
 use std::path::Path;
 use std::rc::Rc;
 use std::{any::Any, path::PathBuf};
 
-use anyhow::Result;
-use gpui::{App, AppContext as _, SharedString, Task};
+use anyhow::{Context as _, Result};
+use gpui::{App, SharedString, Task};
+use project::agent_server_store::CLAUDE_CODE_NAME;
 
-use crate::{AgentServer, AgentServerDelegate, AllAgentServersSettings};
+use crate::{AgentServer, AgentServerDelegate};
 use acp_thread::AgentConnection;
 
 #[derive(Clone)]
 pub struct ClaudeCode;
 
-pub struct ClaudeCodeLoginCommand {
+pub struct AgentServerLoginCommand {
     pub path: PathBuf,
     pub arguments: Vec<String>,
 }
 
-impl ClaudeCode {
-    const BINARY_NAME: &'static str = "claude-code-acp";
-    const PACKAGE_NAME: &'static str = "@zed-industries/claude-code-acp";
-
-    pub fn login_command(
-        delegate: AgentServerDelegate,
-        cx: &mut App,
-    ) -> Task<Result<ClaudeCodeLoginCommand>> {
-        let settings = cx.read_global(|settings: &SettingsStore, _| {
-            settings.get::<AllAgentServersSettings>(None).claude.clone()
-        });
-
-        cx.spawn(async move |cx| {
-            let mut command = if let Some(settings) = settings {
-                settings.command
-            } else {
-                cx.update(|cx| {
-                    delegate.get_or_npm_install_builtin_agent(
-                        Self::BINARY_NAME.into(),
-                        Self::PACKAGE_NAME.into(),
-                        "node_modules/@anthropic-ai/claude-code/cli.js".into(),
-                        true,
-                        Some("0.2.5".parse().unwrap()),
-                        cx,
-                    )
-                })?
-                .await?
-            };
-            command.args.push("/login".into());
-
-            Ok(ClaudeCodeLoginCommand {
-                path: command.path,
-                arguments: command.args,
-            })
-        })
-    }
-}
-
 impl AgentServer for ClaudeCode {
     fn telemetry_id(&self) -> &'static str {
         "claude-code"
@@ -70,56 +32,33 @@ impl AgentServer for ClaudeCode {
 
     fn connect(
         &self,
-        root_dir: &Path,
+        root_dir: Option<&Path>,
         delegate: AgentServerDelegate,
         cx: &mut App,
-    ) -> Task<Result<Rc<dyn AgentConnection>>> {
-        let root_dir = root_dir.to_path_buf();
-        let fs = delegate.project().read(cx).fs().clone();
-        let server_name = self.name();
-        let settings = cx.read_global(|settings: &SettingsStore, _| {
-            settings.get::<AllAgentServersSettings>(None).claude.clone()
-        });
-        let project = delegate.project().clone();
+    ) -> 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().to_string());
+        let is_remote = delegate.project.read(cx).is_via_remote_server();
+        let store = delegate.store.downgrade();
 
         cx.spawn(async move |cx| {
-            let mut project_env = project
-                .update(cx, |project, cx| {
-                    project.directory_environment(root_dir.as_path().into(), cx)
-                })?
-                .await
-                .unwrap_or_default();
-            let mut command = if let Some(settings) = settings {
-                settings.command
-            } else {
-                cx.update(|cx| {
-                    delegate.get_or_npm_install_builtin_agent(
-                        Self::BINARY_NAME.into(),
-                        Self::PACKAGE_NAME.into(),
-                        format!("node_modules/{}/dist/index.js", Self::PACKAGE_NAME).into(),
-                        true,
-                        None,
-                        cx,
-                    )
-                })?
-                .await?
-            };
-            project_env.extend(command.env.take().unwrap_or_default());
-            command.env = Some(project_env);
-
-            command
-                .env
-                .get_or_insert_default()
-                .insert("ANTHROPIC_API_KEY".to_owned(), "".to_owned());
-
-            let root_dir_exists = fs.is_dir(&root_dir).await;
-            anyhow::ensure!(
-                root_dir_exists,
-                "Session root {} does not exist or is not a directory",
-                root_dir.to_string_lossy()
-            );
-
-            crate::acp::connect(server_name, command.clone(), &root_dir, cx).await
+            let (command, root_dir, login) = store
+                .update(cx, |store, cx| {
+                    let agent = store
+                        .get_external_agent(&CLAUDE_CODE_NAME.into())
+                        .context("Claude Code is not registered")?;
+                    anyhow::Ok(agent.get_command(
+                        root_dir.as_deref(),
+                        Default::default(),
+                        delegate.status_tx,
+                        delegate.new_version_available,
+                        &mut cx.to_async(),
+                    ))
+                })??
+                .await?;
+            let connection =
+                crate::acp::connect(name, command, root_dir.as_ref(), is_remote, cx).await?;
+            Ok((connection, login))
         })
     }
 

crates/agent_servers/src/custom.rs πŸ”—

@@ -1,19 +1,19 @@
-use crate::{AgentServerCommand, AgentServerDelegate};
+use crate::AgentServerDelegate;
 use acp_thread::AgentConnection;
-use anyhow::Result;
+use anyhow::{Context as _, Result};
 use gpui::{App, SharedString, Task};
+use project::agent_server_store::ExternalAgentServerName;
 use std::{path::Path, rc::Rc};
 use ui::IconName;
 
 /// A generic agent server implementation for custom user-defined agents
 pub struct CustomAgentServer {
     name: SharedString,
-    command: AgentServerCommand,
 }
 
 impl CustomAgentServer {
-    pub fn new(name: SharedString, command: AgentServerCommand) -> Self {
-        Self { name, command }
+    pub fn new(name: SharedString) -> Self {
+        Self { name }
     }
 }
 
@@ -32,14 +32,36 @@ impl crate::AgentServer for CustomAgentServer {
 
     fn connect(
         &self,
-        root_dir: &Path,
-        _delegate: AgentServerDelegate,
+        root_dir: Option<&Path>,
+        delegate: AgentServerDelegate,
         cx: &mut App,
-    ) -> Task<Result<Rc<dyn AgentConnection>>> {
-        let server_name = self.name();
-        let command = self.command.clone();
-        let root_dir = root_dir.to_path_buf();
-        cx.spawn(async move |cx| crate::acp::connect(server_name, command, &root_dir, cx).await)
+    ) -> 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().to_string());
+        let is_remote = delegate.project.read(cx).is_via_remote_server();
+        let store = delegate.store.downgrade();
+
+        cx.spawn(async move |cx| {
+            let (command, root_dir, login) = store
+                .update(cx, |store, cx| {
+                    let agent = store
+                        .get_external_agent(&ExternalAgentServerName(name.clone()))
+                        .with_context(|| {
+                            format!("Custom agent server `{}` is not registered", name)
+                        })?;
+                    anyhow::Ok(agent.get_command(
+                        root_dir.as_deref(),
+                        Default::default(),
+                        delegate.status_tx,
+                        delegate.new_version_available,
+                        &mut cx.to_async(),
+                    ))
+                })??
+                .await?;
+            let connection =
+                crate::acp::connect(name, command, root_dir.as_ref(), is_remote, cx).await?;
+            Ok((connection, login))
+        })
     }
 
     fn into_any(self: Rc<Self>) -> Rc<dyn std::any::Any> {

crates/agent_servers/src/e2e_tests.rs πŸ”—

@@ -1,12 +1,12 @@
 use crate::{AgentServer, AgentServerDelegate};
-#[cfg(test)]
-use crate::{AgentServerCommand, CustomAgentServerSettings};
 use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus};
 use agent_client_protocol as acp;
 use futures::{FutureExt, StreamExt, channel::mpsc, select};
 use gpui::{AppContext, Entity, TestAppContext};
 use indoc::indoc;
-use project::{FakeFs, Project};
+#[cfg(test)]
+use project::agent_server_store::{AgentServerCommand, CustomAgentServerSettings};
+use project::{FakeFs, Project, agent_server_store::AllAgentServersSettings};
 use std::{
     path::{Path, PathBuf},
     sync::Arc,
@@ -449,7 +449,6 @@ pub use common_e2e_tests;
 // Helpers
 
 pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
-    #[cfg(test)]
     use settings::Settings;
 
     env_logger::try_init().ok();
@@ -468,11 +467,11 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
         language_model::init(client.clone(), cx);
         language_models::init(user_store, client, cx);
         agent_settings::init(cx);
-        crate::settings::init(cx);
+        AllAgentServersSettings::register(cx);
 
         #[cfg(test)]
-        crate::AllAgentServersSettings::override_global(
-            crate::AllAgentServersSettings {
+        AllAgentServersSettings::override_global(
+            AllAgentServersSettings {
                 claude: Some(CustomAgentServerSettings {
                     command: AgentServerCommand {
                         path: "claude-code-acp".into(),
@@ -498,10 +497,11 @@ pub async fn new_test_thread(
     current_dir: impl AsRef<Path>,
     cx: &mut TestAppContext,
 ) -> Entity<AcpThread> {
-    let delegate = AgentServerDelegate::new(project.clone(), None, None);
+    let store = project.read_with(cx, |project, _| project.agent_server_store().clone());
+    let delegate = AgentServerDelegate::new(store, project.clone(), None, None);
 
-    let connection = cx
-        .update(|cx| server.connect(current_dir.as_ref(), delegate, cx))
+    let (connection, _) = cx
+        .update(|cx| server.connect(Some(current_dir.as_ref()), delegate, cx))
         .await
         .unwrap();
 

crates/agent_servers/src/gemini.rs πŸ”—

@@ -1,21 +1,17 @@
 use std::rc::Rc;
 use std::{any::Any, path::Path};
 
-use crate::acp::AcpConnection;
 use crate::{AgentServer, AgentServerDelegate};
-use acp_thread::{AgentConnection, LoadError};
-use anyhow::Result;
-use gpui::{App, AppContext as _, SharedString, Task};
+use acp_thread::AgentConnection;
+use anyhow::{Context as _, Result};
+use collections::HashMap;
+use gpui::{App, SharedString, Task};
 use language_models::provider::google::GoogleLanguageModelProvider;
-use settings::SettingsStore;
-
-use crate::AllAgentServersSettings;
+use project::agent_server_store::GEMINI_NAME;
 
 #[derive(Clone)]
 pub struct Gemini;
 
-const ACP_ARG: &str = "--experimental-acp";
-
 impl AgentServer for Gemini {
     fn telemetry_id(&self) -> &'static str {
         "gemini-cli"
@@ -31,126 +27,37 @@ impl AgentServer for Gemini {
 
     fn connect(
         &self,
-        root_dir: &Path,
+        root_dir: Option<&Path>,
         delegate: AgentServerDelegate,
         cx: &mut App,
-    ) -> Task<Result<Rc<dyn AgentConnection>>> {
-        let root_dir = root_dir.to_path_buf();
-        let fs = delegate.project().read(cx).fs().clone();
-        let server_name = self.name();
-        let settings = cx.read_global(|settings: &SettingsStore, _| {
-            settings.get::<AllAgentServersSettings>(None).gemini.clone()
-        });
-        let project = delegate.project().clone();
+    ) -> 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().to_string());
+        let is_remote = delegate.project.read(cx).is_via_remote_server();
+        let store = delegate.store.downgrade();
 
         cx.spawn(async move |cx| {
-            let ignore_system_version = settings
-                .as_ref()
-                .and_then(|settings| settings.ignore_system_version)
-                .unwrap_or(true);
-            let mut project_env = project
-                .update(cx, |project, cx| {
-                    project.directory_environment(root_dir.as_path().into(), cx)
-                })?
-                .await
-                .unwrap_or_default();
-            let mut command = if let Some(settings) = settings
-                && let Some(command) = settings.custom_command()
-            {
-                command
-            } else {
-                cx.update(|cx| {
-                    delegate.get_or_npm_install_builtin_agent(
-                        Self::BINARY_NAME.into(),
-                        Self::PACKAGE_NAME.into(),
-                        format!("node_modules/{}/dist/index.js", Self::PACKAGE_NAME).into(),
-                        ignore_system_version,
-                        Some(Self::MINIMUM_VERSION.parse().unwrap()),
-                        cx,
-                    )
-                })?
-                .await?
-            };
-            if !command.args.contains(&ACP_ARG.into()) {
-                command.args.push(ACP_ARG.into());
-            }
+            let mut extra_env = HashMap::default();
             if let Some(api_key) = cx.update(GoogleLanguageModelProvider::api_key)?.await.ok() {
-                project_env
-                    .insert("GEMINI_API_KEY".to_owned(), api_key.key);
-            }
-            project_env.extend(command.env.take().unwrap_or_default());
-            command.env = Some(project_env);
-
-            let root_dir_exists = fs.is_dir(&root_dir).await;
-            anyhow::ensure!(
-                root_dir_exists,
-                "Session root {} does not exist or is not a directory",
-                root_dir.to_string_lossy()
-            );
-
-            let result = crate::acp::connect(server_name, command.clone(), &root_dir, cx).await;
-            match &result {
-                Ok(connection) => {
-                    if let Some(connection) = connection.clone().downcast::<AcpConnection>()
-                        && !connection.prompt_capabilities().image
-                    {
-                        let version_output = util::command::new_smol_command(&command.path)
-                            .args(command.args.iter())
-                            .arg("--version")
-                            .kill_on_drop(true)
-                            .output()
-                            .await;
-                        let current_version =
-                            String::from_utf8(version_output?.stdout)?.trim().to_owned();
-
-                        log::error!("connected to gemini, but missing prompt_capabilities.image (version is {current_version})");
-                        return Err(LoadError::Unsupported {
-                            current_version: current_version.into(),
-                            command: (command.path.to_string_lossy().to_string() + " " + &command.args.join(" ")).into(),
-                            minimum_version: Self::MINIMUM_VERSION.into(),
-                        }
-                        .into());
-                    }
-                }
-                Err(e) => {
-                    let version_fut = util::command::new_smol_command(&command.path)
-                        .args(command.args.iter())
-                        .arg("--version")
-                        .kill_on_drop(true)
-                        .output();
-
-                    let help_fut = util::command::new_smol_command(&command.path)
-                        .args(command.args.iter())
-                        .arg("--help")
-                        .kill_on_drop(true)
-                        .output();
-
-                    let (version_output, help_output) =
-                        futures::future::join(version_fut, help_fut).await;
-                    let Some(version_output) = version_output.ok().and_then(|output| String::from_utf8(output.stdout).ok()) else {
-                        return result;
-                    };
-                    let Some((help_stdout, help_stderr)) = help_output.ok().and_then(|output| String::from_utf8(output.stdout).ok().zip(String::from_utf8(output.stderr).ok())) else  {
-                        return result;
-                    };
-
-                    let current_version = version_output.trim().to_string();
-                    let supported = help_stdout.contains(ACP_ARG) || current_version.parse::<semver::Version>().is_ok_and(|version| version >= Self::MINIMUM_VERSION.parse::<semver::Version>().unwrap());
-
-                    log::error!("failed to create ACP connection to gemini (version is {current_version}, supported: {supported}): {e}");
-                    log::debug!("gemini --help stdout: {help_stdout:?}");
-                    log::debug!("gemini --help stderr: {help_stderr:?}");
-                    if !supported {
-                        return Err(LoadError::Unsupported {
-                            current_version: current_version.into(),
-                            command: (command.path.to_string_lossy().to_string() + " " + &command.args.join(" ")).into(),
-                            minimum_version: Self::MINIMUM_VERSION.into(),
-                        }
-                        .into());
-                    }
-                }
+                extra_env.insert("GEMINI_API_KEY".into(), api_key.key);
             }
-            result
+            let (command, root_dir, login) = store
+                .update(cx, |store, cx| {
+                    let agent = store
+                        .get_external_agent(&GEMINI_NAME.into())
+                        .context("Gemini CLI is not registered")?;
+                    anyhow::Ok(agent.get_command(
+                        root_dir.as_deref(),
+                        extra_env,
+                        delegate.status_tx,
+                        delegate.new_version_available,
+                        &mut cx.to_async(),
+                    ))
+                })??
+                .await?;
+            let connection =
+                crate::acp::connect(name, command, root_dir.as_ref(), is_remote, cx).await?;
+            Ok((connection, login))
         })
     }
 
@@ -159,18 +66,11 @@ impl AgentServer for Gemini {
     }
 }
 
-impl Gemini {
-    const PACKAGE_NAME: &str = "@google/gemini-cli";
-
-    const MINIMUM_VERSION: &str = "0.2.1";
-
-    const BINARY_NAME: &str = "gemini";
-}
-
 #[cfg(test)]
 pub(crate) mod tests {
+    use project::agent_server_store::AgentServerCommand;
+
     use super::*;
-    use crate::AgentServerCommand;
     use std::path::Path;
 
     crate::common_e2e_tests!(async |_, _, _| Gemini, allow_option_id = "proceed_once");

crates/agent_servers/src/settings.rs πŸ”—

@@ -1,110 +0,0 @@
-use std::path::PathBuf;
-
-use crate::AgentServerCommand;
-use anyhow::Result;
-use collections::HashMap;
-use gpui::{App, SharedString};
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsKey, SettingsSources, SettingsUi};
-
-pub fn init(cx: &mut App) {
-    AllAgentServersSettings::register(cx);
-}
-
-#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug, SettingsUi, SettingsKey)]
-#[settings_key(key = "agent_servers")]
-pub struct AllAgentServersSettings {
-    pub gemini: Option<BuiltinAgentServerSettings>,
-    pub claude: Option<CustomAgentServerSettings>,
-
-    /// Custom agent servers configured by the user
-    #[serde(flatten)]
-    pub custom: HashMap<SharedString, CustomAgentServerSettings>,
-}
-
-#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)]
-pub struct BuiltinAgentServerSettings {
-    /// Absolute path to a binary to be used when launching this agent.
-    ///
-    /// This can be used to run a specific binary without automatic downloads or searching `$PATH`.
-    #[serde(rename = "command")]
-    pub path: Option<PathBuf>,
-    /// If a binary is specified in `command`, it will be passed these arguments.
-    pub args: Option<Vec<String>>,
-    /// If a binary is specified in `command`, it will be passed these environment variables.
-    pub env: Option<HashMap<String, String>>,
-    /// Whether to skip searching `$PATH` for an agent server binary when
-    /// launching this agent.
-    ///
-    /// This has no effect if a `command` is specified. Otherwise, when this is
-    /// `false`, Zed will search `$PATH` for an agent server binary and, if one
-    /// is found, use it for threads with this agent. If no agent binary is
-    /// found on `$PATH`, Zed will automatically install and use its own binary.
-    /// When this is `true`, Zed will not search `$PATH`, and will always use
-    /// its own binary.
-    ///
-    /// Default: true
-    pub ignore_system_version: Option<bool>,
-}
-
-impl BuiltinAgentServerSettings {
-    pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
-        self.path.map(|path| AgentServerCommand {
-            path,
-            args: self.args.unwrap_or_default(),
-            env: self.env,
-        })
-    }
-}
-
-impl From<AgentServerCommand> for BuiltinAgentServerSettings {
-    fn from(value: AgentServerCommand) -> Self {
-        BuiltinAgentServerSettings {
-            path: Some(value.path),
-            args: Some(value.args),
-            env: value.env,
-            ..Default::default()
-        }
-    }
-}
-
-#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)]
-pub struct CustomAgentServerSettings {
-    #[serde(flatten)]
-    pub command: AgentServerCommand,
-}
-
-impl settings::Settings for AllAgentServersSettings {
-    type FileContent = Self;
-
-    fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
-        let mut settings = AllAgentServersSettings::default();
-
-        for AllAgentServersSettings {
-            gemini,
-            claude,
-            custom,
-        } in sources.defaults_and_customizations()
-        {
-            if gemini.is_some() {
-                settings.gemini = gemini.clone();
-            }
-            if claude.is_some() {
-                settings.claude = claude.clone();
-            }
-
-            // Merge custom agents
-            for (name, config) in custom {
-                // Skip built-in agent names to avoid conflicts
-                if name != "gemini" && name != "claude" {
-                    settings.custom.insert(name.clone(), config.clone());
-                }
-            }
-        }
-
-        Ok(settings)
-    }
-
-    fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
-}

crates/agent_ui/src/acp/message_editor.rs πŸ”—

@@ -699,10 +699,15 @@ impl MessageEditor {
             self.project.read(cx).fs().clone(),
             self.history_store.clone(),
         ));
-        let delegate = AgentServerDelegate::new(self.project.clone(), None, None);
-        let connection = server.connect(Path::new(""), delegate, cx);
+        let delegate = AgentServerDelegate::new(
+            self.project.read(cx).agent_server_store().clone(),
+            self.project.clone(),
+            None,
+            None,
+        );
+        let connection = server.connect(None, delegate, cx);
         cx.spawn(async move |_, cx| {
-            let agent = connection.await?;
+            let (agent, _) = connection.await?;
             let agent = agent.downcast::<agent2::NativeAgentConnection>().unwrap();
             let summary = agent
                 .0

crates/agent_ui/src/acp/thread_view.rs πŸ”—

@@ -6,7 +6,7 @@ use acp_thread::{
 use acp_thread::{AgentConnection, Plan};
 use action_log::ActionLog;
 use agent_client_protocol::{self as acp, PromptCapabilities};
-use agent_servers::{AgentServer, AgentServerDelegate, ClaudeCode};
+use agent_servers::{AgentServer, AgentServerDelegate};
 use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting};
 use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore, NativeAgentServer};
 use anyhow::{Context as _, Result, anyhow, bail};
@@ -40,7 +40,6 @@ use std::path::Path;
 use std::sync::Arc;
 use std::time::Instant;
 use std::{collections::BTreeMap, rc::Rc, time::Duration};
-use task::SpawnInTerminal;
 use terminal_view::terminal_panel::TerminalPanel;
 use text::Anchor;
 use theme::{AgentFontSize, ThemeSettings};
@@ -263,6 +262,7 @@ pub struct AcpThreadView {
     workspace: WeakEntity<Workspace>,
     project: Entity<Project>,
     thread_state: ThreadState,
+    login: Option<task::SpawnInTerminal>,
     history_store: Entity<HistoryStore>,
     hovered_recent_history_item: Option<usize>,
     entry_view_state: Entity<EntryViewState>,
@@ -392,6 +392,7 @@ impl AcpThreadView {
             project: project.clone(),
             entry_view_state,
             thread_state: Self::initial_state(agent, resume_thread, workspace, project, window, cx),
+            login: None,
             message_editor,
             model_selector: None,
             profile_selector: None,
@@ -444,9 +445,11 @@ impl AcpThreadView {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> ThreadState {
-        if !project.read(cx).is_local() && agent.clone().downcast::<NativeAgentServer>().is_none() {
+        if project.read(cx).is_via_collab()
+            && agent.clone().downcast::<NativeAgentServer>().is_none()
+        {
             return ThreadState::LoadError(LoadError::Other(
-                "External agents are not yet supported for remote projects.".into(),
+                "External agents are not yet supported in shared projects.".into(),
             ));
         }
         let mut worktrees = project.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
@@ -466,20 +469,23 @@ impl AcpThreadView {
                     Some(worktree.read(cx).abs_path())
                 }
             })
-            .next()
-            .unwrap_or_else(|| paths::home_dir().as_path().into());
+            .next();
         let (status_tx, mut status_rx) = watch::channel("Loading…".into());
         let (new_version_available_tx, mut new_version_available_rx) = watch::channel(None);
         let delegate = AgentServerDelegate::new(
+            project.read(cx).agent_server_store().clone(),
             project.clone(),
             Some(status_tx),
             Some(new_version_available_tx),
         );
 
-        let connect_task = agent.connect(&root_dir, delegate, cx);
+        let connect_task = agent.connect(root_dir.as_deref(), delegate, cx);
         let load_task = cx.spawn_in(window, async move |this, cx| {
             let connection = match connect_task.await {
-                Ok(connection) => connection,
+                Ok((connection, login)) => {
+                    this.update(cx, |this, _| this.login = login).ok();
+                    connection
+                }
                 Err(err) => {
                     this.update_in(cx, |this, window, cx| {
                         if err.downcast_ref::<LoadError>().is_some() {
@@ -506,6 +512,14 @@ impl AcpThreadView {
                 })
                 .log_err()
             } else {
+                let root_dir = if let Some(acp_agent) = connection
+                    .clone()
+                    .downcast::<agent_servers::AcpConnection>()
+                {
+                    acp_agent.root_dir().into()
+                } else {
+                    root_dir.unwrap_or(paths::home_dir().as_path().into())
+                };
                 cx.update(|_, cx| {
                     connection
                         .clone()
@@ -1462,9 +1476,12 @@ impl AcpThreadView {
         self.thread_error.take();
         configuration_view.take();
         pending_auth_method.replace(method.clone());
-        let authenticate = if method.0.as_ref() == "claude-login" {
+        let authenticate = if (method.0.as_ref() == "claude-login"
+            || method.0.as_ref() == "spawn-gemini-cli")
+            && let Some(login) = self.login.clone()
+        {
             if let Some(workspace) = self.workspace.upgrade() {
-                Self::spawn_claude_login(&workspace, window, cx)
+                Self::spawn_external_agent_login(login, workspace, false, window, cx)
             } else {
                 Task::ready(Ok(()))
             }
@@ -1511,31 +1528,28 @@ impl AcpThreadView {
             }));
     }
 
-    fn spawn_claude_login(
-        workspace: &Entity<Workspace>,
+    fn spawn_external_agent_login(
+        login: task::SpawnInTerminal,
+        workspace: Entity<Workspace>,
+        previous_attempt: bool,
         window: &mut Window,
         cx: &mut App,
     ) -> Task<Result<()>> {
         let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
             return Task::ready(Ok(()));
         };
-        let project_entity = workspace.read(cx).project();
-        let project = project_entity.read(cx);
-        let cwd = project.first_project_directory(cx);
-        let shell = project.terminal_settings(&cwd, cx).shell.clone();
-
-        let delegate = AgentServerDelegate::new(project_entity.clone(), None, None);
-        let command = ClaudeCode::login_command(delegate, cx);
+        let project = workspace.read(cx).project().clone();
+        let cwd = project.read(cx).first_project_directory(cx);
+        let shell = project.read(cx).terminal_settings(&cwd, cx).shell.clone();
 
         window.spawn(cx, async move |cx| {
-            let login_command = command.await?;
-            let command = login_command
-                .path
-                .to_str()
-                .with_context(|| format!("invalid login command: {:?}", login_command.path))?;
-            let command = shlex::try_quote(command)?;
-            let args = login_command
-                .arguments
+            let mut task = login.clone();
+            task.command = task
+                .command
+                .map(|command| anyhow::Ok(shlex::try_quote(&command)?.to_string()))
+                .transpose()?;
+            task.args = task
+                .args
                 .iter()
                 .map(|arg| {
                     Ok(shlex::try_quote(arg)
@@ -1543,26 +1557,16 @@ impl AcpThreadView {
                         .to_string())
                 })
                 .collect::<Result<Vec<_>>>()?;
+            task.full_label = task.label.clone();
+            task.id = task::TaskId(format!("external-agent-{}-login", task.label));
+            task.command_label = task.label.clone();
+            task.use_new_terminal = true;
+            task.allow_concurrent_runs = true;
+            task.hide = task::HideStrategy::Always;
+            task.shell = shell;
 
             let terminal = terminal_panel.update_in(cx, |terminal_panel, window, cx| {
-                terminal_panel.spawn_task(
-                    &SpawnInTerminal {
-                        id: task::TaskId("claude-login".into()),
-                        full_label: "claude /login".to_owned(),
-                        label: "claude /login".to_owned(),
-                        command: Some(command.into()),
-                        args,
-                        command_label: "claude /login".to_owned(),
-                        cwd,
-                        use_new_terminal: true,
-                        allow_concurrent_runs: true,
-                        hide: task::HideStrategy::Always,
-                        shell,
-                        ..Default::default()
-                    },
-                    window,
-                    cx,
-                )
+                terminal_panel.spawn_task(&login, window, cx)
             })?;
 
             let terminal = terminal.await?;
@@ -1578,7 +1582,9 @@ impl AcpThreadView {
                             cx.background_executor().timer(Duration::from_secs(1)).await;
                             let content =
                                 terminal.update(cx, |terminal, _cx| terminal.get_content())?;
-                            if content.contains("Login successful") {
+                            if content.contains("Login successful")
+                                || content.contains("Type your message")
+                            {
                                 return anyhow::Ok(());
                             }
                         }
@@ -1594,6 +1600,9 @@ impl AcpThreadView {
                     }
                 }
                 _ = exit_status => {
+                    if !previous_attempt && project.read_with(cx, |project, _| project.is_via_remote_server())? && login.label.contains("gemini") {
+                        return cx.update(|window, cx| Self::spawn_external_agent_login(login, workspace, true, window, cx))?.await
+                    }
                     return Err(anyhow!("exited before logging in"));
                 }
             }
@@ -3088,26 +3097,38 @@ impl AcpThreadView {
                             })
                             .children(connection.auth_methods().iter().enumerate().rev().map(
                                 |(ix, method)| {
-                                    Button::new(
-                                        SharedString::from(method.id.0.clone()),
-                                        method.name.clone(),
-                                    )
-                                    .when(ix == 0, |el| {
-                                        el.style(ButtonStyle::Tinted(ui::TintColor::Warning))
-                                    })
-                                    .label_size(LabelSize::Small)
-                                    .on_click({
-                                        let method_id = method.id.clone();
-                                        cx.listener(move |this, _, window, cx| {
-                                            telemetry::event!(
-                                                "Authenticate Agent Started",
-                                                agent = this.agent.telemetry_id(),
-                                                method = method_id
-                                            );
+                                    let (method_id, name) = if self
+                                        .project
+                                        .read(cx)
+                                        .is_via_remote_server()
+                                        && method.id.0.as_ref() == "oauth-personal"
+                                        && method.name == "Log in with Google"
+                                    {
+                                        ("spawn-gemini-cli".into(), "Log in with Gemini CLI".into())
+                                    } else {
+                                        (method.id.0.clone(), method.name.clone())
+                                    };
 
-                                            this.authenticate(method_id.clone(), window, cx)
+                                    Button::new(SharedString::from(method_id.clone()), name)
+                                        .when(ix == 0, |el| {
+                                            el.style(ButtonStyle::Tinted(ui::TintColor::Warning))
+                                        })
+                                        .label_size(LabelSize::Small)
+                                        .on_click({
+                                            cx.listener(move |this, _, window, cx| {
+                                                telemetry::event!(
+                                                    "Authenticate Agent Started",
+                                                    agent = this.agent.telemetry_id(),
+                                                    method = method_id
+                                                );
+
+                                                this.authenticate(
+                                                    acp::AuthMethodId(method_id.clone()),
+                                                    window,
+                                                    cx,
+                                                )
+                                            })
                                         })
-                                    })
                                 },
                             )),
                     )
@@ -5710,11 +5731,11 @@ pub(crate) mod tests {
 
         fn connect(
             &self,
-            _root_dir: &Path,
+            _root_dir: Option<&Path>,
             _delegate: AgentServerDelegate,
             _cx: &mut App,
-        ) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
-            Task::ready(Ok(Rc::new(self.connection.clone())))
+        ) -> Task<gpui::Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
+            Task::ready(Ok((Rc::new(self.connection.clone()), None)))
         }
 
         fn into_any(self: Rc<Self>) -> Rc<dyn Any> {

crates/agent_ui/src/agent_configuration.rs πŸ”—

@@ -5,7 +5,6 @@ mod tool_picker;
 
 use std::{ops::Range, sync::Arc};
 
-use agent_servers::{AgentServerCommand, AllAgentServersSettings, CustomAgentServerSettings};
 use agent_settings::AgentSettings;
 use anyhow::Result;
 use assistant_tool::{ToolSource, ToolWorkingSet};
@@ -26,6 +25,10 @@ use language_model::{
 };
 use notifications::status_toast::{StatusToast, ToastIcon};
 use project::{
+    agent_server_store::{
+        AgentServerCommand, AgentServerStore, AllAgentServersSettings, CLAUDE_CODE_NAME,
+        CustomAgentServerSettings, GEMINI_NAME,
+    },
     context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
     project_settings::{ContextServerSettings, ProjectSettings},
 };
@@ -45,11 +48,13 @@ pub(crate) use manage_profiles_modal::ManageProfilesModal;
 use crate::{
     AddContextServer, ExternalAgent, NewExternalAgentThread,
     agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider},
+    placeholder_command,
 };
 
 pub struct AgentConfiguration {
     fs: Arc<dyn Fs>,
     language_registry: Arc<LanguageRegistry>,
+    agent_server_store: Entity<AgentServerStore>,
     workspace: WeakEntity<Workspace>,
     focus_handle: FocusHandle,
     configuration_views_by_provider: HashMap<LanguageModelProviderId, AnyView>,
@@ -66,6 +71,7 @@ pub struct AgentConfiguration {
 impl AgentConfiguration {
     pub fn new(
         fs: Arc<dyn Fs>,
+        agent_server_store: Entity<AgentServerStore>,
         context_server_store: Entity<ContextServerStore>,
         tools: Entity<ToolWorkingSet>,
         language_registry: Arc<LanguageRegistry>,
@@ -104,6 +110,7 @@ impl AgentConfiguration {
             workspace,
             focus_handle,
             configuration_views_by_provider: HashMap::default(),
+            agent_server_store,
             context_server_store,
             expanded_context_server_tools: HashMap::default(),
             expanded_provider_configurations: HashMap::default(),
@@ -991,17 +998,30 @@ impl AgentConfiguration {
     }
 
     fn render_agent_servers_section(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
-        let settings = AllAgentServersSettings::get_global(cx).clone();
-        let user_defined_agents = settings
+        let custom_settings = cx
+            .global::<SettingsStore>()
+            .get::<AllAgentServersSettings>(None)
             .custom
-            .iter()
-            .map(|(name, settings)| {
+            .clone();
+        let user_defined_agents = self
+            .agent_server_store
+            .read(cx)
+            .external_agents()
+            .filter(|name| name.0 != GEMINI_NAME && name.0 != CLAUDE_CODE_NAME)
+            .cloned()
+            .collect::<Vec<_>>();
+        let user_defined_agents = user_defined_agents
+            .into_iter()
+            .map(|name| {
                 self.render_agent_server(
                     IconName::Ai,
                     name.clone(),
                     ExternalAgent::Custom {
-                        name: name.clone(),
-                        command: settings.command.clone(),
+                        name: name.clone().into(),
+                        command: custom_settings
+                            .get(&name.0)
+                            .map(|settings| settings.command.clone())
+                            .unwrap_or(placeholder_command()),
                     },
                     cx,
                 )

crates/agent_ui/src/agent_panel.rs πŸ”—

@@ -5,9 +5,11 @@ use std::sync::Arc;
 use std::time::Duration;
 
 use acp_thread::AcpThread;
-use agent_servers::AgentServerCommand;
 use agent2::{DbThreadMetadata, HistoryEntry};
 use db::kvp::{Dismissable, KEY_VALUE_STORE};
+use project::agent_server_store::{
+    AgentServerCommand, AllAgentServersSettings, CLAUDE_CODE_NAME, GEMINI_NAME,
+};
 use serde::{Deserialize, Serialize};
 use zed_actions::OpenBrowser;
 use zed_actions::agent::{OpenClaudeCodeOnboardingModal, ReauthenticateAgent};
@@ -33,7 +35,9 @@ use crate::{
     thread_history::{HistoryEntryElement, ThreadHistory},
     ui::{AgentOnboardingModal, EndTrialUpsell},
 };
-use crate::{ExternalAgent, NewExternalAgentThread, NewNativeAgentThreadFromSummary};
+use crate::{
+    ExternalAgent, NewExternalAgentThread, NewNativeAgentThreadFromSummary, placeholder_command,
+};
 use agent::{
     Thread, ThreadError, ThreadEvent, ThreadId, ThreadSummary, TokenUsageRatio,
     context_store::ContextStore,
@@ -62,7 +66,7 @@ use project::{DisableAiSettings, Project, ProjectPath, Worktree};
 use prompt_store::{PromptBuilder, PromptStore, UserPromptId};
 use rules_library::{RulesLibrary, open_rules_library};
 use search::{BufferSearchBar, buffer_search};
-use settings::{Settings, update_settings_file};
+use settings::{Settings, SettingsStore, update_settings_file};
 use theme::ThemeSettings;
 use time::UtcOffset;
 use ui::utils::WithRemSize;
@@ -1094,7 +1098,7 @@ impl AgentPanel {
         let workspace = self.workspace.clone();
         let project = self.project.clone();
         let fs = self.fs.clone();
-        let is_not_local = !self.project.read(cx).is_local();
+        let is_via_collab = self.project.read(cx).is_via_collab();
 
         const LAST_USED_EXTERNAL_AGENT_KEY: &str = "agent_panel__last_used_external_agent";
 
@@ -1126,7 +1130,7 @@ impl AgentPanel {
                     agent
                 }
                 None => {
-                    if is_not_local {
+                    if is_via_collab {
                         ExternalAgent::NativeAgent
                     } else {
                         cx.background_spawn(async move {
@@ -1503,6 +1507,7 @@ impl AgentPanel {
     }
 
     pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let agent_server_store = self.project.read(cx).agent_server_store().clone();
         let context_server_store = self.project.read(cx).context_server_store();
         let tools = self.thread_store.read(cx).tools();
         let fs = self.fs.clone();
@@ -1511,6 +1516,7 @@ impl AgentPanel {
         self.configuration = Some(cx.new(|cx| {
             AgentConfiguration::new(
                 fs,
+                agent_server_store,
                 context_server_store,
                 tools,
                 self.language_registry.clone(),
@@ -2503,6 +2509,7 @@ impl AgentPanel {
     }
 
     fn render_toolbar_new(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let agent_server_store = self.project.read(cx).agent_server_store().clone();
         let focus_handle = self.focus_handle(cx);
 
         let active_thread = match &self.active_view {
@@ -2535,8 +2542,10 @@ impl AgentPanel {
             .with_handle(self.new_thread_menu_handle.clone())
             .menu({
                 let workspace = self.workspace.clone();
-                let is_not_local = workspace
-                    .update(cx, |workspace, cx| !workspace.project().read(cx).is_local())
+                let is_via_collab = workspace
+                    .update(cx, |workspace, cx| {
+                        workspace.project().read(cx).is_via_collab()
+                    })
                     .unwrap_or_default();
 
                 move |window, cx| {
@@ -2628,7 +2637,7 @@ impl AgentPanel {
                                     ContextMenuEntry::new("New Gemini CLI Thread")
                                         .icon(IconName::AiGemini)
                                         .icon_color(Color::Muted)
-                                        .disabled(is_not_local)
+                                        .disabled(is_via_collab)
                                         .handler({
                                             let workspace = workspace.clone();
                                             move |window, cx| {
@@ -2655,7 +2664,7 @@ impl AgentPanel {
                                 menu.item(
                                     ContextMenuEntry::new("New Claude Code Thread")
                                         .icon(IconName::AiClaude)
-                                        .disabled(is_not_local)
+                                        .disabled(is_via_collab)
                                         .icon_color(Color::Muted)
                                         .handler({
                                             let workspace = workspace.clone();
@@ -2680,19 +2689,25 @@ impl AgentPanel {
                                 )
                             })
                             .when(cx.has_flag::<GeminiAndNativeFeatureFlag>(), |mut menu| {
-                                // Add custom agents from settings
-                                let settings =
-                                    agent_servers::AllAgentServersSettings::get_global(cx);
-                                for (agent_name, agent_settings) in &settings.custom {
+                                let agent_names = agent_server_store
+                                    .read(cx)
+                                    .external_agents()
+                                    .filter(|name| {
+                                        name.0 != GEMINI_NAME && name.0 != CLAUDE_CODE_NAME
+                                    })
+                                    .cloned()
+                                    .collect::<Vec<_>>();
+                                let custom_settings = cx.global::<SettingsStore>().get::<AllAgentServersSettings>(None).custom.clone();
+                                for agent_name in agent_names {
                                     menu = menu.item(
                                         ContextMenuEntry::new(format!("New {} Thread", agent_name))
                                             .icon(IconName::Terminal)
                                             .icon_color(Color::Muted)
-                                            .disabled(is_not_local)
+                                            .disabled(is_via_collab)
                                             .handler({
                                                 let workspace = workspace.clone();
                                                 let agent_name = agent_name.clone();
-                                                let agent_settings = agent_settings.clone();
+                                                let custom_settings = custom_settings.clone();
                                                 move |window, cx| {
                                                     if let Some(workspace) = workspace.upgrade() {
                                                         workspace.update(cx, |workspace, cx| {
@@ -2703,10 +2718,9 @@ impl AgentPanel {
                                                                     panel.new_agent_thread(
                                                                         AgentType::Custom {
                                                                             name: agent_name
-                                                                                .clone(),
-                                                                            command: agent_settings
-                                                                                .command
-                                                                                .clone(),
+                                                                                .clone()
+                                                                                .into(),
+                                                                            command: custom_settings.get(&agent_name.0).map(|settings| settings.command.clone()).unwrap_or(placeholder_command())
                                                                         },
                                                                         window,
                                                                         cx,

crates/agent_ui/src/agent_ui.rs πŸ”—

@@ -28,7 +28,6 @@ use std::rc::Rc;
 use std::sync::Arc;
 
 use agent::{Thread, ThreadId};
-use agent_servers::AgentServerCommand;
 use agent_settings::{AgentProfileId, AgentSettings, LanguageModelSelection};
 use assistant_slash_command::SlashCommandRegistry;
 use client::Client;
@@ -41,6 +40,7 @@ use language_model::{
     ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry,
 };
 use project::DisableAiSettings;
+use project::agent_server_store::AgentServerCommand;
 use prompt_store::PromptBuilder;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
@@ -174,6 +174,14 @@ enum ExternalAgent {
     },
 }
 
+fn placeholder_command() -> AgentServerCommand {
+    AgentServerCommand {
+        path: "/placeholder".into(),
+        args: vec![],
+        env: None,
+    }
+}
+
 impl ExternalAgent {
     fn name(&self) -> &'static str {
         match self {
@@ -193,10 +201,9 @@ impl ExternalAgent {
             Self::Gemini => Rc::new(agent_servers::Gemini),
             Self::ClaudeCode => Rc::new(agent_servers::ClaudeCode),
             Self::NativeAgent => Rc::new(agent2::NativeAgentServer::new(fs, history)),
-            Self::Custom { name, command } => Rc::new(agent_servers::CustomAgentServer::new(
-                name.clone(),
-                command.clone(),
-            )),
+            Self::Custom { name, command: _ } => {
+                Rc::new(agent_servers::CustomAgentServer::new(name.clone()))
+            }
         }
     }
 }

crates/project/Cargo.toml πŸ”—

@@ -67,6 +67,7 @@ regex.workspace = true
 remote.workspace = true
 rpc.workspace = true
 schemars.workspace = true
+semver.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true
@@ -85,6 +86,7 @@ text.workspace = true
 toml.workspace = true
 url.workspace = true
 util.workspace = true
+watch.workspace = true
 which.workspace = true
 worktree.workspace = true
 zlog.workspace = true

crates/project/src/agent_server_store.rs πŸ”—

@@ -0,0 +1,1091 @@
+use std::{
+    any::Any,
+    borrow::Borrow,
+    path::{Path, PathBuf},
+    str::FromStr as _,
+    sync::Arc,
+    time::Duration,
+};
+
+use anyhow::{Context as _, Result, bail};
+use collections::HashMap;
+use fs::{Fs, RemoveOptions, RenameOptions};
+use futures::StreamExt as _;
+use gpui::{
+    App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task,
+};
+use node_runtime::NodeRuntime;
+use remote::RemoteClient;
+use rpc::{
+    AnyProtoClient, TypedEnvelope,
+    proto::{self, ToProto},
+};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::{SettingsKey, SettingsSources, SettingsStore, SettingsUi};
+use util::{ResultExt as _, debug_panic};
+
+use crate::ProjectEnvironment;
+
+#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
+pub struct AgentServerCommand {
+    #[serde(rename = "command")]
+    pub path: PathBuf,
+    #[serde(default)]
+    pub args: Vec<String>,
+    pub env: Option<HashMap<String, String>>,
+}
+
+impl std::fmt::Debug for AgentServerCommand {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let filtered_env = self.env.as_ref().map(|env| {
+            env.iter()
+                .map(|(k, v)| {
+                    (
+                        k,
+                        if util::redact::should_redact(k) {
+                            "[REDACTED]"
+                        } else {
+                            v
+                        },
+                    )
+                })
+                .collect::<Vec<_>>()
+        });
+
+        f.debug_struct("AgentServerCommand")
+            .field("path", &self.path)
+            .field("args", &self.args)
+            .field("env", &filtered_env)
+            .finish()
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+pub struct ExternalAgentServerName(pub SharedString);
+
+impl std::fmt::Display for ExternalAgentServerName {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", self.0)
+    }
+}
+
+impl From<&'static str> for ExternalAgentServerName {
+    fn from(value: &'static str) -> Self {
+        ExternalAgentServerName(value.into())
+    }
+}
+
+impl From<ExternalAgentServerName> for SharedString {
+    fn from(value: ExternalAgentServerName) -> Self {
+        value.0
+    }
+}
+
+impl Borrow<str> for ExternalAgentServerName {
+    fn borrow(&self) -> &str {
+        &self.0
+    }
+}
+
+pub trait ExternalAgentServer {
+    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>)>>;
+
+    fn as_any_mut(&mut self) -> &mut dyn Any;
+}
+
+impl dyn ExternalAgentServer {
+    fn downcast_mut<T: ExternalAgentServer + 'static>(&mut self) -> Option<&mut T> {
+        self.as_any_mut().downcast_mut()
+    }
+}
+
+enum AgentServerStoreState {
+    Local {
+        node_runtime: NodeRuntime,
+        fs: Arc<dyn Fs>,
+        project_environment: Entity<ProjectEnvironment>,
+        downstream_client: Option<(u64, AnyProtoClient)>,
+        settings: Option<AllAgentServersSettings>,
+        _subscriptions: [Subscription; 1],
+    },
+    Remote {
+        project_id: u64,
+        upstream_client: Entity<RemoteClient>,
+    },
+    Collab,
+}
+
+pub struct AgentServerStore {
+    state: AgentServerStoreState,
+    external_agents: HashMap<ExternalAgentServerName, Box<dyn ExternalAgentServer>>,
+}
+
+pub struct AgentServersUpdated;
+
+impl EventEmitter<AgentServersUpdated> for AgentServerStore {}
+
+impl AgentServerStore {
+    pub fn init_remote(session: &AnyProtoClient) {
+        session.add_entity_message_handler(Self::handle_external_agents_updated);
+        session.add_entity_message_handler(Self::handle_loading_status_updated);
+        session.add_entity_message_handler(Self::handle_new_version_available);
+    }
+
+    pub fn init_headless(session: &AnyProtoClient) {
+        session.add_entity_request_handler(Self::handle_get_agent_server_command);
+    }
+
+    fn agent_servers_settings_changed(&mut self, cx: &mut Context<Self>) {
+        let AgentServerStoreState::Local {
+            node_runtime,
+            fs,
+            project_environment,
+            downstream_client,
+            settings: old_settings,
+            ..
+        } = &mut self.state
+        else {
+            debug_panic!(
+                "should not be subscribed to agent server settings changes in non-local project"
+            );
+            return;
+        };
+
+        let new_settings = cx
+            .global::<SettingsStore>()
+            .get::<AllAgentServersSettings>(None)
+            .clone();
+        if Some(&new_settings) == old_settings.as_ref() {
+            return;
+        }
+
+        self.external_agents.clear();
+        self.external_agents.insert(
+            GEMINI_NAME.into(),
+            Box::new(LocalGemini {
+                fs: fs.clone(),
+                node_runtime: node_runtime.clone(),
+                project_environment: project_environment.clone(),
+                custom_command: new_settings
+                    .gemini
+                    .clone()
+                    .and_then(|settings| settings.custom_command()),
+                ignore_system_version: new_settings
+                    .gemini
+                    .as_ref()
+                    .and_then(|settings| settings.ignore_system_version)
+                    .unwrap_or(true),
+            }),
+        );
+        self.external_agents.insert(
+            CLAUDE_CODE_NAME.into(),
+            Box::new(LocalClaudeCode {
+                fs: fs.clone(),
+                node_runtime: node_runtime.clone(),
+                project_environment: project_environment.clone(),
+                custom_command: new_settings.claude.clone().map(|settings| settings.command),
+            }),
+        );
+        self.external_agents
+            .extend(new_settings.custom.iter().map(|(name, settings)| {
+                (
+                    ExternalAgentServerName(name.clone()),
+                    Box::new(LocalCustomAgent {
+                        command: settings.command.clone(),
+                        project_environment: project_environment.clone(),
+                    }) as Box<dyn ExternalAgentServer>,
+                )
+            }));
+
+        *old_settings = Some(new_settings.clone());
+
+        if let Some((project_id, downstream_client)) = downstream_client {
+            downstream_client
+                .send(proto::ExternalAgentsUpdated {
+                    project_id: *project_id,
+                    names: self
+                        .external_agents
+                        .keys()
+                        .map(|name| name.to_string())
+                        .collect(),
+                })
+                .log_err();
+        }
+        cx.emit(AgentServersUpdated);
+    }
+
+    pub fn local(
+        node_runtime: NodeRuntime,
+        fs: Arc<dyn Fs>,
+        project_environment: Entity<ProjectEnvironment>,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let subscription = cx.observe_global::<SettingsStore>(|this, cx| {
+            this.agent_servers_settings_changed(cx);
+        });
+        let this = Self {
+            state: AgentServerStoreState::Local {
+                node_runtime,
+                fs,
+                project_environment,
+                downstream_client: None,
+                settings: None,
+                _subscriptions: [subscription],
+            },
+            external_agents: Default::default(),
+        };
+        cx.spawn(async move |this, cx| {
+            cx.background_executor().timer(Duration::from_secs(1)).await;
+            this.update(cx, |this, cx| {
+                this.agent_servers_settings_changed(cx);
+            })
+            .ok();
+        })
+        .detach();
+        this
+    }
+
+    pub(crate) fn remote(
+        project_id: u64,
+        upstream_client: Entity<RemoteClient>,
+        _cx: &mut Context<Self>,
+    ) -> Self {
+        // Set up the builtin agents here so they're immediately available in
+        // remote projects--we know that the HeadlessProject on the other end
+        // will have them.
+        let external_agents = [
+            (
+                GEMINI_NAME.into(),
+                Box::new(RemoteExternalAgentServer {
+                    project_id,
+                    upstream_client: upstream_client.clone(),
+                    name: GEMINI_NAME.into(),
+                    status_tx: None,
+                    new_version_available_tx: None,
+                }) as Box<dyn ExternalAgentServer>,
+            ),
+            (
+                CLAUDE_CODE_NAME.into(),
+                Box::new(RemoteExternalAgentServer {
+                    project_id,
+                    upstream_client: upstream_client.clone(),
+                    name: CLAUDE_CODE_NAME.into(),
+                    status_tx: None,
+                    new_version_available_tx: None,
+                }) as Box<dyn ExternalAgentServer>,
+            ),
+        ]
+        .into_iter()
+        .collect();
+
+        Self {
+            state: AgentServerStoreState::Remote {
+                project_id,
+                upstream_client,
+            },
+            external_agents,
+        }
+    }
+
+    pub(crate) fn collab(_cx: &mut Context<Self>) -> Self {
+        Self {
+            state: AgentServerStoreState::Collab,
+            external_agents: Default::default(),
+        }
+    }
+
+    pub fn shared(&mut self, project_id: u64, client: AnyProtoClient) {
+        match &mut self.state {
+            AgentServerStoreState::Local {
+                downstream_client, ..
+            } => {
+                client
+                    .send(proto::ExternalAgentsUpdated {
+                        project_id,
+                        names: self
+                            .external_agents
+                            .keys()
+                            .map(|name| name.to_string())
+                            .collect(),
+                    })
+                    .log_err();
+                *downstream_client = Some((project_id, client));
+            }
+            AgentServerStoreState::Remote { .. } => {
+                debug_panic!(
+                    "external agents over collab not implemented, remote project should not be shared"
+                );
+            }
+            AgentServerStoreState::Collab => {
+                debug_panic!("external agents over collab not implemented, should not be shared");
+            }
+        }
+    }
+
+    pub fn get_external_agent(
+        &mut self,
+        name: &ExternalAgentServerName,
+    ) -> Option<&mut (dyn ExternalAgentServer + 'static)> {
+        self.external_agents
+            .get_mut(name)
+            .map(|agent| agent.as_mut())
+    }
+
+    pub fn external_agents(&self) -> impl Iterator<Item = &ExternalAgentServerName> {
+        self.external_agents.keys()
+    }
+
+    async fn handle_get_agent_server_command(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::GetAgentServerCommand>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::AgentServerCommand> {
+        let (command, root_dir, login) = this
+            .update(&mut cx, |this, cx| {
+                let AgentServerStoreState::Local {
+                    downstream_client, ..
+                } = &this.state
+                else {
+                    debug_panic!("should not receive GetAgentServerCommand in a non-local project");
+                    bail!("unexpected GetAgentServerCommand request in a non-local project");
+                };
+                let agent = this
+                    .external_agents
+                    .get_mut(&*envelope.payload.name)
+                    .with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
+                let (status_tx, new_version_available_tx) = downstream_client
+                    .clone()
+                    .map(|(project_id, downstream_client)| {
+                        let (status_tx, mut status_rx) = watch::channel(SharedString::from(""));
+                        let (new_version_available_tx, mut new_version_available_rx) =
+                            watch::channel(None);
+                        cx.spawn({
+                            let downstream_client = downstream_client.clone();
+                            let name = envelope.payload.name.clone();
+                            async move |_, _| {
+                                while let Some(status) = status_rx.recv().await.ok() {
+                                    downstream_client.send(
+                                        proto::ExternalAgentLoadingStatusUpdated {
+                                            project_id,
+                                            name: name.clone(),
+                                            status: status.to_string(),
+                                        },
+                                    )?;
+                                }
+                                anyhow::Ok(())
+                            }
+                        })
+                        .detach_and_log_err(cx);
+                        cx.spawn({
+                            let name = envelope.payload.name.clone();
+                            async move |_, _| {
+                                if let Some(version) =
+                                    new_version_available_rx.recv().await.ok().flatten()
+                                {
+                                    downstream_client.send(
+                                        proto::NewExternalAgentVersionAvailable {
+                                            project_id,
+                                            name: name.clone(),
+                                            version,
+                                        },
+                                    )?;
+                                }
+                                anyhow::Ok(())
+                            }
+                        })
+                        .detach_and_log_err(cx);
+                        (status_tx, new_version_available_tx)
+                    })
+                    .unzip();
+                anyhow::Ok(agent.get_command(
+                    envelope.payload.root_dir.as_deref(),
+                    HashMap::default(),
+                    status_tx,
+                    new_version_available_tx,
+                    &mut cx.to_async(),
+                ))
+            })??
+            .await?;
+        Ok(proto::AgentServerCommand {
+            path: command.path.to_string_lossy().to_string(),
+            args: command.args,
+            env: command
+                .env
+                .map(|env| env.into_iter().collect())
+                .unwrap_or_default(),
+            root_dir: root_dir,
+            login: login.map(|login| login.to_proto()),
+        })
+    }
+
+    async fn handle_external_agents_updated(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::ExternalAgentsUpdated>,
+        mut cx: AsyncApp,
+    ) -> Result<()> {
+        this.update(&mut cx, |this, cx| {
+            let AgentServerStoreState::Remote {
+                project_id,
+                upstream_client,
+            } = &this.state
+            else {
+                debug_panic!(
+                    "handle_external_agents_updated should not be called for a non-remote project"
+                );
+                bail!("unexpected ExternalAgentsUpdated message")
+            };
+
+            let mut status_txs = this
+                .external_agents
+                .iter_mut()
+                .filter_map(|(name, agent)| {
+                    Some((
+                        name.clone(),
+                        agent
+                            .downcast_mut::<RemoteExternalAgentServer>()?
+                            .status_tx
+                            .take(),
+                    ))
+                })
+                .collect::<HashMap<_, _>>();
+            let mut new_version_available_txs = this
+                .external_agents
+                .iter_mut()
+                .filter_map(|(name, agent)| {
+                    Some((
+                        name.clone(),
+                        agent
+                            .downcast_mut::<RemoteExternalAgentServer>()?
+                            .new_version_available_tx
+                            .take(),
+                    ))
+                })
+                .collect::<HashMap<_, _>>();
+
+            this.external_agents = envelope
+                .payload
+                .names
+                .into_iter()
+                .map(|name| {
+                    let agent = RemoteExternalAgentServer {
+                        project_id: *project_id,
+                        upstream_client: upstream_client.clone(),
+                        name: ExternalAgentServerName(name.clone().into()),
+                        status_tx: status_txs.remove(&*name).flatten(),
+                        new_version_available_tx: new_version_available_txs
+                            .remove(&*name)
+                            .flatten(),
+                    };
+                    (
+                        ExternalAgentServerName(name.into()),
+                        Box::new(agent) as Box<dyn ExternalAgentServer>,
+                    )
+                })
+                .collect();
+            cx.emit(AgentServersUpdated);
+            Ok(())
+        })?
+    }
+
+    async fn handle_loading_status_updated(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::ExternalAgentLoadingStatusUpdated>,
+        mut cx: AsyncApp,
+    ) -> Result<()> {
+        this.update(&mut cx, |this, _| {
+            if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
+                && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
+                && let Some(status_tx) = &mut agent.status_tx
+            {
+                status_tx.send(envelope.payload.status.into()).ok();
+            }
+        })
+    }
+
+    async fn handle_new_version_available(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
+        mut cx: AsyncApp,
+    ) -> Result<()> {
+        this.update(&mut cx, |this, _| {
+            if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
+                && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
+                && let Some(new_version_available_tx) = &mut agent.new_version_available_tx
+            {
+                new_version_available_tx
+                    .send(Some(envelope.payload.version))
+                    .ok();
+            }
+        })
+    }
+}
+
+fn get_or_npm_install_builtin_agent(
+    binary_name: SharedString,
+    package_name: SharedString,
+    entrypoint_path: PathBuf,
+    minimum_version: Option<semver::Version>,
+    status_tx: Option<watch::Sender<SharedString>>,
+    new_version_available: Option<watch::Sender<Option<String>>>,
+    fs: Arc<dyn Fs>,
+    node_runtime: NodeRuntime,
+    cx: &mut AsyncApp,
+) -> Task<std::result::Result<AgentServerCommand, anyhow::Error>> {
+    cx.spawn(async move |cx| {
+        let node_path = node_runtime.binary_path().await?;
+        let dir = paths::data_dir()
+            .join("external_agents")
+            .join(binary_name.as_str());
+        fs.create_dir(&dir).await?;
+
+        let mut stream = fs.read_dir(&dir).await?;
+        let mut versions = Vec::new();
+        let mut to_delete = Vec::new();
+        while let Some(entry) = stream.next().await {
+            let Ok(entry) = entry else { continue };
+            let Some(file_name) = entry.file_name() else {
+                continue;
+            };
+
+            if let Some(name) = file_name.to_str()
+                && let Some(version) = semver::Version::from_str(name).ok()
+                && fs
+                    .is_file(&dir.join(file_name).join(&entrypoint_path))
+                    .await
+            {
+                versions.push((version, file_name.to_owned()));
+            } else {
+                to_delete.push(file_name.to_owned())
+            }
+        }
+
+        versions.sort();
+        let newest_version = if let Some((version, file_name)) = versions.last().cloned()
+            && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
+        {
+            versions.pop();
+            Some(file_name)
+        } else {
+            None
+        };
+        log::debug!("existing version of {package_name}: {newest_version:?}");
+        to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
+
+        cx.background_spawn({
+            let fs = fs.clone();
+            let dir = dir.clone();
+            async move {
+                for file_name in to_delete {
+                    fs.remove_dir(
+                        &dir.join(file_name),
+                        RemoveOptions {
+                            recursive: true,
+                            ignore_if_not_exists: false,
+                        },
+                    )
+                    .await
+                    .ok();
+                }
+            }
+        })
+        .detach();
+
+        let version = if let Some(file_name) = newest_version {
+            cx.background_spawn({
+                let file_name = file_name.clone();
+                let dir = dir.clone();
+                let fs = fs.clone();
+                async move {
+                    let latest_version =
+                        node_runtime.npm_package_latest_version(&package_name).await;
+                    if let Ok(latest_version) = latest_version
+                        && &latest_version != &file_name.to_string_lossy()
+                    {
+                        download_latest_version(
+                            fs,
+                            dir.clone(),
+                            node_runtime,
+                            package_name.clone(),
+                        )
+                        .await
+                        .log_err();
+                        if let Some(mut new_version_available) = new_version_available {
+                            new_version_available.send(Some(latest_version)).ok();
+                        }
+                    }
+                }
+            })
+            .detach();
+            file_name
+        } else {
+            if let Some(mut status_tx) = status_tx {
+                status_tx.send("Installing…".into()).ok();
+            }
+            let dir = dir.clone();
+            cx.background_spawn(download_latest_version(
+                fs.clone(),
+                dir.clone(),
+                node_runtime,
+                package_name.clone(),
+            ))
+            .await?
+            .into()
+        };
+
+        let agent_server_path = dir.join(version).join(entrypoint_path);
+        let agent_server_path_exists = fs.is_file(&agent_server_path).await;
+        anyhow::ensure!(
+            agent_server_path_exists,
+            "Missing entrypoint path {} after installation",
+            agent_server_path.to_string_lossy()
+        );
+
+        anyhow::Ok(AgentServerCommand {
+            path: node_path,
+            args: vec![agent_server_path.to_string_lossy().to_string()],
+            env: None,
+        })
+    })
+}
+
+fn find_bin_in_path(
+    bin_name: SharedString,
+    root_dir: PathBuf,
+    env: HashMap<String, String>,
+    cx: &mut AsyncApp,
+) -> Task<Option<PathBuf>> {
+    cx.background_executor().spawn(async move {
+        let which_result = if cfg!(windows) {
+            which::which(bin_name.as_str())
+        } else {
+            let shell_path = env.get("PATH").cloned();
+            which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
+        };
+
+        if let Err(which::Error::CannotFindBinaryPath) = which_result {
+            return None;
+        }
+
+        which_result.log_err()
+    })
+}
+
+async fn download_latest_version(
+    fs: Arc<dyn Fs>,
+    dir: PathBuf,
+    node_runtime: NodeRuntime,
+    package_name: SharedString,
+) -> Result<String> {
+    log::debug!("downloading latest version of {package_name}");
+
+    let tmp_dir = tempfile::tempdir_in(&dir)?;
+
+    node_runtime
+        .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
+        .await?;
+
+    let version = node_runtime
+        .npm_package_installed_version(tmp_dir.path(), &package_name)
+        .await?
+        .context("expected package to be installed")?;
+
+    fs.rename(
+        &tmp_dir.keep(),
+        &dir.join(&version),
+        RenameOptions {
+            ignore_if_exists: true,
+            overwrite: false,
+        },
+    )
+    .await?;
+
+    anyhow::Ok(version)
+}
+
+struct RemoteExternalAgentServer {
+    project_id: u64,
+    upstream_client: Entity<RemoteClient>,
+    name: ExternalAgentServerName,
+    status_tx: Option<watch::Sender<SharedString>>,
+    new_version_available_tx: Option<watch::Sender<Option<String>>>,
+}
+
+// new method: status_updated
+// does nothing in the all-local case
+// for RemoteExternalAgentServer, sends on the stored tx
+// etc.
+
+impl ExternalAgentServer for RemoteExternalAgentServer {
+    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 project_id = self.project_id;
+        let name = self.name.to_string();
+        let upstream_client = self.upstream_client.downgrade();
+        let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
+        self.status_tx = status_tx;
+        self.new_version_available_tx = new_version_available_tx;
+        cx.spawn(async move |cx| {
+            let mut response = upstream_client
+                .update(cx, |upstream_client, _| {
+                    upstream_client
+                        .proto_client()
+                        .request(proto::GetAgentServerCommand {
+                            project_id,
+                            name,
+                            root_dir: root_dir.clone(),
+                        })
+                })?
+                .await?;
+            let root_dir = response.root_dir;
+            response.env.extend(extra_env);
+            let command = upstream_client.update(cx, |client, _| {
+                client.build_command(
+                    Some(response.path),
+                    &response.args,
+                    &response.env.into_iter().collect(),
+                    Some(root_dir.clone()),
+                    None,
+                )
+            })??;
+            Ok((
+                AgentServerCommand {
+                    path: command.program.into(),
+                    args: command.args,
+                    env: Some(command.env),
+                },
+                root_dir,
+                response
+                    .login
+                    .map(|login| task::SpawnInTerminal::from_proto(login)),
+            ))
+        })
+    }
+
+    fn as_any_mut(&mut self) -> &mut dyn Any {
+        self
+    }
+}
+
+struct LocalGemini {
+    fs: Arc<dyn Fs>,
+    node_runtime: NodeRuntime,
+    project_environment: Entity<ProjectEnvironment>,
+    custom_command: Option<AgentServerCommand>,
+    ignore_system_version: bool,
+}
+
+impl ExternalAgentServer for LocalGemini {
+    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 node_runtime = self.node_runtime.clone();
+        let project_environment = self.project_environment.downgrade();
+        let custom_command = self.custom_command.clone();
+        let ignore_system_version = self.ignore_system_version;
+        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 = if let Some(mut custom_command) = custom_command {
+                env.extend(custom_command.env.unwrap_or_default());
+                custom_command.env = Some(env);
+                custom_command
+            } else if !ignore_system_version
+                && let Some(bin) =
+                    find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
+            {
+                AgentServerCommand {
+                    path: bin,
+                    args: Vec::new(),
+                    env: Some(env),
+                }
+            } else {
+                let mut command = get_or_npm_install_builtin_agent(
+                    GEMINI_NAME.into(),
+                    "@google/gemini-cli".into(),
+                    "node_modules/@google/gemini-cli/dist/index.js".into(),
+                    Some("0.2.1".parse().unwrap()),
+                    status_tx,
+                    new_version_available_tx,
+                    fs,
+                    node_runtime,
+                    cx,
+                )
+                .await?;
+                command.env = Some(env);
+                command
+            };
+
+            // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
+            let login = task::SpawnInTerminal {
+                command: Some(command.path.clone().to_proto()),
+                args: command.args.clone(),
+                env: command.env.clone().unwrap_or_default(),
+                label: "gemini /auth".into(),
+                ..Default::default()
+            };
+
+            command.env.get_or_insert_default().extend(extra_env);
+            command.args.push("--experimental-acp".into());
+            Ok((command, root_dir.to_proto(), Some(login)))
+        })
+    }
+
+    fn as_any_mut(&mut self) -> &mut dyn Any {
+        self
+    }
+}
+
+struct LocalClaudeCode {
+    fs: Arc<dyn Fs>,
+    node_runtime: NodeRuntime,
+    project_environment: Entity<ProjectEnvironment>,
+    custom_command: Option<AgentServerCommand>,
+}
+
+impl ExternalAgentServer for LocalClaudeCode {
+    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 node_runtime = self.node_runtime.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();
+            env.insert("ANTHROPIC_API_KEY".into(), "".into());
+
+            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 mut command = get_or_npm_install_builtin_agent(
+                    "claude-code-acp".into(),
+                    "@zed-industries/claude-code-acp".into(),
+                    "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
+                    Some("0.2.5".parse().unwrap()),
+                    status_tx,
+                    new_version_available_tx,
+                    fs,
+                    node_runtime,
+                    cx,
+                )
+                .await?;
+                command.env = Some(env);
+                let login = command
+                    .args
+                    .first()
+                    .and_then(|path| {
+                        path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
+                    })
+                    .map(|path_prefix| task::SpawnInTerminal {
+                        command: Some(command.path.clone().to_proto()),
+                        args: vec![
+                            Path::new(path_prefix)
+                                .join("@anthropic-ai/claude-code/cli.js")
+                                .to_string_lossy()
+                                .to_string(),
+                            "/login".into(),
+                        ],
+                        env: command.env.clone().unwrap_or_default(),
+                        label: "claude /login".into(),
+                        ..Default::default()
+                    });
+                (command, login)
+            };
+
+            command.env.get_or_insert_default().extend(extra_env);
+            Ok((command, root_dir.to_proto(), login))
+        })
+    }
+
+    fn as_any_mut(&mut self) -> &mut dyn Any {
+        self
+    }
+}
+
+struct LocalCustomAgent {
+    project_environment: Entity<ProjectEnvironment>,
+    command: AgentServerCommand,
+}
+
+impl ExternalAgentServer for LocalCustomAgent {
+    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 mut command = self.command.clone();
+        let root_dir: Arc<Path> = root_dir
+            .map(|root_dir| Path::new(root_dir))
+            .unwrap_or(paths::home_dir())
+            .into();
+        let project_environment = self.project_environment.downgrade();
+        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();
+            env.extend(command.env.unwrap_or_default());
+            env.extend(extra_env);
+            command.env = Some(env);
+            Ok((command, root_dir.to_proto(), None))
+        })
+    }
+
+    fn as_any_mut(&mut self) -> &mut dyn Any {
+        self
+    }
+}
+
+pub const GEMINI_NAME: &'static str = "gemini";
+pub const CLAUDE_CODE_NAME: &'static str = "claude";
+
+#[derive(
+    Default, Deserialize, Serialize, Clone, JsonSchema, Debug, SettingsUi, SettingsKey, PartialEq,
+)]
+#[settings_key(key = "agent_servers")]
+pub struct AllAgentServersSettings {
+    pub gemini: Option<BuiltinAgentServerSettings>,
+    pub claude: Option<CustomAgentServerSettings>,
+
+    /// Custom agent servers configured by the user
+    #[serde(flatten)]
+    pub custom: HashMap<SharedString, CustomAgentServerSettings>,
+}
+
+#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)]
+pub struct BuiltinAgentServerSettings {
+    /// Absolute path to a binary to be used when launching this agent.
+    ///
+    /// This can be used to run a specific binary without automatic downloads or searching `$PATH`.
+    #[serde(rename = "command")]
+    pub path: Option<PathBuf>,
+    /// If a binary is specified in `command`, it will be passed these arguments.
+    pub args: Option<Vec<String>>,
+    /// If a binary is specified in `command`, it will be passed these environment variables.
+    pub env: Option<HashMap<String, String>>,
+    /// Whether to skip searching `$PATH` for an agent server binary when
+    /// launching this agent.
+    ///
+    /// This has no effect if a `command` is specified. Otherwise, when this is
+    /// `false`, Zed will search `$PATH` for an agent server binary and, if one
+    /// is found, use it for threads with this agent. If no agent binary is
+    /// found on `$PATH`, Zed will automatically install and use its own binary.
+    /// When this is `true`, Zed will not search `$PATH`, and will always use
+    /// its own binary.
+    ///
+    /// Default: true
+    pub ignore_system_version: Option<bool>,
+}
+
+impl BuiltinAgentServerSettings {
+    pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
+        self.path.map(|path| AgentServerCommand {
+            path,
+            args: self.args.unwrap_or_default(),
+            env: self.env,
+        })
+    }
+}
+
+impl From<AgentServerCommand> for BuiltinAgentServerSettings {
+    fn from(value: AgentServerCommand) -> Self {
+        BuiltinAgentServerSettings {
+            path: Some(value.path),
+            args: Some(value.args),
+            env: value.env,
+            ..Default::default()
+        }
+    }
+}
+
+#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)]
+pub struct CustomAgentServerSettings {
+    #[serde(flatten)]
+    pub command: AgentServerCommand,
+}
+
+impl settings::Settings for AllAgentServersSettings {
+    type FileContent = Self;
+
+    fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
+        let mut settings = AllAgentServersSettings::default();
+
+        for AllAgentServersSettings {
+            gemini,
+            claude,
+            custom,
+        } in sources.defaults_and_customizations()
+        {
+            if gemini.is_some() {
+                settings.gemini = gemini.clone();
+            }
+            if claude.is_some() {
+                settings.claude = claude.clone();
+            }
+
+            // Merge custom agents
+            for (name, config) in custom {
+                // Skip built-in agent names to avoid conflicts
+                if name != GEMINI_NAME && name != CLAUDE_CODE_NAME {
+                    settings.custom.insert(name.clone(), config.clone());
+                }
+            }
+        }
+
+        Ok(settings)
+    }
+
+    fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
+}

crates/project/src/project.rs πŸ”—

@@ -1,3 +1,4 @@
+pub mod agent_server_store;
 pub mod buffer_store;
 mod color_extractor;
 pub mod connection_manager;
@@ -34,7 +35,11 @@ mod yarn;
 
 use dap::inline_value::{InlineValueLocation, VariableLookupKind, VariableScope};
 
-use crate::{git_store::GitStore, lsp_store::log_store::LogKind};
+use crate::{
+    agent_server_store::{AgentServerStore, AllAgentServersSettings},
+    git_store::GitStore,
+    lsp_store::log_store::LogKind,
+};
 pub use git_store::{
     ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate,
     git_traversal::{ChildEntriesGitIter, GitEntry, GitEntryRef, GitTraversal},
@@ -179,6 +184,7 @@ pub struct Project {
     buffer_ordered_messages_tx: mpsc::UnboundedSender<BufferOrderedMessage>,
     languages: Arc<LanguageRegistry>,
     dap_store: Entity<DapStore>,
+    agent_server_store: Entity<AgentServerStore>,
 
     breakpoint_store: Entity<BreakpointStore>,
     collab_client: Arc<client::Client>,
@@ -1019,6 +1025,7 @@ impl Project {
         WorktreeSettings::register(cx);
         ProjectSettings::register(cx);
         DisableAiSettings::register(cx);
+        AllAgentServersSettings::register(cx);
     }
 
     pub fn init(client: &Arc<Client>, cx: &mut App) {
@@ -1174,6 +1181,10 @@ impl Project {
                 )
             });
 
+            let agent_server_store = cx.new(|cx| {
+                AgentServerStore::local(node.clone(), fs.clone(), environment.clone(), cx)
+            });
+
             cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach();
 
             Self {
@@ -1200,6 +1211,7 @@ impl Project {
                 remote_client: None,
                 breakpoint_store,
                 dap_store,
+                agent_server_store,
 
                 buffers_needing_diff: Default::default(),
                 git_diff_debouncer: DebouncedDelay::new(),
@@ -1338,6 +1350,9 @@ impl Project {
                 )
             });
 
+            let agent_server_store =
+                cx.new(|cx| AgentServerStore::remote(REMOTE_SERVER_PROJECT_ID, remote.clone(), cx));
+
             cx.subscribe(&remote, Self::on_remote_client_event).detach();
 
             let this = Self {
@@ -1353,6 +1368,7 @@ impl Project {
                 join_project_response_message_id: 0,
                 client_state: ProjectClientState::Local,
                 git_store,
+                agent_server_store,
                 client_subscriptions: Vec::new(),
                 _subscriptions: vec![
                     cx.on_release(Self::release),
@@ -1407,6 +1423,7 @@ impl Project {
             remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.dap_store);
             remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.settings_observer);
             remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.git_store);
+            remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.agent_server_store);
 
             remote_proto.add_entity_message_handler(Self::handle_create_buffer_for_peer);
             remote_proto.add_entity_message_handler(Self::handle_update_worktree);
@@ -1422,6 +1439,7 @@ impl Project {
             ToolchainStore::init(&remote_proto);
             DapStore::init(&remote_proto, cx);
             GitStore::init(&remote_proto);
+            AgentServerStore::init_remote(&remote_proto);
 
             this
         })
@@ -1564,6 +1582,8 @@ impl Project {
             )
         })?;
 
+        let agent_server_store = cx.new(|cx| AgentServerStore::collab(cx))?;
+
         let project = cx.new(|cx| {
             let replica_id = response.payload.replica_id as ReplicaId;
 
@@ -1624,6 +1644,7 @@ impl Project {
                 breakpoint_store,
                 dap_store: dap_store.clone(),
                 git_store: git_store.clone(),
+                agent_server_store,
                 buffers_needing_diff: Default::default(),
                 git_diff_debouncer: DebouncedDelay::new(),
                 terminals: Terminals {
@@ -5199,6 +5220,10 @@ impl Project {
         &self.git_store
     }
 
+    pub fn agent_server_store(&self) -> &Entity<AgentServerStore> {
+        &self.agent_server_store
+    }
+
     #[cfg(test)]
     fn git_scans_complete(&self, cx: &Context<Self>) -> Task<()> {
         cx.spawn(async move |this, cx| {

crates/proto/proto/ai.proto πŸ”—

@@ -2,6 +2,7 @@ syntax = "proto3";
 package zed.messages;
 
 import "buffer.proto";
+import "task.proto";
 
 message Context {
     repeated ContextOperation operations = 1;
@@ -164,3 +165,35 @@ enum LanguageModelRole {
     LanguageModelSystem = 2;
     reserved 3;
 }
+
+message GetAgentServerCommand {
+    uint64 project_id = 1;
+    string name = 2;
+    optional string root_dir = 3;
+}
+
+message AgentServerCommand {
+    string path = 1;
+    repeated string args = 2;
+    map<string, string> env = 3;
+    string root_dir = 4;
+
+    optional SpawnInTerminal login = 5;
+}
+
+message ExternalAgentsUpdated {
+    uint64 project_id = 1;
+    repeated string names = 2;
+}
+
+message ExternalAgentLoadingStatusUpdated {
+    uint64 project_id = 1;
+    string name = 2;
+    string status = 3;
+}
+
+message NewExternalAgentVersionAvailable {
+    uint64 project_id = 1;
+    string name = 2;
+    string version = 3;
+}

crates/proto/proto/debugger.proto πŸ”—

@@ -3,6 +3,7 @@ package zed.messages;
 
 import "core.proto";
 import "buffer.proto";
+import "task.proto";
 
 enum BreakpointState {
     Enabled = 0;
@@ -533,14 +534,6 @@ message DebugScenario {
     optional string configuration = 7;
 }
 
-message SpawnInTerminal {
-    string label = 1;
-    optional string command = 2;
-    repeated string args = 3;
-    map<string, string> env = 4;
-    optional string cwd = 5;
-}
-
 message LogToDebugConsole {
     uint64 project_id = 1;
     uint64 session_id = 2;

crates/proto/proto/task.proto πŸ”—

@@ -40,3 +40,11 @@ enum HideStrategy {
     HideNever = 1;
     HideOnSuccess = 2;
 }
+
+message SpawnInTerminal {
+    string label = 1;
+    optional string command = 2;
+    repeated string args = 3;
+    map<string, string> env = 4;
+    optional string cwd = 5;
+}

crates/proto/proto/zed.proto πŸ”—

@@ -405,7 +405,15 @@ message Envelope {
         GetProcessesResponse get_processes_response = 370;
 
         ResolveToolchain resolve_toolchain = 371;
-        ResolveToolchainResponse resolve_toolchain_response = 372; // current max
+        ResolveToolchainResponse resolve_toolchain_response = 372;
+
+        GetAgentServerCommand get_agent_server_command = 373;
+        AgentServerCommand agent_server_command = 374;
+
+        ExternalAgentsUpdated external_agents_updated = 375;
+
+        ExternalAgentLoadingStatusUpdated external_agent_loading_status_updated = 376;
+        NewExternalAgentVersionAvailable new_external_agent_version_available = 377; // current max
     }
 
     reserved 87 to 88;

crates/proto/src/proto.rs πŸ”—

@@ -319,6 +319,11 @@ messages!(
     (GitClone, Background),
     (GitCloneResponse, Background),
     (ToggleLspLogs, Background),
+    (GetAgentServerCommand, Background),
+    (AgentServerCommand, Background),
+    (ExternalAgentsUpdated, Background),
+    (ExternalAgentLoadingStatusUpdated, Background),
+    (NewExternalAgentVersionAvailable, Background),
 );
 
 request_messages!(
@@ -491,6 +496,7 @@ request_messages!(
     (GitClone, GitCloneResponse),
     (ToggleLspLogs, Ack),
     (GetProcesses, GetProcessesResponse),
+    (GetAgentServerCommand, AgentServerCommand)
 );
 
 lsp_messages!(
@@ -644,7 +650,11 @@ entity_messages!(
     GetDocumentDiagnostics,
     PullWorkspaceDiagnostics,
     GetDefaultBranch,
-    GitClone
+    GitClone,
+    GetAgentServerCommand,
+    ExternalAgentsUpdated,
+    ExternalAgentLoadingStatusUpdated,
+    NewExternalAgentVersionAvailable,
 );
 
 entity_messages!(

crates/remote_server/src/headless_project.rs πŸ”—

@@ -12,6 +12,7 @@ use node_runtime::NodeRuntime;
 use project::{
     LspStore, LspStoreEvent, ManifestTree, PrettierStore, ProjectEnvironment, ProjectPath,
     ToolchainStore, WorktreeId,
+    agent_server_store::AgentServerStore,
     buffer_store::{BufferStore, BufferStoreEvent},
     debugger::{breakpoint_store::BreakpointStore, dap_store::DapStore},
     git_store::GitStore,
@@ -44,6 +45,7 @@ pub struct HeadlessProject {
     pub lsp_store: Entity<LspStore>,
     pub task_store: Entity<TaskStore>,
     pub dap_store: Entity<DapStore>,
+    pub agent_server_store: Entity<AgentServerStore>,
     pub settings_observer: Entity<SettingsObserver>,
     pub next_entry_id: Arc<AtomicUsize>,
     pub languages: Arc<LanguageRegistry>,
@@ -182,7 +184,7 @@ impl HeadlessProject {
                     .as_local_store()
                     .expect("Toolchain store to be local")
                     .clone(),
-                environment,
+                environment.clone(),
                 manifest_tree,
                 languages.clone(),
                 http_client.clone(),
@@ -193,6 +195,13 @@ impl HeadlessProject {
             lsp_store
         });
 
+        let agent_server_store = cx.new(|cx| {
+            let mut agent_server_store =
+                AgentServerStore::local(node_runtime.clone(), fs.clone(), environment, cx);
+            agent_server_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone());
+            agent_server_store
+        });
+
         cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach();
         language_extension::init(
             language_extension::LspAccess::ViaLspStore(lsp_store.clone()),
@@ -226,6 +235,7 @@ impl HeadlessProject {
         session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &dap_store);
         session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &settings_observer);
         session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &git_store);
+        session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &agent_server_store);
 
         session.add_request_handler(cx.weak_entity(), Self::handle_list_remote_directory);
         session.add_request_handler(cx.weak_entity(), Self::handle_get_path_metadata);
@@ -264,6 +274,7 @@ impl HeadlessProject {
         // todo(debugger): Re init breakpoint store when we set it up for collab
         // BreakpointStore::init(&client);
         GitStore::init(&session);
+        AgentServerStore::init_headless(&session);
 
         HeadlessProject {
             next_entry_id: Default::default(),
@@ -275,6 +286,7 @@ impl HeadlessProject {
             lsp_store,
             task_store,
             dap_store,
+            agent_server_store,
             languages,
             extensions,
             git_store,

crates/zed/Cargo.toml πŸ”—

@@ -24,7 +24,6 @@ acp_tools.workspace = true
 agent.workspace = true
 agent_ui.workspace = true
 agent_settings.workspace = true
-agent_servers.workspace = true
 anyhow.workspace = true
 askpass.workspace = true
 assets.workspace = true

crates/zed/src/main.rs πŸ”—

@@ -567,7 +567,6 @@ pub fn main() {
         language_model::init(app_state.client.clone(), cx);
         language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx);
         agent_settings::init(cx);
-        agent_servers::init(cx);
         acp_tools::init(cx);
         web_search::init(cx);
         web_search_providers::init(app_state.client.clone(), cx);