mod comms; mod config; mod observability; mod session; mod tui; mod worktree; use anyhow::Result; use clap::Parser; use std::path::PathBuf; use tracing_subscriber::EnvFilter; #[derive(Parser, Debug)] #[command(name = "ecc", version, about = "ECC 2.0 — Agentic IDE control plane")] struct Cli { #[command(subcommand)] command: Option, } #[derive(clap::Subcommand, Debug)] enum Commands { /// Launch the TUI dashboard Dashboard, /// Start a new agent session Start { /// Task description for the agent #[arg(short, long)] task: String, /// Agent type (claude, codex, custom) #[arg(short, long, default_value = "claude")] agent: String, /// Create a dedicated worktree for this session #[arg(short, long)] worktree: bool, /// Source session to delegate from #[arg(long)] from_session: Option, }, /// Delegate a new session from an existing one Delegate { /// Source session ID or alias from_session: String, /// Task description for the delegated session #[arg(short, long)] task: Option, /// Agent type (claude, codex, custom) #[arg(short, long, default_value = "claude")] agent: String, /// Create a dedicated worktree for the delegated session #[arg(short, long, default_value_t = true)] worktree: bool, }, /// Route work to an existing delegate when possible, otherwise spawn a new one Assign { /// Lead session ID or alias from_session: String, /// Task description for the assignment #[arg(short, long)] task: String, /// Agent type (claude, codex, custom) #[arg(short, long, default_value = "claude")] agent: String, /// Create a dedicated worktree if a new delegate must be spawned #[arg(short, long, default_value_t = true)] worktree: bool, }, /// Route unread task handoffs from a lead session inbox through the assignment policy DrainInbox { /// Lead session ID or alias session_id: String, /// Agent type for routed delegates #[arg(short, long, default_value = "claude")] agent: String, /// Create a dedicated worktree if new delegates must be spawned #[arg(short, long, default_value_t = true)] worktree: bool, /// Maximum unread task handoffs to route #[arg(long, default_value_t = 5)] limit: usize, }, /// Sweep unread task handoffs across lead sessions and route them through the assignment policy AutoDispatch { /// Agent type for routed delegates #[arg(short, long, default_value = "claude")] agent: String, /// Create a dedicated worktree if new delegates must be spawned #[arg(short, long, default_value_t = true)] worktree: bool, /// Maximum lead sessions to sweep in one pass #[arg(long, default_value_t = 10)] lead_limit: usize, }, /// Dispatch unread handoffs, then rebalance delegate backlog across lead teams CoordinateBacklog { /// Agent type for routed delegates #[arg(short, long, default_value = "claude")] agent: String, /// Create a dedicated worktree if new delegates must be spawned #[arg(short, long, default_value_t = true)] worktree: bool, /// Maximum lead sessions to sweep in one pass #[arg(long, default_value_t = 10)] lead_limit: usize, }, /// Rebalance unread handoffs across lead teams with backed-up delegates RebalanceAll { /// Agent type for routed delegates #[arg(short, long, default_value = "claude")] agent: String, /// Create a dedicated worktree if new delegates must be spawned #[arg(short, long, default_value_t = true)] worktree: bool, /// Maximum lead sessions to sweep in one pass #[arg(long, default_value_t = 10)] lead_limit: usize, }, /// Rebalance unread handoffs off backed-up delegates onto clearer team capacity RebalanceTeam { /// Lead session ID or alias session_id: String, /// Agent type for routed delegates #[arg(short, long, default_value = "claude")] agent: String, /// Create a dedicated worktree if new delegates must be spawned #[arg(short, long, default_value_t = true)] worktree: bool, /// Maximum handoffs to reroute in one pass #[arg(long, default_value_t = 5)] limit: usize, }, /// List active sessions Sessions, /// Show session details Status { /// Session ID or alias session_id: Option, }, /// Show delegated team board for a session Team { /// Lead session ID or alias session_id: Option, /// Delegation depth to traverse #[arg(long, default_value_t = 2)] depth: usize, }, /// Stop a running session Stop { /// Session ID or alias session_id: String, }, /// Resume a failed or stopped session Resume { /// Session ID or alias session_id: String, }, /// Send or inspect inter-session messages Messages { #[command(subcommand)] command: MessageCommands, }, /// Run as background daemon Daemon, #[command(hide = true)] RunSession { #[arg(long)] session_id: String, #[arg(long)] task: String, #[arg(long)] agent: String, #[arg(long)] cwd: PathBuf, }, } #[derive(clap::Subcommand, Debug)] enum MessageCommands { /// Send a structured message between sessions Send { #[arg(long)] from: String, #[arg(long)] to: String, #[arg(long, value_enum)] kind: MessageKindArg, #[arg(long)] text: String, #[arg(long)] context: Option, #[arg(long)] file: Vec, }, /// Show recent messages for a session Inbox { session_id: String, #[arg(long, default_value_t = 10)] limit: usize, }, } #[derive(clap::ValueEnum, Clone, Debug)] enum MessageKindArg { Handoff, Query, Response, Completed, Conflict, } #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter(EnvFilter::from_default_env()) .init(); let cli = Cli::parse(); let cfg = config::Config::load()?; let db = session::store::StateStore::open(&cfg.db_path)?; match cli.command { Some(Commands::Dashboard) | None => { tui::app::run(db, cfg).await?; } Some(Commands::Start { task, agent, worktree: use_worktree, from_session, }) => { let session_id = session::manager::create_session(&db, &cfg, &task, &agent, use_worktree).await?; if let Some(from_session) = from_session { let from_id = resolve_session_id(&db, &from_session)?; send_handoff_message(&db, &from_id, &session_id)?; } println!("Session started: {session_id}"); } Some(Commands::Delegate { from_session, task, agent, worktree: use_worktree, }) => { let from_id = resolve_session_id(&db, &from_session)?; let source = db .get_session(&from_id)? .ok_or_else(|| anyhow::anyhow!("Session not found: {from_id}"))?; let task = task.unwrap_or_else(|| { format!( "Follow up on {}: {}", short_session(&source.id), source.task ) }); let session_id = session::manager::create_session(&db, &cfg, &task, &agent, use_worktree).await?; send_handoff_message(&db, &source.id, &session_id)?; println!( "Delegated session started: {} <- {}", session_id, short_session(&source.id) ); } Some(Commands::Assign { from_session, task, agent, worktree: use_worktree, }) => { let lead_id = resolve_session_id(&db, &from_session)?; let outcome = session::manager::assign_session( &db, &cfg, &lead_id, &task, &agent, use_worktree, ) .await?; println!( "Assignment routed: {} -> {} ({})", short_session(&lead_id), short_session(&outcome.session_id), match outcome.action { session::manager::AssignmentAction::Spawned => "spawned", session::manager::AssignmentAction::ReusedIdle => "reused-idle", session::manager::AssignmentAction::ReusedActive => "reused-active", } ); } Some(Commands::DrainInbox { session_id, agent, worktree: use_worktree, limit, }) => { let lead_id = resolve_session_id(&db, &session_id)?; let outcomes = session::manager::drain_inbox( &db, &cfg, &lead_id, &agent, use_worktree, limit, ) .await?; if outcomes.is_empty() { println!("No unread task handoffs for {}", short_session(&lead_id)); } else { println!( "Routed {} inbox task handoff(s) from {}", outcomes.len(), short_session(&lead_id) ); for outcome in outcomes { println!( "- {} -> {} ({}) | {}", outcome.message_id, short_session(&outcome.session_id), match outcome.action { session::manager::AssignmentAction::Spawned => "spawned", session::manager::AssignmentAction::ReusedIdle => "reused-idle", session::manager::AssignmentAction::ReusedActive => "reused-active", }, outcome.task ); } } } Some(Commands::AutoDispatch { agent, worktree: use_worktree, lead_limit, }) => { let outcomes = session::manager::auto_dispatch_backlog( &db, &cfg, &agent, use_worktree, lead_limit, ) .await?; if outcomes.is_empty() { println!("No unread task handoff backlog found"); } else { let total_routed: usize = outcomes.iter().map(|outcome| outcome.routed.len()).sum(); println!( "Auto-dispatched {} task handoff(s) across {} lead session(s)", total_routed, outcomes.len() ); for outcome in outcomes { println!( "- {} | unread {} | routed {}", short_session(&outcome.lead_session_id), outcome.unread_count, outcome.routed.len() ); } } } Some(Commands::CoordinateBacklog { agent, worktree: use_worktree, lead_limit, }) => { let outcome = session::manager::coordinate_backlog( &db, &cfg, &agent, use_worktree, lead_limit, ) .await?; let total_routed: usize = outcome .dispatched .iter() .map(|dispatch| dispatch.routed.len()) .sum(); let total_rerouted: usize = outcome .rebalanced .iter() .map(|rebalance| rebalance.rerouted.len()) .sum(); if total_routed == 0 && total_rerouted == 0 && outcome.remaining_backlog_sessions == 0 { println!("Backlog already clear"); } else { println!( "Coordinated backlog: dispatched {} handoff(s) across {} lead(s); rebalanced {} handoff(s) across {} lead(s); remaining {} handoff(s) across {} session(s)", total_routed, outcome.dispatched.len(), total_rerouted, outcome.rebalanced.len(), outcome.remaining_backlog_messages, outcome.remaining_backlog_sessions ); } } Some(Commands::RebalanceAll { agent, worktree: use_worktree, lead_limit, }) => { let outcomes = session::manager::rebalance_all_teams( &db, &cfg, &agent, use_worktree, lead_limit, ) .await?; if outcomes.is_empty() { println!("No delegate backlog needed global rebalancing"); } else { let total_rerouted: usize = outcomes.iter().map(|outcome| outcome.rerouted.len()).sum(); println!( "Rebalanced {} task handoff(s) across {} lead session(s)", total_rerouted, outcomes.len() ); for outcome in outcomes { println!( "- {} | rerouted {}", short_session(&outcome.lead_session_id), outcome.rerouted.len() ); } } } Some(Commands::RebalanceTeam { session_id, agent, worktree: use_worktree, limit, }) => { let lead_id = resolve_session_id(&db, &session_id)?; let outcomes = session::manager::rebalance_team_backlog( &db, &cfg, &lead_id, &agent, use_worktree, limit, ) .await?; if outcomes.is_empty() { println!("No delegate backlog needed rebalancing for {}", short_session(&lead_id)); } else { println!( "Rebalanced {} task handoff(s) for {}", outcomes.len(), short_session(&lead_id) ); for outcome in outcomes { println!( "- {} | {} -> {} ({}) | {}", outcome.message_id, short_session(&outcome.from_session_id), short_session(&outcome.session_id), match outcome.action { session::manager::AssignmentAction::Spawned => "spawned", session::manager::AssignmentAction::ReusedIdle => "reused-idle", session::manager::AssignmentAction::ReusedActive => "reused-active", }, outcome.task ); } } } Some(Commands::Sessions) => { let sessions = session::manager::list_sessions(&db)?; for s in sessions { println!("{} [{}] {}", s.id, s.state, s.task); } } Some(Commands::Status { session_id }) => { let id = session_id.unwrap_or_else(|| "latest".to_string()); let status = session::manager::get_status(&db, &id)?; println!("{status}"); } Some(Commands::Team { session_id, depth }) => { let id = session_id.unwrap_or_else(|| "latest".to_string()); let team = session::manager::get_team_status(&db, &id, depth)?; println!("{team}"); } Some(Commands::Stop { session_id }) => { session::manager::stop_session(&db, &session_id).await?; println!("Session stopped: {session_id}"); } Some(Commands::Resume { session_id }) => { let resumed_id = session::manager::resume_session(&db, &cfg, &session_id).await?; println!("Session resumed: {resumed_id}"); } Some(Commands::Messages { command }) => match command { MessageCommands::Send { from, to, kind, text, context, file, } => { let from = resolve_session_id(&db, &from)?; let to = resolve_session_id(&db, &to)?; let message = build_message(kind, text, context, file)?; comms::send(&db, &from, &to, &message)?; println!("Message sent: {} -> {}", short_session(&from), short_session(&to)); } MessageCommands::Inbox { session_id, limit } => { let session_id = resolve_session_id(&db, &session_id)?; let messages = db.list_messages_for_session(&session_id, limit)?; let unread_before = db .unread_message_counts()? .get(&session_id) .copied() .unwrap_or(0); if unread_before > 0 { let _ = db.mark_messages_read(&session_id)?; } if messages.is_empty() { println!("No messages for {}", short_session(&session_id)); } else { println!("Messages for {}", short_session(&session_id)); for message in messages { println!( "{} {} -> {} | {}", message.timestamp.format("%H:%M:%S"), short_session(&message.from_session), short_session(&message.to_session), comms::preview(&message.msg_type, &message.content) ); } } } }, Some(Commands::Daemon) => { println!("Starting ECC daemon..."); session::daemon::run(db, cfg).await?; } Some(Commands::RunSession { session_id, task, agent, cwd, }) => { session::manager::run_session(&cfg, &session_id, &task, &agent, &cwd).await?; } } Ok(()) } fn resolve_session_id(db: &session::store::StateStore, value: &str) -> Result { if value == "latest" { return db .get_latest_session()? .map(|session| session.id) .ok_or_else(|| anyhow::anyhow!("No sessions found")); } db.get_session(value)? .map(|session| session.id) .ok_or_else(|| anyhow::anyhow!("Session not found: {value}")) } fn build_message( kind: MessageKindArg, text: String, context: Option, files: Vec, ) -> Result { Ok(match kind { MessageKindArg::Handoff => comms::MessageType::TaskHandoff { task: text, context: context.unwrap_or_default(), }, MessageKindArg::Query => comms::MessageType::Query { question: text }, MessageKindArg::Response => comms::MessageType::Response { answer: text }, MessageKindArg::Completed => comms::MessageType::Completed { summary: text, files_changed: files, }, MessageKindArg::Conflict => { let file = files .first() .cloned() .ok_or_else(|| anyhow::anyhow!("Conflict messages require at least one --file"))?; comms::MessageType::Conflict { file, description: context.unwrap_or(text), } } }) } fn short_session(session_id: &str) -> String { session_id.chars().take(8).collect() } fn send_handoff_message( db: &session::store::StateStore, from_id: &str, to_id: &str, ) -> Result<()> { let from_session = db .get_session(from_id)? .ok_or_else(|| anyhow::anyhow!("Session not found: {from_id}"))?; let context = format!( "Delegated from {} [{}] | cwd {}{}", short_session(&from_session.id), from_session.agent_type, from_session.working_dir.display(), from_session .worktree .as_ref() .map(|worktree| format!( " | worktree {} ({})", worktree.branch, worktree.path.display() )) .unwrap_or_default() ); comms::send( db, &from_session.id, to_id, &comms::MessageType::TaskHandoff { task: from_session.task, context, }, ) } #[cfg(test)] mod tests { use super::*; #[test] fn cli_parses_resume_command() { let cli = Cli::try_parse_from(["ecc", "resume", "deadbeef"]) .expect("resume subcommand should parse"); match cli.command { Some(Commands::Resume { session_id }) => assert_eq!(session_id, "deadbeef"), _ => panic!("expected resume subcommand"), } } #[test] fn cli_parses_messages_send_command() { let cli = Cli::try_parse_from([ "ecc", "messages", "send", "--from", "planner", "--to", "worker", "--kind", "query", "--text", "Need context", ]) .expect("messages send should parse"); match cli.command { Some(Commands::Messages { command: MessageCommands::Send { from, to, kind, text, .. }, }) => { assert_eq!(from, "planner"); assert_eq!(to, "worker"); assert!(matches!(kind, MessageKindArg::Query)); assert_eq!(text, "Need context"); } _ => panic!("expected messages send subcommand"), } } #[test] fn cli_parses_start_with_handoff_source() { let cli = Cli::try_parse_from([ "ecc", "start", "--task", "Follow up", "--agent", "claude", "--from-session", "planner", ]) .expect("start with handoff source should parse"); match cli.command { Some(Commands::Start { from_session, task, agent, .. }) => { assert_eq!(task, "Follow up"); assert_eq!(agent, "claude"); assert_eq!(from_session.as_deref(), Some("planner")); } _ => panic!("expected start subcommand"), } } #[test] fn cli_parses_delegate_command() { let cli = Cli::try_parse_from([ "ecc", "delegate", "planner", "--task", "Review auth changes", "--agent", "codex", ]) .expect("delegate should parse"); match cli.command { Some(Commands::Delegate { from_session, task, agent, .. }) => { assert_eq!(from_session, "planner"); assert_eq!(task.as_deref(), Some("Review auth changes")); assert_eq!(agent, "codex"); } _ => panic!("expected delegate subcommand"), } } #[test] fn cli_parses_team_command() { let cli = Cli::try_parse_from(["ecc", "team", "planner", "--depth", "3"]) .expect("team should parse"); match cli.command { Some(Commands::Team { session_id, depth }) => { assert_eq!(session_id.as_deref(), Some("planner")); assert_eq!(depth, 3); } _ => panic!("expected team subcommand"), } } #[test] fn cli_parses_assign_command() { let cli = Cli::try_parse_from([ "ecc", "assign", "lead", "--task", "Review auth changes", "--agent", "claude", ]) .expect("assign should parse"); match cli.command { Some(Commands::Assign { from_session, task, agent, .. }) => { assert_eq!(from_session, "lead"); assert_eq!(task, "Review auth changes"); assert_eq!(agent, "claude"); } _ => panic!("expected assign subcommand"), } } #[test] fn cli_parses_drain_inbox_command() { let cli = Cli::try_parse_from([ "ecc", "drain-inbox", "lead", "--agent", "claude", "--limit", "3", ]) .expect("drain-inbox should parse"); match cli.command { Some(Commands::DrainInbox { session_id, agent, limit, .. }) => { assert_eq!(session_id, "lead"); assert_eq!(agent, "claude"); assert_eq!(limit, 3); } _ => panic!("expected drain-inbox subcommand"), } } #[test] fn cli_parses_auto_dispatch_command() { let cli = Cli::try_parse_from([ "ecc", "auto-dispatch", "--agent", "claude", "--lead-limit", "4", ]) .expect("auto-dispatch should parse"); match cli.command { Some(Commands::AutoDispatch { agent, lead_limit, .. }) => { assert_eq!(agent, "claude"); assert_eq!(lead_limit, 4); } _ => panic!("expected auto-dispatch subcommand"), } } #[test] fn cli_parses_coordinate_backlog_command() { let cli = Cli::try_parse_from([ "ecc", "coordinate-backlog", "--agent", "claude", "--lead-limit", "7", ]) .expect("coordinate-backlog should parse"); match cli.command { Some(Commands::CoordinateBacklog { agent, lead_limit, .. }) => { assert_eq!(agent, "claude"); assert_eq!(lead_limit, 7); } _ => panic!("expected coordinate-backlog subcommand"), } } #[test] fn cli_parses_rebalance_all_command() { let cli = Cli::try_parse_from([ "ecc", "rebalance-all", "--agent", "claude", "--lead-limit", "6", ]) .expect("rebalance-all should parse"); match cli.command { Some(Commands::RebalanceAll { agent, lead_limit, .. }) => { assert_eq!(agent, "claude"); assert_eq!(lead_limit, 6); } _ => panic!("expected rebalance-all subcommand"), } } #[test] fn cli_parses_rebalance_team_command() { let cli = Cli::try_parse_from([ "ecc", "rebalance-team", "lead", "--agent", "claude", "--limit", "2", ]) .expect("rebalance-team should parse"); match cli.command { Some(Commands::RebalanceTeam { session_id, agent, limit, .. }) => { assert_eq!(session_id, "lead"); assert_eq!(agent, "claude"); assert_eq!(limit, 2); } _ => panic!("expected rebalance-team subcommand"), } } }