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
1513impl SpawnTaskTool {
1514    /// Create a new `SpawnTaskTool` with a concurrency limit for parallel worker tasks.
1515    pub fn new(max_concurrent: usize) -> Self {
1516        Self {
1517            semaphore: Arc::new(tokio::sync::Semaphore::new(max_concurrent)),
1518        }
1519    }
1520}
1521
1522#[async_trait]
1523impl Tool for SpawnTaskTool {
1524    fn name(&self) -> &str {
1525        "spawn_task"
1526    }
1527    fn description(&self) -> &str {
1528        "Spawn a sub-agent to handle a task independently. Supports parallel execution. 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)."
1529    }
1530    fn parameters(&self) -> Value {
1531        json!({
1532            "type": "object",
1533            "properties": {
1534                "prompt": { "type": "string", "description": "Task description for the worker" },
1535                "max_turns": { "type": "integer", "description": "Max iterations (default 20)" },
1536                "isolate": { "type": "boolean", "description": "Run each sub-task in an isolated git worktree+branch (recommended when tasks modify files). Default false." },
1537                "tasks": {
1538                    "type": "array",
1539                    "items": { "type": "object", "properties": {
1540                        "prompt": { "type": "string" }
1541                    }},
1542                    "description": "Multiple tasks to run in parallel"
1543                },
1544                "depends_on": {
1545                    "type": "array",
1546                    "items": { "type": "string" },
1547                    "description": "List of task IDs that must complete before this task starts."
1548                },
1549                "timeout_secs": {
1550                    "type": "integer",
1551                    "description": "Timeout in seconds when waiting for depends_on tasks (default 300)"
1552                }
1553            },
1554            "required": []
1555        })
1556    }
1557    async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
1558        // Handle depends_on: wait for prerequisite tasks before proceeding
1559        let depends_on: Vec<String> = args["depends_on"]
1560            .as_array()
1561            .map(|arr| {
1562                arr.iter()
1563                    .filter_map(|v| v.as_str().map(String::from))
1564                    .collect()
1565            })
1566            .unwrap_or_default();
1567
1568        if !depends_on.is_empty() {
1569            let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(300);
1570            wait_for_tasks(depends_on, timeout_secs).await?;
1571        }
1572
1573        // Single task or batch
1574        let tasks: Vec<String> = if let Some(arr) = args["tasks"].as_array() {
1575            arr.iter()
1576                .filter_map(|t| t["prompt"].as_str().map(String::from))
1577                .collect()
1578        } else if let Some(p) = args["prompt"].as_str() {
1579            vec![p.to_string()]
1580        } else {
1581            return Ok("No prompt provided.".to_string());
1582        };
1583
1584        let max_turns = args["max_turns"].as_u64().unwrap_or(20);
1585        // Opt-in filesystem isolation: each sub-task runs in its own git
1586        // worktree + branch, so parallel workers never clobber the main tree or
1587        // each other. Changes stay on the branch for review/merge.
1588        let isolate = args["isolate"].as_bool().unwrap_or(false);
1589        let repo = _ctx.cwd.clone();
1590        let mut results = Vec::new();
1591
1592        // Find worker binary
1593        let worker_bin = find_worker_binary();
1594
1595        let mut handles = Vec::new();
1596        for (i, prompt) in tasks.iter().enumerate() {
1597            let permit = self
1598                .semaphore
1599                .clone()
1600                .acquire_owned()
1601                .await
1602                .map_err(|e| anyhow::anyhow!("semaphore: {e}"))?;
1603            let bin = worker_bin.clone();
1604            let prompt = prompt.clone();
1605            let worktree = if isolate {
1606                create_worktree(&repo, &format!("{}-{}", std::process::id(), i))
1607            } else {
1608                None
1609            };
1610            let cwd = worktree.as_ref().map(|(p, _)| p.clone());
1611            let branch = worktree.as_ref().map(|(_, b)| b.clone());
1612
1613            let handle = tokio::spawn(async move {
1614                let _permit = permit;
1615                let result = run_worker(&bin, &prompt, max_turns, cwd).await;
1616                (i, result, branch)
1617            });
1618            handles.push(handle);
1619        }
1620
1621        for handle in handles {
1622            let (i, result, branch) = handle.await.map_err(|e| anyhow::anyhow!("join: {e}"))?;
1623            let loc = branch
1624                .map(|b| format!(" [isolated → branch `{b}`; review/merge it]"))
1625                .unwrap_or_default();
1626            match result {
1627                Ok(text) => results.push(format!("[Worker {}] ✅{}\n{}", i + 1, loc, text)),
1628                Err(e) => results.push(format!("[Worker {}] ❌{} {}", i + 1, loc, e)),
1629            }
1630        }
1631
1632        // Cluster co-evolution: promote worker results as global strategies
1633        if tasks.len() > 1 {
1634            let successful: Vec<&String> = results.iter()
1635                .filter(|r| r.contains('✅'))
1636                .collect();
1637            if successful.len() >= 2 {
1638                let strategies_dir = dirs_next::home_dir()
1639                    .unwrap_or_default()
1640                    .join(".aegis/strategies");
1641                let _ = std::fs::create_dir_all(&strategies_dir);
1642
1643                // Consolidation: if >50 worker strategy files, prune oldest 10
1644                if let Ok(entries) = std::fs::read_dir(&strategies_dir) {
1645                    let mut worker_files: Vec<std::path::PathBuf> = entries
1646                        .filter_map(|e| e.ok())
1647                        .map(|e| e.path())
1648                        .filter(|p| {
1649                            p.extension().is_some_and(|ext| ext == "md")
1650                                && p.file_name().is_some_and(|n| n.to_string_lossy().contains("worker"))
1651                        })
1652                        .collect();
1653                    if worker_files.len() > 50 {
1654                        worker_files.sort_by_key(|p| {
1655                            std::fs::metadata(p).and_then(|m| m.modified()).ok()
1656                        });
1657                        for old in worker_files.iter().take(10) {
1658                            let _ = std::fs::remove_file(old);
1659                        }
1660                    }
1661                }
1662
1663                let ts = std::time::SystemTime::now()
1664                    .duration_since(std::time::UNIX_EPOCH)
1665                    .unwrap_or_default()
1666                    .as_secs();
1667                for (i, result) in successful.iter().enumerate() {
1668                    // Extract text after "✅\n" prefix
1669                    let body = if let Some(pos) = result.find('✅') {
1670                        let after = &result[pos + '✅'.len_utf8()..];
1671                        after.trim_start_matches('\n')
1672                    } else {
1673                        result.as_str()
1674                    };
1675
1676                    // Quality filter: skip if content too short
1677                    if body.len() <= 100 {
1678                        continue;
1679                    }
1680
1681                    // Dedup by content hash: sum of first 200 bytes mod 100000
1682                    let hash_input = &body[..body.floor_char_boundary(200)];
1683                    let hash: u64 = hash_input.bytes().map(|b| b as u64).sum::<u64>() % 100000;
1684                    let hash_filename = format!("strat-hash-{hash}.md");
1685                    if strategies_dir.join(&hash_filename).exists() {
1686                        continue;
1687                    }
1688
1689                    let summary = &body[..body.floor_char_boundary(300)];
1690                    let id = format!("strat-worker-{ts}-{i}");
1691                    let content = format!(
1692                        "---\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"
1693                    );
1694                    let _ = std::fs::write(strategies_dir.join(&hash_filename), content);
1695                }
1696            }
1697        }
1698
1699        Ok(results.join("\n\n"))
1700    }
1701}
1702
1703fn find_worker_binary() -> String {
1704    // The worker is now the `worker` subcommand of this same binary (the former
1705    // standalone `aegis-worker` was merged into the main CLI). Self-reference via
1706    // current_exe so there is nothing extra to ship or locate on PATH.
1707    std::env::current_exe()
1708        .map(|p| p.display().to_string())
1709        .unwrap_or_else(|_| "aegis".to_string())
1710}
1711
1712/// Poll aegis-worker HTTP API until all task IDs are done or timeout is reached.
1713/// Task status endpoint: GET {AEGIS_WORKER_URL}/tasks/{id}
1714/// Response: { "status": "running" | "done" | "error" }
1715/// "done" or "error" both count as finished.
1716async fn wait_for_tasks(task_ids: Vec<String>, timeout_secs: u64) -> Result<()> {
1717    let base_url = std::env::var("AEGIS_WORKER_URL")
1718        .unwrap_or_else(|_| "http://localhost:3001".to_string());
1719    let base_url = base_url.trim_end_matches('/').to_string();
1720
1721    let client = reqwest::Client::new();
1722    let deadline = tokio::time::Instant::now()
1723        + std::time::Duration::from_secs(timeout_secs);
1724
1725    let mut remaining: std::collections::HashSet<String> =
1726        task_ids.into_iter().collect();
1727
1728    while !remaining.is_empty() {
1729        if tokio::time::Instant::now() >= deadline {
1730            return Err(anyhow::anyhow!(
1731                "Timed out waiting for tasks: {:?}",
1732                remaining
1733            ));
1734        }
1735
1736        let mut completed = Vec::new();
1737        for task_id in remaining.iter() {
1738            let url = format!("{}/tasks/{}", base_url, task_id);
1739            match client.get(&url).send().await {
1740                Ok(resp) => {
1741                    if let Ok(body) = resp.json::<serde_json::Value>().await {
1742                        let status = body["status"].as_str().unwrap_or("");
1743                        if status == "done" || status == "error" {
1744                            completed.push(task_id.clone());
1745                        }
1746                    }
1747                }
1748                Err(_) => {
1749                    // Worker may not be up yet; keep waiting
1750                }
1751            }
1752        }
1753        for id in completed {
1754            remaining.remove(&id);
1755        }
1756
1757        if !remaining.is_empty() {
1758            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
1759        }
1760    }
1761    Ok(())
1762}
1763
1764/// Create an isolated git worktree + branch for a sub-task, so the sub-agent
1765/// works without touching the main tree or sibling tasks. Returns
1766/// `(worktree_path, branch)`, or `None` if `repo` isn't a git repo / git fails.
1767fn create_worktree(repo: &std::path::Path, task_id: &str) -> Option<(std::path::PathBuf, String)> {
1768    let is_repo = std::process::Command::new("git")
1769        .arg("-C")
1770        .arg(repo)
1771        .args(["rev-parse", "--is-inside-work-tree"])
1772        .output()
1773        .ok()
1774        .map(|o| o.status.success())
1775        .unwrap_or(false);
1776    if !is_repo {
1777        return None;
1778    }
1779    let branch = format!("aegis/task-{task_id}");
1780    let wt = std::env::temp_dir().join(format!("aegis-wt-{task_id}"));
1781    let ok = std::process::Command::new("git")
1782        .arg("-C")
1783        .arg(repo)
1784        .args(["worktree", "add", "-b", &branch])
1785        .arg(&wt)
1786        .arg("HEAD")
1787        .output()
1788        .ok()
1789        .map(|o| o.status.success())
1790        .unwrap_or(false);
1791    if ok {
1792        Some((wt, branch))
1793    } else {
1794        None
1795    }
1796}
1797
1798async fn run_worker(bin: &str, prompt: &str, max_turns: u64, cwd: Option<std::path::PathBuf>) -> Result<String> {
1799    let request = serde_json::json!({
1800        "jsonrpc": "2.0", "id": 1, "method": "task/run",
1801        "params": { "prompt": prompt, "max_turns": max_turns }
1802    });
1803
1804    let mut child = tokio::process::Command::new(bin);
1805    child
1806        .arg("worker")
1807        .stdin(Stdio::piped())
1808        .stdout(Stdio::piped())
1809        .stderr(Stdio::null());
1810    if let Some(dir) = &cwd {
1811        child.current_dir(dir);
1812    }
1813    let mut child = child
1814        .spawn()
1815        .map_err(|e| anyhow::anyhow!("Failed to spawn worker: {e}. Could not run `{bin} worker`."))?;
1816
1817    // Send request
1818    if let Some(mut stdin) = child.stdin.take() {
1819        use tokio::io::AsyncWriteExt;
1820        stdin.write_all(format!("{}\n", request).as_bytes()).await?;
1821        drop(stdin);
1822    }
1823
1824    // Read with timeout
1825    let output = tokio::time::timeout(
1826        std::time::Duration::from_secs(900), // 15 min
1827        child.wait_with_output(),
1828    )
1829    .await
1830    .map_err(|_| anyhow::anyhow!("Worker timed out"))??;
1831
1832    let stdout = String::from_utf8_lossy(&output.stdout);
1833
1834    // Parse last JSON-RPC response
1835    for line in stdout.lines().rev() {
1836        if let Ok(resp) = serde_json::from_str::<serde_json::Value>(line) {
1837            if let Some(result) = resp.get("result") {
1838                return Ok(result["result"]
1839                    .as_str()
1840                    .unwrap_or("(no result)")
1841                    .to_string());
1842            }
1843            if let Some(error) = resp.get("error") {
1844                return Err(anyhow::anyhow!(
1845                    "{}",
1846                    error["message"].as_str().unwrap_or("unknown error")
1847                ));
1848            }
1849        }
1850    }
1851
1852    Ok(stdout.to_string())
1853}
1854
1855// ═══════════════════════════════════════════
1856// web_search
1857// ═══════════════════════════════════════════
1858
1859pub struct WebSearchTool;
1860
1861impl WebSearchTool {
1862    /// Create a new `WebSearchTool` with default configuration.
1863    pub fn new() -> Self {
1864        WebSearchTool
1865    }
1866
1867    fn load_config_key(key: &str) -> Option<String> {
1868        // Try <config_dir>/config.toml [tools] section first
1869        {
1870            let config_path = aegis_types::paths::config_path();
1871            if let Ok(content) = std::fs::read_to_string(&config_path) {
1872                if let Ok(val) = content.parse::<toml::Value>() {
1873                    if let Some(s) = val.get("tools").and_then(|t| t.get(key)).and_then(|v| v.as_str()) {
1874                        if !s.is_empty() {
1875                            return Some(s.to_string());
1876                        }
1877                    }
1878                }
1879            }
1880        }
1881        None
1882    }
1883}
1884
1885impl Default for WebSearchTool {
1886    fn default() -> Self {
1887        Self::new()
1888    }
1889}
1890
1891#[async_trait]
1892impl Tool for WebSearchTool {
1893    fn name(&self) -> &str {
1894        "web_search"
1895    }
1896
1897    fn description(&self) -> &str {
1898        "Search the web and return a list of results with title, URL, and snippet."
1899    }
1900
1901    fn parameters(&self) -> Value {
1902        json!({
1903            "type": "object",
1904            "properties": {
1905                "query": { "type": "string", "description": "Search query" },
1906                "num_results": { "type": "integer", "description": "Number of results (default 5)" }
1907            },
1908            "required": ["query"]
1909        })
1910    }
1911
1912    async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
1913        let query = args["query"].as_str().unwrap_or("").trim().to_string();
1914        if query.is_empty() {
1915            return Ok("Error: query is required".to_string());
1916        }
1917        let num_results = args["num_results"].as_u64().unwrap_or(5) as usize;
1918
1919        // Determine backend
1920        let exa_key = Self::load_config_key("exa_api_key")
1921            .or_else(|| std::env::var("EXA_API_KEY").ok());
1922        let tavily_key = Self::load_config_key("tavily_api_key")
1923            .or_else(|| std::env::var("TAVILY_API_KEY").ok());
1924
1925        if let Some(key) = exa_key {
1926            search_exa(&key, &query, num_results).await
1927        } else if let Some(key) = tavily_key {
1928            search_tavily(&key, &query, num_results).await
1929        } else {
1930            search_duckduckgo(&query, num_results).await
1931        }
1932    }
1933}
1934
1935async fn search_exa(api_key: &str, query: &str, num_results: usize) -> Result<String> {
1936    use aegis_security::is_safe_url;
1937    let url = "https://api.exa.ai/search";
1938    is_safe_url(url).map_err(|e| anyhow::anyhow!("SSRF check failed: {e}"))?;
1939
1940    let client = reqwest::Client::new();
1941    let resp: Value = client
1942        .post(url)
1943        .header("x-api-key", api_key)
1944        .header("Content-Type", "application/json")
1945        .json(&json!({ "query": query, "numResults": num_results }))
1946        .send()
1947        .await?
1948        .json()
1949        .await?;
1950
1951    let mut out = String::new();
1952    if let Some(results) = resp["results"].as_array() {
1953        for (i, r) in results.iter().enumerate() {
1954            let title = r["title"].as_str().unwrap_or("(no title)");
1955            let url = r["url"].as_str().unwrap_or("");
1956            let snippet = r["text"].as_str().or_else(|| r["snippet"].as_str()).unwrap_or("");
1957            let snippet = if snippet.len() > 300 { &snippet[..snippet.floor_char_boundary(300)] } else { snippet };
1958            out.push_str(&format!("[{}] {}\n{}\n{}\n\n", i + 1, title, url, snippet));
1959        }
1960    }
1961    if out.is_empty() {
1962        out = "No results found.".to_string();
1963    }
1964    Ok(out.trim_end().to_string())
1965}
1966
1967async fn search_tavily(api_key: &str, query: &str, num_results: usize) -> Result<String> {
1968    use aegis_security::is_safe_url;
1969    let url = "https://api.tavily.com/search";
1970    is_safe_url(url).map_err(|e| anyhow::anyhow!("SSRF check failed: {e}"))?;
1971
1972    let client = reqwest::Client::new();
1973    let resp: Value = client
1974        .post(url)
1975        .json(&json!({
1976            "api_key": api_key,
1977            "query": query,
1978            "max_results": num_results
1979        }))
1980        .send()
1981        .await?
1982        .json()
1983        .await?;
1984
1985    let mut out = String::new();
1986    if let Some(results) = resp["results"].as_array() {
1987        for (i, r) in results.iter().enumerate() {
1988            let title = r["title"].as_str().unwrap_or("(no title)");
1989            let url = r["url"].as_str().unwrap_or("");
1990            let snippet = r["content"].as_str().unwrap_or("");
1991            let snippet = if snippet.len() > 300 { &snippet[..snippet.floor_char_boundary(300)] } else { snippet };
1992            out.push_str(&format!("[{}] {}\n{}\n{}\n\n", i + 1, title, url, snippet));
1993        }
1994    }
1995    if out.is_empty() {
1996        out = "No results found.".to_string();
1997    }
1998    Ok(out.trim_end().to_string())
1999}
2000
2001async fn search_duckduckgo(query: &str, num_results: usize) -> Result<String> {
2002    use aegis_security::is_safe_url;
2003    let encoded = urlencoding_encode(query);
2004    let url = format!("https://html.duckduckgo.com/html/?q={}", encoded);
2005    is_safe_url(&url).map_err(|e| anyhow::anyhow!("SSRF check failed: {e}"))?;
2006
2007    let client = reqwest::Client::builder()
2008        .user_agent("Mozilla/5.0 (compatible; aegis-agent/0.1)")
2009        .build()?;
2010    let html = client.get(&url).send().await?.text().await?;
2011
2012    let document = scraper::Html::parse_document(&html);
2013    let result_sel = scraper::Selector::parse(".result").map_err(|e| anyhow::anyhow!("Invalid selector: {e}"))?;
2014    let title_sel = scraper::Selector::parse(".result__title a, .result__a").map_err(|e| anyhow::anyhow!("Invalid selector: {e}"))?;
2015    let snippet_sel = scraper::Selector::parse(".result__snippet").map_err(|e| anyhow::anyhow!("Invalid selector: {e}"))?;
2016    let url_sel = scraper::Selector::parse(".result__url").map_err(|e| anyhow::anyhow!("Invalid selector: {e}"))?;
2017
2018    let mut out = String::new();
2019    let mut count = 0;
2020
2021    for result in document.select(&result_sel) {
2022        if count >= num_results {
2023            break;
2024        }
2025        let title = result.select(&title_sel).next()
2026            .map(|e| e.text().collect::<Vec<_>>().join(""))
2027            .unwrap_or_default();
2028        let title = title.trim().to_string();
2029        if title.is_empty() {
2030            continue;
2031        }
2032        let url = result.select(&url_sel).next()
2033            .map(|e| e.text().collect::<Vec<_>>().join("").trim().to_string())
2034            .unwrap_or_default();
2035        let snippet = result.select(&snippet_sel).next()
2036            .map(|e| e.text().collect::<Vec<_>>().join("").trim().to_string())
2037            .unwrap_or_default();
2038        let snippet = if snippet.len() > 300 { snippet[..snippet.floor_char_boundary(300)].to_string() } else { snippet };
2039
2040        count += 1;
2041        out.push_str(&format!("[{count}] {title}\n{url}\n{snippet}\n\n"));
2042    }
2043
2044    if out.is_empty() {
2045        out = "No results found (DuckDuckGo fallback).".to_string();
2046    }
2047    Ok(out.trim_end().to_string())
2048}
2049
2050fn urlencoding_encode(s: &str) -> String {
2051    let mut out = String::new();
2052    for b in s.bytes() {
2053        match b {
2054            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
2055                out.push(b as char);
2056            }
2057            b' ' => out.push('+'),
2058            _ => out.push_str(&format!("%{:02X}", b)),
2059        }
2060    }
2061    out
2062}
2063
2064// ═══════════════════════════════════════════
2065// web_extract
2066// ═══════════════════════════════════════════
2067
2068pub struct WebExtractTool;
2069
2070impl WebExtractTool {
2071    /// Create a new `WebExtractTool` with default configuration.
2072    pub fn new() -> Self {
2073        WebExtractTool
2074    }
2075}
2076
2077impl Default for WebExtractTool {
2078    fn default() -> Self {
2079        Self::new()
2080    }
2081}
2082
2083#[async_trait]
2084impl Tool for WebExtractTool {
2085    fn name(&self) -> &str {
2086        "web_extract"
2087    }
2088
2089    fn description(&self) -> &str {
2090        "Fetch a URL and extract the main text content as plain text."
2091    }
2092
2093    fn parameters(&self) -> Value {
2094        json!({
2095            "type": "object",
2096            "properties": {
2097                "url": { "type": "string", "description": "URL to fetch and extract" }
2098            },
2099            "required": ["url"]
2100        })
2101    }
2102
2103    async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
2104        use aegis_security::is_safe_url;
2105
2106        let url = args["url"].as_str().unwrap_or("").trim();
2107        if url.is_empty() {
2108            return Ok("Error: url is required".to_string());
2109        }
2110
2111        // Identity gate: web_extract is not read-only (it reaches out to
2112        // the network and returns whatever the remote host says — which can
2113        // include prompt-injection payloads). Reject at identity level for
2114        // trust tiers that can't be trusted with network-derived content.
2115        if ctx.sandbox_enabled {
2116            let identity = ctx.effective_identity();
2117            let policy =
2118                aegis_security::derive_sandbox_policy(&identity, "web_extract", &ctx.cwd);
2119            if policy.deny_all {
2120                return Ok(format!(
2121                    "web_extract denied by sandbox policy: identity '{}' with trust level '{}' is not authorized to fetch external URLs.",
2122                    identity.display(),
2123                    identity.trust_level(),
2124                ));
2125            }
2126            tracing::debug!(
2127                identity = %identity.display(),
2128                trust = %identity.trust_level(),
2129                url = %url,
2130                "web_extract: identity check passed"
2131            );
2132        }
2133
2134        is_safe_url(url).map_err(|e| anyhow::anyhow!("SSRF check failed: {e}"))?;
2135
2136        let client = reqwest::Client::builder()
2137            .user_agent("Mozilla/5.0 (compatible; aegis-agent/0.1)")
2138            .build()?;
2139        let html = client.get(url).send().await?.text().await?;
2140
2141        let document = scraper::Html::parse_document(&html);
2142
2143        // Remove script and style nodes by selecting content from article/main/body
2144        let content = extract_text_content(&document);
2145
2146        // Compress whitespace
2147        let content = compress_whitespace(&content);
2148
2149        const MAX_LEN: usize = 4000;
2150        if content.len() > MAX_LEN {
2151            let truncated = &content[..MAX_LEN];
2152            Ok(format!("{}\n\n... [content truncated at 4000 characters. Use a more specific URL or search for subsections.]", truncated))
2153        } else {
2154            Ok(content)
2155        }
2156    }
2157}
2158
2159fn extract_text_content(document: &scraper::Html) -> String {
2160    // Try article, then main, then body
2161    for selector_str in &["article", "main", "body"] {
2162        if let Ok(sel) = scraper::Selector::parse(selector_str) {
2163            if let Some(elem) = document.select(&sel).next() {
2164                return collect_text_skip_scripts(&elem);
2165            }
2166        }
2167    }
2168    // Fallback: whole document
2169    document.root_element().text().collect::<Vec<_>>().join(" ")
2170}
2171
2172fn collect_text_skip_scripts(elem: &scraper::ElementRef) -> String {
2173    let mut text = String::new();
2174    for node in elem.descendants() {
2175        if let Some(el) = scraper::ElementRef::wrap(node) {
2176            let tag = el.value().name();
2177            if tag == "script" || tag == "style" || tag == "noscript" {
2178                continue;
2179            }
2180        } else if let Some(t) = node.value().as_text() {
2181            text.push_str(t);
2182        }
2183    }
2184    text
2185}
2186
2187fn compress_whitespace(s: &str) -> String {
2188    let mut result = String::with_capacity(s.len());
2189    let mut last_was_space = true;
2190    for c in s.chars() {
2191        if c.is_whitespace() {
2192            if !last_was_space {
2193                result.push(' ');
2194                last_was_space = true;
2195            }
2196        } else {
2197            result.push(c);
2198            last_was_space = false;
2199        }
2200    }
2201    result.trim().to_string()
2202}
2203
2204// ═══════════════════════════════════════════
2205// browser (browser-harness subprocess)
2206// ═══════════════════════════════════════════
2207
2208pub struct BrowserTool {
2209    pub binary: String,
2210    pub timeout_secs: u64,
2211}
2212
2213#[async_trait]
2214impl Tool for BrowserTool {
2215    fn name(&self) -> &str {
2216        "browser"
2217    }
2218    fn description(&self) -> &str {
2219        "Control a real browser via browser-harness. Actions: navigate, click, type, screenshot, extract."
2220    }
2221    fn parameters(&self) -> Value {
2222        json!({
2223            "type": "object",
2224            "properties": {
2225                "action": {
2226                    "type": "string",
2227                    "enum": ["navigate", "click", "type", "screenshot", "extract"],
2228                    "description": "Browser action to perform"
2229                },
2230                "url":     { "type": "string",  "description": "URL to navigate to (navigate)" },
2231                "x":       { "type": "number",  "description": "X coordinate (click, type)" },
2232                "y":       { "type": "number",  "description": "Y coordinate (click, type)" },
2233                "text":    { "type": "string",  "description": "Text to type (type)" },
2234                "selector":{ "type": "string",  "description": "CSS selector to click or extract (optional)" }
2235            },
2236            "required": ["action"]
2237        })
2238    }
2239    async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
2240        let action = args["action"].as_str().unwrap_or("");
2241
2242        let code = match action {
2243            "navigate" => {
2244                let url = args["url"].as_str().unwrap_or("");
2245                if url.is_empty() {
2246                    return Ok("Error: url is required for navigate".to_string());
2247                }
2248                format!(
2249                    "from browser_harness import Browser\nb = Browser()\nb.go('{url}')\nprint(b.title())\n"
2250                )
2251            }
2252            "click" => {
2253                if let Some(sel) = args["selector"].as_str() {
2254                    format!(
2255                        "from browser_harness import Browser\nb = Browser()\nb.click('{sel}')\nprint('clicked')\n"
2256                    )
2257                } else {
2258                    let x = args["x"].as_f64().unwrap_or(0.0);
2259                    let y = args["y"].as_f64().unwrap_or(0.0);
2260                    format!(
2261                        "from browser_harness import Browser\nb = Browser()\nb.click_at({x}, {y})\nprint('clicked')\n"
2262                    )
2263                }
2264            }
2265            "type" => {
2266                let text = args["text"].as_str().unwrap_or("");
2267                if let Some(sel) = args["selector"].as_str() {
2268                    format!(
2269                        "from browser_harness import Browser\nb = Browser()\nb.type('{sel}', '{text}')\nprint('typed')\n"
2270                    )
2271                } else {
2272                    let x = args["x"].as_f64().unwrap_or(0.0);
2273                    let y = args["y"].as_f64().unwrap_or(0.0);
2274                    format!(
2275                        "from browser_harness import Browser\nb = Browser()\nb.click_at({x}, {y})\nb.keyboard_type('{text}')\nprint('typed')\n"
2276                    )
2277                }
2278            }
2279            "screenshot" => {
2280                "from browser_harness import Browser\nb = Browser()\npath = b.screenshot()\nprint(path)\n".to_string()
2281            }
2282            "extract" => {
2283                if let Some(sel) = args["selector"].as_str() {
2284                    format!(
2285                        "from browser_harness import Browser\nb = Browser()\nprint(b.text('{sel}'))\n"
2286                    )
2287                } else {
2288                    "from browser_harness import Browser\nb = Browser()\nprint(b.text('body'))\n".to_string()
2289                }
2290            }
2291            _ => return Ok(format!("Unknown action: {action}. Use: navigate, click, type, screenshot, extract")),
2292        };
2293
2294        let output = tokio::time::timeout(
2295            std::time::Duration::from_secs(self.timeout_secs),
2296            tokio::process::Command::new(&self.binary)
2297                .arg("-c")
2298                .arg(&code)
2299                .stdout(Stdio::piped())
2300                .stderr(Stdio::piped())
2301                .output(),
2302        )
2303        .await
2304        .map_err(|_| anyhow::anyhow!("browser-harness timed out after {}s", self.timeout_secs))??;
2305
2306        let stdout = String::from_utf8_lossy(&output.stdout);
2307        let stderr = String::from_utf8_lossy(&output.stderr);
2308        let code_exit = output.status.code().unwrap_or(-1);
2309
2310        if code_exit != 0 && stdout.is_empty() {
2311            return Ok(format!("[browser error]\n{stderr}"));
2312        }
2313        let mut result = stdout.to_string();
2314        if !stderr.is_empty() {
2315            result.push_str(&format!("\n[stderr] {stderr}"));
2316        }
2317        Ok(result.trim_end().to_string())
2318    }
2319}
2320
2321// ═══════════════════════════════════════════
2322// maigret (OSINT username search)
2323// ═══════════════════════════════════════════
2324
2325pub struct MaigretTool {
2326    pub maigret_path: String,
2327    pub timeout_secs: u64,
2328    pub top_sites: u64,
2329}
2330
2331#[async_trait]
2332impl Tool for MaigretTool {
2333    fn name(&self) -> &str {
2334        "maigret"
2335    }
2336    fn description(&self) -> &str {
2337        "Search for social media accounts and public profiles by username using Maigret OSINT tool. Returns found accounts across hundreds of sites."
2338    }
2339    fn parameters(&self) -> Value {
2340        json!({
2341            "type": "object",
2342            "properties": {
2343                "username": {
2344                    "type": "string",
2345                    "description": "Username to search for"
2346                },
2347                "top_sites": {
2348                    "type": "integer",
2349                    "description": "Limit to top N sites (default: configured value, 0 = all)"
2350                }
2351            },
2352            "required": ["username"]
2353        })
2354    }
2355    async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
2356        let username = args["username"].as_str().unwrap_or("").trim().to_string();
2357        if username.is_empty() {
2358            return Ok("Error: username is required.".to_string());
2359        }
2360        // Basic sanitization — reject suspicious chars
2361        if username.contains([';', '&', '|', '$', '`', '\'', '"', '\n']) {
2362            return Ok("Error: invalid characters in username.".to_string());
2363        }
2364
2365        let top = args["top_sites"].as_u64().unwrap_or(self.top_sites);
2366
2367        let mut cmd = tokio::process::Command::new("python");
2368        cmd.arg("-m")
2369            .arg("maigret")
2370            .arg(&username)
2371            .arg("--no-color")
2372            .arg("--no-progressbar")
2373            .arg("-J")
2374            .arg("ndjson");
2375        if top > 0 {
2376            cmd.arg("--top-sites").arg(top.to_string());
2377        }
2378        cmd.current_dir(&self.maigret_path)
2379            .stdout(Stdio::piped())
2380            .stderr(Stdio::piped());
2381
2382        let output = tokio::time::timeout(
2383            std::time::Duration::from_secs(self.timeout_secs),
2384            cmd.output(),
2385        )
2386        .await
2387        .map_err(|_| anyhow::anyhow!("maigret timed out after {}s", self.timeout_secs))??;
2388
2389        let stdout = String::from_utf8_lossy(&output.stdout);
2390        let stderr = String::from_utf8_lossy(&output.stderr);
2391
2392        // maigret writes reports/report_{username}_ndjson.json
2393        let report_path = std::path::Path::new(&self.maigret_path)
2394            .join("reports")
2395            .join(format!("report_{username}_ndjson.json"));
2396
2397        if report_path.exists() {
2398            let json_raw = tokio::fs::read_to_string(&report_path).await?;
2399            // Parse and produce a clean summary
2400            if let Ok(data) = serde_json::from_str::<Value>(&json_raw) {
2401                return Ok(format_maigret_report(&username, &data));
2402            }
2403        }
2404
2405        // Fallback: return raw stdout
2406        let mut result = stdout.to_string();
2407        if result.is_empty() && !stderr.is_empty() {
2408            result = stderr.to_string();
2409        }
2410        Ok(truncate_output(&result, 20_000))
2411    }
2412}
2413
2414fn format_maigret_report(username: &str, data: &Value) -> String {
2415    let mut found = Vec::new();
2416    let mut not_found = 0usize;
2417
2418    if let Some(obj) = data.as_object() {
2419        for (site, info) in obj {
2420            let status = info["status"]["status"].as_str().unwrap_or("");
2421            if status == "Claimed" {
2422                let url = info["url_user"].as_str()
2423                    .or_else(|| info["url"].as_str())
2424                    .unwrap_or("");
2425                let mut extra = Vec::new();
2426                // Collect interesting fields
2427                for key in &["name", "bio", "location", "followers", "following", "posts"] {
2428                    if let Some(v) = info["ids"].get(key).or_else(|| info.get(key)) {
2429                        if !v.is_null() {
2430                            extra.push(format!("{key}={v}"));
2431                        }
2432                    }
2433                }
2434                let extra_str = if extra.is_empty() {
2435                    String::new()
2436                } else {
2437                    format!(" [{}]", extra.join(", "))
2438                };
2439                found.push(format!("  + {site}: {url}{extra_str}"));
2440            } else {
2441                not_found += 1;
2442            }
2443        }
2444    }
2445
2446    found.sort();
2447    let mut out = format!(
2448        "Maigret results for '{}': {} accounts found, {} not found\n\n",
2449        username,
2450        found.len(),
2451        not_found
2452    );
2453    if found.is_empty() {
2454        out.push_str("No accounts found.\n");
2455    } else {
2456        out.push_str(&found.join("\n"));
2457    }
2458    out
2459}
2460
2461#[cfg(test)]
2462mod tests {
2463    use super::*;
2464    use serde_json::json;
2465
2466    #[test]
2467    fn bg_backend_from_config_parses() {
2468        assert_eq!(BgBackend::from_config("tmux"), BgBackend::Tmux);
2469        assert_eq!(BgBackend::from_config("  TMUX "), BgBackend::Tmux);
2470        assert_eq!(BgBackend::from_config("child"), BgBackend::Child);
2471        assert_eq!(BgBackend::from_config("auto"), BgBackend::Auto);
2472        assert_eq!(BgBackend::from_config("nonsense"), BgBackend::Auto);
2473    }
2474
2475    #[test]
2476    fn bg_backend_forced_passthrough() {
2477        // Non-auto backends are returned as-is regardless of tmux availability.
2478        assert_eq!(BgBackend::Child.effective(), BgBackend::Child);
2479        assert_eq!(BgBackend::Tmux.effective(), BgBackend::Tmux);
2480    }
2481
2482    #[test]
2483    fn tmux_session_name_sanitizes_and_prefixes() {
2484        assert_eq!(tmux_session_name("job1"), "aegis-job1");
2485        assert_eq!(tmux_session_name("a b;c$(x)"), "aegis-a_b_c__x_");
2486        assert_eq!(tmux_session_name("ok-_1"), "aegis-ok-_1");
2487    }
2488
2489    #[test]
2490    fn posix_squote_escapes_single_quotes() {
2491        assert_eq!(posix_squote("abc"), "'abc'");
2492        assert_eq!(posix_squote("a'b"), "'a'\\''b'");
2493    }
2494
2495    fn make_ctx(cwd: std::path::PathBuf) -> ToolContext<'static> {
2496        ToolContext {
2497            cwd,
2498            session_id: "test".to_string(),
2499            approve_fn: &|_| true,
2500            yolo: true,
2501            identity: None,
2502            sandbox_enabled: false,
2503        }
2504    }
2505
2506    #[tokio::test]
2507    async fn test_spawn_without_depends() {
2508        let tool = SpawnTaskTool::new(1);
2509        let ctx = make_ctx(std::env::temp_dir());
2510        // No prompt, no depends_on — should return quickly with "No prompt provided."
2511        let result = tool.execute(json!({}), &ctx).await;
2512        assert!(result.is_ok());
2513        assert_eq!(result.unwrap(), "No prompt provided.");
2514    }
2515
2516    #[tokio::test]
2517    async fn test_spawn_with_empty_depends() {
2518        // Empty depends_on array should behave identically to no depends_on.
2519        let tool = SpawnTaskTool::new(1);
2520        let ctx = make_ctx(std::env::temp_dir());
2521        let result = tool.execute(json!({"depends_on": []}), &ctx).await;
2522        assert!(result.is_ok());
2523        assert_eq!(result.unwrap(), "No prompt provided.");
2524    }
2525
2526    #[tokio::test]
2527    async fn test_wait_for_tasks_empty() {
2528        // Empty task list should return immediately with Ok.
2529        let result = wait_for_tasks(vec![], 5).await;
2530        assert!(result.is_ok());
2531    }
2532
2533    #[tokio::test]
2534    async fn test_wait_for_tasks_timeout() {
2535        // With a task ID that doesn't exist, wait_for_tasks should time out.
2536        // Use very short timeout so test runs fast.
2537        let result = wait_for_tasks(vec!["nonexistent-task-id".to_string()], 1).await;
2538        assert!(result.is_err());
2539        let err = result.unwrap_err().to_string();
2540        assert!(err.contains("Timed out") || err.contains("timed out") || err.contains("nonexistent"));
2541    }
2542}
2543