From bffcea28236cb327256cd92f355e191c6c6bf9cb Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 31 Mar 2026 14:47:50 +1100 Subject: [PATCH] feat: use acp-client from builderbot repo as git dependency (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: use acp-client from builderbot repo as git dependency Remove the local acp-client crate (crates/acp-client/) and replace it with a git dependency pointing to block/builderbot on the main branch. The builderbot version is a strict superset of the local copy — same public API (AcpDriver, AgentDriver, MessageWriter, Store, discover_providers) plus remote workspace support, concurrent provider discovery, structured logging, and graceful shutdown. Changes: - Remove 'crates/acp-client' from workspace.members in Cargo.toml - Replace path dependency with git dependency (block/builderbot, main) - Delete src-tauri/crates/acp-client/ directory (5 files, ~1640 lines) - Verified cargo check passes with no code changes needed in goose2 * fix: pin acp-client to rev and clean up config - Pin acp-client git dependency to rev 5af819e instead of branch=main for reproducible builds - Remove redundant single-member [workspace] table from Cargo.toml - Remove deprecated baseUrl from tsconfig.json (TS 5.9+ deprecation) Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update acp-client pin to latest merge commit Pin acp-client to rev 17e6098 (builderbot PR #554 merge commit) to pick up the latest acp crate changes. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- ui/goose2/src-tauri/Cargo.lock | 37 + ui/goose2/src-tauri/Cargo.toml | 5 +- .../src-tauri/crates/acp-client/Cargo.toml | 14 - .../src-tauri/crates/acp-client/src/driver.rs | 1044 ----------------- .../src-tauri/crates/acp-client/src/lib.rs | 47 - .../src-tauri/crates/acp-client/src/simple.rs | 331 ------ .../src-tauri/crates/acp-client/src/types.rs | 202 ---- ui/goose2/tsconfig.json | 1 - 8 files changed, 38 insertions(+), 1643 deletions(-) delete mode 100644 ui/goose2/src-tauri/crates/acp-client/Cargo.toml delete mode 100644 ui/goose2/src-tauri/crates/acp-client/src/driver.rs delete mode 100644 ui/goose2/src-tauri/crates/acp-client/src/lib.rs delete mode 100644 ui/goose2/src-tauri/crates/acp-client/src/simple.rs delete mode 100644 ui/goose2/src-tauri/crates/acp-client/src/types.rs diff --git a/ui/goose2/src-tauri/Cargo.lock b/ui/goose2/src-tauri/Cargo.lock index afa79efe81..556b1763d1 100644 --- a/ui/goose2/src-tauri/Cargo.lock +++ b/ui/goose2/src-tauri/Cargo.lock @@ -5,10 +5,14 @@ version = 4 [[package]] name = "acp-client" version = "0.1.0" +source = "git+https://github.com/block/builderbot?rev=17e609814900afdb2a437523a9756f7ef1b80469#17e609814900afdb2a437523a9756f7ef1b80469" dependencies = [ "agent-client-protocol", "anyhow", "async-trait", + "blox-cli", + "log", + "nix", "serde", "serde_json", "tokio", @@ -352,6 +356,18 @@ dependencies = [ "piper", ] +[[package]] +name = "blox-cli" +version = "0.1.0" +source = "git+https://github.com/block/builderbot?rev=17e609814900afdb2a437523a9756f7ef1b80469#17e609814900afdb2a437523a9756f7ef1b80469" +dependencies = [ + "log", + "serde", + "serde_json", + "thiserror 2.0.18", + "wait-timeout", +] + [[package]] name = "brotli" version = "8.0.2" @@ -2394,6 +2410,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -5083,6 +5111,15 @@ dependencies = [ "libc", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/ui/goose2/src-tauri/Cargo.toml b/ui/goose2/src-tauri/Cargo.toml index 63d5531b4f..5180553672 100644 --- a/ui/goose2/src-tauri/Cargo.toml +++ b/ui/goose2/src-tauri/Cargo.toml @@ -1,6 +1,3 @@ -[workspace] -members = [".", "crates/acp-client"] - [package] name = "goose2" version = "0.1.0" @@ -34,4 +31,4 @@ futures = "0.3" tokio-stream = "0.1" tokio-util = { version = "0.7", features = ["rt"] } async-trait = "0.1" -acp-client = { path = "crates/acp-client" } +acp-client = { git = "https://github.com/block/builderbot", rev = "17e609814900afdb2a437523a9756f7ef1b80469" } diff --git a/ui/goose2/src-tauri/crates/acp-client/Cargo.toml b/ui/goose2/src-tauri/crates/acp-client/Cargo.toml deleted file mode 100644 index 1e0ab1b24b..0000000000 --- a/ui/goose2/src-tauri/crates/acp-client/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "acp-client" -version = "0.1.0" -edition = "2021" -description = "Full-featured client for ACP (Agent Client Protocol) agents like Goose and Claude Code" - -[dependencies] -agent-client-protocol = { version = "0.10", features = ["unstable"] } -tokio = { version = "1", features = ["process", "io-util", "time", "sync", "rt", "macros"] } -tokio-util = { version = "0.7", features = ["compat"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -anyhow = "1" -async-trait = "0.1" diff --git a/ui/goose2/src-tauri/crates/acp-client/src/driver.rs b/ui/goose2/src-tauri/crates/acp-client/src/driver.rs deleted file mode 100644 index 127a8dbf8e..0000000000 --- a/ui/goose2/src-tauri/crates/acp-client/src/driver.rs +++ /dev/null @@ -1,1044 +0,0 @@ -//! Full-featured ACP driver for session management and streaming. -//! -//! This module provides the complete ACP integration including: -//! - Session initialization and resumption -//! - Streaming text and tool calls -//! - Permission handling -//! - Cancellation support - -use std::collections::HashSet; -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use agent_client_protocol::{ - Agent, ClientSideConnection, ContentBlock as AcpContentBlock, ImageContent, Implementation, - InitializeRequest, LoadSessionRequest, McpServer, NewSessionRequest, PermissionOptionId, - PromptRequest, ProtocolVersion, RequestPermissionOutcome, RequestPermissionRequest, - RequestPermissionResponse, SelectedPermissionOutcome, SessionNotification, SessionUpdate, - TextContent, -}; -use async_trait::async_trait; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; -use tokio::process::Command; -use tokio::sync::Mutex; -use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; -use tokio_util::sync::CancellationToken; - -// ============================================================================= -// Public traits and types -// ============================================================================= - -/// Protocol-agnostic message writer — streams agent output. -/// -/// This trait allows different storage backends (database, in-memory, etc.) -/// to receive streaming agent output without coupling to the ACP protocol. -#[async_trait] -pub trait MessageWriter: Send + Sync { - /// Append a text chunk to the current assistant message. - async fn append_text(&self, text: &str); - - /// Flush all buffered text and close the current message block. - async fn finalize(&self); - - /// Record a tool call with its ID and title. - async fn record_tool_call(&self, tool_call_id: &str, title: &str); - - /// Update a previously recorded tool call's title. - async fn update_tool_call_title(&self, tool_call_id: &str, title: &str); - - /// Record the result/output of a tool call. - async fn record_tool_result(&self, content: &str); -} - -/// Storage interface for persisting agent session data. -/// -/// This trait abstracts the storage backend, allowing different implementations -/// (SQLite, PostgreSQL, in-memory, etc.) without changing the driver logic. -#[async_trait] -pub trait Store: Send + Sync { - /// Save the agent's session ID for resumption. - fn set_agent_session_id(&self, session_id: &str, agent_session_id: &str) -> Result<(), String>; - - /// Retrieve existing session messages as `(role, content)` pairs. - /// - /// Used during session resumption to match replayed notifications - /// against previously persisted messages. The default implementation - /// returns an empty list, which is correct for stores that do not - /// support message persistence (e.g. `NoOpStore`). - fn get_session_messages(&self, _session_id: &str) -> Result, String> { - Ok(vec![]) - } -} - -/// Everything needed to run one turn of an agent. -/// -/// Implementors own the protocol details (spawning a process, connecting, -/// sending the prompt, translating streaming events into [`MessageWriter`] -/// calls). -#[async_trait(?Send)] -#[allow(clippy::too_many_arguments)] -pub trait AgentDriver { - /// Run a single turn: send `prompt`, stream results via `writer`. - /// - /// `images` contains `(base64_data, mime_type)` pairs that are sent as - /// `ContentBlock::Image` entries alongside the text prompt. - async fn run( - &self, - session_id: &str, - prompt: &str, - images: &[(String, String)], - working_dir: &Path, - store: &Arc, - writer: &Arc, - cancel_token: &CancellationToken, - agent_session_id: Option<&str>, - ) -> Result<(), String>; -} - -// ============================================================================= -// AcpDriver — the main driver implementation -// ============================================================================= - -/// The main ACP driver for spawning and communicating with ACP agents. -/// -/// Handles session lifecycle, streaming notifications, permission handling, -/// and MCP server injection. -pub struct AcpDriver { - binary_path: PathBuf, - acp_args: Vec, - agent_label: String, - /// Extra environment variables to pass to the agent process. - extra_env: Vec<(String, String)>, - /// MCP servers to inject into the session via NewSessionRequest. - mcp_servers: Vec, -} - -const ACP_SETUP_TIMEOUT: Duration = Duration::from_secs(90); - -impl AcpDriver { - /// Create a driver for the given provider ID (e.g. "goose", "claude"). - /// - /// Looks up the agent in `KNOWN_AGENTS`, locates the binary on disk, - /// and returns a ready-to-use driver. - pub fn new(provider_id: &str) -> Result { - crate::types::find_acp_agent_by_id(provider_id) - .map(|agent| Self { - binary_path: agent.binary_path, - acp_args: agent.acp_args, - agent_label: agent.label, - extra_env: Vec::new(), - mcp_servers: Vec::new(), - }) - .ok_or_else(|| format!("Unknown or unavailable agent provider: {provider_id}")) - } - - /// Create a driver for the first available provider. - pub fn first_available() -> Result { - crate::types::find_acp_agent() - .map(|agent| Self { - binary_path: agent.binary_path, - acp_args: agent.acp_args, - agent_label: agent.label, - extra_env: Vec::new(), - mcp_servers: Vec::new(), - }) - .ok_or_else(|| { - "No ACP agent found. Install Goose, Claude Code, Codex, Pi, or Amp and ensure it's on your PATH." - .to_string() - }) - } - - /// Set extra environment variables to pass to the agent process. - pub fn with_extra_env(mut self, vars: Vec<(String, String)>) -> Self { - self.extra_env = vars; - self - } - - /// Set MCP servers to inject into the session via `NewSessionRequest` or `LoadSessionRequest`. - pub fn with_mcp_servers(mut self, servers: Vec) -> Self { - self.mcp_servers = servers; - self - } -} - -/// Shell-escape a value by wrapping it in single quotes with interior quotes -/// escaped via the standard `'\''` trick. -fn shell_quote(s: &str) -> String { - format!("'{}'", s.replace('\'', "'\\''")) -} - -#[async_trait(?Send)] -impl AgentDriver for AcpDriver { - async fn run( - &self, - session_id: &str, - prompt: &str, - images: &[(String, String)], - working_dir: &Path, - store: &Arc, - writer: &Arc, - cancel_token: &CancellationToken, - agent_session_id: Option<&str>, - ) -> Result<(), String> { - // For local sessions we need Hermit (and similar directory-based shell - // hooks) to activate before the agent binary runs. We match the - // approach used by the actions executor: spawn an interactive login - // shell with `-s` (stdin mode) in the working directory with a clean - // environment. The shell initialises fully (`.zshrc` installs hooks), - // `precmd` fires in the working directory (activating Hermit), then we - // write an `exec ` command to stdin. `exec` replaces the shell - // with the agent binary so all subsequent stdin/stdout traffic is the - // JSON-RPC protocol. - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()); - let mut cmd = Command::new(&shell); - cmd.current_dir(working_dir) // start in project dir so precmd sees hermit config - .env_clear() // clean slate — shell init rebuilds the environment - .env("HOME", std::env::var("HOME").unwrap_or_default()) - .env("USER", std::env::var("USER").unwrap_or_default()) - .env("SHELL", &shell) - .arg("-i") // interactive: ensures hooks like precmd/chpwd are installed - .arg("-l") // login: loads full profile / environment - .arg("-s"); // read commands from stdin (after init completes) - - cmd.stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .kill_on_drop(true); - for (k, v) in &self.extra_env { - cmd.env(k, v); - } - let mut child = cmd.spawn().map_err(|e| { - format!( - "Failed to spawn {} (binary: {}, cwd: {}): {e}", - self.agent_label, - self.binary_path.display(), - working_dir.display() - ) - })?; - - let mut stdin = child - .stdin - .take() - .ok_or_else(|| "Failed to get stdin".to_string())?; - - // Write the exec command to stdin. By the time the shell reads from - // stdin, init is complete and `precmd` has fired in the working - // directory (activating Hermit). `exec` replaces the shell with the - // agent binary — from this point on, stdin belongs to the agent's - // JSON-RPC transport. - let exec_line = format!( - "exec {} {}\n", - shell_quote(&self.binary_path.to_string_lossy()), - self.acp_args - .iter() - .map(|a| shell_quote(a)) - .collect::>() - .join(" ") - ); - stdin - .write_all(exec_line.as_bytes()) - .await - .map_err(|e| format!("Failed to write exec command to shell stdin: {e}"))?; - stdin - .flush() - .await - .map_err(|e| format!("Failed to flush shell stdin: {e}"))?; - - let stdout = child - .stdout - .take() - .ok_or_else(|| "Failed to get stdout".to_string())?; - - let stdin_compat = stdin.compat_write(); - - // Local shell init (.zshrc, plugin banners, Hermit activation) may - // write to stdout before `exec` replaces the shell. Filter out any - // non-JSON lines so they don't reach the JSON-RPC parser. - let (normalized_stdout_writer, normalized_stdout_reader) = tokio::io::duplex(64 * 1024); - tokio::task::spawn_local(async move { - if let Err(error) = normalize_local_acp_stdout(stdout, normalized_stdout_writer).await { - eprintln!("local ACP stdout normalization failed: {error}"); - } - }); - let stdout_compat = normalized_stdout_reader.compat(); - - let is_resuming = agent_session_id.is_some(); - let db_messages = if is_resuming { - store.get_session_messages(session_id).unwrap_or_else(|e| { - eprintln!("Failed to load session messages for replay matching: {e}"); - vec![] - }) - } else { - vec![] - }; - let handler = Arc::new(AcpNotificationHandler::new( - Arc::clone(writer), - is_resuming, - db_messages, - )); - let handler_for_conn = Arc::clone(&handler); - - let (connection, io_future) = - ClientSideConnection::new(handler_for_conn, stdin_compat, stdout_compat, |fut| { - tokio::task::spawn_local(fut); - }); - - tokio::task::spawn_local(async move { - if let Err(e) = io_future.await { - eprintln!("ACP IO error: {e:?}"); - } - }); - - let protocol_result = tokio::select! { - _ = cancel_token.cancelled() => { - writer.finalize().await; - return Ok(()); - } - result = run_acp_protocol( - &connection, working_dir, prompt, images, store, - session_id, agent_session_id, &handler, &self.mcp_servers, - ) => result, - }; - - writer.finalize().await; - let _ = child.kill().await; - - protocol_result - } -} - -/// Filter local ACP stdout, forwarding only valid JSON lines. -/// -/// Local shell initialization (`.zshrc`, Hermit activation, plugin banners) -/// may write non-JSON text to stdout before `exec` replaces the shell with -/// the agent binary. This function reads lines from the child's stdout and -/// only forwards those that parse as valid JSON, discarding everything else. -async fn normalize_local_acp_stdout( - stdout: R, - mut writer: tokio::io::DuplexStream, -) -> Result<(), std::io::Error> { - let mut reader = tokio::io::BufReader::new(stdout); - let mut line = String::new(); - - loop { - line.clear(); - let bytes_read = reader.read_line(&mut line).await?; - if bytes_read == 0 { - break; - } - - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - - if serde_json::from_str::(trimmed).is_ok() { - writer.write_all(trimmed.as_bytes()).await?; - writer.write_all(b"\n").await?; - } - } - - writer.shutdown().await -} - -// ============================================================================= -// ACP notification handler — phase-based replay-sync state machine -// ============================================================================= - -/// The current phase of the notification handler during session resumption. -enum HandlerPhase { - /// Accumulating replay notifications and matching against DB messages. - Replaying(ReplayBuffer), - /// Replay detected as complete; waiting for prompt to be sent. - /// All notifications are dropped; tool-call IDs are recorded. - WaitingForPrompt { - replayed_tool_call_ids: HashSet, - }, - /// Prompt has been sent; forwarding live notifications to the writer. - Live { - replayed_tool_call_ids: HashSet, - }, -} - -/// Accumulates replay notifications and matches them against DB messages. -struct ReplayBuffer { - /// `(role, content)` pairs from the DB, in order. - db_messages: Vec<(String, String)>, - /// Index into `db_messages` of the next message to match. - match_cursor: usize, - /// Index of the last non-user message in `db_messages`. - /// When the cursor passes this, replay is considered complete. - target_index: Option, - /// Text accumulated for the current streaming message. - current_text: String, - /// Role of the current streaming message (`"user"` or `"assistant"`). - current_role: Option, - /// Tool-call IDs observed during replay (used as a safety-net later). - replayed_tool_call_ids: HashSet, - /// Timestamp of the last notification received during replay. - last_notification_at: Instant, - /// Whether at least one notification has been received. - received_any: bool, -} - -impl ReplayBuffer { - fn new(db_messages: Vec<(String, String)>) -> Self { - // Find index of last non-user message. - let target_index = db_messages - .iter() - .enumerate() - .rev() - .find(|(_, (role, _))| role != "user") - .map(|(i, _)| i); - - Self { - db_messages, - match_cursor: 0, - target_index, - current_text: String::new(), - current_role: None, - replayed_tool_call_ids: HashSet::new(), - last_notification_at: Instant::now(), - received_any: false, - } - } - - /// Finalize the current streaming text and try to match it against DB. - /// Called when the role transitions (e.g. from assistant text to tool call). - /// Returns `true` if replay is now considered complete. - fn finalize_current(&mut self) -> bool { - if let Some(role) = self.current_role.take() { - if !self.current_text.is_empty() { - self.current_text.clear(); - return self.try_match(&role); - } - } - false - } - - /// Try to match a role against `db_messages[match_cursor]`. - /// Returns `true` if replay is now considered complete. - fn try_match(&mut self, role: &str) -> bool { - if self.match_cursor >= self.db_messages.len() { - return self.is_complete(); - } - - let (db_role, _) = &self.db_messages[self.match_cursor]; - - if role == db_role { - self.match_cursor += 1; - } - // Don't advance cursor on role mismatch. - - self.is_complete() - } - - /// Returns `true` if the match cursor has passed the target index. - fn is_complete(&self) -> bool { - match self.target_index { - Some(target) => self.match_cursor > target, - None => true, // No non-user messages → complete immediately - } - } -} - -struct AcpNotificationHandler { - writer: Arc, - phase: Mutex, - /// Signalled when replay matching determines all DB messages have been replayed. - replay_done: tokio::sync::Notify, -} - -impl AcpNotificationHandler { - fn new( - writer: Arc, - replaying: bool, - db_messages: Vec<(String, String)>, - ) -> Self { - let phase = if replaying { - HandlerPhase::Replaying(ReplayBuffer::new(db_messages)) - } else { - HandlerPhase::Live { - replayed_tool_call_ids: HashSet::new(), - } - }; - - Self { - writer, - phase: Mutex::new(phase), - replay_done: tokio::sync::Notify::new(), - } - } - - /// Check whether the replay phase has been idle for at least `timeout`. - /// Returns `false` if not in the Replaying phase or no notification received yet. - async fn is_replay_idle(&self, timeout: Duration) -> bool { - let phase = self.phase.lock().await; - if let HandlerPhase::Replaying(buf) = &*phase { - buf.received_any && buf.last_notification_at.elapsed() >= timeout - } else { - false - } - } - - /// Transition from Replaying to WaitingForPrompt. - /// Extracts the replayed_tool_call_ids from the ReplayBuffer. - async fn transition_to_waiting_for_prompt(&self) { - let mut phase = self.phase.lock().await; - let ids = match &mut *phase { - HandlerPhase::Replaying(buf) => std::mem::take(&mut buf.replayed_tool_call_ids), - HandlerPhase::WaitingForPrompt { .. } | HandlerPhase::Live { .. } => return, - }; - *phase = HandlerPhase::WaitingForPrompt { - replayed_tool_call_ids: ids, - }; - } - - /// Transition from WaitingForPrompt (or Replaying) to Live. - async fn transition_to_live(&self) { - let mut phase = self.phase.lock().await; - let ids = match &mut *phase { - HandlerPhase::WaitingForPrompt { - replayed_tool_call_ids, - } => std::mem::take(replayed_tool_call_ids), - HandlerPhase::Replaying(buf) => std::mem::take(&mut buf.replayed_tool_call_ids), - HandlerPhase::Live { .. } => return, - }; - *phase = HandlerPhase::Live { - replayed_tool_call_ids: ids, - }; - } -} - -#[async_trait(?Send)] -impl agent_client_protocol::Client for AcpNotificationHandler { - async fn request_permission( - &self, - args: RequestPermissionRequest, - ) -> agent_client_protocol::Result { - let option_id = args - .options - .first() - .map(|opt| opt.option_id.clone()) - .unwrap_or_else(|| PermissionOptionId::new("approve")); - - Ok(RequestPermissionResponse::new( - RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option_id)), - )) - } - - async fn session_notification( - &self, - notification: SessionNotification, - ) -> agent_client_protocol::Result<()> { - // Determine the action to take under the lock, then drop the lock - // before calling into the writer to avoid holding it across await points. - enum LiveAction { - AppendText(String), - RecordToolCall { - id: String, - title: String, - }, - ToolCallUpdate { - id: String, - title: Option, - result: Option, - }, - Ignore, - Drop, - } - - let live_action = { - let mut phase = self.phase.lock().await; - - match &mut *phase { - // -- Replaying phase: accumulate chunks, match against DB -- - HandlerPhase::Replaying(buf) => { - buf.last_notification_at = Instant::now(); - buf.received_any = true; - - // Record tool-call IDs for the safety-net. - if let Some(id) = notification_tool_call_id(¬ification.update) { - buf.replayed_tool_call_ids.insert(id); - } - - let completed = match ¬ification.update { - SessionUpdate::AgentMessageChunk(chunk) => { - if let AcpContentBlock::Text(text) = &chunk.content { - // If switching from non-assistant role, finalize previous. - let mut done = false; - if buf.current_role.as_deref() != Some("assistant") { - done = buf.finalize_current(); - buf.current_role = Some("assistant".to_string()); - } - buf.current_text.push_str(&text.text); - done - } else { - false - } - } - SessionUpdate::UserMessageChunk(chunk) => { - if let AcpContentBlock::Text(text) = &chunk.content { - let mut done = false; - if buf.current_role.as_deref() != Some("user") { - done = buf.finalize_current(); - buf.current_role = Some("user".to_string()); - } - buf.current_text.push_str(&text.text); - done - } else { - false - } - } - SessionUpdate::ToolCall(_tc) => { - buf.finalize_current(); - buf.try_match("tool_call") - } - SessionUpdate::ToolCallUpdate(update) => { - if update.fields.content.is_some() { - buf.finalize_current(); - buf.try_match("tool_result") - } else { - false - } - } - SessionUpdate::AgentThoughtChunk(_) => { - // Thinking is not persisted — ignore. - false - } - _ => false, - }; - - if completed { - self.replay_done.notify_one(); - } - return Ok(()); - } - - // -- WaitingForPrompt phase: drop everything, record tool-call IDs -- - HandlerPhase::WaitingForPrompt { - replayed_tool_call_ids, - } => { - if let Some(id) = notification_tool_call_id(¬ification.update) { - replayed_tool_call_ids.insert(id); - } - return Ok(()); - } - - // -- Live phase: determine action, then release lock -- - HandlerPhase::Live { - replayed_tool_call_ids, - } => { - // Safety net: drop notifications for tool-call IDs seen during replay. - if let Some(id) = notification_tool_call_id(¬ification.update) { - if replayed_tool_call_ids.contains(&id) { - return Ok(()); - } - } - - match ¬ification.update { - SessionUpdate::AgentMessageChunk(chunk) => { - if let AcpContentBlock::Text(text) = &chunk.content { - LiveAction::AppendText(text.text.clone()) - } else { - LiveAction::Drop - } - } - SessionUpdate::ToolCall(tool_call) => LiveAction::RecordToolCall { - id: tool_call.tool_call_id.0.to_string(), - title: tool_call.title.clone(), - }, - SessionUpdate::ToolCallUpdate(update) => { - let tc_id = update.tool_call_id.0.to_string(); - let title = update.fields.title.clone(); - let result = update - .fields - .content - .as_ref() - .and_then(|c| extract_content_preview(c)); - if title.is_some() || result.is_some() { - LiveAction::ToolCallUpdate { - id: tc_id, - title, - result, - } - } else { - LiveAction::Drop - } - } - _ => LiveAction::Ignore, - } - } - } - // phase lock is dropped here - }; - - // Execute the live action without holding the phase lock. - match live_action { - LiveAction::AppendText(text) => { - self.writer.append_text(&text).await; - } - LiveAction::RecordToolCall { id, title } => { - self.writer.record_tool_call(&id, &title).await; - } - LiveAction::ToolCallUpdate { id, title, result } => { - if let Some(title) = title { - self.writer.update_tool_call_title(&id, &title).await; - } - if let Some(preview) = result { - self.writer.record_tool_result(&preview).await; - } - } - LiveAction::Ignore => {} - LiveAction::Drop => {} - } - Ok(()) - } -} - -/// Extract the tool-call ID from a session update, if it carries one. -fn notification_tool_call_id(update: &SessionUpdate) -> Option { - match update { - SessionUpdate::ToolCall(tc) => Some(tc.tool_call_id.0.to_string()), - SessionUpdate::ToolCallUpdate(tcu) => Some(tcu.tool_call_id.0.to_string()), - _ => None, - } -} - -// ============================================================================= -// Protocol helpers -// ============================================================================= - -#[allow(clippy::too_many_arguments)] -async fn run_acp_protocol( - connection: &ClientSideConnection, - working_dir: &Path, - prompt: &str, - images: &[(String, String)], - store: &Arc, - our_session_id: &str, - acp_session_id: Option<&str>, - handler: &Arc, - mcp_servers: &[McpServer], -) -> Result<(), String> { - let agent_session_id = tokio::time::timeout( - ACP_SETUP_TIMEOUT, - setup_acp_session( - connection, - working_dir, - store, - our_session_id, - acp_session_id, - mcp_servers, - ), - ) - .await - .map_err(|_| { - format!( - "Timed out waiting for ACP protocol startup after {}s", - ACP_SETUP_TIMEOUT.as_secs() - ) - })??; - - // If resuming, wait for replay to complete (content match OR idle timeout). - // An absolute 10s timeout prevents a hang if the server sends zero replay - // notifications (e.g. the session was garbage-collected). - if acp_session_id.is_some() { - let absolute_deadline = tokio::time::Instant::now() + Duration::from_secs(10); - loop { - tokio::select! { - _ = handler.replay_done.notified() => { - break; - } - _ = tokio::time::sleep_until(absolute_deadline) => { - eprintln!("Replay-wait absolute timeout reached (10s) — proceeding"); - break; - } - _ = tokio::time::sleep(Duration::from_millis(100)) => { - if handler.is_replay_idle(Duration::from_secs(1)).await { - break; - } - } - } - } - handler.transition_to_waiting_for_prompt().await; - } - - let mut content_blocks = vec![AcpContentBlock::Text(TextContent::new(prompt))]; - for (data, mime_type) in images { - content_blocks.push(AcpContentBlock::Image(ImageContent::new( - data.as_str(), - mime_type.as_str(), - ))); - } - let prompt_request = PromptRequest::new(agent_session_id, content_blocks); - - handler.transition_to_live().await; - - connection - .prompt(prompt_request) - .await - .map_err(|e| format!("Prompt failed: {e:?}"))?; - - Ok(()) -} - -async fn setup_acp_session( - connection: &ClientSideConnection, - working_dir: &Path, - store: &Arc, - our_session_id: &str, - acp_session_id: Option<&str>, - mcp_servers: &[McpServer], -) -> Result { - let client_info = Implementation::new("acp-client", env!("CARGO_PKG_VERSION")); - let init_request = InitializeRequest::new(ProtocolVersion::LATEST).client_info(client_info); - - let init_response = connection - .initialize(init_request) - .await - .map_err(|e| format!("ACP init failed: {e:?}"))?; - - if !mcp_servers.is_empty() { - let mcp_caps = &init_response.agent_capabilities.mcp_capabilities; - let requires_http = mcp_servers - .iter() - .any(|server| matches!(server, McpServer::Http(_))); - let requires_sse = mcp_servers - .iter() - .any(|server| matches!(server, McpServer::Sse(_))); - - if (requires_http && !mcp_caps.http) || (requires_sse && !mcp_caps.sse) { - return Err(format!( - "Agent does not support required MCP transports (required: http={}, sse={}; agent: http={}, sse={}). Select a provider with MCP support for project tools.", - requires_http, - requires_sse, - mcp_caps.http, - mcp_caps.sse - )); - } - } - - match acp_session_id { - Some(existing_id) => { - if !init_response.agent_capabilities.load_session { - return Err( - "Agent does not support load_session — cannot resume conversation".to_string(), - ); - } - - connection - .load_session( - LoadSessionRequest::new(existing_id.to_string(), working_dir.to_path_buf()) - .mcp_servers(mcp_servers.to_vec()), - ) - .await - .map_err(|e| format!("Failed to load ACP session: {e:?}"))?; - - Ok(existing_id.to_string()) - } - None => { - let new_session_request = - NewSessionRequest::new(working_dir.to_path_buf()).mcp_servers(mcp_servers.to_vec()); - let session_response = connection - .new_session(new_session_request) - .await - .map_err(|e| format!("Failed to create ACP session: {e:?}"))?; - - let new_id = session_response.session_id.to_string(); - store - .set_agent_session_id(our_session_id, &new_id) - .map_err(|e| format!("Failed to save agent session ID: {e}"))?; - Ok(new_id) - } - } -} - -/// Strip outer markdown code fences from tool-result content. -/// -/// Agents often wrap results in ``` fences which are redundant in our `
` display.
-/// The closing fence may be absent when content was truncated by the preview limit.
-pub fn strip_code_fences(content: &str) -> String {
-    let trimmed = content.trim();
-    if let Some(after_open) = trimmed.strip_prefix("```") {
-        if let Some(nl) = after_open.find('\n') {
-            let body = after_open[nl + 1..].trim_end();
-            return body
-                .strip_suffix("```")
-                .unwrap_or(body)
-                .trim_end()
-                .to_string();
-        }
-    }
-    content.to_string()
-}
-
-fn extract_content_preview(content: &[agent_client_protocol::ToolCallContent]) -> Option {
-    for item in content {
-        match item {
-            agent_client_protocol::ToolCallContent::Content(c) => {
-                if let AcpContentBlock::Text(text) = &c.content {
-                    let preview: String = text.text.chars().take(500).collect();
-                    return Some(if text.text.len() > 500 {
-                        format!("{preview}...")
-                    } else {
-                        preview
-                    });
-                }
-            }
-            agent_client_protocol::ToolCallContent::Diff(d) => {
-                return Some(format!(
-                    "{}{}",
-                    d.path.display(),
-                    if d.old_text.is_some() {
-                        " (modified)"
-                    } else {
-                        " (new)"
-                    }
-                ));
-            }
-            agent_client_protocol::ToolCallContent::Terminal(t) => {
-                return Some(format!("Terminal: {}", t.terminal_id.0));
-            }
-            _ => {}
-        }
-    }
-    None
-}
-
-// =============================================================================
-// Basic MessageWriter implementation
-// =============================================================================
-
-/// Simple in-memory message writer for basic usage.
-///
-/// Collects streamed text and tool call information in memory.
-/// Useful for tests and simple one-shot prompting.
-pub struct BasicMessageWriter {
-    text: Mutex,
-    last_flush_at: Mutex,
-}
-
-impl BasicMessageWriter {
-    /// Create a new empty message writer.
-    pub fn new() -> Self {
-        Self {
-            text: Mutex::new(String::new()),
-            last_flush_at: Mutex::new(Instant::now()),
-        }
-    }
-
-    /// Get the accumulated text.
-    pub async fn get_text(&self) -> String {
-        self.text.lock().await.clone()
-    }
-}
-
-impl Default for BasicMessageWriter {
-    fn default() -> Self {
-        Self::new()
-    }
-}
-
-#[async_trait]
-impl MessageWriter for BasicMessageWriter {
-    async fn append_text(&self, text: &str) {
-        let mut current = self.text.lock().await;
-        current.push_str(text);
-        *self.last_flush_at.lock().await = Instant::now();
-    }
-
-    async fn finalize(&self) {
-        // Nothing to do for basic implementation
-    }
-
-    async fn record_tool_call(&self, _tool_call_id: &str, title: &str) {
-        let mut current = self.text.lock().await;
-        current.push_str(&format!("\n[Tool: {}]\n", title));
-    }
-
-    async fn update_tool_call_title(&self, _tool_call_id: &str, _title: &str) {
-        // Nothing to do for basic implementation
-    }
-
-    async fn record_tool_result(&self, content: &str) {
-        let mut current = self.text.lock().await;
-        current.push_str(&format!("\n[Result: {}]\n", content));
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::shell_quote;
-
-    #[test]
-    fn shell_quote_simple_value() {
-        assert_eq!(
-            shell_quote("/usr/local/bin/goose"),
-            "'/usr/local/bin/goose'"
-        );
-    }
-
-    #[test]
-    fn shell_quote_escapes_single_quotes() {
-        assert_eq!(shell_quote("it's here"), "'it'\\''s here'");
-    }
-
-    #[test]
-    fn shell_quote_preserves_spaces() {
-        assert_eq!(shell_quote("/path/with space"), "'/path/with space'");
-    }
-
-    #[tokio::test]
-    async fn local_stdout_normalization_filters_non_json() {
-        use super::normalize_local_acp_stdout;
-        use tokio::io::AsyncReadExt;
-
-        let input = b"Hermit environment /home/user/.hermit activated\n\
-                       {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":null}\n\
-                       some banner text\n\
-                       {\"jsonrpc\":\"2.0\",\"id\":2,\"result\":null}\n";
-
-        let (writer, mut reader) = tokio::io::duplex(64 * 1024);
-        let input_reader = &input[..];
-
-        normalize_local_acp_stdout(input_reader, writer)
-            .await
-            .expect("normalization should succeed");
-
-        let mut output = String::new();
-        reader
-            .read_to_string(&mut output)
-            .await
-            .expect("read should succeed");
-
-        assert_eq!(
-            output,
-            "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":null}\n\
-             {\"jsonrpc\":\"2.0\",\"id\":2,\"result\":null}\n"
-        );
-    }
-
-    #[tokio::test]
-    async fn local_stdout_normalization_passes_empty_input() {
-        use super::normalize_local_acp_stdout;
-        use tokio::io::AsyncReadExt;
-
-        let input = b"";
-        let (writer, mut reader) = tokio::io::duplex(64 * 1024);
-
-        normalize_local_acp_stdout(&input[..], writer)
-            .await
-            .expect("normalization should succeed");
-
-        let mut output = String::new();
-        reader
-            .read_to_string(&mut output)
-            .await
-            .expect("read should succeed");
-
-        assert!(output.is_empty());
-    }
-}
diff --git a/ui/goose2/src-tauri/crates/acp-client/src/lib.rs b/ui/goose2/src-tauri/crates/acp-client/src/lib.rs
deleted file mode 100644
index 8eb27e0cc2..0000000000
--- a/ui/goose2/src-tauri/crates/acp-client/src/lib.rs
+++ /dev/null
@@ -1,47 +0,0 @@
-//! ACP Client - Full-featured client for Agent Client Protocol (ACP)
-//!
-//! This library provides comprehensive ACP support including:
-//! - Agent discovery and binary lookup
-//! - Full session management with history restoration
-//! - Streaming events (text, tool calls, updates)
-//! - Permission handling
-//! - Cancellation and graceful shutdown
-//!
-//! # Architecture
-//!
-//! The library provides two main interfaces:
-//!
-//! 1. **High-level driver interface** (`AcpDriver`) - For applications that need
-//!    full session orchestration, streaming, and database integration
-//! 2. **Simple one-shot interface** (`run_acp_prompt`) - For simple prompting
-//!    without session management
-//!
-//! # Example (Simple)
-//!
-//! ```rust,no_run
-//! use acp_client::{find_acp_agent, run_acp_prompt};
-//! use std::path::Path;
-//!
-//! #[tokio::main(flavor = "current_thread")]
-//! async fn main() -> anyhow::Result<()> {
-//!     let agent = find_acp_agent().ok_or_else(|| anyhow::anyhow!("No ACP agent found"))?;
-//!     let response = run_acp_prompt(&agent, Path::new("."), "Hello!").await?;
-//!     println!("Agent response: {}", response);
-//!     Ok(())
-//! }
-//! ```
-
-mod driver;
-mod simple;
-mod types;
-
-// Re-export the main API
-pub use agent_client_protocol::{McpServer, McpServerHttp, McpServerSse};
-pub use driver::{
-    strip_code_fences, AcpDriver, AgentDriver, BasicMessageWriter, MessageWriter, Store,
-};
-pub use simple::run_acp_prompt;
-pub use types::{
-    discover_providers, find_acp_agent, find_acp_agent_by_id, find_command, AcpAgent,
-    AcpProviderInfo,
-};
diff --git a/ui/goose2/src-tauri/crates/acp-client/src/simple.rs b/ui/goose2/src-tauri/crates/acp-client/src/simple.rs
deleted file mode 100644
index 092be32bc7..0000000000
--- a/ui/goose2/src-tauri/crates/acp-client/src/simple.rs
+++ /dev/null
@@ -1,331 +0,0 @@
-//! Simple one-shot ACP prompting without session management.
-//!
-//! This module provides a convenience wrapper around the full-featured
-//! AcpDriver for simple use cases that don't need session persistence.
-
-use std::path::Path;
-use std::sync::Arc;
-
-use anyhow::{Context, Result};
-use async_trait::async_trait;
-use tokio_util::sync::CancellationToken;
-
-use crate::driver::{AgentDriver, BasicMessageWriter, MessageWriter};
-use crate::types::AcpAgent;
-
-/// Minimal store implementation for simple prompting (no persistence).
-struct NoOpStore;
-
-#[async_trait]
-impl crate::driver::Store for NoOpStore {
-    fn set_agent_session_id(
-        &self,
-        _session_id: &str,
-        _agent_session_id: &str,
-    ) -> Result<(), String> {
-        // No-op: simple prompting doesn't persist sessions
-        Ok(())
-    }
-}
-
-/// Internal driver wrapper for simple prompting.
-///
-/// This wraps the binary path and args from AcpAgent into an AgentDriver
-/// implementation compatible with the driver module's interface.
-struct SimpleDriverWrapper {
-    binary_path: std::path::PathBuf,
-    acp_args: Vec,
-    agent_label: String,
-}
-
-impl SimpleDriverWrapper {
-    fn from_agent(agent: &AcpAgent) -> Self {
-        Self {
-            binary_path: agent.binary_path.clone(),
-            acp_args: agent.acp_args.clone(),
-            agent_label: agent.label.clone(),
-        }
-    }
-}
-
-#[async_trait(?Send)]
-impl AgentDriver for SimpleDriverWrapper {
-    async fn run(
-        &self,
-        session_id: &str,
-        prompt: &str,
-        images: &[(String, String)],
-        working_dir: &Path,
-        store: &Arc,
-        writer: &Arc,
-        cancel_token: &CancellationToken,
-        agent_session_id: Option<&str>,
-    ) -> Result<(), String> {
-        if !images.is_empty() {
-            eprintln!(
-                "SimpleDriverWrapper: discarding {} image(s) - not supported in simple mode",
-                images.len()
-            );
-        }
-
-        // Use the same implementation as AcpDriver, but with our own binary/args
-        use agent_client_protocol::{
-            Agent, ClientSideConnection, ContentBlock as AcpContentBlock, Implementation,
-            InitializeRequest, LoadSessionRequest, NewSessionRequest, PermissionOptionId,
-            PromptRequest, ProtocolVersion, RequestPermissionOutcome, RequestPermissionRequest,
-            RequestPermissionResponse, SelectedPermissionOutcome, SessionNotification,
-            SessionUpdate, TextContent,
-        };
-        use std::process::Stdio;
-        use tokio::process::Command;
-        use tokio::sync::Mutex;
-        use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
-
-        let mut child = Command::new(&self.binary_path)
-            .args(&self.acp_args)
-            .current_dir(working_dir)
-            .stdin(Stdio::piped())
-            .stdout(Stdio::piped())
-            .stderr(Stdio::null())
-            .kill_on_drop(true)
-            .spawn()
-            .map_err(|e| format!("Failed to spawn {}: {e}", self.agent_label))?;
-
-        let stdin = child
-            .stdin
-            .take()
-            .ok_or_else(|| "Failed to get stdin".to_string())?;
-        let stdout = child
-            .stdout
-            .take()
-            .ok_or_else(|| "Failed to get stdout".to_string())?;
-
-        let stdin_compat = stdin.compat_write();
-        let stdout_compat = stdout.compat();
-
-        // Phase-based handler for simple prompting.
-        // With empty db_messages, replay completes immediately.
-        enum SimpleHandlerPhase {
-            Replaying,
-            WaitingForPrompt,
-            Live,
-        }
-
-        struct SimpleHandler {
-            writer: Arc,
-            phase: Mutex,
-        }
-
-        impl SimpleHandler {
-            async fn transition_to_waiting(&self) {
-                let mut phase = self.phase.lock().await;
-                *phase = SimpleHandlerPhase::WaitingForPrompt;
-            }
-
-            async fn transition_to_live(&self) {
-                let mut phase = self.phase.lock().await;
-                *phase = SimpleHandlerPhase::Live;
-            }
-        }
-
-        #[async_trait(?Send)]
-        impl agent_client_protocol::Client for SimpleHandler {
-            async fn request_permission(
-                &self,
-                args: RequestPermissionRequest,
-            ) -> agent_client_protocol::Result {
-                let option_id = args
-                    .options
-                    .first()
-                    .map(|opt| opt.option_id.clone())
-                    .unwrap_or_else(|| PermissionOptionId::new("approve"));
-
-                Ok(RequestPermissionResponse::new(
-                    RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option_id)),
-                ))
-            }
-
-            async fn session_notification(
-                &self,
-                notification: SessionNotification,
-            ) -> agent_client_protocol::Result<()> {
-                let phase = self.phase.lock().await;
-
-                match &*phase {
-                    SimpleHandlerPhase::Replaying | SimpleHandlerPhase::WaitingForPrompt => {
-                        return Ok(());
-                    }
-                    SimpleHandlerPhase::Live => {
-                        // Drop the lock before calling writer
-                        drop(phase);
-                        match ¬ification.update {
-                            SessionUpdate::AgentMessageChunk(chunk) => {
-                                if let AcpContentBlock::Text(text) = &chunk.content {
-                                    self.writer.append_text(&text.text).await;
-                                }
-                            }
-                            _ => {
-                                // Ignore other updates for simple use
-                            }
-                        }
-                    }
-                }
-                Ok(())
-            }
-        }
-
-        let is_resuming = agent_session_id.is_some();
-        let handler = Arc::new(SimpleHandler {
-            writer: Arc::clone(writer),
-            phase: Mutex::new(if is_resuming {
-                SimpleHandlerPhase::Replaying
-            } else {
-                SimpleHandlerPhase::Live
-            }),
-        });
-        let handler_for_conn = Arc::clone(&handler);
-
-        let (connection, io_future) =
-            ClientSideConnection::new(handler_for_conn, stdin_compat, stdout_compat, |fut| {
-                tokio::task::spawn_local(fut);
-            });
-
-        tokio::task::spawn_local(async move {
-            if let Err(e) = io_future.await {
-                eprintln!("ACP IO error: {e:?}");
-            }
-        });
-
-        // Protocol execution
-        let protocol_result = tokio::select! {
-            _ = cancel_token.cancelled() => {
-                writer.finalize().await;
-                return Ok(());
-            }
-            result = async {
-                // Initialize
-                let client_info = Implementation::new("acp-client", env!("CARGO_PKG_VERSION"));
-                let init_request = InitializeRequest::new(ProtocolVersion::LATEST)
-                    .client_info(client_info);
-
-                let init_response = connection
-                    .initialize(init_request)
-                    .await
-                    .map_err(|e| format!("ACP init failed: {e:?}"))?;
-
-                // Create or resume session
-                let agent_session_id = match agent_session_id {
-                    Some(existing_id) => {
-                        if !init_response.agent_capabilities.load_session {
-                            return Err("Agent does not support load_session".to_string());
-                        }
-                        connection
-                            .load_session(LoadSessionRequest::new(
-                                existing_id.to_string(),
-                                working_dir.to_path_buf(),
-                            ))
-                            .await
-                            .map_err(|e| format!("Failed to load session: {e:?}"))?;
-                        existing_id.to_string()
-                    }
-                    None => {
-                        let session_response = connection
-                            .new_session(NewSessionRequest::new(working_dir.to_path_buf()))
-                            .await
-                            .map_err(|e| format!("Failed to create session: {e:?}"))?;
-                        let new_id = session_response.session_id.to_string();
-                        store
-                            .set_agent_session_id(session_id, &new_id)
-                            .map_err(|e| format!("Failed to save session ID: {e}"))?;
-                        new_id
-                    }
-                };
-
-                // Transition: Replaying -> WaitingForPrompt -> Live
-                if is_resuming {
-                    handler.transition_to_waiting().await;
-                }
-
-                // Build and send prompt
-                let prompt_request = PromptRequest::new(
-                    agent_session_id,
-                    vec![AcpContentBlock::Text(TextContent::new(prompt))],
-                );
-
-                if is_resuming {
-                    handler.transition_to_live().await;
-                }
-
-                connection
-                    .prompt(prompt_request)
-                    .await
-                    .map_err(|e| format!("Prompt failed: {e:?}"))?;
-
-                Ok::<_, String>(())
-            } => result,
-        };
-
-        writer.finalize().await;
-        let _ = child.kill().await;
-
-        protocol_result
-    }
-}
-
-/// Run a one-shot prompt through ACP and return the response.
-///
-/// This is a convenience wrapper around the full-featured AcpDriver that
-/// handles session setup/teardown automatically. Use this for simple
-/// one-shot queries without session persistence.
-///
-/// # Arguments
-///
-/// * `agent` - The ACP agent to use
-/// * `working_dir` - The working directory for the agent
-/// * `prompt` - The prompt to send to the agent
-///
-/// # Returns
-///
-/// The agent's text response
-pub async fn run_acp_prompt(agent: &AcpAgent, working_dir: &Path, prompt: &str) -> Result {
-    let working_dir = working_dir.to_path_buf();
-    let prompt = prompt.to_string();
-    let driver = SimpleDriverWrapper::from_agent(agent);
-
-    // Run the ACP session in a blocking task with its own runtime
-    // This is needed because ACP uses !Send futures (LocalSet)
-    tokio::task::spawn_blocking(move || {
-        // Create a new runtime for this thread
-        let rt = tokio::runtime::Builder::new_current_thread()
-            .enable_all()
-            .build()
-            .context("Failed to create runtime")?;
-
-        // Run the ACP session on a LocalSet
-        let local = tokio::task::LocalSet::new();
-        local.block_on(&rt, async move {
-            let writer_impl = Arc::new(BasicMessageWriter::new());
-            let writer = writer_impl.clone() as Arc;
-            let store = Arc::new(NoOpStore) as Arc;
-            let cancel_token = CancellationToken::new();
-
-            driver
-                .run(
-                    "simple-session",
-                    &prompt,
-                    &[],
-                    &working_dir,
-                    &store,
-                    &writer,
-                    &cancel_token,
-                    None,
-                )
-                .await
-                .map_err(|e| anyhow::anyhow!("ACP driver error: {e}"))?;
-
-            Ok(writer_impl.get_text().await)
-        })
-    })
-    .await
-    .context("Task join error")?
-}
diff --git a/ui/goose2/src-tauri/crates/acp-client/src/types.rs b/ui/goose2/src-tauri/crates/acp-client/src/types.rs
deleted file mode 100644
index 1c2a2c7ac5..0000000000
--- a/ui/goose2/src-tauri/crates/acp-client/src/types.rs
+++ /dev/null
@@ -1,202 +0,0 @@
-//! Agent types, discovery, and metadata.
-
-use serde::Serialize;
-use std::path::{Path, PathBuf};
-
-// =============================================================================
-// Known agents — the registry of ACP-compatible providers
-// =============================================================================
-
-/// Static metadata for each known ACP agent.
-pub(crate) struct KnownAgent {
-    /// Unique identifier used in preferences and IPC.
-    pub id: &'static str,
-    /// Human-readable label for the UI.
-    pub label: &'static str,
-    /// CLI command name to search for.
-    pub command: &'static str,
-    /// Arguments to pass when spawning in ACP mode.
-    pub acp_args: &'static [&'static str],
-}
-
-/// All agents we know how to talk to, in display order.
-pub(crate) const KNOWN_AGENTS: &[KnownAgent] = &[
-    KnownAgent {
-        id: "goose",
-        label: "Goose",
-        command: "goose",
-        acp_args: &[
-            "acp",
-            "--with-builtin",
-            "developer",
-            "--with-builtin",
-            "extensionmanager",
-        ],
-    },
-    KnownAgent {
-        id: "claude",
-        label: "Claude Code",
-        command: "claude-agent-acp",
-        acp_args: &[],
-    },
-    KnownAgent {
-        id: "codex",
-        label: "Codex",
-        command: "codex-acp",
-        acp_args: &[],
-    },
-    KnownAgent {
-        id: "pi",
-        label: "Pi",
-        command: "pi-acp",
-        acp_args: &[],
-    },
-    KnownAgent {
-        id: "amp",
-        label: "Amp",
-        command: "amp-acp",
-        acp_args: &[],
-    },
-];
-
-fn find_known_agent_binary(agent: &KnownAgent) -> Option {
-    find_command(agent.command)
-}
-
-// =============================================================================
-// Provider discovery — public API
-// =============================================================================
-
-/// Information about a discovered ACP provider.
-#[derive(Debug, Clone, Serialize)]
-pub struct AcpProviderInfo {
-    pub id: String,
-    pub label: String,
-}
-
-/// Scan the system for all known ACP agents that are installed.
-///
-/// Returns only agents whose CLI binary can be found. The order matches
-/// `KNOWN_AGENTS` (display order).
-pub fn discover_providers() -> Vec {
-    KNOWN_AGENTS
-        .iter()
-        .filter(|agent| find_known_agent_binary(agent).is_some())
-        .map(|agent| AcpProviderInfo {
-            id: agent.id.to_string(),
-            label: agent.label.to_string(),
-        })
-        .collect()
-}
-
-/// Find a specific ACP agent by provider ID (e.g., "goose", "claude").
-pub fn find_acp_agent_by_id(provider_id: &str) -> Option {
-    KNOWN_AGENTS
-        .iter()
-        .find(|a| a.id == provider_id)
-        .and_then(|agent| {
-            find_known_agent_binary(agent).map(|path| AcpAgent {
-                binary_path: path,
-                acp_args: agent.acp_args.iter().map(|s| s.to_string()).collect(),
-                label: agent.label.to_string(),
-            })
-        })
-}
-
-/// Find the first available ACP agent.
-///
-/// Tries each known agent in order and returns the first one found.
-pub fn find_acp_agent() -> Option {
-    for agent in KNOWN_AGENTS {
-        if let Some(path) = find_known_agent_binary(agent) {
-            return Some(AcpAgent {
-                binary_path: path,
-                acp_args: agent.acp_args.iter().map(|s| s.to_string()).collect(),
-                label: agent.label.to_string(),
-            });
-        }
-    }
-    None
-}
-
-// =============================================================================
-// ACP Agent
-// =============================================================================
-
-/// An ACP-compatible agent with its binary path and configuration.
-#[derive(Debug, Clone)]
-pub struct AcpAgent {
-    pub binary_path: PathBuf,
-    pub acp_args: Vec,
-    pub label: String,
-}
-
-impl AcpAgent {
-    /// Get the path to the agent executable.
-    pub fn path(&self) -> &Path {
-        &self.binary_path
-    }
-
-    /// Get the agent's label.
-    pub fn name(&self) -> &str {
-        &self.label
-    }
-}
-
-// =============================================================================
-// Binary discovery
-// =============================================================================
-
-/// Common paths where CLIs might be installed (GUI apps don't inherit shell PATH).
-const COMMON_PATHS: &[&str] = &[
-    "/opt/homebrew/bin",
-    "/usr/local/bin",
-    "/usr/bin",
-    "/home/linuxbrew/.linuxbrew/bin",
-];
-
-/// Find a CLI binary by command name.
-///
-/// Searches in order:
-/// 1. Login shell `which` (picks up user's PATH from `.zshrc` / `.bashrc`)
-/// 2. Common install locations
-pub fn find_command(cmd: &str) -> Option {
-    // Strategy 1: Login shell `which`
-    if let Some(path) = find_via_login_shell(cmd) {
-        if path.exists() {
-            return Some(path);
-        }
-    }
-
-    // Strategy 2: Common paths
-    for dir in COMMON_PATHS {
-        let path = PathBuf::from(dir).join(cmd);
-        if path.exists() {
-            return Some(path);
-        }
-    }
-
-    None
-}
-
-fn find_via_login_shell(cmd: &str) -> Option {
-    let which_cmd = format!("which {cmd}");
-
-    for shell in &["/bin/zsh", "/bin/bash"] {
-        if let Ok(output) = std::process::Command::new(shell)
-            .args(["-l", "-c", &which_cmd])
-            .output()
-        {
-            if output.status.success() {
-                let stdout = String::from_utf8_lossy(&output.stdout);
-                if let Some(path_str) = stdout.lines().rfind(|l| !l.is_empty()) {
-                    let path_str = path_str.trim();
-                    if !path_str.is_empty() && path_str.starts_with('/') {
-                        return Some(PathBuf::from(path_str));
-                    }
-                }
-            }
-        }
-    }
-    None
-}
diff --git a/ui/goose2/tsconfig.json b/ui/goose2/tsconfig.json
index 6715b819a0..9fbb106667 100644
--- a/ui/goose2/tsconfig.json
+++ b/ui/goose2/tsconfig.json
@@ -5,7 +5,6 @@
     "lib": ["ES2020", "DOM", "DOM.Iterable"],
     "module": "ESNext",
     "skipLibCheck": true,
-    "baseUrl": ".",
     "paths": {
       "@/*": ["./src/*"]
     },