2
0
Files
shell/src/headless.rs

1249 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(&current_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(&current_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 &current { 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()
}