diff --git a/src-tauri/src/claude_binary.rs b/src-tauri/src/claude_binary.rs index 7dc8b98a..7cf18cf2 100644 --- a/src-tauri/src/claude_binary.rs +++ b/src-tauri/src/claude_binary.rs @@ -88,6 +88,29 @@ pub fn find_claude_binary(app_handle: &tauri::AppHandle) -> Result, // JSON string of hooks configuration + pub source: Option, // 'claudia', 'native', 'user', etc. pub created_at: String, pub updated_at: String, } @@ -238,6 +239,7 @@ pub fn init_database(app: &AppHandle) -> SqliteResult { enable_file_write BOOLEAN NOT NULL DEFAULT 1, enable_network BOOLEAN NOT NULL DEFAULT 0, hooks TEXT, + source TEXT DEFAULT 'claudia', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP )", @@ -263,6 +265,10 @@ pub fn init_database(app: &AppHandle) -> SqliteResult { "ALTER TABLE agents ADD COLUMN enable_network BOOLEAN DEFAULT 0", [], ); + let _ = conn.execute( + "ALTER TABLE agents ADD COLUMN source TEXT DEFAULT 'claudia'", + [], + ); // Create agent_runs table conn.execute( @@ -353,7 +359,7 @@ pub async fn list_agents(db: State<'_, AgentDb>) -> Result, String> { let conn = db.0.lock().map_err(|e| e.to_string())?; let mut stmt = conn - .prepare("SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks, created_at, updated_at FROM agents ORDER BY created_at DESC") + .prepare("SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks, source, created_at, updated_at FROM agents ORDER BY created_at DESC") .map_err(|e| e.to_string())?; let agents = stmt @@ -371,8 +377,9 @@ pub async fn list_agents(db: State<'_, AgentDb>) -> Result, String> { enable_file_write: row.get::<_, bool>(7).unwrap_or(true), enable_network: row.get::<_, bool>(8).unwrap_or(false), hooks: row.get(9)?, - created_at: row.get(10)?, - updated_at: row.get(11)?, + source: row.get(10).ok(), + created_at: row.get(11)?, + updated_at: row.get(12)?, }) }) .map_err(|e| e.to_string())? @@ -395,6 +402,7 @@ pub async fn create_agent( enable_file_write: Option, enable_network: Option, hooks: Option, + source: Option, ) -> Result { let conn = db.0.lock().map_err(|e| e.to_string())?; let model = model.unwrap_or_else(|| "sonnet".to_string()); @@ -402,9 +410,11 @@ pub async fn create_agent( let enable_file_write = enable_file_write.unwrap_or(true); let enable_network = enable_network.unwrap_or(false); + let source = source.unwrap_or_else(|| "claudia".to_string()); + conn.execute( - "INSERT INTO agents (name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - params![name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks], + "INSERT INTO agents (name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks, source) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks, source], ) .map_err(|e| e.to_string())?; @@ -413,7 +423,7 @@ pub async fn create_agent( // Fetch the created agent let agent = conn .query_row( - "SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks, created_at, updated_at FROM agents WHERE id = ?1", + "SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks, source, created_at, updated_at FROM agents WHERE id = ?1", params![id], |row| { Ok(Agent { @@ -427,8 +437,9 @@ pub async fn create_agent( enable_file_write: row.get(7)?, enable_network: row.get(8)?, hooks: row.get(9)?, - created_at: row.get(10)?, - updated_at: row.get(11)?, + source: row.get(10)?, + created_at: row.get(11)?, + updated_at: row.get(12)?, }) }, ) @@ -498,7 +509,7 @@ pub async fn update_agent( // Fetch the updated agent let agent = conn .query_row( - "SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks, created_at, updated_at FROM agents WHERE id = ?1", + "SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks, source, created_at, updated_at FROM agents WHERE id = ?1", params![id], |row| { Ok(Agent { @@ -512,8 +523,9 @@ pub async fn update_agent( enable_file_write: row.get(7)?, enable_network: row.get(8)?, hooks: row.get(9)?, - created_at: row.get(10)?, - updated_at: row.get(11)?, + source: row.get(10).ok(), + created_at: row.get(11)?, + updated_at: row.get(12)?, }) }, ) @@ -533,6 +545,106 @@ pub async fn delete_agent(db: State<'_, AgentDb>, id: i64) -> Result<(), String> Ok(()) } +/// Delete all native agents from database (keeping .claude/agents files intact) +#[tauri::command] +pub async fn delete_native_agents(db: State<'_, AgentDb>) -> Result { + info!("Deleting native agents from database"); + let conn = db.0.lock().map_err(|e| e.to_string())?; + + // First, let's debug what's actually in the database + let mut stmt = conn + .prepare("SELECT id, name, source FROM agents") + .map_err(|e| e.to_string())?; + + let agent_rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + }) + .map_err(|e| e.to_string())?; + + let mut all_agents = Vec::new(); + for agent in agent_rows { + all_agents.push(agent.map_err(|e| e.to_string())?); + } + + info!("Total agents in database: {}", all_agents.len()); + for (id, name, source) in &all_agents { + info!("Agent ID: {}, Name: '{}', Source: {:?}", id, name, source); + } + + // Count agents that match our criteria (be more flexible) + let count: u32 = conn + .query_row( + "SELECT COUNT(*) FROM agents WHERE source = 'native' OR source = 'claude_native' OR source IS NULL", + [], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + + info!("Found {} agents matching native criteria to delete", count); + + // Delete all native agents (including those with NULL source if they look like native agents) + let rows_affected = conn + .execute( + "DELETE FROM agents WHERE source = 'native' OR source = 'claude_native'", + [], + ) + .map_err(|e| e.to_string())?; + + info!("Deleted {} native agents from database", rows_affected); + + // If no agents were deleted but we expected some, try a broader delete + if rows_affected == 0 && count > 0 { + warn!("No agents deleted with strict criteria, checking for broader match"); + + // Get list of known native agent names from filesystem + let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let agents_dir = home_dir.join(".claude").join("agents"); + + if agents_dir.exists() { + let mut native_names = Vec::new(); + let entries = std::fs::read_dir(&agents_dir) + .map_err(|e| format!("Failed to read agents directory: {}", e))?; + + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; + let path = entry.path(); + + if let Some(extension) = path.extension() { + if extension == "md" { + if let Some(file_name) = path.file_stem().and_then(|n| n.to_str()) { + native_names.push(file_name.to_string()); + } + } + } + } + + // Delete agents that match native agent names (regardless of source) + let mut total_deleted = 0; + for name in native_names { + let deleted = conn + .execute( + "DELETE FROM agents WHERE name LIKE ? OR name = ?", + params![format!("{}%", name), name], + ) + .map_err(|e| e.to_string())?; + total_deleted += deleted; + if deleted > 0 { + info!("Deleted {} agents matching name pattern '{}'", deleted, name); + } + } + + return Ok(total_deleted as u32); + } + } + + Ok(rows_affected as u32) +} + /// Get a single agent by ID #[tauri::command] pub async fn get_agent(db: State<'_, AgentDb>, id: i64) -> Result { @@ -540,7 +652,7 @@ pub async fn get_agent(db: State<'_, AgentDb>, id: i64) -> Result let agent = conn .query_row( - "SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks, created_at, updated_at FROM agents WHERE id = ?1", + "SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks, source, created_at, updated_at FROM agents WHERE id = ?1", params![id], |row| { Ok(Agent { @@ -554,8 +666,9 @@ pub async fn get_agent(db: State<'_, AgentDb>, id: i64) -> Result enable_file_write: row.get::<_, bool>(7).unwrap_or(true), enable_network: row.get::<_, bool>(8).unwrap_or(false), hooks: row.get(9)?, - created_at: row.get(10)?, - updated_at: row.get(11)?, + source: row.get(10).ok(), + created_at: row.get(11)?, + updated_at: row.get(12)?, }) }, ) @@ -1600,9 +1713,9 @@ pub async fn set_claude_binary_path(db: State<'_, AgentDb>, path: String) -> Res /// List all available Claude installations on the system #[tauri::command] pub async fn list_claude_installations( - app: AppHandle, + _app: AppHandle, ) -> Result, String> { - let mut installations = crate::claude_binary::discover_claude_installations(); + let installations = crate::claude_binary::discover_claude_installations(); if installations.is_empty() { return Err("No Claude Code installations found on the system".to_string()); @@ -1672,6 +1785,10 @@ fn create_command_with_env(program: &str) -> Command { /// Import an agent from JSON data #[tauri::command] pub async fn import_agent(db: State<'_, AgentDb>, json_data: String) -> Result { + import_agent_with_source(db, json_data, "claudia".to_string()).await +} + +pub async fn import_agent_with_source(db: State<'_, AgentDb>, json_data: String, source: String) -> Result { // Parse the JSON data let export_data: AgentExport = serde_json::from_str(&json_data).map_err(|e| format!("Invalid JSON format: {}", e))?; @@ -1705,14 +1822,15 @@ pub async fn import_agent(db: State<'_, AgentDb>, json_data: String) -> Result, json_data: String) -> Result, json_data: String) -> Result, json_data: String) -> Result, file_path: String, + source: String, ) -> Result { // Read the file let json_data = std::fs::read_to_string(&file_path).map_err(|e| format!("Failed to read file: {}", e))?; - // Import the agent - import_agent(db, json_data).await + // Import the agent with specified source + import_agent_with_source(db, json_data, source).await } // GitHub Agent Import functionality @@ -1954,3 +2074,214 @@ pub async fn load_agent_session_history( Err(format!("Session file not found: {}", session_id)) } } + +/// List native agents directly from .claude/agents directory (without importing to DB) +#[tauri::command] +pub async fn list_native_agents() -> Result, String> { + info!("Listing native agents from .claude/agents"); + + let home_dir = dirs::home_dir() + .ok_or("Could not find home directory")?; + let agents_dir = home_dir.join(".claude").join("agents"); + + if !agents_dir.exists() { + info!("No .claude/agents directory found"); + return Ok(vec![]); + } + + let mut agents = Vec::new(); + let mut agent_id = 1000; // Use high IDs to avoid conflict with DB agents + + // Read all .md files in the agents directory + let entries = std::fs::read_dir(&agents_dir) + .map_err(|e| format!("Failed to read agents directory: {}", e))?; + + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; + let path = entry.path(); + + if let Some(extension) = path.extension() { + if extension == "md" { + let file_name = path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("unknown"); + + info!("Processing native agent file: {}", file_name); + + // Read the file content + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read {}: {}", file_name, e))?; + + // Parse the frontmatter and content + match parse_agent_markdown(&content, file_name) { + Ok((name, description, system_prompt, icon, _color)) => { + agents.push(Agent { + id: Some(agent_id), + name, + icon, + system_prompt, + default_task: Some(description), + model: "claude-3-5-sonnet-20241022".to_string(), + enable_file_read: true, + enable_file_write: true, + enable_network: true, + hooks: None, + source: Some("native".to_string()), + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + }); + agent_id += 1; + } + Err(e) => { + warn!("Failed to parse agent file {}: {}", file_name, e); + } + } + } + } + } + + info!("Found {} native agents", agents.len()); + Ok(agents) +} + +/// Import native agents from .claude/agents directory +#[tauri::command] +pub async fn import_native_agents(app: AppHandle) -> Result { + info!("Importing native agents from .claude/agents"); + + let home_dir = dirs::home_dir() + .ok_or("Could not find home directory")?; + let agents_dir = home_dir.join(".claude").join("agents"); + + if !agents_dir.exists() { + info!("No .claude/agents directory found"); + return Ok(0); + } + + // Get database connection + let db = app.state::(); + let conn = db.0.lock().map_err(|e| e.to_string())?; + + let mut imported_count = 0; + let mut skipped_count = 0; + + // Read all .md files in the agents directory + if let Ok(entries) = std::fs::read_dir(&agents_dir) { + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + + // Only process .md files + if path.extension() == Some(std::ffi::OsStr::new("md")) { + let file_name = path.file_stem() + .and_then(|s| s.to_str()) + .ok_or("Invalid filename")?; + + info!("Processing agent file: {}", file_name); + + // Read the file content + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read {}: {}", file_name, e))?; + + // Parse the frontmatter and content + let (name, description, system_prompt, icon, _color) = parse_agent_markdown(&content, file_name)?; + + // Check if agent already exists + let existing_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM agents WHERE name = ?1", + params![&name], + |row| row.get(0), + ) + .unwrap_or(0); + + if existing_count > 0 { + info!("Agent '{}' already exists, skipping", name); + skipped_count += 1; + continue; + } + + // Insert the agent + conn.execute( + "INSERT INTO agents (name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks, source, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + params![ + &name, + &icon, + &system_prompt, + &description, // Use description as default_task + "claude-3-5-sonnet-20241022", // Default model + true, // enable_file_read + true, // enable_file_write + true, // enable_network + None::, // hooks + "native", // source + chrono::Utc::now().to_rfc3339(), + chrono::Utc::now().to_rfc3339() + ], + ).map_err(|e| format!("Failed to insert agent '{}': {}", name, e))?; + + imported_count += 1; + info!("Successfully imported agent: {}", name); + } + } + } + + info!("Import complete: {} imported, {} skipped", imported_count, skipped_count); + Ok(imported_count) +} + +/// Parse agent markdown file with frontmatter +fn parse_agent_markdown(content: &str, file_name: &str) -> Result<(String, String, String, String, String), String> { + let lines: Vec<&str> = content.lines().collect(); + + // Check if file has frontmatter + if lines.is_empty() || lines[0] != "---" { + // No frontmatter, use file name as agent name and entire content as prompt + return Ok(( + file_name.to_string(), + format!("Agent imported from {}.md", file_name), + content.to_string(), + "🤖".to_string(), // Default icon + "blue".to_string(), // Default color + )); + } + + // Parse frontmatter + let mut name = file_name.to_string(); + let mut description = String::new(); + let mut icon = "🤖".to_string(); + let mut color = "blue".to_string(); + let mut frontmatter_end = 0; + + for (i, line) in lines.iter().enumerate().skip(1) { + if *line == "---" { + frontmatter_end = i; + break; + } + + if let Some((key, value)) = line.split_once(':') { + let key = key.trim(); + let value = value.trim().trim_matches('"'); + + match key { + "name" => name = value.to_string(), + "description" => description = value.to_string(), + "icon" => icon = value.to_string(), + "color" => color = value.to_string(), + _ => {} + } + } + } + + // Get the system prompt (everything after frontmatter) + let system_prompt = if frontmatter_end > 0 && frontmatter_end + 1 < lines.len() { + lines[(frontmatter_end + 1)..] + .join("\n") + .trim() + .to_string() + } else { + content.to_string() + }; + + Ok((name, description, system_prompt, icon, color)) +} diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 8b964803..66add3f4 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -9,8 +9,6 @@ use std::time::SystemTime; use tauri::{AppHandle, Emitter, Manager}; use tokio::process::{Child, Command}; use tokio::sync::Mutex; -use tauri_plugin_shell::ShellExt; -use tauri_plugin_shell::process::CommandEvent; use regex; /// Global state to track current Claude process @@ -554,7 +552,7 @@ pub async fn check_claude_version(app: AppHandle) -> Result, } -/// Executes a claude mcp command +/// Cached MCP data +#[derive(Debug, Clone, Serialize, Deserialize)] +struct MCPCache { + pub servers: Vec, + pub cached_at: u64, + pub config_hash: String, +} + +/// Cache management for MCP servers +static MCP_CACHE: std::sync::LazyLock>>> = std::sync::LazyLock::new(|| Arc::new(Mutex::new(None))); + +/// Cache expiry time in seconds (5 minutes) +const CACHE_EXPIRY_SECONDS: u64 = 300; + +/// Check if cache is valid +fn is_cache_valid(cache: &MCPCache, current_hash: &str) -> bool { + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + let cache_age = now - cache.cached_at; + + // Cache is valid if: + // 1. Config hash matches (no config changes) + // 2. Cache is not expired + cache.config_hash == current_hash && cache_age < CACHE_EXPIRY_SECONDS +} + +/// Generate a hash of MCP configuration files for cache invalidation +fn get_config_hash(app_handle: &AppHandle) -> String { + let mut hash_content = String::new(); + + // Include Claude MCP config in hash + if let Ok(home_dir) = dirs::home_dir().ok_or("Could not find home directory") { + let claude_dir = home_dir.join(".claude"); + let config_path = claude_dir.join("claude_desktop_config.json"); + + if let Ok(content) = fs::read_to_string(&config_path) { + hash_content.push_str(&content); + } + } + + // Include current working directory .mcp.json in hash + if let Ok(current_dir) = std::env::current_dir() { + let project_config = current_dir.join(".mcp.json"); + if let Ok(content) = fs::read_to_string(&project_config) { + hash_content.push_str(&content); + } + } + + // Simple hash using length and first/last chars for speed + format!("{}-{}", hash_content.len(), + hash_content.chars().take(10).chain(hash_content.chars().rev().take(10)).collect::() + ) +} + +/// Manually invalidate the MCP cache (called when configuration changes) +fn invalidate_mcp_cache() { + let mut cache_guard = MCP_CACHE.lock().unwrap(); + *cache_guard = None; + info!("MCP cache invalidated due to configuration change"); +} + +/// Clear MCP cache command for manual use +#[tauri::command] +pub async fn mcp_clear_cache() -> Result { + invalidate_mcp_cache(); + Ok("MCP cache cleared successfully".to_string()) +} + +/// Executes a claude mcp command with retry for binary discovery fn execute_claude_mcp_command(app_handle: &AppHandle, args: Vec<&str>) -> Result { info!("Executing claude mcp command with args: {:?}", args); - let claude_path = find_claude_binary(app_handle)?; + // Try to find Claude binary with retries + let mut claude_path = None; + let max_retries = 5; + let retry_delay = std::time::Duration::from_millis(500); + + for attempt in 0..max_retries { + match find_claude_binary(app_handle) { + Ok(path) => { + claude_path = Some(path); + break; + } + Err(e) => { + if attempt < max_retries - 1 { + info!("Claude binary not found (attempt {}), retrying in {:?}...", attempt + 1, retry_delay); + std::thread::sleep(retry_delay); + } else { + return Err(anyhow::anyhow!("Failed to find Claude binary after {} attempts: {}", max_retries, e)); + } + } + } + } + + let claude_path = claude_path.unwrap(); let mut cmd = create_command_with_env(&claude_path); cmd.arg("mcp"); for arg in args { @@ -191,6 +282,10 @@ pub async fn mcp_add( match execute_claude_mcp_command(&app, cmd_args) { Ok(output) => { info!("Successfully added MCP server: {}", name); + + // Invalidate cache since configuration changed + invalidate_mcp_cache(); + Ok(AddServerResult { success: true, message: output.trim().to_string(), @@ -211,8 +306,26 @@ pub async fn mcp_add( /// Lists all configured MCP servers #[tauri::command] pub async fn mcp_list(app: AppHandle) -> Result, String> { - info!("Listing MCP servers"); + info!("Listing MCP servers (with caching)"); + + // Check cache first + let config_hash = get_config_hash(&app); + { + let cache_guard = MCP_CACHE.lock().unwrap(); + if let Some(ref cache) = *cache_guard { + if is_cache_valid(cache, &config_hash) { + info!("Returning cached MCP servers ({} servers)", cache.servers.len()); + return Ok(cache.servers.clone()); + } else { + info!("Cache invalid - config changed or expired"); + } + } else { + info!("No cache found - first load"); + } + } + // Cache miss or invalid - fetch fresh data + info!("Fetching fresh MCP server data"); match execute_claude_mcp_command(&app, vec!["list"]) { Ok(output) => { info!("Raw output from 'claude mcp list': {:?}", output); @@ -321,6 +434,21 @@ pub async fn mcp_list(app: AppHandle) -> Result, String> { idx, server.name, server.command ); } + + // Store in cache + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + let cache = MCPCache { + servers: servers.clone(), + cached_at: now, + config_hash, + }; + + { + let mut cache_guard = MCP_CACHE.lock().unwrap(); + *cache_guard = Some(cache); + } + info!("Cached {} MCP servers for future requests", servers.len()); + Ok(servers) } Err(e) => { @@ -440,6 +568,10 @@ pub async fn mcp_add_json( match execute_claude_mcp_command(&app, cmd_args) { Ok(output) => { info!("Successfully added MCP server from JSON: {}", name); + + // Invalidate cache since configuration changed + invalidate_mcp_cache(); + Ok(AddServerResult { success: true, message: output.trim().to_string(), @@ -614,6 +746,20 @@ pub async fn mcp_add_from_claude_desktop( #[tauri::command] pub async fn mcp_serve(app: AppHandle) -> Result { info!("Starting Claude Code as MCP server"); + + // Get process registry + let registry = app.state::(); + + // Check if an MCP server is already running + let running_sessions = registry.0.get_running_claude_sessions() + .map_err(|e| format!("Failed to get running sessions: {}", e))?; + + for session in &running_sessions { + if session.task.contains("mcp serve") { + info!("MCP server already running with PID {}", session.pid); + return Err("MCP server already running".to_string()); + } + } // Start the server in a separate process let claude_path = match find_claude_binary(&app) { @@ -628,9 +774,21 @@ pub async fn mcp_serve(app: AppHandle) -> Result { cmd.arg("mcp").arg("serve"); match cmd.spawn() { - Ok(_) => { - info!("Successfully started Claude Code MCP server"); - Ok("Claude Code MCP server started".to_string()) + Ok(child) => { + let pid = child.id(); + + // Register the process + let session_id = format!("mcp-server-{}", pid); + registry.0.register_claude_session( + session_id.clone(), + pid, + claude_path, + "mcp serve".to_string(), + "claude".to_string(), + ).map_err(|e| format!("Failed to register MCP server process: {}", e))?; + + info!("Successfully started and registered Claude Code MCP server with PID {}", pid); + Ok(format!("Claude Code MCP server started (PID: {})", pid)) } Err(e) => { error!("Failed to start MCP server: {}", e); @@ -724,3 +882,36 @@ pub async fn mcp_save_project_config( Ok("Project MCP configuration saved".to_string()) } + +/// Cleanup orphaned MCP processes on startup +pub fn cleanup_orphaned_mcp_processes() { + info!("Cleaning up orphaned MCP processes"); + + // Use platform-specific command to find and kill orphaned claude mcp serve processes + #[cfg(target_os = "macos")] + { + let _ = Command::new("pkill") + .arg("-f") + .arg("claude mcp serve") + .output(); + } + + #[cfg(target_os = "linux")] + { + let _ = Command::new("pkill") + .arg("-f") + .arg("claude mcp serve") + .output(); + } + + #[cfg(target_os = "windows")] + { + let _ = Command::new("taskkill") + .arg("/F") + .arg("/IM") + .arg("claude.exe") + .output(); + } + + info!("Orphaned MCP process cleanup complete"); +} diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index b459c15e..eae69045 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -254,7 +254,14 @@ fn get_all_usage_entries(claude_path: &PathBuf) -> Vec { let mut processed_hashes = HashSet::new(); let projects_dir = claude_path.join("projects"); + // Check if projects directory exists + if !projects_dir.exists() { + log::warn!("Claude projects directory not found: {:?}", projects_dir); + return all_entries; + } + let mut files_to_process: Vec<(PathBuf, String)> = Vec::new(); + let max_files = 1000; // Limit to prevent excessive scanning if let Ok(projects) = fs::read_dir(&projects_dir) { for project in projects.flatten() { @@ -262,17 +269,31 @@ fn get_all_usage_entries(claude_path: &PathBuf) -> Vec { let project_name = project.file_name().to_string_lossy().to_string(); let project_path = project.path(); - walkdir::WalkDir::new(&project_path) + // Use max_depth to limit recursion + let walker = walkdir::WalkDir::new(&project_path) + .max_depth(3) // Limit directory depth .into_iter() .filter_map(Result::ok) .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("jsonl")) - .for_each(|entry| { - files_to_process.push((entry.path().to_path_buf(), project_name.clone())); - }); + .take(max_files - files_to_process.len()); // Limit total files + + for entry in walker { + files_to_process.push((entry.path().to_path_buf(), project_name.clone())); + if files_to_process.len() >= max_files { + log::warn!("Reached maximum file limit ({}) for usage scanning", max_files); + break; + } + } + + if files_to_process.len() >= max_files { + break; + } } } } + log::info!("Found {} JSONL files to process", files_to_process.len()); + // Sort files by their earliest timestamp to ensure chronological processing // and deterministic deduplication. files_to_process.sort_by_cached_key(|(path, _)| get_earliest_timestamp(path)); @@ -289,12 +310,17 @@ fn get_all_usage_entries(claude_path: &PathBuf) -> Vec { } #[command] -pub fn get_usage_stats(days: Option) -> Result { +pub async fn get_usage_stats(days: Option) -> Result { let claude_path = dirs::home_dir() .ok_or("Failed to get home directory")? .join(".claude"); - let all_entries = get_all_usage_entries(&claude_path); + // Run the file scanning in a blocking task to avoid blocking the UI + let all_entries = tokio::task::spawn_blocking(move || { + get_all_usage_entries(&claude_path) + }) + .await + .map_err(|e| format!("Failed to scan usage files: {}", e))?; if all_entries.is_empty() { return Ok(UsageStats { @@ -449,12 +475,17 @@ pub fn get_usage_stats(days: Option) -> Result { } #[command] -pub fn get_usage_by_date_range(start_date: String, end_date: String) -> Result { +pub async fn get_usage_by_date_range(start_date: String, end_date: String) -> Result { let claude_path = dirs::home_dir() .ok_or("Failed to get home directory")? .join(".claude"); - let all_entries = get_all_usage_entries(&claude_path); + // Run the file scanning in a blocking task to avoid blocking the UI + let all_entries = tokio::task::spawn_blocking(move || { + get_all_usage_entries(&claude_path) + }) + .await + .map_err(|e| format!("Failed to scan usage files: {}", e))?; // Parse dates let start = NaiveDate::parse_from_str(&start_date, "%Y-%m-%d").or_else(|_| { @@ -619,7 +650,7 @@ pub fn get_usage_by_date_range(start_date: String, end_date: String) -> Result, date: Option, ) -> Result, String> { @@ -627,7 +658,12 @@ pub fn get_usage_details( .ok_or("Failed to get home directory")? .join(".claude"); - let mut all_entries = get_all_usage_entries(&claude_path); + // Run the file scanning in a blocking task to avoid blocking the UI + let mut all_entries = tokio::task::spawn_blocking(move || { + get_all_usage_entries(&claude_path) + }) + .await + .map_err(|e| format!("Failed to scan usage files: {}", e))?; // Filter by project if specified if let Some(project) = project_path { @@ -643,7 +679,7 @@ pub fn get_usage_details( } #[command] -pub fn get_session_stats( +pub async fn get_session_stats( since: Option, until: Option, order: Option, @@ -652,7 +688,12 @@ pub fn get_session_stats( .ok_or("Failed to get home directory")? .join(".claude"); - let all_entries = get_all_usage_entries(&claude_path); + // Run the file scanning in a blocking task to avoid blocking the UI + let all_entries = tokio::task::spawn_blocking(move || { + get_all_usage_entries(&claude_path) + }) + .await + .map_err(|e| format!("Failed to scan usage files: {}", e))?; let since_date = since.and_then(|s| NaiveDate::parse_from_str(&s, "%Y%m%d").ok()); let until_date = until.and_then(|s| NaiveDate::parse_from_str(&s, "%Y%m%d").ok()); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1ee99602..819a489b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -8,12 +8,12 @@ mod process; use checkpoint::state::CheckpointState; use commands::agents::{ - cleanup_finished_processes, create_agent, delete_agent, execute_agent, export_agent, + cleanup_finished_processes, create_agent, delete_agent, delete_native_agents, execute_agent, export_agent, export_agent_to_file, fetch_github_agent_content, fetch_github_agents, get_agent, get_agent_run, get_agent_run_with_real_time_metrics, get_claude_binary_path, get_live_session_output, get_session_output, get_session_status, import_agent, - import_agent_from_file, import_agent_from_github, init_database, kill_agent_session, - list_agent_runs, list_agent_runs_with_metrics, list_agents, list_claude_installations, + import_agent_from_file, import_agent_from_github, import_native_agents, init_database, kill_agent_session, + list_agent_runs, list_agent_runs_with_metrics, list_agents, list_native_agents, list_claude_installations, list_running_sessions, load_agent_session_history, set_claude_binary_path, stream_session_output, update_agent, AgentDb, }; use commands::claude::{ @@ -30,9 +30,9 @@ use commands::claude::{ ClaudeProcessState, }; use commands::mcp::{ - mcp_add, mcp_add_from_claude_desktop, mcp_add_json, mcp_get, mcp_get_server_status, mcp_list, + mcp_add, mcp_add_from_claude_desktop, mcp_add_json, mcp_clear_cache, mcp_get, mcp_get_server_status, mcp_list, mcp_read_project_config, mcp_remove, mcp_reset_project_choices, mcp_save_project_config, - mcp_serve, mcp_test_connection, + mcp_serve, mcp_test_connection, cleanup_orphaned_mcp_processes, }; use commands::usage::{ @@ -55,9 +55,29 @@ fn main() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init()) .setup(|app| { + // Cleanup orphaned MCP processes on startup + cleanup_orphaned_mcp_processes(); + // Initialize agents database let conn = init_database(&app.handle()).expect("Failed to initialize agents database"); app.manage(AgentDb(Mutex::new(conn))); + + // Proactively discover and cache Claude binary on startup + let app_handle_clone = app.handle().clone(); + tauri::async_runtime::spawn(async move { + log::info!("Proactively discovering Claude binary on startup..."); + match crate::claude_binary::find_claude_binary(&app_handle_clone) { + Ok(path) => log::info!("Claude binary discovered and cached: {}", path), + Err(e) => log::error!("Failed to discover Claude binary on startup: {}", e), + } + + // Also import native agents on startup + log::info!("Importing native agents on startup..."); + match import_native_agents(app_handle_clone).await { + Ok(count) => log::info!("Imported {} native agents on startup", count), + Err(e) => log::error!("Failed to import native agents on startup: {}", e), + } + }); // Initialize checkpoint state let checkpoint_state = CheckpointState::new(); @@ -133,9 +153,11 @@ fn main() { // Agent Management list_agents, + list_native_agents, create_agent, update_agent, delete_agent, + delete_native_agents, get_agent, execute_agent, list_agent_runs, @@ -157,6 +179,7 @@ fn main() { export_agent_to_file, import_agent, import_agent_from_file, + import_native_agents, fetch_github_agents, fetch_github_agent_content, import_agent_from_github, @@ -180,6 +203,7 @@ fn main() { mcp_get_server_status, mcp_read_project_config, mcp_save_project_config, + mcp_clear_cache, // Storage Management storage_list_tables, diff --git a/src/components/AgentsModal.tsx b/src/components/AgentsModal.tsx index a93d2df8..a672e395 100644 --- a/src/components/AgentsModal.tsx +++ b/src/components/AgentsModal.tsx @@ -32,15 +32,16 @@ interface AgentsModalProps { } export const AgentsModal: React.FC = ({ open, onOpenChange }) => { - const [activeTab, setActiveTab] = useState('agents'); + const [activeTab, setActiveTab] = useState('all'); const [agents, setAgents] = useState([]); + const [nativeAgents, setNativeAgents] = useState([]); const [runningAgents, setRunningAgents] = useState([]); const [loading, setLoading] = useState(true); const [agentToDelete, setAgentToDelete] = useState(null); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); const [showGitHubBrowser, setShowGitHubBrowser] = useState(false); - const { createAgentTab, createCreateAgentTab } = useTabState(); + const { createAgentTab } = useTabState(); // Load agents when modal opens useEffect(() => { @@ -64,8 +65,17 @@ export const AgentsModal: React.FC = ({ open, onOpenChange }) const loadAgents = async () => { try { setLoading(true); - const agentList = await api.listAgents(); - setAgents(agentList); + // Load both database agents and native agents + const [dbAgents, nativeAgentsList] = await Promise.all([ + api.listAgents(), + api.listNativeAgents() + ]); + + // Database agents (Claudia agents) + setAgents(dbAgents); + + // Native agents from .claude/agents folder + setNativeAgents(nativeAgentsList); } catch (error) { console.error('Failed to load agents:', error); } finally { @@ -130,11 +140,26 @@ export const AgentsModal: React.FC = ({ open, onOpenChange }) }; const handleCreateAgent = () => { - // Close modal and create new tab + // Close modal and create new tab with prepopulated agent type based on current tab + const agentType = activeTab === 'native' ? 'native' : activeTab === 'claudia' ? 'claudia' : 'claudia'; // default to claudia onOpenChange(false); - createCreateAgentTab(); + + // Dispatch event with agent type preference + window.dispatchEvent(new CustomEvent('open-create-agent-tab', { + detail: { defaultAgentType: agentType } + })); + }; + + // Filter agents based on active tab + const getFilteredAgents = () => { + if (activeTab === 'all') return [...agents, ...nativeAgents]; + if (activeTab === 'native') return nativeAgents; + if (activeTab === 'claudia') return agents.filter(agent => !agent.source || agent.source === 'claudia' || agent.source === 'user'); + return agents; }; + const filteredAgents = getFilteredAgents(); + const handleImportFromFile = async () => { try { const filePath = await openDialog({ @@ -146,7 +171,9 @@ export const AgentsModal: React.FC = ({ open, onOpenChange }) }); if (filePath) { - const agent = await api.importAgentFromFile(filePath as string); + // Import with agent type based on current tab selection + const agentType = activeTab === 'native' ? 'native' : 'claudia'; + const agent = await api.importAgentFromFile(filePath as string, agentType); loadAgents(); // Refresh list setToast({ message: `Agent "${agent.name}" imported successfully`, type: "success" }); } @@ -160,6 +187,30 @@ export const AgentsModal: React.FC = ({ open, onOpenChange }) setShowGitHubBrowser(true); }; + const handleDeleteNativeAgents = async () => { + if (!confirm('Are you sure you want to delete all native agents from the database? This will not delete the .claude/agents files, but will remove them from the database so they appear properly as native agents.')) { + return; + } + + try { + console.log('Attempting to delete native agents...'); + const count = await api.deleteNativeAgents(); + console.log(`Delete operation returned: ${count}`); + + if (count === 0) { + setToast({ message: "No native agents found in database to delete", type: "success" }); + } else { + setToast({ message: `Successfully deleted ${count} native agents from database`, type: "success" }); + } + + await loadAgents(); // Refresh both lists + } catch (error) { + console.error('Failed to delete native agents:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + setToast({ message: `Failed to delete native agents: ${errorMessage}`, type: "error" }); + } + }; + const handleExportAgent = async (agent: Agent) => { try { const exportData = await api.exportAgent(agent.id!); @@ -209,10 +260,12 @@ export const AgentsModal: React.FC = ({ open, onOpenChange }) - - Available Agents + + All Agents + Claudia + Native - Running Agents + Running {runningAgents.length > 0 && ( {runningAgents.length} @@ -222,163 +275,362 @@ export const AgentsModal: React.FC = ({ open, onOpenChange })
- - - {/* Action buttons at the top */} -
- - - - - - - - - From File - - - - From GitHub - - - -
- {loading ? ( -
- -
- ) : agents.length === 0 ? ( -
- -

No agents available

-

- Create your first agent to get started -

- + + + + + + + + From File + + + + From GitHub + + + +
+ + {/* Scrollable content area */} + +
+ {loading ? ( +
+ +
+ ) : filteredAgents.length === 0 ? ( +
+ +

No agents available

+

+ Create your first agent to get started +

+ +
+ ) : ( +
+ {filteredAgents.map((agent) => ( + +
+
+

+ + {agent.name} + {agent.source === 'native' && ( + Native + )} +

+ {agent.default_task && ( +

+ {agent.default_task} +

+ )} +
+
+ + + +
+
+
+ ))} +
+ )}
- ) : ( -
- {agents.map((agent) => ( - -
-
-

- - {agent.name} -

- {agent.default_task && ( -

- {agent.default_task} -

- )} -
-
- - - -
-
-
- ))} + + + + {/* Claudia Agents Tab */} + + {/* Action buttons at the top */} +
+ + + + + + + + + From File + + + + From GitHub + + + +
+ + {/* Scrollable content area */} + +
+ {loading ? ( +
+ +
+ ) : filteredAgents.length === 0 ? ( +
+ +

No Claudia agents

+

+ Create your first Claudia agent +

+ +
+ ) : ( +
+ {filteredAgents.map((agent) => ( + +
+
+

+ + {agent.name} + Claudia +

+ {agent.default_task && ( +

+ {agent.default_task} +

+ )} +
+
+ + + +
+
+
+ ))} +
+ )}
- )} -
+
- - - {runningAgents.length === 0 ? ( -
- -

No running agents

-

- Agent executions will appear here when started + {/* Native Agents Tab */} + + {/* Info message about native agents */} +

+
+
+

+ These are Claude Code native agents imported from your ~/.claude/agents folder.

+
- ) : ( -
- - {runningAgents.map((run) => ( - handleOpenAgentRun(run)} - > -
-
-

- {getStatusIcon(run.status)} - {run.agent_name} -

-

- {run.task} -

-
- Started: {formatISOTimestamp(run.created_at)} - - {run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'} - +
+
+ + {/* Scrollable content area */} + +
+ {loading ? ( +
+ +
+ ) : filteredAgents.length === 0 ? ( +
+ +

No native agents found

+

+ Place .md agent files in ~/.claude/agents/ to see them here +

+
+ ) : ( +
+ {filteredAgents.map((agent) => ( + +
+
+

+ + {agent.name} + Native +

+ {agent.default_task && ( +

+ {agent.default_task} +

+ )} +
+
+ +
- -
- - ))} - + + ))} +
+ )}
- )} + + + + + +
+ {runningAgents.length === 0 ? ( +
+ +

No running agents

+

+ Agent executions will appear here when started +

+
+ ) : ( +
+ + {runningAgents.map((run) => ( + handleOpenAgentRun(run)} + > +
+
+

+ {getStatusIcon(run.status)} + {run.agent_name} +

+

+ {run.task} +

+
+ Started: {formatISOTimestamp(run.created_at)} + + {run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'} + +
+
+ +
+
+ ))} +
+
+ )} +
diff --git a/src/components/CCAgents.tsx b/src/components/CCAgents.tsx index 3f272fa2..cf429d2c 100644 --- a/src/components/CCAgents.tsx +++ b/src/components/CCAgents.tsx @@ -232,8 +232,8 @@ export const CCAgents: React.FC = ({ onBack, className }) => { return; } - // Import the agent from the selected file - await api.importAgentFromFile(filePath as string); + // Import the agent from the selected file (default to claudia) + await api.importAgentFromFile(filePath as string, 'claudia'); setToast({ message: "Agent imported successfully", type: "success" }); await loadAgents(); diff --git a/src/components/CreateAgent.tsx b/src/components/CreateAgent.tsx index 3c1ffec8..c0ac4050 100644 --- a/src/components/CreateAgent.tsx +++ b/src/components/CreateAgent.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { motion } from "framer-motion"; import { ArrowLeft, Save, Loader2, ChevronDown } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -10,6 +10,13 @@ import { cn } from "@/lib/utils"; import MDEditor from "@uiw/react-md-editor"; import { type AgentIconName } from "./CCAgents"; import { IconPicker, ICON_MAP } from "./IconPicker"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; interface CreateAgentProps { @@ -29,6 +36,10 @@ interface CreateAgentProps { * Optional className for styling */ className?: string; + /** + * Default agent type to select + */ + defaultAgentType?: 'claudia' | 'native'; } /** @@ -42,17 +53,33 @@ export const CreateAgent: React.FC = ({ onBack, onAgentCreated, className, + defaultAgentType = 'claudia', }) => { const [name, setName] = useState(agent?.name || ""); const [selectedIcon, setSelectedIcon] = useState((agent?.icon as AgentIconName) || "bot"); const [systemPrompt, setSystemPrompt] = useState(agent?.system_prompt || ""); const [defaultTask, setDefaultTask] = useState(agent?.default_task || ""); const [model, setModel] = useState(agent?.model || "sonnet"); + const [agentType, setAgentType] = useState<'claudia' | 'native'>( + agent?.source === 'native' ? 'native' : defaultAgentType + ); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); const [showIconPicker, setShowIconPicker] = useState(false); + // Listen for agent type preference from modal + useEffect(() => { + const handleCreateAgentEvent = (event: any) => { + if (event.detail?.defaultAgentType) { + setAgentType(event.detail.defaultAgentType); + } + }; + + window.addEventListener('open-create-agent-tab', handleCreateAgentEvent); + return () => window.removeEventListener('open-create-agent-tab', handleCreateAgentEvent); + }, []); + const isEditMode = !!agent; const handleSave = async () => { @@ -85,7 +112,9 @@ export const CreateAgent: React.FC = ({ selectedIcon, systemPrompt, defaultTask || undefined, - model + model, + undefined, // hooks + agentType ); } @@ -182,8 +211,30 @@ export const CreateAgent: React.FC = ({

