Skip to main content

aegis_tools/
tools.rs

1use crate::registry::{Tool, ToolContext};
2use aegis_security::{check_command, check_path, sanitize_credentials, DangerLevel};
3use anyhow::Result;
4use async_trait::async_trait;
5use serde_json::{json, Value};
6use std::process::Stdio;
7use std::sync::Arc;
8
9// ═══════════════════════════════════════════
10// terminal
11// ═══════════════════════════════════════════
12
13/// SIGKILL an entire process group (the negative pid targets the group). Used to
14/// stop a cancelled/timed-out command **and all its children**, not just `sh`.
15/// No-op on non-unix.
16fn kill_process_group(pid: Option<u32>) {
17    #[cfg(unix)]
18    if let Some(p) = pid {
19        // SAFETY: a plain kill(2) syscall; -p addresses the process group led by p.
20        unsafe {
21            libc::kill(-(p as i32), libc::SIGKILL);
22        }
23    }
24    #[cfg(not(unix))]
25    let _ = pid;
26}
27
28/// RAII guard that kills the command's process group if dropped while still
29/// armed — i.e. when the surrounding future is cancelled. Disarm after the
30/// command finishes (or after an explicit kill) so a recycled pgid is not hit.
31struct ChildKillGuard {
32    pid: Option<u32>,
33}
34impl ChildKillGuard {
35    fn disarm(&mut self) {
36        self.pid = None;
37    }
38}
39impl Drop for ChildKillGuard {
40    fn drop(&mut self) {
41        kill_process_group(self.pid);
42    }
43}
44
45/// Commands that can destroy/overwrite working-dir state and thus warrant a
46/// pre-command rollback snapshot. (`rm` is already covered by the trash shim.)
47fn is_risky_for_snapshot(cmd: &str) -> bool {
48    let l = cmd.to_lowercase();
49    const PATTERNS: &[&str] = &[
50        "mv ",
51        "dd ",
52        "truncate",
53        "mkfs",
54        "shred",
55        "git reset --hard",
56        "git clean",
57        "git checkout .",
58        "-delete",
59    ];
60    PATTERNS.iter().any(|p| l.contains(p))
61}
62
63pub struct TerminalTool;
64
65#[async_trait]
66impl Tool for TerminalTool {
67    fn name(&self) -> &str {
68        "terminal"
69    }
70    fn description(&self) -> &str {
71        "Execute a shell command and return stdout+stderr. Commands are security-checked."
72    }
73    fn parameters(&self) -> Value {
74        json!({
75            "type": "object",
76            "properties": {
77                "command": { "type": "string", "description": "Shell command to execute" },
78                "timeout": { "type": "integer", "description": "Timeout in seconds (default 300)" }
79            },
80            "required": ["command"]
81        })
82    }
83    async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
84        let cmd = args["command"].as_str().unwrap_or("");
85        let timeout = args["timeout"].as_u64().unwrap_or(300);
86
87        // Security check
88        match check_command(cmd) {
89            DangerLevel::Dangerous(reason) => {
90                if !ctx.approve(&format!("⚠️ DANGEROUS: {reason}\nCommand: {cmd}")) {
91                    return Ok(format!("Command blocked: {reason}. User denied."));
92                }
93            }
94            DangerLevel::Warn(reason) => {
95                if !ctx.approve(&format!("⚠ Warning: {reason}\nCommand: {cmd}")) {
96                    return Ok(format!("Command skipped: {reason}. User denied."));
97                }
98            }
99            DangerLevel::Safe => {}
100        }
101
102        // Pre-command rollback point: for risky commands, snapshot the working
103        // dir first (best-effort; the daemon/CLI sets AEGIS_SNAPSHOT + AEGIS_EXE).
104        if std::env::var("AEGIS_SNAPSHOT").is_ok() && is_risky_for_snapshot(cmd) {
105            if let Ok(exe) = std::env::var("AEGIS_EXE") {
106                let _ = std::process::Command::new(&exe)
107                    .arg("__snapshot-cwd")
108                    .arg(&ctx.session_id)
109                    .arg(cmd)
110                    .current_dir(&ctx.cwd)
111                    .status();
112            }
113        }
114
115        let mut command = tokio::process::Command::new("sh");
116        command
117            .arg("-c")
118            .arg(cmd)
119            .current_dir(&ctx.cwd)
120            .env("AEGIS_SESSION", &ctx.session_id)
121            .stdout(Stdio::piped())
122            .stderr(Stdio::piped())
123            .kill_on_drop(true);
124        // Put the command in its own process group so we can kill the WHOLE
125        // subtree (not just `sh`) the instant the turn is cancelled.
126        #[cfg(unix)]
127        command.process_group(0);
128
129        // Sandbox: derive per-identity policy and attach the landlock+seccomp
130        // +user_ns pre_exec hook. When `sandbox_enabled` is false (default),
131        // this whole block is a no-op — matching the "opt-in" promise so
132        // upgrading users see zero behavior change.
133        #[cfg(target_os = "linux")]
134        {
135            if ctx.sandbox_enabled {
136                let identity = ctx.effective_identity();
137                let policy = aegis_security::derive_sandbox_policy(&identity, "terminal", &ctx.cwd);
138                if policy.deny_all {
139                    return Ok(format!(
140                        "Command denied by sandbox policy: identity '{}' with trust level '{}' is not authorized to run shell commands.",
141                        identity.display(),
142                        identity.trust_level(),
143                    ));
144                }
145                let policy_for_hook = policy.clone();
146                // SAFETY: pre_exec runs after fork(2) before execve(2).
147                // `apply_policy_pre_exec` is documented async-signal-safe.
148                // Note: `tokio::process::Command::pre_exec` is an inherent
149                // method (not the CommandExt trait), so no `use` needed.
150                unsafe {
151                    command.pre_exec(move || {
152                        aegis_sandbox::apply_policy_pre_exec(&policy_for_hook)
153                    });
154                }
155                tracing::debug!(
156                    identity = %identity.display(),
157                    trust = %identity.trust_level(),
158                    "terminal: applying sandbox policy"
159                );
160            }
161        }
162
163        let child = command
164            .spawn()
165            .map_err(|e| anyhow::anyhow!("Failed to execute: {e}"))?;
166        // Armed kill-guard: if this future is dropped (user `/stop`/Ctrl+C
167        // cancels the turn — the agent drops `tool.execute`), the guard's Drop
168        // SIGKILLs the process group immediately, so the running command and its
169        // children stop now rather than after they finish.
170        let pid = child.id();
171        let mut guard = ChildKillGuard { pid };
172        let output = match tokio::time::timeout(
173            std::time::Duration::from_secs(timeout),
174            child.wait_with_output(),
175        )
176        .await
177        {
178            Ok(r) => {
179                guard.disarm(); // finished on its own — don't kill a recycled pgid
180                r.map_err(|e| anyhow::anyhow!("Failed to execute: {e}"))?
181            }
182            Err(_) => {
183                // Timed out: kill the whole group now, then report.
184                kill_process_group(pid);
185                guard.disarm();
186                return Err(anyhow::anyhow!("Command timed out after {timeout}s"));
187            }
188        };
189
190        let stdout = String::from_utf8_lossy(&output.stdout);
191        let stderr = String::from_utf8_lossy(&output.stderr);
192        let code = output.status.code().unwrap_or(-1);
193
194        let mut result = String::new();
195        if !stdout.is_empty() {
196            result.push_str(&stdout);
197        }
198        if !stderr.is_empty() {
199            if !result.is_empty() {
200                result.push('\n');
201            }
202            result.push_str("[stderr]\n");
203            result.push_str(&stderr);
204        }
205        if code != 0 {
206            result.push_str(&format!("\n[exit code: {code}]"));
207        }
208
209        // Smart compaction for large terminal output: keep head + tail lines
210        // so errors (usually at the end) are always visible.
211        if result.len() > 50_000 {
212            let lines: Vec<&str> = result.lines().collect();
213            if lines.len() > 100 {
214                let head: String = lines[..20].join("\n");
215                let tail: String = lines[lines.len() - 80..].join("\n");
216                let omitted = lines.len() - 100;
217                result = format!("{head}\n\n... [{omitted} lines omitted] ...\n\n{tail}");
218            } else {
219                result.truncate(result.floor_char_boundary(50_000));
220                result.push_str("\n... [output truncated at 50KB]");
221            }
222        }
223
224        Ok(sanitize_credentials(&result))
225    }
226}
227
228// ═══════════════════════════════════════════
229// read_file
230// ═══════════════════════════════════════════
231
232pub struct ReadFileTool;
233
234#[async_trait]
235impl Tool for ReadFileTool {
236    fn name(&self) -> &str {
237        "read_file"
238    }
239    fn description(&self) -> &str {
240        "Read file contents with line numbers. Supports offset/limit for large files."
241    }
242    fn parameters(&self) -> Value {
243        json!({
244            "type": "object",
245            "properties": {
246                "path": { "type": "string", "description": "File path (relative to CWD)" },
247                "offset": { "type": "integer", "description": "Start line (0-based, default 0)" },
248                "limit": { "type": "integer", "description": "Max lines to read (default 2000)" }
249            },
250            "required": ["path"]
251        })
252    }
253    async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
254        let path_str = args["path"].as_str().unwrap_or("");
255        let offset = args["offset"].as_u64().unwrap_or(0) as usize;
256        let limit = args["limit"].as_u64().unwrap_or(2000) as usize;
257
258        let safe_path = check_path(path_str, &ctx.cwd)?;
259
260        if !safe_path.exists() {
261            // Fuzzy match suggestion
262            if let Some(parent) = safe_path.parent() {
263                if parent.is_dir() {
264                    let fname = safe_path.file_name().unwrap_or_default().to_string_lossy();
265                    let suggestions: Vec<String> = std::fs::read_dir(parent)?
266                        .filter_map(|e| e.ok())
267                        .map(|e| e.file_name().to_string_lossy().to_string())
268                        .filter(|n| strsim::jaro_winkler(n, &fname) > 0.7)
269                        .take(3)
270                        .collect();
271                    if !suggestions.is_empty() {
272                        return Ok(format!(
273                            "File not found: {path_str}\nDid you mean: {}?",
274                            suggestions.join(", ")
275                        ));
276                    }
277                }
278            }
279            anyhow::bail!("File not found: {path_str}");
280        }
281
282        // Binary detection: read first 512 bytes
283        let preview = std::fs::read(safe_path.as_path())?;
284        if preview.len() >= 512 && preview[..512].iter().filter(|&&b| b == 0).count() > 4 {
285            // Check if it's a supported multimodal file type
286            let ext = safe_path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
287            match ext.as_str() {
288                "png" | "jpg" | "jpeg" | "gif" | "webp" => {
289                    let data = base64::Engine::encode(
290                        &base64::engine::general_purpose::STANDARD,
291                        &preview,
292                    );
293                    let mime = match ext.as_str() {
294                        "png" => "image/png",
295                        "jpg" | "jpeg" => "image/jpeg",
296                        "gif" => "image/gif",
297                        "webp" => "image/webp",
298                        _ => "image/png",
299                    };
300                    return Ok(format!("[IMAGE:base64:{mime}:{data}]"));
301                }
302                "pdf" => {
303                    let data = base64::Engine::encode(
304                        &base64::engine::general_purpose::STANDARD,
305                        &preview,
306                    );
307                    return Ok(format!("[DOCUMENT:base64:application/pdf:{data}]"));
308                }
309                _ => {
310                    return Ok(format!(
311                        "Binary file ({} bytes): {path_str}. Supported formats for viewing: png, jpg, gif, webp, pdf.",
312                        preview.len()
313                    ));
314                }
315            }
316        }
317
318        let content = tokio::fs::read_to_string(&safe_path)
319            .await
320            .map_err(|e| anyhow::anyhow!("Cannot read {path_str}: {e}"))?;
321
322        let lines: Vec<&str> = content.lines().collect();
323        let total = lines.len();
324        let end = (offset + limit).min(total);
325        let slice = &lines[offset.min(total)..end];
326
327        let width = format!("{}", end).len();
328        let numbered: String = slice
329            .iter()
330            .enumerate()
331            .map(|(i, line)| format!("{:>width$} │ {}\n", offset + i + 1, line, width = width))
332            .collect();
333
334        let mut result = numbered;
335        if end < total {
336            result.push_str(&format!(
337                "\n... ({} more lines, use offset={} to continue)",
338                total - end,
339                end
340            ));
341        }
342        Ok(result)
343    }
344}
345
346// ═══════════════════════════════════════════
347// write_file
348// ═══════════════════════════════════════════
349
350pub struct WriteFileTool;
351
352#[async_trait]
353impl Tool for WriteFileTool {
354    fn name(&self) -> &str {
355        "write_file"
356    }
357    fn description(&self) -> &str {
358        "Write content to a file. Creates parent directories automatically."
359    }
360    fn parameters(&self) -> Value {
361        json!({
362            "type": "object",
363            "properties": {
364                "path": { "type": "string", "description": "File path" },
365                "content": { "type": "string", "description": "File content to write" }
366            },
367            "required": ["path", "content"]
368        })
369    }
370    async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
371        let path_str = args["path"].as_str().unwrap_or("");
372        let content = args["content"].as_str().unwrap_or("");
373
374        let safe_path = check_path(path_str, &ctx.cwd)?;
375
376        // Warn on credential files
377        let fname = safe_path.file_name().unwrap_or_default().to_string_lossy();
378        let sensitive = [".env", "id_rsa", "id_ed25519", ".pem", ".key"];
379        if sensitive.iter().any(|s| fname.contains(s))
380            && !ctx.approve(&format!("Writing to sensitive file: {path_str}"))
381        {
382            return Ok("Write cancelled: sensitive file.".to_string());
383        }
384
385        if let Some(parent) = safe_path.parent() {
386            tokio::fs::create_dir_all(parent).await?;
387        }
388
389        // Checkpoint: backup before overwrite
390        if safe_path.exists() {
391            let _ = crate::tools::CheckpointManager::backup(&safe_path);
392        }
393
394        tokio::fs::write(&safe_path, content).await?;
395        let lines = content.lines().count();
396        Ok(format!("Wrote {} lines to {path_str}", lines))
397    }
398}
399
400// ═══════════════════════════════════════════
401// patch (find-and-replace)
402// ═══════════════════════════════════════════
403
404pub struct PatchTool;
405
406#[async_trait]
407impl Tool for PatchTool {
408    fn name(&self) -> &str {
409        "patch"
410    }
411    fn description(&self) -> &str {
412        "Replace text in a file. Finds old_string and replaces with new_string."
413    }
414    fn parameters(&self) -> Value {
415        json!({
416            "type": "object",
417            "properties": {
418                "path": { "type": "string", "description": "File path" },
419                "old_string": { "type": "string", "description": "Text to find" },
420                "new_string": { "type": "string", "description": "Replacement text" }
421            },
422            "required": ["path", "old_string", "new_string"]
423        })
424    }
425    async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
426        let path_str = args["path"].as_str().unwrap_or("");
427        let old = args["old_string"].as_str().unwrap_or("");
428        let new = args["new_string"].as_str().unwrap_or("");
429
430        let safe_path = check_path(path_str, &ctx.cwd)?;
431        let content = tokio::fs::read_to_string(&safe_path)
432            .await
433            .map_err(|e| anyhow::anyhow!("Cannot read {path_str}: {e}"))?;
434
435        // Exact match first
436        if content.contains(old) {
437            let _ = CheckpointManager::backup(&safe_path);
438            let updated = content.replacen(old, new, 1);
439            tokio::fs::write(&safe_path, &updated).await?;
440            return Ok(format!("Patched {path_str}: replaced 1 occurrence."));
441        }
442
443        // Fuzzy: try ignoring leading whitespace per line
444        let old_trimmed: Vec<&str> = old.lines().map(|l| l.trim()).collect();
445        let content_lines: Vec<&str> = content.lines().collect();
446
447        if !old_trimmed.is_empty() {
448            for start in 0..content_lines.len() {
449                let end = start + old_trimmed.len();
450                if end > content_lines.len() {
451                    break;
452                }
453                let window: Vec<&str> =
454                    content_lines[start..end].iter().map(|l| l.trim()).collect();
455                if window == old_trimmed {
456                    let mut result_lines: Vec<String> = content_lines[..start]
457                        .iter()
458                        .map(|s| s.to_string())
459                        .collect();
460                    for line in new.lines() {
461                        result_lines.push(line.to_string());
462                    }
463                    result_lines.extend(content_lines[end..].iter().map(|s| s.to_string()));
464                    let updated = result_lines.join("\n");
465                    tokio::fs::write(&safe_path, &updated).await?;
466                    return Ok(format!(
467                        "Patched {path_str}: fuzzy match at line {} (whitespace-insensitive).",
468                        start + 1
469                    ));
470                }
471            }
472        }
473
474        Ok(format!(
475            "No match found in {path_str} for the given old_string."
476        ))
477    }
478}
479
480// ═══════════════════════════════════════════
481// search_files
482// ═══════════════════════════════════════════
483
484pub struct SearchFilesTool;
485
486#[async_trait]
487impl Tool for SearchFilesTool {
488    fn name(&self) -> &str {
489        "search_files"
490    }
491    fn description(&self) -> &str {
492        "Search for text patterns in files. Uses ripgrep if available, falls back to built-in regex."
493    }
494    fn parameters(&self) -> Value {
495        json!({
496            "type": "object",
497            "properties": {
498                "pattern": { "type": "string", "description": "Regex pattern to search for" },
499                "path": { "type": "string", "description": "Directory to search in (default: CWD)" },
500                "include": { "type": "string", "description": "File glob filter, e.g. '*.rs'" },
501                "limit": { "type": "integer", "description": "Max results (default 50)" }
502            },
503            "required": ["pattern"]
504        })
505    }
506    async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
507        let pattern = args["pattern"].as_str().unwrap_or("");
508        let search_path = args["path"].as_str().unwrap_or(".");
509        let include = args["include"].as_str();
510        let limit = args["limit"].as_u64().unwrap_or(50);
511
512        let dir = check_path(search_path, &ctx.cwd)?;
513
514        // Try ripgrep first
515        if which_rg() {
516            return rg_search(pattern, &dir, include, limit).await;
517        }
518
519        // Fallback: built-in regex walk
520        builtin_search(pattern, &dir, include, limit).await
521    }
522}
523
524fn which_rg() -> bool {
525    std::process::Command::new("rg")
526        .arg("--version")
527        .output()
528        .is_ok()
529}
530
531async fn rg_search(
532    pattern: &str,
533    dir: &std::path::Path,
534    include: Option<&str>,
535    limit: u64,
536) -> Result<String> {
537    let mut cmd = tokio::process::Command::new("rg");
538    cmd.arg("--line-number")
539        .arg("--no-heading")
540        .arg("--color=never")
541        .arg("--max-count")
542        .arg(limit.to_string());
543    if let Some(glob) = include {
544        cmd.arg("--glob").arg(glob);
545    }
546    cmd.arg(pattern)
547        .arg(dir)
548        .stdout(Stdio::piped())
549        .stderr(Stdio::piped());
550
551    let output = tokio::time::timeout(std::time::Duration::from_secs(30), cmd.output())
552        .await
553        .map_err(|_| anyhow::anyhow!("search timed out"))??;
554
555    let result = String::from_utf8_lossy(&output.stdout);
556    if result.is_empty() {
557        Ok("No matches found.".to_string())
558    } else {
559        Ok(truncate_output(&result, 50_000))
560    }
561}
562
563async fn builtin_search(
564    pattern: &str,
565    dir: &std::path::Path,
566    include: Option<&str>,
567    limit: u64,
568) -> Result<String> {
569    let re = regex::Regex::new(pattern).map_err(|e| anyhow::anyhow!("Invalid regex: {e}"))?;
570    let glob_re = include.and_then(|g| {
571        let g = g.replace('.', r"\.").replace('*', ".*").replace('?', ".");
572        regex::Regex::new(&format!("^{g}$")).ok()
573    });
574
575    let mut results = Vec::new();
576    let mut stack = vec![dir.to_path_buf()];
577
578    while let Some(d) = stack.pop() {
579        let entries = match std::fs::read_dir(&d) {
580            Ok(e) => e,
581            Err(_) => continue,
582        };
583        for entry in entries.flatten() {
584            let path = entry.path();
585            if path.is_dir() {
586                let name = path.file_name().unwrap_or_default().to_string_lossy();
587                if !name.starts_with('.') && name != "node_modules" && name != "target" {
588                    stack.push(path);
589                }
590            } else if path.is_file() {
591                if let Some(ref gre) = glob_re {
592                    let fname = path.file_name().unwrap_or_default().to_string_lossy();
593                    if !gre.is_match(&fname) {
594                        continue;
595                    }
596                }
597                if let Ok(content) = std::fs::read_to_string(&path) {
598                    for (i, line) in content.lines().enumerate() {
599                        if re.is_match(line) {
600                            let rel = path.strip_prefix(dir).unwrap_or(&path);
601                            results.push(format!("{}:{}:{}", rel.display(), i + 1, line));
602                            if results.len() as u64 >= limit {
603                                break;
604                            }
605                        }
606                    }
607                }
608                if results.len() as u64 >= limit {
609                    break;
610                }
611            }
612        }
613        if results.len() as u64 >= limit {
614            break;
615        }
616    }
617
618    if results.is_empty() {
619        Ok("No matches found.".to_string())
620    } else {
621        Ok(results.join("\n"))
622    }
623}
624
625fn truncate_output(s: &str, max: usize) -> String {
626    if s.len() <= max {
627        return s.to_string();
628    }
629    // Smart compaction: keep head + tail lines instead of a blunt byte cut.
630    // This preserves command output context (errors usually at the end).
631    let lines: Vec<&str> = s.lines().collect();
632    if lines.len() > 100 {
633        let head_n = 20;
634        let tail_n = 80;
635        let head: String = lines[..head_n].join("\n");
636        let tail: String = lines[lines.len() - tail_n..].join("\n");
637        let omitted = lines.len() - head_n - tail_n;
638        return format!("{head}\n\n... [{omitted} lines omitted] ...\n\n{tail}");
639    }
640    // Fallback: byte-level truncation for non-line-structured output
641    let mut t = s[..s.floor_char_boundary(max)].to_string();
642    t.push_str("\n... [truncated]");
643    t
644}
645
646// ═══════════════════════════════════════════
647// Checkpoint system
648// ═══════════════════════════════════════════
649
650pub struct CheckpointManager;
651
652impl CheckpointManager {
653    fn checkpoint_dir() -> std::path::PathBuf {
654        let dir = aegis_types::paths::config_dir().join("checkpoints");
655        let _ = std::fs::create_dir_all(&dir);
656        dir
657    }
658
659    /// Backup a file before overwriting. FIFO, max 20 checkpoints.
660    pub fn backup(path: &std::path::Path) -> Result<()> {
661        if !path.exists() {
662            return Ok(());
663        }
664        let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string();
665        let cp_dir = Self::checkpoint_dir().join(&ts);
666        std::fs::create_dir_all(&cp_dir)?;
667
668        // Preserve relative structure
669        let fname = path.file_name().unwrap_or_default();
670        std::fs::copy(path, cp_dir.join(fname))?;
671
672        // Store original path for restore
673        std::fs::write(cp_dir.join(".original_path"), path.display().to_string())?;
674
675        // FIFO: keep max 20
676        Self::prune_old(20)?;
677        Ok(())
678    }
679
680    /// List all saved checkpoints, returning (checkpoint_name, original_path) sorted newest first.
681    pub fn list() -> Result<Vec<(String, String)>> {
682        let dir = Self::checkpoint_dir();
683        let mut entries: Vec<(String, String)> = Vec::new();
684        if let Ok(rd) = std::fs::read_dir(&dir) {
685            for entry in rd.flatten() {
686                let name = entry.file_name().to_string_lossy().to_string();
687                let orig = std::fs::read_to_string(entry.path().join(".original_path"))
688                    .unwrap_or_default();
689                entries.push((name, orig));
690            }
691        }
692        entries.sort_by(|a, b| b.0.cmp(&a.0));
693        Ok(entries)
694    }
695
696    /// Restore a file from the given checkpoint back to its original path.
697    pub fn restore(checkpoint_name: &str) -> Result<String> {
698        let cp_dir = Self::checkpoint_dir().join(checkpoint_name);
699        if !cp_dir.exists() {
700            anyhow::bail!("Checkpoint not found: {checkpoint_name}");
701        }
702        let orig_path = std::fs::read_to_string(cp_dir.join(".original_path"))?;
703        // Find the backed-up file (not .original_path)
704        for entry in std::fs::read_dir(&cp_dir)?.flatten() {
705            let fname = entry.file_name().to_string_lossy().to_string();
706            if fname == ".original_path" {
707                continue;
708            }
709            std::fs::copy(entry.path(), &orig_path)?;
710            return Ok(format!(
711                "Restored {} from checkpoint {checkpoint_name}",
712                orig_path
713            ));
714        }
715        anyhow::bail!("No file found in checkpoint {checkpoint_name}")
716    }
717
718    fn prune_old(max: usize) -> Result<()> {
719        let dir = Self::checkpoint_dir();
720        let mut entries: Vec<_> = std::fs::read_dir(&dir)?
721            .flatten()
722            .filter(|e| e.path().is_dir())
723            .collect();
724        entries.sort_by_key(|e| e.file_name());
725        while entries.len() > max {
726            if let Some(oldest) = entries.first() {
727                let _ = std::fs::remove_dir_all(oldest.path());
728            }
729            entries.remove(0);
730        }
731        Ok(())
732    }
733}
734
735// ═══════════════════════════════════════════
736// todo tool (session-scoped task list)
737// ═══════════════════════════════════════════
738
739/// Path to the session's todo file (`~/.aegis/todos/<session>.txt`).
740pub fn todo_path(session_id: &str) -> std::path::PathBuf {
741    aegis_types::paths::config_dir()
742        .join("todos")
743        .join(format!("{session_id}.txt"))
744}
745
746/// Summarize the session's todo list as `(completed, total, current_item)`,
747/// where `current_item` is the first not-yet-done task. Returns `None` when
748/// there are no tasks. Used by the CLI to render a live progress bar.
749pub fn read_todo_progress(session_id: &str) -> Option<(usize, usize, String)> {
750    let content = std::fs::read_to_string(todo_path(session_id)).ok()?;
751    let mut total = 0usize;
752    let mut done = 0usize;
753    let mut current = String::new();
754    for line in content.lines() {
755        if line.trim().is_empty() {
756            continue;
757        }
758        total += 1;
759        if line.starts_with("[x] ") {
760            done += 1;
761        } else if current.is_empty() {
762            current = line.strip_prefix("[ ] ").unwrap_or(line).to_string();
763        }
764    }
765    if total == 0 {
766        None
767    } else {
768        Some((done, total, current))
769    }
770}
771
772pub struct TodoTool;
773
774#[async_trait]
775impl Tool for TodoTool {
776    fn name(&self) -> &str {
777        "todo"
778    }
779    fn description(&self) -> &str {
780        "Track a multi-step task as a checklist for this session, shown to the user \
781         as a live progress bar. Use it to plan and drive long tasks: add each step \
782         up front, then mark steps complete as you finish them. If an earlier step \
783         turns out wrong, use `rollback` to reopen it and everything after it (the \
784         progress bar moves back). Actions: add (task), complete (index, 1-based), \
785         reopen (index), rollback (index = first step to redo), list."
786    }
787    fn parameters(&self) -> Value {
788        json!({
789            "type": "object",
790            "properties": {
791                "action": { "type": "string", "enum": ["add", "complete", "reopen", "rollback", "list"], "description": "Action to perform" },
792                "task": { "type": "string", "description": "Task description (for add)" },
793                "index": { "type": "integer", "description": "1-based task index. complete/reopen: that task; rollback: that task and all after it are reopened" }
794            },
795            "required": ["action"]
796        })
797    }
798    async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
799        // Use a simple file-based todo per session
800        let todo_path = todo_path(&ctx.session_id);
801        let _ = std::fs::create_dir_all(todo_path.parent().expect("todo path has parent"));
802
803        let mut tasks: Vec<(bool, String)> = if todo_path.exists() {
804            std::fs::read_to_string(&todo_path)?
805                .lines()
806                .map(|l| {
807                    if let Some(rest) = l.strip_prefix("[x] ") {
808                        (true, rest.to_string())
809                    } else if let Some(rest) = l.strip_prefix("[ ] ") {
810                        (false, rest.to_string())
811                    } else {
812                        (false, l.to_string())
813                    }
814                })
815                .collect()
816        } else {
817            Vec::new()
818        };
819
820        match args["action"].as_str().unwrap_or("list") {
821            "add" => {
822                let task = args["task"].as_str().unwrap_or("(no description)");
823                tasks.push((false, task.to_string()));
824                save_todos(&todo_path, &tasks)?;
825                Ok(format!("Added task #{}: {task}", tasks.len()))
826            }
827            "complete" => {
828                let idx = args["index"].as_u64().unwrap_or(0) as usize;
829                if idx == 0 || idx > tasks.len() {
830                    return Ok("Invalid task index.".to_string());
831                }
832                tasks[idx - 1].0 = true;
833                save_todos(&todo_path, &tasks)?;
834                Ok(format!("Completed task #{}: {}", idx, tasks[idx - 1].1))
835            }
836            "reopen" => {
837                let idx = args["index"].as_u64().unwrap_or(0) as usize;
838                if idx == 0 || idx > tasks.len() {
839                    return Ok("Invalid task index.".to_string());
840                }
841                tasks[idx - 1].0 = false;
842                save_todos(&todo_path, &tasks)?;
843                Ok(format!("Reopened task #{}: {}", idx, tasks[idx - 1].1))
844            }
845            "rollback" => {
846                // Roll back to task #index: reopen it and every task after it,
847                // so the progress bar returns to that step.
848                let idx = args["index"].as_u64().unwrap_or(0) as usize;
849                if idx == 0 || idx > tasks.len() {
850                    return Ok("Invalid task index.".to_string());
851                }
852                for t in tasks.iter_mut().skip(idx - 1) {
853                    t.0 = false;
854                }
855                save_todos(&todo_path, &tasks)?;
856                let done = tasks.iter().filter(|(d, _)| *d).count();
857                Ok(format!(
858                    "Rolled back to task #{} ({}). Progress now {}/{}; tasks #{}–#{} reopened.",
859                    idx,
860                    tasks[idx - 1].1,
861                    done,
862                    tasks.len(),
863                    idx,
864                    tasks.len()
865                ))
866            }
867            _ => {
868                if tasks.is_empty() {
869                    return Ok("No tasks.".to_string());
870                }
871                let list: String = tasks
872                    .iter()
873                    .enumerate()
874                    .map(|(i, (done, desc))| {
875                        let mark = if *done { "x" } else { " " };
876                        format!("  {}. [{}] {}", i + 1, mark, desc)
877                    })
878                    .collect::<Vec<_>>()
879                    .join("\n");
880                Ok(list)
881            }
882        }
883    }
884}
885
886fn save_todos(path: &std::path::Path, tasks: &[(bool, String)]) -> Result<()> {
887    let content: String = tasks
888        .iter()
889        .map(|(done, desc)| {
890            if *done {
891                format!("[x] {desc}")
892            } else {
893                format!("[ ] {desc}")
894            }
895        })
896        .collect::<Vec<_>>()
897        .join("\n");
898    std::fs::write(path, content)?;
899    Ok(())
900}
901
902// ═══════════════════════════════════════════
903// background (long-running process supervisor)
904// ═══════════════════════════════════════════
905
906struct BgTask {
907    child: tokio::process::Child,
908    log: std::path::PathBuf,
909    cmd: String,
910    started: std::time::Instant,
911}
912
913/// Run and supervise long-running processes without blocking the agent loop.
914/// Output is redirected to a per-task log file (so the pipe never fills and
915/// blocks), which the agent tails via the `logs` action.
916pub struct BackgroundTool {
917    dir: std::path::PathBuf,
918    tasks: Arc<std::sync::Mutex<std::collections::HashMap<String, BgTask>>>,
919    backend: BgBackend,
920}
921
922impl BackgroundTool {
923    /// Create a background-tool with logs under `~/.aegis/bg/`.
924    pub fn new() -> Self {
925        let dir = aegis_types::paths::config_dir().join("bg");
926        Self {
927            dir,
928            tasks: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
929            backend: BgBackend::Auto,
930        }
931    }
932
933    /// Set the execution backend (from `[tools] background_backend` config).
934    pub fn with_backend(mut self, backend: BgBackend) -> Self {
935        self.backend = backend;
936        self
937    }
938}
939
940/// Backend for the `background` tool. `Auto` uses tmux when available (giving
941/// tasks an independent lifetime + `tmux attach` re-attachability), else falls
942/// back to a detached child process.
943#[derive(Debug, Clone, Copy, PartialEq, Eq)]
944pub enum BgBackend {
945    Auto,
946    Tmux,
947    Child,
948}
949
950impl BgBackend {
951    /// Parse from the config string (`auto`/`tmux`/`child`; unknown → Auto).
952    pub fn from_config(s: &str) -> Self {
953        match s.trim().to_ascii_lowercase().as_str() {
954            "tmux" => BgBackend::Tmux,
955            "child" => BgBackend::Child,
956            _ => BgBackend::Auto,
957        }
958    }
959
960    /// Resolve `Auto` to a concrete backend based on tmux availability.
961    fn effective(self) -> BgBackend {
962        match self {
963            BgBackend::Auto => {
964                if tmux_available() {
965                    BgBackend::Tmux
966                } else {
967                    BgBackend::Child
968                }
969            }
970            other => other,
971        }
972    }
973}
974
975/// Whether the `tmux` binary is available.
976fn tmux_available() -> bool {
977    std::process::Command::new("tmux")
978        .arg("-V")
979        .stdout(Stdio::null())
980        .stderr(Stdio::null())
981        .status()
982        .map(|s| s.success())
983        .unwrap_or(false)
984}
985
986/// tmux session name for a task id (prefixed + sanitized to avoid collisions
987/// with the user's own sessions and to reject shell/tmux metacharacters).
988fn tmux_session_name(id: &str) -> String {
989    let safe: String = id
990        .chars()
991        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
992        .collect();
993    format!("aegis-{safe}")
994}
995
996/// POSIX single-quote a string for safe embedding in an `sh -c` command.
997fn posix_squote(s: &str) -> String {
998    format!("'{}'", s.replace('\'', "'\\''"))
999}
1000
1001impl Default for BackgroundTool {
1002    fn default() -> Self {
1003        Self::new()
1004    }
1005}
1006
1007/// Read the last `lines` lines of a (possibly large) log file, cheaply.
1008fn tail_file(path: &std::path::Path, lines: usize) -> String {
1009    use std::io::{Read, Seek, SeekFrom};
1010    let mut f = match std::fs::File::open(path) {
1011        Ok(f) => f,
1012        Err(_) => return String::new(),
1013    };
1014    let len = f.metadata().map(|m| m.len()).unwrap_or(0);
1015    const CAP: u64 = 64 * 1024;
1016    let _ = f.seek(SeekFrom::Start(len.saturating_sub(CAP)));
1017    let mut raw = Vec::new();
1018    let _ = f.read_to_end(&mut raw);
1019    let text = String::from_utf8_lossy(&raw);
1020    let all: Vec<&str> = text.lines().collect();
1021    let start = all.len().saturating_sub(lines);
1022    all[start..].join("\n")
1023}
1024
1025/// Persist `(pid, cmd)` for a background task so a later aegis process can
1026/// re-discover it. `backend`/`session` are set for tmux-backed tasks so a
1027/// fresh process can query/kill them via tmux rather than by pid.
1028fn write_bg_meta(dir: &std::path::Path, id: &str, pid: u32, cmd: &str) {
1029    write_bg_meta_full(dir, id, pid, cmd, "child", "");
1030}
1031
1032fn write_bg_meta_full(dir: &std::path::Path, id: &str, pid: u32, cmd: &str, backend: &str, session: &str) {
1033    let meta = json!({ "pid": pid, "cmd": cmd, "backend": backend, "session": session, "started": now_epoch() });
1034    let _ = std::fs::write(dir.join(format!("{id}.meta.json")), meta.to_string());
1035}
1036
1037/// Current unix time in seconds.
1038fn now_epoch() -> u64 {
1039    std::time::SystemTime::now()
1040        .duration_since(std::time::UNIX_EPOCH)
1041        .map(|d| d.as_secs())
1042        .unwrap_or(0)
1043}
1044
1045/// Persisted start time (unix secs) for a background task, if recorded.
1046fn read_bg_started(dir: &std::path::Path, id: &str) -> Option<u64> {
1047    let content = std::fs::read_to_string(dir.join(format!("{id}.meta.json"))).ok()?;
1048    let v: Value = serde_json::from_str(&content).ok()?;
1049    v["started"].as_u64().filter(|s| *s > 0)
1050}
1051
1052/// Human-friendly duration (`45s`, `3m12s`, `1h4m`).
1053fn human_dur(secs: u64) -> String {
1054    if secs < 60 {
1055        format!("{secs}s")
1056    } else if secs < 3600 {
1057        format!("{}m{}s", secs / 60, secs % 60)
1058    } else {
1059        format!("{}h{}m", secs / 3600, (secs % 3600) / 60)
1060    }
1061}
1062
1063/// Elapsed-since-start suffix for a persisted task (e.g. ` (ran 3m12s)`), or "".
1064fn bg_elapsed_suffix(dir: &std::path::Path, id: &str) -> String {
1065    match read_bg_started(dir, id) {
1066        Some(started) => format!(" (ran {})", human_dur(now_epoch().saturating_sub(started))),
1067        None => String::new(),
1068    }
1069}
1070
1071/// Read persisted `(pid, cmd)` for a background task id.
1072fn read_bg_meta(dir: &std::path::Path, id: &str) -> Option<(u32, String)> {
1073    let content = std::fs::read_to_string(dir.join(format!("{id}.meta.json"))).ok()?;
1074    let v: Value = serde_json::from_str(&content).ok()?;
1075    let pid = v["pid"].as_u64()? as u32;
1076    let cmd = v["cmd"].as_str().unwrap_or("").to_string();
1077    Some((pid, cmd))
1078}
1079
1080/// Read the tmux session name for a task if it is tmux-backed.
1081fn read_bg_session(dir: &std::path::Path, id: &str) -> Option<String> {
1082    let content = std::fs::read_to_string(dir.join(format!("{id}.meta.json"))).ok()?;
1083    let v: Value = serde_json::from_str(&content).ok()?;
1084    if v["backend"].as_str() == Some("tmux") {
1085        v["session"].as_str().filter(|s| !s.is_empty()).map(|s| s.to_string())
1086    } else {
1087        None
1088    }
1089}
1090
1091/// Whether a tmux session exists.
1092fn tmux_session_alive(session: &str) -> bool {
1093    std::process::Command::new("tmux")
1094        .args(["has-session", "-t", session])
1095        .stdout(Stdio::null())
1096        .stderr(Stdio::null())
1097        .status()
1098        .map(|s| s.success())
1099        .unwrap_or(false)
1100}
1101
1102/// List all persisted background tasks as `(id, pid, cmd)`.
1103fn list_bg_meta(dir: &std::path::Path) -> Vec<(String, u32, String)> {
1104    let mut out = Vec::new();
1105    if let Ok(entries) = std::fs::read_dir(dir) {
1106        for e in entries.flatten() {
1107            if let Some(fname) = e.file_name().to_str() {
1108                if let Some(id) = fname.strip_suffix(".meta.json") {
1109                    if let Some((pid, cmd)) = read_bg_meta(dir, id) {
1110                        out.push((id.to_string(), pid, cmd));
1111                    }
1112                }
1113            }
1114        }
1115    }
1116    out
1117}
1118
1119/// Whether a process id is still alive (Linux/WSL: `/proc/<pid>` exists).
1120fn pid_alive(pid: u32) -> bool {
1121    pid != 0 && std::path::Path::new(&format!("/proc/{pid}")).exists()
1122}
1123
1124#[async_trait]
1125impl Tool for BackgroundTool {
1126    fn name(&self) -> &str {
1127        "background"
1128    }
1129    fn description(&self) -> &str {
1130        "Run and supervise long-running processes WITHOUT blocking. Use this for \
1131         anything that takes minutes/hours (builds, training, servers, data \
1132         pipelines): start the command in the background, then keep working and \
1133         periodically check it with status/logs, and kill it when done. Far better \
1134         than a blocking `terminal` call for long jobs. Jobs survive an aegis \
1135         restart — status/logs/kill still work in a later session (great for \
1136         resume/watchdog). Actions: start (command, optional id), status (id), \
1137         logs (id, lines), kill (id), list."
1138    }
1139    fn parameters(&self) -> Value {
1140        json!({
1141            "type": "object",
1142            "properties": {
1143                "action": { "type": "string", "enum": ["start", "status", "logs", "kill", "list"] },
1144                "command": { "type": "string", "description": "Shell command to run (action=start)" },
1145                "id": { "type": "string", "description": "Task id for status/logs/kill (auto-generated on start if omitted)" },
1146                "lines": { "type": "integer", "description": "Tail this many log lines (action=logs, default 40)" }
1147            },
1148            "required": ["action"]
1149        })
1150    }
1151    async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
1152        match args["action"].as_str().unwrap_or("list") {
1153            "start" => {
1154                let cmd = args["command"].as_str().unwrap_or("").trim().to_string();
1155                if cmd.is_empty() {
1156                    return Ok("Error: 'command' is required for action=start".to_string());
1157                }
1158                match check_command(&cmd) {
1159                    DangerLevel::Dangerous(reason) => {
1160                        if !ctx.approve(&format!("⚠️ DANGEROUS (background): {reason}\nCommand: {cmd}")) {
1161                            return Ok(format!("Command blocked: {reason}. User denied."));
1162                        }
1163                    }
1164                    DangerLevel::Warn(reason) => {
1165                        if !ctx.approve(&format!("⚠ Warning (background): {reason}\nCommand: {cmd}")) {
1166                            return Ok(format!("Command skipped: {reason}. User denied."));
1167                        }
1168                    }
1169                    DangerLevel::Safe => {}
1170                }
1171                std::fs::create_dir_all(&self.dir).ok();
1172                let id = args["id"].as_str().map(|s| s.to_string()).unwrap_or_else(|| {
1173                    let nanos = std::time::SystemTime::now()
1174                        .duration_since(std::time::UNIX_EPOCH)
1175                        .map(|d| d.as_nanos() as u64)
1176                        .unwrap_or(0);
1177                    format!("bg-{:06x}", nanos & 0xff_ffff)
1178                });
1179                let log = self.dir.join(format!("{id}.log"));
1180
1181                // tmux backend: run in a detached tmux session for an
1182                // independent lifetime (survives aegis restart/exit) and live
1183                // re-attachability. Output is tee'd to the same logfile so the
1184                // existing `logs` action keeps working.
1185                if self.backend.effective() == BgBackend::Tmux {
1186                    if !tmux_available() {
1187                        return Ok("Error: background_backend=tmux but tmux is not installed. Install tmux or set [tools] background_backend = \"child\".".to_string());
1188                    }
1189                    let session = tmux_session_name(&id);
1190                    if tmux_session_alive(&session) {
1191                        return Ok(format!("Error: tmux session '{session}' already exists (task id '{id}' in use)."));
1192                    }
1193                    let inner = format!("{cmd} 2>&1 | tee {}", posix_squote(&log.display().to_string()));
1194                    let started = std::process::Command::new("tmux")
1195                        .args(["new-session", "-d", "-s", &session, "-c"])
1196                        .arg(&ctx.cwd)
1197                        .arg("sh")
1198                        .arg("-c")
1199                        .arg(&inner)
1200                        .status()
1201                        .map(|s| s.success())
1202                        .unwrap_or(false);
1203                    if !started {
1204                        return Ok(format!("Error: failed to start tmux session '{session}' for task '{id}'."));
1205                    }
1206                    // Keep the pane after the command exits so `status` can
1207                    // distinguish "finished" from "gone".
1208                    let _ = std::process::Command::new("tmux")
1209                        .args(["set-option", "-t", &session, "remain-on-exit", "on"])
1210                        .status();
1211                    write_bg_meta_full(&self.dir, &id, 0, &cmd, "tmux", &session);
1212                    return Ok(format!(
1213                        "Started background task '{id}' in tmux session '{session}'.\n\
1214                         Attach (watch / type commands live): tmux attach -t {session}\n\
1215                         Logs: {}\n\
1216                         Check: background action=status id={id}  •  kill: background action=kill id={id}",
1217                        log.display()
1218                    ));
1219                }
1220
1221                let file = std::fs::File::create(&log)
1222                    .map_err(|e| anyhow::anyhow!("create log file: {e}"))?;
1223                let file2 = file.try_clone().map_err(|e| anyhow::anyhow!("clone log file: {e}"))?;
1224                let child = tokio::process::Command::new("sh")
1225                    .arg("-c")
1226                    .arg(&cmd)
1227                    .current_dir(&ctx.cwd)
1228                    .stdout(Stdio::from(file))
1229                    .stderr(Stdio::from(file2))
1230                    .spawn()
1231                    .map_err(|e| anyhow::anyhow!("spawn failed: {e}"))?;
1232                let pid = child.id().unwrap_or(0);
1233                if let Ok(mut tasks) = self.tasks.lock() {
1234                    tasks.insert(
1235                        id.clone(),
1236                        BgTask {
1237                            child,
1238                            log: log.clone(),
1239                            cmd: cmd.clone(),
1240                            started: std::time::Instant::now(),
1241                        },
1242                    );
1243                }
1244                // Persist metadata so a fresh aegis process (e.g. the resume
1245                // watchdog) can still see/poll/kill this job after a restart.
1246                write_bg_meta(&self.dir, &id, pid, &cmd);
1247                Ok(format!(
1248                    "Started background task '{id}' (pid {pid}).\nLogs: {}\nCheck with: background action=status id={id}  •  background action=logs id={id}",
1249                    log.display()
1250                ))
1251            }
1252            "status" => {
1253                let id = args["id"].as_str().unwrap_or("");
1254                // In-memory task (started this session) → exact exit status.
1255                {
1256                    let mut tasks = self.tasks.lock().map_err(|_| anyhow::anyhow!("lock poisoned"))?;
1257                    if let Some(task) = tasks.get_mut(id) {
1258                        let elapsed = task.started.elapsed().as_secs();
1259                        return Ok(match task.child.try_wait() {
1260                            Ok(Some(st)) => format!(
1261                                "Task '{id}' EXITED (code {}) after {elapsed}s.\ncmd: {}",
1262                                st.code().unwrap_or(-1),
1263                                task.cmd
1264                            ),
1265                            Ok(None) => format!("Task '{id}' RUNNING ({elapsed}s).\ncmd: {}", task.cmd),
1266                            Err(e) => format!("Task '{id}' status error: {e}"),
1267                        });
1268                    }
1269                }
1270                // Otherwise fall back to persisted metadata (job started by an
1271                // earlier aegis process). tmux-backed tasks are queried via the
1272                // tmux session; child tasks via pid liveness.
1273                if let Some(session) = read_bg_session(&self.dir, id) {
1274                    let state = if tmux_session_alive(&session) { "RUNNING" } else { "EXITED/gone" };
1275                    let cmd = read_bg_meta(&self.dir, id).map(|(_, c)| c).unwrap_or_default();
1276                    let elapsed = bg_elapsed_suffix(&self.dir, id);
1277                    return Ok(format!(
1278                        "Task '{id}' {state}{elapsed} (tmux session '{session}').\nAttach: tmux attach -t {session}\ncmd: {cmd}"
1279                    ));
1280                }
1281                match read_bg_meta(&self.dir, id) {
1282                    Some((pid, cmd)) => {
1283                        let state = if pid_alive(pid) { "RUNNING" } else { "EXITED" };
1284                        let elapsed = bg_elapsed_suffix(&self.dir, id);
1285                        Ok(format!("Task '{id}' {state}{elapsed} (pid {pid}, from earlier session).\ncmd: {cmd}"))
1286                    }
1287                    None => Ok(format!("No background task '{id}'. Use action=list.")),
1288                }
1289            }
1290            "logs" => {
1291                let id = args["id"].as_str().unwrap_or("");
1292                let lines = args["lines"].as_u64().unwrap_or(40) as usize;
1293                // Log file is on disk regardless of which process started it.
1294                let log = {
1295                    let tasks = self.tasks.lock().map_err(|_| anyhow::anyhow!("lock poisoned"))?;
1296                    tasks.get(id).map(|t| t.log.clone())
1297                }
1298                .unwrap_or_else(|| self.dir.join(format!("{id}.log")));
1299                if !log.exists() {
1300                    return Ok(format!("No background task '{id}'. Use action=list."));
1301                }
1302                let out = tail_file(&log, lines);
1303                if out.is_empty() {
1304                    Ok(format!("(no output yet for '{id}')"))
1305                } else {
1306                    Ok(format!("--- {id} (last {lines} lines) ---\n{out}"))
1307                }
1308            }
1309            "kill" => {
1310                let id = args["id"].as_str().unwrap_or("");
1311                // In-memory child first.
1312                {
1313                    let mut tasks = self.tasks.lock().map_err(|_| anyhow::anyhow!("lock poisoned"))?;
1314                    if let Some(mut task) = tasks.remove(id) {
1315                        let _ = task.child.start_kill();
1316                        let _ = std::fs::remove_file(self.dir.join(format!("{id}.meta.json")));
1317                        return Ok(format!("Killed background task '{id}'."));
1318                    }
1319                }
1320                // Otherwise kill by tmux session or persisted pid.
1321                if let Some(session) = read_bg_session(&self.dir, id) {
1322                    let _ = std::process::Command::new("tmux")
1323                        .args(["kill-session", "-t", &session])
1324                        .status();
1325                    let _ = std::fs::remove_file(self.dir.join(format!("{id}.meta.json")));
1326                    return Ok(format!("Killed background task '{id}' (tmux session '{session}')."));
1327                }
1328                match read_bg_meta(&self.dir, id) {
1329                    Some((pid, _)) => {
1330                        let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
1331                        let _ = std::fs::remove_file(self.dir.join(format!("{id}.meta.json")));
1332                        Ok(format!("Sent kill to background task '{id}' (pid {pid})."))
1333                    }
1334                    None => Ok(format!("No background task '{id}'.")),
1335                }
1336            }
1337            _ => {
1338                // Merge in-memory + persisted tasks so jobs survive restarts.
1339                let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1340                let mut out = String::from("Background tasks:\n");
1341                if let Ok(tasks) = self.tasks.lock() {
1342                    for (id, task) in tasks.iter() {
1343                        seen.insert(id.clone());
1344                        out.push_str(&format!(
1345                            "  {id}: {}s (this session)  {}\n",
1346                            task.started.elapsed().as_secs(),
1347                            task.cmd
1348                        ));
1349                    }
1350                }
1351                for (id, pid, cmd) in list_bg_meta(&self.dir) {
1352                    if seen.contains(&id) {
1353                        continue;
1354                    }
1355                    if let Some(session) = read_bg_session(&self.dir, &id) {
1356                        let state = if tmux_session_alive(&session) { "RUNNING" } else { "exited" };
1357                        out.push_str(&format!("  {id}: {state} (tmux {session})  {cmd}\n"));
1358                    } else {
1359                        let state = if pid_alive(pid) { "RUNNING" } else { "exited" };
1360                        out.push_str(&format!("  {id}: {state} (pid {pid})  {cmd}\n"));
1361                    }
1362                }
1363                if out == "Background tasks:\n" {
1364                    return Ok("No background tasks.".to_string());
1365                }
1366                Ok(out)
1367            }
1368        }
1369    }
1370}
1371
1372// ═══════════════════════════════════════════
1373// clarify tool
1374// ═══════════════════════════════════════════
1375
1376pub struct ClarifyTool;
1377
1378#[async_trait]
1379impl Tool for ClarifyTool {
1380    fn name(&self) -> &str {
1381        "clarify"
1382    }
1383    fn description(&self) -> &str {
1384        "Ask the user a question and let them pick an answer. ALWAYS use this tool \
1385         (with concrete `options`) for any decision point — including confirming or \
1386         adjusting a plan, choosing a direction, or yes/no approvals — instead of \
1387         writing the choices in your prose reply and waiting for them to type. The \
1388         user picks with arrow keys; a 'manual input' choice is always added. Ask \
1389         several questions at once via `questions` (user switches with ←/→)."
1390    }
1391    fn parameters(&self) -> Value {
1392        json!({
1393            "type": "object",
1394            "properties": {
1395                "question": { "type": "string", "description": "The question to ask the user" },
1396                "options": {
1397                    "type": "array",
1398                    "items": { "type": "string" },
1399                    "description": "Optional preset answers the user can choose from"
1400                },
1401                "questions": {
1402                    "type": "array",
1403                    "description": "Ask multiple questions at once; user switches with ←/→",
1404                    "items": {
1405                        "type": "object",
1406                        "properties": {
1407                            "question": { "type": "string" },
1408                            "options": { "type": "array", "items": { "type": "string" } }
1409                        },
1410                        "required": ["question"]
1411                    }
1412                }
1413            }
1414        })
1415    }
1416    async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
1417        let question = args["question"].as_str().unwrap_or("Could you clarify?");
1418        eprintln!("\n❓ {question}");
1419        eprint!("Your answer: ");
1420        let _ = std::io::Write::flush(&mut std::io::stderr());
1421        let mut answer = String::new();
1422        std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut answer)?;
1423        Ok(format!("User answered: {}", answer.trim()))
1424    }
1425}
1426
1427// ═══════════════════════════════════════════
1428// session_search tool
1429// ═══════════════════════════════════════════
1430
1431pub struct SessionSearchTool;
1432
1433#[async_trait]
1434impl Tool for SessionSearchTool {
1435    fn name(&self) -> &str {
1436        "session_search"
1437    }
1438    fn description(&self) -> &str {
1439        "Search past conversation sessions for relevant information using full-text search."
1440    }
1441    fn parameters(&self) -> Value {
1442        json!({
1443            "type": "object",
1444            "properties": {
1445                "query": { "type": "string", "description": "Search query" },
1446                "limit": { "type": "integer", "description": "Max results (default 5)" }
1447            },
1448            "required": ["query"]
1449        })
1450    }
1451    async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
1452        let query = args["query"].as_str().unwrap_or("");
1453        let limit = args["limit"].as_u64().unwrap_or(5) as u32;
1454
1455        let db_path = aegis_types::paths::config_dir().join("sessions.db");
1456        if !db_path.exists() {
1457            return Ok("No session history available.".to_string());
1458        }
1459
1460        let conn = rusqlite::Connection::open(&db_path)?;
1461        let sanitized = query
1462            .split_whitespace()
1463            .map(|w| {
1464                let c: String = w
1465                    .chars()
1466                    .filter(|c| c.is_alphanumeric() || *c == '_')
1467                    .collect();
1468                format!("\"{c}\"")
1469            })
1470            .filter(|s| s != "\"\"")
1471            .collect::<Vec<_>>()
1472            .join(" ");
1473        if sanitized.is_empty() {
1474            return Ok("Empty query.".to_string());
1475        }
1476
1477        let mut stmt = conn.prepare(
1478            "SELECT m.session_id, m.role, snippet(messages_fts, 0, '>>>', '<<<', '...', 32)
1479             FROM messages_fts f JOIN messages m ON m.id = f.rowid
1480             WHERE messages_fts MATCH ?1 ORDER BY rank LIMIT ?2",
1481        )?;
1482        let results: Vec<String> = stmt
1483            .query_map(rusqlite::params![sanitized, limit], |row| {
1484                let sid: String = row.get(0)?;
1485                let role: String = row.get(1)?;
1486                let snip: String = row.get(2)?;
1487                Ok(format!(
1488                    "[{}] ({}) {}",
1489                    &sid[..sid.len().min(15)],
1490                    role,
1491                    snip
1492                ))
1493            })?
1494            .filter_map(|r| r.ok())
1495            .collect();
1496
1497        if results.is_empty() {
1498            Ok("No results found.".to_string())
1499        } else {
1500            Ok(results.join("\n"))
1501        }
1502    }
1503}
1504
1505// ═══════════════════════════════════════════
1506// spawn_task (multi-agent orchestration)
1507// ═══════════════════════════════════════════
1508
1509pub struct SpawnTaskTool {
1510    semaphore: Arc<tokio::sync::Semaphore>,
1511}
1512
1513/// A single sub-agent task with optional per-task overrides. Enables
1514/// heterogeneous dispatch: each spawned worker may run a different model
1515/// and/or iteration budget than its siblings.
1516#[derive(Debug, Clone, PartialEq)]
1517struct TaskSpec {
1518    prompt: String,
1519    /// Model override for this sub-agent; `None` → worker's config default.
1520    model: Option<String>,
1521    max_turns: u64,
1522}
1523
1524/// Read a non-empty, trimmed string field from a JSON object, else `None`.
1525fn opt_str(v: &Value, key: &str) -> Option<String> {
1526    v[key]
1527        .as_str()
1528        .map(str::trim)
1529        .filter(|s| !s.is_empty())
1530        .map(str::to_string)
1531}
1532
1533/// Parse the tool arguments into the list of sub-tasks to dispatch.
1534///
1535/// Supports two shapes (batch takes precedence):
1536/// - batch: `tasks: [{ prompt, model?, max_turns? }, ...]`
1537/// - single: top-level `prompt` (+ optional `model`, `max_turns`)
1538///
1539/// Per-task `model` / `max_turns` fall back to the top-level values, which in
1540/// turn default to (config model) / 20.
1541fn parse_task_specs(args: &Value) -> Vec<TaskSpec> {
1542    let top_model = opt_str(args, "model");
1543    let top_max_turns = args["max_turns"].as_u64().unwrap_or(20);
1544
1545    if let Some(arr) = args["tasks"].as_array() {
1546        arr.iter()
1547            .filter_map(|t| {
1548                let prompt = opt_str(t, "prompt")?;
1549                Some(TaskSpec {
1550                    prompt,
1551                    model: opt_str(t, "model").or_else(|| top_model.clone()),
1552                    max_turns: t["max_turns"].as_u64().unwrap_or(top_max_turns),
1553                })
1554            })
1555            .collect()
1556    } else if let Some(p) = args["prompt"].as_str() {
1557        vec![TaskSpec {
1558            prompt: p.to_string(),
1559            model: top_model,
1560            max_turns: top_max_turns,
1561        }]
1562    } else {
1563        Vec::new()
1564    }
1565}
1566
1567/// Human-readable count of each model across the spawned sub-tasks, e.g.
1568/// `"3×gpt-4o, 9×gpt-4o-mini"` (or `"12×default model"` when none override).
1569fn model_breakdown(specs: &[TaskSpec]) -> String {
1570    use std::collections::BTreeMap;
1571    let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
1572    for s in specs {
1573        *counts
1574            .entry(s.model.as_deref().unwrap_or("default model"))
1575            .or_insert(0) += 1;
1576    }
1577    counts
1578        .iter()
1579        .map(|(m, n)| format!("{n}×{m}"))
1580        .collect::<Vec<_>>()
1581        .join(", ")
1582}
1583
1584/// One-line banner prepended to a `spawn_task` result so both the user and the
1585/// relaying model can see how many sub-agents were dispatched and with which models.
1586fn dispatch_header(specs: &[TaskSpec]) -> String {
1587    let n = specs.len();
1588    let noun = if n == 1 { "sub-agent" } else { "sub-agents" };
1589    format!(
1590        "🤖 Dispatched {n} {noun} in parallel (models: {})",
1591        model_breakdown(specs)
1592    )
1593}
1594
1595/// One-line footer summarizing successes/failures across the dispatched sub-agents.
1596fn dispatch_footer(ok: usize, total: usize) -> String {
1597    let failed = total.saturating_sub(ok);
1598    format!("── {total} sub-agents · {ok} ✅ / {failed} ❌ ──")
1599}
1600
1601/// Hard upper bound on how many characters of a single sub-agent's reply are
1602/// folded back into the parent's context. A dozen fanned-out sub-agents each
1603/// returning a long transcript would blow up the parent context, so we cap
1604/// every reply. The worker is also instructed to self-summarize; this is the
1605/// safety backstop for a model that ignores that instruction.
1606const MAX_WORKER_SUMMARY_CHARS: usize = 2000;
1607
1608/// Truncate a sub-agent reply to `max` chars on a char boundary, appending a
1609/// note when truncated. Guarantees the parent context cost per sub-agent has a
1610/// fixed upper bound regardless of model compliance.
1611fn cap_summary(text: &str, max: usize) -> String {
1612    if text.chars().count() <= max {
1613        return text.to_string();
1614    }
1615    let truncated: String = text.chars().take(max).collect();
1616    format!(
1617        "{truncated}\n…[truncated to {max} chars to protect the parent context; \
1618the full result is in this sub-agent's own session]"
1619    )
1620}
1621
1622impl SpawnTaskTool {
1623    /// Create a new `SpawnTaskTool` with a concurrency limit for parallel worker tasks.
1624    pub fn new(max_concurrent: usize) -> Self {        Self {
1625            semaphore: Arc::new(tokio::sync::Semaphore::new(max_concurrent)),
1626        }
1627    }
1628}
1629
1630#[async_trait]
1631impl Tool for SpawnTaskTool {
1632    fn name(&self) -> &str {
1633        "spawn_task"
1634    }
1635    fn description(&self) -> &str {
1636        "Spawn a sub-agent to handle a task independently. Supports parallel execution: pass `tasks` to fan out a dozen+ sub-agents at once (e.g. to investigate many leads in parallel). Each sub-task may run a DIFFERENT model via its `model` field — assign a strong model to the most suspicious lead and a cheap/fast model to bulk checks (heterogeneous dispatch). Each sub-agent returns ONLY a concise summary of its result (not its full transcript), so fanning out many sub-agents won't blow up your context. Set isolate=true for file-modifying tasks: each runs in its own git worktree+branch (no clobbering the main tree or sibling tasks; changes land on a branch for review)."
1637    }
1638    fn parameters(&self) -> Value {
1639        json!({
1640            "type": "object",
1641            "properties": {
1642                "prompt": { "type": "string", "description": "Task description for the worker (single-task mode)" },
1643                "model": { "type": "string", "description": "Model to run the sub-agent(s) with (e.g. gpt-4o, gpt-4o-mini, o1). Omit to use the configured default. For a `tasks` batch this is the fallback for items that don't set their own model." },
1644                "max_turns": { "type": "integer", "description": "Max iterations (default 20). For a `tasks` batch this is the fallback for items that don't set their own." },
1645                "isolate": { "type": "boolean", "description": "Run each sub-task in an isolated git worktree+branch (recommended when tasks modify files). Default false." },
1646                "tasks": {
1647                    "type": "array",
1648                    "items": { "type": "object", "properties": {
1649                        "prompt": { "type": "string", "description": "Task description for this sub-agent" },
1650                        "model": { "type": "string", "description": "Per-task model override for heterogeneous dispatch (falls back to top-level `model`, then the config default)" },
1651                        "max_turns": { "type": "integer", "description": "Per-task iteration budget (falls back to top-level `max_turns`)" }
1652                    }, "required": ["prompt"] },
1653                    "description": "Multiple tasks to run in parallel. Each item may specify its own `model`/`max_turns` so different sub-agents can use different models."
1654                },
1655                "depends_on": {
1656                    "type": "array",
1657                    "items": { "type": "string" },
1658                    "description": "List of task IDs that must complete before this task starts."
1659                },
1660                "timeout_secs": {
1661                    "type": "integer",
1662                    "description": "Timeout in seconds when waiting for depends_on tasks (default 300)"
1663                }
1664            },
1665            "required": []
1666        })
1667    }
1668    async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
1669        // Handle depends_on: wait for prerequisite tasks before proceeding
1670        let depends_on: Vec<String> = args["depends_on"]
1671            .as_array()
1672            .map(|arr| {
1673                arr.iter()
1674                    .filter_map(|v| v.as_str().map(String::from))
1675                    .collect()
1676            })
1677            .unwrap_or_default();
1678
1679        if !depends_on.is_empty() {
1680            let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(300);
1681            wait_for_tasks(depends_on, timeout_secs).await?;
1682        }
1683
1684        // Single task or batch, with optional per-task model/max_turns.
1685        let specs = parse_task_specs(&args);
1686        if specs.is_empty() {
1687            return Ok("No prompt provided.".to_string());
1688        }
1689
1690        // Opt-in filesystem isolation: each sub-task runs in its own git
1691        // worktree + branch, so parallel workers never clobber the main tree or
1692        // each other. Changes stay on the branch for review/merge.
1693        let isolate = args["isolate"].as_bool().unwrap_or(false);
1694        let repo = _ctx.cwd.clone();
1695        let mut results = Vec::new();
1696        let mut ok_count = 0usize;
1697
1698        // Find worker binary
1699        let worker_bin = find_worker_binary();
1700
1701        let mut handles = Vec::new();
1702        for (i, spec) in specs.iter().enumerate() {
1703            let permit = self
1704                .semaphore
1705                .clone()
1706                .acquire_owned()
1707                .await
1708                .map_err(|e| anyhow::anyhow!("semaphore: {e}"))?;
1709            let bin = worker_bin.clone();
1710            let prompt = spec.prompt.clone();
1711            let model = spec.model.clone();
1712            let max_turns = spec.max_turns;
1713            let worktree = if isolate {
1714                create_worktree(&repo, &format!("{}-{}", std::process::id(), i))
1715            } else {
1716                None
1717            };
1718            let cwd = worktree.as_ref().map(|(p, _)| p.clone());
1719            let branch = worktree.as_ref().map(|(_, b)| b.clone());
1720
1721            let handle = tokio::spawn(async move {
1722                let _permit = permit;
1723                let label = model.clone().unwrap_or_default();
1724                let result = run_worker(&bin, &prompt, max_turns, model, cwd).await;
1725                (i, result, branch, label)
1726            });
1727            handles.push(handle);
1728        }
1729
1730        for handle in handles {
1731            let (i, result, branch, model_label) =
1732                handle.await.map_err(|e| anyhow::anyhow!("join: {e}"))?;
1733            let loc = branch
1734                .map(|b| format!(" [isolated → branch `{b}`; review/merge it]"))
1735                .unwrap_or_default();
1736            let model_tag = if model_label.is_empty() {
1737                String::new()
1738            } else {
1739                format!(" [model: {model_label}]")
1740            };
1741            match result {
1742                Ok(text) => {
1743                    ok_count += 1;
1744                    let summary = cap_summary(&text, MAX_WORKER_SUMMARY_CHARS);
1745                    results.push(format!("[Worker {}] ✅{}{}\n{}", i + 1, model_tag, loc, summary))
1746                }
1747                Err(e) => results.push(format!("[Worker {}] ❌{}{} {}", i + 1, model_tag, loc, e)),
1748            }
1749        }
1750
1751        // Cluster co-evolution: promote worker results as global strategies
1752        if specs.len() > 1 {
1753            let successful: Vec<&String> = results.iter()
1754                .filter(|r| r.contains('✅'))
1755                .collect();
1756            if successful.len() >= 2 {
1757                let strategies_dir = dirs_next::home_dir()
1758                    .unwrap_or_default()
1759                    .join(".aegis/strategies");
1760                let _ = std::fs::create_dir_all(&strategies_dir);
1761
1762                // Consolidation: if >50 worker strategy files, prune oldest 10
1763                if let Ok(entries) = std::fs::read_dir(&strategies_dir) {
1764                    let mut worker_files: Vec<std::path::PathBuf> = entries
1765                        .filter_map(|e| e.ok())
1766                        .map(|e| e.path())
1767                        .filter(|p| {
1768                            p.extension().is_some_and(|ext| ext == "md")
1769                                && p.file_name().is_some_and(|n| n.to_string_lossy().contains("worker"))
1770                        })
1771                        .collect();
1772                    if worker_files.len() > 50 {
1773                        worker_files.sort_by_key(|p| {
1774                            std::fs::metadata(p).and_then(|m| m.modified()).ok()
1775                        });
1776                        for old in worker_files.iter().take(10) {
1777                            let _ = std::fs::remove_file(old);
1778                        }
1779                    }
1780                }
1781
1782                let ts = std::time::SystemTime::now()
1783                    .duration_since(std::time::UNIX_EPOCH)
1784                    .unwrap_or_default()
1785                    .as_secs();
1786                for (i, result) in successful.iter().enumerate() {
1787                    // Extract text after "✅\n" prefix
1788                    let body = if let Some(pos) = result.find('✅') {
1789                        let after = &result[pos + '✅'.len_utf8()..];
1790                        after.trim_start_matches('\n')
1791                    } else {
1792                        result.as_str()
1793                    };
1794
1795                    // Quality filter: skip if content too short
1796                    if body.len() <= 100 {
1797                        continue;
1798                    }
1799
1800                    // Dedup by content hash: sum of first 200 bytes mod 100000
1801                    let hash_input = &body[..body.floor_char_boundary(200)];
1802                    let hash: u64 = hash_input.bytes().map(|b| b as u64).sum::<u64>() % 100000;
1803                    let hash_filename = format!("strat-hash-{hash}.md");
1804                    if strategies_dir.join(&hash_filename).exists() {
1805                        continue;
1806                    }
1807
1808                    let summary = &body[..body.floor_char_boundary(300)];
1809                    let id = format!("strat-worker-{ts}-{i}");
1810                    let content = format!(
1811                        "---\nid: {id}\ntrigger: \"worker task result\"\nversion: 1\nstatus: active\n---\n# Worker Strategy {i}\n\n## Steps\n{summary}\n\n## Source\nPromoted from cluster worker result.\n"
1812                    );
1813                    let _ = std::fs::write(strategies_dir.join(&hash_filename), content);
1814                }
1815            }
1816        }
1817
1818        let header = dispatch_header(&specs);
1819        let footer = dispatch_footer(ok_count, specs.len());
1820        Ok(format!("{}\n\n{}\n\n{}", header, results.join("\n\n"), footer))
1821    }
1822}
1823
1824fn find_worker_binary() -> String {
1825    // The worker is now the `worker` subcommand of this same binary (the former
1826    // standalone `aegis-worker` was merged into the main CLI). Self-reference via
1827    // current_exe so there is nothing extra to ship or locate on PATH.
1828    std::env::current_exe()
1829        .map(|p| p.display().to_string())
1830        .unwrap_or_else(|_| "aegis".to_string())
1831}
1832
1833/// Poll aegis-worker HTTP API until all task IDs are done or timeout is reached.
1834/// Task status endpoint: GET {AEGIS_WORKER_URL}/tasks/{id}
1835/// Response: { "status": "running" | "done" | "error" }
1836/// "done" or "error" both count as finished.
1837async fn wait_for_tasks(task_ids: Vec<String>, timeout_secs: u64) -> Result<()> {
1838    let base_url = std::env::var("AEGIS_WORKER_URL")
1839        .unwrap_or_else(|_| "http://localhost:3001".to_string());
1840    let base_url = base_url.trim_end_matches('/').to_string();
1841
1842    let client = reqwest::Client::new();
1843    let deadline = tokio::time::Instant::now()
1844        + std::time::Duration::from_secs(timeout_secs);
1845
1846    let mut remaining: std::collections::HashSet<String> =
1847        task_ids.into_iter().collect();
1848
1849    while !remaining.is_empty() {
1850        if tokio::time::Instant::now() >= deadline {
1851            return Err(anyhow::anyhow!(
1852                "Timed out waiting for tasks: {:?}",
1853                remaining
1854            ));
1855        }
1856
1857        let mut completed = Vec::new();
1858        for task_id in remaining.iter() {
1859            let url = format!("{}/tasks/{}", base_url, task_id);
1860            match client.get(&url).send().await {
1861                Ok(resp) => {
1862                    if let Ok(body) = resp.json::<serde_json::Value>().await {
1863                        let status = body["status"].as_str().unwrap_or("");
1864                        if status == "done" || status == "error" {
1865                            completed.push(task_id.clone());
1866                        }
1867                    }
1868                }
1869                Err(_) => {
1870                    // Worker may not be up yet; keep waiting
1871                }
1872            }
1873        }
1874        for id in completed {
1875            remaining.remove(&id);
1876        }
1877
1878        if !remaining.is_empty() {
1879            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
1880        }
1881    }
1882    Ok(())
1883}
1884
1885/// Create an isolated git worktree + branch for a sub-task, so the sub-agent
1886/// works without touching the main tree or sibling tasks. Returns
1887/// `(worktree_path, branch)`, or `None` if `repo` isn't a git repo / git fails.
1888fn create_worktree(repo: &std::path::Path, task_id: &str) -> Option<(std::path::PathBuf, String)> {
1889    let is_repo = std::process::Command::new("git")
1890        .arg("-C")
1891        .arg(repo)
1892        .args(["rev-parse", "--is-inside-work-tree"])
1893        .output()
1894        .ok()
1895        .map(|o| o.status.success())
1896        .unwrap_or(false);
1897    if !is_repo {
1898        return None;
1899    }
1900    let branch = format!("aegis/task-{task_id}");
1901    let wt = std::env::temp_dir().join(format!("aegis-wt-{task_id}"));
1902    let ok = std::process::Command::new("git")
1903        .arg("-C")
1904        .arg(repo)
1905        .args(["worktree", "add", "-b", &branch])
1906        .arg(&wt)
1907        .arg("HEAD")
1908        .output()
1909        .ok()
1910        .map(|o| o.status.success())
1911        .unwrap_or(false);
1912    if ok {
1913        Some((wt, branch))
1914    } else {
1915        None
1916    }
1917}
1918
1919async fn run_worker(bin: &str, prompt: &str, max_turns: u64, model: Option<String>, cwd: Option<std::path::PathBuf>) -> Result<String> {
1920    let mut params = serde_json::json!({ "prompt": prompt, "max_turns": max_turns });
1921    // Heterogeneous dispatch: forward a per-task model when set so this
1922    // sub-agent runs a different model than its siblings.
1923    if let Some(m) = model.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
1924        params["model"] = serde_json::Value::String(m.to_string());
1925    }
1926    let request = serde_json::json!({
1927        "jsonrpc": "2.0", "id": 1, "method": "task/run",
1928        "params": params
1929    });
1930
1931    let mut child = tokio::process::Command::new(bin);
1932    child
1933        .arg("worker")
1934        .stdin(Stdio::piped())
1935        .stdout(Stdio::piped())
1936        .stderr(Stdio::null());
1937    if let Some(dir) = &cwd {
1938        child.current_dir(dir);
1939    }
1940    let mut child = child
1941        .spawn()
1942        .map_err(|e| anyhow::anyhow!("Failed to spawn worker: {e}. Could not run `{bin} worker`."))?;
1943
1944    // Send request
1945    if let Some(mut stdin) = child.stdin.take() {
1946        use tokio::io::AsyncWriteExt;
1947        stdin.write_all(format!("{}\n", request).as_bytes()).await?;
1948        drop(stdin);
1949    }
1950
1951    // Read with timeout
1952    let output = tokio::time::timeout(
1953        std::time::Duration::from_secs(900), // 15 min
1954        child.wait_with_output(),
1955    )
1956    .await
1957    .map_err(|_| anyhow::anyhow!("Worker timed out"))??;
1958
1959    let stdout = String::from_utf8_lossy(&output.stdout);
1960
1961    // Parse last JSON-RPC response
1962    for line in stdout.lines().rev() {
1963        if let Ok(resp) = serde_json::from_str::<serde_json::Value>(line) {
1964            if let Some(result) = resp.get("result") {
1965                return Ok(result["result"]
1966                    .as_str()
1967                    .unwrap_or("(no result)")
1968                    .to_string());
1969            }
1970            if let Some(error) = resp.get("error") {
1971                return Err(anyhow::anyhow!(
1972                    "{}",
1973                    error["message"].as_str().unwrap_or("unknown error")
1974                ));
1975            }
1976        }
1977    }
1978
1979    Ok(stdout.to_string())
1980}
1981
1982// ═══════════════════════════════════════════
1983// web_search
1984// ═══════════════════════════════════════════
1985
1986pub struct WebSearchTool;
1987
1988impl WebSearchTool {
1989    /// Create a new `WebSearchTool` with default configuration.
1990    pub fn new() -> Self {
1991        WebSearchTool
1992    }
1993
1994    fn load_config_key(key: &str) -> Option<String> {
1995        // Try <config_dir>/config.toml [tools] section first
1996        {
1997            let config_path = aegis_types::paths::config_path();
1998            if let Ok(content) = std::fs::read_to_string(&config_path) {
1999                if let Ok(val) = content.parse::<toml::Value>() {
2000                    if let Some(s) = val.get("tools").and_then(|t| t.get(key)).and_then(|v| v.as_str()) {
2001                        if !s.is_empty() {
2002                            return Some(s.to_string());
2003                        }
2004                    }
2005                }
2006            }
2007        }
2008        None
2009    }
2010}
2011
2012impl Default for WebSearchTool {
2013    fn default() -> Self {
2014        Self::new()
2015    }
2016}
2017
2018#[async_trait]
2019impl Tool for WebSearchTool {
2020    fn name(&self) -> &str {
2021        "web_search"
2022    }
2023
2024    fn description(&self) -> &str {
2025        "Search the web and return a list of results with title, URL, and snippet."
2026    }
2027
2028    fn parameters(&self) -> Value {
2029        json!({
2030            "type": "object",
2031            "properties": {
2032                "query": { "type": "string", "description": "Search query" },
2033                "num_results": { "type": "integer", "description": "Number of results (default 5)" }
2034            },
2035            "required": ["query"]
2036        })
2037    }
2038
2039    async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
2040        let query = args["query"].as_str().unwrap_or("").trim().to_string();
2041        if query.is_empty() {
2042            return Ok("Error: query is required".to_string());
2043        }
2044        let num_results = args["num_results"].as_u64().unwrap_or(5) as usize;
2045
2046        // Determine backend
2047        let exa_key = Self::load_config_key("exa_api_key")
2048            .or_else(|| std::env::var("EXA_API_KEY").ok());
2049        let tavily_key = Self::load_config_key("tavily_api_key")
2050            .or_else(|| std::env::var("TAVILY_API_KEY").ok());
2051
2052        if let Some(key) = exa_key {
2053            search_exa(&key, &query, num_results).await
2054        } else if let Some(key) = tavily_key {
2055            search_tavily(&key, &query, num_results).await
2056        } else {
2057            search_duckduckgo(&query, num_results).await
2058        }
2059    }
2060}
2061
2062async fn search_exa(api_key: &str, query: &str, num_results: usize) -> Result<String> {
2063    use aegis_security::is_safe_url;
2064    let url = "https://api.exa.ai/search";
2065    is_safe_url(url).map_err(|e| anyhow::anyhow!("SSRF check failed: {e}"))?;
2066
2067    let client = reqwest::Client::new();
2068    let resp: Value = client
2069        .post(url)
2070        .header("x-api-key", api_key)
2071        .header("Content-Type", "application/json")
2072        .json(&json!({ "query": query, "numResults": num_results }))
2073        .send()
2074        .await?
2075        .json()
2076        .await?;
2077
2078    let mut out = String::new();
2079    if let Some(results) = resp["results"].as_array() {
2080        for (i, r) in results.iter().enumerate() {
2081            let title = r["title"].as_str().unwrap_or("(no title)");
2082            let url = r["url"].as_str().unwrap_or("");
2083            let snippet = r["text"].as_str().or_else(|| r["snippet"].as_str()).unwrap_or("");
2084            let snippet = if snippet.len() > 300 { &snippet[..snippet.floor_char_boundary(300)] } else { snippet };
2085            out.push_str(&format!("[{}] {}\n{}\n{}\n\n", i + 1, title, url, snippet));
2086        }
2087    }
2088    if out.is_empty() {
2089        out = "No results found.".to_string();
2090    }
2091    Ok(out.trim_end().to_string())
2092}
2093
2094async fn search_tavily(api_key: &str, query: &str, num_results: usize) -> Result<String> {
2095    use aegis_security::is_safe_url;
2096    let url = "https://api.tavily.com/search";
2097    is_safe_url(url).map_err(|e| anyhow::anyhow!("SSRF check failed: {e}"))?;
2098
2099    let client = reqwest::Client::new();
2100    let resp: Value = client
2101        .post(url)
2102        .json(&json!({
2103            "api_key": api_key,
2104            "query": query,
2105            "max_results": num_results
2106        }))
2107        .send()
2108        .await?
2109        .json()
2110        .await?;
2111
2112    let mut out = String::new();
2113    if let Some(results) = resp["results"].as_array() {
2114        for (i, r) in results.iter().enumerate() {
2115            let title = r["title"].as_str().unwrap_or("(no title)");
2116            let url = r["url"].as_str().unwrap_or("");
2117            let snippet = r["content"].as_str().unwrap_or("");
2118            let snippet = if snippet.len() > 300 { &snippet[..snippet.floor_char_boundary(300)] } else { snippet };
2119            out.push_str(&format!("[{}] {}\n{}\n{}\n\n", i + 1, title, url, snippet));
2120        }
2121    }
2122    if out.is_empty() {
2123        out = "No results found.".to_string();
2124    }
2125    Ok(out.trim_end().to_string())
2126}
2127
2128async fn search_duckduckgo(query: &str, num_results: usize) -> Result<String> {
2129    use aegis_security::is_safe_url;
2130    let encoded = urlencoding_encode(query);
2131    let url = format!("https://html.duckduckgo.com/html/?q={}", encoded);
2132    is_safe_url(&url).map_err(|e| anyhow::anyhow!("SSRF check failed: {e}"))?;
2133
2134    let client = reqwest::Client::builder()
2135        .user_agent("Mozilla/5.0 (compatible; aegis-agent/0.1)")
2136        .build()?;
2137    let html = client.get(&url).send().await?.text().await?;
2138
2139    let document = scraper::Html::parse_document(&html);
2140    let result_sel = scraper::Selector::parse(".result").map_err(|e| anyhow::anyhow!("Invalid selector: {e}"))?;
2141    let title_sel = scraper::Selector::parse(".result__title a, .result__a").map_err(|e| anyhow::anyhow!("Invalid selector: {e}"))?;
2142    let snippet_sel = scraper::Selector::parse(".result__snippet").map_err(|e| anyhow::anyhow!("Invalid selector: {e}"))?;
2143    let url_sel = scraper::Selector::parse(".result__url").map_err(|e| anyhow::anyhow!("Invalid selector: {e}"))?;
2144
2145    let mut out = String::new();
2146    let mut count = 0;
2147
2148    for result in document.select(&result_sel) {
2149        if count >= num_results {
2150            break;
2151        }
2152        let title = result.select(&title_sel).next()
2153            .map(|e| e.text().collect::<Vec<_>>().join(""))
2154            .unwrap_or_default();
2155        let title = title.trim().to_string();
2156        if title.is_empty() {
2157            continue;
2158        }
2159        let url = result.select(&url_sel).next()
2160            .map(|e| e.text().collect::<Vec<_>>().join("").trim().to_string())
2161            .unwrap_or_default();
2162        let snippet = result.select(&snippet_sel).next()
2163            .map(|e| e.text().collect::<Vec<_>>().join("").trim().to_string())
2164            .unwrap_or_default();
2165        let snippet = if snippet.len() > 300 { snippet[..snippet.floor_char_boundary(300)].to_string() } else { snippet };
2166
2167        count += 1;
2168        out.push_str(&format!("[{count}] {title}\n{url}\n{snippet}\n\n"));
2169    }
2170
2171    if out.is_empty() {
2172        out = "No results found (DuckDuckGo fallback).".to_string();
2173    }
2174    Ok(out.trim_end().to_string())
2175}
2176
2177fn urlencoding_encode(s: &str) -> String {
2178    let mut out = String::new();
2179    for b in s.bytes() {
2180        match b {
2181            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
2182                out.push(b as char);
2183            }
2184            b' ' => out.push('+'),
2185            _ => out.push_str(&format!("%{:02X}", b)),
2186        }
2187    }
2188    out
2189}
2190
2191// ═══════════════════════════════════════════
2192// web_extract
2193// ═══════════════════════════════════════════
2194
2195pub struct WebExtractTool;
2196
2197impl WebExtractTool {
2198    /// Create a new `WebExtractTool` with default configuration.
2199    pub fn new() -> Self {
2200        WebExtractTool
2201    }
2202}
2203
2204impl Default for WebExtractTool {
2205    fn default() -> Self {
2206        Self::new()
2207    }
2208}
2209
2210#[async_trait]
2211impl Tool for WebExtractTool {
2212    fn name(&self) -> &str {
2213        "web_extract"
2214    }
2215
2216    fn description(&self) -> &str {
2217        "Fetch a URL and convert its main content to clean Markdown (readability extraction + HTML→Markdown). Set format='text' for plain text."
2218    }
2219
2220    fn parameters(&self) -> Value {
2221        json!({
2222            "type": "object",
2223            "properties": {
2224                "url": { "type": "string", "description": "URL to fetch and extract" },
2225                "format": { "type": "string", "enum": ["markdown", "text"], "description": "Output format (default: markdown)" },
2226                "max_chars": { "type": "integer", "description": "Truncate output to this many characters (default 16000; 0 = no limit)" }
2227            },
2228            "required": ["url"]
2229        })
2230    }
2231
2232    async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
2233        use aegis_security::is_safe_url;
2234
2235        let url = args["url"].as_str().unwrap_or("").trim();
2236        if url.is_empty() {
2237            return Ok("Error: url is required".to_string());
2238        }
2239
2240        // Identity gate: web_extract is not read-only (it reaches out to
2241        // the network and returns whatever the remote host says — which can
2242        // include prompt-injection payloads). Reject at identity level for
2243        // trust tiers that can't be trusted with network-derived content.
2244        if ctx.sandbox_enabled {
2245            let identity = ctx.effective_identity();
2246            let policy =
2247                aegis_security::derive_sandbox_policy(&identity, "web_extract", &ctx.cwd);
2248            if policy.deny_all {
2249                return Ok(format!(
2250                    "web_extract denied by sandbox policy: identity '{}' with trust level '{}' is not authorized to fetch external URLs.",
2251                    identity.display(),
2252                    identity.trust_level(),
2253                ));
2254            }
2255            tracing::debug!(
2256                identity = %identity.display(),
2257                trust = %identity.trust_level(),
2258                url = %url,
2259                "web_extract: identity check passed"
2260            );
2261        }
2262
2263        is_safe_url(url).map_err(|e| anyhow::anyhow!("SSRF check failed: {e}"))?;
2264
2265        let format = args["format"].as_str().unwrap_or("markdown");
2266        // Default cap ~16k chars (≈4k tokens); 0 disables truncation.
2267        let max_chars = args["max_chars"].as_u64().map(|v| v as usize).unwrap_or(16_000);
2268
2269        let client = reqwest::Client::builder()
2270            .user_agent("Mozilla/5.0 (compatible; aegis-agent/0.1)")
2271            .build()?;
2272
2273        let mut resp = client.get(url).send().await?;
2274
2275        // Hard cap the downloaded body to protect against OOM on small hosts
2276        // (1c1g). Stream chunks and stop once the cap is exceeded.
2277        const MAX_BYTES: usize = 10 * 1024 * 1024; // 10 MiB
2278        let content_type = resp
2279            .headers()
2280            .get(reqwest::header::CONTENT_TYPE)
2281            .and_then(|v| v.to_str().ok())
2282            .unwrap_or("")
2283            .to_lowercase();
2284        let mut body: Vec<u8> = Vec::new();
2285        while let Some(chunk) = resp.chunk().await? {
2286            body.extend_from_slice(&chunk);
2287            if body.len() >= MAX_BYTES {
2288                body.truncate(MAX_BYTES);
2289                break;
2290            }
2291        }
2292
2293        // Non-HTML text (json, plaintext, csv, markdown) → return as-is.
2294        let is_html = content_type.contains("html") || content_type.is_empty();
2295        let content = if !is_html && content_type.starts_with("text/") {
2296            String::from_utf8_lossy(&body).into_owned()
2297        } else {
2298            let html = String::from_utf8_lossy(&body).into_owned();
2299            // Keep all non-Send readability/DOM types inside this sync helper so
2300            // the async future stays Send (Readability is !Send).
2301            if format == "text" {
2302                let document = scraper::Html::parse_document(&html);
2303                compress_whitespace(&extract_text_content(&document))
2304            } else {
2305                html_to_markdown(&html, url)
2306            }
2307        };
2308
2309        if max_chars > 0 && content.chars().count() > max_chars {
2310            let truncated: String = content.chars().take(max_chars).collect();
2311            Ok(format!(
2312                "{truncated}\n\n... [content truncated at {max_chars} characters. Use a more specific URL, or pass a larger max_chars.]"
2313            ))
2314        } else {
2315            Ok(content)
2316        }
2317    }
2318}
2319
2320/// Convert a full HTML page to Markdown: run readability to isolate the main
2321/// article, then convert that fragment to Markdown. Falls back to whole-page
2322/// conversion, then to plain text, so the result is never worse than before.
2323///
2324/// All `!Send` types (`dom_smoothie::Readability`, `dom_query::Document`) are
2325/// confined to this synchronous function so callers can stay `Send` across
2326/// `await` points.
2327fn html_to_markdown(html: &str, url: &str) -> String {
2328    let (title, body_html) = match extract_readable(html, url) {
2329        Some((t, c)) if !c.trim().is_empty() => (Some(t), c),
2330        _ => (None, html.to_string()),
2331    };
2332
2333    let body_md = match htmd::convert(&body_html) {
2334        Ok(md) => md,
2335        Err(_) => {
2336            // Last-resort fallback: old plain-text behavior.
2337            let document = scraper::Html::parse_document(html);
2338            compress_whitespace(&extract_text_content(&document))
2339        }
2340    };
2341
2342    let body_md = body_md.trim();
2343    match title {
2344        Some(t) if !t.trim().is_empty() && !body_md.starts_with("# ") => {
2345            format!("# {}\n\n{}", t.trim(), body_md)
2346        }
2347        _ => body_md.to_string(),
2348    }
2349}
2350
2351/// Run readability extraction, returning `(title, main_content_html)`.
2352/// Returns `None` if extraction fails, so the caller can fall back.
2353fn extract_readable(html: &str, url: &str) -> Option<(String, String)> {
2354    let url_opt = if url.starts_with("http://") || url.starts_with("https://") {
2355        Some(url)
2356    } else {
2357        None
2358    };
2359    let mut readability = dom_smoothie::Readability::new(html, url_opt, None).ok()?;
2360    let article = readability.parse().ok()?;
2361    Some((article.title.clone(), article.content.to_string()))
2362}
2363
2364fn extract_text_content(document: &scraper::Html) -> String {
2365    // Try article, then main, then body
2366    for selector_str in &["article", "main", "body"] {
2367        if let Ok(sel) = scraper::Selector::parse(selector_str) {
2368            if let Some(elem) = document.select(&sel).next() {
2369                return collect_text_skip_scripts(&elem);
2370            }
2371        }
2372    }
2373    // Fallback: whole document
2374    document.root_element().text().collect::<Vec<_>>().join(" ")
2375}
2376
2377fn collect_text_skip_scripts(elem: &scraper::ElementRef) -> String {
2378    let mut text = String::new();
2379    for node in elem.descendants() {
2380        if let Some(el) = scraper::ElementRef::wrap(node) {
2381            let tag = el.value().name();
2382            if tag == "script" || tag == "style" || tag == "noscript" {
2383                continue;
2384            }
2385        } else if let Some(t) = node.value().as_text() {
2386            text.push_str(t);
2387        }
2388    }
2389    text
2390}
2391
2392fn compress_whitespace(s: &str) -> String {
2393    let mut result = String::with_capacity(s.len());
2394    let mut last_was_space = true;
2395    for c in s.chars() {
2396        if c.is_whitespace() {
2397            if !last_was_space {
2398                result.push(' ');
2399                last_was_space = true;
2400            }
2401        } else {
2402            result.push(c);
2403            last_was_space = false;
2404        }
2405    }
2406    result.trim().to_string()
2407}
2408
2409// ═══════════════════════════════════════════
2410// browser (browser-harness subprocess)
2411// ═══════════════════════════════════════════
2412
2413pub struct BrowserTool {
2414    pub binary: String,
2415    pub timeout_secs: u64,
2416}
2417
2418#[async_trait]
2419impl Tool for BrowserTool {
2420    fn name(&self) -> &str {
2421        "browser"
2422    }
2423    fn description(&self) -> &str {
2424        "Control a real browser via browser-harness. Actions: navigate, click, type, screenshot, extract."
2425    }
2426    fn parameters(&self) -> Value {
2427        json!({
2428            "type": "object",
2429            "properties": {
2430                "action": {
2431                    "type": "string",
2432                    "enum": ["navigate", "click", "type", "screenshot", "extract"],
2433                    "description": "Browser action to perform"
2434                },
2435                "url":     { "type": "string",  "description": "URL to navigate to (navigate)" },
2436                "x":       { "type": "number",  "description": "X coordinate (click, type)" },
2437                "y":       { "type": "number",  "description": "Y coordinate (click, type)" },
2438                "text":    { "type": "string",  "description": "Text to type (type)" },
2439                "selector":{ "type": "string",  "description": "CSS selector to click or extract (optional)" }
2440            },
2441            "required": ["action"]
2442        })
2443    }
2444    async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
2445        let action = args["action"].as_str().unwrap_or("");
2446
2447        let code = match action {
2448            "navigate" => {
2449                let url = args["url"].as_str().unwrap_or("");
2450                if url.is_empty() {
2451                    return Ok("Error: url is required for navigate".to_string());
2452                }
2453                format!(
2454                    "from browser_harness import Browser\nb = Browser()\nb.go('{url}')\nprint(b.title())\n"
2455                )
2456            }
2457            "click" => {
2458                if let Some(sel) = args["selector"].as_str() {
2459                    format!(
2460                        "from browser_harness import Browser\nb = Browser()\nb.click('{sel}')\nprint('clicked')\n"
2461                    )
2462                } else {
2463                    let x = args["x"].as_f64().unwrap_or(0.0);
2464                    let y = args["y"].as_f64().unwrap_or(0.0);
2465                    format!(
2466                        "from browser_harness import Browser\nb = Browser()\nb.click_at({x}, {y})\nprint('clicked')\n"
2467                    )
2468                }
2469            }
2470            "type" => {
2471                let text = args["text"].as_str().unwrap_or("");
2472                if let Some(sel) = args["selector"].as_str() {
2473                    format!(
2474                        "from browser_harness import Browser\nb = Browser()\nb.type('{sel}', '{text}')\nprint('typed')\n"
2475                    )
2476                } else {
2477                    let x = args["x"].as_f64().unwrap_or(0.0);
2478                    let y = args["y"].as_f64().unwrap_or(0.0);
2479                    format!(
2480                        "from browser_harness import Browser\nb = Browser()\nb.click_at({x}, {y})\nb.keyboard_type('{text}')\nprint('typed')\n"
2481                    )
2482                }
2483            }
2484            "screenshot" => {
2485                "from browser_harness import Browser\nb = Browser()\npath = b.screenshot()\nprint(path)\n".to_string()
2486            }
2487            "extract" => {
2488                if let Some(sel) = args["selector"].as_str() {
2489                    format!(
2490                        "from browser_harness import Browser\nb = Browser()\nprint(b.text('{sel}'))\n"
2491                    )
2492                } else {
2493                    "from browser_harness import Browser\nb = Browser()\nprint(b.text('body'))\n".to_string()
2494                }
2495            }
2496            _ => return Ok(format!("Unknown action: {action}. Use: navigate, click, type, screenshot, extract")),
2497        };
2498
2499        let output = tokio::time::timeout(
2500            std::time::Duration::from_secs(self.timeout_secs),
2501            tokio::process::Command::new(&self.binary)
2502                .arg("-c")
2503                .arg(&code)
2504                .stdout(Stdio::piped())
2505                .stderr(Stdio::piped())
2506                .output(),
2507        )
2508        .await
2509        .map_err(|_| anyhow::anyhow!("browser-harness timed out after {}s", self.timeout_secs))??;
2510
2511        let stdout = String::from_utf8_lossy(&output.stdout);
2512        let stderr = String::from_utf8_lossy(&output.stderr);
2513        let code_exit = output.status.code().unwrap_or(-1);
2514
2515        if code_exit != 0 && stdout.is_empty() {
2516            return Ok(format!("[browser error]\n{stderr}"));
2517        }
2518        let mut result = stdout.to_string();
2519        if !stderr.is_empty() {
2520            result.push_str(&format!("\n[stderr] {stderr}"));
2521        }
2522        Ok(result.trim_end().to_string())
2523    }
2524}
2525
2526// ═══════════════════════════════════════════
2527// maigret (OSINT username search)
2528// ═══════════════════════════════════════════
2529
2530pub struct MaigretTool {
2531    pub maigret_path: String,
2532    pub timeout_secs: u64,
2533    pub top_sites: u64,
2534}
2535
2536#[async_trait]
2537impl Tool for MaigretTool {
2538    fn name(&self) -> &str {
2539        "maigret"
2540    }
2541    fn description(&self) -> &str {
2542        "Search for social media accounts and public profiles by username using Maigret OSINT tool. Returns found accounts across hundreds of sites."
2543    }
2544    fn parameters(&self) -> Value {
2545        json!({
2546            "type": "object",
2547            "properties": {
2548                "username": {
2549                    "type": "string",
2550                    "description": "Username to search for"
2551                },
2552                "top_sites": {
2553                    "type": "integer",
2554                    "description": "Limit to top N sites (default: configured value, 0 = all)"
2555                }
2556            },
2557            "required": ["username"]
2558        })
2559    }
2560    async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
2561        let username = args["username"].as_str().unwrap_or("").trim().to_string();
2562        if username.is_empty() {
2563            return Ok("Error: username is required.".to_string());
2564        }
2565        // Basic sanitization — reject suspicious chars
2566        if username.contains([';', '&', '|', '$', '`', '\'', '"', '\n']) {
2567            return Ok("Error: invalid characters in username.".to_string());
2568        }
2569
2570        let top = args["top_sites"].as_u64().unwrap_or(self.top_sites);
2571
2572        let mut cmd = tokio::process::Command::new("python");
2573        cmd.arg("-m")
2574            .arg("maigret")
2575            .arg(&username)
2576            .arg("--no-color")
2577            .arg("--no-progressbar")
2578            .arg("-J")
2579            .arg("ndjson");
2580        if top > 0 {
2581            cmd.arg("--top-sites").arg(top.to_string());
2582        }
2583        cmd.current_dir(&self.maigret_path)
2584            .stdout(Stdio::piped())
2585            .stderr(Stdio::piped());
2586
2587        let output = tokio::time::timeout(
2588            std::time::Duration::from_secs(self.timeout_secs),
2589            cmd.output(),
2590        )
2591        .await
2592        .map_err(|_| anyhow::anyhow!("maigret timed out after {}s", self.timeout_secs))??;
2593
2594        let stdout = String::from_utf8_lossy(&output.stdout);
2595        let stderr = String::from_utf8_lossy(&output.stderr);
2596
2597        // maigret writes reports/report_{username}_ndjson.json
2598        let report_path = std::path::Path::new(&self.maigret_path)
2599            .join("reports")
2600            .join(format!("report_{username}_ndjson.json"));
2601
2602        if report_path.exists() {
2603            let json_raw = tokio::fs::read_to_string(&report_path).await?;
2604            // Parse and produce a clean summary
2605            if let Ok(data) = serde_json::from_str::<Value>(&json_raw) {
2606                return Ok(format_maigret_report(&username, &data));
2607            }
2608        }
2609
2610        // Fallback: return raw stdout
2611        let mut result = stdout.to_string();
2612        if result.is_empty() && !stderr.is_empty() {
2613            result = stderr.to_string();
2614        }
2615        Ok(truncate_output(&result, 20_000))
2616    }
2617}
2618
2619fn format_maigret_report(username: &str, data: &Value) -> String {
2620    let mut found = Vec::new();
2621    let mut not_found = 0usize;
2622
2623    if let Some(obj) = data.as_object() {
2624        for (site, info) in obj {
2625            let status = info["status"]["status"].as_str().unwrap_or("");
2626            if status == "Claimed" {
2627                let url = info["url_user"].as_str()
2628                    .or_else(|| info["url"].as_str())
2629                    .unwrap_or("");
2630                let mut extra = Vec::new();
2631                // Collect interesting fields
2632                for key in &["name", "bio", "location", "followers", "following", "posts"] {
2633                    if let Some(v) = info["ids"].get(key).or_else(|| info.get(key)) {
2634                        if !v.is_null() {
2635                            extra.push(format!("{key}={v}"));
2636                        }
2637                    }
2638                }
2639                let extra_str = if extra.is_empty() {
2640                    String::new()
2641                } else {
2642                    format!(" [{}]", extra.join(", "))
2643                };
2644                found.push(format!("  + {site}: {url}{extra_str}"));
2645            } else {
2646                not_found += 1;
2647            }
2648        }
2649    }
2650
2651    found.sort();
2652    let mut out = format!(
2653        "Maigret results for '{}': {} accounts found, {} not found\n\n",
2654        username,
2655        found.len(),
2656        not_found
2657    );
2658    if found.is_empty() {
2659        out.push_str("No accounts found.\n");
2660    } else {
2661        out.push_str(&found.join("\n"));
2662    }
2663    out
2664}
2665
2666#[cfg(test)]
2667mod tests {
2668    use super::*;
2669    use serde_json::json;
2670
2671    #[test]
2672    fn bg_backend_from_config_parses() {
2673        assert_eq!(BgBackend::from_config("tmux"), BgBackend::Tmux);
2674        assert_eq!(BgBackend::from_config("  TMUX "), BgBackend::Tmux);
2675        assert_eq!(BgBackend::from_config("child"), BgBackend::Child);
2676        assert_eq!(BgBackend::from_config("auto"), BgBackend::Auto);
2677        assert_eq!(BgBackend::from_config("nonsense"), BgBackend::Auto);
2678    }
2679
2680    #[test]
2681    fn bg_backend_forced_passthrough() {
2682        // Non-auto backends are returned as-is regardless of tmux availability.
2683        assert_eq!(BgBackend::Child.effective(), BgBackend::Child);
2684        assert_eq!(BgBackend::Tmux.effective(), BgBackend::Tmux);
2685    }
2686
2687    #[test]
2688    fn tmux_session_name_sanitizes_and_prefixes() {
2689        assert_eq!(tmux_session_name("job1"), "aegis-job1");
2690        assert_eq!(tmux_session_name("a b;c$(x)"), "aegis-a_b_c__x_");
2691        assert_eq!(tmux_session_name("ok-_1"), "aegis-ok-_1");
2692    }
2693
2694    #[test]
2695    fn posix_squote_escapes_single_quotes() {
2696        assert_eq!(posix_squote("abc"), "'abc'");
2697        assert_eq!(posix_squote("a'b"), "'a'\\''b'");
2698    }
2699
2700    #[test]
2701    fn html_to_markdown_preserves_structure() {
2702        let html = r#"<html><head><title>Doc Title</title></head><body>
2703            <article>
2704              <h1>Heading One</h1>
2705              <p>A paragraph with <a href="https://example.com">a link</a>.</p>
2706              <ul><li>first</li><li>second</li></ul>
2707              <pre><code>let x = 1;</code></pre>
2708            </article></body></html>"#;
2709        let md = html_to_markdown(html, "https://example.com/page");
2710        // Structure survived the HTML→Markdown conversion.
2711        assert!(md.contains("Heading One"), "missing heading: {md}");
2712        assert!(md.contains("first") && md.contains("second"), "missing list: {md}");
2713        assert!(md.contains("- ") || md.contains("* "), "list not markdown: {md}");
2714        assert!(md.contains("example.com"), "missing link: {md}");
2715    }
2716
2717    #[test]
2718    fn html_to_markdown_falls_back_on_garbage() {
2719        // Non-article content should still yield something, never panic.
2720        let md = html_to_markdown("<p>just text</p>", "not-a-url");
2721        assert!(md.contains("just text"), "got: {md}");
2722    }
2723
2724    fn make_ctx(cwd: std::path::PathBuf) -> ToolContext<'static> {
2725        ToolContext {
2726            cwd,
2727            session_id: "test".to_string(),
2728            approve_fn: &|_| true,
2729            yolo: true,
2730            identity: None,
2731            sandbox_enabled: false,
2732        }
2733    }
2734
2735    #[tokio::test]
2736    async fn test_spawn_without_depends() {
2737        let tool = SpawnTaskTool::new(1);
2738        let ctx = make_ctx(std::env::temp_dir());
2739        // No prompt, no depends_on — should return quickly with "No prompt provided."
2740        let result = tool.execute(json!({}), &ctx).await;
2741        assert!(result.is_ok());
2742        assert_eq!(result.unwrap(), "No prompt provided.");
2743    }
2744
2745    #[tokio::test]
2746    async fn test_spawn_with_empty_depends() {
2747        // Empty depends_on array should behave identically to no depends_on.
2748        let tool = SpawnTaskTool::new(1);
2749        let ctx = make_ctx(std::env::temp_dir());
2750        let result = tool.execute(json!({"depends_on": []}), &ctx).await;
2751        assert!(result.is_ok());
2752        assert_eq!(result.unwrap(), "No prompt provided.");
2753    }
2754
2755    #[test]
2756    fn parse_specs_single_prompt_defaults() {
2757        let specs = parse_task_specs(&json!({ "prompt": "look at logs" }));
2758        assert_eq!(specs.len(), 1);
2759        assert_eq!(specs[0].prompt, "look at logs");
2760        assert_eq!(specs[0].model, None);
2761        assert_eq!(specs[0].max_turns, 20);
2762    }
2763
2764    #[test]
2765    fn parse_specs_single_with_top_level_model_and_turns() {
2766        let specs = parse_task_specs(&json!({
2767            "prompt": "deep dive", "model": "o1", "max_turns": 40
2768        }));
2769        assert_eq!(specs.len(), 1);
2770        assert_eq!(specs[0].model.as_deref(), Some("o1"));
2771        assert_eq!(specs[0].max_turns, 40);
2772    }
2773
2774    #[test]
2775    fn parse_specs_batch_heterogeneous_models() {
2776        let specs = parse_task_specs(&json!({
2777            "model": "gpt-4o-mini",
2778            "max_turns": 15,
2779            "tasks": [
2780                { "prompt": "suspicious lead", "model": "gpt-4o", "max_turns": 30 },
2781                { "prompt": "bulk check A" },
2782                { "prompt": "bulk check B", "model": "  o1  " }
2783            ]
2784        }));
2785        assert_eq!(specs.len(), 3);
2786        assert_eq!(specs[0].model.as_deref(), Some("gpt-4o"));
2787        assert_eq!(specs[0].max_turns, 30);
2788        assert_eq!(specs[1].model.as_deref(), Some("gpt-4o-mini"));
2789        assert_eq!(specs[1].max_turns, 15);
2790        assert_eq!(specs[2].model.as_deref(), Some("o1"));
2791        assert_eq!(specs[2].max_turns, 15);
2792    }
2793
2794    #[test]
2795    fn parse_specs_batch_skips_items_without_prompt() {
2796        let specs = parse_task_specs(&json!({
2797            "tasks": [ { "model": "gpt-4o" }, { "prompt": "real one" } ]
2798        }));
2799        assert_eq!(specs.len(), 1);
2800        assert_eq!(specs[0].prompt, "real one");
2801    }
2802
2803    #[test]
2804    fn parse_specs_empty_when_nothing_provided() {
2805        assert!(parse_task_specs(&json!({})).is_empty());
2806    }
2807
2808    #[test]
2809    fn model_breakdown_groups_and_counts() {
2810        let specs = parse_task_specs(&json!({
2811            "model": "gpt-4o-mini",
2812            "tasks": [
2813                { "prompt": "a", "model": "gpt-4o" },
2814                { "prompt": "b" },
2815                { "prompt": "c" }
2816            ]
2817        }));
2818        // 1×gpt-4o + 2×gpt-4o-mini (b,c fall back to top-level)
2819        let bd = model_breakdown(&specs);
2820        assert!(bd.contains("1×gpt-4o"), "got: {bd}");
2821        assert!(bd.contains("2×gpt-4o-mini"), "got: {bd}");
2822    }
2823
2824    #[test]
2825    fn model_breakdown_default_when_no_override() {
2826        let specs = parse_task_specs(&json!({ "tasks": [{ "prompt": "a" }, { "prompt": "b" }] }));
2827        assert_eq!(model_breakdown(&specs), "2×default model");
2828    }
2829
2830    #[test]
2831    fn dispatch_header_singular_vs_plural() {
2832        let one = parse_task_specs(&json!({ "prompt": "x" }));
2833        assert!(dispatch_header(&one).contains("1 sub-agent "));
2834        let many = parse_task_specs(&json!({ "tasks": [{ "prompt": "a" }, { "prompt": "b" }] }));
2835        assert!(dispatch_header(&many).contains("2 sub-agents"));
2836    }
2837
2838    #[test]
2839    fn dispatch_footer_counts_failures() {
2840        assert_eq!(dispatch_footer(11, 12), "── 12 sub-agents · 11 ✅ / 1 ❌ ──");
2841        assert_eq!(dispatch_footer(3, 3), "── 3 sub-agents · 3 ✅ / 0 ❌ ──");
2842        // saturating: ok never exceeds total
2843        assert_eq!(dispatch_footer(5, 3), "── 3 sub-agents · 5 ✅ / 0 ❌ ──");
2844    }
2845
2846    #[test]
2847    fn cap_summary_passes_short_text_unchanged() {
2848        assert_eq!(cap_summary("short result", 2000), "short result");
2849        let exactly = "x".repeat(50);
2850        assert_eq!(cap_summary(&exactly, 50), exactly);
2851    }
2852
2853    #[test]
2854    fn cap_summary_truncates_long_text_with_note() {
2855        let long = "y".repeat(5000);
2856        let out = cap_summary(&long, 100);
2857        assert!(out.starts_with(&"y".repeat(100)));
2858        assert!(out.contains("truncated to 100 chars"));
2859        assert!(out.contains("sub-agent's own session"));
2860        // body kept exactly `max` chars before the note
2861        assert_eq!(out.chars().take_while(|c| *c == 'y').count(), 100);
2862    }
2863
2864    #[test]
2865    fn cap_summary_multibyte_does_not_panic() {
2866        // 10 multi-byte chars, cap at 4 chars — must slice on char boundary.
2867        let s = "配置项排查结论一二三四"; // 11 chars
2868        let out = cap_summary(s, 4);
2869        assert!(out.starts_with("配置项排"));
2870        assert!(out.contains("truncated to 4 chars"));
2871    }
2872
2873    #[tokio::test]
2874    async fn test_wait_for_tasks_empty() {
2875        // Empty task list should return immediately with Ok.
2876        let result = wait_for_tasks(vec![], 5).await;
2877        assert!(result.is_ok());
2878    }
2879
2880    #[tokio::test]
2881    async fn test_wait_for_tasks_timeout() {
2882        // With a task ID that doesn't exist, wait_for_tasks should time out.
2883        // Use very short timeout so test runs fast.
2884        let result = wait_for_tasks(vec!["nonexistent-task-id".to_string()], 1).await;
2885        assert!(result.is_err());
2886        let err = result.unwrap_err().to_string();
2887        assert!(err.contains("Timed out") || err.contains("timed out") || err.contains("nonexistent"));
2888    }
2889}
2890