Initial commit
This commit is contained in:
417
src-tauri/src/acp/binary_cache.rs
Normal file
417
src-tauri/src/acp/binary_cache.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
1715
src-tauri/src/acp/connection.rs
Normal file
1715
src-tauri/src/acp/connection.rs
Normal file
File diff suppressed because it is too large
Load Diff
77
src-tauri/src/acp/error.rs
Normal file
77
src-tauri/src/acp/error.rs
Normal 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")
|
||||
}
|
||||
512
src-tauri/src/acp/file_system_runtime.rs
Normal file
512
src-tauri/src/acp/file_system_runtime.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
197
src-tauri/src/acp/manager.rs
Normal file
197
src-tauri/src/acp/manager.rs
Normal 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
9
src-tauri/src/acp/mod.rs
Normal 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;
|
||||
400
src-tauri/src/acp/preflight.rs
Normal file
400
src-tauri/src/acp/preflight.rs
Normal 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
|
||||
}
|
||||
542
src-tauri/src/acp/registry.rs
Normal file
542
src-tauri/src/acp/registry.rs
Normal 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",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
125
src-tauri/src/acp/remote_registry.rs
Normal file
125
src-tauri/src/acp/remote_registry.rs
Normal 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(),
|
||||
}))
|
||||
}
|
||||
517
src-tauri/src/acp/terminal_runtime.rs
Normal file
517
src-tauri/src/acp/terminal_runtime.rs
Normal 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
257
src-tauri/src/acp/types.rs
Normal 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>,
|
||||
}
|
||||
Reference in New Issue
Block a user