1pub(crate) mod config_setting;
2pub(crate) mod handlers;
3pub(crate) mod mappers;
4pub(crate) mod model_config;
5pub(crate) mod relay;
6pub(crate) mod session;
7pub(crate) mod session_manager;
8pub(crate) mod session_registry;
9pub(crate) mod session_store;
10pub mod testing;
11
12pub use mappers::map_mcp_prompt_to_available_command;
13pub use session_manager::SessionManager;
14
15use agent_client_protocol::{self as acp, ByteStreams};
16use llm::ReasoningEffort;
17use std::sync::Arc;
18use std::{fs::create_dir_all, path::PathBuf};
19use tokio::io::{stdin, stdout};
20use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
21use tracing::info;
22use tracing_appender::rolling::daily;
23use tracing_subscriber::EnvFilter;
24
25use llm::oauth::OAuthCredentialStore;
26use session_manager::{InitialSessionSelection, SessionManagerConfig};
27use session_registry::SessionRegistry;
28use session_store::SessionStore;
29
30#[derive(clap::Args, Debug)]
31pub struct AcpArgs {
32 #[clap(long, default_value = "/tmp/aether-acp-logs")]
34 pub log_dir: PathBuf,
35
36 #[clap(long, conflicts_with_all = ["model", "reasoning_effort"])]
38 pub agent: Option<String>,
39
40 #[clap(long, conflicts_with = "agent")]
43 pub model: Option<String>,
44
45 #[clap(long, value_name = "low|medium|high|xhigh", requires = "model", conflicts_with = "agent")]
47 pub reasoning_effort: Option<ReasoningEffort>,
48}
49
50#[derive(Debug)]
52pub enum AcpRunOutcome {
53 CleanDisconnect,
55}
56
57#[derive(Debug)]
59pub enum AcpRunError {
60 Protocol(acp::Error),
61}
62
63impl std::fmt::Display for AcpRunError {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 match self {
66 AcpRunError::Protocol(e) => write!(f, "ACP protocol error: {e}"),
67 }
68 }
69}
70
71impl std::error::Error for AcpRunError {
72 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
73 match self {
74 AcpRunError::Protocol(e) => Some(e),
75 }
76 }
77}
78
79pub async fn run_acp(args: AcpArgs) -> Result<AcpRunOutcome, AcpRunError> {
80 info!("Starting Aether ACP server");
81
82 setup_logging(&args);
83
84 let initial_selection = if let Some(agent) = args.agent.clone() {
85 InitialSessionSelection::agent(agent)
86 } else if let Some(model) = args.model.clone() {
87 InitialSessionSelection::model(model, args.reasoning_effort)
88 } else {
89 InitialSessionSelection::default()
90 };
91 let session_store =
92 SessionStore::new().map_or_else(|e| panic!("Failed to initialize session store: {e}"), Arc::new);
93 let manager = Arc::new(SessionManager::new(SessionManagerConfig {
94 registry: Arc::new(SessionRegistry::new()),
95 session_store,
96 has_oauth_credential: OAuthCredentialStore::has_credential,
97 initial_selection,
98 }));
99
100 let transport = ByteStreams::new(stdout().compat_write(), stdin().compat());
101 let connect_result = handlers::acp_agent_builder(manager.clone()).connect_to(transport).await;
102
103 manager.shutdown_all_sessions().await;
104
105 match connect_result {
106 Ok(()) => Ok(AcpRunOutcome::CleanDisconnect),
107 Err(err) => Err(AcpRunError::Protocol(err)),
108 }
109}
110
111fn setup_logging(args: &AcpArgs) {
112 create_dir_all(&args.log_dir).ok();
113 tracing_subscriber::fmt()
114 .with_writer(daily(&args.log_dir, "aether-acp.log"))
115 .with_ansi(false) .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
117 .pretty()
118 .init();
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use clap::Parser;
125
126 #[derive(Debug, Parser)]
127 struct TestCli {
128 #[command(flatten)]
129 args: AcpArgs,
130 }
131
132 #[test]
133 fn agent_conflicts_with_model() {
134 let err = TestCli::try_parse_from(["test", "--agent", "planner", "--model", "anthropic:claude-sonnet-4-5"])
135 .expect_err("agent and model should conflict");
136 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
137 }
138
139 #[test]
140 fn agent_conflicts_with_reasoning_effort() {
141 let err = TestCli::try_parse_from(["test", "--agent", "planner", "--reasoning-effort", "high"])
142 .expect_err("agent and reasoning effort should conflict");
143 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
144 }
145
146 #[test]
147 fn reasoning_effort_requires_model() {
148 let err = TestCli::try_parse_from(["test", "--reasoning-effort", "high"])
149 .expect_err("reasoning effort should require model");
150 assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
151 }
152
153 #[test]
154 fn reasoning_effort_with_model_is_allowed() {
155 let cli =
156 TestCli::try_parse_from(["test", "--model", "anthropic:claude-sonnet-4-5", "--reasoning-effort", "high"])
157 .expect("reasoning effort can configure an explicit model session");
158 assert_eq!(cli.args.reasoning_effort, Some(ReasoningEffort::High));
159 }
160}