use serde::{Deserialize, Serialize};
use std::io::Write;
use std::path::PathBuf;
use std::sync::Mutex;
use crate::agent::progress::{ProgressEmitter, ProgressEvent};
use crate::config::ExecutionMode;
use crate::observability::dashboard::TokenUsage;
pub fn headless_mode_block_reason(mode: ExecutionMode, stdin_is_terminal: bool) -> Option<String> {
if mode == ExecutionMode::Normal && !stdin_is_terminal {
Some(
"headless run in `--mode normal` cannot confirm tool executions \
(stdin is not a terminal): every write tool would be refused and the \
agent would spend tokens until MAX_ITERATIONS without making progress. \
Re-run with `-m yolo` or `-m auto-edit`, e.g. `selfware -m yolo -p \"...\"`."
.to_string(),
)
} else {
None
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionResult {
pub session_id: String,
pub exit_status: i32,
pub stop_reason: String,
pub num_turns: usize,
pub patch_bytes: usize,
pub patch_lines: usize,
pub usage: TokenUsage,
pub model: String,
pub duration_ms: u64,
pub failure_mode: Option<String>,
pub artifact_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize)]
pub struct HeadlessEvent {
pub event: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub step: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub args: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ok: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub outcome: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl HeadlessEvent {
pub fn step_started(step: usize, model: String) -> Self {
Self {
event: "step_started",
step: Some(step),
model: Some(model),
tool: None,
args: None,
ok: None,
outcome: None,
reason: None,
}
}
pub fn tool_call_started(tool: String, args: String) -> Self {
Self {
event: "tool_call_started",
step: None,
model: None,
tool: Some(tool),
args: Some(args),
ok: None,
outcome: None,
reason: None,
}
}
pub fn tool_call_completed(tool: String, ok: bool) -> Self {
Self {
event: "tool_call_completed",
step: None,
model: None,
tool: Some(tool),
args: None,
ok: Some(ok),
outcome: None,
reason: None,
}
}
pub fn step_completed(step: usize) -> Self {
Self {
event: "step_completed",
step: Some(step),
model: None,
tool: None,
args: None,
ok: None,
outcome: None,
reason: None,
}
}
pub fn task_completed(outcome: String) -> Self {
Self {
event: "task_completed",
step: None,
model: None,
tool: None,
args: None,
ok: None,
outcome: Some(outcome),
reason: None,
}
}
pub fn task_failed(reason: String) -> Self {
Self {
event: "task_failed",
step: None,
model: None,
tool: None,
args: None,
ok: None,
outcome: None,
reason: Some(reason),
}
}
pub fn turn_decision(decision: String, detail: String) -> Self {
Self {
event: "turn_decision",
step: None,
model: None,
tool: None,
args: None,
ok: None,
outcome: if detail.is_empty() {
None
} else {
Some(detail)
},
reason: Some(decision),
}
}
}
#[allow(dead_code)]
pub fn emit_event(event: &HeadlessEvent) {
if let Ok(json) = serde_json::to_string(event) {
use std::io::Write;
let stdout = std::io::stdout();
let mut lock = stdout.lock();
let _ = writeln!(lock, "{}", json);
}
}
pub fn emit_result(result: &SessionResult) {
if let Ok(json) = serde_json::to_string(result) {
use std::io::Write;
let stdout = std::io::stdout();
let mut lock = stdout.lock();
let _ = writeln!(lock, "{}", json);
}
}
fn head_exists() -> bool {
std::process::Command::new("git")
.args(["rev-parse", "--verify", "HEAD"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn capture_patch() -> anyhow::Result<String> {
let tmp_index_dir = tempfile::Builder::new()
.prefix("selfware-index-")
.tempdir()
.map_err(|e| anyhow::anyhow!("creating temp dir for git index: {}", e))?;
let tmp_index = tmp_index_dir.path().join("index");
let read_tree = std::process::Command::new("git")
.env("GIT_INDEX_FILE", &tmp_index)
.args(["read-tree", "HEAD"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
let seed_ok = matches!(read_tree, Ok(ref s) if s.success());
if !seed_ok && head_exists() {
anyhow::bail!("git read-tree HEAD failed while seeding the patch index");
}
let add_status = std::process::Command::new("git")
.env("GIT_INDEX_FILE", &tmp_index)
.args(["add", "-A"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
if !matches!(add_status, Ok(ref s) if s.success()) {
anyhow::bail!("git add -A failed while staging the patch");
}
let out = std::process::Command::new("git")
.env("GIT_INDEX_FILE", &tmp_index)
.args([
"diff",
"--cached",
"HEAD",
"--",
".",
":(exclude).selfware/**",
":(exclude).claude/**",
":(exclude)__pycache__/**",
":(exclude)**/__pycache__/**",
":(exclude)selfware.toml",
":(exclude)*.bak",
":(exclude)**/*.bak",
])
.output()
.map_err(|e| anyhow::anyhow!("running git diff: {}", e))?;
drop(tmp_index_dir);
if !out.status.success() {
anyhow::bail!(
"git diff failed (status={:?}): {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
}
String::from_utf8(out.stdout).map_err(|e| anyhow::anyhow!("non-UTF8 patch: {}", e))
}
pub struct JsonlProgressEmitter {
lock: Mutex<()>,
}
impl JsonlProgressEmitter {
pub fn new() -> Self {
Self {
lock: Mutex::new(()),
}
}
}
impl Default for JsonlProgressEmitter {
fn default() -> Self {
Self::new()
}
}
impl JsonlProgressEmitter {
fn event_json_line(event: ProgressEvent) -> Option<String> {
match event {
ProgressEvent::StepStarted { step, model, .. } => {
serde_json::to_string(&HeadlessEvent::step_started(step, model)).ok()
}
ProgressEvent::ToolCallStarted { tool, args_short } => {
serde_json::to_string(&HeadlessEvent::tool_call_started(tool, args_short)).ok()
}
ProgressEvent::ToolCallCompleted { tool, ok, .. } => {
serde_json::to_string(&HeadlessEvent::tool_call_completed(tool, ok)).ok()
}
ProgressEvent::StepCompleted { step, .. } => {
serde_json::to_string(&HeadlessEvent::step_completed(step)).ok()
}
ProgressEvent::TaskCompleted { outcome } => {
serde_json::to_string(&HeadlessEvent::task_completed(outcome)).ok()
}
ProgressEvent::TaskFailed { reason } => {
serde_json::to_string(&HeadlessEvent::task_failed(reason)).ok()
}
ProgressEvent::TurnDecision { decision, detail } => {
serde_json::to_string(&HeadlessEvent::turn_decision(decision, detail)).ok()
}
ProgressEvent::LlmRequestSent { tokens } => Some(
serde_json::json!({
"event": "llm_request_sent",
"prompt_tokens": tokens,
})
.to_string(),
),
ProgressEvent::LlmResponseReceived {
finish_reason,
completion_tokens,
} => Some(
serde_json::json!({
"event": "llm_response_received",
"finish_reason": finish_reason,
"completion_tokens": completion_tokens,
})
.to_string(),
),
_ => None,
}
}
}
impl ProgressEmitter for JsonlProgressEmitter {
fn emit(&self, event: ProgressEvent) {
if let Some(line) = Self::event_json_line(event) {
let _guard = self.lock.lock().unwrap_or_else(|e| e.into_inner());
let mut stdout = std::io::stdout().lock();
let _ = writeln!(stdout, "{}", line);
let _ = stdout.flush();
}
}
}
#[cfg(test)]
#[path = "../../tests/unit/cli/headless/headless_test.rs"]
mod tests;