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