use serde::{Deserialize, Serialize}; use std::time::Duration; use tokio::task::JoinHandle; const AI_AGENT_STATUS_PROBE_TIMEOUT: Duration = Duration::from_secs(5); #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AiAgentId { ClaudeCode, Codex, Copilot, Opencode, Pi, #[serde(alias = "gemini")] Antigravity, Kiro, Hermes, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum AiAgentPermissionMode { #[default] Safe, PowerUser, } #[derive(Debug, Clone, Serialize)] pub struct AiAgentAvailability { pub installed: bool, pub version: Option, } #[derive(Debug, Clone, Serialize)] pub struct AiAgentsStatus { pub claude_code: AiAgentAvailability, pub codex: AiAgentAvailability, pub copilot: AiAgentAvailability, pub opencode: AiAgentAvailability, pub pi: AiAgentAvailability, pub antigravity: AiAgentAvailability, pub kiro: AiAgentAvailability, pub hermes: AiAgentAvailability, } #[derive(Debug, Clone, Serialize)] #[serde(tag = "kind")] pub enum AiAgentStreamEvent { Init { session_id: String, }, TextDelta { text: String, }, ThinkingDelta { text: String, }, ToolStart { tool_name: String, tool_id: String, #[serde(skip_serializing_if = "Option::is_none")] input: Option, }, ToolDone { tool_id: String, #[serde(skip_serializing_if = "Option::is_none")] output: Option, }, Error { message: String, }, Done, } #[derive(Debug, Clone, Deserialize)] pub struct AiAgentStreamRequest { pub agent: AiAgentId, pub message: String, pub system_prompt: Option, pub vault_path: String, #[serde(default)] pub vault_paths: Vec, pub permission_mode: Option, #[serde(default)] pub event_name: Option, } impl AiAgentStreamRequest { fn permission_mode(&self) -> AiAgentPermissionMode { self.permission_mode.unwrap_or_default() } } /// Probe every supported AI-agent CLI in parallel. /// /// Each per-agent `check_cli()` is synchronous and can block for up to ~1 s /// when the binary is missing and we fall through to the login-shell /// fallback (`/bin/zsh -lc 'command -v '` etc., evaluating the full /// shell startup). Running them sequentially used to add ~5 s to cold start /// when no agents are installed. Fan them out across Tokio's blocking pool /// so the user-perceived wall time is the slowest single probe rather than /// the sum of all supported probes. /// /// A panicking probe is mapped to `installed: false` so the IPC handler /// always returns a fully populated `AiAgentsStatus` and the frontend can /// keep rendering. pub async fn get_ai_agents_status() -> AiAgentsStatus { let claude = tokio::task::spawn_blocking(availability_from_claude); let codex = tokio::task::spawn_blocking(crate::codex_cli::check_cli); let copilot = tokio::task::spawn_blocking(crate::copilot_cli::check_cli); let opencode = tokio::task::spawn_blocking(crate::opencode_cli::check_cli); let pi = tokio::task::spawn_blocking(crate::pi_cli::check_cli); let antigravity = tokio::task::spawn_blocking(crate::antigravity_cli::check_cli); let kiro = tokio::task::spawn_blocking(crate::kiro_cli::check_cli); let hermes = tokio::task::spawn_blocking(crate::hermes_cli::check_cli); let (claude, codex, copilot, opencode, pi, antigravity, kiro, hermes) = tokio::join!( availability_or_missing(claude, AI_AGENT_STATUS_PROBE_TIMEOUT), availability_or_missing(codex, AI_AGENT_STATUS_PROBE_TIMEOUT), availability_or_missing(copilot, AI_AGENT_STATUS_PROBE_TIMEOUT), availability_or_missing(opencode, AI_AGENT_STATUS_PROBE_TIMEOUT), availability_or_missing(pi, AI_AGENT_STATUS_PROBE_TIMEOUT), availability_or_missing(antigravity, AI_AGENT_STATUS_PROBE_TIMEOUT), availability_or_missing(kiro, AI_AGENT_STATUS_PROBE_TIMEOUT), availability_or_missing(hermes, AI_AGENT_STATUS_PROBE_TIMEOUT) ); AiAgentsStatus { claude_code: claude, codex, copilot, opencode, pi, antigravity, kiro, hermes, } } async fn availability_or_missing( probe: JoinHandle, timeout: Duration, ) -> AiAgentAvailability { match tokio::time::timeout(timeout, probe).await { Ok(Ok(availability)) => availability, Ok(Err(_)) | Err(_) => missing_availability(), } } fn missing_availability() -> AiAgentAvailability { AiAgentAvailability { installed: false, version: None, } } pub fn run_ai_agent_stream(request: AiAgentStreamRequest, emit: F) -> Result where F: FnMut(AiAgentStreamEvent), { let permission_mode = request.permission_mode(); match request.agent { AiAgentId::ClaudeCode => run_claude_agent_stream(request, permission_mode, emit), AiAgentId::Codex => run_shared_agent_stream( request, permission_mode, crate::codex_cli::run_agent_stream, emit, ), AiAgentId::Copilot => run_shared_agent_stream( request, permission_mode, crate::copilot_cli::run_agent_stream, emit, ), AiAgentId::Opencode => run_shared_agent_stream( request, permission_mode, crate::opencode_cli::run_agent_stream, emit, ), AiAgentId::Pi => run_shared_agent_stream( request, permission_mode, crate::pi_cli::run_agent_stream, emit, ), AiAgentId::Antigravity => run_shared_agent_stream( request, permission_mode, crate::antigravity_cli::run_agent_stream, emit, ), AiAgentId::Kiro => run_shared_agent_stream( request, permission_mode, crate::kiro_cli::run_agent_stream, emit, ), AiAgentId::Hermes => run_shared_agent_stream( request, permission_mode, crate::hermes_cli::run_agent_stream, emit, ), } } fn run_claude_agent_stream( request: AiAgentStreamRequest, permission_mode: AiAgentPermissionMode, mut emit: F, ) -> Result where F: FnMut(AiAgentStreamEvent), { let mapped = crate::claude_cli::AgentStreamRequest { message: request.message, system_prompt: request.system_prompt, vault_path: request.vault_path, vault_paths: request.vault_paths, permission_mode, }; crate::claude_cli::run_agent_stream(mapped, |event| { if let Some(mapped_event) = map_claude_event(event) { emit(mapped_event); } }) } fn run_shared_agent_stream( request: AiAgentStreamRequest, permission_mode: AiAgentPermissionMode, runner: R, emit: F, ) -> Result where F: FnMut(AiAgentStreamEvent), R: FnOnce(crate::cli_agent_runtime::AgentStreamRequest, F) -> Result, { let mapped = crate::cli_agent_runtime::AgentStreamRequest { message: request.message, system_prompt: request.system_prompt, vault_path: request.vault_path, vault_paths: request.vault_paths, permission_mode, }; runner(mapped, emit) } fn availability_from_claude() -> AiAgentAvailability { let status = crate::claude_cli::check_cli(); AiAgentAvailability { installed: status.installed, version: status.version, } } fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option { match event { crate::claude_cli::ClaudeStreamEvent::Init { session_id } => { Some(AiAgentStreamEvent::Init { session_id }) } crate::claude_cli::ClaudeStreamEvent::TextDelta { text } => { Some(AiAgentStreamEvent::TextDelta { text }) } crate::claude_cli::ClaudeStreamEvent::ThinkingDelta { text } => { Some(AiAgentStreamEvent::ThinkingDelta { text }) } crate::claude_cli::ClaudeStreamEvent::ToolStart { tool_name, tool_id, input, } => Some(AiAgentStreamEvent::ToolStart { tool_name, tool_id, input, }), crate::claude_cli::ClaudeStreamEvent::ToolDone { tool_id, output } => { Some(AiAgentStreamEvent::ToolDone { tool_id, output }) } crate::claude_cli::ClaudeStreamEvent::Error { message } => { Some(AiAgentStreamEvent::Error { message }) } crate::claude_cli::ClaudeStreamEvent::Done => Some(AiAgentStreamEvent::Done), crate::claude_cli::ClaudeStreamEvent::Result { text, .. } if !text.is_empty() => { Some(AiAgentStreamEvent::TextDelta { text }) } crate::claude_cli::ClaudeStreamEvent::Result { .. } => None, } } #[cfg(test)] mod tests { use super::*; fn request_with_permission( permission_mode: Option, ) -> AiAgentStreamRequest { AiAgentStreamRequest { agent: AiAgentId::Codex, message: "Summarize this vault".into(), system_prompt: None, vault_path: "/tmp/vault".into(), vault_paths: Vec::new(), permission_mode, event_name: None, } } #[test] fn stream_request_uses_default_or_explicit_permission_mode() { assert_eq!( request_with_permission(None).permission_mode(), AiAgentPermissionMode::Safe ); assert_eq!( request_with_permission(Some(AiAgentPermissionMode::PowerUser)).permission_mode(), AiAgentPermissionMode::PowerUser ); } #[tokio::test] async fn normalize_status_contains_all_agents() { let status = get_ai_agents_status().await; let install_flags = [ status.claude_code.installed, status.codex.installed, status.copilot.installed, status.opencode.installed, status.pi.installed, status.antigravity.installed, status.kiro.installed, status.hermes.installed, ]; assert!(install_flags .iter() .all(|installed| matches!(installed, true | false))); } #[tokio::test] async fn availability_probe_timeout_returns_missing_status() { let handle = tokio::task::spawn_blocking(|| { std::thread::sleep(std::time::Duration::from_millis(50)); AiAgentAvailability { installed: true, version: Some("late".into()), } }); let status = availability_or_missing(handle, std::time::Duration::from_millis(1)).await; assert!(!status.installed); assert_eq!(status.version, None); } #[test] fn map_claude_done_event_preserves_completion_signal() { let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Done); assert!(matches!(mapped, Some(AiAgentStreamEvent::Done))); } #[test] fn map_claude_text_events_preserve_stream_data() { assert!(matches!( map_claude_event(crate::claude_cli::ClaudeStreamEvent::Init { session_id: "session-1".into(), }), Some(AiAgentStreamEvent::Init { session_id }) if session_id == "session-1" )); assert!(matches!( map_claude_event(crate::claude_cli::ClaudeStreamEvent::TextDelta { text: "visible output".into(), }), Some(AiAgentStreamEvent::TextDelta { text }) if text == "visible output" )); assert!(matches!( map_claude_event(crate::claude_cli::ClaudeStreamEvent::ThinkingDelta { text: "thinking".into(), }), Some(AiAgentStreamEvent::ThinkingDelta { text }) if text == "thinking" )); } #[test] fn map_claude_tool_events_preserve_stream_data() { let started = map_claude_event(crate::claude_cli::ClaudeStreamEvent::ToolStart { tool_name: "Read".into(), tool_id: "tool-1".into(), input: Some("{\"file\":\"note.md\"}".into()), }); let finished = map_claude_event(crate::claude_cli::ClaudeStreamEvent::ToolDone { tool_id: "tool-1".into(), output: Some("done".into()), }); assert!(matches!( started, Some(AiAgentStreamEvent::ToolStart { tool_name, tool_id, input }) if tool_name == "Read" && tool_id == "tool-1" && input.as_deref() == Some("{\"file\":\"note.md\"}") )); assert!(matches!( finished, Some(AiAgentStreamEvent::ToolDone { tool_id, output }) if tool_id == "tool-1" && output.as_deref() == Some("done") )); } #[test] fn map_claude_error_event_preserves_message() { let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Error { message: "missing auth".into(), }); assert!(matches!( mapped, Some(AiAgentStreamEvent::Error { message }) if message == "missing auth" )); } #[test] fn map_claude_result_event_preserves_final_text() { let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Result { text: "Final answer from Claude".into(), session_id: "session-1".into(), }); assert!(matches!( mapped, Some(AiAgentStreamEvent::TextDelta { text }) if text == "Final answer from Claude" )); } #[test] fn map_claude_empty_result_event_is_ignored() { let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Result { text: String::new(), session_id: "session-1".into(), }); assert!(mapped.is_none()); } }