From 49e21f2ad1d49f45508dd4a641595b63c7b55b03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Vitor=20Alves?= Date: Thu, 8 Oct 2026 14:59:35 -0300 Subject: [PATCH 1/2] Add opencode agent support via ACP --- src-tauri/src/agents/detect.rs | 29 ++++++++++++++++++++++++++--- src-tauri/src/agents/policy.rs | 1 + src-tauri/src/core/mentions.rs | 2 +- src/features/claude/agents.ts | 9 +++++++++ src/lib/mentions.ts | 2 +- 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/agents/detect.rs b/src-tauri/src/agents/detect.rs index 8c68f7ff..b14f9040 100644 --- a/src-tauri/src/agents/detect.rs +++ b/src-tauri/src/agents/detect.rs @@ -16,12 +16,18 @@ pub enum AgentKind { Claude, Codex, Gemini, + Opencode, } impl AgentKind { - pub const ALL: [AgentKind; 3] = [AgentKind::Claude, AgentKind::Codex, AgentKind::Gemini]; + pub const ALL: [AgentKind; 4] = [ + AgentKind::Claude, + AgentKind::Codex, + AgentKind::Gemini, + AgentKind::Opencode, + ]; /// Agents exposed to the app. Gemini launch code is kept intact — add it here to enable it. - pub const ENABLED: [AgentKind; 2] = [AgentKind::Claude, AgentKind::Codex]; + pub const ENABLED: [AgentKind; 3] = [AgentKind::Claude, AgentKind::Codex, AgentKind::Opencode]; pub fn is_enabled(self) -> bool { Self::ENABLED.contains(&self) @@ -42,6 +48,7 @@ impl AgentKind { "claude" => Some(Self::Claude), "codex" => Some(Self::Codex), "gemini" => Some(Self::Gemini), + "opencode" => Some(Self::Opencode), _ => None, } } @@ -51,6 +58,7 @@ impl AgentKind { Self::Claude => "claude", Self::Codex => "codex", Self::Gemini => "gemini", + Self::Opencode => "opencode", } } @@ -59,6 +67,7 @@ impl AgentKind { Self::Claude => "Claude Code", Self::Codex => "Codex", Self::Gemini => "Gemini", + Self::Opencode => "opencode", } } @@ -67,6 +76,7 @@ impl AgentKind { Self::Claude => "claude", Self::Codex => "codex", Self::Gemini => "gemini", + Self::Opencode => "opencode", } } @@ -74,7 +84,7 @@ impl AgentKind { match self { Self::Claude => Some(("claude-agent-acp", CLAUDE_ACP_PACKAGE)), Self::Codex => Some(("codex-acp", CODEX_ACP_PACKAGE)), - Self::Gemini => None, + Self::Gemini | Self::Opencode => None, } } } @@ -242,6 +252,11 @@ pub async fn detect_with(kind: AgentKind, custom_path: Option<&str>) -> Detected args: vec![gemini_acp_flag(&cli).await.into()], env: vec![], }), + AgentKind::Opencode => Ok(LaunchSpec { + command: cli.clone(), + args: vec!["acp".into()], + env: vec![], + }), AgentKind::Codex => adapter_launch(kind, find_in_path("npx").as_deref()).map(|mut spec| { if std::env::var_os("CODEX_PATH").is_none() { spec.env @@ -254,6 +269,7 @@ pub async fn detect_with(kind: AgentKind, custom_path: Option<&str>) -> Detected info.authenticated = match kind { AgentKind::Claude => claude_authenticated(&cli).await, AgentKind::Codex => codex_authenticated(&cli).await, + AgentKind::Opencode => Some(true), AgentKind::Gemini => None, }; @@ -315,6 +331,11 @@ async fn detect_custom(kind: AgentKind, custom: PathBuf) -> DetectedAgent { args: vec![gemini_acp_flag(&custom).await.into()], env: vec![], }), + AgentKind::Opencode => Ok(LaunchSpec { + command: custom.clone(), + args: vec!["acp".into()], + env: vec![], + }), AgentKind::Codex => adapter_launch(kind, find_in_path("npx").as_deref()).map(|mut spec| { spec.env.push(("CODEX_PATH".into(), display.clone())); spec @@ -331,6 +352,7 @@ async fn detect_custom(kind: AgentKind, custom: PathBuf) -> DetectedAgent { info.authenticated = match (kind, auth_cli.as_deref()) { (AgentKind::Claude, Some(cli)) => claude_authenticated(cli).await, (AgentKind::Codex, Some(cli)) => codex_authenticated(cli).await, + (AgentKind::Opencode, _) => Some(true), _ => None, }; let launch = match launch { @@ -398,6 +420,7 @@ mod tests { fn claude_and_codex_enabled() { assert!(AgentKind::Claude.is_enabled()); assert!(AgentKind::Codex.is_enabled()); + assert!(AgentKind::Opencode.is_enabled()); assert!(!AgentKind::Gemini.is_enabled()); assert_eq!(AgentKind::Claude.path_setting_key(), "agent.claude.path"); } diff --git a/src-tauri/src/agents/policy.rs b/src-tauri/src/agents/policy.rs index e219067a..ebcb6202 100644 --- a/src-tauri/src/agents/policy.rs +++ b/src-tauri/src/agents/policy.rs @@ -90,6 +90,7 @@ impl RunPermissions { match (agent, self) { (AgentKind::Codex, Self::Bypass) => "agent-full-access", (AgentKind::Codex, _) => "read-only", + (AgentKind::Opencode, _) => "build", (_, Self::Bypass) => "bypassPermissions", _ => "default", } diff --git a/src-tauri/src/core/mentions.rs b/src-tauri/src/core/mentions.rs index cbe42918..2a754f5a 100644 --- a/src-tauri/src/core/mentions.rs +++ b/src-tauri/src/core/mentions.rs @@ -1,7 +1,7 @@ //! Agent mention detection (`@claude`, `@codex`) for review comments. /// Mention handles, one per agent id. The first mentioned handle decides which agent answers. -pub const AGENT_HANDLES: [&str; 2] = ["claude", "codex"]; +pub const AGENT_HANDLES: [&str; 3] = ["claude", "codex", "opencode"]; /// True when `body` mentions an agent (case-insensitive, whole word) outside fenced code blocks and inline code spans. pub fn mentions_agent(body: &str) -> bool { diff --git a/src/features/claude/agents.ts b/src/features/claude/agents.ts index 4f228b26..a417ab50 100644 --- a/src/features/claude/agents.ts +++ b/src/features/claude/agents.ts @@ -37,6 +37,15 @@ export const AGENTS: AgentMeta[] = [ login: 'Run `codex login` in a terminal to sign in.', pathHint: 'Point to the codex CLI or codex-acp.', }, + { + id: 'opencode', + name: 'opencode', + short: 'opencode', + handle: 'opencode', + install: 'npm i -g @opencode/cli', + login: 'Run `opencode auth login` in a terminal to sign in.', + pathHint: 'Point to the opencode CLI.', + }, ]; const AGENTS_KEY = ['agents']; diff --git a/src/lib/mentions.ts b/src/lib/mentions.ts index 3c5b8d57..d80cf56e 100644 --- a/src/lib/mentions.ts +++ b/src/lib/mentions.ts @@ -1,7 +1,7 @@ // Mirror of `crate::core::mentions` (src-tauri): `@claude` / `@codex` (case-insensitive, whole word) outside code. /** Mention handles, one per agent id. */ -export const AGENT_HANDLES = ['claude', 'codex'] as const; +export const AGENT_HANDLES = ['claude', 'codex', 'opencode'] as const; function stripCode(body: string): string { const out: string[] = []; From a82b5f812f5a65e2c7adcedb2f85f5d877831088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Vitor=20Alves?= Date: Thu, 8 Oct 2026 15:09:52 -0300 Subject: [PATCH 2/2] Use opencode tool names in prompts and skip permission prompts for diffity tools --- src-tauri/src/agents/manager.rs | 7 +++++++ src-tauri/src/agents/session.rs | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/agents/manager.rs b/src-tauri/src/agents/manager.rs index ec6e3de5..747a36d7 100644 --- a/src-tauri/src/agents/manager.rs +++ b/src-tauri/src/agents/manager.rs @@ -498,6 +498,13 @@ impl AgentManager { None => prompt, }; + // opencode exposes MCP tools as `tools..` instead of `mcp____`. + let prompt = if kind == Some(AgentKind::Opencode) { + prompt.replace("mcp__diffity__", "tools.diffity.") + } else { + prompt + }; + let rt = self.runtime(&rec, binding).await?; let user = ChatMessageContent::User(UserMessageContent { diff --git a/src-tauri/src/agents/session.rs b/src-tauri/src/agents/session.rs index 4405105e..0669c603 100644 --- a/src-tauri/src/agents/session.rs +++ b/src-tauri/src/agents/session.rs @@ -315,13 +315,13 @@ impl Shared { /// Diffity's own MCP tools are already gated by mode on the bridge, so they never need a user prompt. /// Matched by exact name so a shell command that merely mentions a tool doesn't pass: Claude titles -/// them `mcp__diffity__add_comment`, Codex `mcp.diffity.add_comment`. +/// them `mcp__diffity__add_comment`, Codex `mcp.diffity.add_comment`, opencode `tools.diffity.add_comment`. fn is_own_tool(title: Option<&str>) -> bool { let Some(title) = title else { return false; }; let lower = title.trim().to_ascii_lowercase(); - let Some(rest) = ["mcp__diffity__", "mcp.diffity.", "diffity."] + let Some(rest) = ["mcp__diffity__", "mcp.diffity.", "diffity.", "tools.diffity."] .iter() .find_map(|prefix| lower.strip_prefix(prefix)) else {