use crate::{
agent::{mcp, serve},
model::settings,
};
use anyhow::{Result, anyhow};
use cacp::{
AgentConn, Client, Direction, Error, Tap,
schema::{
AuthenticateRequest, CancelNotification, ClientCapabilities, ContentBlock, EnvVariable,
FileSystemCapabilities, HttpHeader, 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::{
io::{AsyncBufReadExt, BufReader},
process::{Child, ChildStderr, Command},
runtime::Runtime,
sync::{mpsc, oneshot},
};
const DEBUG: &str = "CYDONIA_DEBUG";
const SERVER: &str = "cydonia";
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>),
Stderr(String),
TurnDone(Result<StopReason, Error>),
Closed,
}
pub type Events = mpsc::UnboundedReceiver<Event>;
pub type Sender = mpsc::UnboundedSender<Event>;
pub fn channel() -> (Sender, Events) {
mpsc::unbounded_channel()
}
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,
}
#[derive(Default)]
pub struct Launch {
pub cwd: PathBuf,
pub previous: Option<String>,
}
impl Launch {
pub fn new(cwd: PathBuf) -> Self {
Self {
cwd,
..Default::default()
}
}
}
impl Session {
pub async fn spawn(entry: &settings::Agent, launch: Launch, tx: Sender) -> Result<Self> {
let mut command = Command::new(&entry.command);
command.args(&entry.args).envs(&entry.env);
command.stderr(Stdio::piped());
let configured = mcp::servers();
let (conn, mut child) =
cacp::spawn(&mut command, Arc::new(Frontend(tx.clone())), debug_tap())
.map_err(|e| anyhow!("failed to start {}: {}", entry.command, error_text(&e)))?;
if let Some(stderr) = child.stderr.take() {
runtime().spawn(drain(stderr, tx.clone()));
}
Self::open(conn, child, tx, launch, configured).await
}
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, &cwd);
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).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).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,
cwd: &std::path::Path,
) -> Vec<McpServer> {
let http = init.agent_capabilities.mcp_capabilities.http;
let ours = http.then(serve::url).flatten().map(|url| {
McpServer::Http(McpServerHttp {
name: SERVER.to_owned(),
url,
headers: vec![{
let (name, value) = serve::project(cwd);
HttpHeader {
name: name.to_owned(),
value,
meta: None,
}
}],
meta: None,
})
});
ours.into_iter()
.chain(
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) -> 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 {
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("; ")))
}
async fn drain(stderr: ChildStderr, tx: Sender) {
let echo = std::env::var_os(DEBUG).is_some();
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
if echo {
eprintln!("{line}");
}
if tx.send(Event::Stderr(line)).is_err() {
return;
}
}
}
pub fn spend_replay(events: &mut Events, tx: &Sender) {
let mut usage = None;
let mut kept = Vec::new();
while let Ok(event) = events.try_recv() {
match event {
Event::Update(SessionUpdate::UsageUpdate(update)) => usage = Some(update),
Event::Update(_) => {}
other => kept.push(other),
}
}
if let Some(update) = usage {
let _ = tx.send(Event::Update(SessionUpdate::UsageUpdate(update)));
}
for event in kept {
let _ = tx.send(event);
}
}
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}");
}
}))
}