1249 lines
44 KiB
Rust
1249 lines
44 KiB
Rust
use std::io::Write;
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
use std::sync::Arc;
|
||
use std::time::Duration;
|
||
use crate::agent::Agent;
|
||
use crate::ai::{ClaudeManager, OutputEvent};
|
||
use crate::config;
|
||
|
||
const STATE_DIR: &str = "/tmp/aishell";
|
||
|
||
pub fn run(config_or_task: &str, cwd_override: Option<&str>, name_override: Option<&str>) -> Result<(), String> {
|
||
let _ = std::fs::remove_dir_all(STATE_DIR);
|
||
create_state_dir();
|
||
|
||
let args: Vec<String> = std::env::args().collect();
|
||
let loop_mode = args.iter().any(|a| a == "--loop");
|
||
let once = !loop_mode;
|
||
|
||
let is_multi = config_or_task.ends_with(".json") || std::path::Path::new(config_or_task).is_dir();
|
||
let (configs, interval) = if is_multi {
|
||
let loaded = config::load_full(config_or_task);
|
||
(loaded.agents, loaded.interval)
|
||
} else {
|
||
let cwd = cwd_override
|
||
.map(|s| s.to_string())
|
||
.unwrap_or_else(|| std::env::current_dir()
|
||
.map(|p| p.display().to_string())
|
||
.unwrap_or_else(|_| ".".to_string()));
|
||
let name = name_override.unwrap_or("task").to_string();
|
||
(vec![config::AgentConfig { name, task: config_or_task.to_string(), cwd }], None)
|
||
};
|
||
|
||
if configs.is_empty() {
|
||
return Err(format!("no agents found in {config_or_task}"));
|
||
}
|
||
|
||
if !is_multi {
|
||
return run_once(&configs);
|
||
}
|
||
|
||
if once {
|
||
return execute_once(configs);
|
||
}
|
||
|
||
let running = Arc::new(AtomicBool::new(true));
|
||
setup_ctrlc(running.clone());
|
||
|
||
let mut cycle = 1;
|
||
let mut saved_sessions: Vec<String> = Vec::new();
|
||
let mut current_configs = configs;
|
||
let mut prev_decision = String::new();
|
||
|
||
loop {
|
||
eprintln!("\n── cycle {} ──", cycle);
|
||
|
||
let agents = spawn_and_wait(¤t_configs, &running);
|
||
if agents.is_empty() { break; }
|
||
write_state(&agents);
|
||
|
||
// AI integration: skip if only 1 agent and no previous decision
|
||
let skip_ai = agents.len() <= 1 && prev_decision.is_empty();
|
||
|
||
atomic_write(
|
||
&format!("{STATE_DIR}/loop.json"),
|
||
b"{\"ready\":false}",
|
||
);
|
||
|
||
let decision = if skip_ai {
|
||
eprintln!("\n (AI integration skipped: single agent)");
|
||
agents.first().map(|a| a.output.clone()).unwrap_or_default()
|
||
} else {
|
||
eprintln!("\n ◐ AI integrating...");
|
||
match integrate_results(&agents, &prev_decision, cycle) {
|
||
Ok(d) => {
|
||
let data = serde_json::json!({
|
||
"cycle": cycle,
|
||
"agents": agents.iter().map(|a| a.to_ai_json()).collect::<Vec<_>>(),
|
||
"decision": &d,
|
||
});
|
||
atomic_write(
|
||
&format!("{STATE_DIR}/decision.json"),
|
||
serde_json::to_string_pretty(&data).unwrap_or_default().as_bytes(),
|
||
);
|
||
eprintln!(" ✓ Done\n");
|
||
println!("{d}");
|
||
d
|
||
}
|
||
Err(e) => { eprintln!(" ✗ {e}"); String::new() }
|
||
}
|
||
};
|
||
|
||
// Auto-save immediately after AI integration (before waiting)
|
||
let all_indices: Vec<usize> = (0..agents.len()).collect();
|
||
let path = save_session(cycle, &agents, &decision, &all_indices);
|
||
eprintln!(" saved: {path}");
|
||
saved_sessions.push(path);
|
||
|
||
// Extract @agent commands from AI decision → next cycle's config
|
||
let next_configs = extract_agent_configs(&decision);
|
||
let has_next = !next_configs.is_empty();
|
||
|
||
if has_next {
|
||
eprintln!(" AI proposed {} agent(s) for next cycle:", next_configs.len());
|
||
for c in &next_configs {
|
||
eprintln!(" @{} {} ({})", c.name, c.task, c.cwd);
|
||
}
|
||
}
|
||
|
||
// Pause: interval-based auto or manual signal
|
||
if let Some(secs) = interval {
|
||
eprintln!(" next cycle in {secs}s... (aishell stop to quit)");
|
||
write_loop_waiting(&agents, has_next);
|
||
for _ in 0..secs * 2 {
|
||
if !running.load(Ordering::Relaxed) { break; }
|
||
// Check for early stop signal
|
||
if let Ok(content) = std::fs::read_to_string(format!("{STATE_DIR}/loop.json")) {
|
||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&content) {
|
||
if v["quit"].as_bool() == Some(true) { break; }
|
||
}
|
||
}
|
||
std::thread::sleep(Duration::from_millis(500));
|
||
}
|
||
} else {
|
||
write_loop_waiting(&agents, has_next);
|
||
eprintln!(" waiting... (aishell next / aishell stop)");
|
||
let action = wait_for_loop_signal(&running);
|
||
reset_loop_control();
|
||
match action { LoopAction::Next => {} LoopAction::Quit => break }
|
||
}
|
||
|
||
if !running.load(Ordering::Relaxed) { break; }
|
||
|
||
// Prepare next cycle
|
||
prev_decision = decision;
|
||
if has_next {
|
||
current_configs = next_configs;
|
||
}
|
||
// If AI didn't propose new agents, reuse current config
|
||
cycle += 1;
|
||
}
|
||
|
||
if !saved_sessions.is_empty() {
|
||
eprintln!("\nsessions saved:");
|
||
for s in &saved_sessions { eprintln!(" {s}"); }
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
pub fn run_preset(preset_name: &str) -> Result<(), String> {
|
||
let configs = config::preset(preset_name)
|
||
.ok_or_else(|| format!("unknown preset: {preset_name}"))?;
|
||
let once = !std::env::args().any(|a| a == "--loop");
|
||
if once {
|
||
execute_once(configs)
|
||
} else {
|
||
run_with_configs(configs)
|
||
}
|
||
}
|
||
|
||
/// Single-cycle execution: agents → AI integration → save → exit.
|
||
fn execute_once(configs: Vec<config::AgentConfig>) -> Result<(), String> {
|
||
let _ = std::fs::remove_dir_all(STATE_DIR);
|
||
create_state_dir();
|
||
let running = Arc::new(AtomicBool::new(true));
|
||
setup_ctrlc(running.clone());
|
||
let agents = spawn_and_wait(&configs, &running);
|
||
if agents.is_empty() { return Ok(()); }
|
||
write_state(&agents);
|
||
|
||
let decision = if agents.len() > 1 {
|
||
eprintln!("\n ◐ AI integrating...");
|
||
match integrate_results(&agents, "", 1) {
|
||
Ok(d) => {
|
||
let data = serde_json::json!({
|
||
"cycle": 1,
|
||
"agents": agents.iter().map(|a| a.to_ai_json()).collect::<Vec<_>>(),
|
||
"decision": &d,
|
||
});
|
||
atomic_write(
|
||
&format!("{STATE_DIR}/decision.json"),
|
||
serde_json::to_string_pretty(&data).unwrap_or_default().as_bytes(),
|
||
);
|
||
eprintln!(" ✓ Done\n");
|
||
println!("{d}");
|
||
d
|
||
}
|
||
Err(e) => { eprintln!(" ✗ {e}"); String::new() }
|
||
}
|
||
} else {
|
||
let out = strip_dir_listing(&agents[0].output).to_string();
|
||
println!("{out}");
|
||
out
|
||
};
|
||
|
||
let all: Vec<usize> = (0..agents.len()).collect();
|
||
let path = save_session(1, &agents, &decision, &all);
|
||
eprintln!(" saved: {path}");
|
||
|
||
// Write done marker for background monitoring
|
||
atomic_write(&format!("{STATE_DIR}/done"), b"1");
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn run_with_configs(configs: Vec<config::AgentConfig>) -> Result<(), String> {
|
||
let _ = std::fs::remove_dir_all(STATE_DIR);
|
||
create_state_dir();
|
||
|
||
let running = Arc::new(AtomicBool::new(true));
|
||
setup_ctrlc(running.clone());
|
||
|
||
let mut cycle = 1;
|
||
let mut saved_sessions: Vec<String> = Vec::new();
|
||
let mut current_configs = configs;
|
||
let mut prev_decision = String::new();
|
||
|
||
loop {
|
||
eprintln!("\n── cycle {} ──", cycle);
|
||
let agents = spawn_and_wait(¤t_configs, &running);
|
||
if agents.is_empty() { break; }
|
||
write_state(&agents);
|
||
|
||
let skip_ai = agents.len() <= 1 && prev_decision.is_empty();
|
||
atomic_write(&format!("{STATE_DIR}/loop.json"), b"{\"ready\":false}");
|
||
|
||
let decision = if skip_ai {
|
||
eprintln!("\n (AI integration skipped: single agent)");
|
||
agents.first().map(|a| a.output.clone()).unwrap_or_default()
|
||
} else {
|
||
eprintln!("\n ◐ AI integrating...");
|
||
match integrate_results(&agents, &prev_decision, cycle) {
|
||
Ok(d) => {
|
||
let data = serde_json::json!({
|
||
"cycle": cycle,
|
||
"agents": agents.iter().map(|a| a.to_ai_json()).collect::<Vec<_>>(),
|
||
"decision": &d,
|
||
});
|
||
atomic_write(
|
||
&format!("{STATE_DIR}/decision.json"),
|
||
serde_json::to_string_pretty(&data).unwrap_or_default().as_bytes(),
|
||
);
|
||
eprintln!(" ✓ Done\n");
|
||
println!("{d}");
|
||
d
|
||
}
|
||
Err(e) => { eprintln!(" ✗ {e}"); String::new() }
|
||
}
|
||
};
|
||
|
||
let all_indices: Vec<usize> = (0..agents.len()).collect();
|
||
let path = save_session(cycle, &agents, &decision, &all_indices);
|
||
eprintln!(" saved: {path}");
|
||
saved_sessions.push(path);
|
||
|
||
let next_configs = extract_agent_configs(&decision);
|
||
let has_next = !next_configs.is_empty();
|
||
if has_next {
|
||
eprintln!(" AI proposed {} agent(s) for next cycle", next_configs.len());
|
||
}
|
||
|
||
write_loop_waiting(&agents, has_next);
|
||
eprintln!(" waiting...");
|
||
|
||
let action = wait_for_loop_signal(&running);
|
||
reset_loop_control();
|
||
|
||
match action { LoopAction::Next => {} LoopAction::Quit => break }
|
||
if !running.load(Ordering::Relaxed) { break; }
|
||
|
||
prev_decision = decision;
|
||
if has_next { current_configs = next_configs; }
|
||
cycle += 1;
|
||
}
|
||
|
||
if !saved_sessions.is_empty() {
|
||
eprintln!("\nsessions saved:");
|
||
for s in &saved_sessions { eprintln!(" {s}"); }
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn run_once(configs: &[config::AgentConfig]) -> Result<(), String> {
|
||
let running = Arc::new(AtomicBool::new(true));
|
||
setup_ctrlc(running.clone());
|
||
let agents = spawn_and_wait(configs, &running);
|
||
if agents.is_empty() { return Ok(()); }
|
||
write_state(&agents);
|
||
|
||
let output = &agents[0].output;
|
||
let clean = strip_dir_listing(output);
|
||
println!("{clean}");
|
||
|
||
// If the agent proposed @agents, chain them
|
||
let next = extract_agent_configs(clean);
|
||
if !next.is_empty() {
|
||
eprintln!("\n AI proposed {} agent(s):", next.len());
|
||
for c in &next { eprintln!(" @{} {}", c.name, c.task); }
|
||
|
||
let _ = std::fs::remove_dir_all(STATE_DIR);
|
||
create_state_dir();
|
||
return run_with_configs(next);
|
||
}
|
||
|
||
atomic_write(&format!("{STATE_DIR}/done"), b"1");
|
||
Ok(())
|
||
}
|
||
|
||
/// Remove directory listing that Claude CLI prepends to output.
|
||
fn strip_dir_listing(text: &str) -> &str {
|
||
// Pattern: lines of single words (filenames) at the start, possibly starting with "."
|
||
let mut end = 0;
|
||
for line in text.lines() {
|
||
let trimmed = line.trim();
|
||
if trimmed.is_empty() { end += line.len() + 1; continue; }
|
||
// If line looks like a filename (no spaces, short)
|
||
if !trimmed.contains(' ') && trimmed.len() < 80 {
|
||
end += line.len() + 1;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
text.get(end..).unwrap_or(text).trim_start_matches('\n')
|
||
}
|
||
|
||
fn spawn_and_wait(configs: &[config::AgentConfig], running: &Arc<AtomicBool>) -> Vec<Agent> {
|
||
let mut agents = Vec::new();
|
||
let mut next_id = 1;
|
||
for cfg in configs {
|
||
let cwd = expand_tilde(&cfg.cwd);
|
||
match Agent::spawn(next_id, &cfg.name, &cfg.task, &cwd) {
|
||
Ok(agent) => {
|
||
eprintln!(" started: {} ({})", cfg.name, cwd);
|
||
agents.push(agent);
|
||
next_id += 1;
|
||
}
|
||
Err(e) => eprintln!(" failed: {}: {e}", cfg.name),
|
||
}
|
||
}
|
||
|
||
let mut last_status: Vec<String> = Vec::new();
|
||
while running.load(Ordering::Relaxed) {
|
||
let mut any_running = false;
|
||
for agent in &mut agents {
|
||
agent.poll();
|
||
if agent.is_running() { any_running = true; }
|
||
}
|
||
|
||
if agents.iter().any(|a| a.dirty) {
|
||
write_state(&agents);
|
||
for a in &mut agents { a.dirty = false; }
|
||
}
|
||
|
||
let current: Vec<String> = agents.iter().map(|a| {
|
||
let icon = match a.conclusion() {
|
||
"success" => "✓", "error" | "stopped" => "✗", _ => "●",
|
||
};
|
||
format!(" {icon} {:16} {:6} {}", a.name, a.elapsed(), a.status)
|
||
}).collect();
|
||
if current != last_status {
|
||
for line in ¤t { eprintln!("{line}"); }
|
||
last_status = current;
|
||
}
|
||
|
||
if !any_running { break; }
|
||
std::thread::sleep(Duration::from_millis(200));
|
||
}
|
||
agents
|
||
}
|
||
|
||
// ── File-based loop control ────────────────────────────────
|
||
|
||
enum LoopAction {
|
||
Next,
|
||
Quit,
|
||
}
|
||
|
||
/// Write loop.json control file. External tools edit this to control the loop.
|
||
/// Extract @agent-name task -c cwd from AI decision text.
|
||
fn extract_agent_configs(text: &str) -> Vec<config::AgentConfig> {
|
||
text.lines()
|
||
.filter(|line| line.starts_with('@') && line.len() > 1)
|
||
.filter_map(|line| {
|
||
let rest = line[1..].trim();
|
||
let mut parts = rest.splitn(2, char::is_whitespace);
|
||
let name = parts.next()?.to_string();
|
||
let remainder = parts.next().unwrap_or("").to_string();
|
||
|
||
let (task, cwd) = if let Some(pos) = remainder.find(" -c ") {
|
||
(remainder[..pos].to_string(), remainder[pos + 4..].trim().to_string())
|
||
} else {
|
||
(remainder, std::env::current_dir()
|
||
.map(|p| p.display().to_string())
|
||
.unwrap_or_else(|_| ".".to_string()))
|
||
};
|
||
|
||
if name.is_empty() || task.is_empty() { return None; }
|
||
Some(config::AgentConfig { name, task, cwd })
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Write loop.json: paused state with agent list for external control.
|
||
fn write_loop_waiting(agents: &[Agent], has_next: bool) {
|
||
let agents_list: Vec<_> = agents.iter().enumerate().map(|(i, a)| {
|
||
serde_json::json!({
|
||
"id": i,
|
||
"name": a.name,
|
||
"conclusion": a.conclusion(),
|
||
"summary": a.summary(),
|
||
})
|
||
}).collect();
|
||
|
||
let control = serde_json::json!({
|
||
"ready": true,
|
||
"next": false,
|
||
"quit": false,
|
||
"save": null,
|
||
"has_next_agents": has_next,
|
||
"agents": agents_list,
|
||
});
|
||
|
||
atomic_write(
|
||
&format!("{STATE_DIR}/loop.json"),
|
||
serde_json::to_string_pretty(&control).unwrap_or_default().as_bytes(),
|
||
);
|
||
}
|
||
|
||
/// Reset loop.json after resuming: next=false, save=null.
|
||
fn reset_loop_control() {
|
||
let path = format!("{STATE_DIR}/loop.json");
|
||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||
if let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&content) {
|
||
v["next"] = serde_json::json!(false);
|
||
v["save"] = serde_json::json!(null);
|
||
atomic_write(&path, serde_json::to_string_pretty(&v).unwrap_or_default().as_bytes());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Poll loop.json until `next: true` or `quit: true`.
|
||
/// Only reads signals when `ready: true` (set by write_loop_waiting).
|
||
fn wait_for_loop_signal(running: &Arc<AtomicBool>) -> LoopAction {
|
||
let path = format!("{STATE_DIR}/loop.json");
|
||
loop {
|
||
if !running.load(Ordering::Relaxed) {
|
||
return LoopAction::Quit;
|
||
}
|
||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&content) {
|
||
// Only process signals after ready=true was set
|
||
if v["ready"].as_bool() != Some(true) {
|
||
std::thread::sleep(Duration::from_millis(500));
|
||
continue;
|
||
}
|
||
if v["quit"].as_bool() == Some(true) {
|
||
return LoopAction::Quit;
|
||
}
|
||
if v["next"].as_bool() == Some(true) {
|
||
return LoopAction::Next;
|
||
}
|
||
}
|
||
}
|
||
std::thread::sleep(Duration::from_millis(500));
|
||
}
|
||
}
|
||
|
||
// ── Session persistence ────────────────────────────────────
|
||
|
||
fn save_session(cycle: usize, agents: &[Agent], decision: &str, indices: &[usize]) -> String {
|
||
let session_dir = session_base_dir();
|
||
let _ = std::fs::create_dir_all(&session_dir);
|
||
|
||
let timestamp = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_secs())
|
||
.unwrap_or(0);
|
||
|
||
let filename = format!("{session_dir}/{timestamp}.json");
|
||
|
||
let saved_agents: Vec<_> = indices.iter()
|
||
.filter_map(|&i| agents.get(i).map(|a| a.to_json()))
|
||
.collect();
|
||
|
||
let session = serde_json::json!({
|
||
"cycle": cycle,
|
||
"timestamp": timestamp,
|
||
"agents": saved_agents,
|
||
"decision": decision,
|
||
});
|
||
|
||
let json = serde_json::to_string_pretty(&session).unwrap_or_default();
|
||
atomic_write(&filename, json.as_bytes());
|
||
|
||
// aigpt memory is NOT auto-saved. Use `aishell remember` for important decisions.
|
||
|
||
// Update unified command history
|
||
update_cmd_history(&saved_agents, timestamp);
|
||
|
||
filename
|
||
}
|
||
|
||
/// Write a compressed summary to aigpt memory.
|
||
fn save_to_aigpt_memory(decision: &str, agents: &[serde_json::Value]) {
|
||
let agent_names: Vec<&str> = agents.iter()
|
||
.filter_map(|a| a["name"].as_str())
|
||
.collect();
|
||
|
||
// Build a concise memory entry
|
||
let summary = format!(
|
||
"[aishell session] agents: {}\n{}",
|
||
agent_names.join(", "),
|
||
if decision.len() > 500 {
|
||
format!("{}...", &decision.chars().take(500).collect::<String>())
|
||
} else {
|
||
decision.to_string()
|
||
}
|
||
);
|
||
|
||
// Write to aigpt memory directory
|
||
if let Some(memory_dir) = aigpt_memory_dir() {
|
||
let _ = std::fs::create_dir_all(&memory_dir);
|
||
let timestamp = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_secs())
|
||
.unwrap_or(0);
|
||
|
||
let record = serde_json::json!({
|
||
"uri": format!("at://did/ai.syui.gpt.memory/{timestamp}"),
|
||
"value": {
|
||
"$type": "ai.syui.gpt.memory",
|
||
"content": {
|
||
"$type": "ai.syui.gpt.memory#markdown",
|
||
"text": summary
|
||
},
|
||
"createdAt": format!("{}Z", chrono_now())
|
||
}
|
||
});
|
||
|
||
let path = format!("{memory_dir}/{timestamp}.json");
|
||
let json = serde_json::to_string_pretty(&record).unwrap_or_default();
|
||
atomic_write(&path, json.as_bytes());
|
||
}
|
||
}
|
||
|
||
fn aigpt_memory_dir() -> Option<String> {
|
||
let home = std::env::var("HOME").ok()?;
|
||
let config_path = if cfg!(target_os = "macos") {
|
||
format!("{home}/Library/Application Support/ai.syui.gpt/config.json")
|
||
} else {
|
||
format!("{home}/.config/ai.syui.gpt/config.json")
|
||
};
|
||
|
||
let content = std::fs::read_to_string(&config_path).ok()?;
|
||
let config: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||
|
||
let path = config["bot"]["path"].as_str()?;
|
||
let did = config["bot"]["did"].as_str()?;
|
||
let expanded = expand_tilde(path);
|
||
Some(format!("{expanded}/{did}/ai.syui.gpt.memory"))
|
||
}
|
||
|
||
fn chrono_now() -> String {
|
||
// Simple ISO 8601 without chrono crate
|
||
let secs = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_secs())
|
||
.unwrap_or(0);
|
||
// Days since epoch → date, remainder → time
|
||
let days = secs / 86400;
|
||
let rem = secs % 86400;
|
||
let h = rem / 3600;
|
||
let m = (rem % 3600) / 60;
|
||
let s = rem % 60;
|
||
// Approximate date from days since 1970-01-01 (good enough for memory records)
|
||
let (y, mo, d) = days_to_ymd(days);
|
||
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}")
|
||
}
|
||
|
||
fn days_to_ymd(mut days: u64) -> (u64, u64, u64) {
|
||
let mut y = 1970;
|
||
loop {
|
||
let dy = if y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) { 366 } else { 365 };
|
||
if days < dy { break; }
|
||
days -= dy;
|
||
y += 1;
|
||
}
|
||
let leap = y % 4 == 0 && (y % 100 != 0 || y % 400 == 0);
|
||
let mdays = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||
let mut mo = 0;
|
||
for &md in &mdays {
|
||
if days < md { break; }
|
||
days -= md;
|
||
mo += 1;
|
||
}
|
||
(y, mo + 1, days + 1)
|
||
}
|
||
|
||
fn session_base_dir() -> String {
|
||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||
if cfg!(target_os = "macos") {
|
||
format!("{home}/Library/Application Support/ai.syui.gpt/sessions")
|
||
} else {
|
||
format!("{home}/.local/share/aishell/sessions")
|
||
}
|
||
}
|
||
|
||
// ── AI integration ─────────────────────────────────────────
|
||
|
||
fn integrate_results(agents: &[Agent], prev_decision: &str, cycle: usize) -> Result<String, String> {
|
||
let mut claude = ClaudeManager::spawn()?;
|
||
let data: Vec<_> = agents.iter().map(|a| a.to_ai_json()).collect();
|
||
let payload = serde_json::to_string_pretty(
|
||
&serde_json::json!({ "event": "all_done", "cycle": cycle, "agents": data })
|
||
).unwrap_or_default();
|
||
|
||
let prev_context = if prev_decision.is_empty() {
|
||
String::new()
|
||
} else {
|
||
format!("前回のサイクルでの判断:\n{}\n\n---\n\n", prev_decision)
|
||
};
|
||
|
||
claude.send(&format!(
|
||
"{prev_context}以下はcycle {cycle}のエージェント実行結果です。\n\n```json\n{payload}\n```\n\n\
|
||
結果を統合し回答してください:\n\
|
||
1. 全体の結論(1行)\n\
|
||
2. 各エージェントの重要な発見(箇条書き)\n\
|
||
3. 次に取るべきアクション\n\n\
|
||
次のサイクルでエージェントを実行する場合は、以下の形式で指示してください:\n\
|
||
@agent-name タスク内容 -c 作業ディレクトリ\n\
|
||
不要なら@agentは書かないでください。"
|
||
));
|
||
|
||
let mut output = String::new();
|
||
let start = std::time::Instant::now();
|
||
loop {
|
||
if start.elapsed() > Duration::from_secs(60) { break; }
|
||
match claude.try_recv() {
|
||
Some(OutputEvent::StreamStart) => { output.clear(); }
|
||
Some(OutputEvent::StreamChunk(text)) => { output.push_str(&text); }
|
||
Some(OutputEvent::StreamEnd) => break,
|
||
None => {}
|
||
}
|
||
std::thread::sleep(Duration::from_millis(50));
|
||
}
|
||
if output.is_empty() { Err("timeout".into()) } else { Ok(output) }
|
||
}
|
||
|
||
// ── Status/Log/Decision commands ───────────────────────────
|
||
|
||
pub fn status(verbose: bool) {
|
||
let path = format!("{STATE_DIR}/agents.json");
|
||
match std::fs::read_to_string(&path) {
|
||
Ok(content) => {
|
||
if let Ok(agents) = serde_json::from_str::<Vec<serde_json::Value>>(&content) {
|
||
for a in &agents {
|
||
let id = a["id"].as_u64().unwrap_or(0);
|
||
let name = a["name"].as_str().unwrap_or("?");
|
||
let c = a["conclusion"].as_str().unwrap_or("?");
|
||
let elapsed = a["elapsed"].as_str().unwrap_or("");
|
||
let task = a["task"].as_str().unwrap_or("?");
|
||
let icon = match c {
|
||
"success" => "✓", "error" | "stopped" => "✗",
|
||
"running" => "●", _ => "○",
|
||
};
|
||
println!(" {icon} {id} {name:16} {c:10} {elapsed:>6} {task}");
|
||
if verbose {
|
||
if let Ok(d) = std::fs::read_to_string(format!("{STATE_DIR}/{id}.json")) {
|
||
if let Ok(d) = serde_json::from_str::<serde_json::Value>(&d) {
|
||
let s = d["summary"].as_str().unwrap_or("");
|
||
if !s.is_empty() { println!(" {s}"); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Err(_) => println!("No session."),
|
||
}
|
||
}
|
||
|
||
pub fn log(id_or_name: &str) {
|
||
let path = if id_or_name.chars().all(|c| c.is_ascii_digit()) {
|
||
format!("{STATE_DIR}/{id_or_name}.json")
|
||
} else {
|
||
find_agent_by_name(id_or_name).unwrap_or_default()
|
||
};
|
||
match std::fs::read_to_string(&path) {
|
||
Ok(content) => {
|
||
if let Ok(a) = serde_json::from_str::<serde_json::Value>(&content) {
|
||
let name = a["name"].as_str().unwrap_or("?");
|
||
let c = a["conclusion"].as_str().unwrap_or("?");
|
||
let elapsed = a["elapsed"].as_str().unwrap_or("");
|
||
let task = a["task"].as_str().unwrap_or("");
|
||
let result = a["result"].as_str().unwrap_or("(no output)");
|
||
eprintln!("{name} [{c}] {elapsed}");
|
||
eprintln!("task: {task}\n");
|
||
println!("{result}");
|
||
}
|
||
}
|
||
Err(_) => eprintln!("Agent '{id_or_name}' not found."),
|
||
}
|
||
}
|
||
|
||
pub fn decision() {
|
||
let path = format!("{STATE_DIR}/decision.json");
|
||
match std::fs::read_to_string(&path) {
|
||
Ok(content) => {
|
||
if let Ok(d) = serde_json::from_str::<serde_json::Value>(&content) {
|
||
println!("{}", d["decision"].as_str().unwrap_or("No decision."));
|
||
}
|
||
}
|
||
Err(_) => println!("No decision."),
|
||
}
|
||
}
|
||
|
||
/// Run review preset, get commit message, and commit.
|
||
pub fn commit() {
|
||
use std::process::Command;
|
||
|
||
// Check if there are changes
|
||
let status = Command::new("git").args(["status", "--short"]).output();
|
||
match status {
|
||
Ok(o) if o.stdout.is_empty() => {
|
||
eprintln!("nothing to commit");
|
||
return;
|
||
}
|
||
Err(e) => { eprintln!("git error: {e}"); return; }
|
||
_ => {}
|
||
}
|
||
|
||
// Run commit-msg agent
|
||
eprintln!(" generating commit message...");
|
||
let cwd = std::env::current_dir()
|
||
.map(|p| p.display().to_string())
|
||
.unwrap_or_else(|_| ".".to_string());
|
||
let configs = vec![config::AgentConfig {
|
||
name: "commit-msg".into(),
|
||
task: "Suggest a commit message for the current git changes. Output ONLY the message, nothing else. Conventional commits format. One line.".into(),
|
||
cwd,
|
||
}];
|
||
|
||
let running = Arc::new(AtomicBool::new(true));
|
||
setup_ctrlc(running.clone());
|
||
let agents = spawn_and_wait(&configs, &running);
|
||
if agents.is_empty() { return; }
|
||
|
||
let msg = strip_dir_listing(&agents[0].output).trim().to_string();
|
||
let msg = msg.trim_matches('`').trim().to_string();
|
||
if msg.is_empty() {
|
||
eprintln!(" failed to generate message");
|
||
return;
|
||
}
|
||
|
||
eprintln!(" message: {msg}");
|
||
eprintln!(" committing...");
|
||
|
||
let _ = Command::new("git").args(["add", "-A"]).status();
|
||
match Command::new("git").args(["commit", "-m", &msg]).status() {
|
||
Ok(s) if s.success() => eprintln!(" ✓ committed"),
|
||
Ok(s) => eprintln!(" ✗ git commit failed: {}", s.code().unwrap_or(-1)),
|
||
Err(e) => eprintln!(" ✗ {e}"),
|
||
}
|
||
}
|
||
|
||
/// Preview agent configuration without executing.
|
||
pub fn plan(preset: Option<&str>, config_path: Option<&str>) {
|
||
let configs = if let Some(name) = preset {
|
||
config::preset(name).unwrap_or_default()
|
||
} else if let Some(path) = config_path {
|
||
config::load(path)
|
||
} else {
|
||
eprintln!("usage: aishell plan -p <preset> or aishell plan -f <config>");
|
||
return;
|
||
};
|
||
|
||
if configs.is_empty() {
|
||
println!("(no agents)");
|
||
return;
|
||
}
|
||
|
||
println!("{} agent(s):", configs.len());
|
||
for (i, c) in configs.iter().enumerate() {
|
||
let last = i == configs.len() - 1;
|
||
let prefix = if last { "└─" } else { "├─" };
|
||
let task_short: String = c.task.chars().take(50).collect();
|
||
println!(" {prefix} {:16} {task_short}", c.name);
|
||
}
|
||
}
|
||
|
||
/// Save the latest decision to aigpt memory (explicit, not automatic).
|
||
pub fn remember() {
|
||
let dec_path = format!("{STATE_DIR}/decision.json");
|
||
match std::fs::read_to_string(&dec_path) {
|
||
Ok(content) => {
|
||
if let Ok(d) = serde_json::from_str::<serde_json::Value>(&content) {
|
||
let decision = d["decision"].as_str().unwrap_or("");
|
||
let agents = d["agents"].as_array()
|
||
.map(|a| a.to_vec())
|
||
.unwrap_or_default();
|
||
if decision.is_empty() {
|
||
eprintln!("No decision to remember.");
|
||
return;
|
||
}
|
||
save_to_aigpt_memory(decision, &agents);
|
||
eprintln!("saved to aigpt memory");
|
||
}
|
||
}
|
||
Err(_) => eprintln!("No decision. Run a cycle first."),
|
||
}
|
||
}
|
||
|
||
/// Wait for background run to complete, then show summary.
|
||
pub fn wait_done() {
|
||
let done_path = format!("{STATE_DIR}/done");
|
||
eprint!(" waiting...");
|
||
loop {
|
||
if std::path::Path::new(&done_path).exists() {
|
||
eprintln!(" done");
|
||
let _ = std::fs::remove_file(&done_path);
|
||
// Show compact summary
|
||
status(false);
|
||
if let Ok(content) = std::fs::read_to_string(format!("{STATE_DIR}/decision.json")) {
|
||
if let Ok(d) = serde_json::from_str::<serde_json::Value>(&content) {
|
||
let text = d["decision"].as_str().unwrap_or("");
|
||
let first: String = text.lines()
|
||
.filter(|l| !l.starts_with('#') && !l.is_empty())
|
||
.take(3).collect::<Vec<_>>().join("\n");
|
||
if !first.is_empty() { println!("\n{first}"); }
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||
eprint!(".");
|
||
}
|
||
}
|
||
|
||
pub fn signal_next(save: Vec<usize>) {
|
||
let save_val = if save.is_empty() {
|
||
serde_json::json!(null)
|
||
} else {
|
||
serde_json::json!(save)
|
||
};
|
||
let control = serde_json::json!({"ready": true, "next": true, "quit": false, "save": save_val});
|
||
atomic_write(
|
||
&format!("{STATE_DIR}/loop.json"),
|
||
serde_json::to_string_pretty(&control).unwrap_or_default().as_bytes(),
|
||
);
|
||
eprintln!("signaled: next (save: {:?})", save);
|
||
}
|
||
|
||
pub fn signal_quit() {
|
||
let control = serde_json::json!({"ready": true, "quit": true});
|
||
atomic_write(
|
||
&format!("{STATE_DIR}/loop.json"),
|
||
serde_json::to_string_pretty(&control).unwrap_or_default().as_bytes(),
|
||
);
|
||
eprintln!("signaled: quit");
|
||
}
|
||
|
||
/// Update cmd_history.json: deduplicated command log across all agents.
|
||
fn update_cmd_history(agents: &[serde_json::Value], session_ts: u64) {
|
||
let history_path = format!("{}/cmd_history.json", session_base_dir());
|
||
|
||
// Load existing
|
||
let mut entries: Vec<serde_json::Value> = std::fs::read_to_string(&history_path)
|
||
.ok()
|
||
.and_then(|s| serde_json::from_str(&s).ok())
|
||
.unwrap_or_default();
|
||
|
||
let now = chrono_now();
|
||
|
||
for agent in agents {
|
||
let agent_name = agent["name"].as_str().unwrap_or("?");
|
||
let process = match agent["process"].as_array() {
|
||
Some(p) => p,
|
||
None => continue,
|
||
};
|
||
|
||
// Pair tool invocations with their results
|
||
let mut i = 0;
|
||
while i < process.len() {
|
||
let entry = &process[i];
|
||
let kind = entry["type"].as_str().unwrap_or("");
|
||
|
||
if kind != "tool" { i += 1; continue; }
|
||
|
||
let detail = entry["detail"].as_str().unwrap_or("");
|
||
if detail.is_empty() { i += 1; continue; }
|
||
|
||
let (tool, command) = match detail.split_once(": ") {
|
||
Some((t, c)) if t == "Bash" => (t, c),
|
||
_ => { i += 1; continue; }
|
||
};
|
||
|
||
// Check next entry for result — default ok=true, only false on explicit error
|
||
let ok = if i + 1 < process.len() {
|
||
let next = &process[i + 1];
|
||
let nk = next["type"].as_str().unwrap_or("");
|
||
if nk == "tool_result" {
|
||
let rd = next["detail"].as_str().unwrap_or("");
|
||
!(rd.starts_with("Error") || rd.starts_with("error:") || rd.starts_with("FAILED") || rd.contains("exit code"))
|
||
} else { true }
|
||
} else { true };
|
||
|
||
let existing = entries.iter_mut().find(|e| {
|
||
e["tool"].as_str() == Some(tool) && e["command"].as_str() == Some(command)
|
||
});
|
||
|
||
if let Some(e) = existing {
|
||
e["updated_at"] = serde_json::json!(now);
|
||
e["agent"] = serde_json::json!(agent_name);
|
||
e["session"] = serde_json::json!(session_ts);
|
||
e["ok"] = serde_json::json!(ok);
|
||
} else {
|
||
entries.push(serde_json::json!({
|
||
"tool": tool,
|
||
"command": command,
|
||
"agent": agent_name,
|
||
"session": session_ts,
|
||
"updated_at": now,
|
||
"ok": ok,
|
||
}));
|
||
}
|
||
|
||
i += 1;
|
||
}
|
||
}
|
||
|
||
let json = serde_json::to_string_pretty(&entries).unwrap_or_default();
|
||
atomic_write(&history_path, json.as_bytes());
|
||
}
|
||
|
||
pub fn history(detail: Option<&str>) {
|
||
let dir = session_base_dir();
|
||
let mut files: Vec<_> = std::fs::read_dir(&dir)
|
||
.into_iter().flatten().flatten()
|
||
.filter(|e| {
|
||
let name = e.file_name();
|
||
let s = name.to_string_lossy();
|
||
s.ends_with(".json") && s != "cmd_history.json"
|
||
})
|
||
.collect();
|
||
files.sort_by_key(|e| e.file_name());
|
||
|
||
if files.is_empty() {
|
||
println!("No sessions.");
|
||
return;
|
||
}
|
||
|
||
if let Some(id) = detail {
|
||
// Show detail of a specific session
|
||
let target = format!("{id}.json");
|
||
let path = files.iter()
|
||
.find(|f| f.file_name().to_string_lossy().contains(id))
|
||
.map(|f| f.path())
|
||
.unwrap_or_else(|| std::path::PathBuf::from(format!("{dir}/{target}")));
|
||
|
||
match std::fs::read_to_string(&path) {
|
||
Ok(content) => {
|
||
if let Ok(d) = serde_json::from_str::<serde_json::Value>(&content) {
|
||
let cycle = d["cycle"].as_u64().unwrap_or(0);
|
||
let agents = d["agents"].as_array();
|
||
let decision = d["decision"].as_str().unwrap_or("");
|
||
|
||
println!("cycle {cycle}");
|
||
if let Some(agents) = agents {
|
||
for a in agents {
|
||
let name = a["name"].as_str().unwrap_or("?");
|
||
let c = a["conclusion"].as_str().unwrap_or("?");
|
||
println!(" {name} [{c}]");
|
||
}
|
||
}
|
||
if !decision.is_empty() {
|
||
println!("\n{decision}");
|
||
}
|
||
}
|
||
}
|
||
Err(_) => eprintln!("Session '{id}' not found."),
|
||
}
|
||
return;
|
||
}
|
||
|
||
// List all sessions
|
||
for f in &files {
|
||
let path = f.path();
|
||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||
if let Ok(d) = serde_json::from_str::<serde_json::Value>(&content) {
|
||
let ts = path.file_stem()
|
||
.and_then(|s| s.to_str())
|
||
.unwrap_or("?");
|
||
let cycle = d["cycle"].as_u64().unwrap_or(0);
|
||
let agents = d["agents"].as_array().map(|a| a.len()).unwrap_or(0);
|
||
let decision = d["decision"].as_str().unwrap_or("");
|
||
let summary: String = decision.lines().next().unwrap_or("").chars().take(60).collect();
|
||
println!(" {ts} cycle:{cycle} agents:{agents} {summary}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn context() {
|
||
let run = |cmd: &str, args: &[&str]| -> String {
|
||
std::process::Command::new(cmd).args(args)
|
||
.output().ok()
|
||
.filter(|o| o.status.success())
|
||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||
.unwrap_or_default()
|
||
};
|
||
|
||
// Git
|
||
let branch = run("git", &["branch", "--show-current"]);
|
||
let git_status = run("git", &["status", "--short"]);
|
||
let git_log = run("git", &["log", "--oneline", "-5"]);
|
||
let git_diff = run("git", &["diff", "--stat"]);
|
||
|
||
// Session info
|
||
let dec_path = format!("{STATE_DIR}/decision.json");
|
||
let session_dir = session_base_dir();
|
||
let session_count = std::fs::read_dir(&session_dir)
|
||
.into_iter().flatten().flatten()
|
||
.filter(|e| e.path().extension().is_some_and(|x| x == "json") && e.file_name().to_string_lossy() != "cmd_history.json")
|
||
.count();
|
||
let cycle = std::fs::read_to_string(&dec_path).ok()
|
||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||
.and_then(|d| d["cycle"].as_u64())
|
||
.unwrap_or(0);
|
||
|
||
println!("[session]");
|
||
println!("sessions: {session_count} last_cycle: {cycle}");
|
||
|
||
println!("\n[git]");
|
||
println!("branch: {branch}");
|
||
if git_status.is_empty() {
|
||
println!("status: clean");
|
||
} else {
|
||
println!("status:\n{git_status}");
|
||
}
|
||
if !git_diff.is_empty() {
|
||
println!("diff stat:\n{git_diff}");
|
||
let git_diff_content = run("git", &["diff", "HEAD"]);
|
||
if !git_diff_content.is_empty() {
|
||
let truncated: String = git_diff_content.lines().take(30)
|
||
.collect::<Vec<_>>().join("\n");
|
||
let total = git_diff_content.lines().count();
|
||
println!("diff content:\n{truncated}");
|
||
if total > 30 {
|
||
println!("... ({} more lines)", total - 30);
|
||
}
|
||
}
|
||
}
|
||
println!("recent:\n{git_log}");
|
||
|
||
// Agents
|
||
println!("\n[agents]");
|
||
let agent_path = format!("{STATE_DIR}/agents.json");
|
||
if let Ok(content) = std::fs::read_to_string(&agent_path) {
|
||
if let Ok(agents) = serde_json::from_str::<Vec<serde_json::Value>>(&content) {
|
||
if agents.is_empty() {
|
||
println!("(none)");
|
||
} else {
|
||
for a in &agents {
|
||
let name = a["name"].as_str().unwrap_or("?");
|
||
let c = a["conclusion"].as_str().unwrap_or("?");
|
||
let summary = a["summary"].as_str().unwrap_or("");
|
||
println!(" {name} [{c}] {summary}");
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
println!("(no session)");
|
||
}
|
||
|
||
// Last decision
|
||
println!("\n[decision]");
|
||
let dec_path = format!("{STATE_DIR}/decision.json");
|
||
if let Ok(content) = std::fs::read_to_string(&dec_path) {
|
||
if let Ok(d) = serde_json::from_str::<serde_json::Value>(&content) {
|
||
let text = d["decision"].as_str().unwrap_or("");
|
||
// Skip markdown headers, show first substantive lines
|
||
let first_lines: String = text.lines()
|
||
.filter(|l| !l.starts_with('#') && !l.is_empty())
|
||
.take(3)
|
||
.collect::<Vec<_>>().join("\n");
|
||
if first_lines.is_empty() {
|
||
println!("(none)");
|
||
} else {
|
||
println!("{first_lines}");
|
||
}
|
||
}
|
||
} else {
|
||
println!("(none)");
|
||
}
|
||
|
||
// Command history (last 10)
|
||
let history_path = format!("{}/cmd_history.json", session_base_dir());
|
||
if let Ok(content) = std::fs::read_to_string(&history_path) {
|
||
if let Ok(entries) = serde_json::from_str::<Vec<serde_json::Value>>(&content) {
|
||
if !entries.is_empty() {
|
||
println!("\n[commands]");
|
||
for e in entries.iter().rev().take(10) {
|
||
let tool = e["tool"].as_str().unwrap_or("?");
|
||
let cmd = e["command"].as_str().unwrap_or("");
|
||
let ok = if e["ok"].as_bool() == Some(true) { "✓" } else { "✗" };
|
||
println!(" {ok} {tool}: {cmd}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Clean old sessions, keep last N.
|
||
pub fn history_clean() {
|
||
let dir = session_base_dir();
|
||
let mut files: Vec<_> = std::fs::read_dir(&dir)
|
||
.into_iter().flatten().flatten()
|
||
.filter(|e| {
|
||
let s = e.file_name().to_string_lossy().to_string();
|
||
s.ends_with(".json") && s != "cmd_history.json"
|
||
})
|
||
.collect();
|
||
files.sort_by_key(|e| e.file_name());
|
||
|
||
let keep = 10;
|
||
if files.len() <= keep {
|
||
println!("nothing to clean ({} sessions)", files.len());
|
||
return;
|
||
}
|
||
let remove = files.len() - keep;
|
||
for f in files.iter().take(remove) {
|
||
let _ = std::fs::remove_file(f.path());
|
||
}
|
||
println!("removed {} old sessions, kept {}", remove, keep);
|
||
}
|
||
|
||
pub fn history_cmd(filter: Option<&str>) {
|
||
show_cmd_history(filter);
|
||
}
|
||
|
||
fn show_cmd_history(filter: Option<&str>) {
|
||
let path = format!("{}/cmd_history.json", session_base_dir());
|
||
match std::fs::read_to_string(&path) {
|
||
Ok(content) => {
|
||
if let Ok(entries) = serde_json::from_str::<Vec<serde_json::Value>>(&content) {
|
||
if entries.is_empty() { println!("No commands."); return; }
|
||
for e in &entries {
|
||
let cmd = e["command"].as_str().unwrap_or("");
|
||
// Apply filter
|
||
if let Some(f) = filter {
|
||
if !cmd.contains(f) { continue; }
|
||
}
|
||
let agent = e["agent"].as_str().unwrap_or("?");
|
||
let updated = e["updated_at"].as_str().unwrap_or("");
|
||
let ok = if e["ok"].as_bool() == Some(true) { "✓" } else { "✗" };
|
||
println!(" {ok} {agent:16} {updated} {cmd}");
|
||
}
|
||
}
|
||
}
|
||
Err(_) => println!("No command history."),
|
||
}
|
||
}
|
||
|
||
fn find_agent_by_name(name: &str) -> Option<String> {
|
||
let content = std::fs::read_to_string(format!("{STATE_DIR}/agents.json")).ok()?;
|
||
let agents: Vec<serde_json::Value> = serde_json::from_str(&content).ok()?;
|
||
agents.iter()
|
||
.find(|a| a["name"].as_str() == Some(name))
|
||
.and_then(|a| a["id"].as_u64())
|
||
.map(|id| format!("{STATE_DIR}/{id}.json"))
|
||
}
|
||
|
||
// ── Infra ──────────────────────────────────────────────────
|
||
|
||
fn setup_ctrlc(running: Arc<AtomicBool>) {
|
||
static RUNNING: AtomicBool = AtomicBool::new(true);
|
||
RUNNING.store(true, Ordering::SeqCst);
|
||
unsafe {
|
||
libc::signal(libc::SIGINT, handle_sigint as *const () as libc::sighandler_t);
|
||
}
|
||
std::thread::spawn(move || {
|
||
while running.load(Ordering::Relaxed) {
|
||
if !RUNNING.load(Ordering::SeqCst) {
|
||
running.store(false, Ordering::Relaxed);
|
||
break;
|
||
}
|
||
std::thread::sleep(Duration::from_millis(100));
|
||
}
|
||
});
|
||
extern "C" fn handle_sigint(_: libc::c_int) {
|
||
RUNNING.store(false, Ordering::SeqCst);
|
||
}
|
||
}
|
||
|
||
fn write_state(agents: &[Agent]) {
|
||
let summary: Vec<_> = agents.iter().map(|a| a.to_summary_json()).collect();
|
||
atomic_write(
|
||
&format!("{STATE_DIR}/agents.json"),
|
||
serde_json::to_string_pretty(&serde_json::Value::Array(summary)).unwrap_or_default().as_bytes(),
|
||
);
|
||
for a in agents {
|
||
atomic_write(
|
||
&format!("{STATE_DIR}/{}.json", a.id),
|
||
serde_json::to_string_pretty(&a.to_json()).unwrap_or_default().as_bytes(),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Atomic write: write to temp file then rename.
|
||
fn atomic_write(path: &str, content: &[u8]) {
|
||
let tmp = format!("{path}.tmp");
|
||
#[cfg(unix)] {
|
||
use std::os::unix::fs::OpenOptionsExt;
|
||
let ok = std::fs::OpenOptions::new()
|
||
.write(true).create(true).truncate(true).mode(0o600)
|
||
.open(&tmp)
|
||
.and_then(|mut f| f.write_all(content));
|
||
if ok.is_ok() {
|
||
let _ = std::fs::rename(&tmp, path);
|
||
}
|
||
}
|
||
#[cfg(not(unix))] {
|
||
if std::fs::write(&tmp, content).is_ok() {
|
||
let _ = std::fs::rename(&tmp, path);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn create_state_dir() {
|
||
#[cfg(unix)] {
|
||
use std::os::unix::fs::DirBuilderExt;
|
||
let _ = std::fs::DirBuilder::new().mode(0o700).recursive(true).create(STATE_DIR);
|
||
}
|
||
#[cfg(not(unix))] {
|
||
let _ = std::fs::create_dir_all(STATE_DIR);
|
||
}
|
||
}
|
||
|
||
pub fn expand_tilde_pub(path: &str) -> String { expand_tilde(path) }
|
||
|
||
fn expand_tilde(path: &str) -> String {
|
||
if let Some(rest) = path.strip_prefix('~') {
|
||
if let Ok(home) = std::env::var("HOME") {
|
||
return format!("{home}{rest}");
|
||
}
|
||
}
|
||
path.to_string()
|
||
}
|