use crate::input;
use koda_core::agent::KodaAgent;
use koda_core::config::KodaConfig;
use koda_core::db::{Database, Role};
use koda_core::engine::{ApprovalDecision, EngineCommand, EngineEvent, EngineSink};
use koda_core::persistence::Persistence;
use koda_core::session::KodaSession;
use koda_core::trust::TrustMode;
use anyhow::Result;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc;
pub async fn run_headless(
project_root: PathBuf,
mut config: KodaConfig,
db: Database,
session_id: String,
prompt: String,
output_format: &str,
) -> Result<i32> {
let tmp_provider = koda_core::providers::create_provider(&config);
config
.query_and_apply_capabilities(tmp_provider.as_ref())
.await;
let mut agent = KodaAgent::new(&config, project_root.clone(), &[]).await?;
crate::builtin_skills::inject_builtin_skills(&mut agent);
agent.rebuild_system_prompt(&config, &[]);
let agent = Arc::new(agent);
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::channel::<koda_core::engine::EngineCommand>(32);
let mut session = KodaSession::new(session_id, agent, db, &config, TrustMode::Auto).await;
let processed = input::process_input(&prompt, &project_root);
let user_message = if let Some(context) = input::format_context_files(&processed.context_files)
{
format!("{}\n\n{context}", processed.prompt)
} else {
processed.prompt.clone()
};
let pending_images = if processed.images.is_empty() {
None
} else {
Some(processed.images)
};
session
.db
.insert_message(
&session.id,
&Role::User,
Some(&user_message),
None,
None,
None,
)
.await?;
let cli_sink = HeadlessSink::new(cmd_tx);
let cancel = session.cancel.clone();
let result = tokio::select! {
r = session.run_turn(
&config,
pending_images,
&cli_sink,
&mut cmd_rx,
) => r,
_ = tokio::signal::ctrl_c() => {
cancel.cancel();
eprintln!("\n\x1b[33m\u{26a0} Interrupted\x1b[0m");
Ok(())
}
};
if output_format == "json" {
let last_response = session
.db
.last_assistant_message(&session.id)
.await
.unwrap_or_default();
let json = serde_json::json!({
"success": result.is_ok(),
"response": last_response,
"session_id": session.id,
"model": config.model,
});
println!("{}", serde_json::to_string_pretty(&json)?);
}
match result {
Ok(()) => Ok(0),
Err(e) => {
eprintln!("Error: {e}");
Ok(1)
}
}
}
struct HeadlessSink {
cmd_tx: tokio::sync::mpsc::Sender<EngineCommand>,
}
impl HeadlessSink {
fn new(cmd_tx: tokio::sync::mpsc::Sender<EngineCommand>) -> Self {
Self { cmd_tx }
}
}
impl EngineSink for HeadlessSink {
fn emit(&self, event: EngineEvent) {
match event {
EngineEvent::ApprovalRequest {
id,
effect,
tool_name,
detail,
..
} => {
if effect == koda_core::tools::ToolEffect::Destructive {
eprintln!(
"\x1b[31m ✗ Rejected destructive action: {tool_name} — {detail}\x1b[0m"
);
let _ = self.cmd_tx.try_send(EngineCommand::ApprovalResponse {
id,
decision: ApprovalDecision::Reject,
});
} else {
let _ = self.cmd_tx.try_send(EngineCommand::ApprovalResponse {
id,
decision: ApprovalDecision::Approve,
});
}
}
EngineEvent::AskUserRequest { id, question, .. } => {
eprintln!("[koda] AskUser (no interactive session): {question}");
let _ = self.cmd_tx.try_send(EngineCommand::AskUserResponse {
id,
answer: String::new(),
});
}
EngineEvent::LoopCapReached { .. } => {
let _ = self.cmd_tx.try_send(EngineCommand::LoopDecision {
action: koda_core::loop_guard::LoopContinuation::Continue200,
});
}
EngineEvent::TextDelta { text } => {
print!("{text}");
let _ = std::io::stdout().flush();
}
EngineEvent::TextDone => {
println!();
}
EngineEvent::ThinkingStart => {
eprintln!("\x1b[90m \u{1f4ad} thinking...\x1b[0m");
}
EngineEvent::ThinkingDelta { .. } => {}
EngineEvent::ThinkingDone => {}
EngineEvent::ToolCallStart { name, .. } => {
eprintln!("\x1b[36m \u{26a1} {name}\x1b[0m");
}
EngineEvent::ToolOutputLine {
line, is_stderr, ..
} => {
if is_stderr {
eprintln!(" \u{2502}e {line}");
} else {
eprintln!(" \u{2502} {line}");
}
}
EngineEvent::ToolCallResult { name, output, .. } => {
use koda_core::truncate::{Truncated, truncate_for_display};
eprintln!("\x1b[32m \u{2713} {name}\x1b[0m");
match truncate_for_display(&output) {
Truncated::Full(_) => {
for line in output.lines() {
eprintln!(" \u{2502} {line}");
}
}
Truncated::Split {
head,
tail,
hidden,
total,
} => {
for line in &head {
eprintln!(" \u{2502} {line}");
}
eprintln!(
"\x1b[2m{}\x1b[0m",
koda_core::truncate::separator(hidden, total)
);
for line in &tail {
eprintln!(" \u{2502} {line}");
}
}
}
}
EngineEvent::SubAgentStart { agent_name } => {
eprintln!("\x1b[35m \u{1f916} {agent_name}\x1b[0m");
}
EngineEvent::ActionBlocked {
detail, preview, ..
} => {
eprintln!("\x1b[33m \u{1f50d} Would execute: {detail}\x1b[0m");
if let Some(ref p) = preview {
let diff_lines = crate::diff_render::render_lines(p);
for line in &diff_lines {
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
eprintln!(" {text}");
}
}
}
EngineEvent::Info { message } => eprintln!("\x1b[36m {message}\x1b[0m"),
EngineEvent::Warn { message } => eprintln!("\x1b[33m \u{26a0} {message}\x1b[0m"),
EngineEvent::Error { message } => eprintln!("\x1b[31m \u{2717} {message}\x1b[0m"),
EngineEvent::ResponseStart => {}
EngineEvent::SpinnerStart { .. } => {}
EngineEvent::SpinnerStop => {}
EngineEvent::StatusUpdate { .. } => {}
EngineEvent::ContextUsage { .. } => {}
EngineEvent::TurnStart { .. } => {}
EngineEvent::TurnEnd { .. } => {}
EngineEvent::Footer {
completion_tokens,
total_chars,
elapsed_ms,
rate,
..
} => {
let tokens = if completion_tokens > 0 {
completion_tokens
} else {
(total_chars / 4) as i64
};
let secs = elapsed_ms as f64 / 1000.0;
eprintln!(
"\x1b[90m {tokens} tokens \u{00b7} {secs:.1}s \u{00b7} {rate:.0} t/s\x1b[0m"
);
}
}
}
}