collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Headless (non-interactive) mode for CI/CD and scripting.
//!
//! Usage: `collet --headless "fix all clippy warnings"`
//! Runs the agent without TUI, outputs results to stdout.

use anyhow::Result;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;

use crate::agent::context::ConversationContext;
use crate::agent::r#loop::AgentEvent;
use crate::agent::prompt;
use crate::api::provider::OpenAiCompatibleProvider;
use crate::config::Config;
/// Run in headless mode — execute a single prompt and exit.
pub async fn run_headless(
    config: Config,
    client: OpenAiCompatibleProvider,
    prompt_text: String,
    json_metrics: bool,
) -> Result<()> {
    let working_dir = std::env::current_dir()?.to_string_lossy().to_string();

    // Start global project cache background tasks.
    crate::project_cache::global().ensure_background_tasks();

    // Build/reuse cached repo map with BM25 query-relevant file annotation.
    let snap = {
        let wd = working_dir.clone();
        let query = prompt_text.clone();
        tokio::task::spawn_blocking(move || {
            crate::project_cache::global()
                .get_or_build(&wd)
                .snapshot_with_query(&query, 10)
        })
        .await
        .unwrap()
    };

    let system_prompt =
        prompt::build_default_prompt(&snap.map_string, snap.file_count, snap.symbol_count, None);

    // Use the model's known context window as an upper bound on token budget.
    let model_context_window = crate::api::model_profile::context_window_for(&config.model);
    let effective_max_tokens = config.context_max_tokens.min(model_context_window);
    let context = ConversationContext::with_budget(
        system_prompt,
        effective_max_tokens,
        config.compaction_threshold,
    );

    let (event_tx, mut event_rx) = mpsc::unbounded_channel::<AgentEvent>();
    let cancel = CancellationToken::new();

    // Handle Ctrl+C
    let cancel_clone = cancel.clone();
    tokio::spawn(async move {
        tokio::signal::ctrl_c().await.ok();
        cancel_clone.cancel();
    });

    // Resolve @file mentions (syntactic path — no bare-name disk stats in headless).
    let file_mentions = crate::agent::mentions::extract_mentions(&prompt_text);
    let (prompt_text, _mentioned) =
        crate::agent::mentions::resolve_mentions(&prompt_text, &working_dir, &file_mentions);

    let client_clone = client.clone();
    let config_clone = config.clone();
    let working_dir_clone = working_dir.clone();
    let prompt_clone = prompt_text.clone();
    let lsp_manager = crate::lsp::manager::LspManager::new(working_dir.clone());
    let lsp_clone = lsp_manager.clone();

    eprintln!("🚀 collet headless mode (model: {})", config.model);
    eprintln!("📝 Prompt: {}", truncate(&prompt_text, 100));
    eprintln!("---");

    tokio::spawn(async move {
        crate::agent::r#loop::run_with_mode(crate::agent::r#loop::AgentParams {
            client: client_clone,
            config: config_clone,
            context,
            user_msg: prompt_clone,
            working_dir: working_dir_clone,
            event_tx,
            cancel,
            lsp_manager: lsp_clone,
            trust_level: crate::trust::TrustLevel::Full, // headless mode trusts by default
            approval_gate: crate::agent::approval::ApprovalGate::yolo(),
            images: Vec::new(),
        })
        .await;
    });

    // Metrics accumulators (used when --json-metrics is set)
    let start = std::time::Instant::now();
    let mut total_input_tokens: u64 = 0;
    let mut total_output_tokens: u64 = 0;
    let mut total_cached_tokens: u64 = 0;
    let mut iterations: u32 = 0;
    let mut tool_calls: u64 = 0;
    let mut stop_reason = "done";

    // Process events and output to stdout/stderr
    let mut exit_code = 0;
    let mut streamed_any_token = false;
    while let Some(event) = event_rx.recv().await {
        match event {
            AgentEvent::Token(token) => {
                streamed_any_token = true;
                print!("{token}");
            }
            AgentEvent::Response(text) => {
                // For non-streaming providers, text arrives only here (no Token events).
                // If tokens were already streamed, just emit the trailing newline.
                if !text.is_empty() {
                    if streamed_any_token {
                        println!();
                    } else {
                        println!("{text}");
                    }
                }
            }
            AgentEvent::ToolCall { name, args, .. } => {
                tool_calls += 1;
                let preview = if args.len() > 200 {
                    format!("{}...", crate::util::truncate_bytes(&args, 200))
                } else {
                    args
                };
                eprintln!("🔧 {name}: {preview}");
            }
            AgentEvent::ToolResult {
                name,
                result,
                success,
                ..
            } => {
                let icon = if success { "" } else { "" };
                let preview = if result.len() > 300 {
                    format!("{}...", crate::util::truncate_bytes(&result, 300))
                } else {
                    result
                };
                eprintln!("  {icon} {name}: {preview}");
                if !success {
                    exit_code = 1;
                }
            }
            AgentEvent::FileModified { path } => {
                eprintln!("📄 Modified: {path}");
            }
            AgentEvent::Error(msg) => {
                eprintln!("❌ Error: {msg}");
                stop_reason = "error";
                exit_code = 1;
            }
            AgentEvent::GuardStop(msg) => {
                eprintln!("{msg}");
                stop_reason = "guard_stop";
            }
            AgentEvent::Status {
                iteration,
                elapsed_secs,
                prompt_tokens,
                completion_tokens,
                cached_tokens,
                ..
            } => {
                total_input_tokens += prompt_tokens as u64;
                total_output_tokens += completion_tokens as u64;
                total_cached_tokens += cached_tokens as u64;
                iterations = iteration;
                eprintln!("⏱  Iteration {iteration} ({elapsed_secs}s)");
            }
            AgentEvent::StreamRetry {
                attempt,
                max,
                message,
            } => {
                eprintln!("{message} ({attempt}/{max})");
            }
            AgentEvent::PhaseChange { label } => {
                eprintln!("--- {label}");
            }
            AgentEvent::Done { .. } => {
                break;
            }
            AgentEvent::PlanReady { plan, .. } => {
                // In headless mode, print the plan and exit (no interactive approval)
                eprintln!("--- Architect plan ---");
                eprintln!("{plan}");
                break;
            }
            AgentEvent::SwarmAgentStarted {
                agent_name,
                task_preview,
                ..
            } => {
                eprintln!("[Hive] Agent '{agent_name}' started: {task_preview}");
            }
            AgentEvent::SwarmAgentProgress {
                agent_name,
                iteration,
                status,
                ..
            } => {
                eprintln!("[Hive] Agent '{agent_name}' iter:{iteration}{status}");
            }
            AgentEvent::SwarmAgentDone {
                agent_name,
                success,
                tool_calls,
                ..
            } => {
                let icon = if success { "OK" } else { "FAILED" };
                eprintln!("[Hive] Agent '{agent_name}' {icon} ({tool_calls} tools)");
            }
            AgentEvent::SwarmConflict { conflicts } => {
                for (path, agents) in &conflicts {
                    let resolution =
                        crate::agent::swarm::conflict::ConflictResolutionOutcome::UserResolved {
                            choice: "headless-last-writer".to_string(),
                        };
                    eprintln!(
                        "[Hive] Conflict: {path} ({}) — headless auto-resolution: {:?}",
                        agents.join(", "),
                        resolution
                    );
                }
            }
            AgentEvent::SwarmDone {
                merged_response,
                agent_count,
                total_tool_calls,
                conflicts_resolved,
                ..
            } => {
                eprintln!(
                    "[Hive] Done: {agent_count} agents, {total_tool_calls} tools, {conflicts_resolved} conflicts"
                );
                if !merged_response.is_empty() {
                    println!("{merged_response}");
                }
                break;
            }
            AgentEvent::SwarmModeSwitch { .. } => {} // TUI-only
            AgentEvent::SwarmResolvedToSingle { .. } => {} // TUI-only
            AgentEvent::SwarmWorkerApproaching { .. } => {} // TUI-only
            AgentEvent::SwarmWorkersDispatched => {} // TUI-only (headless waits synchronously)
            AgentEvent::SwarmWorkerPaused { .. } => {} // TUI-only
            AgentEvent::SwarmWorkerResumed { .. } => {} // TUI-only
            AgentEvent::SwarmAgentToolCall { .. } => {} // graph visualization
            AgentEvent::SwarmAgentToolResult { .. } => {} // graph visualization
            AgentEvent::SwarmAgentToken { .. } => {} // graph visualization
            AgentEvent::SwarmAgentResponse { .. } => {} // graph visualization
            AgentEvent::PerformanceUpdate { .. } => {} // debug-only, ignored in headless
            AgentEvent::LspInstalled { .. } => {}    // TUI-only
            AgentEvent::McpPids { .. } => {}         // debug-only
            AgentEvent::ImageNotice { .. } => {}     // handled in TUI only
            AgentEvent::SoulReflecting { .. } => {}  // background, no output in headless
            AgentEvent::ApprovalRequired { .. } | AgentEvent::ApprovalDenied { .. } => {} // not used in headless/yolo mode
            AgentEvent::Evolution(_) => {} // evolution events are TUI-only in headless mode
            AgentEvent::ToolBatchProgress { .. } => {}
            AgentEvent::StreamWaiting { .. } => {}
            AgentEvent::CompactionStarted { .. } => {}
            AgentEvent::CompactionDone { .. } => {}
            AgentEvent::ToolResultTruncated { .. } => {}
            AgentEvent::ShellOutput {
                stdout,
                stderr,
                exit_code,
                ..
            } => {
                if !stdout.is_empty() {
                    print!("{stdout}");
                }
                if !stderr.is_empty() {
                    eprint!("{stderr}");
                }
                if exit_code != 0 {
                    eprintln!("(exit {exit_code})");
                }
            }
        }
    }

    // Emit JSON metrics to stderr if requested
    if json_metrics {
        let elapsed_ms = start.elapsed().as_millis() as u64;
        eprintln!(
            "{{\"input_tokens\":{},\"output_tokens\":{},\"cached_tokens\":{},\"iterations\":{},\"tool_calls\":{},\"elapsed_ms\":{},\"stop_reason\":\"{}\"}}",
            total_input_tokens,
            total_output_tokens,
            total_cached_tokens,
            iterations,
            tool_calls,
            elapsed_ms,
            stop_reason
        );
    }

    eprintln!("---");
    eprintln!("✅ Done.");

    if exit_code != 0 {
        std::process::exit(exit_code);
    }

    Ok(())
}

fn truncate(s: &str, max: usize) -> String {
    if s.len() <= max {
        s.to_string()
    } else {
        format!("{}...", crate::util::truncate_bytes(s, max))
    }
}