Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | | |
Expand Down Expand Up @@ -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) │ │ │
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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).
Expand Down
16 changes: 15 additions & 1 deletion crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,8 @@ pub(crate) fn normalize_agent_command_identity(command: &str) -> String {

fn default_agent_args(command: &str) -> Option<Vec<String>> {
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,
Expand Down Expand Up @@ -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!(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
155 changes: 155 additions & 0 deletions desktop/src-tauri/src/managed_agents/config_bridge/opencode.rs
Original file line number Diff line number Diff line change
@@ -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<RuntimeFileConfig> {
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<RuntimeFileConfig> {
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<String> {
val.get(key)?
.as_str()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}

pub(crate) fn opencode_config_path() -> Option<PathBuf> {
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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down Expand Up @@ -222,6 +223,9 @@ fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option<String>
"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,
}
}
Expand Down
35 changes: 34 additions & 1 deletion desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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"),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -342,7 +375,7 @@ pub use overrides::{apply_agent_command_update, create_time_agent_command_overri

fn default_agent_args(command: &str) -> Option<Vec<String>> {
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,
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/managed_agents/discovery/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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()]
);
}
1 change: 1 addition & 0 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading