agent_server_store.rs

   1use std::{
   2    any::Any,
   3    borrow::Borrow,
   4    path::{Path, PathBuf},
   5    str::FromStr as _,
   6    sync::Arc,
   7    time::Duration,
   8};
   9
  10use anyhow::{Context as _, Result, bail};
  11use client::Client;
  12use collections::HashMap;
  13use fs::{Fs, RemoveOptions, RenameOptions};
  14use futures::StreamExt as _;
  15use gpui::{
  16    App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task,
  17};
  18use node_runtime::NodeRuntime;
  19use remote::RemoteClient;
  20use rpc::{AnyProtoClient, TypedEnvelope, proto};
  21use schemars::JsonSchema;
  22use serde::{Deserialize, Serialize};
  23use settings::{SettingsContent, SettingsStore};
  24use util::{ResultExt as _, debug_panic};
  25
  26use crate::ProjectEnvironment;
  27
  28#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
  29pub struct AgentServerCommand {
  30    #[serde(rename = "command")]
  31    pub path: PathBuf,
  32    #[serde(default)]
  33    pub args: Vec<String>,
  34    pub env: Option<HashMap<String, String>>,
  35}
  36
  37impl std::fmt::Debug for AgentServerCommand {
  38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  39        let filtered_env = self.env.as_ref().map(|env| {
  40            env.iter()
  41                .map(|(k, v)| {
  42                    (
  43                        k,
  44                        if util::redact::should_redact(k) {
  45                            "[REDACTED]"
  46                        } else {
  47                            v
  48                        },
  49                    )
  50                })
  51                .collect::<Vec<_>>()
  52        });
  53
  54        f.debug_struct("AgentServerCommand")
  55            .field("path", &self.path)
  56            .field("args", &self.args)
  57            .field("env", &filtered_env)
  58            .finish()
  59    }
  60}
  61
  62#[derive(Clone, Debug, PartialEq, Eq, Hash)]
  63pub struct ExternalAgentServerName(pub SharedString);
  64
  65impl std::fmt::Display for ExternalAgentServerName {
  66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  67        write!(f, "{}", self.0)
  68    }
  69}
  70
  71impl From<&'static str> for ExternalAgentServerName {
  72    fn from(value: &'static str) -> Self {
  73        ExternalAgentServerName(value.into())
  74    }
  75}
  76
  77impl From<ExternalAgentServerName> for SharedString {
  78    fn from(value: ExternalAgentServerName) -> Self {
  79        value.0
  80    }
  81}
  82
  83impl Borrow<str> for ExternalAgentServerName {
  84    fn borrow(&self) -> &str {
  85        &self.0
  86    }
  87}
  88
  89pub trait ExternalAgentServer {
  90    fn get_command(
  91        &mut self,
  92        root_dir: Option<&str>,
  93        extra_env: HashMap<String, String>,
  94        status_tx: Option<watch::Sender<SharedString>>,
  95        new_version_available_tx: Option<watch::Sender<Option<String>>>,
  96        cx: &mut AsyncApp,
  97    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>>;
  98
  99    fn as_any_mut(&mut self) -> &mut dyn Any;
 100}
 101
 102impl dyn ExternalAgentServer {
 103    fn downcast_mut<T: ExternalAgentServer + 'static>(&mut self) -> Option<&mut T> {
 104        self.as_any_mut().downcast_mut()
 105    }
 106}
 107
 108enum AgentServerStoreState {
 109    Local {
 110        node_runtime: NodeRuntime,
 111        fs: Arc<dyn Fs>,
 112        project_environment: Entity<ProjectEnvironment>,
 113        downstream_client: Option<(u64, AnyProtoClient)>,
 114        settings: Option<AllAgentServersSettings>,
 115        _subscriptions: [Subscription; 1],
 116    },
 117    Remote {
 118        project_id: u64,
 119        upstream_client: Entity<RemoteClient>,
 120    },
 121    Collab,
 122}
 123
 124pub struct AgentServerStore {
 125    state: AgentServerStoreState,
 126    external_agents: HashMap<ExternalAgentServerName, Box<dyn ExternalAgentServer>>,
 127}
 128
 129pub struct AgentServersUpdated;
 130
 131impl EventEmitter<AgentServersUpdated> for AgentServerStore {}
 132
 133impl AgentServerStore {
 134    pub fn init_remote(session: &AnyProtoClient) {
 135        session.add_entity_message_handler(Self::handle_external_agents_updated);
 136        session.add_entity_message_handler(Self::handle_loading_status_updated);
 137        session.add_entity_message_handler(Self::handle_new_version_available);
 138    }
 139
 140    pub fn init_headless(session: &AnyProtoClient) {
 141        session.add_entity_request_handler(Self::handle_get_agent_server_command);
 142    }
 143
 144    fn agent_servers_settings_changed(&mut self, cx: &mut Context<Self>) {
 145        let AgentServerStoreState::Local {
 146            node_runtime,
 147            fs,
 148            project_environment,
 149            downstream_client,
 150            settings: old_settings,
 151            ..
 152        } = &mut self.state
 153        else {
 154            debug_panic!(
 155                "should not be subscribed to agent server settings changes in non-local project"
 156            );
 157            return;
 158        };
 159
 160        let new_settings = cx
 161            .global::<SettingsStore>()
 162            .get::<AllAgentServersSettings>(None)
 163            .clone();
 164        if Some(&new_settings) == old_settings.as_ref() {
 165            return;
 166        }
 167
 168        self.external_agents.clear();
 169        self.external_agents.insert(
 170            GEMINI_NAME.into(),
 171            Box::new(LocalGemini {
 172                fs: fs.clone(),
 173                node_runtime: node_runtime.clone(),
 174                project_environment: project_environment.clone(),
 175                custom_command: new_settings
 176                    .gemini
 177                    .clone()
 178                    .and_then(|settings| settings.custom_command()),
 179                ignore_system_version: new_settings
 180                    .gemini
 181                    .as_ref()
 182                    .and_then(|settings| settings.ignore_system_version)
 183                    .unwrap_or(true),
 184            }),
 185        );
 186        self.external_agents
 187            .extend(new_settings.custom.iter().map(|(name, settings)| {
 188                (
 189                    ExternalAgentServerName(name.clone()),
 190                    Box::new(LocalCustomAgent {
 191                        command: settings.command.clone(),
 192                        project_environment: project_environment.clone(),
 193                    }) as Box<dyn ExternalAgentServer>,
 194                )
 195            }));
 196
 197        use feature_flags::FeatureFlagAppExt as _;
 198        if cx.has_flag::<feature_flags::CodexAcpFeatureFlag>() || new_settings.codex.is_some() {
 199            self.external_agents.insert(
 200                CODEX_NAME.into(),
 201                Box::new(LocalCodex {
 202                    fs: fs.clone(),
 203                    project_environment: project_environment.clone(),
 204                    custom_command: new_settings
 205                        .codex
 206                        .clone()
 207                        .and_then(|settings| settings.custom_command()),
 208                }),
 209            );
 210        }
 211
 212        self.external_agents.insert(
 213            CLAUDE_CODE_NAME.into(),
 214            Box::new(LocalClaudeCode {
 215                fs: fs.clone(),
 216                node_runtime: node_runtime.clone(),
 217                project_environment: project_environment.clone(),
 218                custom_command: new_settings
 219                    .claude
 220                    .clone()
 221                    .and_then(|settings| settings.custom_command()),
 222            }),
 223        );
 224
 225        *old_settings = Some(new_settings.clone());
 226
 227        if let Some((project_id, downstream_client)) = downstream_client {
 228            downstream_client
 229                .send(proto::ExternalAgentsUpdated {
 230                    project_id: *project_id,
 231                    names: self
 232                        .external_agents
 233                        .keys()
 234                        .filter(|name| name.0 != CODEX_NAME)
 235                        .map(|name| name.to_string())
 236                        .collect(),
 237                })
 238                .log_err();
 239        }
 240        cx.emit(AgentServersUpdated);
 241    }
 242
 243    pub fn local(
 244        node_runtime: NodeRuntime,
 245        fs: Arc<dyn Fs>,
 246        project_environment: Entity<ProjectEnvironment>,
 247        cx: &mut Context<Self>,
 248    ) -> Self {
 249        let subscription = cx.observe_global::<SettingsStore>(|this, cx| {
 250            this.agent_servers_settings_changed(cx);
 251        });
 252        let mut this = Self {
 253            state: AgentServerStoreState::Local {
 254                node_runtime,
 255                fs,
 256                project_environment,
 257                downstream_client: None,
 258                settings: None,
 259                _subscriptions: [subscription],
 260            },
 261            external_agents: Default::default(),
 262        };
 263        this.agent_servers_settings_changed(cx);
 264        this
 265    }
 266
 267    pub(crate) fn remote(
 268        project_id: u64,
 269        upstream_client: Entity<RemoteClient>,
 270        _cx: &mut Context<Self>,
 271    ) -> Self {
 272        // Set up the builtin agents here so they're immediately available in
 273        // remote projects--we know that the HeadlessProject on the other end
 274        // will have them.
 275        let external_agents = [
 276            (
 277                GEMINI_NAME.into(),
 278                Box::new(RemoteExternalAgentServer {
 279                    project_id,
 280                    upstream_client: upstream_client.clone(),
 281                    name: GEMINI_NAME.into(),
 282                    status_tx: None,
 283                    new_version_available_tx: None,
 284                }) as Box<dyn ExternalAgentServer>,
 285            ),
 286            (
 287                CLAUDE_CODE_NAME.into(),
 288                Box::new(RemoteExternalAgentServer {
 289                    project_id,
 290                    upstream_client: upstream_client.clone(),
 291                    name: CLAUDE_CODE_NAME.into(),
 292                    status_tx: None,
 293                    new_version_available_tx: None,
 294                }) as Box<dyn ExternalAgentServer>,
 295            ),
 296        ]
 297        .into_iter()
 298        .collect();
 299
 300        Self {
 301            state: AgentServerStoreState::Remote {
 302                project_id,
 303                upstream_client,
 304            },
 305            external_agents,
 306        }
 307    }
 308
 309    pub(crate) fn collab(_cx: &mut Context<Self>) -> Self {
 310        Self {
 311            state: AgentServerStoreState::Collab,
 312            external_agents: Default::default(),
 313        }
 314    }
 315
 316    pub fn shared(&mut self, project_id: u64, client: AnyProtoClient, cx: &mut Context<Self>) {
 317        match &mut self.state {
 318            AgentServerStoreState::Local {
 319                downstream_client, ..
 320            } => {
 321                *downstream_client = Some((project_id, client.clone()));
 322                // Send the current list of external agents downstream, but only after a delay,
 323                // to avoid having the message arrive before the downstream project's agent server store
 324                // sets up its handlers.
 325                cx.spawn(async move |this, cx| {
 326                    cx.background_executor().timer(Duration::from_secs(1)).await;
 327                    let names = this.update(cx, |this, _| {
 328                        this.external_agents
 329                            .keys()
 330                            .map(|name| name.to_string())
 331                            .collect()
 332                    })?;
 333                    client
 334                        .send(proto::ExternalAgentsUpdated { project_id, names })
 335                        .log_err();
 336                    anyhow::Ok(())
 337                })
 338                .detach();
 339            }
 340            AgentServerStoreState::Remote { .. } => {
 341                debug_panic!(
 342                    "external agents over collab not implemented, remote project should not be shared"
 343                );
 344            }
 345            AgentServerStoreState::Collab => {
 346                debug_panic!("external agents over collab not implemented, should not be shared");
 347            }
 348        }
 349    }
 350
 351    pub fn get_external_agent(
 352        &mut self,
 353        name: &ExternalAgentServerName,
 354    ) -> Option<&mut (dyn ExternalAgentServer + 'static)> {
 355        self.external_agents
 356            .get_mut(name)
 357            .map(|agent| agent.as_mut())
 358    }
 359
 360    pub fn external_agents(&self) -> impl Iterator<Item = &ExternalAgentServerName> {
 361        self.external_agents.keys()
 362    }
 363
 364    async fn handle_get_agent_server_command(
 365        this: Entity<Self>,
 366        envelope: TypedEnvelope<proto::GetAgentServerCommand>,
 367        mut cx: AsyncApp,
 368    ) -> Result<proto::AgentServerCommand> {
 369        let (command, root_dir, login) = this
 370            .update(&mut cx, |this, cx| {
 371                let AgentServerStoreState::Local {
 372                    downstream_client, ..
 373                } = &this.state
 374                else {
 375                    debug_panic!("should not receive GetAgentServerCommand in a non-local project");
 376                    bail!("unexpected GetAgentServerCommand request in a non-local project");
 377                };
 378                let agent = this
 379                    .external_agents
 380                    .get_mut(&*envelope.payload.name)
 381                    .with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
 382                let (status_tx, new_version_available_tx) = downstream_client
 383                    .clone()
 384                    .map(|(project_id, downstream_client)| {
 385                        let (status_tx, mut status_rx) = watch::channel(SharedString::from(""));
 386                        let (new_version_available_tx, mut new_version_available_rx) =
 387                            watch::channel(None);
 388                        cx.spawn({
 389                            let downstream_client = downstream_client.clone();
 390                            let name = envelope.payload.name.clone();
 391                            async move |_, _| {
 392                                while let Some(status) = status_rx.recv().await.ok() {
 393                                    downstream_client.send(
 394                                        proto::ExternalAgentLoadingStatusUpdated {
 395                                            project_id,
 396                                            name: name.clone(),
 397                                            status: status.to_string(),
 398                                        },
 399                                    )?;
 400                                }
 401                                anyhow::Ok(())
 402                            }
 403                        })
 404                        .detach_and_log_err(cx);
 405                        cx.spawn({
 406                            let name = envelope.payload.name.clone();
 407                            async move |_, _| {
 408                                if let Some(version) =
 409                                    new_version_available_rx.recv().await.ok().flatten()
 410                                {
 411                                    downstream_client.send(
 412                                        proto::NewExternalAgentVersionAvailable {
 413                                            project_id,
 414                                            name: name.clone(),
 415                                            version,
 416                                        },
 417                                    )?;
 418                                }
 419                                anyhow::Ok(())
 420                            }
 421                        })
 422                        .detach_and_log_err(cx);
 423                        (status_tx, new_version_available_tx)
 424                    })
 425                    .unzip();
 426                anyhow::Ok(agent.get_command(
 427                    envelope.payload.root_dir.as_deref(),
 428                    HashMap::default(),
 429                    status_tx,
 430                    new_version_available_tx,
 431                    &mut cx.to_async(),
 432                ))
 433            })??
 434            .await?;
 435        Ok(proto::AgentServerCommand {
 436            path: command.path.to_string_lossy().into_owned(),
 437            args: command.args,
 438            env: command
 439                .env
 440                .map(|env| env.into_iter().collect())
 441                .unwrap_or_default(),
 442            root_dir: root_dir,
 443            login: login.map(|login| login.to_proto()),
 444        })
 445    }
 446
 447    async fn handle_external_agents_updated(
 448        this: Entity<Self>,
 449        envelope: TypedEnvelope<proto::ExternalAgentsUpdated>,
 450        mut cx: AsyncApp,
 451    ) -> Result<()> {
 452        this.update(&mut cx, |this, cx| {
 453            let AgentServerStoreState::Remote {
 454                project_id,
 455                upstream_client,
 456            } = &this.state
 457            else {
 458                debug_panic!(
 459                    "handle_external_agents_updated should not be called for a non-remote project"
 460                );
 461                bail!("unexpected ExternalAgentsUpdated message")
 462            };
 463
 464            let mut status_txs = this
 465                .external_agents
 466                .iter_mut()
 467                .filter_map(|(name, agent)| {
 468                    Some((
 469                        name.clone(),
 470                        agent
 471                            .downcast_mut::<RemoteExternalAgentServer>()?
 472                            .status_tx
 473                            .take(),
 474                    ))
 475                })
 476                .collect::<HashMap<_, _>>();
 477            let mut new_version_available_txs = this
 478                .external_agents
 479                .iter_mut()
 480                .filter_map(|(name, agent)| {
 481                    Some((
 482                        name.clone(),
 483                        agent
 484                            .downcast_mut::<RemoteExternalAgentServer>()?
 485                            .new_version_available_tx
 486                            .take(),
 487                    ))
 488                })
 489                .collect::<HashMap<_, _>>();
 490
 491            this.external_agents = envelope
 492                .payload
 493                .names
 494                .into_iter()
 495                .map(|name| {
 496                    let agent = RemoteExternalAgentServer {
 497                        project_id: *project_id,
 498                        upstream_client: upstream_client.clone(),
 499                        name: ExternalAgentServerName(name.clone().into()),
 500                        status_tx: status_txs.remove(&*name).flatten(),
 501                        new_version_available_tx: new_version_available_txs
 502                            .remove(&*name)
 503                            .flatten(),
 504                    };
 505                    (
 506                        ExternalAgentServerName(name.into()),
 507                        Box::new(agent) as Box<dyn ExternalAgentServer>,
 508                    )
 509                })
 510                .collect();
 511            cx.emit(AgentServersUpdated);
 512            Ok(())
 513        })?
 514    }
 515
 516    async fn handle_loading_status_updated(
 517        this: Entity<Self>,
 518        envelope: TypedEnvelope<proto::ExternalAgentLoadingStatusUpdated>,
 519        mut cx: AsyncApp,
 520    ) -> Result<()> {
 521        this.update(&mut cx, |this, _| {
 522            if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
 523                && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
 524                && let Some(status_tx) = &mut agent.status_tx
 525            {
 526                status_tx.send(envelope.payload.status.into()).ok();
 527            }
 528        })
 529    }
 530
 531    async fn handle_new_version_available(
 532        this: Entity<Self>,
 533        envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
 534        mut cx: AsyncApp,
 535    ) -> Result<()> {
 536        this.update(&mut cx, |this, _| {
 537            if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
 538                && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
 539                && let Some(new_version_available_tx) = &mut agent.new_version_available_tx
 540            {
 541                new_version_available_tx
 542                    .send(Some(envelope.payload.version))
 543                    .ok();
 544            }
 545        })
 546    }
 547}
 548
 549fn get_or_npm_install_builtin_agent(
 550    binary_name: SharedString,
 551    package_name: SharedString,
 552    entrypoint_path: PathBuf,
 553    minimum_version: Option<semver::Version>,
 554    status_tx: Option<watch::Sender<SharedString>>,
 555    new_version_available: Option<watch::Sender<Option<String>>>,
 556    fs: Arc<dyn Fs>,
 557    node_runtime: NodeRuntime,
 558    cx: &mut AsyncApp,
 559) -> Task<std::result::Result<AgentServerCommand, anyhow::Error>> {
 560    cx.spawn(async move |cx| {
 561        let node_path = node_runtime.binary_path().await?;
 562        let dir = paths::data_dir()
 563            .join("external_agents")
 564            .join(binary_name.as_str());
 565        fs.create_dir(&dir).await?;
 566
 567        let mut stream = fs.read_dir(&dir).await?;
 568        let mut versions = Vec::new();
 569        let mut to_delete = Vec::new();
 570        while let Some(entry) = stream.next().await {
 571            let Ok(entry) = entry else { continue };
 572            let Some(file_name) = entry.file_name() else {
 573                continue;
 574            };
 575
 576            if let Some(name) = file_name.to_str()
 577                && let Some(version) = semver::Version::from_str(name).ok()
 578                && fs
 579                    .is_file(&dir.join(file_name).join(&entrypoint_path))
 580                    .await
 581            {
 582                versions.push((version, file_name.to_owned()));
 583            } else {
 584                to_delete.push(file_name.to_owned())
 585            }
 586        }
 587
 588        versions.sort();
 589        let newest_version = if let Some((version, file_name)) = versions.last().cloned()
 590            && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
 591        {
 592            versions.pop();
 593            Some(file_name)
 594        } else {
 595            None
 596        };
 597        log::debug!("existing version of {package_name}: {newest_version:?}");
 598        to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
 599
 600        cx.background_spawn({
 601            let fs = fs.clone();
 602            let dir = dir.clone();
 603            async move {
 604                for file_name in to_delete {
 605                    fs.remove_dir(
 606                        &dir.join(file_name),
 607                        RemoveOptions {
 608                            recursive: true,
 609                            ignore_if_not_exists: false,
 610                        },
 611                    )
 612                    .await
 613                    .ok();
 614                }
 615            }
 616        })
 617        .detach();
 618
 619        let version = if let Some(file_name) = newest_version {
 620            cx.background_spawn({
 621                let file_name = file_name.clone();
 622                let dir = dir.clone();
 623                let fs = fs.clone();
 624                async move {
 625                    let latest_version =
 626                        node_runtime.npm_package_latest_version(&package_name).await;
 627                    if let Ok(latest_version) = latest_version
 628                        && &latest_version != &file_name.to_string_lossy()
 629                    {
 630                        let download_result = download_latest_version(
 631                            fs,
 632                            dir.clone(),
 633                            node_runtime,
 634                            package_name.clone(),
 635                        )
 636                        .await
 637                        .log_err();
 638                        if let Some(mut new_version_available) = new_version_available
 639                            && download_result.is_some()
 640                        {
 641                            new_version_available.send(Some(latest_version)).ok();
 642                        }
 643                    }
 644                }
 645            })
 646            .detach();
 647            file_name
 648        } else {
 649            if let Some(mut status_tx) = status_tx {
 650                status_tx.send("Installing…".into()).ok();
 651            }
 652            let dir = dir.clone();
 653            cx.background_spawn(download_latest_version(
 654                fs.clone(),
 655                dir.clone(),
 656                node_runtime,
 657                package_name.clone(),
 658            ))
 659            .await?
 660            .into()
 661        };
 662
 663        let agent_server_path = dir.join(version).join(entrypoint_path);
 664        let agent_server_path_exists = fs.is_file(&agent_server_path).await;
 665        anyhow::ensure!(
 666            agent_server_path_exists,
 667            "Missing entrypoint path {} after installation",
 668            agent_server_path.to_string_lossy()
 669        );
 670
 671        anyhow::Ok(AgentServerCommand {
 672            path: node_path,
 673            args: vec![agent_server_path.to_string_lossy().into_owned()],
 674            env: None,
 675        })
 676    })
 677}
 678
 679fn find_bin_in_path(
 680    bin_name: SharedString,
 681    root_dir: PathBuf,
 682    env: HashMap<String, String>,
 683    cx: &mut AsyncApp,
 684) -> Task<Option<PathBuf>> {
 685    cx.background_executor().spawn(async move {
 686        let which_result = if cfg!(windows) {
 687            which::which(bin_name.as_str())
 688        } else {
 689            let shell_path = env.get("PATH").cloned();
 690            which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
 691        };
 692
 693        if let Err(which::Error::CannotFindBinaryPath) = which_result {
 694            return None;
 695        }
 696
 697        which_result.log_err()
 698    })
 699}
 700
 701async fn download_latest_version(
 702    fs: Arc<dyn Fs>,
 703    dir: PathBuf,
 704    node_runtime: NodeRuntime,
 705    package_name: SharedString,
 706) -> Result<String> {
 707    log::debug!("downloading latest version of {package_name}");
 708
 709    let tmp_dir = tempfile::tempdir_in(&dir)?;
 710
 711    node_runtime
 712        .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
 713        .await?;
 714
 715    let version = node_runtime
 716        .npm_package_installed_version(tmp_dir.path(), &package_name)
 717        .await?
 718        .context("expected package to be installed")?;
 719
 720    fs.rename(
 721        &tmp_dir.keep(),
 722        &dir.join(&version),
 723        RenameOptions {
 724            ignore_if_exists: true,
 725            overwrite: true,
 726        },
 727    )
 728    .await?;
 729
 730    anyhow::Ok(version)
 731}
 732
 733struct RemoteExternalAgentServer {
 734    project_id: u64,
 735    upstream_client: Entity<RemoteClient>,
 736    name: ExternalAgentServerName,
 737    status_tx: Option<watch::Sender<SharedString>>,
 738    new_version_available_tx: Option<watch::Sender<Option<String>>>,
 739}
 740
 741impl ExternalAgentServer for RemoteExternalAgentServer {
 742    fn get_command(
 743        &mut self,
 744        root_dir: Option<&str>,
 745        extra_env: HashMap<String, String>,
 746        status_tx: Option<watch::Sender<SharedString>>,
 747        new_version_available_tx: Option<watch::Sender<Option<String>>>,
 748        cx: &mut AsyncApp,
 749    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
 750        let project_id = self.project_id;
 751        let name = self.name.to_string();
 752        let upstream_client = self.upstream_client.downgrade();
 753        let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
 754        self.status_tx = status_tx;
 755        self.new_version_available_tx = new_version_available_tx;
 756        cx.spawn(async move |cx| {
 757            let mut response = upstream_client
 758                .update(cx, |upstream_client, _| {
 759                    upstream_client
 760                        .proto_client()
 761                        .request(proto::GetAgentServerCommand {
 762                            project_id,
 763                            name,
 764                            root_dir: root_dir.clone(),
 765                        })
 766                })?
 767                .await?;
 768            let root_dir = response.root_dir;
 769            response.env.extend(extra_env);
 770            let command = upstream_client.update(cx, |client, _| {
 771                client.build_command(
 772                    Some(response.path),
 773                    &response.args,
 774                    &response.env.into_iter().collect(),
 775                    Some(root_dir.clone()),
 776                    None,
 777                )
 778            })??;
 779            Ok((
 780                AgentServerCommand {
 781                    path: command.program.into(),
 782                    args: command.args,
 783                    env: Some(command.env),
 784                },
 785                root_dir,
 786                response
 787                    .login
 788                    .map(|login| task::SpawnInTerminal::from_proto(login)),
 789            ))
 790        })
 791    }
 792
 793    fn as_any_mut(&mut self) -> &mut dyn Any {
 794        self
 795    }
 796}
 797
 798struct LocalGemini {
 799    fs: Arc<dyn Fs>,
 800    node_runtime: NodeRuntime,
 801    project_environment: Entity<ProjectEnvironment>,
 802    custom_command: Option<AgentServerCommand>,
 803    ignore_system_version: bool,
 804}
 805
 806impl ExternalAgentServer for LocalGemini {
 807    fn get_command(
 808        &mut self,
 809        root_dir: Option<&str>,
 810        extra_env: HashMap<String, String>,
 811        status_tx: Option<watch::Sender<SharedString>>,
 812        new_version_available_tx: Option<watch::Sender<Option<String>>>,
 813        cx: &mut AsyncApp,
 814    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
 815        let fs = self.fs.clone();
 816        let node_runtime = self.node_runtime.clone();
 817        let project_environment = self.project_environment.downgrade();
 818        let custom_command = self.custom_command.clone();
 819        let ignore_system_version = self.ignore_system_version;
 820        let root_dir: Arc<Path> = root_dir
 821            .map(|root_dir| Path::new(root_dir))
 822            .unwrap_or(paths::home_dir())
 823            .into();
 824
 825        cx.spawn(async move |cx| {
 826            let mut env = project_environment
 827                .update(cx, |project_environment, cx| {
 828                    project_environment.get_directory_environment(root_dir.clone(), cx)
 829                })?
 830                .await
 831                .unwrap_or_default();
 832
 833            let mut command = if let Some(mut custom_command) = custom_command {
 834                env.extend(custom_command.env.unwrap_or_default());
 835                custom_command.env = Some(env);
 836                custom_command
 837            } else if !ignore_system_version
 838                && let Some(bin) =
 839                    find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
 840            {
 841                AgentServerCommand {
 842                    path: bin,
 843                    args: Vec::new(),
 844                    env: Some(env),
 845                }
 846            } else {
 847                let mut command = get_or_npm_install_builtin_agent(
 848                    GEMINI_NAME.into(),
 849                    "@google/gemini-cli".into(),
 850                    "node_modules/@google/gemini-cli/dist/index.js".into(),
 851                    Some("0.2.1".parse().unwrap()),
 852                    status_tx,
 853                    new_version_available_tx,
 854                    fs,
 855                    node_runtime,
 856                    cx,
 857                )
 858                .await?;
 859                command.env = Some(env);
 860                command
 861            };
 862
 863            // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
 864            let login = task::SpawnInTerminal {
 865                command: Some(command.path.to_string_lossy().into_owned()),
 866                args: command.args.clone(),
 867                env: command.env.clone().unwrap_or_default(),
 868                label: "gemini /auth".into(),
 869                ..Default::default()
 870            };
 871
 872            command.env.get_or_insert_default().extend(extra_env);
 873            command.args.push("--experimental-acp".into());
 874            Ok((
 875                command,
 876                root_dir.to_string_lossy().into_owned(),
 877                Some(login),
 878            ))
 879        })
 880    }
 881
 882    fn as_any_mut(&mut self) -> &mut dyn Any {
 883        self
 884    }
 885}
 886
 887struct LocalClaudeCode {
 888    fs: Arc<dyn Fs>,
 889    node_runtime: NodeRuntime,
 890    project_environment: Entity<ProjectEnvironment>,
 891    custom_command: Option<AgentServerCommand>,
 892}
 893
 894impl ExternalAgentServer for LocalClaudeCode {
 895    fn get_command(
 896        &mut self,
 897        root_dir: Option<&str>,
 898        extra_env: HashMap<String, String>,
 899        status_tx: Option<watch::Sender<SharedString>>,
 900        new_version_available_tx: Option<watch::Sender<Option<String>>>,
 901        cx: &mut AsyncApp,
 902    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
 903        let fs = self.fs.clone();
 904        let node_runtime = self.node_runtime.clone();
 905        let project_environment = self.project_environment.downgrade();
 906        let custom_command = self.custom_command.clone();
 907        let root_dir: Arc<Path> = root_dir
 908            .map(|root_dir| Path::new(root_dir))
 909            .unwrap_or(paths::home_dir())
 910            .into();
 911
 912        cx.spawn(async move |cx| {
 913            let mut env = project_environment
 914                .update(cx, |project_environment, cx| {
 915                    project_environment.get_directory_environment(root_dir.clone(), cx)
 916                })?
 917                .await
 918                .unwrap_or_default();
 919            env.insert("ANTHROPIC_API_KEY".into(), "".into());
 920
 921            let (mut command, login) = if let Some(mut custom_command) = custom_command {
 922                env.extend(custom_command.env.unwrap_or_default());
 923                custom_command.env = Some(env);
 924                (custom_command, None)
 925            } else {
 926                let mut command = get_or_npm_install_builtin_agent(
 927                    "claude-code-acp".into(),
 928                    "@zed-industries/claude-code-acp".into(),
 929                    "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
 930                    Some("0.5.2".parse().unwrap()),
 931                    status_tx,
 932                    new_version_available_tx,
 933                    fs,
 934                    node_runtime,
 935                    cx,
 936                )
 937                .await?;
 938                command.env = Some(env);
 939                let login = command
 940                    .args
 941                    .first()
 942                    .and_then(|path| {
 943                        path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
 944                    })
 945                    .map(|path_prefix| task::SpawnInTerminal {
 946                        command: Some(command.path.to_string_lossy().into_owned()),
 947                        args: vec![
 948                            Path::new(path_prefix)
 949                                .join("@anthropic-ai/claude-agent-sdk/cli.js")
 950                                .to_string_lossy()
 951                                .to_string(),
 952                            "/login".into(),
 953                        ],
 954                        env: command.env.clone().unwrap_or_default(),
 955                        label: "claude /login".into(),
 956                        ..Default::default()
 957                    });
 958                (command, login)
 959            };
 960
 961            command.env.get_or_insert_default().extend(extra_env);
 962            Ok((command, root_dir.to_string_lossy().into_owned(), login))
 963        })
 964    }
 965
 966    fn as_any_mut(&mut self) -> &mut dyn Any {
 967        self
 968    }
 969}
 970
 971struct LocalCodex {
 972    fs: Arc<dyn Fs>,
 973    project_environment: Entity<ProjectEnvironment>,
 974    custom_command: Option<AgentServerCommand>,
 975}
 976
 977impl ExternalAgentServer for LocalCodex {
 978    fn get_command(
 979        &mut self,
 980        root_dir: Option<&str>,
 981        extra_env: HashMap<String, String>,
 982        _status_tx: Option<watch::Sender<SharedString>>,
 983        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
 984        cx: &mut AsyncApp,
 985    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
 986        let fs = self.fs.clone();
 987        let project_environment = self.project_environment.downgrade();
 988        let custom_command = self.custom_command.clone();
 989        let root_dir: Arc<Path> = root_dir
 990            .map(|root_dir| Path::new(root_dir))
 991            .unwrap_or(paths::home_dir())
 992            .into();
 993
 994        cx.spawn(async move |cx| {
 995            let mut env = project_environment
 996                .update(cx, |project_environment, cx| {
 997                    project_environment.get_directory_environment(root_dir.clone(), cx)
 998                })?
 999                .await
1000                .unwrap_or_default();
1001
1002            let mut command = if let Some(mut custom_command) = custom_command {
1003                env.extend(custom_command.env.unwrap_or_default());
1004                custom_command.env = Some(env);
1005                custom_command
1006            } else {
1007                let dir = paths::data_dir().join("external_agents").join(CODEX_NAME);
1008                fs.create_dir(&dir).await?;
1009
1010                // Find or install the latest Codex release (no update checks for now).
1011                let http = cx.update(|cx| Client::global(cx).http_client())?;
1012                let release = ::http_client::github::latest_github_release(
1013                    "zed-industries/codex-acp",
1014                    true,
1015                    false,
1016                    http.clone(),
1017                )
1018                .await
1019                .context("fetching Codex latest release")?;
1020
1021                let version_dir = dir.join(&release.tag_name);
1022                if !fs.is_dir(&version_dir).await {
1023                    // Assemble release download URL from prefix, tag, and filename based on target triple.
1024                    // If unsupported, silently skip download.
1025                    let tag = release.tag_name.clone(); // e.g. "v0.1.0"
1026                    let version_number = tag.trim_start_matches('v');
1027                    if let Some(asset_url) = codex_release_url(version_number) {
1028                        let http = http.clone();
1029                        let mut response = http
1030                            .get(&asset_url, Default::default(), true)
1031                            .await
1032                            .with_context(|| {
1033                                format!("downloading Codex binary from {}", asset_url)
1034                            })?;
1035                        anyhow::ensure!(
1036                            response.status().is_success(),
1037                            "failed to download Codex release: {}",
1038                            response.status()
1039                        );
1040
1041                        // Extract archive into the version directory.
1042                        if asset_url.ends_with(".zip") {
1043                            let reader = futures::io::BufReader::new(response.body_mut());
1044                            util::archive::extract_zip(&version_dir, reader)
1045                                .await
1046                                .context("extracting Codex binary from zip")?;
1047                        } else {
1048                            // Decompress and extract the tar.gz into the version directory.
1049                            let reader = futures::io::BufReader::new(response.body_mut());
1050                            let decoder =
1051                                async_compression::futures::bufread::GzipDecoder::new(reader);
1052                            let archive = async_tar::Archive::new(decoder);
1053                            archive
1054                                .unpack(&version_dir)
1055                                .await
1056                                .context("extracting Codex binary from tar.gz")?;
1057                        }
1058                    }
1059                }
1060
1061                let bin_name = if cfg!(windows) {
1062                    "codex-acp.exe"
1063                } else {
1064                    "codex-acp"
1065                };
1066                let bin_path = version_dir.join(bin_name);
1067                anyhow::ensure!(
1068                    fs.is_file(&bin_path).await,
1069                    "Missing Codex binary at {} after installation",
1070                    bin_path.to_string_lossy()
1071                );
1072
1073                let mut cmd = AgentServerCommand {
1074                    path: bin_path,
1075                    args: Vec::new(),
1076                    env: None,
1077                };
1078                cmd.env = Some(env);
1079                cmd
1080            };
1081
1082            command.env.get_or_insert_default().extend(extra_env);
1083            Ok((command, root_dir.to_string_lossy().into_owned(), None))
1084        })
1085    }
1086
1087    fn as_any_mut(&mut self) -> &mut dyn Any {
1088        self
1089    }
1090}
1091
1092/// Assemble Codex release URL for the current OS/arch and the given version number.
1093/// Returns None if the current target is unsupported.
1094/// Example output:
1095/// https://github.com/zed-industries/codex-acp/releases/download/v{version}/codex-acp-{version}-{arch}-{platform}.{ext}
1096fn codex_release_url(version: &str) -> Option<String> {
1097    let arch = if cfg!(target_arch = "x86_64") {
1098        "x86_64"
1099    } else if cfg!(target_arch = "aarch64") {
1100        "aarch64"
1101    } else {
1102        return None;
1103    };
1104
1105    let platform = if cfg!(target_os = "macos") {
1106        "apple-darwin"
1107    } else if cfg!(target_os = "windows") {
1108        "pc-windows-msvc"
1109    } else if cfg!(target_os = "linux") {
1110        "unknown-linux-gnu"
1111    } else {
1112        return None;
1113    };
1114
1115    // Only Windows x86_64 uses .zip in release assets
1116    let ext = if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
1117        "zip"
1118    } else {
1119        "tar.gz"
1120    };
1121
1122    let prefix = "https://github.com/zed-industries/codex-acp/releases/download";
1123
1124    Some(format!(
1125        "{prefix}/v{version}/codex-acp-{version}-{arch}-{platform}.{ext}"
1126    ))
1127}
1128
1129struct LocalCustomAgent {
1130    project_environment: Entity<ProjectEnvironment>,
1131    command: AgentServerCommand,
1132}
1133
1134impl ExternalAgentServer for LocalCustomAgent {
1135    fn get_command(
1136        &mut self,
1137        root_dir: Option<&str>,
1138        extra_env: HashMap<String, String>,
1139        _status_tx: Option<watch::Sender<SharedString>>,
1140        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1141        cx: &mut AsyncApp,
1142    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1143        let mut command = self.command.clone();
1144        let root_dir: Arc<Path> = root_dir
1145            .map(|root_dir| Path::new(root_dir))
1146            .unwrap_or(paths::home_dir())
1147            .into();
1148        let project_environment = self.project_environment.downgrade();
1149        cx.spawn(async move |cx| {
1150            let mut env = project_environment
1151                .update(cx, |project_environment, cx| {
1152                    project_environment.get_directory_environment(root_dir.clone(), cx)
1153                })?
1154                .await
1155                .unwrap_or_default();
1156            env.extend(command.env.unwrap_or_default());
1157            env.extend(extra_env);
1158            command.env = Some(env);
1159            Ok((command, root_dir.to_string_lossy().into_owned(), None))
1160        })
1161    }
1162
1163    fn as_any_mut(&mut self) -> &mut dyn Any {
1164        self
1165    }
1166}
1167
1168#[cfg(test)]
1169mod tests {
1170    #[test]
1171    fn assembles_codex_release_url_for_current_target() {
1172        let version_number = "0.1.0";
1173
1174        // This test fails the build if we are building a version of Zed
1175        // which does not have a known build of codex-acp, to prevent us
1176        // from accidentally doing a release on a new target without
1177        // realizing that codex-acp support will not work on that target!
1178        //
1179        // Additionally, it verifies that our logic for assembling URLs
1180        // correctly resolves to a known-good URL on each of our targets.
1181        let allowed = [
1182            "https://github.com/zed-industries/codex-acp/releases/download/v0.1.0/codex-acp-0.1.0-aarch64-apple-darwin.tar.gz",
1183            "https://github.com/zed-industries/codex-acp/releases/download/v0.1.0/codex-acp-0.1.0-aarch64-pc-windows-msvc.tar.gz",
1184            "https://github.com/zed-industries/codex-acp/releases/download/v0.1.0/codex-acp-0.1.0-aarch64-unknown-linux-gnu.tar.gz",
1185            "https://github.com/zed-industries/codex-acp/releases/download/v0.1.0/codex-acp-0.1.0-x86_64-apple-darwin.tar.gz",
1186            "https://github.com/zed-industries/codex-acp/releases/download/v0.1.0/codex-acp-0.1.0-x86_64-pc-windows-msvc.zip",
1187            "https://github.com/zed-industries/codex-acp/releases/download/v0.1.0/codex-acp-0.1.0-x86_64-unknown-linux-gnu.tar.gz",
1188        ];
1189
1190        if let Some(url) = super::codex_release_url(version_number) {
1191            assert!(
1192                allowed.contains(&url.as_str()),
1193                "Assembled URL {} not in allowed list",
1194                url
1195            );
1196        } else {
1197            panic!(
1198                "This target does not have a known codex-acp release! We should fix this by building a release of codex-acp for this target, as otherwise codex-acp will not be usable with this Zed build."
1199            );
1200        }
1201    }
1202}
1203
1204pub const GEMINI_NAME: &'static str = "gemini";
1205pub const CLAUDE_CODE_NAME: &'static str = "claude";
1206pub const CODEX_NAME: &'static str = "codex";
1207
1208#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1209pub struct AllAgentServersSettings {
1210    pub gemini: Option<BuiltinAgentServerSettings>,
1211    pub claude: Option<BuiltinAgentServerSettings>,
1212    pub codex: Option<BuiltinAgentServerSettings>,
1213    pub custom: HashMap<SharedString, CustomAgentServerSettings>,
1214}
1215#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1216pub struct BuiltinAgentServerSettings {
1217    pub path: Option<PathBuf>,
1218    pub args: Option<Vec<String>>,
1219    pub env: Option<HashMap<String, String>>,
1220    pub ignore_system_version: Option<bool>,
1221    pub default_mode: Option<String>,
1222}
1223
1224impl BuiltinAgentServerSettings {
1225    pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
1226        self.path.map(|path| AgentServerCommand {
1227            path,
1228            args: self.args.unwrap_or_default(),
1229            env: self.env,
1230        })
1231    }
1232}
1233
1234impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
1235    fn from(value: settings::BuiltinAgentServerSettings) -> Self {
1236        BuiltinAgentServerSettings {
1237            path: value.path,
1238            args: value.args,
1239            env: value.env,
1240            ignore_system_version: value.ignore_system_version,
1241            default_mode: value.default_mode,
1242        }
1243    }
1244}
1245
1246impl From<AgentServerCommand> for BuiltinAgentServerSettings {
1247    fn from(value: AgentServerCommand) -> Self {
1248        BuiltinAgentServerSettings {
1249            path: Some(value.path),
1250            args: Some(value.args),
1251            env: value.env,
1252            ..Default::default()
1253        }
1254    }
1255}
1256
1257#[derive(Clone, JsonSchema, Debug, PartialEq)]
1258pub struct CustomAgentServerSettings {
1259    pub command: AgentServerCommand,
1260    /// The default mode to use for this agent.
1261    ///
1262    /// Note: Not only all agents support modes.
1263    ///
1264    /// Default: None
1265    pub default_mode: Option<String>,
1266}
1267
1268impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
1269    fn from(value: settings::CustomAgentServerSettings) -> Self {
1270        CustomAgentServerSettings {
1271            command: AgentServerCommand {
1272                path: value.path,
1273                args: value.args,
1274                env: value.env,
1275            },
1276            default_mode: value.default_mode,
1277        }
1278    }
1279}
1280
1281impl settings::Settings for AllAgentServersSettings {
1282    fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self {
1283        let agent_settings = content.agent_servers.clone().unwrap();
1284        Self {
1285            gemini: agent_settings.gemini.map(Into::into),
1286            claude: agent_settings.claude.map(Into::into),
1287            codex: agent_settings.codex.map(Into::into),
1288            custom: agent_settings
1289                .custom
1290                .into_iter()
1291                .map(|(k, v)| (k, v.into()))
1292                .collect(),
1293        }
1294    }
1295
1296    fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut SettingsContent) {}
1297}