Basic Information

- {/* Name and Icon */} -
+ {/* Agent Type, Name and Icon */} +
+
+ + + {isEditMode && ( +

+ Agent type cannot be changed when editing +

+ )} +
+
{ + try { + return await invoke('list_native_agents'); + } catch (error) { + console.error("Failed to list native agents:", error); + throw error; + } + }, + /** * Creates a new agent * @param name - The agent name @@ -677,7 +691,8 @@ export const api = { system_prompt: string, default_task?: string, model?: string, - hooks?: string + hooks?: string, + source?: string ): Promise { try { return await invoke('create_agent', { @@ -686,7 +701,8 @@ export const api = { systemPrompt: system_prompt, defaultTask: default_task, model, - hooks + hooks, + source: source || 'claudia' }); } catch (error) { console.error("Failed to create agent:", error); @@ -744,6 +760,19 @@ export const api = { } }, + /** + * Deletes all native agents from database (keeping .claude/agents files intact) + * @returns Promise resolving to the number of agents deleted + */ + async deleteNativeAgents(): Promise { + try { + return await invoke('delete_native_agents'); + } catch (error) { + console.error("Failed to delete native agents:", error); + throw error; + } + }, + /** * Gets a single agent by ID * @param id - The agent ID @@ -789,11 +818,12 @@ export const api = { /** * Imports an agent from a file * @param filePath - The path to the JSON file + * @param source - The source type ('claudia' or 'native') * @returns Promise resolving to the imported agent */ - async importAgentFromFile(filePath: string): Promise { + async importAgentFromFile(filePath: string, source: string): Promise { try { - return await invoke('import_agent_from_file', { filePath }); + return await invoke('import_agent_from_file', { filePath, source }); } catch (error) { console.error("Failed to import agent from file:", error); throw error; @@ -1390,6 +1420,20 @@ export const api = { } }, + /** + * Clears the MCP server cache to force fresh discovery + */ + async mcpClearCache(): Promise { + try { + const result = await invoke("mcp_clear_cache"); + console.log("API: MCP cache cleared:", result); + return result; + } catch (error) { + console.error("API: Failed to clear MCP cache:", error); + throw error; + } + }, + /** * Gets details for a specific MCP server */