Initial commit

This commit is contained in:
xggz
2026-03-06 22:56:13 +08:00
commit 54d1097b41
273 changed files with 92457 additions and 0 deletions

View File

@@ -0,0 +1,417 @@
use std::collections::HashSet;
use std::io::Read;
use std::path::{Path, PathBuf};
use crate::acp::error::AcpError;
use crate::acp::registry;
use crate::models::agent::AgentType;
pub(crate) fn cache_dir() -> Result<PathBuf, AcpError> {
let base = dirs::cache_dir()
.ok_or_else(|| AcpError::DownloadFailed("cannot determine cache directory".into()))?;
Ok(base.join("app.codeg").join("acp-binaries"))
}
fn normalize_version_label(version: &str) -> String {
let trimmed = version.trim();
if let Some(stripped) = trimmed
.strip_prefix('v')
.or_else(|| trimmed.strip_prefix('V'))
{
stripped.trim().to_string()
} else {
trimmed.to_string()
}
}
pub(crate) fn agent_cache_key(agent_type: AgentType) -> String {
registry::registry_id_for(agent_type).to_string()
}
pub(crate) fn binary_dir(agent_id: &str, version: &str) -> Result<PathBuf, AcpError> {
let version = normalize_version_label(version);
if version.is_empty() {
return Err(AcpError::DownloadFailed(
"binary version is empty".to_string(),
));
}
Ok(cache_dir()?
.join(agent_id)
.join(version)
.join(registry::current_platform()))
}
pub fn clear_agent_cache(agent_type: AgentType) -> Result<(), AcpError> {
let agent_id = agent_cache_key(agent_type);
let dir = cache_dir()?.join(agent_id);
if dir.exists() {
std::fs::remove_dir_all(&dir)
.map_err(|e| AcpError::DownloadFailed(format!("failed to clear cache: {e}")))?;
}
Ok(())
}
fn installed_binary_path(agent_id: &str, version: &str, cmd_name: &str) -> Option<PathBuf> {
let bin_name = if cfg!(target_os = "windows") {
format!("{cmd_name}.exe")
} else {
cmd_name.to_string()
};
let normalized = normalize_version_label(version);
if normalized.is_empty() {
return None;
}
let path = cache_dir()
.ok()?
.join(agent_id)
.join(normalized)
.join(registry::current_platform())
.join(bin_name);
if !path.exists() {
return None;
}
if is_binary_file_compatible(path.as_path()) {
return Some(path);
}
let _ = std::fs::remove_file(path);
None
}
fn installed_version_labels(agent_id: &str, cmd_name: &str) -> Result<Vec<String>, AcpError> {
let root = cache_dir()?.join(agent_id);
if !root.exists() {
return Ok(Vec::new());
}
let mut versions = Vec::new();
let mut seen = HashSet::new();
let entries = std::fs::read_dir(&root)
.map_err(|e| AcpError::DownloadFailed(format!("failed to read cache dir: {e}")))?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let raw_version = entry.file_name().to_string_lossy().to_string();
let normalized = normalize_version_label(&raw_version);
if normalized.is_empty() {
continue;
}
if installed_binary_path(agent_id, &normalized, cmd_name).is_some()
&& seen.insert(normalized.clone())
{
versions.push(normalized);
}
}
Ok(versions)
}
fn installed_version_for_agent(
agent_type: AgentType,
cmd_name: &str,
) -> Result<Option<String>, AcpError> {
let agent_id = agent_cache_key(agent_type);
let mut versions = installed_version_labels(&agent_id, cmd_name)?;
if versions.is_empty() {
return Ok(None);
}
versions.sort_by(|a, b| version_cmp(a, b));
Ok(versions.pop())
}
pub fn detect_installed_version(
agent_type: AgentType,
cmd_name: &str,
) -> Result<Option<String>, AcpError> {
installed_version_for_agent(agent_type, cmd_name)
}
fn version_cmp(a: &str, b: &str) -> std::cmp::Ordering {
let mut a_parts = parse_version_parts(a);
let mut b_parts = parse_version_parts(b);
let len = a_parts.len().max(b_parts.len());
a_parts.resize(len, 0);
b_parts.resize(len, 0);
for i in 0..len {
match a_parts[i].cmp(&b_parts[i]) {
std::cmp::Ordering::Equal => continue,
order => return order,
}
}
a.cmp(b)
}
fn parse_version_parts(input: &str) -> Vec<u32> {
input
.trim_start_matches(|c: char| !c.is_ascii_digit())
.split('.')
.map(|part| {
let numeric: String = part.chars().take_while(|c| c.is_ascii_digit()).collect();
numeric.parse::<u32>().unwrap_or(0)
})
.collect()
}
/// Ensure a binary agent is available locally.
/// Returns the absolute path to the executable.
pub async fn ensure_binary_for_agent(
agent_type: AgentType,
version: &str,
archive_url: &str,
cmd_name: &str,
) -> Result<PathBuf, AcpError> {
if let Some(path) = find_cached_binary_for_agent(agent_type, version, cmd_name)? {
return Ok(path);
}
let agent_id = agent_cache_key(agent_type);
ensure_binary(&agent_id, version, archive_url, cmd_name).await
}
/// Ensure a binary is available for a specific cache key.
/// Returns the absolute path to the executable.
pub async fn ensure_binary(
agent_id: &str,
version: &str,
archive_url: &str,
cmd_name: &str,
) -> Result<PathBuf, AcpError> {
if let Some(path) = find_cached_binary(agent_id, version, cmd_name)? {
return Ok(path);
}
let dir = binary_dir(agent_id, version)?;
let bin_name = if cfg!(target_os = "windows") {
format!("{cmd_name}.exe")
} else {
cmd_name.to_string()
};
// Download and extract
std::fs::create_dir_all(&dir)
.map_err(|e| AcpError::DownloadFailed(format!("failed to create cache dir: {e}")))?;
let tmp_dir = dir.join(".tmp");
if tmp_dir.exists() {
let _ = std::fs::remove_dir_all(&tmp_dir);
}
std::fs::create_dir_all(&tmp_dir)
.map_err(|e| AcpError::DownloadFailed(format!("failed to create tmp dir: {e}")))?;
let result: Result<PathBuf, AcpError> = async {
let archive_path = tmp_dir.join("archive");
download_file(archive_url, &archive_path).await?;
let extract_dir = tmp_dir.join("extracted");
std::fs::create_dir_all(&extract_dir)
.map_err(|e| AcpError::DownloadFailed(format!("failed to create extract dir: {e}")))?;
if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
extract_tar_gz(&archive_path, &extract_dir)?;
} else if archive_url.ends_with(".tar.bz2") || archive_url.ends_with(".tbz2") {
extract_tar_bz2(&archive_path, &extract_dir)?;
} else if archive_url.ends_with(".zip") {
extract_zip(&archive_path, &extract_dir)?;
} else {
return Err(AcpError::DownloadFailed(format!(
"unsupported archive format: {archive_url}"
)));
}
// Find the binary in extracted files and move to final location.
let extracted_bin = find_binary_recursive(&extract_dir, &bin_name).ok_or_else(|| {
AcpError::DownloadFailed(format!("binary '{bin_name}' not found in archive"))
})?;
let final_path = dir.join(&bin_name);
std::fs::copy(&extracted_bin, &final_path)
.map_err(|e| AcpError::DownloadFailed(format!("failed to copy binary: {e}")))?;
if !is_binary_file_compatible(&final_path) {
let _ = std::fs::remove_file(&final_path);
return Err(AcpError::DownloadFailed(
"downloaded binary format is invalid for current platform".into(),
));
}
set_executable_permissions(&final_path)?;
Ok(final_path)
}
.await;
// Always clean up temp extraction artifacts.
let _ = std::fs::remove_dir_all(&tmp_dir);
if result.is_err() {
// Avoid leaving empty version/platform directories on failed downloads.
let _ = std::fs::remove_dir_all(&dir);
}
result
}
pub(crate) fn find_cached_binary(
agent_id: &str,
version: &str,
cmd_name: &str,
) -> Result<Option<PathBuf>, AcpError> {
Ok(installed_binary_path(agent_id, version, cmd_name))
}
pub(crate) fn find_cached_binary_for_agent(
agent_type: AgentType,
version: &str,
cmd_name: &str,
) -> Result<Option<PathBuf>, AcpError> {
let agent_id = agent_cache_key(agent_type);
find_cached_binary(&agent_id, version, cmd_name)
}
pub(crate) fn find_binary_recursive(dir: &PathBuf, name: &str) -> Option<PathBuf> {
if !dir.exists() {
return None;
}
for entry in walkdir::WalkDir::new(dir).into_iter().flatten() {
if entry.file_type().is_file() && entry.file_name().to_string_lossy() == name {
return Some(entry.into_path());
}
}
None
}
async fn download_file(url: &str, dest: &PathBuf) -> Result<(), AcpError> {
let response = reqwest::Client::new()
.get(url)
.send()
.await
.map_err(|e| AcpError::DownloadFailed(format!("HTTP request failed: {e}")))?;
if !response.status().is_success() {
return Err(AcpError::DownloadFailed(format!(
"HTTP {} for {url}",
response.status()
)));
}
let bytes = response
.bytes()
.await
.map_err(|e| AcpError::DownloadFailed(format!("failed to read response: {e}")))?;
std::fs::write(dest, &bytes)
.map_err(|e| AcpError::DownloadFailed(format!("failed to write archive: {e}")))?;
Ok(())
}
fn extract_tar_gz(archive: &PathBuf, dest: &PathBuf) -> Result<(), AcpError> {
let file = std::fs::File::open(archive)
.map_err(|e| AcpError::DownloadFailed(format!("failed to open archive: {e}")))?;
let gz = flate2::read::GzDecoder::new(file);
let mut tar = tar::Archive::new(gz);
tar.unpack(dest)
.map_err(|e| AcpError::DownloadFailed(format!("failed to extract tar.gz: {e}")))?;
Ok(())
}
fn extract_tar_bz2(archive: &PathBuf, dest: &PathBuf) -> Result<(), AcpError> {
let file = std::fs::File::open(archive)
.map_err(|e| AcpError::DownloadFailed(format!("failed to open archive: {e}")))?;
let bz = bzip2::read::BzDecoder::new(file);
let mut tar = tar::Archive::new(bz);
tar.unpack(dest)
.map_err(|e| AcpError::DownloadFailed(format!("failed to extract tar.bz2: {e}")))?;
Ok(())
}
fn extract_zip(archive: &PathBuf, dest: &PathBuf) -> Result<(), AcpError> {
let file = std::fs::File::open(archive)
.map_err(|e| AcpError::DownloadFailed(format!("failed to open archive: {e}")))?;
let mut zip = zip::ZipArchive::new(file)
.map_err(|e| AcpError::DownloadFailed(format!("failed to read zip: {e}")))?;
zip.extract(dest)
.map_err(|e| AcpError::DownloadFailed(format!("failed to extract zip: {e}")))?;
Ok(())
}
fn set_executable_permissions(path: &Path) -> Result<(), AcpError> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)
.map_err(|e| AcpError::DownloadFailed(e.to_string()))?
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).map_err(|e| AcpError::DownloadFailed(e.to_string()))
}
#[cfg(not(unix))]
{
let _ = path;
Ok(())
}
}
pub(crate) fn is_binary_file_compatible(path: &Path) -> bool {
let mut file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return false,
};
let mut header = [0_u8; 4];
if file.read_exact(&mut header).is_err() {
return false;
}
#[cfg(target_os = "macos")]
{
matches!(
header,
[0xFE, 0xED, 0xFA, 0xCE]
| [0xCE, 0xFA, 0xED, 0xFE]
| [0xFE, 0xED, 0xFA, 0xCF]
| [0xCF, 0xFA, 0xED, 0xFE]
| [0xCA, 0xFE, 0xBA, 0xBE]
| [0xBE, 0xBA, 0xFE, 0xCA]
| [0xCA, 0xFE, 0xBA, 0xBF]
| [0xBF, 0xBA, 0xFE, 0xCA]
)
}
#[cfg(target_os = "linux")]
{
header == [0x7F, b'E', b'L', b'F']
}
#[cfg(target_os = "windows")]
{
header[0] == b'M' && header[1] == b'Z'
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_key_uses_registry_id() {
assert_eq!(agent_cache_key(AgentType::OpenCode), "opencode");
assert_eq!(agent_cache_key(AgentType::Codex), "codex-acp");
}
#[test]
fn version_normalization_is_consistent() {
assert_eq!(normalize_version_label("v1.2.15"), "1.2.15");
assert_eq!(normalize_version_label("V0.9.4 "), "0.9.4");
assert_eq!(normalize_version_label("1.25.1"), "1.25.1");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
use serde::Serialize;
#[derive(Debug, thiserror::Error)]
pub enum AcpError {
#[error("agent process failed to spawn: {0}")]
SpawnFailed(String),
#[error("connection not found: {0}")]
ConnectionNotFound(String),
#[error("ACP protocol error: {0}")]
Protocol(String),
#[error("agent process exited unexpectedly")]
ProcessExited,
#[allow(dead_code)]
#[error("conversation error: {0}")]
Conversation(String),
#[error("binary download failed: {0}")]
DownloadFailed(String),
#[allow(dead_code)]
#[error("agent not found: {0}")]
AgentNotFound(String),
#[error("platform not supported: {0}")]
PlatformNotSupported(String),
}
impl AcpError {
pub fn protocol(raw: impl Into<String>) -> Self {
let raw = raw.into();
let sanitized = sanitize_protocol_message(&raw);
if is_executable_format_error(&sanitized) {
return Self::Protocol(
"Agent executable appears incompatible or corrupted. Please retry to re-download it."
.into(),
);
}
Self::Protocol(sanitized)
}
}
impl Serialize for AcpError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
fn sanitize_protocol_message(raw: &str) -> String {
let without_spawned_at = regex::Regex::new(r#"\s*,?\s*"spawned_at"\s*:\s*"[^"]*"\s*,?"#)
.ok()
.map(|re| re.replace_all(raw, "").into_owned())
.unwrap_or_else(|| raw.to_string());
let without_dangling_comma = regex::Regex::new(r#",\s*([}\]])"#)
.ok()
.map(|re| re.replace_all(&without_spawned_at, "$1").into_owned())
.unwrap_or(without_spawned_at);
regex::Regex::new(r#"/(?:Users|home)/[^"\s]+"#)
.ok()
.map(|re| {
re.replace_all(&without_dangling_comma, "<local-path>")
.into_owned()
})
.unwrap_or(without_dangling_comma)
}
fn is_executable_format_error(message: &str) -> bool {
let lowered = message.to_lowercase();
lowered.contains("malformed mach-o file")
|| lowered.contains("exec format error")
|| lowered.contains("bad cpu type in executable")
|| lowered.contains("not a valid win32 application")
|| lowered.contains("is not a valid application for this os platform")
}

View File

@@ -0,0 +1,512 @@
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use sacp::schema::{
ReadTextFileRequest, ReadTextFileResponse, WriteTextFileRequest, WriteTextFileResponse,
};
use tokio::sync::Semaphore;
const FS_MAX_CONCURRENT_OPS: usize = 8;
const FS_IO_TIMEOUT: Duration = Duration::from_secs(30);
const FS_MAX_FILE_SIZE_BYTES: u64 = 16 * 1024 * 1024;
const FS_MAX_READ_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
const FS_MAX_WRITE_BYTES: usize = 2 * 1024 * 1024;
const FS_SLOW_OPERATION_MS: u128 = 200;
#[derive(Debug)]
pub enum FileSystemRuntimeError {
InvalidParams(String),
Internal(String),
}
impl FileSystemRuntimeError {
pub fn to_rpc_error(self) -> sacp::Error {
match self {
Self::InvalidParams(message) => sacp::Error::invalid_params().data(message),
Self::Internal(message) => sacp::util::internal_error(message),
}
}
}
#[derive(Clone)]
pub struct FileSystemRuntime {
workspace_root: PathBuf,
workspace_root_canonical: Option<PathBuf>,
io_semaphore: Arc<Semaphore>,
}
impl FileSystemRuntime {
pub fn new(workspace_root: PathBuf) -> Self {
let workspace_root = if workspace_root.is_absolute() {
workspace_root
} else {
std::env::current_dir()
.unwrap_or_default()
.join(workspace_root)
};
let workspace_root_canonical = std::fs::canonicalize(&workspace_root).ok();
Self {
workspace_root,
workspace_root_canonical,
io_semaphore: Arc::new(Semaphore::new(FS_MAX_CONCURRENT_OPS)),
}
}
pub async fn read_text_file(
&self,
request: ReadTextFileRequest,
) -> Result<ReadTextFileResponse, FileSystemRuntimeError> {
if !request.path.is_absolute() {
return Err(FileSystemRuntimeError::InvalidParams(
"fs/read_text_file requires an absolute path".to_string(),
));
}
if matches!(request.line, Some(0)) {
return Err(FileSystemRuntimeError::InvalidParams(
"fs/read_text_file line must be >= 1".to_string(),
));
}
let _permit = self
.io_semaphore
.clone()
.acquire_owned()
.await
.map_err(|_| {
FileSystemRuntimeError::Internal("filesystem runtime closed".to_string())
})?;
let workspace_root = self.workspace_root.clone();
let workspace_root_canonical = self.workspace_root_canonical.clone();
let path = request.path;
let line = request.line;
let limit = request.limit;
let started_at = Instant::now();
let path_for_log = path.clone();
let response = run_blocking_with_timeout("fs/read_text_file", move || {
read_text_file_impl(
&path,
line,
limit,
&workspace_root,
workspace_root_canonical.as_deref(),
)
.map(ReadTextFileResponse::new)
})
.await;
log_if_slow("fs/read_text_file", &path_for_log, started_at);
response
}
pub async fn write_text_file(
&self,
request: WriteTextFileRequest,
) -> Result<WriteTextFileResponse, FileSystemRuntimeError> {
if !request.path.is_absolute() {
return Err(FileSystemRuntimeError::InvalidParams(
"fs/write_text_file requires an absolute path".to_string(),
));
}
if request.content.len() > FS_MAX_WRITE_BYTES {
return Err(FileSystemRuntimeError::InvalidParams(format!(
"write payload too large ({} bytes, limit {} bytes)",
request.content.len(),
FS_MAX_WRITE_BYTES
)));
}
let _permit = self
.io_semaphore
.clone()
.acquire_owned()
.await
.map_err(|_| {
FileSystemRuntimeError::Internal("filesystem runtime closed".to_string())
})?;
let workspace_root = self.workspace_root.clone();
let workspace_root_canonical = self.workspace_root_canonical.clone();
let path = request.path;
let content = request.content;
let started_at = Instant::now();
let path_for_log = path.clone();
let response = run_blocking_with_timeout("fs/write_text_file", move || {
ensure_path_in_workspace(
&path,
&workspace_root,
workspace_root_canonical.as_deref(),
true,
)?;
atomic_write_text(&path, content.as_bytes())?;
Ok(WriteTextFileResponse::new())
})
.await;
log_if_slow("fs/write_text_file", &path_for_log, started_at);
response
}
}
async fn run_blocking_with_timeout<T, F>(
operation: &'static str,
f: F,
) -> Result<T, FileSystemRuntimeError>
where
T: Send + 'static,
F: FnOnce() -> Result<T, FileSystemRuntimeError> + Send + 'static,
{
let task = tokio::task::spawn_blocking(f);
let join_result = tokio::time::timeout(FS_IO_TIMEOUT, task)
.await
.map_err(|_| {
FileSystemRuntimeError::Internal(format!(
"{operation} timed out after {}s",
FS_IO_TIMEOUT.as_secs()
))
})?;
let op_result = join_result.map_err(|err| {
FileSystemRuntimeError::Internal(format!("{operation} worker failed: {err}"))
})?;
op_result
}
fn read_text_file_impl(
path: &Path,
line: Option<u32>,
limit: Option<u32>,
workspace_root: &Path,
workspace_root_canonical: Option<&Path>,
) -> Result<String, FileSystemRuntimeError> {
ensure_path_in_workspace(path, workspace_root, workspace_root_canonical, false)?;
let metadata = std::fs::metadata(path).map_err(|err| map_io_error("read", path, err))?;
if metadata.len() > FS_MAX_FILE_SIZE_BYTES {
return Err(FileSystemRuntimeError::InvalidParams(format!(
"file too large for fs/read_text_file ({} bytes, limit {} bytes)",
metadata.len(),
FS_MAX_FILE_SIZE_BYTES
)));
}
let file = File::open(path).map_err(|err| map_io_error("read", path, err))?;
let mut reader = BufReader::new(file);
let start_line = usize::try_from(line.unwrap_or(1)).unwrap_or(usize::MAX);
let max_lines = limit.map(|v| usize::try_from(v).unwrap_or(usize::MAX));
let mut current_line = 1usize;
let mut taken = 0usize;
let mut out = String::with_capacity(
usize::try_from(metadata.len())
.unwrap_or(FS_MAX_READ_RESPONSE_BYTES)
.min(FS_MAX_READ_RESPONSE_BYTES),
);
let mut line_buf = String::new();
loop {
line_buf.clear();
let bytes_read = reader
.read_line(&mut line_buf)
.map_err(|err| map_io_error("read", path, err))?;
if bytes_read == 0 {
break;
}
if current_line >= start_line {
if let Some(max) = max_lines {
if taken >= max {
break;
}
}
if out.len().saturating_add(line_buf.len()) > FS_MAX_READ_RESPONSE_BYTES {
return Err(FileSystemRuntimeError::InvalidParams(format!(
"read result too large (limit {} bytes). Narrow with line/limit.",
FS_MAX_READ_RESPONSE_BYTES
)));
}
out.push_str(&line_buf);
taken += 1;
}
current_line = current_line.saturating_add(1);
}
Ok(out)
}
fn atomic_write_text(path: &Path, bytes: &[u8]) -> Result<(), FileSystemRuntimeError> {
let parent = path.parent().ok_or_else(|| {
FileSystemRuntimeError::InvalidParams(format!(
"cannot determine parent directory for path: {}",
path.display()
))
})?;
if !parent.exists() {
return Err(FileSystemRuntimeError::InvalidParams(format!(
"parent directory does not exist: {}",
parent.display()
)));
}
let temp_path = parent.join(format!(
".codeg-fs-{}.{}.tmp",
std::process::id(),
uuid::Uuid::new_v4().simple()
));
let existing_permissions = std::fs::metadata(path).ok().map(|m| m.permissions());
let write_result = (|| {
let mut tmp = OpenOptions::new()
.create_new(true)
.write(true)
.open(&temp_path)
.map_err(|err| map_io_error("create temporary file", &temp_path, err))?;
tmp.write_all(bytes)
.map_err(|err| map_io_error("write", &temp_path, err))?;
tmp.sync_all()
.map_err(|err| map_io_error("flush", &temp_path, err))?;
if let Some(permissions) = existing_permissions {
std::fs::set_permissions(&temp_path, permissions)
.map_err(|err| map_io_error("set permissions", &temp_path, err))?;
}
replace_file(&temp_path, path)?;
sync_directory(parent)?;
Ok(())
})();
if write_result.is_err() {
let _ = std::fs::remove_file(&temp_path);
}
write_result
}
#[cfg(unix)]
fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), FileSystemRuntimeError> {
std::fs::rename(temp_path, target_path).map_err(|err| map_io_error("replace", target_path, err))
}
#[cfg(target_os = "windows")]
fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), FileSystemRuntimeError> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::{
MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
};
fn to_wide(path: &Path) -> Vec<u16> {
path.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
let src = to_wide(temp_path);
let dst = to_wide(target_path);
// SAFETY: pointers are valid, null-terminated UTF-16 buffers alive for the call.
let ok = unsafe {
MoveFileExW(
src.as_ptr(),
dst.as_ptr(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
)
};
if ok == 0 {
let err = std::io::Error::last_os_error();
return Err(map_io_error("atomically replace", target_path, err));
}
Ok(())
}
#[cfg(not(any(unix, target_os = "windows")))]
fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), FileSystemRuntimeError> {
std::fs::rename(temp_path, target_path).map_err(|err| map_io_error("replace", target_path, err))
}
#[cfg(unix)]
fn sync_directory(path: &Path) -> Result<(), FileSystemRuntimeError> {
let dir = File::open(path).map_err(|err| map_io_error("sync directory", path, err))?;
dir.sync_all()
.map_err(|err| map_io_error("sync directory", path, err))
}
#[cfg(not(unix))]
fn sync_directory(_path: &Path) -> Result<(), FileSystemRuntimeError> {
Ok(())
}
fn canonical_workspace_root(workspace_root: &Path, canonical: Option<&Path>) -> PathBuf {
canonical
.map(Path::to_path_buf)
.or_else(|| std::fs::canonicalize(workspace_root).ok())
.unwrap_or_else(|| workspace_root.to_path_buf())
}
fn ensure_path_in_workspace(
path: &Path,
workspace_root: &Path,
workspace_root_canonical: Option<&Path>,
for_write: bool,
) -> Result<(), FileSystemRuntimeError> {
let root = canonical_workspace_root(workspace_root, workspace_root_canonical);
let target = canonical_target_path(path, for_write)?;
if !target.starts_with(&root) {
return Err(FileSystemRuntimeError::InvalidParams(format!(
"path is outside workspace root: {}",
path.display()
)));
}
Ok(())
}
fn canonical_target_path(path: &Path, for_write: bool) -> Result<PathBuf, FileSystemRuntimeError> {
if !for_write || path.exists() {
return std::fs::canonicalize(path).map_err(|err| map_io_error("access", path, err));
}
let parent = path.parent().ok_or_else(|| {
FileSystemRuntimeError::InvalidParams(format!(
"cannot determine parent directory for path: {}",
path.display()
))
})?;
if !parent.exists() {
return Err(FileSystemRuntimeError::InvalidParams(format!(
"parent directory does not exist: {}",
parent.display()
)));
}
std::fs::canonicalize(parent).map_err(|err| map_io_error("access", parent, err))
}
fn map_io_error(action: &str, path: &Path, err: std::io::Error) -> FileSystemRuntimeError {
match err.kind() {
ErrorKind::NotFound
| ErrorKind::PermissionDenied
| ErrorKind::InvalidInput
| ErrorKind::InvalidData => FileSystemRuntimeError::InvalidParams(format!(
"failed to {action} {}: {err}",
path.display()
)),
_ => FileSystemRuntimeError::Internal(format!(
"failed to {action} {}: {err}",
path.display()
)),
}
}
fn log_if_slow(operation: &str, path: &Path, started_at: Instant) {
let elapsed = started_at.elapsed();
if elapsed.as_millis() >= FS_SLOW_OPERATION_MS {
eprintln!(
"[ACP] {operation} slow path={} elapsed_ms={}",
path.display(),
elapsed.as_millis()
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn temp_workspace() -> PathBuf {
let path = std::env::temp_dir().join(format!("codeg-fs-test-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&path).expect("create test workspace");
path
}
#[tokio::test(flavor = "current_thread")]
async fn read_honors_line_and_limit() {
let workspace = temp_workspace();
let file = workspace.join("sample.txt");
fs::write(&file, "a\nb\nc\nd\n").expect("write file");
let runtime = FileSystemRuntime::new(workspace.clone());
let response = runtime
.read_text_file(ReadTextFileRequest::new("sid", &file).line(2).limit(2))
.await
.expect("read file");
assert_eq!(response.content, "b\nc\n");
let _ = fs::remove_dir_all(workspace);
}
#[tokio::test(flavor = "current_thread")]
async fn rejects_path_outside_workspace() {
let workspace = temp_workspace();
let outside = std::env::temp_dir().join(format!("outside-{}.txt", uuid::Uuid::new_v4()));
fs::write(&outside, "x").expect("write outside file");
let runtime = FileSystemRuntime::new(workspace.clone());
let error = runtime
.read_text_file(ReadTextFileRequest::new("sid", &outside))
.await
.expect_err("should reject outside path");
match error {
FileSystemRuntimeError::InvalidParams(message) => {
assert!(message.contains("outside workspace"));
}
other => panic!("unexpected error: {other:?}"),
}
let _ = fs::remove_file(outside);
let _ = fs::remove_dir_all(workspace);
}
#[tokio::test(flavor = "current_thread")]
async fn write_replaces_existing_content() {
let workspace = temp_workspace();
let file = workspace.join("target.txt");
fs::write(&file, "old").expect("write old content");
let runtime = FileSystemRuntime::new(workspace.clone());
runtime
.write_text_file(WriteTextFileRequest::new("sid", &file, "new-content"))
.await
.expect("write file");
let content = fs::read_to_string(&file).expect("read file");
assert_eq!(content, "new-content");
let leaked_tmp = fs::read_dir(&workspace)
.expect("read workspace")
.filter_map(Result::ok)
.any(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with(".codeg-fs-")
});
assert!(!leaked_tmp, "temporary file should be cleaned up");
let _ = fs::remove_dir_all(workspace);
}
}

View File

@@ -0,0 +1,197 @@
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::acp::connection::{spawn_agent_connection, AgentConnection, ConnectionCommand};
use crate::acp::error::AcpError;
use crate::acp::types::{ConnectionInfo, PromptInputBlock};
use crate::models::agent::AgentType;
pub struct ConnectionManager {
connections: Arc<Mutex<HashMap<String, AgentConnection>>>,
}
impl ConnectionManager {
pub fn new() -> Self {
Self {
connections: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn spawn_agent(
&self,
agent_type: AgentType,
working_dir: Option<String>,
session_id: Option<String>,
runtime_env: BTreeMap<String, String>,
owner_window_label: String,
app_handle: tauri::AppHandle,
) -> Result<String, AcpError> {
let connection_id = uuid::Uuid::new_v4().to_string();
eprintln!(
"[ACP] spawning connection id={} owner_window={} agent={:?}",
connection_id, owner_window_label, agent_type
);
let conn = spawn_agent_connection(
connection_id.clone(),
agent_type,
working_dir,
session_id,
runtime_env,
owner_window_label,
app_handle,
)
.await?;
self.connections
.lock()
.await
.insert(connection_id.clone(), conn);
Ok(connection_id)
}
pub async fn send_prompt(
&self,
conn_id: &str,
blocks: Vec<PromptInputBlock>,
) -> Result<(), AcpError> {
let cmd_tx = {
let connections = self.connections.lock().await;
let conn = connections
.get(conn_id)
.ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?;
conn.cmd_tx.clone()
};
cmd_tx
.send(ConnectionCommand::Prompt { blocks })
.await
.map_err(|_| AcpError::ProcessExited)
}
pub async fn set_mode(&self, conn_id: &str, mode_id: String) -> Result<(), AcpError> {
let cmd_tx = {
let connections = self.connections.lock().await;
let conn = connections
.get(conn_id)
.ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?;
conn.cmd_tx.clone()
};
cmd_tx
.send(ConnectionCommand::SetMode { mode_id })
.await
.map_err(|_| AcpError::ProcessExited)
}
pub async fn set_config_option(
&self,
conn_id: &str,
config_id: String,
value_id: String,
) -> Result<(), AcpError> {
let cmd_tx = {
let connections = self.connections.lock().await;
let conn = connections
.get(conn_id)
.ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?;
conn.cmd_tx.clone()
};
cmd_tx
.send(ConnectionCommand::SetConfigOption {
config_id,
value_id,
})
.await
.map_err(|_| AcpError::ProcessExited)
}
pub async fn cancel(&self, conn_id: &str) -> Result<(), AcpError> {
let cmd_tx = {
let connections = self.connections.lock().await;
let conn = connections
.get(conn_id)
.ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?;
conn.cmd_tx.clone()
};
cmd_tx
.send(ConnectionCommand::Cancel)
.await
.map_err(|_| AcpError::ProcessExited)
}
pub async fn respond_permission(
&self,
conn_id: &str,
request_id: &str,
option_id: &str,
) -> Result<(), AcpError> {
let cmd_tx = {
let connections = self.connections.lock().await;
let conn = connections
.get(conn_id)
.ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?;
conn.cmd_tx.clone()
};
cmd_tx
.send(ConnectionCommand::RespondPermission {
request_id: request_id.into(),
option_id: option_id.into(),
})
.await
.map_err(|_| AcpError::ProcessExited)
}
pub async fn disconnect(&self, conn_id: &str) -> Result<(), AcpError> {
let cmd_tx = {
let mut connections = self.connections.lock().await;
connections.remove(conn_id).map(|conn| conn.cmd_tx)
};
if let Some(cmd_tx) = cmd_tx {
let _ = cmd_tx.send(ConnectionCommand::Disconnect).await;
Ok(())
} else {
Err(AcpError::ConnectionNotFound(conn_id.into()))
}
}
pub async fn disconnect_by_owner_window(&self, owner_window_label: &str) -> usize {
let cmd_txs = {
let mut connections = self.connections.lock().await;
let ids: Vec<String> = connections
.iter()
.filter_map(|(id, conn)| {
if conn.owner_window_label == owner_window_label {
Some(id.clone())
} else {
None
}
})
.collect();
let mut txs = Vec::with_capacity(ids.len());
for id in ids {
if let Some(conn) = connections.remove(&id) {
txs.push(conn.cmd_tx);
}
}
txs
};
let disconnected = cmd_txs.len();
for cmd_tx in cmd_txs {
let _ = cmd_tx.send(ConnectionCommand::Disconnect).await;
}
eprintln!(
"[ACP] disconnect by owner window owner_window={} count={}",
owner_window_label, disconnected
);
disconnected
}
pub async fn list_connections(&self) -> Vec<ConnectionInfo> {
let connections = self.connections.lock().await;
connections.values().map(|c| c.info()).collect()
}
}

9
src-tauri/src/acp/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
pub mod binary_cache;
pub mod connection;
pub mod error;
pub mod file_system_runtime;
pub mod manager;
pub mod preflight;
pub mod registry;
pub mod terminal_runtime;
pub mod types;

View File

@@ -0,0 +1,400 @@
use serde::Serialize;
use std::sync::Mutex;
use crate::acp::binary_cache;
use crate::acp::registry::{self, AgentDistribution};
use crate::models::agent::AgentType;
/// Cache for NPX environment check results.
/// Stores `Some(checks)` after a successful (all-pass) run;
/// stays `None` if checks failed so they are retried next time.
static NPX_ENV_CACHE: Mutex<Option<Vec<CheckItem>>> = Mutex::new(None);
/// Cache for UVX environment check results.
/// Stores `Some(checks)` after a successful (all-pass) run;
/// stays `None` if checks failed so they are retried next time.
static UVX_ENV_CACHE: Mutex<Option<Vec<CheckItem>>> = Mutex::new(None);
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
#[allow(dead_code)]
pub enum FixActionKind {
OpenUrl,
RedownloadBinary,
RetryConnection,
}
#[derive(Debug, Clone, Serialize)]
pub struct FixAction {
pub label: String,
pub kind: FixActionKind,
pub payload: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckStatus {
Pass,
Fail,
Warn,
}
#[derive(Debug, Clone, Serialize)]
pub struct CheckItem {
pub check_id: String,
pub label: String,
pub status: CheckStatus,
pub message: String,
pub fixes: Vec<FixAction>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PreflightResult {
pub agent_type: AgentType,
pub agent_name: String,
pub passed: bool,
pub checks: Vec<CheckItem>,
}
pub async fn run_preflight(agent_type: AgentType) -> PreflightResult {
let meta = registry::get_agent_meta(agent_type);
let checks = match &meta.distribution {
AgentDistribution::Npx { node_required, .. } => check_npx_environment(*node_required).await,
AgentDistribution::Uvx { .. } => check_uvx_environment().await,
AgentDistribution::Binary {
version,
cmd,
platforms,
..
} => check_binary_environment(agent_type, version, cmd, platforms).await,
};
let passed = checks
.iter()
.all(|c| !matches!(c.status, CheckStatus::Fail));
PreflightResult {
agent_type,
agent_name: meta.name.to_string(),
passed,
checks,
}
}
async fn check_npx_environment(node_required: Option<&str>) -> Vec<CheckItem> {
// Return cached result if a previous check passed.
// The cache stores only the base checks (node_available + npx_available);
// the per-agent node_version check is appended separately.
let cached = NPX_ENV_CACHE.lock().unwrap().clone();
if let Some(cached) = cached {
let mut checks = cached;
if let Some(required) = node_required {
// Extract node version string from the cached node_available message
// (format: "Node.js v20.19.0 available")
let node_ver = extract_node_version_from_message(&checks[0].message);
checks.push(build_node_version_check(node_ver.as_deref(), required));
}
return checks;
}
// Run node and npx checks in parallel
let (node_result, npx_result) = tokio::join!(
crate::process::tokio_command("node")
.arg("--version")
.output(),
crate::process::tokio_command("npx")
.arg("--version")
.output(),
);
// Track the raw node version string for reuse in the version check
let mut node_version_str: Option<String> = None;
let node_check = match node_result {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
node_version_str = Some(version.clone());
CheckItem {
check_id: "node_available".into(),
label: "Node.js".into(),
status: CheckStatus::Pass,
message: format!("Node.js {version} available"),
fixes: vec![],
}
}
_ => CheckItem {
check_id: "node_available".into(),
label: "Node.js".into(),
status: CheckStatus::Fail,
message: "Node.js is not installed or not in PATH".into(),
fixes: vec![FixAction {
label: "Install Node.js".into(),
kind: FixActionKind::OpenUrl,
payload: "https://nodejs.org/".into(),
}],
},
};
let npx_check = match npx_result {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
CheckItem {
check_id: "npx_available".into(),
label: "npx".into(),
status: CheckStatus::Pass,
message: format!("npx {version} available"),
fixes: vec![],
}
}
_ => CheckItem {
check_id: "npx_available".into(),
label: "npx".into(),
status: CheckStatus::Fail,
message: "npx is not installed or not in PATH".into(),
fixes: vec![FixAction {
label: "Install Node.js".into(),
kind: FixActionKind::OpenUrl,
payload: "https://nodejs.org/".into(),
}],
},
};
let mut checks = vec![node_check, npx_check];
// Cache only if all checks passed — failed results are not cached so
// the user can retry after installing the missing tools.
let all_passed = checks
.iter()
.all(|c| !matches!(c.status, CheckStatus::Fail));
if all_passed {
*NPX_ENV_CACHE.lock().unwrap() = Some(checks.clone());
}
// After caching the base checks, append the per-agent Node.js version
// requirement if specified. Only meaningful when node is available.
if let Some(required) = node_required {
if all_passed {
checks.push(build_node_version_check(
node_version_str.as_deref(),
required,
));
}
}
checks
}
/// Parse a Node.js version string like "v20.19.0" or "20.19.0" into (major, minor, patch).
/// Handles pre-release suffixes such as "v22.0.0-nightly" by stripping non-numeric tails.
fn parse_node_version(v: &str) -> Option<(u32, u32, u32)> {
let v = v.trim().trim_start_matches('v');
let mut parts = v.splitn(3, '.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch_str = parts.next()?;
// Strip pre-release/build suffixes: "0-nightly" → "0", "3+build" → "3"
let patch_digits: String = patch_str
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
let patch = patch_digits.parse().ok()?;
Some((major, minor, patch))
}
/// Extract the node version string from a cached node_available message.
/// Expected format: "Node.js v20.19.0 available" → Some("v20.19.0")
fn extract_node_version_from_message(message: &str) -> Option<String> {
message
.split_whitespace()
.find(|s| s.starts_with('v') && s.contains('.'))
.map(|s| s.to_string())
}
/// Build a `CheckItem` for the Node.js version requirement check.
/// `current_version` is the raw output from `node --version` (e.g. "v20.19.0").
fn build_node_version_check(current_version: Option<&str>, required: &str) -> CheckItem {
let current_version = match current_version {
Some(v) => v,
None => {
return CheckItem {
check_id: "node_version".into(),
label: "Node.js version".into(),
status: CheckStatus::Fail,
message: "Cannot determine Node.js version".into(),
fixes: vec![],
};
}
};
let current = parse_node_version(current_version);
let required_parsed = parse_node_version(required);
match (current, required_parsed) {
(Some(cur), Some(req)) if cur >= req => CheckItem {
check_id: "node_version".into(),
label: "Node.js version".into(),
status: CheckStatus::Pass,
message: format!(
"Node.js {current_version} meets the minimum requirement (>={required})"
),
fixes: vec![],
},
(Some(_), Some(_)) => CheckItem {
check_id: "node_version".into(),
label: "Node.js version".into(),
status: CheckStatus::Fail,
message: format!(
"Node.js {current_version} is too old — this package requires Node.js >={required}"
),
fixes: vec![FixAction {
label: "Update Node.js".into(),
kind: FixActionKind::OpenUrl,
payload: "https://nodejs.org/".into(),
}],
},
_ => CheckItem {
check_id: "node_version".into(),
label: "Node.js version".into(),
status: CheckStatus::Warn,
message: format!("Cannot parse Node.js version; required >={required}"),
fixes: vec![],
},
}
}
async fn check_uvx_environment() -> Vec<CheckItem> {
// Return cached result if a previous check passed
let cached = UVX_ENV_CACHE.lock().unwrap().clone();
if let Some(cached) = cached {
return cached;
}
// Run uv and uvx checks in parallel
let (uv_result, uvx_result) = tokio::join!(
crate::process::tokio_command("uv")
.arg("--version")
.output(),
crate::process::tokio_command("uvx")
.arg("--version")
.output(),
);
let install_fix = vec![FixAction {
label: "Install uv".into(),
kind: FixActionKind::OpenUrl,
payload: "https://docs.astral.sh/uv/getting-started/installation/".into(),
}];
let uv_check = match uv_result {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
CheckItem {
check_id: "uv_available".into(),
label: "uv".into(),
status: CheckStatus::Pass,
message: format!("uv {version} available"),
fixes: vec![],
}
}
_ => CheckItem {
check_id: "uv_available".into(),
label: "uv".into(),
status: CheckStatus::Fail,
message: "uv is not installed or not in PATH".into(),
fixes: install_fix.clone(),
},
};
let uvx_check = match uvx_result {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
CheckItem {
check_id: "uvx_available".into(),
label: "uvx".into(),
status: CheckStatus::Pass,
message: format!("uvx {version} available"),
fixes: vec![],
}
}
_ => CheckItem {
check_id: "uvx_available".into(),
label: "uvx".into(),
status: CheckStatus::Fail,
message: "uvx is not installed or not in PATH".into(),
fixes: install_fix,
},
};
let checks = vec![uv_check, uvx_check];
let all_passed = checks
.iter()
.all(|c| !matches!(c.status, CheckStatus::Fail));
if all_passed {
*UVX_ENV_CACHE.lock().unwrap() = Some(checks.clone());
}
checks
}
async fn check_binary_environment(
agent_type: AgentType,
version: &str,
cmd: &str,
platforms: &[registry::PlatformBinary],
) -> Vec<CheckItem> {
let mut checks = Vec::new();
// Check platform support
let current = registry::current_platform();
let platform_supported = platforms.iter().any(|p| p.platform == current);
let platform_check = if platform_supported {
CheckItem {
check_id: "platform_supported".into(),
label: "Platform".into(),
status: CheckStatus::Pass,
message: format!("Platform {current} is supported"),
fixes: vec![],
}
} else {
CheckItem {
check_id: "platform_supported".into(),
label: "Platform".into(),
status: CheckStatus::Fail,
message: format!("Platform {current} is not supported"),
fixes: vec![],
}
};
checks.push(platform_check);
// Check binary cache
if platform_supported {
let cache_check = match binary_cache::find_cached_binary_for_agent(agent_type, version, cmd)
{
Ok(Some(_)) => CheckItem {
check_id: "binary_cached".into(),
label: "Binary cache".into(),
status: CheckStatus::Pass,
message: "Binary is cached locally".into(),
fixes: vec![],
},
Ok(None) => CheckItem {
check_id: "binary_cached".into(),
label: "Binary cache".into(),
status: CheckStatus::Warn,
message: "Binary not cached yet, will be downloaded on first connection".into(),
fixes: vec![],
},
Err(_) => CheckItem {
check_id: "binary_cached".into(),
label: "Binary cache".into(),
status: CheckStatus::Warn,
message: "Cannot determine binary cache path".into(),
fixes: vec![],
},
};
checks.push(cache_check);
}
checks
}

View File

@@ -0,0 +1,542 @@
use crate::models::agent::AgentType;
#[derive(Debug, Clone)]
pub enum AgentDistribution {
Npx {
version: &'static str,
package: &'static str,
args: &'static [&'static str],
env: &'static [(&'static str, &'static str)],
/// Minimum Node.js version required, e.g. "22.12.0". None means no specific requirement.
node_required: Option<&'static str>,
},
Uvx {
version: &'static str,
package: &'static str,
args: &'static [&'static str],
env: &'static [(&'static str, &'static str)],
},
Binary {
version: &'static str,
cmd: &'static str,
args: &'static [&'static str],
env: &'static [(&'static str, &'static str)],
platforms: &'static [PlatformBinary],
},
}
#[derive(Debug, Clone)]
pub struct PlatformBinary {
pub platform: &'static str,
pub url: &'static str,
}
#[derive(Debug, Clone)]
pub struct AcpAgentMeta {
#[allow(dead_code)]
pub agent_type: AgentType,
pub name: &'static str,
pub description: &'static str,
pub distribution: AgentDistribution,
}
impl AcpAgentMeta {
pub fn registry_version(&self) -> Option<&'static str> {
match &self.distribution {
AgentDistribution::Npx { version, .. }
| AgentDistribution::Uvx { version, .. }
| AgentDistribution::Binary { version, .. } => Some(*version),
}
}
}
pub fn current_platform() -> &'static str {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
"darwin-aarch64"
}
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
{
"darwin-x86_64"
}
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
{
"linux-aarch64"
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
{
"linux-x86_64"
}
#[cfg(all(target_os = "windows", target_arch = "aarch64"))]
{
"windows-aarch64"
}
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
{
"windows-x86_64"
}
}
pub fn all_acp_agents() -> Vec<AgentType> {
vec![
AgentType::Auggie,
AgentType::Autohand,
AgentType::ClaudeCode,
AgentType::Cline,
AgentType::CodebuddyCode,
AgentType::Codex,
AgentType::CorustAgent,
AgentType::FactoryDroid,
AgentType::Gemini,
AgentType::GithubCopilot,
AgentType::Goose,
AgentType::Junie,
AgentType::Kimi,
AgentType::MinionCode,
AgentType::MistralVibe,
AgentType::OpenClaw,
AgentType::OpenCode,
AgentType::Qoder,
AgentType::QwenCode,
AgentType::Stakpak,
]
}
pub fn registry_id_for(agent_type: AgentType) -> &'static str {
match agent_type {
AgentType::Auggie => "auggie",
AgentType::Autohand => "autohand",
AgentType::ClaudeCode => "claude-acp",
AgentType::Cline => "cline",
AgentType::CodebuddyCode => "codebuddy-code",
AgentType::Codex => "codex-acp",
AgentType::CorustAgent => "corust-agent",
AgentType::FactoryDroid => "factory-droid",
AgentType::Gemini => "gemini",
AgentType::GithubCopilot => "github-copilot",
AgentType::Goose => "goose",
AgentType::Junie => "junie-acp",
AgentType::Kimi => "kimi",
AgentType::MinionCode => "minion-code",
AgentType::MistralVibe => "mistral-vibe",
AgentType::OpenClaw => "openclaw-acp",
AgentType::OpenCode => "opencode",
AgentType::Qoder => "qoder",
AgentType::QwenCode => "qwen-code",
AgentType::Stakpak => "stakpak",
}
}
#[allow(dead_code)]
pub fn from_registry_id(id: &str) -> Option<AgentType> {
match id {
"auggie" => Some(AgentType::Auggie),
"autohand" => Some(AgentType::Autohand),
"claude-acp" => Some(AgentType::ClaudeCode),
"cline" => Some(AgentType::Cline),
"codebuddy-code" => Some(AgentType::CodebuddyCode),
"codex-acp" => Some(AgentType::Codex),
"corust-agent" => Some(AgentType::CorustAgent),
"factory-droid" => Some(AgentType::FactoryDroid),
"gemini" => Some(AgentType::Gemini),
"github-copilot" => Some(AgentType::GithubCopilot),
"goose" => Some(AgentType::Goose),
"junie-acp" => Some(AgentType::Junie),
"kimi" => Some(AgentType::Kimi),
"minion-code" => Some(AgentType::MinionCode),
"mistral-vibe" => Some(AgentType::MistralVibe),
"openclaw-acp" => Some(AgentType::OpenClaw),
"opencode" => Some(AgentType::OpenCode),
"qoder" => Some(AgentType::Qoder),
"qwen-code" => Some(AgentType::QwenCode),
"stakpak" => Some(AgentType::Stakpak),
_ => None,
}
}
pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta {
match agent_type {
AgentType::Auggie => AcpAgentMeta {
agent_type,
name: "Auggie CLI",
description: "Augment Code's powerful software agent, backed by industry-leading context engine",
distribution: AgentDistribution::Npx {
version: "0.17.0",
package: "@augmentcode/auggie@0.17.0",
args: &["--acp"],
env: &[("AUGMENT_DISABLE_AUTO_UPDATE", "1")],
node_required: None,
},
},
AgentType::Autohand => AcpAgentMeta {
agent_type,
name: "Autohand Code",
description: "Autohand Code - AI coding agent powered by Autohand AI",
distribution: AgentDistribution::Npx {
version: "0.2.1",
package: "@autohandai/autohand-acp@0.2.1",
args: &[],
env: &[],
node_required: None,
},
},
AgentType::ClaudeCode => AcpAgentMeta {
agent_type,
name: "Claude Code",
description: "ACP wrapper for Anthropic's Claude",
distribution: AgentDistribution::Npx {
version: "0.18.0",
package: "@zed-industries/claude-agent-acp@0.18.0",
args: &[],
env: &[],
node_required: None,
},
},
AgentType::Cline => AcpAgentMeta {
agent_type,
name: "Cline",
description: "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
distribution: AgentDistribution::Npx {
version: "2.5.0",
package: "cline@2.5.0",
args: &["--acp"],
env: &[],
node_required: None,
},
},
AgentType::CodebuddyCode => AcpAgentMeta {
agent_type,
name: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
distribution: AgentDistribution::Npx {
version: "2.51.2",
package: "@tencent-ai/codebuddy-code@2.51.2",
args: &["--acp"],
env: &[],
node_required: None,
},
},
AgentType::Codex => AcpAgentMeta {
agent_type,
name: "Codex CLI",
description: "ACP adapter for OpenAI's coding assistant",
distribution: AgentDistribution::Binary {
version: "0.9.4",
cmd: "codex-acp",
args: &[],
env: &[],
platforms: &[
PlatformBinary {
platform: "darwin-aarch64",
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-aarch64-apple-darwin.tar.gz",
},
PlatformBinary {
platform: "darwin-x86_64",
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-x86_64-apple-darwin.tar.gz",
},
PlatformBinary {
platform: "linux-aarch64",
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-aarch64-unknown-linux-gnu.tar.gz",
},
PlatformBinary {
platform: "linux-x86_64",
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-x86_64-unknown-linux-gnu.tar.gz",
},
PlatformBinary {
platform: "windows-aarch64",
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-aarch64-pc-windows-msvc.zip",
},
PlatformBinary {
platform: "windows-x86_64",
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-x86_64-pc-windows-msvc.zip",
},
],
},
},
AgentType::CorustAgent => AcpAgentMeta {
agent_type,
name: "Corust Agent",
description: "Co-building with a seasoned Rust partner.",
distribution: AgentDistribution::Binary {
version: "0.3.4",
cmd: "corust-agent-acp",
args: &[],
env: &[],
platforms: &[
PlatformBinary {
platform: "darwin-aarch64",
url: "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.3.4/agent-darwin-arm64.tar.gz",
},
PlatformBinary {
platform: "linux-x86_64",
url: "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.3.4/agent-linux-x64.tar.gz",
},
PlatformBinary {
platform: "windows-x86_64",
url: "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.3.4/agent-windows-x64.zip",
},
],
},
},
AgentType::FactoryDroid => AcpAgentMeta {
agent_type,
name: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
distribution: AgentDistribution::Npx {
version: "0.63.0",
package: "droid@0.63.0",
args: &["exec", "--output-format", "acp"],
env: &[("DROID_DISABLE_AUTO_UPDATE", "true"), ("FACTORY_DROID_AUTO_UPDATE_ENABLED", "false")],
node_required: None,
},
},
AgentType::Gemini => AcpAgentMeta {
agent_type,
name: "Gemini CLI",
description: "Google's official CLI for Gemini",
distribution: AgentDistribution::Npx {
version: "0.30.0",
package: "@google/gemini-cli@0.30.0",
args: &["--experimental-acp"],
env: &[],
node_required: None,
},
},
AgentType::GithubCopilot => AcpAgentMeta {
agent_type,
name: "GitHub Copilot",
description: "GitHub's AI pair programmer",
distribution: AgentDistribution::Npx {
version: "1.432.0",
package: "@github/copilot-language-server@1.432.0",
args: &["--acp"],
env: &[],
node_required: None,
},
},
AgentType::Goose => AcpAgentMeta {
agent_type,
name: "goose",
description: "A local, extensible, open source AI agent that automates engineering tasks",
distribution: AgentDistribution::Binary {
version: "1.25.1",
cmd: "goose",
args: &[],
env: &[],
platforms: &[
PlatformBinary {
platform: "darwin-aarch64",
url: "https://github.com/block/goose/releases/download/v1.25.1/goose-aarch64-apple-darwin.tar.bz2",
},
PlatformBinary {
platform: "darwin-x86_64",
url: "https://github.com/block/goose/releases/download/v1.25.1/goose-x86_64-apple-darwin.tar.bz2",
},
PlatformBinary {
platform: "linux-aarch64",
url: "https://github.com/block/goose/releases/download/v1.25.1/goose-aarch64-unknown-linux-gnu.tar.bz2",
},
PlatformBinary {
platform: "linux-x86_64",
url: "https://github.com/block/goose/releases/download/v1.25.1/goose-x86_64-unknown-linux-gnu.tar.bz2",
},
PlatformBinary {
platform: "windows-x86_64",
url: "https://github.com/block/goose/releases/download/v1.25.1/goose-x86_64-pc-windows-msvc.zip",
},
],
},
},
AgentType::Junie => AcpAgentMeta {
agent_type,
name: "Junie",
description: "AI Coding Agent by JetBrains",
distribution: AgentDistribution::Npx {
version: "849.19.0",
package: "@jetbrains/junie-cli@849.19.0",
args: &["--acp=true"],
env: &[],
node_required: None,
},
},
AgentType::Kimi => AcpAgentMeta {
agent_type,
name: "Kimi CLI",
description: "Moonshot AI's coding assistant",
distribution: AgentDistribution::Binary {
version: "1.14.0",
cmd: "kimi",
args: &[],
env: &[],
platforms: &[
PlatformBinary {
platform: "darwin-aarch64",
url: "https://github.com/MoonshotAI/kimi-cli/releases/download/1.14.0/kimi-1.14.0-aarch64-apple-darwin.tar.gz",
},
PlatformBinary {
platform: "linux-aarch64",
url: "https://github.com/MoonshotAI/kimi-cli/releases/download/1.14.0/kimi-1.14.0-aarch64-unknown-linux-gnu.tar.gz",
},
PlatformBinary {
platform: "linux-x86_64",
url: "https://github.com/MoonshotAI/kimi-cli/releases/download/1.14.0/kimi-1.14.0-x86_64-unknown-linux-gnu.tar.gz",
},
PlatformBinary {
platform: "windows-x86_64",
url: "https://github.com/MoonshotAI/kimi-cli/releases/download/1.14.0/kimi-1.14.0-x86_64-pc-windows-msvc.zip",
},
],
},
},
AgentType::MinionCode => AcpAgentMeta {
agent_type,
name: "Minion Code",
description: "An enhanced AI code assistant built on the Minion framework with rich development tools",
distribution: AgentDistribution::Uvx {
version: "0.1.39",
package: "minion-code@0.1.39",
args: &["acp"],
env: &[],
},
},
AgentType::MistralVibe => AcpAgentMeta {
agent_type,
name: "Mistral Vibe",
description: "Mistral's open-source coding assistant",
distribution: AgentDistribution::Binary {
version: "2.2.1",
cmd: "vibe-acp",
args: &[],
env: &[],
platforms: &[
PlatformBinary {
platform: "darwin-aarch64",
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-darwin-aarch64-2.2.1.zip",
},
PlatformBinary {
platform: "darwin-x86_64",
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-darwin-x86_64-2.2.1.zip",
},
PlatformBinary {
platform: "linux-aarch64",
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-linux-aarch64-2.2.1.zip",
},
PlatformBinary {
platform: "linux-x86_64",
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-linux-x86_64-2.2.1.zip",
},
PlatformBinary {
platform: "windows-aarch64",
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-windows-aarch64-2.2.1.zip",
},
PlatformBinary {
platform: "windows-x86_64",
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-windows-x86_64-2.2.1.zip",
},
],
},
},
AgentType::OpenClaw => AcpAgentMeta {
agent_type,
name: "OpenClaw",
description: "Open-source personal AI assistant with ACP bridge",
distribution: AgentDistribution::Npx {
version: "2026.2.26",
package: "openclaw@2026.2.26",
args: &["acp"],
env: &[],
node_required: Some("22.12.0"),
},
},
AgentType::OpenCode => AcpAgentMeta {
agent_type,
name: "OpenCode",
description: "The open source coding agent",
distribution: AgentDistribution::Binary {
version: "1.2.15",
cmd: "opencode",
args: &["acp"],
env: &[],
platforms: &[
PlatformBinary {
platform: "darwin-aarch64",
url: "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-darwin-arm64.zip",
},
PlatformBinary {
platform: "darwin-x86_64",
url: "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-darwin-x64.zip",
},
PlatformBinary {
platform: "linux-aarch64",
url: "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-linux-arm64.tar.gz",
},
PlatformBinary {
platform: "linux-x86_64",
url: "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-linux-x64.tar.gz",
},
PlatformBinary {
platform: "windows-x86_64",
url: "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-windows-x64.zip",
},
],
},
},
AgentType::Qoder => AcpAgentMeta {
agent_type,
name: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
distribution: AgentDistribution::Npx {
version: "0.1.29",
package: "@qoder-ai/qodercli@0.1.29",
args: &["--acp"],
env: &[],
node_required: None,
},
},
AgentType::QwenCode => AcpAgentMeta {
agent_type,
name: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
distribution: AgentDistribution::Npx {
version: "0.10.6",
package: "@qwen-code/qwen-code@0.10.6",
args: &["--acp", "--experimental-skills"],
env: &[],
node_required: None,
},
},
AgentType::Stakpak => AcpAgentMeta {
agent_type,
name: "Stakpak",
description: "Open-source DevOps agent in Rust with enterprise-grade security",
distribution: AgentDistribution::Binary {
version: "0.3.62",
cmd: "stakpak",
args: &[],
env: &[],
platforms: &[
PlatformBinary {
platform: "darwin-aarch64",
url: "https://github.com/stakpak/agent/releases/download/v0.3.62/stakpak-darwin-aarch64.tar.gz",
},
PlatformBinary {
platform: "darwin-x86_64",
url: "https://github.com/stakpak/agent/releases/download/v0.3.62/stakpak-darwin-x86_64.tar.gz",
},
PlatformBinary {
platform: "linux-aarch64",
url: "https://github.com/stakpak/agent/releases/download/v0.3.62/stakpak-linux-aarch64.tar.gz",
},
PlatformBinary {
platform: "linux-x86_64",
url: "https://github.com/stakpak/agent/releases/download/v0.3.62/stakpak-linux-x86_64.tar.gz",
},
PlatformBinary {
platform: "windows-x86_64",
url: "https://github.com/stakpak/agent/releases/download/v0.3.62/stakpak-windows-x86_64.zip",
},
],
},
},
}
}

View File

@@ -0,0 +1,125 @@
use std::collections::BTreeMap;
use serde::Deserialize;
use crate::acp::registry;
use crate::models::agent::AgentType;
pub const REGISTRY_URL: &str =
"https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
#[derive(Debug, Clone)]
pub struct RegistryAgent {
pub agent_type: AgentType,
pub registry_id: String,
pub name: String,
pub description: String,
pub version: Option<String>,
}
#[derive(Debug, Clone)]
pub struct RegistryBinaryRelease {
pub version: String,
pub archive_url: String,
}
#[derive(Debug, Deserialize)]
struct RegistryPayload {
agents: Vec<RegistryAgentItem>,
}
#[derive(Debug, Deserialize)]
struct RegistryAgentItem {
id: String,
name: String,
description: String,
#[serde(default)]
version: Option<String>,
#[serde(default)]
distribution: RegistryDistribution,
}
#[derive(Debug, Default, Deserialize)]
struct RegistryDistribution {
#[serde(default)]
binary: BTreeMap<String, RegistryBinaryPlatformItem>,
}
#[derive(Debug, Default, Deserialize)]
struct RegistryBinaryPlatformItem {
#[serde(default, alias = "url")]
archive: String,
}
async fn fetch_registry_payload() -> Result<RegistryPayload, String> {
let response = reqwest::Client::new()
.get(REGISTRY_URL)
.send()
.await
.map_err(|e| format!("failed to fetch ACP registry: {e}"))?;
if !response.status().is_success() {
return Err(format!(
"failed to fetch ACP registry: HTTP {}",
response.status()
));
}
response
.text()
.await
.map_err(|e| format!("failed to read ACP registry response: {e}"))
.and_then(|text| {
serde_json::from_str::<RegistryPayload>(&text)
.map_err(|e| format!("failed to parse ACP registry JSON: {e}"))
})
}
pub async fn fetch_supported_agents() -> Result<Vec<RegistryAgent>, String> {
let payload = fetch_registry_payload().await?;
let mut supported = Vec::new();
for item in payload.agents {
if let Some(agent_type) = registry::from_registry_id(&item.id) {
supported.push(RegistryAgent {
agent_type,
registry_id: item.id,
name: item.name,
description: item.description,
version: item.version,
});
}
}
Ok(supported)
}
pub async fn fetch_binary_release(
agent_type: AgentType,
platform: &str,
) -> Result<Option<RegistryBinaryRelease>, String> {
let payload = fetch_registry_payload().await?;
let item = payload.agents.into_iter().find(|item| {
registry::from_registry_id(&item.id)
.map(|candidate| candidate == agent_type)
.unwrap_or(false)
});
let Some(item) = item else {
return Ok(None);
};
if item.version.as_deref().unwrap_or_default().is_empty() {
return Ok(None);
}
let platform_item = item.distribution.binary.get(platform);
let Some(platform_item) = platform_item else {
return Ok(None);
};
if platform_item.archive.is_empty() {
return Ok(None);
}
Ok(Some(RegistryBinaryRelease {
version: item.version.unwrap_or_default(),
archive_url: platform_item.archive.clone(),
}))
}

View File

@@ -0,0 +1,517 @@
use std::collections::HashMap;
use std::process::Stdio;
use std::sync::Arc;
use sacp::schema::{
CreateTerminalRequest, CreateTerminalResponse, KillTerminalCommandRequest,
KillTerminalCommandResponse, ReleaseTerminalRequest, ReleaseTerminalResponse,
TerminalExitStatus, TerminalOutputRequest, TerminalOutputResponse, WaitForTerminalExitRequest,
WaitForTerminalExitResponse,
};
use tokio::io::{AsyncRead, AsyncReadExt};
use tokio::sync::Mutex;
type TerminalMap = HashMap<String, Arc<TerminalInstance>>;
const DEFAULT_OUTPUT_BYTE_LIMIT: u64 = 1_000_000;
#[derive(Debug)]
pub enum TerminalRuntimeError {
InvalidParams(String),
Internal(String),
}
impl TerminalRuntimeError {
pub fn to_rpc_error(self) -> sacp::Error {
match self {
Self::InvalidParams(message) => sacp::Error::invalid_params().data(message),
Self::Internal(message) => sacp::util::internal_error(message),
}
}
}
#[derive(Debug, Default, Clone)]
struct TerminalSnapshot {
output: String,
output_base_offset: u64,
truncated: bool,
exit_status: Option<TerminalExitStatus>,
}
struct TerminalInstance {
session_id: String,
output_limit: Option<usize>,
child: Mutex<Option<tokio::process::Child>>,
snapshot: Mutex<TerminalSnapshot>,
}
impl TerminalInstance {
fn new(session_id: String, output_limit: Option<u64>, child: tokio::process::Child) -> Self {
Self {
session_id,
output_limit: output_limit.and_then(|v| usize::try_from(v).ok()),
child: Mutex::new(Some(child)),
snapshot: Mutex::new(TerminalSnapshot::default()),
}
}
async fn append_output(&self, text: &str) {
let mut snapshot = self.snapshot.lock().await;
snapshot.output.push_str(text);
if let Some(limit) = self.output_limit {
let removed = enforce_output_limit(&mut snapshot.output, limit);
if removed > 0 {
snapshot.truncated = true;
snapshot.output_base_offset = snapshot
.output_base_offset
.saturating_add(u64::try_from(removed).unwrap_or(u64::MAX));
}
}
}
async fn refresh_exit_status(&self) -> Result<(), TerminalRuntimeError> {
{
let snapshot = self.snapshot.lock().await;
if snapshot.exit_status.is_some() {
return Ok(());
}
}
let maybe_status = {
let mut child_guard = self.child.lock().await;
if let Some(child) = child_guard.as_mut() {
match child.try_wait() {
Ok(Some(status)) => {
*child_guard = None;
Some(status)
}
Ok(None) => None,
Err(err) => {
return Err(TerminalRuntimeError::Internal(format!(
"failed to query terminal exit status: {err}"
)))
}
}
} else {
None
}
};
if let Some(status) = maybe_status {
let mut snapshot = self.snapshot.lock().await;
snapshot.exit_status = Some(map_exit_status(status));
}
Ok(())
}
async fn wait_for_exit(&self) -> Result<TerminalExitStatus, TerminalRuntimeError> {
self.refresh_exit_status().await?;
{
let snapshot = self.snapshot.lock().await;
if let Some(exit_status) = snapshot.exit_status.clone() {
return Ok(exit_status);
}
}
let exit_status = {
let mut child_guard = self.child.lock().await;
let Some(child) = child_guard.as_mut() else {
return Err(TerminalRuntimeError::Internal(
"terminal process missing while waiting for exit".to_string(),
));
};
let status = child.wait().await.map_err(|err| {
TerminalRuntimeError::Internal(format!(
"failed waiting for terminal process to exit: {err}"
))
})?;
*child_guard = None;
map_exit_status(status)
};
let mut snapshot = self.snapshot.lock().await;
snapshot.exit_status = Some(exit_status.clone());
Ok(exit_status)
}
async fn kill_command(&self) -> Result<(), TerminalRuntimeError> {
self.refresh_exit_status().await?;
{
let snapshot = self.snapshot.lock().await;
if snapshot.exit_status.is_some() {
return Ok(());
}
}
let exit_status = {
let mut child_guard = self.child.lock().await;
let Some(child) = child_guard.as_mut() else {
return Ok(());
};
if let Err(err) = child.kill().await {
if err.kind() != std::io::ErrorKind::InvalidInput {
return Err(TerminalRuntimeError::Internal(format!(
"failed to kill terminal process: {err}"
)));
}
}
let status = child.wait().await.map_err(|err| {
TerminalRuntimeError::Internal(format!(
"failed to wait for killed terminal process: {err}"
))
})?;
*child_guard = None;
map_exit_status(status)
};
let mut snapshot = self.snapshot.lock().await;
snapshot.exit_status = Some(exit_status);
Ok(())
}
async fn snapshot(&self) -> TerminalSnapshot {
self.snapshot.lock().await.clone()
}
}
pub struct TerminalRuntime {
terminals: Mutex<TerminalMap>,
}
#[derive(Debug, Clone)]
pub struct TerminalOutputDelta {
pub output: String,
pub next_offset: u64,
pub had_gap: bool,
pub truncated: bool,
pub exit_status: Option<TerminalExitStatus>,
}
impl TerminalRuntime {
pub fn new() -> Self {
Self {
terminals: Mutex::new(HashMap::new()),
}
}
pub async fn create_terminal(
&self,
request: CreateTerminalRequest,
) -> Result<CreateTerminalResponse, TerminalRuntimeError> {
if let Some(cwd) = request.cwd.as_ref() {
if !cwd.is_absolute() {
return Err(TerminalRuntimeError::InvalidParams(
"terminal/create requires an absolute cwd when provided".to_string(),
));
}
}
let output_byte_limit = request
.output_byte_limit
.unwrap_or(DEFAULT_OUTPUT_BYTE_LIMIT);
if output_byte_limit == 0 {
return Err(TerminalRuntimeError::InvalidParams(
"terminal/create outputByteLimit must be greater than 0".to_string(),
));
}
let mut command = crate::process::tokio_command(&request.command);
command
.args(&request.args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null());
if let Some(cwd) = request.cwd.as_ref() {
command.current_dir(cwd);
}
for env_var in &request.env {
command.env(&env_var.name, &env_var.value);
}
let mut child = command.spawn().map_err(|err| {
TerminalRuntimeError::Internal(format!(
"failed to spawn terminal command {}: {err}",
request.command
))
})?;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let terminal_id = format!("term_{}", uuid::Uuid::new_v4().simple());
let terminal = Arc::new(TerminalInstance::new(
request.session_id.to_string(),
Some(output_byte_limit),
child,
));
if let Some(reader) = stdout {
let terminal_ref = terminal.clone();
tokio::spawn(async move {
read_stream(reader, terminal_ref).await;
});
}
if let Some(reader) = stderr {
let terminal_ref = terminal.clone();
tokio::spawn(async move {
read_stream(reader, terminal_ref).await;
});
}
self.terminals
.lock()
.await
.insert(terminal_id.clone(), terminal);
Ok(CreateTerminalResponse::new(terminal_id))
}
pub async fn terminal_output(
&self,
request: TerminalOutputRequest,
) -> Result<TerminalOutputResponse, TerminalRuntimeError> {
let terminal = self
.find_terminal(
&request.terminal_id.to_string(),
&request.session_id.to_string(),
)
.await?;
terminal.refresh_exit_status().await?;
let snapshot = terminal.snapshot().await;
Ok(
TerminalOutputResponse::new(snapshot.output, snapshot.truncated)
.exit_status(snapshot.exit_status),
)
}
pub async fn terminal_output_delta(
&self,
session_id: &str,
terminal_id: &str,
from_offset: Option<u64>,
) -> Result<TerminalOutputDelta, TerminalRuntimeError> {
let terminal = self.find_terminal(terminal_id, session_id).await?;
terminal.refresh_exit_status().await?;
let snapshot = terminal.snapshot().await;
let output_len = u64::try_from(snapshot.output.len()).unwrap_or(u64::MAX);
let base_offset = snapshot.output_base_offset;
let end_offset = base_offset.saturating_add(output_len);
let requested_offset = from_offset.unwrap_or(base_offset);
let had_gap = from_offset
.map(|offset| offset < base_offset)
.unwrap_or(false);
let start_offset = requested_offset.clamp(base_offset, end_offset);
let start_index = usize::try_from(start_offset.saturating_sub(base_offset)).unwrap_or(0);
let output = snapshot.output[start_index..].to_string();
Ok(TerminalOutputDelta {
output,
next_offset: end_offset,
had_gap,
truncated: snapshot.truncated,
exit_status: snapshot.exit_status,
})
}
pub async fn wait_for_terminal_exit(
&self,
request: WaitForTerminalExitRequest,
) -> Result<WaitForTerminalExitResponse, TerminalRuntimeError> {
let terminal = self
.find_terminal(
&request.terminal_id.to_string(),
&request.session_id.to_string(),
)
.await?;
let exit_status = terminal.wait_for_exit().await?;
Ok(WaitForTerminalExitResponse::new(exit_status))
}
pub async fn kill_terminal(
&self,
request: KillTerminalCommandRequest,
) -> Result<KillTerminalCommandResponse, TerminalRuntimeError> {
let terminal = self
.find_terminal(
&request.terminal_id.to_string(),
&request.session_id.to_string(),
)
.await?;
terminal.kill_command().await?;
Ok(KillTerminalCommandResponse::new())
}
pub async fn release_terminal(
&self,
request: ReleaseTerminalRequest,
) -> Result<ReleaseTerminalResponse, TerminalRuntimeError> {
let terminal_id = request.terminal_id.to_string();
let session_id = request.session_id.to_string();
let terminal = {
let mut terminals = self.terminals.lock().await;
let Some(existing) = terminals.get(&terminal_id) else {
return Err(TerminalRuntimeError::InvalidParams(format!(
"terminal {terminal_id} not found"
)));
};
if existing.session_id != session_id {
return Err(TerminalRuntimeError::InvalidParams(format!(
"terminal {terminal_id} does not belong to session {session_id}"
)));
}
terminals.remove(&terminal_id).expect("terminal exists")
};
terminal.kill_command().await?;
Ok(ReleaseTerminalResponse::new())
}
pub async fn release_all_for_session(&self, session_id: &str) {
let removed = {
let mut terminals = self.terminals.lock().await;
let ids: Vec<String> = terminals
.iter()
.filter(|(_, term)| term.session_id == session_id)
.map(|(id, _)| id.clone())
.collect();
let mut removed = Vec::with_capacity(ids.len());
for id in ids {
if let Some(term) = terminals.remove(&id) {
removed.push(term);
}
}
removed
};
for terminal in removed {
if let Err(err) = terminal.kill_command().await {
eprintln!("[ACP] Failed to release terminal during cleanup: {err:?}");
}
}
}
async fn find_terminal(
&self,
terminal_id: &str,
session_id: &str,
) -> Result<Arc<TerminalInstance>, TerminalRuntimeError> {
let terminal = {
let terminals = self.terminals.lock().await;
terminals.get(terminal_id).cloned()
}
.ok_or_else(|| {
TerminalRuntimeError::InvalidParams(format!("terminal {terminal_id} not found"))
})?;
if terminal.session_id != session_id {
return Err(TerminalRuntimeError::InvalidParams(format!(
"terminal {terminal_id} does not belong to session {session_id}"
)));
}
Ok(terminal)
}
}
async fn read_stream<R>(mut reader: R, terminal: Arc<TerminalInstance>)
where
R: AsyncRead + Unpin,
{
let mut buffer = [0_u8; 4096];
let mut pending = Vec::<u8>::new();
loop {
match reader.read(&mut buffer).await {
Ok(0) => {
if !pending.is_empty() {
let text = String::from_utf8_lossy(&pending).to_string();
terminal.append_output(&text).await;
pending.clear();
}
break;
}
Ok(size) => {
pending.extend_from_slice(&buffer[..size]);
let decoded = decode_available_utf8(&mut pending);
if !decoded.is_empty() {
terminal.append_output(&decoded).await;
}
}
Err(_) => break,
}
}
}
fn map_exit_status(status: std::process::ExitStatus) -> TerminalExitStatus {
#[cfg(unix)]
let signal = std::os::unix::process::ExitStatusExt::signal(&status).map(|s| s.to_string());
#[cfg(not(unix))]
let signal: Option<String> = None;
let exit_code = status.code().and_then(|code| u32::try_from(code).ok());
TerminalExitStatus::new()
.exit_code(exit_code)
.signal(signal)
}
fn enforce_output_limit(output: &mut String, limit: usize) -> usize {
if output.len() <= limit {
return 0;
}
let mut start = output.len().saturating_sub(limit);
while start < output.len() && !output.is_char_boundary(start) {
start += 1;
}
output.drain(..start);
start
}
fn decode_available_utf8(pending: &mut Vec<u8>) -> String {
let mut output = String::new();
let mut consumed = 0usize;
let mut remaining = pending.as_slice();
while !remaining.is_empty() {
match std::str::from_utf8(remaining) {
Ok(text) => {
output.push_str(text);
consumed = consumed.saturating_add(remaining.len());
break;
}
Err(err) => {
let valid_up_to = err.valid_up_to();
if valid_up_to > 0 {
if let Ok(text) = std::str::from_utf8(&remaining[..valid_up_to]) {
output.push_str(text);
}
consumed = consumed.saturating_add(valid_up_to);
remaining = &remaining[valid_up_to..];
}
match err.error_len() {
Some(invalid_len) => {
output.push_str(&String::from_utf8_lossy(&remaining[..invalid_len]));
consumed = consumed.saturating_add(invalid_len);
remaining = &remaining[invalid_len..];
}
None => break, // keep partial UTF-8 sequence for next chunk
}
}
}
}
if consumed > 0 {
pending.drain(..consumed);
}
output
}

257
src-tauri/src/acp/types.rs Normal file
View File

@@ -0,0 +1,257 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PromptInputBlock {
Text {
text: String,
},
ResourceLink {
uri: String,
name: String,
#[serde(default)]
mime_type: Option<String>,
#[serde(default)]
description: Option<String>,
},
}
/// Events pushed from Rust backend to frontend via Tauri event system.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AcpEvent {
/// Agent returned text content (streaming delta)
ContentDelta { connection_id: String, text: String },
/// Agent thinking/reasoning
Thinking { connection_id: String, text: String },
/// Agent initiated a tool call
ToolCall {
connection_id: String,
tool_call_id: String,
title: String,
kind: String,
status: String,
content: Option<String>,
raw_input: Option<String>,
raw_output: Option<String>,
},
/// Tool call status/content updated
ToolCallUpdate {
connection_id: String,
tool_call_id: String,
title: Option<String>,
status: Option<String>,
content: Option<String>,
raw_input: Option<String>,
raw_output: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
raw_output_append: Option<bool>,
},
/// Agent requests permission
PermissionRequest {
connection_id: String,
request_id: String,
tool_call: serde_json::Value,
options: Vec<PermissionOptionInfo>,
},
/// Turn completed
TurnComplete {
connection_id: String,
stop_reason: String,
},
/// Session established with agent-assigned session ID
SessionStarted {
connection_id: String,
session_id: String,
},
/// Session modes are available for this connection
SessionModes {
connection_id: String,
modes: SessionModeStateInfo,
},
/// Session configuration options are available/updated for this connection
SessionConfigOptions {
connection_id: String,
config_options: Vec<SessionConfigOptionInfo>,
},
/// Initial selector payloads (modes/config options) have been emitted
SelectorsReady { connection_id: String },
/// Current session mode changed
ModeChanged {
connection_id: String,
mode_id: String,
},
/// Agent reported plan update for current turn
PlanUpdate {
connection_id: String,
entries: Vec<PlanEntryInfo>,
},
/// Connection status changed
StatusChanged {
connection_id: String,
status: ConnectionStatus,
},
/// Error occurred
Error {
connection_id: String,
message: String,
},
/// Available slash commands updated
AvailableCommands {
connection_id: String,
commands: Vec<AvailableCommandInfo>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionOptionInfo {
pub option_id: String,
pub name: String,
pub kind: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionModeInfo {
pub id: String,
pub name: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionModeStateInfo {
pub current_mode_id: String,
pub available_modes: Vec<SessionModeInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionConfigSelectOptionInfo {
pub value: String,
pub name: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionConfigSelectGroupInfo {
pub group: String,
pub name: String,
pub options: Vec<SessionConfigSelectOptionInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionConfigSelectInfo {
pub current_value: String,
pub options: Vec<SessionConfigSelectOptionInfo>,
pub groups: Vec<SessionConfigSelectGroupInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SessionConfigKindInfo {
Select(SessionConfigSelectInfo),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionConfigOptionInfo {
pub id: String,
pub name: String,
pub description: Option<String>,
pub category: Option<String>,
pub kind: SessionConfigKindInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanEntryInfo {
pub content: String,
pub priority: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ConnectionStatus {
Connecting,
Downloading,
Connected,
Prompting,
Disconnected,
Error,
}
#[derive(Debug, Clone, Serialize)]
pub struct ConnectionInfo {
pub id: String,
pub agent_type: crate::models::agent::AgentType,
pub status: ConnectionStatus,
}
#[derive(Debug, Clone, Serialize)]
pub struct AcpAgentInfo {
pub agent_type: crate::models::agent::AgentType,
pub registry_id: String,
pub registry_version: Option<String>,
pub name: String,
pub description: String,
pub available: bool,
pub distribution_type: String,
pub enabled: bool,
pub sort_order: i32,
pub installed_version: Option<String>,
pub env: BTreeMap<String, String>,
pub config_json: Option<String>,
pub config_file_path: Option<String>,
pub opencode_auth_json: Option<String>,
pub codex_auth_json: Option<String>,
pub codex_config_toml: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AgentSkillScope {
Global,
Project,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AgentSkillLayout {
MarkdownFile,
SkillDirectory,
}
#[derive(Debug, Clone, Serialize)]
pub struct AgentSkillLocation {
pub scope: AgentSkillScope,
pub path: String,
pub exists: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct AgentSkillItem {
pub id: String,
pub name: String,
pub scope: AgentSkillScope,
pub layout: AgentSkillLayout,
pub path: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct AgentSkillsListResult {
pub supported: bool,
pub message: Option<String>,
pub locations: Vec<AgentSkillLocation>,
pub skills: Vec<AgentSkillItem>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AgentSkillContent {
pub skill: AgentSkillItem,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AvailableCommandInfo {
pub name: String,
pub description: String,
pub input_hint: Option<String>,
}