pub(crate) mod config_setting;
pub(crate) mod handlers;
pub(crate) mod mappers;
pub(crate) mod model_config;
pub(crate) mod relay;
pub(crate) mod session;
pub(crate) mod session_manager;
pub(crate) mod session_registry;
pub(crate) mod session_store;
pub mod testing;
pub use mappers::map_mcp_prompt_to_available_command;
pub use session_manager::SessionManager;
use agent_client_protocol::{self as acp, ByteStreams};
use llm::ReasoningEffort;
use std::sync::Arc;
use std::{fs::create_dir_all, path::PathBuf};
use tokio::io::{stdin, stdout};
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use tracing::info;
use tracing_appender::rolling::daily;
use tracing_subscriber::EnvFilter;
use llm::oauth::OAuthCredentialStore;
use session_manager::{InitialSessionSelection, SessionManagerConfig};
use session_registry::SessionRegistry;
use session_store::SessionStore;
#[derive(clap::Args, Debug)]
pub struct AcpArgs {
#[clap(long, default_value = "/tmp/aether-acp-logs")]
pub log_dir: PathBuf,
#[clap(long, conflicts_with_all = ["model", "reasoning_effort"])]
pub agent: Option<String>,
#[clap(long, conflicts_with = "agent")]
pub model: Option<String>,
#[clap(long, value_name = "low|medium|high|xhigh", requires = "model", conflicts_with = "agent")]
pub reasoning_effort: Option<ReasoningEffort>,
}
#[derive(Debug)]
pub enum AcpRunOutcome {
CleanDisconnect,
}
#[derive(Debug)]
pub enum AcpRunError {
Protocol(acp::Error),
}
impl std::fmt::Display for AcpRunError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AcpRunError::Protocol(e) => write!(f, "ACP protocol error: {e}"),
}
}
}
impl std::error::Error for AcpRunError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AcpRunError::Protocol(e) => Some(e),
}
}
}
pub async fn run_acp(args: AcpArgs) -> Result<AcpRunOutcome, AcpRunError> {
info!("Starting Aether ACP server");
setup_logging(&args);
let initial_selection = if let Some(agent) = args.agent.clone() {
InitialSessionSelection::agent(agent)
} else if let Some(model) = args.model.clone() {
InitialSessionSelection::model(model, args.reasoning_effort)
} else {
InitialSessionSelection::default()
};
let session_store =
SessionStore::new().map_or_else(|e| panic!("Failed to initialize session store: {e}"), Arc::new);
let manager = Arc::new(SessionManager::new(SessionManagerConfig {
registry: Arc::new(SessionRegistry::new()),
session_store,
has_oauth_credential: OAuthCredentialStore::has_credential,
initial_selection,
}));
let transport = ByteStreams::new(stdout().compat_write(), stdin().compat());
let connect_result = handlers::acp_agent_builder(manager.clone()).connect_to(transport).await;
manager.shutdown_all_sessions().await;
match connect_result {
Ok(()) => Ok(AcpRunOutcome::CleanDisconnect),
Err(err) => Err(AcpRunError::Protocol(err)),
}
}
fn setup_logging(args: &AcpArgs) {
create_dir_all(&args.log_dir).ok();
tracing_subscriber::fmt()
.with_writer(daily(&args.log_dir, "aether-acp.log"))
.with_ansi(false) .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.pretty()
.init();
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[derive(Debug, Parser)]
struct TestCli {
#[command(flatten)]
args: AcpArgs,
}
#[test]
fn agent_conflicts_with_model() {
let err = TestCli::try_parse_from(["test", "--agent", "planner", "--model", "anthropic:claude-sonnet-4-5"])
.expect_err("agent and model should conflict");
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn agent_conflicts_with_reasoning_effort() {
let err = TestCli::try_parse_from(["test", "--agent", "planner", "--reasoning-effort", "high"])
.expect_err("agent and reasoning effort should conflict");
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn reasoning_effort_requires_model() {
let err = TestCli::try_parse_from(["test", "--reasoning-effort", "high"])
.expect_err("reasoning effort should require model");
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn reasoning_effort_with_model_is_allowed() {
let cli =
TestCli::try_parse_from(["test", "--model", "anthropic:claude-sonnet-4-5", "--reasoning-effort", "high"])
.expect("reasoning effort can configure an explicit model session");
assert_eq!(cli.args.reasoning_effort, Some(ReasoningEffort::High));
}
}