From 8e3bc45e1386ecb08c7dfb4a3f22414c8f68f511 Mon Sep 17 00:00:00 2001 From: Lee Salminen Date: Wed, 22 Jul 2026 06:51:25 -0600 Subject: [PATCH] feat(agents): add OpenCode as a native ACP runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers OpenCode in the managed ACP runtime catalog so Desktop can discover and spawn it like Goose/Claude/Codex. Uses OpenCode's native ACP entrypoint (opencode acp) — no separate *-acp npm adapter. - KNOWN_ACP_RUNTIMES entry (id: opencode), CLI-login runtime, provider-locked in the persona UI like claude/codex - config bridge for ~/.config/opencode/opencode.json: model, provider (from the provider/model string), MCP servers with enabled flags - ~/.opencode/bin added to binary discovery; process-sweep name - buzz-acp default-arg normalization: opencode → acp - docs: README / TESTING / buzz-acp README - unit tests: discovery, arg normalization (desktop + buzz-acp), config bridge, persona UI capability Closes #2368 Co-Authored-By: Claude Fable 5 Signed-off-by: Lee Salminen --- README.md | 6 +- TESTING.md | 2 +- crates/buzz-acp/README.md | 16 +- crates/buzz-acp/src/config.rs | 16 +- .../src/managed_agents/config_bridge/mod.rs | 1 + .../managed_agents/config_bridge/opencode.rs | 155 ++++++++++++++++++ .../managed_agents/config_bridge/reader.rs | 4 + .../src-tauri/src/managed_agents/discovery.rs | 35 +++- .../src/managed_agents/discovery/tests.rs | 2 +- .../discovery/tests/opencode.rs | 21 +++ .../src-tauri/src/managed_agents/runtime.rs | 1 + .../agents/ui/agentConfigOptions.test.mjs | 3 +- .../features/agents/ui/agentConfigOptions.tsx | 3 +- .../agents/ui/personaRuntimeModel.test.mjs | 1 + .../features/agents/ui/personaRuntimeModel.ts | 4 +- 15 files changed, 258 insertions(+), 12 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/config_bridge/opencode.rs create mode 100644 desktop/src-tauri/src/managed_agents/discovery/tests/opencode.rs diff --git a/README.md b/README.md index 6aa3eeff0b..e90d3daf8d 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ Agents are part of the room, not haunted cron jobs. |---|---|---| | Relay, channels, threads, DMs, canvases, media, search, audit log | Mobile clients (iOS + Android, Flutter) | Web-of-trust reputation across relays | | Desktop app (Tauri + React) | Workflow approval gates (infra exists, glue still drying) | Push notifications | -| `buzz-cli` (agent-first, JSON in / JSON out) + ACP harness (Goose, Codex, Claude Code) | Huddle lifecycle events | Culture features | +| `buzz-cli` (agent-first, JSON in / JSON out) + ACP harness (Goose, Codex, Claude Code, OpenCode) | Huddle lifecycle events | Culture features | | YAML workflows: message / reaction / schedule / webhook triggers | | | | Git events (NIP-34: patches, repo announcements, status) | | | | Git hosting backend | | | @@ -174,7 +174,7 @@ If you'd rather point buzz at a different bash-compatible shell, set `BUZZ_SHELL ┌─────────────────────────────────────────────────────────────────────────┐ │ Clients │ │ Human client AI agent CLI / scripts │ -│ (Buzz desktop) (Goose, Codex, ...) (buzz-cli, agents) │ +│ (Buzz desktop) (Goose, Codex, OpenCode, ...) (buzz-cli, agents) │ │ │ ┌──────────────┐ │ │ │ │ │ buzz-acp │ │ │ │ │ │ (ACP ↔ MCP) │ │ │ @@ -204,7 +204,7 @@ A Rust workspace of focused crates. Single source of truth: the relay. See [ARCH **Services** — `buzz-db` (Postgres) · `buzz-auth` (NIP-42/98 Schnorr auth, rate limiting) · `buzz-pubsub` (Redis, presence, typing) · `buzz-search` (Postgres FTS) · `buzz-audit` (hash-chain log). Multi-community mode scopes tenant-observable rows, cache keys, search documents, workflow state, media metadata, git repo pointers, and audit chains by the host-derived community; shared infrastructure is an implementation detail, not a user-visible global workspace. -**Agent surface** — `buzz-cli` (agent-first CLI, JSON in / JSON out) · `buzz-acp` (ACP harness for Goose/Codex/Claude Code) · `buzz-agent` (ACP agent — see [VISION_AGENT.md](VISION_AGENT.md)) · `buzz-dev-mcp` (shell + file-edit tools) · `buzz-workflow` (YAML automation) · `buzz-persona` (agent persona packs) +**Agent surface** — `buzz-cli` (agent-first CLI, JSON in / JSON out) · `buzz-acp` (ACP harness for Goose/Codex/Claude Code/OpenCode) · `buzz-agent` (ACP agent — see [VISION_AGENT.md](VISION_AGENT.md)) · `buzz-dev-mcp` (shell + file-edit tools) · `buzz-workflow` (YAML automation) · `buzz-persona` (agent persona packs) **Git & pairing** — `git-sign-nostr` / `git-credential-nostr` (nostr-signed git) · `buzz-pair-relay` / `buzz-pairing-cli` (relay pairing) diff --git a/TESTING.md b/TESTING.md index 242dd44f25..2113be46ee 100644 --- a/TESTING.md +++ b/TESTING.md @@ -180,7 +180,7 @@ header fallback. There is no REST API for fetching message threads — use ## ACP Harness (optional, end-to-end with a real agent) `buzz-acp` connects an ACP-speaking agent (goose, codex, claude code, -buzz-agent) to the relay. The harness listens for events, drives the +OpenCode, buzz-agent) to the relay. The harness listens for events, drives the agent over stdio, and the agent replies through MCP tools. Minimum recipe — assumes the relay from step 3 is running and the channel diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index b9c8406242..d945fb5482 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -9,7 +9,7 @@ Buzz Relay ──WS──→ buzz-acp ──stdio──→ Your Agent (send_message, etc.) ``` -Supports any agent that speaks [ACP](https://agentclientprotocol.com/) over stdio: **goose**, **codex** (via [codex-acp](https://github.com/agentclientprotocol/codex-acp)), and **claude code** (via [claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp)). +Supports any agent that speaks [ACP](https://agentclientprotocol.com/) over stdio: **goose**, **codex** (via [codex-acp](https://github.com/agentclientprotocol/codex-acp)), **claude code** (via [claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp)), and **OpenCode** (native `opencode acp`). ## Prerequisites @@ -89,6 +89,20 @@ buzz-acp Older installs that still expose `claude-code-acp` are also supported. `buzz-acp` treats both Claude ACP command names as the same zero-arg runtime. +## Running with OpenCode + +[OpenCode](https://opencode.ai/) speaks ACP natively via `opencode acp` — no separate adapter package. + +```bash +# Install: curl -fsSL https://opencode.ai/install | bash +# Auth: opencode auth login + +export BUZZ_ACP_AGENT_COMMAND="opencode" +# Default args are: acp + +buzz-acp +``` + ## Configuration All configuration is via environment variables (or CLI flags — every env var has a matching flag). diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index d78c4fa319..0b91e14e61 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -610,7 +610,8 @@ pub(crate) fn normalize_agent_command_identity(command: &str) -> String { fn default_agent_args(command: &str) -> Option> { match normalize_agent_command_identity(command).as_str() { - "goose" => Some(vec!["acp".to_string()]), + // OpenCode speaks ACP natively via its `acp` subcommand, like Goose. + "goose" | "opencode" | "open-code" => Some(vec!["acp".to_string()]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, @@ -1432,6 +1433,19 @@ mod tests { assert_eq!(normalize_agent_args("goose", vec!["".into()]), vec!["acp"]); } + #[test] + fn normalizes_opencode_args_to_acp() { + assert_eq!(normalize_agent_args("opencode", Vec::new()), vec!["acp"]); + assert_eq!( + normalize_agent_args("opencode", vec!["acp".into()]), + vec!["acp"] + ); + assert_eq!( + normalize_agent_args("/home/me/.opencode/bin/opencode", Vec::new()), + vec!["acp"] + ); + } + #[test] fn normalizes_codex_and_claude_args_to_empty() { assert_eq!( diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs index f8b045fc72..e13830abb3 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs @@ -2,6 +2,7 @@ mod buzz_agent; mod claude; mod codex; mod goose; +mod opencode; pub(crate) mod reader; mod schema_walker; pub(crate) mod types; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/opencode.rs b/desktop/src-tauri/src/managed_agents/config_bridge/opencode.rs new file mode 100644 index 0000000000..8b6851e644 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/opencode.rs @@ -0,0 +1,155 @@ +use std::path::PathBuf; + +use super::types::{ExtensionEntry, RuntimeFileConfig}; + +/// Read OpenCode config from `~/.config/opencode/opencode.json` (or +/// `$OPENCODE_CONFIG`). Model strings are `provider/model`; MCP servers live +/// under the `mcp` key of the same file. +pub(super) fn read_config_file() -> Option { + let path = opencode_config_path()?; + let raw = std::fs::read_to_string(path).ok()?; + parse_opencode_config(&raw) +} + +fn parse_opencode_config(raw: &str) -> Option { + let json: serde_json::Value = serde_json::from_str(raw).ok()?; + + let model = json_string(&json, "model"); + let provider = model + .as_deref() + .and_then(|m| m.split_once('/')) + .map(|(provider, _)| provider.to_string()); + + let skip = &[ + "$schema", + "model", + "small_model", + "provider", + "mcp", + "agent", + "permission", + ]; + let mut extra = super::schema_walker::extract_config_fields(&json, skip); + + if let Some(serde_json::Value::Object(providers)) = json.get("provider") { + for (name, _) in providers { + extra.insert(format!("provider.{name}"), "configured".to_string()); + } + } + if let Some(serde_json::Value::Object(agents)) = json.get("agent") { + for (name, _) in agents { + extra.insert(format!("agent.{name}"), "configured".to_string()); + } + } + + let extensions = json + .get("mcp") + .and_then(|v| v.as_object()) + .map(|servers| { + servers + .iter() + .map(|(name, config)| ExtensionEntry { + name: name.clone(), + kind: "mcp".to_string(), + enabled: config + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(true), + }) + .collect() + }) + .unwrap_or_default(); + + Some(RuntimeFileConfig { + model, + provider, + mode: None, + thinking_effort: None, + max_output_tokens: None, + context_limit: None, + system_prompt: None, + extensions, + extra, + }) +} + +fn json_string(val: &serde_json::Value, key: &str) -> Option { + val.get(key)? + .as_str() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +pub(crate) fn opencode_config_path() -> Option { + if let Ok(path) = std::env::var("OPENCODE_CONFIG") { + return Some(PathBuf::from(path)); + } + let config_root = std::env::var("XDG_CONFIG_HOME") + .map(PathBuf::from) + .ok() + .or_else(|| dirs::home_dir().map(|home| home.join(".config")))?; + Some(config_root.join("opencode").join("opencode.json")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_model_and_provider_from_model_string() { + let cfg = parse_opencode_config( + r#"{"$schema": "https://opencode.ai/config.json", "model": "anthropic/claude-sonnet-4-5"}"#, + ) + .unwrap(); + assert_eq!(cfg.model.as_deref(), Some("anthropic/claude-sonnet-4-5")); + assert_eq!(cfg.provider.as_deref(), Some("anthropic")); + assert!(!cfg.extra.contains_key("model")); + assert!(!cfg.extra.contains_key("$schema")); + } + + #[test] + fn provider_and_agent_tables_marked_configured() { + let cfg = parse_opencode_config( + r#"{ + "provider": {"openrouter": {"apiKey": "sk"}}, + "agent": {"review": {"model": "anthropic/claude-haiku-4-5"}} + }"#, + ) + .unwrap(); + assert_eq!( + cfg.extra.get("provider.openrouter").map(String::as_str), + Some("configured") + ); + assert_eq!( + cfg.extra.get("agent.review").map(String::as_str), + Some("configured") + ); + assert!(cfg.model.is_none()); + assert!(cfg.provider.is_none()); + } + + #[test] + fn mcp_servers_become_extensions_with_enabled_flag() { + let cfg = parse_opencode_config( + r#"{"mcp": { + "filesystem": {"type": "local", "command": ["npx", "mcp-fs"]}, + "disabled-one": {"type": "local", "command": ["x"], "enabled": false} + }}"#, + ) + .unwrap(); + assert_eq!(cfg.extensions.len(), 2); + let enabled: Vec<_> = cfg + .extensions + .iter() + .filter(|e| e.enabled) + .map(|e| e.name.as_str()) + .collect(); + assert_eq!(enabled, vec!["filesystem"]); + } + + #[test] + fn invalid_json_returns_none() { + assert!(parse_opencode_config("{{{{not valid").is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index 372d2cfde1..3681641127 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -22,6 +22,7 @@ pub(crate) fn read_config_surface( "goose" => super::goose::read_config_file().map(|c| (c, true)), "claude" => super::claude::read_config_file().map(|c| (c, true)), "codex" => super::codex::read_config_file().map(|c| (c, true)), + "opencode" => super::opencode::read_config_file().map(|c| (c, true)), "buzz-agent" => super::buzz_agent::read_config_file().map(|c| (c, true)), _ => None, }) @@ -222,6 +223,9 @@ fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option "codex" => { super::codex::codex_config_path().map(|path| path.to_string_lossy().into_owned()) } + "opencode" => { + super::opencode::opencode_config_path().map(|path| path.to_string_lossy().into_owned()) + } _ => None, } } diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 50206567ad..7a606573fa 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -16,6 +16,7 @@ pub(crate) use runtime_metadata::KnownAcpRuntime; const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default"; const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default"; +const OPENCODE_AVATAR_URL: &str = "https://github.com/sst.png"; const BUZZ_AGENT_AVATAR_URL: &str = "https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png"; @@ -36,6 +37,7 @@ fn common_binary_paths() -> &'static [PathBuf] { } if let Some(home) = dirs::home_dir() { paths.extend([ + home.join(".opencode/bin"), home.join(".local/share/mise/shims"), home.join(".local/bin"), home.join(".volta/bin"), @@ -157,6 +159,37 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. auth_probe_args: Some(&["codex", "login", "status"]), }, + KnownAcpRuntime { + id: "opencode", + label: "OpenCode", + commands: &["opencode"], + aliases: &["open-code"], + avatar_url: OPENCODE_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, + underlying_cli: Some("opencode"), + cli_install_commands: &["curl -fsSL https://opencode.ai/install | bash"], + cli_install_commands_windows: &["npm install -g opencode-ai"], + adapter_install_commands: &[], + install_instructions_url: "https://opencode.ai/docs/", + cli_install_hint: "Install the OpenCode CLI via the official install script.", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: true, + model_env_var: None, + provider_env_var: None, + provider_locked: true, + default_env: &[], + config_file_path: Some("~/.config/opencode/opencode.json"), + config_file_format: Some("json"), + supports_acp_native_config: false, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + required_normalized_fields: &[], + login_hint: Some("Run `opencode auth login` to authenticate."), + auth_probe_args: None, + }, KnownAcpRuntime { id: "buzz-agent", label: "Buzz Agent", @@ -342,7 +375,7 @@ pub use overrides::{apply_agent_command_update, create_time_agent_command_overri fn default_agent_args(command: &str) -> Option> { match normalize_command_identity(command).as_str() { - "goose" => Some(vec!["acp".to_string()]), + "goose" | "opencode" | "open-code" => Some(vec!["acp".to_string()]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 0ed4fe0f6a..9d9ef2a715 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -11,7 +11,7 @@ use super::{ GOOSE_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; - +mod opencode; #[test] fn resolves_known_avatar_for_bare_command() { let avatar_url = managed_agent_avatar_url("goose").expect("goose avatar should resolve"); diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/opencode.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/opencode.rs new file mode 100644 index 0000000000..b1b872e237 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/opencode.rs @@ -0,0 +1,21 @@ +use super::super::{managed_agent_avatar_url, normalize_agent_args, OPENCODE_AVATAR_URL}; + +#[test] +fn resolves_opencode_avatar() { + assert_eq!( + managed_agent_avatar_url("/usr/local/bin/opencode"), + Some(OPENCODE_AVATAR_URL.to_string()) + ); +} + +#[test] +fn normalizes_opencode_args_to_acp() { + assert_eq!( + normalize_agent_args("opencode", Vec::new()), + vec!["acp".to_string()] + ); + assert_eq!( + normalize_agent_args("open-code", Vec::new()), + vec!["acp".to_string()] + ); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 77b3a241e0..95ae0fb1b0 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -39,6 +39,7 @@ pub(crate) const KNOWN_AGENT_BINARIES: &[&str] = &[ "codex-acp", "codex_acp", "goose", + "opencode", // buzz-dev-mcp's multicall personalities (rg, tree, buzz, // git-credential-nostr, git-sign-nostr) are short-lived per-tool-call // invocations — not listed here. diff --git a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs index d0690cefc3..9dc373b255 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs +++ b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs @@ -141,9 +141,10 @@ test("runtimeSupportsLlmProviderSelection is true for buzz-agent and goose", () assert.equal(runtimeSupportsLlmProviderSelection("goose"), true); }); -test("runtimeSupportsLlmProviderSelection is false for codex and claude", () => { +test("runtimeSupportsLlmProviderSelection is false for codex, claude, and opencode", () => { assert.equal(runtimeSupportsLlmProviderSelection("codex"), false); assert.equal(runtimeSupportsLlmProviderSelection("claude"), false); + assert.equal(runtimeSupportsLlmProviderSelection("opencode"), false); }); test("resetConfigForHarnessChange clears harness-specific values", () => { diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 6ae81ff6cb..6d0e5c3d8a 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -133,6 +133,7 @@ const PERSONA_MODEL_OPTIONS_BY_RUNTIME: Record< "buzz-agent": [DEFAULT_MODEL_OPTION], claude: [DEFAULT_MODEL_OPTION], codex: [DEFAULT_MODEL_OPTION], + opencode: [DEFAULT_MODEL_OPTION], }; export function getRuntimePersonaModelOptions( @@ -151,7 +152,7 @@ function isKnownLlmProvider( * Required credential env keys for the given runtime + provider combination. * Derived from PROVIDER_CREDENTIAL_CONFIG — single source of truth. * - * buzz-agent and goose use provider-specific credentials; claude and codex + * buzz-agent and goose use provider-specific credentials; claude, codex, and opencode * handle auth via CLI login (surfaced separately via the CliLogin surface). */ export function requiredCredentialEnvKeys( diff --git a/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs b/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs index 967bd3e129..8bbbfbcfcf 100644 --- a/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs +++ b/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs @@ -195,6 +195,7 @@ test("resolveRuntimeProviderCapability classifies known CLI-login runtimes as lo // The core fix: a not-yet-loaded catalog must not force these to "unknown". assert.equal(resolveRuntimeProviderCapability("claude", false), "locked"); assert.equal(resolveRuntimeProviderCapability("codex", false), "locked"); + assert.equal(resolveRuntimeProviderCapability("opencode", false), "locked"); assert.equal(resolveRuntimeProviderCapability(" claude ", false), "locked"); }); diff --git a/desktop/src/features/agents/ui/personaRuntimeModel.ts b/desktop/src/features/agents/ui/personaRuntimeModel.ts index d8da4108c9..0a59c92565 100644 --- a/desktop/src/features/agents/ui/personaRuntimeModel.ts +++ b/desktop/src/features/agents/ui/personaRuntimeModel.ts @@ -18,7 +18,7 @@ export type ProviderRuntimeCapability = "capable" | "locked" | "unknown"; * provider. To avoid that, we resolve capability STATICALLY for known ids: * * - buzz-agent / goose → "capable" (`isProviderCapable`, id-based). - * - claude / codex → "locked" (CLI-login runtimes; no LLM provider selection). + * - claude / codex / opencode → "locked" (CLI-login runtimes; no LLM provider selection). * - anything else (custom, empty, genuinely unknown) → "unknown". * * `isProviderCapable` is the caller-supplied {@link @@ -33,7 +33,7 @@ export function resolveRuntimeProviderCapability( return "capable"; } const id = runtimeId.trim(); - if (id === "claude" || id === "codex") { + if (id === "claude" || id === "codex" || id === "opencode") { return "locked"; } return "unknown";