diff --git a/Cargo.lock b/Cargo.lock index 412e52f3..518916aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -391,7 +391,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -811,6 +811,30 @@ dependencies = [ "serde_json", ] +[[package]] +name = "elizacp" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f0076b9b1a59653610ab02ef229f71272064c0b47ffcaec734063ca01ee7236" +dependencies = [ + "agent-client-protocol-schema", + "anyhow", + "clap", + "futures", + "rand 0.9.2", + "regex", + "rmcp", + "sacp", + "sacp-tokio", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1100,8 +1124,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link", - "windows-result", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -1334,7 +1358,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -1859,6 +1883,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "normpath" version = "1.5.0" @@ -2009,7 +2045,7 @@ dependencies = [ "libc", "redox_syscall 0.5.18", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2211,6 +2247,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process-wrap" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5fd83ab7fa55fd06f5e665e3fc52b8bca451c0486b8ea60ad649cd1c10a5da" +dependencies = [ + "futures", + "indexmap", + "nix", + "tokio", + "tracing", + "windows", +] + [[package]] name = "pulldown-cmark" version = "0.10.3" @@ -2461,12 +2511,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots", ] @@ -2495,14 +2547,19 @@ dependencies = [ "base64", "chrono", "futures", + "http", "pastey", "pin-project-lite", + "process-wrap", + "reqwest", "rmcp-macros", "schemars", "serde", "serde_json", + "sse-stream", "thiserror 2.0.17", "tokio", + "tokio-stream", "tokio-util", "tracing", ] @@ -3014,6 +3071,19 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +[[package]] +name = "sse-stream" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3089,10 +3159,11 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "symposium-acp-agent" -version = "1.0.0-alpha.3" +version = "1.0.0" dependencies = [ "anyhow", "clap", + "elizacp", "sacp", "sacp-conductor", "sacp-tokio", @@ -3105,7 +3176,7 @@ dependencies = [ [[package]] name = "symposium-acp-proxy" -version = "1.0.0-alpha.3" +version = "1.0.0" dependencies = [ "anyhow", "chrono", @@ -3434,6 +3505,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -3878,6 +3960,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.83" @@ -3952,6 +4047,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -3960,9 +4090,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", ] [[package]] @@ -3987,19 +4128,53 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -4008,7 +4183,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4053,7 +4228,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4093,7 +4268,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -4104,6 +4279,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" diff --git a/md/SUMMARY.md b/md/SUMMARY.md index 4e07b469..7cad5b64 100644 --- a/md/SUMMARY.md +++ b/md/SUMMARY.md @@ -29,6 +29,7 @@ - [Testing](./design/vscode-extension/testing.md) - [Testing Implementation](./design/vscode-extension/testing-implementation.md) - [Packaging](./design/vscode-extension/packaging.md) + - [Agent Registry](./design/vscode-extension/agent-registry.md) - [Implementation Status](./design/vscode-extension/implementation-status.md) # References diff --git a/md/design/vscode-extension/agent-registry.md b/md/design/vscode-extension/agent-registry.md new file mode 100644 index 00000000..630ca7d7 --- /dev/null +++ b/md/design/vscode-extension/agent-registry.md @@ -0,0 +1,163 @@ +# Agent Registry Integration + +The VSCode extension supports multiple ACP-compatible agents. Users can select from built-in defaults or add agents from the [ACP Agent Registry](https://github.com/agentclientprotocol/registry). + +## Agent Configuration + +Agent configurations are stored in VSCode settings. Each agent is represented as an `AgentConfig` object: + +```typescript +interface AgentConfig { + // Required fields + id: string; + distribution: { + npx?: { package: string; args?: string[] }; + pipx?: { package: string; args?: string[] }; + binary?: { + [platform: string]: { // e.g., "darwin-aarch64", "linux-x86_64" + url: string; + executable: string; + args?: string[]; + }; + }; + }; + + // Optional fields (populated from registry if imported) + name?: string; // display name, defaults to id + version?: string; + description?: string; + // ... other registry fields as needed + + // Source tracking + _source?: "registry" | "custom"; // defaults to "custom" if omitted +} +``` + +### Built-in Agents + +Three agents ship as defaults with `_source: "custom"`: + +```json +[ + { + "id": "claude-code", + "name": "Claude Code", + "distribution": { "npx": { "package": "@zed-industries/claude-code-acp@latest" } } + }, + { + "id": "codex", + "name": "Codex", + "distribution": { "npx": { "package": "@zed-industries/codex-acp@latest" } } + }, + { + "id": "gemini", + "name": "Gemini", + "distribution": { "npx": { "package": "@google/gemini-cli@latest", "args": ["--experimental-acp"] } } + } +] +``` + +### Registry-Imported Agents + +When a user imports an agent from the registry, the full registry entry is stored with `_source: "registry"`: + +```json +{ + "id": "gemini", + "name": "Gemini CLI", + "version": "0.22.3", + "description": "Google's official CLI for Gemini", + "_source": "registry", + "distribution": { + "npx": { "package": "@google/gemini-cli@0.22.3", "args": ["--experimental-acp"] } + } +} +``` + +### Custom Agents + +Users can manually add agents with minimal configuration: + +```json +{ + "id": "my-agent", + "distribution": { "npx": { "package": "my-agent-package" } } +} +``` + +## Registry Sync + +For agents with `_source: "registry"`, the extension checks for updates and applies them automatically. Agents removed from the registry are left unchanged—the configuration still works, it just won't receive future updates. + +The registry URL: +``` +https://github.com/agentclientprotocol/registry/releases/latest/download/registry.json +``` + +## Spawning an Agent + +At spawn time, the extension resolves the distribution to a command: + +1. If `distribution.npx` exists → `npx -y {package} {args...}` +2. Else if `distribution.pipx` exists → `pipx run {package} {args...}` +3. Else if `distribution.binary[currentPlatform]` exists: + - Check `~/.symposium/bin/{id}/{version}/` for cached binary + - If not present, download and extract from `url` + - Execute `{cache-path}/{executable} {args...}` +4. Else → error (no compatible distribution for this platform) + +### Platform Detection + +Map from Node.js to registry platform keys: + +| `process.platform` | `process.arch` | Registry Key | +|--------------------|----------------|--------------| +| `darwin` | `arm64` | `darwin-aarch64` | +| `darwin` | `x64` | `darwin-x86_64` | +| `linux` | `x64` | `linux-x86_64` | +| `linux` | `arm64` | `linux-aarch64` | +| `win32` | `x64` | `windows-x86_64` | + +## User Interface + +### Agent Selection + +The extension provides UI for: +- Viewing configured agents +- Selecting the active agent +- Adding agents from registry (opens picker dialog) +- Removing agents (built-ins can be removed but will reappear on reset) + +### Add from Registry Flow + +The dialog only shows agents not already in the user's configuration: + +``` +┌─────────────────────────────────────────┐ +│ Add Agent from Registry │ +├─────────────────────────────────────────┤ +│ ○ Auggie CLI │ +│ Augment Code's software agent │ +│ │ +│ ○ Mistral Vibe │ +│ Mistral's open-source coding asst │ +│ │ +│ ○ OpenCode │ +│ Open source coding agent │ +│ │ +│ ○ Qwen Code │ +│ Alibaba's Qwen coding assistant │ +│ │ +│ [Cancel] [Add] │ +└─────────────────────────────────────────┘ +``` + +## Open Questions + +- **When to refresh registry agents**: When should we check for updates to `_source: "registry"` agents? Options include: on extension activation, first time an agent is used in a session, manual refresh only. One proposal: check the first time the user opens a tab with a given agent during a session. + +- **Registry caching**: Should we cache `registry.json` locally for offline "Add from registry" support? + +## Decisions + +- **Binary cleanup**: Delete old versions when downloading a new one. No accumulation. diff --git a/src/symposium-acp-agent/Cargo.toml b/src/symposium-acp-agent/Cargo.toml index 11500d0a..4eed3815 100644 --- a/src/symposium-acp-agent/Cargo.toml +++ b/src/symposium-acp-agent/Cargo.toml @@ -25,3 +25,6 @@ clap = { workspace = true } # Symposium components symposium-acp-proxy = { path = "../symposium-acp-proxy", version = "1.0.0" } symposium-ferris = { path = "../symposium-ferris", version = "1.0.0" } + +# Built-in agents +elizacp.workspace = true diff --git a/src/symposium-acp-agent/README.md b/src/symposium-acp-agent/README.md index 5778567e..406ffd39 100644 --- a/src/symposium-acp-agent/README.md +++ b/src/symposium-acp-agent/README.md @@ -25,20 +25,10 @@ Wrap Claude Code: symposium-acp-agent -- npx -y @zed-industries/claude-code-acp ``` -Disable optional components: -```bash -symposium-acp-agent --no-sparkle -- npx -y @zed-industries/claude-code-acp -``` - -## Options - -- `--no-sparkle` - Disable Sparkle integration -- `--no-crate-researcher` - Disable Rust crate source research - ## Components The agent includes all Symposium components: -- **Rust Crate Sources** - Research Rust crate source code via sub-agent pattern +- **Ferris** - Rust development tools (crate sources, rust researcher) - **Sparkle** - AI collaboration identity framework ## Documentation diff --git a/src/symposium-acp-agent/src/main.rs b/src/symposium-acp-agent/src/main.rs index beb9fc1e..3547385c 100644 --- a/src/symposium-acp-agent/src/main.rs +++ b/src/symposium-acp-agent/src/main.rs @@ -6,12 +6,14 @@ //! //! Usage: //! symposium-acp-agent [OPTIONS] -- [agent-args...] +//! symposium-acp-agent eliza //! //! Example: //! symposium-acp-agent -- npx -y @zed-industries/claude-code-acp +//! symposium-acp-agent eliza use anyhow::Result; -use clap::Parser; +use clap::{Parser, Subcommand}; use sacp::Component; use sacp_tokio::AcpAgent; use std::path::PathBuf; @@ -25,6 +27,9 @@ use std::path::PathBuf; and it provides Symposium's capabilities on top of the underlying agent." )] struct Cli { + #[command(subcommand)] + command: Option, + /// Enable or disable Sparkle integration (default: yes) #[arg(long, default_value = "yes", value_parser = parse_yes_no)] sparkle: bool, @@ -53,10 +58,16 @@ struct Cli { log: Option, /// The agent command and arguments (e.g., npx -y @zed-industries/claude-code-acp) - #[arg(last = true, required = true, num_args = 1..)] + #[arg(last = true, num_args = 1..)] agent: Vec, } +#[derive(Subcommand, Debug)] +enum Command { + /// Run the built-in Eliza agent (useful for testing) + Eliza, +} + fn parse_yes_no(s: &str) -> Result { match s.to_lowercase().as_str() { "yes" | "true" | "1" => Ok(true), @@ -93,26 +104,42 @@ async fn main() -> Result<()> { .init(); } - // Build a shell command string from the args - let agent: AcpAgent = AcpAgent::from_args(&cli.agent)?; - tracing::debug!("agent: {:?}", agent.server()); - - // Run Symposium with the agent as the downstream component - let ferris_config = build_ferris_config(cli.ferris, &cli.ferris_tools); - - let mut symposium = symposium_acp_proxy::Symposium::new() - .sparkle(cli.sparkle) - .ferris(ferris_config) - .cargo(cli.cargo); - - if let Some(trace_dir) = cli.trace_dir { - symposium = symposium.trace_dir(trace_dir); + match cli.command { + Some(Command::Eliza) => { + // Run the built-in Eliza agent directly (no Symposium wrapping) + elizacp::ElizaAgent::new() + .serve(sacp_tokio::Stdio::new()) + .await?; + } + None => { + // Run with a downstream agent + if cli.agent.is_empty() { + anyhow::bail!( + "No agent command provided. Use -- or 'eliza' subcommand." + ); + } + + let agent: AcpAgent = AcpAgent::from_args(&cli.agent)?; + tracing::debug!("agent: {:?}", agent.server()); + + // Run Symposium with the agent as the downstream component + let ferris_config = build_ferris_config(cli.ferris, &cli.ferris_tools); + + let mut symposium = symposium_acp_proxy::Symposium::new() + .sparkle(cli.sparkle) + .ferris(ferris_config) + .cargo(cli.cargo); + + if let Some(trace_dir) = cli.trace_dir { + symposium = symposium.trace_dir(trace_dir); + } + + symposium + .with_agent(agent) + .serve(sacp_tokio::Stdio::new()) + .await?; + } } - symposium - .with_agent(agent) - .serve(sacp_tokio::Stdio::new()) - .await?; - Ok(()) } diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json index ab8a8182..b3546090 100644 --- a/vscode-extension/package-lock.json +++ b/vscode-extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "symposium", - "version": "10.0.0-alpha.2", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "symposium", - "version": "10.0.0-alpha.2", + "version": "1.0.0", "license": "MIT", "dependencies": { "@agentclientprotocol/sdk": "^0.5.1", diff --git a/vscode-extension/package.json b/vscode-extension/package.json index b09ad582..6b57bfe7 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -40,25 +40,97 @@ ".*": { "type": "object", "properties": { - "command": { + "name": { "type": "string", - "description": "Command to launch the agent" + "description": "Display name for the agent" }, - "args": { - "type": "array", - "items": { - "type": "string" - }, - "default": [], - "description": "Arguments to pass to the agent command" + "version": { + "type": "string", + "description": "Version of the agent" + }, + "description": { + "type": "string", + "description": "Description of the agent" }, - "env": { + "distribution": { "type": "object", - "additionalProperties": { - "type": "string" - }, - "default": {}, - "description": "Environment variables for the agent process" + "description": "Distribution methods for spawning the agent", + "properties": { + "npx": { + "type": "object", + "properties": { + "package": { + "type": "string", + "description": "NPM package name" + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional arguments" + } + }, + "required": [ + "package" + ] + }, + "pipx": { + "type": "object", + "properties": { + "package": { + "type": "string", + "description": "PyPI package name" + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional arguments" + } + }, + "required": [ + "package" + ] + }, + "binary": { + "type": "object", + "description": "Binary distributions keyed by platform (e.g., darwin-aarch64, linux-x86_64)", + "additionalProperties": { + "type": "object", + "properties": { + "archive": { + "type": "string", + "description": "Download URL for the binary archive" + }, + "cmd": { + "type": "string", + "description": "Command to run (e.g., ./opencode)" + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional arguments" + } + }, + "required": [ + "archive", + "cmd" + ] + } + } + } + }, + "_source": { + "type": "string", + "enum": [ + "registry", + "custom" + ], + "description": "Source of the agent configuration (registry = auto-updated, custom = user-defined)" }, "bypassPermissions": { "type": "boolean", @@ -67,31 +139,17 @@ } }, "required": [ - "command" + "distribution" ] } }, - "default": { - "ElizACP": { - "command": "elizacp", - "args": [], - "env": {} - }, - "Claude Code": { - "command": "npx", - "args": [ - "-y", - "@zed-industries/claude-code-acp@latest" - ], - "env": {} - } - }, - "description": "Configured ACP agents (keyed by agent name)" + "default": {}, + "description": "Additional ACP agents (keyed by agent ID). Built-in agents (zed-claude-code, zed-codex, google-gemini) are always available." }, - "symposium.currentAgent": { + "symposium.currentAgentId": { "type": "string", - "default": "Claude Code", - "description": "Name of the currently selected agent" + "default": "zed-claude-code", + "description": "ID of the currently selected agent" }, "symposium.traceDir": { "type": "string", @@ -115,16 +173,6 @@ "type": "boolean", "default": false, "description": "When enabled, Enter adds a newline and Shift+Enter (or Cmd+Enter on Mac) sends the prompt. When disabled (default), Enter sends and Shift+Enter adds a newline." - }, - "symposium.enableSparkle": { - "type": "boolean", - "default": true, - "description": "Enable the Sparkle component for collaborative AI identity and memory." - }, - "symposium.enableCrateResearcher": { - "type": "boolean", - "default": true, - "description": "Enable the Rust Crate Researcher component for Rust crate documentation lookup." } } }, @@ -136,6 +184,10 @@ { "command": "symposium.discussSelection", "title": "Discuss in Symposium" + }, + { + "command": "symposium.addAgentFromRegistry", + "title": "Symposium: Add Agent from Registry" } ], "viewsContainers": { diff --git a/vscode-extension/src/acpAgentActor.ts b/vscode-extension/src/acpAgentActor.ts index 35668e58..19d0a287 100644 --- a/vscode-extension/src/acpAgentActor.ts +++ b/vscode-extension/src/acpAgentActor.ts @@ -10,6 +10,7 @@ import { Writable, Readable } from "stream"; import * as acp from "@agentclientprotocol/sdk"; import * as vscode from "vscode"; import { AgentConfiguration } from "./agentConfiguration"; +import { getAgentById, resolveDistribution } from "./agentRegistry"; import { logger } from "./extension"; /** @@ -216,34 +217,18 @@ export class AcpAgentActor { const vsConfig = vscode.workspace.getConfiguration("symposium"); // Get the agent definition - const agents = vsConfig.get< - Record< - string, - { command: string; args?: string[]; env?: Record } - > - >("agents", {}); - const agent = agents[config.agentName]; + const agent = getAgentById(config.agentId); if (!agent) { throw new Error( - `Agent "${config.agentName}" not found in configured agents`, + `Agent "${config.agentId}" not found in configured agents`, ); } - // Build the agent command with its arguments - const agentCmd = agent.command; - const agentArgs = agent.args || []; + // Resolve distribution to command and args + const resolved = await resolveDistribution(agent); - // Build conductor arguments: [--trace-dir ] -- [agent-args...] - const conductorArgs: string[] = []; - - // Add trace directory if configured - const traceDir = vsConfig.get("traceDir", ""); - if (traceDir) { - conductorArgs.push("--trace-dir", traceDir); - } - - // Add log level if configured, or inherit from general logLevel if set to debug + // Get log level if configured let agentLogLevel = vsConfig.get("agentLogLevel", ""); if (!agentLogLevel) { const generalLogLevel = vsConfig.get("logLevel", "error"); @@ -251,30 +236,39 @@ export class AcpAgentActor { agentLogLevel = "debug"; } } + + // Build the spawn command and args + const spawnArgs: string[] = []; + if (agentLogLevel) { - conductorArgs.push("--log", agentLogLevel); + spawnArgs.push("--log", agentLogLevel); } - // Add component disable flags if components are disabled - if (!config.enableSparkle) { - conductorArgs.push("--no-sparkle"); - } - if (!config.enableCrateResearcher) { - conductorArgs.push("--no-crate-researcher"); - } + if (resolved.isSymposiumBuiltin) { + // Symposium builtin (e.g., eliza) - run conductor with subcommand directly + spawnArgs.push(resolved.command, ...resolved.args); + } else { + // External agent - wrap with conductor + const traceDir = vsConfig.get("traceDir", ""); + if (traceDir) { + spawnArgs.push("--trace-dir", traceDir); + } - conductorArgs.push("--", agentCmd, ...agentArgs); + spawnArgs.push("--", resolved.command, ...resolved.args); + } logger.important("agent", "Spawning ACP agent", { command: conductorCommand, - args: conductorArgs, + args: spawnArgs, }); - // Merge environment variables - const env = agent.env ? { ...process.env, ...agent.env } : process.env; + // Merge environment variables (from resolved distribution if any) + const env = resolved.env + ? { ...process.env, ...resolved.env } + : process.env; // Spawn the agent process - this.agentProcess = spawn(conductorCommand, conductorArgs, { + this.agentProcess = spawn(conductorCommand, spawnArgs, { stdio: ["pipe", "pipe", "pipe"], env: env as NodeJS.ProcessEnv, }); diff --git a/vscode-extension/src/agentConfiguration.ts b/vscode-extension/src/agentConfiguration.ts index 6d5400c4..9e6a39d5 100644 --- a/vscode-extension/src/agentConfiguration.ts +++ b/vscode-extension/src/agentConfiguration.ts @@ -1,31 +1,28 @@ import * as vscode from "vscode"; +import { + getAgentById, + getCurrentAgentId, + DEFAULT_AGENT_ID, +} from "./agentRegistry"; /** * AgentConfiguration - Identifies a unique agent setup * - * Consists of the base agent name and workspace folder. + * Consists of the agent ID and workspace folder. * Tabs with the same configuration can share an ACP agent process. */ export class AgentConfiguration { constructor( - public readonly agentName: string, + public readonly agentId: string, public readonly workspaceFolder: vscode.WorkspaceFolder, - public readonly enableSparkle: boolean = true, - public readonly enableCrateResearcher: boolean = true, ) {} /** * Get a unique key for this configuration */ key(): string { - const components = [ - this.enableSparkle ? "sparkle" : "", - this.enableCrateResearcher ? "crate-researcher" : "", - ] - .filter((c) => c) - .join("+"); - return `${this.agentName}:${this.workspaceFolder.uri.fsPath}:${components}`; + return `${this.agentId}:${this.workspaceFolder.uri.fsPath}`; } /** @@ -39,15 +36,8 @@ export class AgentConfiguration { * Get a human-readable description */ describe(): string { - const components = [ - this.enableSparkle ? "Sparkle" : null, - this.enableCrateResearcher ? "Rust Crate Researcher" : null, - ].filter((c) => c !== null); - - if (components.length === 0) { - return this.agentName; - } - return `${this.agentName} + ${components.join(" + ")}`; + const agent = getAgentById(this.agentId); + return agent?.name ?? this.agentId; } /** @@ -57,17 +47,8 @@ export class AgentConfiguration { static async fromSettings( workspaceFolder?: vscode.WorkspaceFolder, ): Promise { - const config = vscode.workspace.getConfiguration("symposium"); - - // Get current agent - const currentAgentName = config.get("currentAgent", "Claude Code"); - - // Get component settings - const enableSparkle = config.get("enableSparkle", true); - const enableCrateResearcher = config.get( - "enableCrateResearcher", - true, - ); + // Get current agent ID + const currentAgentId = getCurrentAgentId(); // Determine workspace folder let folder = workspaceFolder; @@ -89,11 +70,6 @@ export class AgentConfiguration { } } - return new AgentConfiguration( - currentAgentName, - folder, - enableSparkle, - enableCrateResearcher, - ); + return new AgentConfiguration(currentAgentId, folder); } } diff --git a/vscode-extension/src/agentRegistry.ts b/vscode-extension/src/agentRegistry.ts new file mode 100644 index 00000000..cad996f3 --- /dev/null +++ b/vscode-extension/src/agentRegistry.ts @@ -0,0 +1,580 @@ +/** + * Agent Registry - Types and built-in agent definitions + * + * Supports multiple distribution methods (npx, pipx, binary) and + * merges built-in agents with user-configured agents from settings. + */ + +const REGISTRY_URL = + "https://github.com/agentclientprotocol/registry/releases/latest/download/registry.json"; + +import * as vscode from "vscode"; +import * as os from "os"; +import * as path from "path"; + +/** + * Distribution methods for spawning an agent + */ +export interface NpxDistribution { + package: string; + args?: string[]; +} + +export interface PipxDistribution { + package: string; + args?: string[]; +} + +export interface BinaryDistribution { + archive: string; + cmd: string; + args?: string[]; +} + +export interface SymposiumDistribution { + subcommand: string; + args?: string[]; +} + +export interface Distribution { + npx?: NpxDistribution; + pipx?: PipxDistribution; + binary?: Record; // keyed by platform, e.g., "darwin-aarch64" + symposium?: SymposiumDistribution; // built-in to symposium-acp-agent +} + +/** + * Agent configuration - matches registry format + */ +export interface AgentConfig { + id: string; + distribution: Distribution; + name?: string; + version?: string; + description?: string; + _source?: "registry" | "custom"; +} + +/** + * Settings format - object keyed by agent id (id is implicit in key) + */ +export type AgentSettingsEntry = Omit; +export type AgentSettings = Record; + +/** + * Built-in agents - these are always available unless overridden in settings + */ +export const BUILT_IN_AGENTS: AgentConfig[] = [ + { + id: "zed-claude-code", + name: "Claude Code", + distribution: { + npx: { package: "@zed-industries/claude-code-acp@latest" }, + }, + _source: "custom", + }, + { + id: "zed-codex", + name: "Codex", + distribution: { + npx: { package: "@zed-industries/codex-acp@latest" }, + }, + _source: "custom", + }, + { + id: "google-gemini", + name: "Gemini", + distribution: { + npx: { + package: "@google/gemini-cli@latest", + args: ["--experimental-acp"], + }, + }, + _source: "custom", + }, + { + id: "elizacp", + name: "ElizACP", + description: "Built-in Eliza agent for testing", + distribution: { + symposium: { subcommand: "eliza" }, + }, + _source: "custom", + }, +]; + +/** + * Default agent ID when none is selected + */ +export const DEFAULT_AGENT_ID = "zed-claude-code"; + +/** + * Get the current platform key for binary distribution lookup + */ +export function getPlatformKey(): string { + const platform = process.platform; + const arch = process.arch; + + const platformMap: Record> = { + darwin: { + arm64: "darwin-aarch64", + x64: "darwin-x86_64", + }, + linux: { + x64: "linux-x86_64", + arm64: "linux-aarch64", + }, + win32: { + x64: "windows-x86_64", + }, + }; + + return platformMap[platform]?.[arch] ?? `${platform}-${arch}`; +} + +/** + * Get the cache directory for binary agents + */ +export function getBinaryCacheDir(agentId: string, version: string): string { + return path.join(os.homedir(), ".symposium", "bin", agentId, version); +} + +/** + * Merge built-in agents with user settings. + * Settings entries override built-ins with the same id. + */ +export function getEffectiveAgents(): AgentConfig[] { + const config = vscode.workspace.getConfiguration("symposium"); + const settingsAgents = config.get("agents", {}); + + // Start with built-ins + const agentsById = new Map(); + for (const agent of BUILT_IN_AGENTS) { + agentsById.set(agent.id, agent); + } + + // Override/add from settings + for (const [id, entry] of Object.entries(settingsAgents)) { + agentsById.set(id, { id, ...entry }); + } + + return Array.from(agentsById.values()); +} + +/** + * Get a specific agent by ID + */ +export function getAgentById(id: string): AgentConfig | undefined { + const agents = getEffectiveAgents(); + return agents.find((a) => a.id === id); +} + +/** + * Get the currently selected agent ID from settings + */ +export function getCurrentAgentId(): string { + const config = vscode.workspace.getConfiguration("symposium"); + return config.get("currentAgentId", DEFAULT_AGENT_ID); +} + +/** + * Get the currently selected agent config + */ +export function getCurrentAgent(): AgentConfig | undefined { + return getAgentById(getCurrentAgentId()); +} + +/** + * Resolved spawn command + */ +export interface ResolvedCommand { + command: string; + args: string[]; + env?: Record; + /** If true, this is a built-in symposium subcommand - don't wrap with conductor */ + isSymposiumBuiltin?: boolean; +} + +/** + * Resolve an agent's distribution to a spawn command. + * Priority: symposium > npx > pipx > binary + * + * @throws Error if no compatible distribution is found + */ +export async function resolveDistribution( + agent: AgentConfig, +): Promise { + const dist = agent.distribution; + + // Try symposium builtin first (e.g., eliza subcommand) + if (dist.symposium) { + return { + command: dist.symposium.subcommand, + args: dist.symposium.args ?? [], + isSymposiumBuiltin: true, + }; + } + + // Try npx + if (dist.npx) { + return { + command: "npx", + args: ["-y", dist.npx.package, ...(dist.npx.args ?? [])], + }; + } + + // Try pipx + if (dist.pipx) { + return { + command: "pipx", + args: ["run", dist.pipx.package, ...(dist.pipx.args ?? [])], + }; + } + + // Try binary for current platform + if (dist.binary) { + const platformKey = getPlatformKey(); + const binaryDist = dist.binary[platformKey]; + + if (binaryDist) { + const version = agent.version ?? "latest"; + const cacheDir = getBinaryCacheDir(agent.id, version); + // cmd may have leading "./" - strip it for the path + const executable = binaryDist.cmd.replace(/^\.\//, ""); + const executablePath = path.join(cacheDir, executable); + + // Check if binary exists in cache + const fs = await import("fs/promises"); + try { + await fs.access(executablePath); + } catch { + // Binary not cached - need to download + await downloadAndCacheBinary(agent, binaryDist, cacheDir); + } + + return { + command: executablePath, + args: binaryDist.args ?? [], + }; + } + } + + throw new Error( + `No compatible distribution found for agent "${agent.id}" on platform ${getPlatformKey()}`, + ); +} + +/** + * Download and cache a binary distribution + */ +async function downloadAndCacheBinary( + agent: AgentConfig, + binaryDist: BinaryDistribution, + cacheDir: string, +): Promise { + const fs = await import("fs/promises"); + + // Clean up old versions first + const parentDir = path.dirname(cacheDir); + try { + const entries = await fs.readdir(parentDir); + for (const entry of entries) { + const entryPath = path.join(parentDir, entry); + if (entryPath !== cacheDir) { + await fs.rm(entryPath, { recursive: true, force: true }); + } + } + } catch { + // Parent directory doesn't exist yet, that's fine + } + + // Create cache directory + await fs.mkdir(cacheDir, { recursive: true }); + + // Download the binary + const response = await fetch(binaryDist.archive); + if (!response.ok) { + throw new Error( + `Failed to download binary for ${agent.id}: ${response.status} ${response.statusText}`, + ); + } + + const buffer = await response.arrayBuffer(); + const url = new URL(binaryDist.archive); + const filename = path.basename(url.pathname); + const downloadPath = path.join(cacheDir, filename); + + await fs.writeFile(downloadPath, Buffer.from(buffer)); + + // Extract if it's an archive + if ( + filename.endsWith(".tar.gz") || + filename.endsWith(".tgz") || + filename.endsWith(".zip") + ) { + await extractArchive(downloadPath, cacheDir); + // Remove the archive after extraction + await fs.unlink(downloadPath); + } + + // Make executable on Unix + if (process.platform !== "win32") { + const executable = binaryDist.cmd.replace(/^\.\//, ""); + const executablePath = path.join(cacheDir, executable); + await fs.chmod(executablePath, 0o755); + } +} + +/** + * Extract an archive to a directory + */ +async function extractArchive( + archivePath: string, + destDir: string, +): Promise { + const { exec } = await import("child_process"); + const { promisify } = await import("util"); + const execAsync = promisify(exec); + + if (archivePath.endsWith(".zip")) { + if (process.platform === "win32") { + await execAsync( + `powershell -command "Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}'"`, + ); + } else { + await execAsync(`unzip -o "${archivePath}" -d "${destDir}"`); + } + } else { + // tar.gz or tgz + await execAsync(`tar -xzf "${archivePath}" -C "${destDir}"`); + } +} + +/** + * Registry entry format (as returned from the registry API) + */ +export interface RegistryEntry { + id: string; + name: string; + version: string; + description?: string; + distribution: Distribution; +} + +/** + * Registry JSON format + */ +interface RegistryJson { + version: string; + agents: RegistryEntry[]; +} + +/** + * Fetch the agent registry from GitHub releases. + * Returns agents that are NOT already in the user's effective agents list. + */ +export async function fetchAvailableRegistryAgents(): Promise { + const response = await fetch(REGISTRY_URL); + if (!response.ok) { + throw new Error( + `Failed to fetch registry: ${response.status} ${response.statusText}`, + ); + } + + const registryJson = (await response.json()) as RegistryJson; + + // Filter out agents already configured + const effectiveAgents = getEffectiveAgents(); + const existingIds = new Set(effectiveAgents.map((a) => a.id)); + + return registryJson.agents.filter((entry) => !existingIds.has(entry.id)); +} + +/** + * Fetch all agents from the registry (without filtering) + */ +export async function fetchRegistry(): Promise { + const response = await fetch(REGISTRY_URL); + if (!response.ok) { + throw new Error( + `Failed to fetch registry: ${response.status} ${response.statusText}`, + ); + } + + const registryJson = (await response.json()) as RegistryJson; + return registryJson.agents; +} + +/** + * Add an agent from the registry to user settings + */ +export async function addAgentFromRegistry( + entry: RegistryEntry, +): Promise { + const config = vscode.workspace.getConfiguration("symposium"); + const currentAgents = config.get("agents", {}); + + const newEntry: AgentSettingsEntry = { + name: entry.name, + version: entry.version, + description: entry.description, + distribution: entry.distribution, + _source: "registry", + }; + + const updatedAgents = { + ...currentAgents, + [entry.id]: newEntry, + }; + + await config.update( + "agents", + updatedAgents, + vscode.ConfigurationTarget.Global, + ); +} + +/** + * Check for updates to registry-sourced agents and update them in settings. + * Returns a summary of what was updated. + */ +export async function checkForRegistryUpdates(): Promise<{ + updated: string[]; + failed: string[]; +}> { + const result = { updated: [] as string[], failed: [] as string[] }; + + // Fetch the registry + let registryAgents: RegistryEntry[]; + try { + registryAgents = await fetchRegistry(); + } catch (error) { + throw new Error( + `Failed to fetch registry: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + // Create a lookup map by agent id + const registryById = new Map(); + for (const agent of registryAgents) { + registryById.set(agent.id, agent); + } + + // Get current settings + const config = vscode.workspace.getConfiguration("symposium"); + const currentAgents = config.get("agents", {}); + + // Find registry-sourced agents that have updates + const updates: Record = {}; + + for (const [id, entry] of Object.entries(currentAgents)) { + if (entry._source !== "registry") { + continue; + } + + const registryEntry = registryById.get(id); + if (!registryEntry) { + // Agent was removed from registry - leave as-is + continue; + } + + // Check if version changed + if (entry.version !== registryEntry.version) { + updates[id] = { + name: registryEntry.name, + version: registryEntry.version, + description: registryEntry.description, + distribution: registryEntry.distribution, + _source: "registry", + }; + result.updated.push(registryEntry.name); + } + } + + // Apply updates if any + if (Object.keys(updates).length > 0) { + const updatedAgents = { + ...currentAgents, + ...updates, + }; + + await config.update( + "agents", + updatedAgents, + vscode.ConfigurationTarget.Global, + ); + } + + return result; +} + +/** + * Show a QuickPick dialog to add an agent from the registry + */ +export async function showAddAgentFromRegistryDialog(): Promise { + // Show progress while fetching + const agents = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Fetching agent registry...", + cancellable: false, + }, + async () => { + try { + return await fetchAvailableRegistryAgents(); + } catch (error) { + vscode.window.showErrorMessage( + `Failed to fetch registry: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } + }, + ); + + if (agents === null) { + return false; + } + + if (agents.length === 0) { + vscode.window.showInformationMessage( + "All registry agents are already configured.", + ); + return false; + } + + // Create QuickPick items + interface AgentQuickPickItem extends vscode.QuickPickItem { + agent: RegistryEntry; + } + + const items: AgentQuickPickItem[] = agents.map((agent) => ({ + label: agent.name, + description: `v${agent.version}`, + detail: agent.description, + agent, + })); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: "Select an agent to add", + title: "Add Agent from Registry", + matchOnDescription: true, + matchOnDetail: true, + }); + + if (!selected) { + return false; + } + + try { + await addAgentFromRegistry(selected.agent); + vscode.window.showInformationMessage( + `Added ${selected.agent.name} to your agents.`, + ); + return true; + } catch (error) { + vscode.window.showErrorMessage( + `Failed to add agent: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } +} diff --git a/vscode-extension/src/chatViewProvider.ts b/vscode-extension/src/chatViewProvider.ts index fccc8323..541fdb3c 100644 --- a/vscode-extension/src/chatViewProvider.ts +++ b/vscode-extension/src/chatViewProvider.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode"; import * as acp from "@agentclientprotocol/sdk"; import { AcpAgentActor, ToolCallInfo, SlashCommandInfo } from "./acpAgentActor"; import { AgentConfiguration } from "./agentConfiguration"; +import { getAgentById } from "./agentRegistry"; import { WorkspaceFileIndex } from "./workspaceFileIndex"; import { getConductorCommand } from "./binaryPath"; import { logger } from "./extension"; @@ -86,14 +87,14 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { if (existing) { logger.debug("agent", "Reusing existing agent actor", { configKey: key, - agentName: config.agentName, + agentId: config.agentId, }); return existing; } logger.important("agent", "Spawning new agent actor", { configKey: key, - agentName: config.agentName, + agentId: config.agentId, }); // Create a new actor with callbacks @@ -142,8 +143,13 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { // Check if this agent has bypass permissions enabled const vsConfig = vscode.workspace.getConfiguration("symposium"); const agents = vsConfig.get>("agents", {}); - const agentConfig = agents[config.agentName]; - const bypassPermissions = agentConfig?.bypassPermissions || false; + const agentSettingsEntry = agents[config.agentId]; + const bypassPermissions = + agentSettingsEntry?.bypassPermissions || false; + + // Get display name for logging + const agent = getAgentById(config.agentId); + const displayName = agent?.name ?? config.agentId; if (bypassPermissions) { // Auto-approve - find the "allow_once" option @@ -155,7 +161,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { "approval", "Auto-approved (bypass permissions enabled)", { - agent: config.agentName, + agent: displayName, tool: params.toolCall.title, }, ); @@ -166,7 +172,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { } // Need user approval - send request to webview and wait for response - return this.#requestUserApproval(params, config.agentName); + return this.#requestUserApproval(params, displayName); }, onToolCall: (agentSessionId: string, toolCall: ToolCallInfo) => { const tabId = this.#agentSessionToTab.get(agentSessionId); @@ -555,10 +561,11 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { this.#nextMessageIndex.set(message.tabId, 0); // Update tab title immediately (before spawning agent) + const agentForTitle = getAgentById(config.agentId); this.#sendToWebview({ type: "set-tab-title", tabId: message.tabId, - title: config.agentName, + title: agentForTitle?.name ?? config.agentId, }); // Get or create an actor for this configuration (may spawn process) @@ -1237,10 +1244,11 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { this.#messageQueues.set(message.tabId, []); this.#nextMessageIndex.set(message.tabId, 0); + const agentForNewTab = getAgentById(config.agentId); this.#sendToWebview({ type: "set-tab-title", tabId: message.tabId, - title: config.agentName, + title: agentForNewTab?.name ?? config.agentId, }); const actor = await this.#getOrCreateActor(config); @@ -1266,7 +1274,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { logger.important("agent", "Agent session created", { tabId: message.tabId, agentSessionId, - agentName: config.agentName, + agentId: config.agentId, }); } catch (err) { logger.error("agent", "Failed to create agent session", { diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 6eb80bd9..649472a3 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -3,6 +3,7 @@ import { ChatViewProvider } from "./chatViewProvider"; import { SettingsViewProvider } from "./settingsViewProvider"; import { DiscussCodeActionProvider } from "./discussCodeActionProvider"; import { Logger } from "./logger"; +import { showAddAgentFromRegistryDialog } from "./agentRegistry"; import { v4 as uuidv4 } from "uuid"; // Global logger instance @@ -67,6 +68,16 @@ export function activate(context: vscode.ExtensionContext) { }), ); + // Command to add agent from registry + context.subscriptions.push( + vscode.commands.registerCommand( + "symposium.addAgentFromRegistry", + async () => { + await showAddAgentFromRegistryDialog(); + }, + ), + ); + // Register "Discuss in Symposium" code action provider context.subscriptions.push( vscode.languages.registerCodeActionsProvider( diff --git a/vscode-extension/src/settingsViewProvider.ts b/vscode-extension/src/settingsViewProvider.ts index 9d4eadfc..31b3f783 100644 --- a/vscode-extension/src/settingsViewProvider.ts +++ b/vscode-extension/src/settingsViewProvider.ts @@ -1,4 +1,10 @@ import * as vscode from "vscode"; +import { + getEffectiveAgents, + getCurrentAgentId, + AgentConfig, + checkForRegistryUpdates, +} from "./agentRegistry"; export class SettingsViewProvider implements vscode.WebviewViewProvider { public static readonly viewType = "symposium.settingsView"; @@ -47,10 +53,10 @@ export class SettingsViewProvider implements vscode.WebviewViewProvider { break; case "set-current-agent": // Update current agent setting - const config = vscode.workspace.getConfiguration("symposium"); - await config.update( - "currentAgent", - message.agentName, + const vsConfig = vscode.workspace.getConfiguration("symposium"); + await vsConfig.update( + "currentAgentId", + message.agentId, vscode.ConfigurationTarget.Global, ); vscode.window.showInformationMessage( @@ -62,7 +68,7 @@ export class SettingsViewProvider implements vscode.WebviewViewProvider { case "toggle-bypass-permissions": // Toggle bypass permissions for an agent - await this.#toggleBypassPermissions(message.agentName); + await this.#toggleBypassPermissions(message.agentId); break; case "open-settings": // Open VSCode settings focused on Symposium @@ -71,31 +77,43 @@ export class SettingsViewProvider implements vscode.WebviewViewProvider { "symposium", ); break; + case "add-agent-from-registry": + // Show the add agent from registry dialog + vscode.commands.executeCommand("symposium.addAgentFromRegistry"); + break; + case "check-for-updates": + // Check for registry updates + await this.#checkForUpdates(); + break; case "toggle-require-modifier-to-send": // Toggle the requireModifierToSend setting await this.#toggleRequireModifierToSend(); break; - case "toggle-component": - // Toggle a component enabled/disabled - await this.#toggleComponentSetting(message.componentId); - break; } }); } - async #toggleBypassPermissions(agentName: string) { + async #toggleBypassPermissions(agentId: string) { const config = vscode.workspace.getConfiguration("symposium"); const agents = config.get>("agents", {}); - if (agents[agentName]) { - const currentValue = agents[agentName].bypassPermissions || false; - agents[agentName].bypassPermissions = !currentValue; - await config.update("agents", agents, vscode.ConfigurationTarget.Global); - vscode.window.showInformationMessage( - `${agentName}: Bypass permissions ${!currentValue ? "enabled" : "disabled"}`, - ); - this.#sendConfiguration(); + // Get the agent to find its display name + const effectiveAgents = getEffectiveAgents(); + const agent = effectiveAgents.find((a) => a.id === agentId); + const displayName = agent?.name ?? agentId; + + // Initialize agent entry in settings if it doesn't exist + if (!agents[agentId]) { + agents[agentId] = {}; } + + const currentValue = agents[agentId].bypassPermissions || false; + agents[agentId].bypassPermissions = !currentValue; + await config.update("agents", agents, vscode.ConfigurationTarget.Global); + vscode.window.showInformationMessage( + `${displayName}: Bypass permissions ${!currentValue ? "enabled" : "disabled"}`, + ); + this.#sendConfiguration(); } async #toggleRequireModifierToSend() { @@ -109,17 +127,38 @@ export class SettingsViewProvider implements vscode.WebviewViewProvider { this.#sendConfiguration(); } - async #toggleComponentSetting(componentId: string) { - const config = vscode.workspace.getConfiguration("symposium"); - const settingName = - componentId === "sparkle" ? "enableSparkle" : "enableCrateResearcher"; - const currentValue = config.get(settingName, true); - await config.update( - settingName, - !currentValue, - vscode.ConfigurationTarget.Global, + async #checkForUpdates() { + const result = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Checking for agent updates...", + cancellable: false, + }, + async () => { + try { + return await checkForRegistryUpdates(); + } catch (error) { + vscode.window.showErrorMessage( + `Failed to check for updates: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } + }, ); - this.#sendConfiguration(); + + if (result === null) { + return; + } + + if (result.updated.length === 0) { + vscode.window.showInformationMessage("All agents are up to date."); + } else { + vscode.window.showInformationMessage( + `Updated ${result.updated.length} agent(s): ${result.updated.join(", ")}`, + ); + // Refresh the UI to show new versions + this.#sendConfiguration(); + } } #sendConfiguration() { @@ -128,25 +167,26 @@ export class SettingsViewProvider implements vscode.WebviewViewProvider { } const config = vscode.workspace.getConfiguration("symposium"); - const agents = config.get("agents", {}); - const currentAgent = config.get("currentAgent", ""); + const settingsAgents = config.get>("agents", {}); + + // Get effective agents (built-ins + settings) and merge bypass settings + const effectiveAgents = getEffectiveAgents(); + const agents = effectiveAgents.map((agent) => ({ + ...agent, + bypassPermissions: settingsAgents[agent.id]?.bypassPermissions ?? false, + })); + + const currentAgentId = getCurrentAgentId(); const requireModifierToSend = config.get( "requireModifierToSend", false, ); - const enableSparkle = config.get("enableSparkle", true); - const enableCrateResearcher = config.get( - "enableCrateResearcher", - true, - ); this.#view.webview.postMessage({ type: "config", agents, - currentAgent, + currentAgentId, requireModifierToSend, - enableSparkle, - enableCrateResearcher, }); } @@ -187,6 +227,15 @@ export class SettingsViewProvider implements vscode.WebviewViewProvider { justify-content: space-between; align-items: center; } + .agent-name { + display: flex; + align-items: baseline; + gap: 6px; + } + .agent-version { + font-size: 11px; + color: var(--vscode-descriptionForeground); + } .agent-item:hover { background: var(--vscode-list-hoverBackground); } @@ -245,27 +294,13 @@ export class SettingsViewProvider implements vscode.WebviewViewProvider {
Loading...
- - -
-

Components

-
- - -
-
- - +
@@ -284,7 +319,7 @@ export class SettingsViewProvider implements vscode.WebviewViewProvider { @@ -300,17 +335,21 @@ export class SettingsViewProvider implements vscode.WebviewViewProvider { vscode.postMessage({ type: 'open-settings' }); }; - // Handle require modifier to send checkbox - document.getElementById('require-modifier-to-send').onchange = (e) => { - vscode.postMessage({ type: 'toggle-require-modifier-to-send' }); + // Handle add agent link + document.getElementById('add-agent-link').onclick = (e) => { + e.preventDefault(); + vscode.postMessage({ type: 'add-agent-from-registry' }); }; - // Handle component checkboxes - document.getElementById('component-sparkle').onchange = (e) => { - vscode.postMessage({ type: 'toggle-component', componentId: 'sparkle' }); + // Handle check for updates link + document.getElementById('check-updates-link').onclick = (e) => { + e.preventDefault(); + vscode.postMessage({ type: 'check-for-updates' }); }; - document.getElementById('component-crate-researcher').onchange = (e) => { - vscode.postMessage({ type: 'toggle-component', componentId: 'crate-researcher' }); + + // Handle require modifier to send checkbox + document.getElementById('require-modifier-to-send').onchange = (e) => { + vscode.postMessage({ type: 'toggle-require-modifier-to-send' }); }; // Handle messages from extension @@ -318,48 +357,52 @@ export class SettingsViewProvider implements vscode.WebviewViewProvider { const message = event.data; if (message.type === 'config') { - renderAgents(message.agents, message.currentAgent); - renderComponents(message.enableSparkle, message.enableCrateResearcher); + renderAgents(message.agents, message.currentAgentId); renderPreferences(message.requireModifierToSend); } }); - function renderComponents(enableSparkle, enableCrateResearcher) { - document.getElementById('component-sparkle').checked = enableSparkle; - document.getElementById('component-crate-researcher').checked = enableCrateResearcher; - } - function renderPreferences(requireModifierToSend) { const checkbox = document.getElementById('require-modifier-to-send'); checkbox.checked = requireModifierToSend; } - function renderAgents(agents, currentAgent) { + function renderAgents(agents, currentAgentId) { const list = document.getElementById('agent-list'); list.innerHTML = ''; - for (const [name, config] of Object.entries(agents)) { + for (const agent of agents) { + const displayName = agent.name || agent.id; + const isActive = agent.id === currentAgentId; + const item = document.createElement('div'); - item.className = 'agent-item' + (name === currentAgent ? ' active' : ''); + item.className = 'agent-item' + (isActive ? ' active' : ''); const badges = []; - if (name === currentAgent) { + if (isActive) { badges.push('Active'); } - if (config.bypassPermissions) { + if (agent.bypassPermissions) { badges.push('Bypass Permissions'); } + const versionHtml = agent.version + ? \`v\${agent.version}\` + : ''; + item.innerHTML = \` - \${name} +
+ \${displayName} + \${versionHtml} +
\${badges.join('')}
\`; // Handle clicking on the agent name (switch agent) - const nameSpan = item.querySelector('span:first-child'); + const nameSpan = item.querySelector('.agent-name'); nameSpan.onclick = (e) => { e.stopPropagation(); - vscode.postMessage({ type: 'set-current-agent', agentName: name }); + vscode.postMessage({ type: 'set-current-agent', agentId: agent.id, agentName: displayName }); }; // Handle clicking on the bypass badge (toggle bypass) @@ -367,7 +410,7 @@ export class SettingsViewProvider implements vscode.WebviewViewProvider { if (bypassBadge) { bypassBadge.onclick = (e) => { e.stopPropagation(); - vscode.postMessage({ type: 'toggle-bypass-permissions', agentName: name }); + vscode.postMessage({ type: 'toggle-bypass-permissions', agentId: agent.id }); }; } diff --git a/vscode-extension/src/test/settings.test.ts b/vscode-extension/src/test/settings.test.ts index e9d83756..9a930b23 100644 --- a/vscode-extension/src/test/settings.test.ts +++ b/vscode-extension/src/test/settings.test.ts @@ -15,46 +15,16 @@ suite("Settings Test Suite", () => { "Default value should be false", ); }); - - test("symposium.enableSparkle should be registered", async () => { - const config = vscode.workspace.getConfiguration("symposium"); - const inspect = config.inspect("enableSparkle"); - - assert.ok(inspect, "Setting should exist"); - assert.strictEqual( - inspect.defaultValue, - true, - "Default value should be true", - ); - }); - - test("symposium.enableCrateResearcher should be registered", async () => { - const config = vscode.workspace.getConfiguration("symposium"); - const inspect = config.inspect("enableCrateResearcher"); - - assert.ok(inspect, "Setting should exist"); - assert.strictEqual( - inspect.defaultValue, - true, - "Default value should be true", - ); - }); }); // Test that settings can be read and written suite("Settings Read/Write", () => { // Store original values to restore after tests let originalRequireModifier: boolean | undefined; - let originalEnableSparkle: boolean | undefined; - let originalEnableCrateResearcher: boolean | undefined; suiteSetup(async () => { const config = vscode.workspace.getConfiguration("symposium"); originalRequireModifier = config.get("requireModifierToSend"); - originalEnableSparkle = config.get("enableSparkle"); - originalEnableCrateResearcher = config.get( - "enableCrateResearcher", - ); }); suiteTeardown(async () => { @@ -67,20 +37,6 @@ suite("Settings Test Suite", () => { vscode.ConfigurationTarget.Global, ); } - if (originalEnableSparkle !== undefined) { - await config.update( - "enableSparkle", - originalEnableSparkle, - vscode.ConfigurationTarget.Global, - ); - } - if (originalEnableCrateResearcher !== undefined) { - await config.update( - "enableCrateResearcher", - originalEnableCrateResearcher, - vscode.ConfigurationTarget.Global, - ); - } }); test("requireModifierToSend can be toggled", async () => { @@ -114,62 +70,6 @@ suite("Settings Test Suite", () => { vscode.ConfigurationTarget.Global, ); }); - - test("enableSparkle can be toggled", async () => { - const initialValue = - vscode.workspace - .getConfiguration("symposium") - .get("enableSparkle") ?? true; - - await vscode.workspace - .getConfiguration("symposium") - .update( - "enableSparkle", - !initialValue, - vscode.ConfigurationTarget.Global, - ); - - const newValue = vscode.workspace - .getConfiguration("symposium") - .get("enableSparkle"); - assert.strictEqual(newValue, !initialValue, "Value should be toggled"); - - await vscode.workspace - .getConfiguration("symposium") - .update( - "enableSparkle", - initialValue, - vscode.ConfigurationTarget.Global, - ); - }); - - test("enableCrateResearcher can be toggled", async () => { - const initialValue = - vscode.workspace - .getConfiguration("symposium") - .get("enableCrateResearcher") ?? true; - - await vscode.workspace - .getConfiguration("symposium") - .update( - "enableCrateResearcher", - !initialValue, - vscode.ConfigurationTarget.Global, - ); - - const newValue = vscode.workspace - .getConfiguration("symposium") - .get("enableCrateResearcher"); - assert.strictEqual(newValue, !initialValue, "Value should be toggled"); - - await vscode.workspace - .getConfiguration("symposium") - .update( - "enableCrateResearcher", - initialValue, - vscode.ConfigurationTarget.Global, - ); - }); }); // Test that settings flow correctly to webview HTML generation @@ -178,7 +78,9 @@ suite("Settings Test Suite", () => { this.timeout(10000); // Activate the extension - const extension = vscode.extensions.getExtension("symposium-dev.symposium"); + const extension = vscode.extensions.getExtension( + "symposium-dev.symposium", + ); assert.ok(extension); await extension.activate(); @@ -224,7 +126,9 @@ suite("Settings Test Suite", () => { this.timeout(10000); // Activate the extension - const extension = vscode.extensions.getExtension("symposium-dev.symposium"); + const extension = vscode.extensions.getExtension( + "symposium-dev.symposium", + ); assert.ok(extension); await extension.activate(); @@ -234,16 +138,16 @@ suite("Settings Test Suite", () => { // Update a setting - the SettingsViewProvider listens for changes // and sends updated config to the webview - const originalSparkle = + const originalValue = vscode.workspace .getConfiguration("symposium") - .get("enableSparkle") ?? true; + .get("requireModifierToSend") ?? false; await vscode.workspace .getConfiguration("symposium") .update( - "enableSparkle", - !originalSparkle, + "requireModifierToSend", + !originalValue, vscode.ConfigurationTarget.Global, ); @@ -253,19 +157,15 @@ suite("Settings Test Suite", () => { // Verify setting changed (re-fetch config) const newValue = vscode.workspace .getConfiguration("symposium") - .get("enableSparkle"); - assert.strictEqual( - newValue, - !originalSparkle, - "Setting should be toggled", - ); + .get("requireModifierToSend"); + assert.strictEqual(newValue, !originalValue, "Setting should be toggled"); // Restore await vscode.workspace .getConfiguration("symposium") .update( - "enableSparkle", - originalSparkle, + "requireModifierToSend", + originalValue, vscode.ConfigurationTarget.Global, ); }); diff --git a/vscode-extension/test-workspace/.vscode/settings.json b/vscode-extension/test-workspace/.vscode/settings.json index 4e870152..66765762 100644 --- a/vscode-extension/test-workspace/.vscode/settings.json +++ b/vscode-extension/test-workspace/.vscode/settings.json @@ -1,3 +1,3 @@ { - "symposium.currentAgent": "ElizACP" + "symposium.currentAgentId": "elizacp", }