ACP Extensions (#40663)

Richard Feldman created

Adds the ability to install ACP agents via extensions

Release Notes:

- N/A *or* Added/Fixed/Improved ...

Change summary

crates/agent_ui/src/agent_panel.rs                                                         | 212 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql                             |   1 
crates/collab/migrations/20250618090000_add_agent_servers_provides_field_to_extensions.sql |   2 
crates/collab/src/db/queries/extensions.rs                                                 |   7 
crates/collab/src/db/tables/extension_version.rs                                           |   5 
crates/collab/src/db/tests/extension_tests.rs                                              |  66 
crates/extension/src/extension_manifest.rs                                                 |  73 
crates/extension_cli/src/main.rs                                                           |  15 
crates/extension_host/benches/extension_compilation_benchmark.rs                           |   1 
crates/extension_host/src/capability_granter.rs                                            |   1 
crates/extension_host/src/extension_store_test.rs                                          |   3 
crates/extensions_ui/src/extensions_ui.rs                                                  |   2 
crates/project/src/agent_server_store.rs                                                   | 668 
crates/project/src/project.rs                                                              |   2 
crates/rpc/src/extension.rs                                                                |   1 
crates/ui/src/components/context_menu.rs                                                   |  40 
crates/zed_actions/src/lib.rs                                                              |   1 
17 files changed, 982 insertions(+), 118 deletions(-)

Detailed changes

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

@@ -6,8 +6,11 @@ use std::sync::Arc;
 use acp_thread::AcpThread;
 use agent::{ContextServerRegistry, DbThreadMetadata, HistoryEntry, HistoryStore};
 use db::kvp::{Dismissable, KEY_VALUE_STORE};
-use project::agent_server_store::{
-    AgentServerCommand, AllAgentServersSettings, CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME,
+use project::{
+    ExternalAgentServerName,
+    agent_server_store::{
+        AgentServerCommand, AllAgentServersSettings, CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME,
+    },
 };
 use serde::{Deserialize, Serialize};
 use settings::{
@@ -41,6 +44,8 @@ use assistant_text_thread::{TextThread, TextThreadEvent, TextThreadSummary};
 use client::{UserStore, zed_urls};
 use cloud_llm_client::{Plan, PlanV1, PlanV2, UsageLimit};
 use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
+use extension::ExtensionEvents;
+use extension_host::ExtensionStore;
 use fs::Fs;
 use gpui::{
     Action, AnyElement, App, AsyncWindowContext, Corner, DismissEvent, Entity, EventEmitter,
@@ -422,6 +427,7 @@ pub struct AgentPanel {
     agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
     agent_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
     agent_navigation_menu: Option<Entity<ContextMenu>>,
+    _extension_subscription: Option<Subscription>,
     width: Option<Pixels>,
     height: Option<Pixels>,
     zoomed: bool,
@@ -632,7 +638,24 @@ impl AgentPanel {
             )
         });
 
-        Self {
+        // Subscribe to extension events to sync agent servers when extensions change
+        let extension_subscription = if let Some(extension_events) = ExtensionEvents::try_global(cx)
+        {
+            Some(
+                cx.subscribe(&extension_events, |this, _source, event, cx| match event {
+                    extension::Event::ExtensionInstalled(_)
+                    | extension::Event::ExtensionUninstalled(_)
+                    | extension::Event::ExtensionsInstalledChanged => {
+                        this.sync_agent_servers_from_extensions(cx);
+                    }
+                    _ => {}
+                }),
+            )
+        } else {
+            None
+        };
+
+        let mut panel = Self {
             active_view,
             workspace,
             user_store,
@@ -650,6 +673,7 @@ impl AgentPanel {
             agent_panel_menu_handle: PopoverMenuHandle::default(),
             agent_navigation_menu_handle: PopoverMenuHandle::default(),
             agent_navigation_menu: None,
+            _extension_subscription: extension_subscription,
             width: None,
             height: None,
             zoomed: false,
@@ -659,7 +683,11 @@ impl AgentPanel {
             history_store,
             selected_agent: AgentType::default(),
             loading: false,
-        }
+        };
+
+        // Initial sync of agent servers from extensions
+        panel.sync_agent_servers_from_extensions(cx);
+        panel
     }
 
     pub fn toggle_focus(
@@ -1309,6 +1337,31 @@ impl AgentPanel {
         self.selected_agent.clone()
     }
 
+    fn sync_agent_servers_from_extensions(&mut self, cx: &mut Context<Self>) {
+        if let Some(extension_store) = ExtensionStore::try_global(cx) {
+            let (manifests, extensions_dir) = {
+                let store = extension_store.read(cx);
+                let installed = store.installed_extensions();
+                let manifests: Vec<_> = installed
+                    .iter()
+                    .map(|(id, entry)| (id.clone(), entry.manifest.clone()))
+                    .collect();
+                let extensions_dir = paths::extensions_dir().join("installed");
+                (manifests, extensions_dir)
+            };
+
+            self.project.update(cx, |project, cx| {
+                project.agent_server_store().update(cx, |store, cx| {
+                    let manifest_refs: Vec<_> = manifests
+                        .iter()
+                        .map(|(id, manifest)| (id.as_ref(), manifest.as_ref()))
+                        .collect();
+                    store.sync_extension_agents(manifest_refs, extensions_dir, cx);
+                });
+            });
+        }
+    }
+
     pub fn new_agent_thread(
         &mut self,
         agent: AgentType,
@@ -1744,6 +1797,16 @@ impl AgentPanel {
         let agent_server_store = self.project.read(cx).agent_server_store().clone();
         let focus_handle = self.focus_handle(cx);
 
+        // Get custom icon path for selected agent before building menu (to avoid borrow issues)
+        let selected_agent_custom_icon =
+            if let AgentType::Custom { name, .. } = &self.selected_agent {
+                agent_server_store
+                    .read(cx)
+                    .agent_icon(&ExternalAgentServerName(name.clone()))
+            } else {
+                None
+            };
+
         let active_thread = match &self.active_view {
             ActiveView::ExternalAgentThread { thread_view } => {
                 thread_view.read(cx).as_native_thread(cx)
@@ -1757,12 +1820,7 @@ impl AgentPanel {
                 {
                     let focus_handle = focus_handle.clone();
                     move |_window, cx| {
-                        Tooltip::for_action_in(
-                            "New…",
-                            &ToggleNewThreadMenu,
-                            &focus_handle,
-                            cx,
-                        )
+                        Tooltip::for_action_in("New…", &ToggleNewThreadMenu, &focus_handle, cx)
                     }
                 },
             )
@@ -1781,8 +1839,7 @@ impl AgentPanel {
 
                     let active_thread = active_thread.clone();
                     Some(ContextMenu::build(window, cx, |menu, _window, cx| {
-                        menu
-                            .context(focus_handle.clone())
+                        menu.context(focus_handle.clone())
                             .header("Zed Agent")
                             .when_some(active_thread, |this, active_thread| {
                                 let thread = active_thread.read(cx);
@@ -1939,77 +1996,110 @@ impl AgentPanel {
                                     }),
                             )
                             .map(|mut menu| {
-                                let agent_names = agent_server_store
-                                    .read(cx)
+                                let agent_server_store_read = agent_server_store.read(cx);
+                                let agent_names = agent_server_store_read
                                     .external_agents()
                                     .filter(|name| {
-                                        name.0 != GEMINI_NAME && name.0 != CLAUDE_CODE_NAME && name.0 != CODEX_NAME
+                                        name.0 != GEMINI_NAME
+                                            && name.0 != CLAUDE_CODE_NAME
+                                            && name.0 != CODEX_NAME
                                     })
                                     .cloned()
                                     .collect::<Vec<_>>();
-                                let custom_settings = cx.global::<SettingsStore>().get::<AllAgentServersSettings>(None).custom.clone();
+                                let custom_settings = cx
+                                    .global::<SettingsStore>()
+                                    .get::<AllAgentServersSettings>(None)
+                                    .custom
+                                    .clone();
                                 for agent_name in agent_names {
-                                    menu = menu.item(
-                                        ContextMenuEntry::new(format!("New {} Thread", agent_name))
-                                            .icon(IconName::Terminal)
-                                            .icon_color(Color::Muted)
-                                            .disabled(is_via_collab)
-                                            .handler({
-                                                let workspace = workspace.clone();
-                                                let agent_name = agent_name.clone();
-                                                let custom_settings = custom_settings.clone();
-                                                move |window, cx| {
-                                                    if let Some(workspace) = workspace.upgrade() {
-                                                        workspace.update(cx, |workspace, cx| {
-                                                            if let Some(panel) =
-                                                                workspace.panel::<AgentPanel>(cx)
-                                                            {
-                                                                panel.update(cx, |panel, cx| {
-                                                                    panel.new_agent_thread(
-                                                                        AgentType::Custom {
-                                                                            name: agent_name.clone().into(),
-                                                                            command: custom_settings
-                                                                                .get(&agent_name.0)
-                                                                                .map(|settings| {
-                                                                                    settings.command.clone()
-                                                                                })
-                                                                                .unwrap_or(placeholder_command()),
-                                                                        },
-                                                                        window,
-                                                                        cx,
-                                                                    );
-                                                                });
-                                                            }
-                                                        });
-                                                    }
+                                    let icon_path = agent_server_store_read.agent_icon(&agent_name);
+                                    let mut entry =
+                                        ContextMenuEntry::new(format!("New {} Thread", agent_name));
+                                    if let Some(icon_path) = icon_path {
+                                        entry = entry.custom_icon_path(icon_path);
+                                    } else {
+                                        entry = entry.icon(IconName::Terminal);
+                                    }
+                                    entry = entry
+                                        .icon_color(Color::Muted)
+                                        .disabled(is_via_collab)
+                                        .handler({
+                                            let workspace = workspace.clone();
+                                            let agent_name = agent_name.clone();
+                                            let custom_settings = custom_settings.clone();
+                                            move |window, cx| {
+                                                if let Some(workspace) = workspace.upgrade() {
+                                                    workspace.update(cx, |workspace, cx| {
+                                                        if let Some(panel) =
+                                                            workspace.panel::<AgentPanel>(cx)
+                                                        {
+                                                            panel.update(cx, |panel, cx| {
+                                                                panel.new_agent_thread(
+                                                                    AgentType::Custom {
+                                                                        name: agent_name
+                                                                            .clone()
+                                                                            .into(),
+                                                                        command: custom_settings
+                                                                            .get(&agent_name.0)
+                                                                            .map(|settings| {
+                                                                                settings
+                                                                                    .command
+                                                                                    .clone()
+                                                                            })
+                                                                            .unwrap_or(
+                                                                                placeholder_command(
+                                                                                ),
+                                                                            ),
+                                                                    },
+                                                                    window,
+                                                                    cx,
+                                                                );
+                                                            });
+                                                        }
+                                                    });
                                                 }
-                                            }),
-                                    );
+                                            }
+                                        });
+                                    menu = menu.item(entry);
                                 }
 
                                 menu
                             })
-                            .separator().link(
-                                    "Add Other Agents",
-                                    OpenBrowser {
-                                        url: zed_urls::external_agents_docs(cx),
-                                    }
-                                    .boxed_clone(),
-                                )
+                            .separator()
+                            .link(
+                                "Add Other Agents",
+                                OpenBrowser {
+                                    url: zed_urls::external_agents_docs(cx),
+                                }
+                                .boxed_clone(),
+                            )
                     }))
                 }
             });
 
         let selected_agent_label = self.selected_agent.label();
+
+        let has_custom_icon = selected_agent_custom_icon.is_some();
         let selected_agent = div()
             .id("selected_agent_icon")
-            .when_some(self.selected_agent.icon(), |this, icon| {
+            .when_some(selected_agent_custom_icon, |this, icon_path| {
+                let label = selected_agent_label.clone();
                 this.px(DynamicSpacing::Base02.rems(cx))
-                    .child(Icon::new(icon).color(Color::Muted))
+                    .child(Icon::from_path(icon_path).color(Color::Muted))
                     .tooltip(move |_window, cx| {
-                        Tooltip::with_meta(selected_agent_label.clone(), None, "Selected Agent", cx)
+                        Tooltip::with_meta(label.clone(), None, "Selected Agent", cx)
                     })
             })
+            .when(!has_custom_icon, |this| {
+                this.when_some(self.selected_agent.icon(), |this, icon| {
+                    let label = selected_agent_label.clone();
+                    this.px(DynamicSpacing::Base02.rems(cx))
+                        .child(Icon::new(icon).color(Color::Muted))
+                        .tooltip(move |_window, cx| {
+                            Tooltip::with_meta(label.clone(), None, "Selected Agent", cx)
+                        })
+                })
+            })
             .into_any_element();
 
         h_flex()

crates/collab/migrations.sqlite/20221109000000_test_schema.sql πŸ”—

@@ -467,6 +467,7 @@ CREATE TABLE extension_versions (
     provides_grammars BOOLEAN NOT NULL DEFAULT FALSE,
     provides_language_servers BOOLEAN NOT NULL DEFAULT FALSE,
     provides_context_servers BOOLEAN NOT NULL DEFAULT FALSE,
+    provides_agent_servers BOOLEAN NOT NULL DEFAULT FALSE,
     provides_slash_commands BOOLEAN NOT NULL DEFAULT FALSE,
     provides_indexed_docs_providers BOOLEAN NOT NULL DEFAULT FALSE,
     provides_snippets BOOLEAN NOT NULL DEFAULT FALSE,

crates/collab/src/db/queries/extensions.rs πŸ”—

@@ -310,6 +310,9 @@ impl Database {
                                 .provides
                                 .contains(&ExtensionProvides::ContextServers),
                         ),
+                        provides_agent_servers: ActiveValue::Set(
+                            version.provides.contains(&ExtensionProvides::AgentServers),
+                        ),
                         provides_slash_commands: ActiveValue::Set(
                             version.provides.contains(&ExtensionProvides::SlashCommands),
                         ),
@@ -422,6 +425,10 @@ fn apply_provides_filter(
         condition = condition.add(extension_version::Column::ProvidesContextServers.eq(true));
     }
 
+    if provides_filter.contains(&ExtensionProvides::AgentServers) {
+        condition = condition.add(extension_version::Column::ProvidesAgentServers.eq(true));
+    }
+
     if provides_filter.contains(&ExtensionProvides::SlashCommands) {
         condition = condition.add(extension_version::Column::ProvidesSlashCommands.eq(true));
     }

crates/collab/src/db/tables/extension_version.rs πŸ”—

@@ -24,6 +24,7 @@ pub struct Model {
     pub provides_grammars: bool,
     pub provides_language_servers: bool,
     pub provides_context_servers: bool,
+    pub provides_agent_servers: bool,
     pub provides_slash_commands: bool,
     pub provides_indexed_docs_providers: bool,
     pub provides_snippets: bool,
@@ -57,6 +58,10 @@ impl Model {
             provides.insert(ExtensionProvides::ContextServers);
         }
 
+        if self.provides_agent_servers {
+            provides.insert(ExtensionProvides::AgentServers);
+        }
+
         if self.provides_slash_commands {
             provides.insert(ExtensionProvides::SlashCommands);
         }

crates/collab/src/db/tests/extension_tests.rs πŸ”—

@@ -16,6 +16,72 @@ test_both_dbs!(
     test_extensions_sqlite
 );
 
+test_both_dbs!(
+    test_agent_servers_filter,
+    test_agent_servers_filter_postgres,
+    test_agent_servers_filter_sqlite
+);
+
+async fn test_agent_servers_filter(db: &Arc<Database>) {
+    // No extensions initially
+    let versions = db.get_known_extension_versions().await.unwrap();
+    assert!(versions.is_empty());
+
+    // Shared timestamp
+    let t0 = time::OffsetDateTime::from_unix_timestamp_nanos(0).unwrap();
+    let t0 = time::PrimitiveDateTime::new(t0.date(), t0.time());
+
+    // Insert two extensions, only one provides AgentServers
+    db.insert_extension_versions(
+        &[
+            (
+                "ext_agent_servers",
+                vec![NewExtensionVersion {
+                    name: "Agent Servers Provider".into(),
+                    version: semver::Version::parse("1.0.0").unwrap(),
+                    description: "has agent servers".into(),
+                    authors: vec!["author".into()],
+                    repository: "org/agent-servers".into(),
+                    schema_version: 1,
+                    wasm_api_version: None,
+                    provides: BTreeSet::from_iter([ExtensionProvides::AgentServers]),
+                    published_at: t0,
+                }],
+            ),
+            (
+                "ext_plain",
+                vec![NewExtensionVersion {
+                    name: "Plain Extension".into(),
+                    version: semver::Version::parse("0.1.0").unwrap(),
+                    description: "no agent servers".into(),
+                    authors: vec!["author2".into()],
+                    repository: "org/plain".into(),
+                    schema_version: 1,
+                    wasm_api_version: None,
+                    provides: BTreeSet::default(),
+                    published_at: t0,
+                }],
+            ),
+        ]
+        .into_iter()
+        .collect(),
+    )
+    .await
+    .unwrap();
+
+    // Filter by AgentServers provides
+    let provides_filter = BTreeSet::from_iter([ExtensionProvides::AgentServers]);
+
+    let filtered = db
+        .get_extensions(None, Some(&provides_filter), 1, 10)
+        .await
+        .unwrap();
+
+    // Expect only the extension that declared AgentServers
+    assert_eq!(filtered.len(), 1);
+    assert_eq!(filtered[0].id.as_ref(), "ext_agent_servers");
+}
+
 async fn test_extensions(db: &Arc<Database>) {
     let versions = db.get_known_extension_versions().await.unwrap();
     assert!(versions.is_empty());

crates/extension/src/extension_manifest.rs πŸ”—

@@ -82,6 +82,8 @@ pub struct ExtensionManifest {
     #[serde(default)]
     pub context_servers: BTreeMap<Arc<str>, ContextServerManifestEntry>,
     #[serde(default)]
+    pub agent_servers: BTreeMap<Arc<str>, AgentServerManifestEntry>,
+    #[serde(default)]
     pub slash_commands: BTreeMap<Arc<str>, SlashCommandManifestEntry>,
     #[serde(default)]
     pub snippets: Option<PathBuf>,
@@ -138,6 +140,48 @@ pub struct LibManifestEntry {
     pub version: Option<SemanticVersion>,
 }
 
+#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
+pub struct AgentServerManifestEntry {
+    /// Display name for the agent (shown in menus).
+    pub name: String,
+    /// Environment variables to set when launching the agent server.
+    #[serde(default)]
+    pub env: HashMap<String, String>,
+    /// Optional icon path (relative to extension root, e.g., "ai.svg").
+    /// Should be a small SVG icon for display in menus.
+    #[serde(default)]
+    pub icon: Option<String>,
+    /// Per-target configuration for archive-based installation.
+    /// The key format is "{os}-{arch}" where:
+    /// - os: "darwin" (macOS), "linux", "windows"
+    /// - arch: "aarch64" (arm64), "x86_64"
+    ///
+    /// Example:
+    /// ```toml
+    /// [agent_servers.myagent.targets.darwin-aarch64]
+    /// archive = "https://example.com/myagent-darwin-arm64.zip"
+    /// cmd = "./myagent"
+    /// args = ["--serve"]
+    /// sha256 = "abc123..."  # optional
+    /// ```
+    pub targets: HashMap<String, TargetConfig>,
+}
+
+#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
+pub struct TargetConfig {
+    /// URL to download the archive from (e.g., "https://github.com/owner/repo/releases/download/v1.0.0/myagent-darwin-arm64.zip")
+    pub archive: String,
+    /// Command to run (e.g., "./myagent" or "./myagent.exe")
+    pub cmd: String,
+    /// Command-line arguments to pass to the agent server.
+    #[serde(default)]
+    pub args: Vec<String>,
+    /// Optional SHA-256 hash of the archive for verification.
+    /// If not provided and the URL is a GitHub release, we'll attempt to fetch it from GitHub.
+    #[serde(default)]
+    pub sha256: Option<String>,
+}
+
 #[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
 pub enum ExtensionLibraryKind {
     Rust,
@@ -266,6 +310,7 @@ fn manifest_from_old_manifest(
             .collect(),
         language_servers: Default::default(),
         context_servers: BTreeMap::default(),
+        agent_servers: BTreeMap::default(),
         slash_commands: BTreeMap::default(),
         snippets: None,
         capabilities: Vec::new(),
@@ -298,6 +343,7 @@ mod tests {
             grammars: BTreeMap::default(),
             language_servers: BTreeMap::default(),
             context_servers: BTreeMap::default(),
+            agent_servers: BTreeMap::default(),
             slash_commands: BTreeMap::default(),
             snippets: None,
             capabilities: vec![],
@@ -404,4 +450,31 @@ mod tests {
         );
         assert!(manifest.allow_exec("docker", &["ps"]).is_err()); // wrong first arg
     }
+    #[test]
+    fn parse_manifest_with_agent_server_archive_launcher() {
+        let toml_src = r#"
+id = "example.agent-server-ext"
+name = "Agent Server Example"
+version = "1.0.0"
+schema_version = 0
+
+[agent_servers.foo]
+name = "Foo Agent"
+
+[agent_servers.foo.targets.linux-x86_64]
+archive = "https://example.com/agent-linux-x64.tar.gz"
+cmd = "./agent"
+args = ["--serve"]
+"#;
+
+        let manifest: ExtensionManifest = toml::from_str(toml_src).expect("manifest should parse");
+        assert_eq!(manifest.id.as_ref(), "example.agent-server-ext");
+        assert!(manifest.agent_servers.contains_key("foo"));
+        let entry = manifest.agent_servers.get("foo").unwrap();
+        assert!(entry.targets.contains_key("linux-x86_64"));
+        let target = entry.targets.get("linux-x86_64").unwrap();
+        assert_eq!(target.archive, "https://example.com/agent-linux-x64.tar.gz");
+        assert_eq!(target.cmd, "./agent");
+        assert_eq!(target.args, vec!["--serve"]);
+    }
 }

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

@@ -235,6 +235,21 @@ async fn copy_extension_resources(
         .with_context(|| "failed to copy icons")?;
     }
 
+    for (_, agent_entry) in &manifest.agent_servers {
+        if let Some(icon_path) = &agent_entry.icon {
+            let source_icon = extension_path.join(icon_path);
+            let dest_icon = output_dir.join(icon_path);
+
+            // Create parent directory if needed
+            if let Some(parent) = dest_icon.parent() {
+                fs::create_dir_all(parent)?;
+            }
+
+            fs::copy(&source_icon, &dest_icon)
+                .with_context(|| format!("failed to copy agent server icon '{}'", icon_path))?;
+        }
+    }
+
     if !manifest.languages.is_empty() {
         let output_languages_dir = output_dir.join("languages");
         fs::create_dir_all(&output_languages_dir)?;

crates/extension_host/src/capability_granter.rs πŸ”—

@@ -107,6 +107,7 @@ mod tests {
             grammars: BTreeMap::default(),
             language_servers: BTreeMap::default(),
             context_servers: BTreeMap::default(),
+            agent_servers: BTreeMap::default(),
             slash_commands: BTreeMap::default(),
             snippets: None,
             capabilities: vec![],

crates/extension_host/src/extension_store_test.rs πŸ”—

@@ -159,6 +159,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                         .collect(),
                         language_servers: BTreeMap::default(),
                         context_servers: BTreeMap::default(),
+                        agent_servers: BTreeMap::default(),
                         slash_commands: BTreeMap::default(),
                         snippets: None,
                         capabilities: Vec::new(),
@@ -189,6 +190,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                         grammars: BTreeMap::default(),
                         language_servers: BTreeMap::default(),
                         context_servers: BTreeMap::default(),
+                        agent_servers: BTreeMap::default(),
                         slash_commands: BTreeMap::default(),
                         snippets: None,
                         capabilities: Vec::new(),
@@ -368,6 +370,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
                 grammars: BTreeMap::default(),
                 language_servers: BTreeMap::default(),
                 context_servers: BTreeMap::default(),
+                agent_servers: BTreeMap::default(),
                 slash_commands: BTreeMap::default(),
                 snippets: None,
                 capabilities: Vec::new(),

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

@@ -66,6 +66,7 @@ pub fn init(cx: &mut App) {
                         ExtensionCategoryFilter::ContextServers => {
                             ExtensionProvides::ContextServers
                         }
+                        ExtensionCategoryFilter::AgentServers => ExtensionProvides::AgentServers,
                         ExtensionCategoryFilter::SlashCommands => ExtensionProvides::SlashCommands,
                         ExtensionCategoryFilter::IndexedDocsProviders => {
                             ExtensionProvides::IndexedDocsProviders
@@ -189,6 +190,7 @@ fn extension_provides_label(provides: ExtensionProvides) -> &'static str {
         ExtensionProvides::Grammars => "Grammars",
         ExtensionProvides::LanguageServers => "Language Servers",
         ExtensionProvides::ContextServers => "MCP Servers",
+        ExtensionProvides::AgentServers => "Agent Servers",
         ExtensionProvides::SlashCommands => "Slash Commands",
         ExtensionProvides::IndexedDocsProviders => "Indexed Docs Providers",
         ExtensionProvides::Snippets => "Snippets",

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

@@ -1,6 +1,7 @@
 use std::{
     any::Any,
     borrow::Borrow,
+    collections::HashSet,
     path::{Path, PathBuf},
     str::FromStr as _,
     sync::Arc,
@@ -126,13 +127,198 @@ enum AgentServerStoreState {
 pub struct AgentServerStore {
     state: AgentServerStoreState,
     external_agents: HashMap<ExternalAgentServerName, Box<dyn ExternalAgentServer>>,
+    agent_icons: HashMap<ExternalAgentServerName, SharedString>,
 }
 
 pub struct AgentServersUpdated;
 
 impl EventEmitter<AgentServersUpdated> for AgentServerStore {}
 
+#[cfg(test)]
+mod ext_agent_tests {
+    use super::*;
+    use std::fmt::Write as _;
+
+    // Helper to build a store in Collab mode so we can mutate internal maps without
+    // needing to spin up a full project environment.
+    fn collab_store() -> AgentServerStore {
+        AgentServerStore {
+            state: AgentServerStoreState::Collab,
+            external_agents: HashMap::default(),
+            agent_icons: HashMap::default(),
+        }
+    }
+
+    // A simple fake that implements ExternalAgentServer without needing async plumbing.
+    struct NoopExternalAgent;
+
+    impl ExternalAgentServer for NoopExternalAgent {
+        fn get_command(
+            &mut self,
+            _root_dir: Option<&str>,
+            _extra_env: HashMap<String, String>,
+            _status_tx: Option<watch::Sender<SharedString>>,
+            _new_version_available_tx: Option<watch::Sender<Option<String>>>,
+            _cx: &mut AsyncApp,
+        ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
+            Task::ready(Ok((
+                AgentServerCommand {
+                    path: PathBuf::from("noop"),
+                    args: Vec::new(),
+                    env: None,
+                },
+                "".to_string(),
+                None,
+            )))
+        }
+
+        fn as_any_mut(&mut self) -> &mut dyn Any {
+            self
+        }
+    }
+
+    #[test]
+    fn external_agent_server_name_display() {
+        let name = ExternalAgentServerName(SharedString::from("Ext: Tool"));
+        let mut s = String::new();
+        write!(&mut s, "{name}").unwrap();
+        assert_eq!(s, "Ext: Tool");
+    }
+
+    #[test]
+    fn sync_extension_agents_removes_previous_extension_entries() {
+        let mut store = collab_store();
+
+        // Seed with a couple of agents that will be replaced by extensions
+        store.external_agents.insert(
+            ExternalAgentServerName(SharedString::from("foo-agent")),
+            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
+        );
+        store.external_agents.insert(
+            ExternalAgentServerName(SharedString::from("bar-agent")),
+            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
+        );
+        store.external_agents.insert(
+            ExternalAgentServerName(SharedString::from("custom")),
+            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
+        );
+
+        // Simulate the removal phase: if we're syncing extensions that provide
+        // "foo-agent" and "bar-agent", those should be removed first
+        let extension_agent_names: HashSet<String> =
+            ["foo-agent".to_string(), "bar-agent".to_string()]
+                .into_iter()
+                .collect();
+
+        let keys_to_remove: Vec<_> = store
+            .external_agents
+            .keys()
+            .filter(|name| extension_agent_names.contains(name.0.as_ref()))
+            .cloned()
+            .collect();
+
+        for key in keys_to_remove {
+            store.external_agents.remove(&key);
+        }
+
+        // Only the custom entry should remain.
+        let remaining: Vec<_> = store
+            .external_agents
+            .keys()
+            .map(|k| k.0.to_string())
+            .collect();
+        assert_eq!(remaining, vec!["custom".to_string()]);
+    }
+}
+
 impl AgentServerStore {
+    /// Synchronizes extension-provided agent servers with the store.
+    pub fn sync_extension_agents<'a, I>(
+        &mut self,
+        manifests: I,
+        extensions_dir: PathBuf,
+        cx: &mut Context<Self>,
+    ) where
+        I: IntoIterator<Item = (&'a str, &'a extension::ExtensionManifest)>,
+    {
+        // Collect manifests first so we can iterate twice
+        let manifests: Vec<_> = manifests.into_iter().collect();
+
+        // Remove existing extension-provided agents by tracking which ones we're about to add
+        let extension_agent_names: HashSet<_> = manifests
+            .iter()
+            .flat_map(|(_, manifest)| manifest.agent_servers.keys().map(|k| k.to_string()))
+            .collect();
+
+        let keys_to_remove: Vec<_> = self
+            .external_agents
+            .keys()
+            .filter(|name| {
+                // Remove if it matches an extension agent name from any extension
+                extension_agent_names.contains(name.0.as_ref())
+            })
+            .cloned()
+            .collect();
+        for key in &keys_to_remove {
+            self.external_agents.remove(key);
+            self.agent_icons.remove(key);
+        }
+
+        // Insert agent servers from extension manifests
+        match &self.state {
+            AgentServerStoreState::Local {
+                project_environment,
+                fs,
+                http_client,
+                ..
+            } => {
+                for (ext_id, manifest) in manifests {
+                    for (agent_name, agent_entry) in &manifest.agent_servers {
+                        let display = SharedString::from(agent_entry.name.clone());
+
+                        // Store absolute icon path if provided, resolving symlinks for dev extensions
+                        if let Some(icon) = &agent_entry.icon {
+                            let icon_path = extensions_dir.join(ext_id).join(icon);
+                            // Canonicalize to resolve symlinks (dev extensions are symlinked)
+                            let absolute_icon_path = icon_path
+                                .canonicalize()
+                                .unwrap_or(icon_path)
+                                .to_string_lossy()
+                                .to_string();
+                            self.agent_icons.insert(
+                                ExternalAgentServerName(display.clone()),
+                                SharedString::from(absolute_icon_path),
+                            );
+                        }
+
+                        // Archive-based launcher (download from URL)
+                        self.external_agents.insert(
+                            ExternalAgentServerName(display),
+                            Box::new(LocalExtensionArchiveAgent {
+                                fs: fs.clone(),
+                                http_client: http_client.clone(),
+                                project_environment: project_environment.clone(),
+                                extension_id: Arc::from(ext_id),
+                                agent_id: agent_name.clone(),
+                                targets: agent_entry.targets.clone(),
+                                env: agent_entry.env.clone(),
+                            }) as Box<dyn ExternalAgentServer>,
+                        );
+                    }
+                }
+            }
+            _ => {
+                // Only local projects support local extension agents
+            }
+        }
+
+        cx.emit(AgentServersUpdated);
+    }
+
+    pub fn agent_icon(&self, name: &ExternalAgentServerName) -> Option<SharedString> {
+        self.agent_icons.get(name).cloned()
+    }
+
     pub fn init_remote(session: &AnyProtoClient) {
         session.add_entity_message_handler(Self::handle_external_agents_updated);
         session.add_entity_message_handler(Self::handle_loading_status_updated);
@@ -202,7 +388,7 @@ impl AgentServerStore {
                     .gemini
                     .as_ref()
                     .and_then(|settings| settings.ignore_system_version)
-                    .unwrap_or(true),
+                    .unwrap_or(false),
             }),
         );
         self.external_agents.insert(
@@ -279,7 +465,9 @@ impl AgentServerStore {
                 _subscriptions: [subscription],
             },
             external_agents: Default::default(),
+            agent_icons: Default::default(),
         };
+        if let Some(_events) = extension::ExtensionEvents::try_global(cx) {}
         this.agent_servers_settings_changed(cx);
         this
     }
@@ -288,7 +476,7 @@ impl AgentServerStore {
         // Set up the builtin agents here so they're immediately available in
         // remote projects--we know that the HeadlessProject on the other end
         // will have them.
-        let external_agents = [
+        let external_agents: [(ExternalAgentServerName, Box<dyn ExternalAgentServer>); 3] = [
             (
                 CLAUDE_CODE_NAME.into(),
                 Box::new(RemoteExternalAgentServer {
@@ -319,16 +507,15 @@ impl AgentServerStore {
                     new_version_available_tx: None,
                 }) as Box<dyn ExternalAgentServer>,
             ),
-        ]
-        .into_iter()
-        .collect();
+        ];
 
         Self {
             state: AgentServerStoreState::Remote {
                 project_id,
                 upstream_client,
             },
-            external_agents,
+            external_agents: external_agents.into_iter().collect(),
+            agent_icons: HashMap::default(),
         }
     }
 
@@ -336,6 +523,7 @@ impl AgentServerStore {
         Self {
             state: AgentServerStoreState::Collab,
             external_agents: Default::default(),
+            agent_icons: Default::default(),
         }
     }
 
@@ -392,7 +580,7 @@ impl AgentServerStore {
         envelope: TypedEnvelope<proto::GetAgentServerCommand>,
         mut cx: AsyncApp,
     ) -> Result<proto::AgentServerCommand> {
-        let (command, root_dir, login) = this
+        let (command, root_dir, login_command) = this
             .update(&mut cx, |this, cx| {
                 let AgentServerStoreState::Local {
                     downstream_client, ..
@@ -466,7 +654,7 @@ impl AgentServerStore {
                 .map(|env| env.into_iter().collect())
                 .unwrap_or_default(),
             root_dir: root_dir,
-            login: login.map(|login| login.to_proto()),
+            login: login_command.map(|cmd| cmd.to_proto()),
         })
     }
 
@@ -811,9 +999,7 @@ impl ExternalAgentServer for RemoteExternalAgentServer {
                     env: Some(command.env),
                 },
                 root_dir,
-                response
-                    .login
-                    .map(|login| task::SpawnInTerminal::from_proto(login)),
+                None,
             ))
         })
     }
@@ -959,7 +1145,7 @@ impl ExternalAgentServer for LocalClaudeCode {
                 .unwrap_or_default();
             env.insert("ANTHROPIC_API_KEY".into(), "".into());
 
-            let (mut command, login) = if let Some(mut custom_command) = custom_command {
+            let (mut command, login_command) = if let Some(mut custom_command) = custom_command {
                 env.extend(custom_command.env.unwrap_or_default());
                 custom_command.env = Some(env);
                 (custom_command, None)
@@ -1000,7 +1186,11 @@ impl ExternalAgentServer for LocalClaudeCode {
             };
 
             command.env.get_or_insert_default().extend(extra_env);
-            Ok((command, root_dir.to_string_lossy().into_owned(), login))
+            Ok((
+                command,
+                root_dir.to_string_lossy().into_owned(),
+                login_command,
+            ))
         })
     }
 
@@ -1080,10 +1270,15 @@ impl ExternalAgentServer for LocalCodex {
                         .into_iter()
                         .find(|asset| asset.name == asset_name)
                         .with_context(|| format!("no asset found matching `{asset_name:?}`"))?;
+                    // Strip "sha256:" prefix from digest if present (GitHub API format)
+                    let digest = asset
+                        .digest
+                        .as_deref()
+                        .and_then(|d| d.strip_prefix("sha256:").or(Some(d)));
                     ::http_client::github_download::download_server_binary(
                         &*http,
                         &asset.browser_download_url,
-                        asset.digest.as_deref(),
+                        digest,
                         &version_dir,
                         if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
                             AssetKind::Zip
@@ -1127,11 +1322,7 @@ impl ExternalAgentServer for LocalCodex {
 
 pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp";
 
-/// Assemble Codex release URL for the current OS/arch and the given version number.
-/// Returns None if the current target is unsupported.
-/// Example output:
-/// https://github.com/zed-industries/codex-acp/releases/download/v{version}/codex-acp-{version}-{arch}-{platform}.{ext}
-fn asset_name(version: &str) -> Option<String> {
+fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> {
     let arch = if cfg!(target_arch = "x86_64") {
         "x86_64"
     } else if cfg!(target_arch = "aarch64") {
@@ -1157,14 +1348,220 @@ fn asset_name(version: &str) -> Option<String> {
         "tar.gz"
     };
 
+    Some((arch, platform, ext))
+}
+
+fn asset_name(version: &str) -> Option<String> {
+    let (arch, platform, ext) = get_platform_info()?;
     Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
 }
 
+struct LocalExtensionArchiveAgent {
+    fs: Arc<dyn Fs>,
+    http_client: Arc<dyn HttpClient>,
+    project_environment: Entity<ProjectEnvironment>,
+    extension_id: Arc<str>,
+    agent_id: Arc<str>,
+    targets: HashMap<String, extension::TargetConfig>,
+    env: HashMap<String, String>,
+}
+
 struct LocalCustomAgent {
     project_environment: Entity<ProjectEnvironment>,
     command: AgentServerCommand,
 }
 
+impl ExternalAgentServer for LocalExtensionArchiveAgent {
+    fn get_command(
+        &mut self,
+        root_dir: Option<&str>,
+        extra_env: HashMap<String, String>,
+        _status_tx: Option<watch::Sender<SharedString>>,
+        _new_version_available_tx: Option<watch::Sender<Option<String>>>,
+        cx: &mut AsyncApp,
+    ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
+        let fs = self.fs.clone();
+        let http_client = self.http_client.clone();
+        let project_environment = self.project_environment.downgrade();
+        let extension_id = self.extension_id.clone();
+        let agent_id = self.agent_id.clone();
+        let targets = self.targets.clone();
+        let base_env = self.env.clone();
+
+        let root_dir: Arc<Path> = root_dir
+            .map(|root_dir| Path::new(root_dir))
+            .unwrap_or(paths::home_dir())
+            .into();
+
+        cx.spawn(async move |cx| {
+            // Get project environment
+            let mut env = project_environment
+                .update(cx, |project_environment, cx| {
+                    project_environment.get_local_directory_environment(
+                        &Shell::System,
+                        root_dir.clone(),
+                        cx,
+                    )
+                })?
+                .await
+                .unwrap_or_default();
+
+            // Merge manifest env and extra env
+            env.extend(base_env);
+            env.extend(extra_env);
+
+            let cache_key = format!("{}/{}", extension_id, agent_id);
+            let dir = paths::data_dir().join("external_agents").join(&cache_key);
+            fs.create_dir(&dir).await?;
+
+            // Determine platform key
+            let os = if cfg!(target_os = "macos") {
+                "darwin"
+            } else if cfg!(target_os = "linux") {
+                "linux"
+            } else if cfg!(target_os = "windows") {
+                "windows"
+            } else {
+                anyhow::bail!("unsupported OS");
+            };
+
+            let arch = if cfg!(target_arch = "aarch64") {
+                "aarch64"
+            } else if cfg!(target_arch = "x86_64") {
+                "x86_64"
+            } else {
+                anyhow::bail!("unsupported architecture");
+            };
+
+            let platform_key = format!("{}-{}", os, arch);
+            let target_config = targets.get(&platform_key).with_context(|| {
+                format!(
+                    "no target specified for platform '{}'. Available platforms: {}",
+                    platform_key,
+                    targets
+                        .keys()
+                        .map(|k| k.as_str())
+                        .collect::<Vec<_>>()
+                        .join(", ")
+                )
+            })?;
+
+            let archive_url = &target_config.archive;
+
+            // Use URL as version identifier for caching
+            // Hash the URL to get a stable directory name
+            use std::collections::hash_map::DefaultHasher;
+            use std::hash::{Hash, Hasher};
+            let mut hasher = DefaultHasher::new();
+            archive_url.hash(&mut hasher);
+            let url_hash = hasher.finish();
+            let version_dir = dir.join(format!("v_{:x}", url_hash));
+
+            if !fs.is_dir(&version_dir).await {
+                // Determine SHA256 for verification
+                let sha256 = if let Some(provided_sha) = &target_config.sha256 {
+                    // Use provided SHA256
+                    Some(provided_sha.clone())
+                } else if archive_url.starts_with("https://github.com/") {
+                    // Try to fetch SHA256 from GitHub API
+                    // Parse URL to extract repo and tag/file info
+                    // Format: https://github.com/owner/repo/releases/download/tag/file.zip
+                    if let Some(caps) = archive_url.strip_prefix("https://github.com/") {
+                        let parts: Vec<&str> = caps.split('/').collect();
+                        if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
+                            let repo = format!("{}/{}", parts[0], parts[1]);
+                            let tag = parts[4];
+                            let filename = parts[5..].join("/");
+
+                            // Try to get release info from GitHub
+                            if let Ok(release) = ::http_client::github::get_release_by_tag_name(
+                                &repo,
+                                tag,
+                                http_client.clone(),
+                            )
+                            .await
+                            {
+                                // Find matching asset
+                                if let Some(asset) =
+                                    release.assets.iter().find(|a| a.name == filename)
+                                {
+                                    // Strip "sha256:" prefix if present
+                                    asset.digest.as_ref().and_then(|d| {
+                                        d.strip_prefix("sha256:")
+                                            .map(|s| s.to_string())
+                                            .or_else(|| Some(d.clone()))
+                                    })
+                                } else {
+                                    None
+                                }
+                            } else {
+                                None
+                            }
+                        } else {
+                            None
+                        }
+                    } else {
+                        None
+                    }
+                } else {
+                    None
+                };
+
+                // Determine archive type from URL
+                let asset_kind = if archive_url.ends_with(".zip") {
+                    AssetKind::Zip
+                } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
+                    AssetKind::TarGz
+                } else {
+                    anyhow::bail!("unsupported archive type in URL: {}", archive_url);
+                };
+
+                // Download and extract
+                ::http_client::github_download::download_server_binary(
+                    &*http_client,
+                    archive_url,
+                    sha256.as_deref(),
+                    &version_dir,
+                    asset_kind,
+                )
+                .await?;
+            }
+
+            // Validate and resolve cmd path
+            let cmd = &target_config.cmd;
+            if cmd.contains("..") {
+                anyhow::bail!("command path cannot contain '..': {}", cmd);
+            }
+
+            let cmd_path = if cmd.starts_with("./") || cmd.starts_with(".\\") {
+                // Relative to extraction directory
+                version_dir.join(&cmd[2..])
+            } else {
+                // On PATH
+                anyhow::bail!("command must be relative (start with './'): {}", cmd);
+            };
+
+            anyhow::ensure!(
+                fs.is_file(&cmd_path).await,
+                "Missing command {} after extraction",
+                cmd_path.to_string_lossy()
+            );
+
+            let command = AgentServerCommand {
+                path: cmd_path,
+                args: target_config.args.clone(),
+                env: Some(env),
+            };
+
+            Ok((command, root_dir.to_string_lossy().into_owned(), None))
+        })
+    }
+
+    fn as_any_mut(&mut self) -> &mut dyn Any {
+        self
+    }
+}
+
 impl ExternalAgentServer for LocalCustomAgent {
     fn get_command(
         &mut self,
@@ -1203,42 +1600,6 @@ impl ExternalAgentServer for LocalCustomAgent {
     }
 }
 
-#[cfg(test)]
-mod tests {
-    #[test]
-    fn assembles_codex_release_url_for_current_target() {
-        let version_number = "0.1.0";
-
-        // This test fails the build if we are building a version of Zed
-        // which does not have a known build of codex-acp, to prevent us
-        // from accidentally doing a release on a new target without
-        // realizing that codex-acp support will not work on that target!
-        //
-        // Additionally, it verifies that our logic for assembling URLs
-        // correctly resolves to a known-good URL on each of our targets.
-        let allowed = [
-            "codex-acp-0.1.0-aarch64-apple-darwin.tar.gz",
-            "codex-acp-0.1.0-aarch64-pc-windows-msvc.tar.gz",
-            "codex-acp-0.1.0-aarch64-unknown-linux-gnu.tar.gz",
-            "codex-acp-0.1.0-x86_64-apple-darwin.tar.gz",
-            "codex-acp-0.1.0-x86_64-pc-windows-msvc.zip",
-            "codex-acp-0.1.0-x86_64-unknown-linux-gnu.tar.gz",
-        ];
-
-        if let Some(url) = super::asset_name(version_number) {
-            assert!(
-                allowed.contains(&url.as_str()),
-                "Assembled asset name {} not in allowed list",
-                url
-            );
-        } else {
-            panic!(
-                "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."
-            );
-        }
-    }
-}
-
 pub const GEMINI_NAME: &'static str = "gemini";
 pub const CLAUDE_CODE_NAME: &'static str = "claude";
 pub const CODEX_NAME: &'static str = "codex";
@@ -1331,3 +1692,200 @@ impl settings::Settings for AllAgentServersSettings {
         }
     }
 }
+
+#[cfg(test)]
+mod extension_agent_tests {
+    use super::*;
+    use gpui::TestAppContext;
+    use std::sync::Arc;
+
+    #[test]
+    fn extension_agent_constructs_proper_display_names() {
+        // Verify the display name format for extension-provided agents
+        let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent"));
+        assert!(name1.0.contains(": "));
+
+        let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent"));
+        assert_eq!(name2.0, "MyExt: MyAgent");
+
+        // Non-extension agents shouldn't have the separator
+        let custom = ExternalAgentServerName(SharedString::from("custom"));
+        assert!(!custom.0.contains(": "));
+    }
+
+    struct NoopExternalAgent;
+
+    impl ExternalAgentServer for NoopExternalAgent {
+        fn get_command(
+            &mut self,
+            _root_dir: Option<&str>,
+            _extra_env: HashMap<String, String>,
+            _status_tx: Option<watch::Sender<SharedString>>,
+            _new_version_available_tx: Option<watch::Sender<Option<String>>>,
+            _cx: &mut AsyncApp,
+        ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
+            Task::ready(Ok((
+                AgentServerCommand {
+                    path: PathBuf::from("noop"),
+                    args: Vec::new(),
+                    env: None,
+                },
+                "".to_string(),
+                None,
+            )))
+        }
+
+        fn as_any_mut(&mut self) -> &mut dyn Any {
+            self
+        }
+    }
+
+    #[test]
+    fn sync_removes_only_extension_provided_agents() {
+        let mut store = AgentServerStore {
+            state: AgentServerStoreState::Collab,
+            external_agents: HashMap::default(),
+            agent_icons: HashMap::default(),
+        };
+
+        // Seed with extension agents (contain ": ") and custom agents (don't contain ": ")
+        store.external_agents.insert(
+            ExternalAgentServerName(SharedString::from("Ext1: Agent1")),
+            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
+        );
+        store.external_agents.insert(
+            ExternalAgentServerName(SharedString::from("Ext2: Agent2")),
+            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
+        );
+        store.external_agents.insert(
+            ExternalAgentServerName(SharedString::from("custom-agent")),
+            Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
+        );
+
+        // Simulate removal phase
+        let keys_to_remove: Vec<_> = store
+            .external_agents
+            .keys()
+            .filter(|name| name.0.contains(": "))
+            .cloned()
+            .collect();
+
+        for key in keys_to_remove {
+            store.external_agents.remove(&key);
+        }
+
+        // Only custom-agent should remain
+        assert_eq!(store.external_agents.len(), 1);
+        assert!(
+            store
+                .external_agents
+                .contains_key(&ExternalAgentServerName(SharedString::from("custom-agent")))
+        );
+    }
+
+    #[test]
+    fn archive_launcher_constructs_with_all_fields() {
+        use extension::AgentServerManifestEntry;
+
+        let mut env = HashMap::default();
+        env.insert("GITHUB_TOKEN".into(), "secret".into());
+
+        let mut targets = HashMap::default();
+        targets.insert(
+            "darwin-aarch64".to_string(),
+            extension::TargetConfig {
+                archive:
+                    "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip"
+                        .into(),
+                cmd: "./agent".into(),
+                args: vec![],
+                sha256: None,
+            },
+        );
+
+        let _entry = AgentServerManifestEntry {
+            name: "GitHub Agent".into(),
+            targets,
+            env,
+            icon: None,
+        };
+
+        // Verify display name construction
+        let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent"));
+        assert_eq!(expected_name.0, "GitHub Agent");
+    }
+
+    #[gpui::test]
+    async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) {
+        let fs = fs::FakeFs::new(cx.background_executor.clone());
+        let http_client = http_client::FakeHttpClient::with_404_response();
+        let project_environment = cx.new(|cx| crate::ProjectEnvironment::new(None, cx));
+
+        let agent = LocalExtensionArchiveAgent {
+            fs,
+            http_client,
+            project_environment,
+            extension_id: Arc::from("my-extension"),
+            agent_id: Arc::from("my-agent"),
+            targets: {
+                let mut map = HashMap::default();
+                map.insert(
+                    "darwin-aarch64".to_string(),
+                    extension::TargetConfig {
+                        archive: "https://example.com/my-agent-darwin-arm64.zip".into(),
+                        cmd: "./my-agent".into(),
+                        args: vec!["--serve".into()],
+                        sha256: None,
+                    },
+                );
+                map
+            },
+            env: {
+                let mut map = HashMap::default();
+                map.insert("PORT".into(), "8080".into());
+                map
+            },
+        };
+
+        // Verify agent is properly constructed
+        assert_eq!(agent.extension_id.as_ref(), "my-extension");
+        assert_eq!(agent.agent_id.as_ref(), "my-agent");
+        assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string()));
+        assert!(agent.targets.contains_key("darwin-aarch64"));
+    }
+
+    #[test]
+    fn sync_extension_agents_registers_archive_launcher() {
+        use extension::AgentServerManifestEntry;
+
+        let expected_name = ExternalAgentServerName(SharedString::from("Release Agent"));
+        assert_eq!(expected_name.0, "Release Agent");
+
+        // Verify the manifest entry structure for archive-based installation
+        let mut env = HashMap::default();
+        env.insert("API_KEY".into(), "secret".into());
+
+        let mut targets = HashMap::default();
+        targets.insert(
+            "linux-x86_64".to_string(),
+            extension::TargetConfig {
+                archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(),
+                cmd: "./release-agent".into(),
+                args: vec!["serve".into()],
+                sha256: None,
+            },
+        );
+
+        let manifest_entry = AgentServerManifestEntry {
+            name: "Release Agent".into(),
+            targets: targets.clone(),
+            env,
+            icon: None,
+        };
+
+        // Verify target config is present
+        assert!(manifest_entry.targets.contains_key("linux-x86_64"));
+        let target = manifest_entry.targets.get("linux-x86_64").unwrap();
+        assert_eq!(target.cmd, "./release-agent");
+    }
+}

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

@@ -40,7 +40,7 @@ use crate::{
     git_store::GitStore,
     lsp_store::{SymbolLocation, log_store::LogKind},
 };
-pub use agent_server_store::{AgentServerStore, AgentServersUpdated};
+pub use agent_server_store::{AgentServerStore, AgentServersUpdated, ExternalAgentServerName};
 pub use git_store::{
     ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate,
     git_traversal::{ChildEntriesGitIter, GitEntry, GitEntryRef, GitTraversal},

crates/rpc/src/extension.rs πŸ”—

@@ -42,6 +42,7 @@ pub enum ExtensionProvides {
     Grammars,
     LanguageServers,
     ContextServers,
+    AgentServers,
     SlashCommands,
     IndexedDocsProviders,
     Snippets,

crates/ui/src/components/context_menu.rs πŸ”—

@@ -47,6 +47,7 @@ pub struct ContextMenuEntry {
     toggle: Option<(IconPosition, bool)>,
     label: SharedString,
     icon: Option<IconName>,
+    custom_icon_path: Option<SharedString>,
     icon_position: IconPosition,
     icon_size: IconSize,
     icon_color: Option<Color>,
@@ -66,6 +67,7 @@ impl ContextMenuEntry {
             toggle: None,
             label: label.into(),
             icon: None,
+            custom_icon_path: None,
             icon_position: IconPosition::Start,
             icon_size: IconSize::Small,
             icon_color: None,
@@ -90,6 +92,12 @@ impl ContextMenuEntry {
         self
     }
 
+    pub fn custom_icon_path(mut self, path: impl Into<SharedString>) -> Self {
+        self.custom_icon_path = Some(path.into());
+        self.icon = None; // Clear IconName if custom path is set
+        self
+    }
+
     pub fn icon_position(mut self, position: IconPosition) -> Self {
         self.icon_position = position;
         self
@@ -387,6 +395,7 @@ impl ContextMenu {
             label: label.into(),
             handler: Rc::new(move |_, window, cx| handler(window, cx)),
             icon: None,
+            custom_icon_path: None,
             icon_position: IconPosition::End,
             icon_size: IconSize::Small,
             icon_color: None,
@@ -415,6 +424,7 @@ impl ContextMenu {
             label: label.into(),
             handler: Rc::new(move |_, window, cx| handler(window, cx)),
             icon: None,
+            custom_icon_path: None,
             icon_position: IconPosition::End,
             icon_size: IconSize::Small,
             icon_color: None,
@@ -443,6 +453,7 @@ impl ContextMenu {
             label: label.into(),
             handler: Rc::new(move |_, window, cx| handler(window, cx)),
             icon: None,
+            custom_icon_path: None,
             icon_position: IconPosition::End,
             icon_size: IconSize::Small,
             icon_color: None,
@@ -470,6 +481,7 @@ impl ContextMenu {
             label: label.into(),
             handler: Rc::new(move |_, window, cx| handler(window, cx)),
             icon: None,
+            custom_icon_path: None,
             icon_position: position,
             icon_size: IconSize::Small,
             icon_color: None,
@@ -528,6 +540,7 @@ impl ContextMenu {
                 window.dispatch_action(action.boxed_clone(), cx);
             }),
             icon: None,
+            custom_icon_path: None,
             icon_position: IconPosition::End,
             icon_size: IconSize::Small,
             icon_color: None,
@@ -558,6 +571,7 @@ impl ContextMenu {
                 window.dispatch_action(action.boxed_clone(), cx);
             }),
             icon: None,
+            custom_icon_path: None,
             icon_size: IconSize::Small,
             icon_position: IconPosition::End,
             icon_color: None,
@@ -578,6 +592,7 @@ impl ContextMenu {
             action: Some(action.boxed_clone()),
             handler: Rc::new(move |_, window, cx| window.dispatch_action(action.boxed_clone(), cx)),
             icon: Some(IconName::ArrowUpRight),
+            custom_icon_path: None,
             icon_size: IconSize::XSmall,
             icon_position: IconPosition::End,
             icon_color: None,
@@ -897,6 +912,7 @@ impl ContextMenu {
             label,
             handler,
             icon,
+            custom_icon_path,
             icon_position,
             icon_size,
             icon_color,
@@ -927,7 +943,29 @@ impl ContextMenu {
             Color::Default
         };
 
-        let label_element = if let Some(icon_name) = icon {
+        let label_element = if let Some(custom_path) = custom_icon_path {
+            h_flex()
+                .gap_1p5()
+                .when(
+                    *icon_position == IconPosition::Start && toggle.is_none(),
+                    |flex| {
+                        flex.child(
+                            Icon::from_path(custom_path.clone())
+                                .size(*icon_size)
+                                .color(icon_color),
+                        )
+                    },
+                )
+                .child(Label::new(label.clone()).color(label_color).truncate())
+                .when(*icon_position == IconPosition::End, |flex| {
+                    flex.child(
+                        Icon::from_path(custom_path.clone())
+                            .size(*icon_size)
+                            .color(icon_color),
+                    )
+                })
+                .into_any_element()
+        } else if let Some(icon_name) = icon {
             h_flex()
                 .gap_1p5()
                 .when(

crates/zed_actions/src/lib.rs πŸ”—

@@ -70,6 +70,7 @@ pub enum ExtensionCategoryFilter {
     Grammars,
     LanguageServers,
     ContextServers,
+    AgentServers,
     SlashCommands,
     IndexedDocsProviders,
     Snippets,