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