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.insert(
 187            CLAUDE_CODE_NAME.into(),
 188            Box::new(LocalClaudeCode {
 189                fs: fs.clone(),
 190                node_runtime: node_runtime.clone(),
 191                project_environment: project_environment.clone(),
 192                custom_command: new_settings
 193                    .claude
 194                    .clone()
 195                    .and_then(|settings| settings.custom_command()),
 196            }),
 197        );
 198        self.external_agents.insert(
 199            CODEX_NAME.into(),
 200            Box::new(LocalCodex {
 201                fs: fs.clone(),
 202                project_environment: project_environment.clone(),
 203                custom_command: new_settings
 204                    .codex
 205                    .clone()
 206                    .and_then(|settings| settings.custom_command()),
 207            }),
 208        );
 209        self.external_agents.insert(
 210            CODEX_NAME.into(),
 211            Box::new(LocalCodex {
 212                fs: fs.clone(),
 213                project_environment: project_environment.clone(),
 214                custom_command: new_settings
 215                    .codex
 216                    .clone()
 217                    .and_then(|settings| settings.custom_command()),
 218            }),
 219        );
 220        self.external_agents
 221            .extend(new_settings.custom.iter().map(|(name, settings)| {
 222                (
 223                    ExternalAgentServerName(name.clone()),
 224                    Box::new(LocalCustomAgent {
 225                        command: settings.command.clone(),
 226                        project_environment: project_environment.clone(),
 227                    }) as Box<dyn ExternalAgentServer>,
 228                )
 229            }));
 230
 231        *old_settings = Some(new_settings.clone());
 232
 233        if let Some((project_id, downstream_client)) = downstream_client {
 234            downstream_client
 235                .send(proto::ExternalAgentsUpdated {
 236                    project_id: *project_id,
 237                    names: self
 238                        .external_agents
 239                        .keys()
 240                        .map(|name| name.to_string())
 241                        .collect(),
 242                })
 243                .log_err();
 244        }
 245        cx.emit(AgentServersUpdated);
 246    }
 247
 248    pub fn local(
 249        node_runtime: NodeRuntime,
 250        fs: Arc<dyn Fs>,
 251        project_environment: Entity<ProjectEnvironment>,
 252        cx: &mut Context<Self>,
 253    ) -> Self {
 254        let subscription = cx.observe_global::<SettingsStore>(|this, cx| {
 255            this.agent_servers_settings_changed(cx);
 256        });
 257        let mut this = Self {
 258            state: AgentServerStoreState::Local {
 259                node_runtime,
 260                fs,
 261                project_environment,
 262                downstream_client: None,
 263                settings: None,
 264                _subscriptions: [subscription],
 265            },
 266            external_agents: Default::default(),
 267        };
 268        this.agent_servers_settings_changed(cx);
 269        this
 270    }
 271
 272    pub(crate) fn remote(
 273        project_id: u64,
 274        upstream_client: Entity<RemoteClient>,
 275        _cx: &mut Context<Self>,
 276    ) -> Self {
 277        // Set up the builtin agents here so they're immediately available in
 278        // remote projects--we know that the HeadlessProject on the other end
 279        // will have them.
 280        let external_agents = [
 281            (
 282                GEMINI_NAME.into(),
 283                Box::new(RemoteExternalAgentServer {
 284                    project_id,
 285                    upstream_client: upstream_client.clone(),
 286                    name: GEMINI_NAME.into(),
 287                    status_tx: None,
 288                    new_version_available_tx: None,
 289                }) as Box<dyn ExternalAgentServer>,
 290            ),
 291            (
 292                CLAUDE_CODE_NAME.into(),
 293                Box::new(RemoteExternalAgentServer {
 294                    project_id,
 295                    upstream_client: upstream_client.clone(),
 296                    name: CLAUDE_CODE_NAME.into(),
 297                    status_tx: None,
 298                    new_version_available_tx: None,
 299                }) as Box<dyn ExternalAgentServer>,
 300            ),
 301        ]
 302        .into_iter()
 303        .collect();
 304
 305        Self {
 306            state: AgentServerStoreState::Remote {
 307                project_id,
 308                upstream_client,
 309            },
 310            external_agents,
 311        }
 312    }
 313
 314    pub(crate) fn collab(_cx: &mut Context<Self>) -> Self {
 315        Self {
 316            state: AgentServerStoreState::Collab,
 317            external_agents: Default::default(),
 318        }
 319    }
 320
 321    pub fn shared(&mut self, project_id: u64, client: AnyProtoClient, cx: &mut Context<Self>) {
 322        match &mut self.state {
 323            AgentServerStoreState::Local {
 324                downstream_client, ..
 325            } => {
 326                *downstream_client = Some((project_id, client.clone()));
 327                // Send the current list of external agents downstream, but only after a delay,
 328                // to avoid having the message arrive before the downstream project's agent server store
 329                // sets up its handlers.
 330                cx.spawn(async move |this, cx| {
 331                    cx.background_executor().timer(Duration::from_secs(1)).await;
 332                    let names = this.update(cx, |this, _| {
 333                        this.external_agents
 334                            .keys()
 335                            .map(|name| name.to_string())
 336                            .collect()
 337                    })?;
 338                    client
 339                        .send(proto::ExternalAgentsUpdated { project_id, names })
 340                        .log_err();
 341                    anyhow::Ok(())
 342                })
 343                .detach();
 344            }
 345            AgentServerStoreState::Remote { .. } => {
 346                debug_panic!(
 347                    "external agents over collab not implemented, remote project should not be shared"
 348                );
 349            }
 350            AgentServerStoreState::Collab => {
 351                debug_panic!("external agents over collab not implemented, should not be shared");
 352            }
 353        }
 354    }
 355
 356    pub fn get_external_agent(
 357        &mut self,
 358        name: &ExternalAgentServerName,
 359    ) -> Option<&mut (dyn ExternalAgentServer + 'static)> {
 360        self.external_agents
 361            .get_mut(name)
 362            .map(|agent| agent.as_mut())
 363    }
 364
 365    pub fn external_agents(&self) -> impl Iterator<Item = &ExternalAgentServerName> {
 366        self.external_agents.keys()
 367    }
 368
 369    async fn handle_get_agent_server_command(
 370        this: Entity<Self>,
 371        envelope: TypedEnvelope<proto::GetAgentServerCommand>,
 372        mut cx: AsyncApp,
 373    ) -> Result<proto::AgentServerCommand> {
 374        let (command, root_dir, login) = this
 375            .update(&mut cx, |this, cx| {
 376                let AgentServerStoreState::Local {
 377                    downstream_client, ..
 378                } = &this.state
 379                else {
 380                    debug_panic!("should not receive GetAgentServerCommand in a non-local project");
 381                    bail!("unexpected GetAgentServerCommand request in a non-local project");
 382                };
 383                let agent = this
 384                    .external_agents
 385                    .get_mut(&*envelope.payload.name)
 386                    .with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
 387                let (status_tx, new_version_available_tx) = downstream_client
 388                    .clone()
 389                    .map(|(project_id, downstream_client)| {
 390                        let (status_tx, mut status_rx) = watch::channel(SharedString::from(""));
 391                        let (new_version_available_tx, mut new_version_available_rx) =
 392                            watch::channel(None);
 393                        cx.spawn({
 394                            let downstream_client = downstream_client.clone();
 395                            let name = envelope.payload.name.clone();
 396                            async move |_, _| {
 397                                while let Some(status) = status_rx.recv().await.ok() {
 398                                    downstream_client.send(
 399                                        proto::ExternalAgentLoadingStatusUpdated {
 400                                            project_id,
 401                                            name: name.clone(),
 402                                            status: status.to_string(),
 403                                        },
 404                                    )?;
 405                                }
 406                                anyhow::Ok(())
 407                            }
 408                        })
 409                        .detach_and_log_err(cx);
 410                        cx.spawn({
 411                            let name = envelope.payload.name.clone();
 412                            async move |_, _| {
 413                                if let Some(version) =
 414                                    new_version_available_rx.recv().await.ok().flatten()
 415                                {
 416                                    downstream_client.send(
 417                                        proto::NewExternalAgentVersionAvailable {
 418                                            project_id,
 419                                            name: name.clone(),
 420                                            version,
 421                                        },
 422                                    )?;
 423                                }
 424                                anyhow::Ok(())
 425                            }
 426                        })
 427                        .detach_and_log_err(cx);
 428                        (status_tx, new_version_available_tx)
 429                    })
 430                    .unzip();
 431                anyhow::Ok(agent.get_command(
 432                    envelope.payload.root_dir.as_deref(),
 433                    HashMap::default(),
 434                    status_tx,
 435                    new_version_available_tx,
 436                    &mut cx.to_async(),
 437                ))
 438            })??
 439            .await?;
 440        Ok(proto::AgentServerCommand {
 441            path: command.path.to_string_lossy().into_owned(),
 442            args: command.args,
 443            env: command
 444                .env
 445                .map(|env| env.into_iter().collect())
 446                .unwrap_or_default(),
 447            root_dir: root_dir,
 448            login: login.map(|login| login.to_proto()),
 449        })
 450    }
 451
 452    async fn handle_external_agents_updated(
 453        this: Entity<Self>,
 454        envelope: TypedEnvelope<proto::ExternalAgentsUpdated>,
 455        mut cx: AsyncApp,
 456    ) -> Result<()> {
 457        this.update(&mut cx, |this, cx| {
 458            let AgentServerStoreState::Remote {
 459                project_id,
 460                upstream_client,
 461            } = &this.state
 462            else {
 463                debug_panic!(
 464                    "handle_external_agents_updated should not be called for a non-remote project"
 465                );
 466                bail!("unexpected ExternalAgentsUpdated message")
 467            };
 468
 469            let mut status_txs = this
 470                .external_agents
 471                .iter_mut()
 472                .filter_map(|(name, agent)| {
 473                    Some((
 474                        name.clone(),
 475                        agent
 476                            .downcast_mut::<RemoteExternalAgentServer>()?
 477                            .status_tx
 478                            .take(),
 479                    ))
 480                })
 481                .collect::<HashMap<_, _>>();
 482            let mut new_version_available_txs = this
 483                .external_agents
 484                .iter_mut()
 485                .filter_map(|(name, agent)| {
 486                    Some((
 487                        name.clone(),
 488                        agent
 489                            .downcast_mut::<RemoteExternalAgentServer>()?
 490                            .new_version_available_tx
 491                            .take(),
 492                    ))
 493                })
 494                .collect::<HashMap<_, _>>();
 495
 496            this.external_agents = envelope
 497                .payload
 498                .names
 499                .into_iter()
 500                .map(|name| {
 501                    let agent = RemoteExternalAgentServer {
 502                        project_id: *project_id,
 503                        upstream_client: upstream_client.clone(),
 504                        name: ExternalAgentServerName(name.clone().into()),
 505                        status_tx: status_txs.remove(&*name).flatten(),
 506                        new_version_available_tx: new_version_available_txs
 507                            .remove(&*name)
 508                            .flatten(),
 509                    };
 510                    (
 511                        ExternalAgentServerName(name.into()),
 512                        Box::new(agent) as Box<dyn ExternalAgentServer>,
 513                    )
 514                })
 515                .collect();
 516            cx.emit(AgentServersUpdated);
 517            Ok(())
 518        })?
 519    }
 520
 521    async fn handle_loading_status_updated(
 522        this: Entity<Self>,
 523        envelope: TypedEnvelope<proto::ExternalAgentLoadingStatusUpdated>,
 524        mut cx: AsyncApp,
 525    ) -> Result<()> {
 526        this.update(&mut cx, |this, _| {
 527            if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
 528                && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
 529                && let Some(status_tx) = &mut agent.status_tx
 530            {
 531                status_tx.send(envelope.payload.status.into()).ok();
 532            }
 533        })
 534    }
 535
 536    async fn handle_new_version_available(
 537        this: Entity<Self>,
 538        envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
 539        mut cx: AsyncApp,
 540    ) -> Result<()> {
 541        this.update(&mut cx, |this, _| {
 542            if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
 543                && let Some(agent) = agent.downcast_mut::<RemoteExternalAgentServer>()
 544                && let Some(new_version_available_tx) = &mut agent.new_version_available_tx
 545            {
 546                new_version_available_tx
 547                    .send(Some(envelope.payload.version))
 548                    .ok();
 549            }
 550        })
 551    }
 552}
 553
 554fn get_or_npm_install_builtin_agent(
 555    binary_name: SharedString,
 556    package_name: SharedString,
 557    entrypoint_path: PathBuf,
 558    minimum_version: Option<semver::Version>,
 559    status_tx: Option<watch::Sender<SharedString>>,
 560    new_version_available: Option<watch::Sender<Option<String>>>,
 561    fs: Arc<dyn Fs>,
 562    node_runtime: NodeRuntime,
 563    cx: &mut AsyncApp,
 564) -> Task<std::result::Result<AgentServerCommand, anyhow::Error>> {
 565    cx.spawn(async move |cx| {
 566        let node_path = node_runtime.binary_path().await?;
 567        let dir = paths::data_dir()
 568            .join("external_agents")
 569            .join(binary_name.as_str());
 570        fs.create_dir(&dir).await?;
 571
 572        let mut stream = fs.read_dir(&dir).await?;
 573        let mut versions = Vec::new();
 574        let mut to_delete = Vec::new();
 575        while let Some(entry) = stream.next().await {
 576            let Ok(entry) = entry else { continue };
 577            let Some(file_name) = entry.file_name() else {
 578                continue;
 579            };
 580
 581            if let Some(name) = file_name.to_str()
 582                && let Some(version) = semver::Version::from_str(name).ok()
 583                && fs
 584                    .is_file(&dir.join(file_name).join(&entrypoint_path))
 585                    .await
 586            {
 587                versions.push((version, file_name.to_owned()));
 588            } else {
 589                to_delete.push(file_name.to_owned())
 590            }
 591        }
 592
 593        versions.sort();
 594        let newest_version = if let Some((version, file_name)) = versions.last().cloned()
 595            && minimum_version.is_none_or(|minimum_version| version >= minimum_version)
 596        {
 597            versions.pop();
 598            Some(file_name)
 599        } else {
 600            None
 601        };
 602        log::debug!("existing version of {package_name}: {newest_version:?}");
 603        to_delete.extend(versions.into_iter().map(|(_, file_name)| file_name));
 604
 605        cx.background_spawn({
 606            let fs = fs.clone();
 607            let dir = dir.clone();
 608            async move {
 609                for file_name in to_delete {
 610                    fs.remove_dir(
 611                        &dir.join(file_name),
 612                        RemoveOptions {
 613                            recursive: true,
 614                            ignore_if_not_exists: false,
 615                        },
 616                    )
 617                    .await
 618                    .ok();
 619                }
 620            }
 621        })
 622        .detach();
 623
 624        let version = if let Some(file_name) = newest_version {
 625            cx.background_spawn({
 626                let file_name = file_name.clone();
 627                let dir = dir.clone();
 628                let fs = fs.clone();
 629                async move {
 630                    let latest_version =
 631                        node_runtime.npm_package_latest_version(&package_name).await;
 632                    if let Ok(latest_version) = latest_version
 633                        && &latest_version != &file_name.to_string_lossy()
 634                    {
 635                        let download_result = download_latest_version(
 636                            fs,
 637                            dir.clone(),
 638                            node_runtime,
 639                            package_name.clone(),
 640                        )
 641                        .await
 642                        .log_err();
 643                        if let Some(mut new_version_available) = new_version_available
 644                            && download_result.is_some()
 645                        {
 646                            new_version_available.send(Some(latest_version)).ok();
 647                        }
 648                    }
 649                }
 650            })
 651            .detach();
 652            file_name
 653        } else {
 654            if let Some(mut status_tx) = status_tx {
 655                status_tx.send("Installing…".into()).ok();
 656            }
 657            let dir = dir.clone();
 658            cx.background_spawn(download_latest_version(
 659                fs.clone(),
 660                dir.clone(),
 661                node_runtime,
 662                package_name.clone(),
 663            ))
 664            .await?
 665            .into()
 666        };
 667
 668        let agent_server_path = dir.join(version).join(entrypoint_path);
 669        let agent_server_path_exists = fs.is_file(&agent_server_path).await;
 670        anyhow::ensure!(
 671            agent_server_path_exists,
 672            "Missing entrypoint path {} after installation",
 673            agent_server_path.to_string_lossy()
 674        );
 675
 676        anyhow::Ok(AgentServerCommand {
 677            path: node_path,
 678            args: vec![agent_server_path.to_string_lossy().into_owned()],
 679            env: None,
 680        })
 681    })
 682}
 683
 684fn find_bin_in_path(
 685    bin_name: SharedString,
 686    root_dir: PathBuf,
 687    env: HashMap<String, String>,
 688    cx: &mut AsyncApp,
 689) -> Task<Option<PathBuf>> {
 690    cx.background_executor().spawn(async move {
 691        let which_result = if cfg!(windows) {
 692            which::which(bin_name.as_str())
 693        } else {
 694            let shell_path = env.get("PATH").cloned();
 695            which::which_in(bin_name.as_str(), shell_path.as_ref(), &root_dir)
 696        };
 697
 698        if let Err(which::Error::CannotFindBinaryPath) = which_result {
 699            return None;
 700        }
 701
 702        which_result.log_err()
 703    })
 704}
 705
 706async fn download_latest_version(
 707    fs: Arc<dyn Fs>,
 708    dir: PathBuf,
 709    node_runtime: NodeRuntime,
 710    package_name: SharedString,
 711) -> Result<String> {
 712    log::debug!("downloading latest version of {package_name}");
 713
 714    let tmp_dir = tempfile::tempdir_in(&dir)?;
 715
 716    node_runtime
 717        .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
 718        .await?;
 719
 720    let version = node_runtime
 721        .npm_package_installed_version(tmp_dir.path(), &package_name)
 722        .await?
 723        .context("expected package to be installed")?;
 724
 725    fs.rename(
 726        &tmp_dir.keep(),
 727        &dir.join(&version),
 728        RenameOptions {
 729            ignore_if_exists: true,
 730            overwrite: true,
 731        },
 732    )
 733    .await?;
 734
 735    anyhow::Ok(version)
 736}
 737
 738struct RemoteExternalAgentServer {
 739    project_id: u64,
 740    upstream_client: Entity<RemoteClient>,
 741    name: ExternalAgentServerName,
 742    status_tx: Option<watch::Sender<SharedString>>,
 743    new_version_available_tx: Option<watch::Sender<Option<String>>>,
 744}
 745
 746impl ExternalAgentServer for RemoteExternalAgentServer {
 747    fn get_command(
 748        &mut self,
 749        root_dir: Option<&str>,
 750        extra_env: HashMap<String, String>,
 751        status_tx: Option<watch::Sender<SharedString>>,
 752        new_version_available_tx: Option<watch::Sender<Option<String>>>,
 753        cx: &mut AsyncApp,
 754    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
 755        let project_id = self.project_id;
 756        let name = self.name.to_string();
 757        let upstream_client = self.upstream_client.downgrade();
 758        let root_dir = root_dir.map(|root_dir| root_dir.to_owned());
 759        self.status_tx = status_tx;
 760        self.new_version_available_tx = new_version_available_tx;
 761        cx.spawn(async move |cx| {
 762            let mut response = upstream_client
 763                .update(cx, |upstream_client, _| {
 764                    upstream_client
 765                        .proto_client()
 766                        .request(proto::GetAgentServerCommand {
 767                            project_id,
 768                            name,
 769                            root_dir: root_dir.clone(),
 770                        })
 771                })?
 772                .await?;
 773            let root_dir = response.root_dir;
 774            response.env.extend(extra_env);
 775            let command = upstream_client.update(cx, |client, _| {
 776                client.build_command(
 777                    Some(response.path),
 778                    &response.args,
 779                    &response.env.into_iter().collect(),
 780                    Some(root_dir.clone()),
 781                    None,
 782                )
 783            })??;
 784            Ok((
 785                AgentServerCommand {
 786                    path: command.program.into(),
 787                    args: command.args,
 788                    env: Some(command.env),
 789                },
 790                root_dir,
 791                response
 792                    .login
 793                    .map(|login| task::SpawnInTerminal::from_proto(login)),
 794            ))
 795        })
 796    }
 797
 798    fn as_any_mut(&mut self) -> &mut dyn Any {
 799        self
 800    }
 801}
 802
 803struct LocalGemini {
 804    fs: Arc<dyn Fs>,
 805    node_runtime: NodeRuntime,
 806    project_environment: Entity<ProjectEnvironment>,
 807    custom_command: Option<AgentServerCommand>,
 808    ignore_system_version: bool,
 809}
 810
 811impl ExternalAgentServer for LocalGemini {
 812    fn get_command(
 813        &mut self,
 814        root_dir: Option<&str>,
 815        extra_env: HashMap<String, String>,
 816        status_tx: Option<watch::Sender<SharedString>>,
 817        new_version_available_tx: Option<watch::Sender<Option<String>>>,
 818        cx: &mut AsyncApp,
 819    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
 820        let fs = self.fs.clone();
 821        let node_runtime = self.node_runtime.clone();
 822        let project_environment = self.project_environment.downgrade();
 823        let custom_command = self.custom_command.clone();
 824        let ignore_system_version = self.ignore_system_version;
 825        let root_dir: Arc<Path> = root_dir
 826            .map(|root_dir| Path::new(root_dir))
 827            .unwrap_or(paths::home_dir())
 828            .into();
 829
 830        cx.spawn(async move |cx| {
 831            let mut env = project_environment
 832                .update(cx, |project_environment, cx| {
 833                    project_environment.get_directory_environment(root_dir.clone(), cx)
 834                })?
 835                .await
 836                .unwrap_or_default();
 837
 838            let mut command = if let Some(mut custom_command) = custom_command {
 839                env.extend(custom_command.env.unwrap_or_default());
 840                custom_command.env = Some(env);
 841                custom_command
 842            } else if !ignore_system_version
 843                && let Some(bin) =
 844                    find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await
 845            {
 846                AgentServerCommand {
 847                    path: bin,
 848                    args: Vec::new(),
 849                    env: Some(env),
 850                }
 851            } else {
 852                let mut command = get_or_npm_install_builtin_agent(
 853                    GEMINI_NAME.into(),
 854                    "@google/gemini-cli".into(),
 855                    "node_modules/@google/gemini-cli/dist/index.js".into(),
 856                    Some("0.2.1".parse().unwrap()),
 857                    status_tx,
 858                    new_version_available_tx,
 859                    fs,
 860                    node_runtime,
 861                    cx,
 862                )
 863                .await?;
 864                command.env = Some(env);
 865                command
 866            };
 867
 868            // Gemini CLI doesn't seem to have a dedicated invocation for logging in--we just run it normally without any arguments.
 869            let login = task::SpawnInTerminal {
 870                command: Some(command.path.to_string_lossy().into_owned()),
 871                args: command.args.clone(),
 872                env: command.env.clone().unwrap_or_default(),
 873                label: "gemini /auth".into(),
 874                ..Default::default()
 875            };
 876
 877            command.env.get_or_insert_default().extend(extra_env);
 878            command.args.push("--experimental-acp".into());
 879            Ok((
 880                command,
 881                root_dir.to_string_lossy().into_owned(),
 882                Some(login),
 883            ))
 884        })
 885    }
 886
 887    fn as_any_mut(&mut self) -> &mut dyn Any {
 888        self
 889    }
 890}
 891
 892struct LocalClaudeCode {
 893    fs: Arc<dyn Fs>,
 894    node_runtime: NodeRuntime,
 895    project_environment: Entity<ProjectEnvironment>,
 896    custom_command: Option<AgentServerCommand>,
 897}
 898
 899impl ExternalAgentServer for LocalClaudeCode {
 900    fn get_command(
 901        &mut self,
 902        root_dir: Option<&str>,
 903        extra_env: HashMap<String, String>,
 904        status_tx: Option<watch::Sender<SharedString>>,
 905        new_version_available_tx: Option<watch::Sender<Option<String>>>,
 906        cx: &mut AsyncApp,
 907    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
 908        let fs = self.fs.clone();
 909        let node_runtime = self.node_runtime.clone();
 910        let project_environment = self.project_environment.downgrade();
 911        let custom_command = self.custom_command.clone();
 912        let root_dir: Arc<Path> = root_dir
 913            .map(|root_dir| Path::new(root_dir))
 914            .unwrap_or(paths::home_dir())
 915            .into();
 916
 917        cx.spawn(async move |cx| {
 918            let mut env = project_environment
 919                .update(cx, |project_environment, cx| {
 920                    project_environment.get_directory_environment(root_dir.clone(), cx)
 921                })?
 922                .await
 923                .unwrap_or_default();
 924            env.insert("ANTHROPIC_API_KEY".into(), "".into());
 925
 926            let (mut command, login) = if let Some(mut custom_command) = custom_command {
 927                env.extend(custom_command.env.unwrap_or_default());
 928                custom_command.env = Some(env);
 929                (custom_command, None)
 930            } else {
 931                let mut command = get_or_npm_install_builtin_agent(
 932                    "claude-code-acp".into(),
 933                    "@zed-industries/claude-code-acp".into(),
 934                    "node_modules/@zed-industries/claude-code-acp/dist/index.js".into(),
 935                    Some("0.5.2".parse().unwrap()),
 936                    status_tx,
 937                    new_version_available_tx,
 938                    fs,
 939                    node_runtime,
 940                    cx,
 941                )
 942                .await?;
 943                command.env = Some(env);
 944                let login = command
 945                    .args
 946                    .first()
 947                    .and_then(|path| {
 948                        path.strip_suffix("/@zed-industries/claude-code-acp/dist/index.js")
 949                    })
 950                    .map(|path_prefix| task::SpawnInTerminal {
 951                        command: Some(command.path.to_string_lossy().into_owned()),
 952                        args: vec![
 953                            Path::new(path_prefix)
 954                                .join("@anthropic-ai/claude-agent-sdk/cli.js")
 955                                .to_string_lossy()
 956                                .to_string(),
 957                            "/login".into(),
 958                        ],
 959                        env: command.env.clone().unwrap_or_default(),
 960                        label: "claude /login".into(),
 961                        ..Default::default()
 962                    });
 963                (command, login)
 964            };
 965
 966            command.env.get_or_insert_default().extend(extra_env);
 967            Ok((command, root_dir.to_string_lossy().into_owned(), login))
 968        })
 969    }
 970
 971    fn as_any_mut(&mut self) -> &mut dyn Any {
 972        self
 973    }
 974}
 975
 976struct LocalCodex {
 977    fs: Arc<dyn Fs>,
 978    project_environment: Entity<ProjectEnvironment>,
 979    custom_command: Option<AgentServerCommand>,
 980}
 981
 982impl ExternalAgentServer for LocalCodex {
 983    fn get_command(
 984        &mut self,
 985        root_dir: Option<&str>,
 986        extra_env: HashMap<String, String>,
 987        _status_tx: Option<watch::Sender<SharedString>>,
 988        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
 989        cx: &mut AsyncApp,
 990    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
 991        let fs = self.fs.clone();
 992        let project_environment = self.project_environment.downgrade();
 993        let custom_command = self.custom_command.clone();
 994        let root_dir: Arc<Path> = root_dir
 995            .map(|root_dir| Path::new(root_dir))
 996            .unwrap_or(paths::home_dir())
 997            .into();
 998
 999        cx.spawn(async move |cx| {
1000            let mut env = project_environment
1001                .update(cx, |project_environment, cx| {
1002                    project_environment.get_directory_environment(root_dir.clone(), cx)
1003                })?
1004                .await
1005                .unwrap_or_default();
1006
1007            let mut command = if let Some(mut custom_command) = custom_command {
1008                env.extend(custom_command.env.unwrap_or_default());
1009                custom_command.env = Some(env);
1010                custom_command
1011            } else {
1012                let dir = paths::data_dir().join("external_agents").join(CODEX_NAME);
1013                fs.create_dir(&dir).await?;
1014
1015                // Find or install the latest Codex release (no update checks for now).
1016                let http = cx.update(|cx| Client::global(cx).http_client())?;
1017                let release = ::http_client::github::latest_github_release(
1018                    "zed-industries/codex-acp",
1019                    true,
1020                    false,
1021                    http.clone(),
1022                )
1023                .await
1024                .context("fetching Codex latest release")?;
1025
1026                let version_dir = dir.join(&release.tag_name);
1027                if !fs.is_dir(&version_dir).await {
1028                    // Determine the asset name based on CPU architecture.
1029                    let arch = if cfg!(target_arch = "x86_64") {
1030                        "x86_64"
1031                    } else if cfg!(target_arch = "aarch64") {
1032                        "aarch64"
1033                    } else {
1034                        std::env::consts::ARCH
1035                    };
1036                    let asset_name = format!("{arch}.tar.gz");
1037                    let asset_url = release
1038                        .assets
1039                        .iter()
1040                        .find(|a| a.name == asset_name)
1041                        .map(|a| a.browser_download_url.clone())
1042                        .context(format!(
1043                            "no asset named {asset_name} in release {}",
1044                            release.tag_name
1045                        ))?;
1046
1047                    let http = http.clone();
1048                    let mut response = http
1049                        .get(&asset_url, Default::default(), true)
1050                        .await
1051                        .context("downloading Codex binary")?;
1052                    anyhow::ensure!(
1053                        response.status().is_success(),
1054                        "failed to download Codex release: {}",
1055                        response.status()
1056                    );
1057
1058                    // Decompress and extract the tar.gz into the version directory.
1059                    let reader = futures::io::BufReader::new(response.body_mut());
1060                    let decoder = async_compression::futures::bufread::GzipDecoder::new(reader);
1061                    let archive = async_tar::Archive::new(decoder);
1062                    archive
1063                        .unpack(&version_dir)
1064                        .await
1065                        .context("extracting Codex binary")?;
1066                }
1067
1068                let bin_name = if cfg!(windows) {
1069                    "codex-acp.exe"
1070                } else {
1071                    "codex-acp"
1072                };
1073                let bin_path = version_dir.join(bin_name);
1074                anyhow::ensure!(
1075                    fs.is_file(&bin_path).await,
1076                    "Missing Codex binary at {} after installation",
1077                    bin_path.to_string_lossy()
1078                );
1079
1080                let mut cmd = AgentServerCommand {
1081                    path: bin_path,
1082                    args: Vec::new(),
1083                    env: None,
1084                };
1085                cmd.env = Some(env);
1086                cmd
1087            };
1088
1089            command.env.get_or_insert_default().extend(extra_env);
1090            Ok((command, root_dir.to_string_lossy().into_owned(), None))
1091        })
1092    }
1093
1094    fn as_any_mut(&mut self) -> &mut dyn Any {
1095        self
1096    }
1097}
1098
1099struct LocalCustomAgent {
1100    project_environment: Entity<ProjectEnvironment>,
1101    command: AgentServerCommand,
1102}
1103
1104impl ExternalAgentServer for LocalCustomAgent {
1105    fn get_command(
1106        &mut self,
1107        root_dir: Option<&str>,
1108        extra_env: HashMap<String, String>,
1109        _status_tx: Option<watch::Sender<SharedString>>,
1110        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
1111        cx: &mut AsyncApp,
1112    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
1113        let mut command = self.command.clone();
1114        let root_dir: Arc<Path> = root_dir
1115            .map(|root_dir| Path::new(root_dir))
1116            .unwrap_or(paths::home_dir())
1117            .into();
1118        let project_environment = self.project_environment.downgrade();
1119        cx.spawn(async move |cx| {
1120            let mut env = project_environment
1121                .update(cx, |project_environment, cx| {
1122                    project_environment.get_directory_environment(root_dir.clone(), cx)
1123                })?
1124                .await
1125                .unwrap_or_default();
1126            env.extend(command.env.unwrap_or_default());
1127            env.extend(extra_env);
1128            command.env = Some(env);
1129            Ok((command, root_dir.to_string_lossy().into_owned(), None))
1130        })
1131    }
1132
1133    fn as_any_mut(&mut self) -> &mut dyn Any {
1134        self
1135    }
1136}
1137
1138pub const GEMINI_NAME: &'static str = "gemini";
1139pub const CLAUDE_CODE_NAME: &'static str = "claude";
1140pub const CODEX_NAME: &'static str = "codex";
1141
1142#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1143pub struct AllAgentServersSettings {
1144    pub gemini: Option<BuiltinAgentServerSettings>,
1145    pub claude: Option<BuiltinAgentServerSettings>,
1146    pub codex: Option<BuiltinAgentServerSettings>,
1147    pub custom: HashMap<SharedString, CustomAgentServerSettings>,
1148}
1149#[derive(Default, Clone, JsonSchema, Debug, PartialEq)]
1150pub struct BuiltinAgentServerSettings {
1151    pub path: Option<PathBuf>,
1152    pub args: Option<Vec<String>>,
1153    pub env: Option<HashMap<String, String>>,
1154    pub ignore_system_version: Option<bool>,
1155    pub default_mode: Option<String>,
1156}
1157
1158impl BuiltinAgentServerSettings {
1159    pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
1160        self.path.map(|path| AgentServerCommand {
1161            path,
1162            args: self.args.unwrap_or_default(),
1163            env: self.env,
1164        })
1165    }
1166}
1167
1168impl From<settings::BuiltinAgentServerSettings> for BuiltinAgentServerSettings {
1169    fn from(value: settings::BuiltinAgentServerSettings) -> Self {
1170        BuiltinAgentServerSettings {
1171            path: value.path,
1172            args: value.args,
1173            env: value.env,
1174            ignore_system_version: value.ignore_system_version,
1175            default_mode: value.default_mode,
1176        }
1177    }
1178}
1179
1180impl From<AgentServerCommand> for BuiltinAgentServerSettings {
1181    fn from(value: AgentServerCommand) -> Self {
1182        BuiltinAgentServerSettings {
1183            path: Some(value.path),
1184            args: Some(value.args),
1185            env: value.env,
1186            ..Default::default()
1187        }
1188    }
1189}
1190
1191#[derive(Clone, JsonSchema, Debug, PartialEq)]
1192pub struct CustomAgentServerSettings {
1193    pub command: AgentServerCommand,
1194    /// The default mode to use for this agent.
1195    ///
1196    /// Note: Not only all agents support modes.
1197    ///
1198    /// Default: None
1199    pub default_mode: Option<String>,
1200}
1201
1202impl From<settings::CustomAgentServerSettings> for CustomAgentServerSettings {
1203    fn from(value: settings::CustomAgentServerSettings) -> Self {
1204        CustomAgentServerSettings {
1205            command: AgentServerCommand {
1206                path: value.path,
1207                args: value.args,
1208                env: value.env,
1209            },
1210            default_mode: value.default_mode,
1211        }
1212    }
1213}
1214
1215impl settings::Settings for AllAgentServersSettings {
1216    fn from_settings(content: &settings::SettingsContent, _cx: &mut App) -> Self {
1217        let agent_settings = content.agent_servers.clone().unwrap();
1218        Self {
1219            gemini: agent_settings.gemini.map(Into::into),
1220            claude: agent_settings.claude.map(Into::into),
1221            codex: agent_settings.codex.map(Into::into),
1222            custom: agent_settings
1223                .custom
1224                .into_iter()
1225                .map(|(k, v)| (k, v.into()))
1226                .collect(),
1227        }
1228    }
1229
1230    fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut SettingsContent) {}
1231}