feat: use acp-client from builderbot repo as git dependency (#20)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Toohey
2026-03-31 14:47:50 +11:00
committed by GitHub
parent cf68533896
commit bffcea2823
8 changed files with 38 additions and 1643 deletions
+37
View File
@@ -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"
+1 -4
View File
@@ -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" }
@@ -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"
File diff suppressed because it is too large Load Diff
@@ -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,
};
@@ -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<String>,
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<dyn crate::driver::Store>,
writer: &Arc<dyn MessageWriter>,
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<dyn MessageWriter>,
phase: Mutex<SimpleHandlerPhase>,
}
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<RequestPermissionResponse> {
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 &notification.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<String> {
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<dyn MessageWriter>;
let store = Arc::new(NoOpStore) as Arc<dyn crate::driver::Store>;
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")?
}
@@ -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<PathBuf> {
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<AcpProviderInfo> {
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<AcpAgent> {
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<AcpAgent> {
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<String>,
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<PathBuf> {
// 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<PathBuf> {
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
}
-1
View File
@@ -5,7 +5,6 @@
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},