use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use crate::adapter::{Adapter, ConnectRequest};
use crate::agent::{
AgentDetails, AgentId, AgentInstallation, AuthStatus, Capabilities, SessionOptions,
};
use crate::error::AgentError;
use crate::event::{Diagnostic, PlanUsage};
use crate::session::{self, Events, Session};
const USAGE_CACHE_TTL: Duration = Duration::from_secs(60);
const PROBE_COMMANDS_WAIT: Duration = Duration::from_secs(2);
pub struct Runtime {
adapters: HashMap<AgentId, Arc<dyn Adapter>>,
pinned: Vec<AgentInstallation>,
profiles: &'static [crate::catalog::AgentProfile],
usage_cache: Mutex<HashMap<(AgentId, PathBuf), (Instant, PlanUsage)>>,
}
impl Default for Runtime {
fn default() -> Self {
Self::new()
}
}
impl Runtime {
pub fn new() -> Self {
use crate::catalog::{Connection, NativeKind};
let mut adapters: HashMap<AgentId, Arc<dyn Adapter>> = HashMap::new();
for profile in crate::catalog::PROFILES {
let adapter: Arc<dyn Adapter> = match &profile.connection {
Connection::Acp { .. } => {
Arc::new(crate::adapter::acp::AcpAdapter::for_profile(profile))
}
Connection::Native(NativeKind::Claude) => {
Arc::new(crate::adapter::claude::ClaudeAdapter::new())
}
Connection::Native(NativeKind::Codex) => {
Arc::new(crate::adapter::codex::CodexAdapter::new())
}
Connection::Native(NativeKind::Antigravity) => continue,
};
adapters.insert(AgentId::new(profile.id), adapter);
}
Self {
adapters,
pinned: Vec::new(),
profiles: crate::catalog::PROFILES,
usage_cache: Mutex::new(HashMap::new()),
}
}
#[cfg(test)]
pub(crate) fn with_test_adapter(adapter: impl Adapter + 'static) -> Self {
use crate::agent::InstallationSource;
let id = AgentId::new("mock");
let mut runtime = Self::new();
runtime.profiles = &[];
runtime.adapters.insert(id.clone(), Arc::new(adapter));
runtime.pinned.push(AgentInstallation {
name: "Mock".into(),
id,
executable_path: PathBuf::from("mock"),
source: InstallationSource::Pinned,
auth: None,
acp_args: None,
});
runtime
}
pub async fn discover(&self) -> DiscoveryReport {
let mut report = crate::discovery::discover(self.profiles).await;
report.agents.retain(|a| self.adapters.contains_key(&a.id));
report.missing.retain(|m| self.adapters.contains_key(&m.id));
report.agents.splice(0..0, self.pinned.iter().cloned());
report
}
pub fn prewarm(&self) {
tokio::spawn(crate::process::login_shell_path());
}
pub async fn open(
&self,
agent: &AgentInstallation,
options: SessionOptions,
) -> Result<(Session, Events), AgentError> {
let adapter: Arc<dyn Adapter> = match (self.adapters.get(&agent.id), &agent.acp_args) {
(Some(adapter), _) => Arc::clone(adapter),
(None, Some(args)) => Arc::new(crate::adapter::acp::AcpAdapter::new(args.clone())),
(None, None) => {
return Err(AgentError::ProtocolFailed(format!(
"no adapter implemented for {} yet",
agent.id
)));
}
};
let connection = adapter
.connect(ConnectRequest {
installation: agent.clone(),
options: options.clone(),
})
.await?;
Ok(session::start(agent.clone(), connection, &options))
}
pub async fn probe(&self, agent: &AgentInstallation) -> Result<AgentDetails, AgentError> {
let opened = self
.open(agent, SessionOptions::in_dir(std::env::temp_dir()))
.await;
let (session, mut events) = match opened {
Err(AgentError::AuthRequired { login }) => {
return Ok(AgentDetails {
version: None,
auth: AuthStatus::Unauthenticated { login },
capabilities: Capabilities::default(),
config_options: Vec::new(),
commands: Vec::new(),
});
}
other => other?,
};
let deadline = tokio::time::Instant::now() + PROBE_COMMANDS_WAIT;
while session.info().details.commands.is_empty() {
let Ok(Some(Ok(_))) = tokio::time::timeout_at(deadline, events.next()).await else {
break;
};
}
let details = session.info().details;
session.close().await.ok();
Ok(details)
}
pub async fn probe_auth(&self, agent: &AgentInstallation) -> Result<AuthStatus, AgentError> {
let opened = self
.open(agent, SessionOptions::in_dir(std::env::temp_dir()))
.await;
match opened {
Err(AgentError::AuthRequired { login }) => Ok(AuthStatus::Unauthenticated { login }),
Err(e) => Err(e),
Ok((session, _events)) => {
let auth = session.info().details.auth.clone();
session.close().await.ok();
Ok(auth)
}
}
}
pub async fn plan_usage(&self, agent: &AgentInstallation) -> Result<PlanUsage, AgentError> {
let key = (agent.id.clone(), agent.executable_path.clone());
if let Some((at, usage)) = self.usage_cache.lock().unwrap().get(&key)
&& at.elapsed() < USAGE_CACHE_TTL
{
return Ok(usage.clone());
}
let adapter = self
.adapters
.get(&agent.id)
.ok_or_else(|| AgentError::UnsupportedFeature("plan usage".into()))?;
let usage = adapter.plan_usage(agent).await?;
self.usage_cache
.lock()
.unwrap()
.insert(key, (Instant::now(), usage.clone()));
Ok(usage)
}
pub async fn plan_usage_all(&self) -> Vec<AgentPlanUsage> {
let report = self.discover().await;
let probes = report.agents.into_iter().map(|agent| async move {
let usage = self.plan_usage(&agent).await;
AgentPlanUsage { agent, usage }
});
futures::future::join_all(probes).await
}
}
#[derive(Debug)]
pub struct AgentPlanUsage {
pub agent: AgentInstallation,
pub usage: Result<PlanUsage, AgentError>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DiscoveryReport {
pub agents: Vec<AgentInstallation>,
pub missing: Vec<MissingAgent>,
pub diagnostics: Vec<Diagnostic>,
}
impl DiscoveryReport {
pub fn require(&self, id: impl AsRef<str>) -> Result<&AgentInstallation, AgentError> {
let id = id.as_ref();
self.agents
.iter()
.find(|a| a.id.as_str() == id)
.ok_or_else(|| AgentError::NotInstalled(AgentId::new(id)))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MissingAgent {
pub id: AgentId,
pub name: String,
pub searched: Vec<PathBuf>,
pub install_hint: String,
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn make_exe(dir: &std::path::Path, name: &str) -> std::path::PathBuf {
std::fs::create_dir_all(dir).unwrap();
let exe = dir.join(name);
std::fs::write(&exe, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
exe
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn discover_hides_agents_without_adapter() {
let _guard = env_lock().lock().unwrap();
let home = tempfile::tempdir().unwrap();
let bin = home.path().join(".local/bin");
make_exe(&bin, "agy");
make_exe(&bin, "claude");
let orig_home = std::env::var_os("HOME");
let orig_path = std::env::var_os("PATH");
unsafe {
std::env::set_var("HOME", home.path());
std::env::set_var("PATH", bin.to_string_lossy().to_string());
}
let raw = crate::discovery::discover(crate::catalog::PROFILES).await;
assert!(
raw.agents.iter().any(|a| a.id.as_str() == "antigravity"),
"raw discover should find agy when present"
);
let runtime = Runtime::new();
let report = runtime.discover().await;
assert!(
!report.agents.iter().any(|a| a.id.as_str() == "antigravity"),
"antigravity must not appear as usable"
);
assert!(
!report
.missing
.iter()
.any(|m| m.id.as_str() == "antigravity"),
"antigravity must not appear as missing either"
);
assert!(
report.agents.iter().any(|a| a.id.as_str() == "claude"),
"supported agents still appear"
);
unsafe {
if let Some(v) = orig_home {
std::env::set_var("HOME", v);
} else {
std::env::remove_var("HOME");
}
if let Some(v) = orig_path {
std::env::set_var("PATH", v);
} else {
std::env::remove_var("PATH");
}
}
}
}