use crate::{agent::mcp, model::settings};
use anyhow::{Result, anyhow};
use cacp::{
AgentConn, Client, Direction, Error, Tap,
schema::{
AuthenticateRequest, CancelNotification, ClientCapabilities, ContentBlock, EnvVariable,
FileSystemCapabilities, InitializeRequest, InitializeResponse, LoadSessionRequest,
McpServer, McpServerHttp, McpServerStdio, NewSessionRequest, NewSessionResponse,
PromptRequest, ReadTextFileRequest, ReadTextFileResponse, RequestPermissionRequest,
RequestPermissionResponse, SessionConfigOptionValue, SessionId, SessionNotification,
SessionUpdate, SetSessionConfigOptionRequest, SetSessionModeRequest, StopReason,
WriteTextFileRequest, WriteTextFileResponse,
},
};
use std::process::Stdio;
use std::{
path::PathBuf,
sync::{Arc, OnceLock},
time::Duration,
};
use tokio::{
process::{Child, Command},
runtime::Runtime,
sync::{mpsc, oneshot},
};
const DEBUG: &str = "CYDONIA_DEBUG";
const SHUTDOWN_GRACE: Duration = Duration::from_millis(250);
pub fn runtime() -> &'static Runtime {
static RUNTIME: OnceLock<Runtime> = OnceLock::new();
RUNTIME.get_or_init(|| Runtime::new().expect("failed to start the tokio runtime"))
}
pub enum Event {
Update(SessionUpdate),
Permission(RequestPermissionRequest, Reply<RequestPermissionResponse>),
TurnDone(Result<StopReason, Error>),
Closed,
}
pub type Events = mpsc::UnboundedReceiver<Event>;
pub struct Reply<T>(oneshot::Sender<Result<T, Error>>);
impl<T> Reply<T> {
pub fn send(self, value: T) {
let _ = self.0.send(Ok(value));
}
}
pub struct Session {
conn: Option<AgentConn>,
tx: mpsc::UnboundedSender<Event>,
child: Option<Child>,
pub session_id: SessionId,
pub init: InitializeResponse,
pub response: NewSessionResponse,
pub cwd: PathBuf,
pub loaded: bool,
}
pub type StatusFn = Box<dyn Fn(&str) + Send + Sync>;
#[derive(Default)]
pub struct Launch {
pub cwd: PathBuf,
pub previous: Option<String>,
pub status: Option<StatusFn>,
}
impl Launch {
pub fn new(cwd: PathBuf) -> Self {
Self {
cwd,
..Default::default()
}
}
fn say(&self, message: &str) {
if let Some(status) = &self.status {
status(message);
}
}
}
impl Session {
pub async fn spawn(entry: &settings::Agent, launch: Launch) -> Result<(Self, Events)> {
let mut command = Command::new(&entry.command);
command.args(&entry.args).envs(&entry.env);
if std::env::var_os(DEBUG).is_none() {
command.stderr(Stdio::null());
}
let configured = mcp::servers();
let (tx, mut events) = mpsc::unbounded_channel();
let (conn, child) = cacp::spawn(&mut command, Arc::new(Frontend(tx.clone())), debug_tap())
.map_err(|e| anyhow!("failed to start {}: {}", entry.command, error_text(&e)))?;
let echo = tx.clone();
let session = Self::open(conn, child, tx, launch, configured).await?;
if session.loaded {
let mut usage = None;
while let Ok(event) = events.try_recv() {
if let Event::Update(SessionUpdate::UsageUpdate(update)) = event {
usage = Some(update);
}
}
if let Some(update) = usage {
let _ = echo.send(Event::Update(SessionUpdate::UsageUpdate(update)));
}
}
Ok((session, events))
}
async fn open(
conn: AgentConn,
child: Child,
tx: mpsc::UnboundedSender<Event>,
launch: Launch,
configured: Vec<mcp::McpServer>,
) -> Result<Self> {
let cwd = launch.cwd.clone();
let init = conn
.initialize(InitializeRequest::new(ClientCapabilities {
fs: FileSystemCapabilities {
read_text_file: true,
write_text_file: true,
meta: None,
},
..Default::default()
}))
.await
.map_err(|e| anyhow!("initialize failed: {}", error_text(&e)))?;
let mcp_servers = acp_mcp_servers(&configured, &init);
let mut loaded = false;
let mut response = None;
if let Some(id) = launch
.previous
.clone()
.filter(|_| init.agent_capabilities.load_session)
{
let load = || LoadSessionRequest {
mcp_servers: mcp_servers.clone(),
..LoadSessionRequest::new(id.clone(), cwd.clone())
};
let result = match conn.load_session(load()).await {
Err(e) if e.is_auth_required() => {
authenticate(&conn, &init, &launch).await?;
conn.load_session(load()).await
}
other => other,
};
if let Ok(load_response) = result {
response = Some(NewSessionResponse {
session_id: id.clone().into(),
modes: load_response.modes,
config_options: load_response.config_options,
meta: None,
});
loaded = true;
}
}
let response = match response {
Some(response) => response,
None => {
let new_session = || NewSessionRequest {
mcp_servers: mcp_servers.clone(),
..NewSessionRequest::new(cwd.clone())
};
match conn.new_session(new_session()).await {
Ok(response) => response,
Err(e) if e.is_auth_required() => {
authenticate(&conn, &init, &launch).await?;
conn.new_session(new_session()).await.map_err(|e| {
anyhow!(
"session/new failed after authentication: {}",
error_text(&e)
)
})?
}
Err(e) => return Err(anyhow!("session/new failed: {}", error_text(&e))),
}
}
};
Ok(Self {
conn: Some(conn),
tx,
child: Some(child),
session_id: response.session_id.clone(),
init,
response,
cwd,
loaded,
})
}
fn conn(&self) -> AgentConn {
self.conn.clone().expect("the session is being dropped")
}
pub fn prompt(&self, content: &str) {
self.prompt_blocks(vec![content.to_owned().into()]);
}
pub fn prompt_blocks(&self, blocks: Vec<ContentBlock>) {
let request = PromptRequest::new(self.session_id.clone(), blocks);
let conn = self.conn();
let tx = self.tx.clone();
runtime().spawn(async move {
let done = conn
.prompt(request)
.await
.map(|response| response.stop_reason);
let _ = tx.send(Event::TurnDone(done));
});
}
pub fn cancel(&self) -> Result<(), Error> {
self.conn().cancel(CancelNotification {
session_id: self.session_id.clone(),
meta: None,
})
}
pub fn set_mode(&self, mode_id: &str) {
let request = SetSessionModeRequest {
session_id: self.session_id.clone(),
mode_id: mode_id.into(),
meta: None,
};
let conn = self.conn();
runtime().spawn(async move { conn.set_session_mode(request).await });
}
pub fn set_config_option(&self, config_id: &str, value: SessionConfigOptionValue) {
let request = SetSessionConfigOptionRequest {
session_id: self.session_id.clone(),
config_id: config_id.into(),
value,
meta: None,
};
let conn = self.conn();
runtime().spawn(async move { conn.set_session_config_option(request).await });
}
}
struct Frontend(mpsc::UnboundedSender<Event>);
impl Client for Frontend {
async fn session_update(&self, notification: SessionNotification) {
let _ = self.0.send(Event::Update(notification.update));
}
async fn request_permission(
&self,
request: RequestPermissionRequest,
) -> Result<RequestPermissionResponse, Error> {
let (tx, rx) = oneshot::channel();
self.0
.send(Event::Permission(request, Reply(tx)))
.map_err(|_| Error::internal_error().data("the frontend is gone"))?;
rx.await.unwrap_or_else(|_| Err(Error::method_not_found()))
}
async fn read_text_file(
&self,
request: ReadTextFileRequest,
) -> Result<ReadTextFileResponse, Error> {
read_text_file(&request)
}
async fn write_text_file(
&self,
request: WriteTextFileRequest,
) -> Result<WriteTextFileResponse, Error> {
std::fs::write(&request.path, &request.content)
.map(|()| WriteTextFileResponse::default())
.map_err(|e| io_error(&request.path, &e))
}
}
impl Drop for Session {
fn drop(&mut self) {
let (Some(conn), Some(mut child)) = (self.conn.take(), self.child.take()) else {
return;
};
drop(conn);
runtime().spawn(async move {
let _ = tokio::time::timeout(SHUTDOWN_GRACE, child.wait()).await;
});
}
}
impl Drop for Frontend {
fn drop(&mut self) {
let _ = self.0.send(Event::Closed);
}
}
const _: () = {
const fn assert_send<T: Send>() {}
assert_send::<Session>();
assert_send::<Event>();
};
fn acp_mcp_servers(configured: &[mcp::McpServer], init: &InitializeResponse) -> Vec<McpServer> {
let http = init.agent_capabilities.mcp_capabilities.http;
configured
.iter()
.filter(|server| server.enabled)
.filter_map(|server| match (&server.command, &server.url) {
(Some(command), _) => Some(McpServer::Stdio(McpServerStdio {
name: server.name.clone(),
command: command.into(),
args: server.args.clone(),
env: server
.env
.iter()
.map(|(name, value)| EnvVariable {
name: name.clone(),
value: value.clone(),
meta: None,
})
.collect(),
meta: None,
})),
(None, Some(url)) if http => Some(McpServer::Http(McpServerHttp {
name: server.name.clone(),
url: url.clone(),
headers: Vec::new(),
meta: None,
})),
_ => None,
})
.collect()
}
async fn authenticate(conn: &AgentConn, init: &InitializeResponse, launch: &Launch) -> Result<()> {
if init.auth_methods.is_empty() {
return Err(anyhow!(
"authentication required, but the agent advertises no auth methods"
));
}
let mut failures = Vec::new();
for method in &init.auth_methods {
launch.say(&format!(
"authenticating — {} (finish any sign-in your browser opens)",
method.name()
));
let request = AuthenticateRequest {
method_id: method.id().clone(),
meta: None,
};
match conn.authenticate(request).await {
Ok(_) => return Ok(()),
Err(e) => failures.push(format!("{}: {}", method.name(), error_text(&e))),
}
}
Err(anyhow!("authentication failed — {}", failures.join("; ")))
}
pub fn error_text(e: &Error) -> String {
match &e.data {
Some(data) => {
let detail = data
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| data.to_string());
format!("{} — {detail}", e.message)
}
None => e.message.clone(),
}
}
fn read_text_file(request: &ReadTextFileRequest) -> Result<ReadTextFileResponse, Error> {
let content =
std::fs::read_to_string(&request.path).map_err(|e| io_error(&request.path, &e))?;
let content = match (request.line, request.limit) {
(None, None) => content,
(line, limit) => {
let skip = line.map(|l| l.saturating_sub(1) as usize).unwrap_or(0);
let take = limit.map(|l| l as usize).unwrap_or(usize::MAX);
content
.lines()
.skip(skip)
.take(take)
.collect::<Vec<_>>()
.join("\n")
}
};
Ok(ReadTextFileResponse {
content,
meta: None,
})
}
fn io_error(path: &std::path::Path, e: &std::io::Error) -> Error {
Error::internal_error().data(format!("{}: {e}", path.display()))
}
fn debug_tap() -> Option<Tap> {
let path = std::env::var(DEBUG).ok()?;
Some(Arc::new(move |direction: Direction, line: &str| {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let _ = writeln!(f, "{direction:?}: {line}");
}
}))
}