use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use anyhow::Result;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum AgentStatus {
Running,
Complete,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentRecord {
pub session_id: String,
pub doc_title: String,
pub doc_path: PathBuf,
pub action: String,
pub status: AgentStatus,
pub started_at: String,
pub finished_at: Option<String>,
}
pub fn agent_history_dir(override_path: Option<&Path>) -> PathBuf {
let dir = match override_path {
Some(p) => p.to_path_buf(),
None => dirs_home().join(".lazyspec").join("agents"),
};
let _ = fs::create_dir_all(&dir);
dir
}
fn dirs_home() -> PathBuf {
std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
}
pub fn save_record(record: &AgentRecord, override_path: Option<&Path>) -> Result<()> {
let dir = agent_history_dir(override_path);
let file_path = dir.join(format!("{}.json", record.session_id));
let json = serde_json::to_string_pretty(record)?;
fs::write(file_path, json)?;
Ok(())
}
pub fn load_all_records(override_path: Option<&Path>) -> Result<Vec<AgentRecord>> {
let dir = agent_history_dir(override_path);
let mut records = Vec::new();
let entries = match fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => return Ok(records),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let content = fs::read_to_string(&path)?;
match serde_json::from_str::<AgentRecord>(&content) {
Ok(record) => records.push(record),
Err(_) => continue,
}
}
records.sort_by(|a, b| b.started_at.cmp(&a.started_at));
Ok(records)
}
pub fn update_record_status(
session_id: &str,
status: AgentStatus,
override_path: Option<&Path>,
) -> Result<()> {
let dir = agent_history_dir(override_path);
let file_path = dir.join(format!("{session_id}.json"));
let content = fs::read_to_string(&file_path)?;
let mut record: AgentRecord = serde_json::from_str(&content)?;
record.status = status;
if record.status == AgentStatus::Complete || record.status == AgentStatus::Failed {
record.finished_at = Some(chrono::Utc::now().to_rfc3339());
}
let json = serde_json::to_string_pretty(&record)?;
fs::write(file_path, json)?;
Ok(())
}
pub fn build_create_children_prompt(doc_content: &str, child_type: &str) -> String {
format!(
"You are a specification document generator. Given the parent document below, generate \
child documents of type \"{child_type}\" that break down the parent into actionable pieces. \
For each child document, run `lazyspec create {child_type}` with an appropriate title \
and fill in the generated file with relevant content derived from the parent. \
Preserve traceability by including a relation back to the parent document.\n\n\
---\n\n{doc_content}"
)
}
pub fn build_expand_prompt(doc_content: &str, doc_path: &Path) -> String {
format!(
"You are editing the specification document at `{}`. Your task is to flesh out and expand any sparse \
or incomplete sections while preserving the YAML frontmatter exactly as-is. Do not remove \
or reorder existing content. Focus on adding detail, clarifying intent, and filling gaps. \
Use the Edit tool to modify the file in place. Do not output the document as text.\n\n---\n\n{}",
doc_path.display(),
doc_content
)
}
pub struct AgentSpawner {
running: Vec<(String, Child)>,
pub records: Vec<AgentRecord>,
}
impl Default for AgentSpawner {
fn default() -> Self {
Self::new()
}
}
impl AgentSpawner {
pub fn new() -> Self {
let records = load_all_records(None).unwrap_or_default();
AgentSpawner {
running: Vec::new(),
records,
}
}
pub fn spawn(
&mut self,
prompt: &str,
doc_path: &Path,
doc_title: &str,
action: &str,
) -> Result<()> {
let session_id = uuid::Uuid::new_v4().to_string();
let child = Command::new("claude")
.args(["-p", prompt])
.args(["--session-id", &session_id])
.args(["--allowedTools", "Read,Edit,Write,Bash(lazyspec *)"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
let record = AgentRecord {
session_id: session_id.clone(),
doc_title: doc_title.to_string(),
doc_path: doc_path.to_path_buf(),
action: action.to_string(),
status: AgentStatus::Running,
started_at: chrono::Utc::now().to_rfc3339(),
finished_at: None,
};
let _ = save_record(&record, None);
self.records.push(record);
self.running.push((session_id, child));
Ok(())
}
pub fn poll_finished(&mut self) {
let mut finished = Vec::new();
self.running
.retain_mut(|(session_id, child)| match child.try_wait() {
Ok(Some(exit_status)) => {
let status = if exit_status.success() {
AgentStatus::Complete
} else {
AgentStatus::Failed
};
finished.push((session_id.clone(), status));
false
}
Ok(None) => true,
Err(_) => {
finished.push((session_id.clone(), AgentStatus::Failed));
false
}
});
let now = chrono::Utc::now().to_rfc3339();
for (session_id, status) in finished {
let _ = update_record_status(&session_id, status.clone(), None);
if let Some(rec) = self.records.iter_mut().find(|r| r.session_id == session_id) {
rec.status = status;
rec.finished_at = Some(now.clone());
}
}
}
pub fn active_count(&self) -> usize {
self.running.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sample_record(session_id: &str, started_at: &str) -> AgentRecord {
AgentRecord {
session_id: session_id.to_string(),
doc_title: "Test Doc".to_string(),
doc_path: PathBuf::from("/tmp/test.md"),
action: "Expand document".to_string(),
status: AgentStatus::Running,
started_at: started_at.to_string(),
finished_at: None,
}
}
#[test]
fn agent_record_roundtrip_serialize() {
let record = sample_record("abc-123", "2026-03-09T10:00:00Z");
let json = serde_json::to_string(&record).unwrap();
let deserialized: AgentRecord = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.session_id, "abc-123");
assert_eq!(deserialized.status, AgentStatus::Running);
assert_eq!(deserialized.finished_at, None);
}
#[test]
fn agent_record_save_and_load() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let r1 = sample_record("id-1", "2026-03-09T10:00:00Z");
let r2 = sample_record("id-2", "2026-03-09T11:00:00Z");
save_record(&r1, Some(dir)).unwrap();
save_record(&r2, Some(dir)).unwrap();
let loaded = load_all_records(Some(dir)).unwrap();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].session_id, "id-2");
assert_eq!(loaded[1].session_id, "id-1");
}
#[test]
fn agent_record_update_status() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let record = sample_record("id-update", "2026-03-09T10:00:00Z");
save_record(&record, Some(dir)).unwrap();
update_record_status("id-update", AgentStatus::Complete, Some(dir)).unwrap();
let loaded = load_all_records(Some(dir)).unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].status, AgentStatus::Complete);
assert!(loaded[0].finished_at.is_some());
}
#[test]
fn agent_record_load_empty_dir() {
let tmp = TempDir::new().unwrap();
let records = load_all_records(Some(tmp.path())).unwrap();
assert!(records.is_empty());
}
}