scud/commands/spawn/
mod.rs

1//! Spawn command - Launch parallel Claude Code agents in terminal windows
2//!
3//! This module provides functionality to:
4//! - Detect available terminal emulators (Kitty, WezTerm, iTerm2, tmux)
5//! - Spawn multiple terminal windows with Claude Code sessions
6//! - Generate task-specific prompts for each agent
7//! - Track spawn session state for TUI integration
8//! - Install Claude Code hooks for automatic task completion
9
10pub mod agent;
11pub mod hooks;
12pub mod monitor;
13pub mod terminal;
14pub mod tui;
15
16use anyhow::Result;
17use colored::Colorize;
18use std::path::PathBuf;
19use std::thread;
20use std::time::Duration;
21
22use crate::commands::helpers::{flatten_all_tasks, resolve_group_tag};
23use crate::models::task::{Task, TaskStatus};
24use crate::storage::Storage;
25
26use self::monitor::SpawnSession;
27use self::terminal::{parse_terminal, Terminal};
28
29/// Information about a task to spawn
30struct TaskInfo<'a> {
31    task: &'a Task,
32    tag: String,
33}
34
35/// Main entry point for the spawn command
36pub fn run(
37    project_root: Option<PathBuf>,
38    tag: Option<&str>,
39    limit: usize,
40    all_tags: bool,
41    terminal_arg: &str,
42    dry_run: bool,
43    session: Option<String>,
44    attach: bool,
45    monitor: bool,
46    claim: bool,
47) -> Result<()> {
48    let storage = Storage::new(project_root.clone());
49
50    if !storage.is_initialized() {
51        anyhow::bail!("SCUD not initialized. Run: scud init");
52    }
53
54    // Load all phases for cross-tag dependency checking
55    let all_phases = storage.load_tasks()?;
56    let all_tasks_flat = flatten_all_tasks(&all_phases);
57
58    // Determine phase tag
59    let phase_tag = if all_tags {
60        "all".to_string()
61    } else {
62        resolve_group_tag(&storage, tag, true)?
63    };
64
65    // Get ready tasks
66    let ready_tasks = get_ready_tasks(&all_phases, &all_tasks_flat, &phase_tag, limit, all_tags)?;
67
68    if ready_tasks.is_empty() {
69        println!("{}", "No ready tasks to spawn.".yellow());
70        println!("Check: scud list --status pending");
71        return Ok(());
72    }
73
74    // Detect or parse terminal
75    let terminal = parse_terminal(terminal_arg)?;
76    terminal::check_terminal_available(&terminal)?;
77
78    // Generate session name
79    let session_name = session.unwrap_or_else(|| format!("scud-{}", phase_tag));
80
81    // Display spawn plan
82    println!("{}", "SCUD Spawn".cyan().bold());
83    println!("{}", "═".repeat(50));
84    println!("{:<20} {}", "Terminal:".dimmed(), terminal.name().green());
85    println!("{:<20} {}", "Session:".dimmed(), session_name.cyan());
86    println!("{:<20} {}", "Tasks:".dimmed(), ready_tasks.len());
87    println!();
88
89    for (i, info) in ready_tasks.iter().enumerate() {
90        println!(
91            "  {} {} {} | {}",
92            format!("[{}]", i + 1).dimmed(),
93            info.tag.dimmed(),
94            info.task.id.cyan(),
95            info.task.title
96        );
97    }
98    println!();
99
100    if dry_run {
101        println!("{}", "Dry run - no terminals spawned.".yellow());
102        return Ok(());
103    }
104
105    // Get working directory
106    let working_dir = project_root
107        .clone()
108        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
109
110    // Check and install Claude Code hooks for automatic task completion
111    if !hooks::hooks_installed(&working_dir) {
112        println!(
113            "{}",
114            "Installing Claude Code hooks for task completion...".dimmed()
115        );
116        if let Err(e) = hooks::install_hooks(&working_dir) {
117            println!(
118                "  {} Hook installation: {}",
119                "!".yellow(),
120                e.to_string().dimmed()
121            );
122        } else {
123            println!(
124                "  {} Hooks installed (tasks auto-complete on agent stop)",
125                "✓".green()
126            );
127        }
128    }
129
130    // Create spawn session metadata
131    let mut spawn_session = SpawnSession::new(
132        &session_name,
133        &phase_tag,
134        terminal.name(),
135        &working_dir.to_string_lossy(),
136    );
137
138    // Spawn agents
139    println!("{}", "Spawning agents...".green());
140
141    let mut success_count = 0;
142    let mut claimed_tasks: Vec<(String, String)> = Vec::new(); // (task_id, tag) pairs for claiming
143
144    for info in &ready_tasks {
145        let prompt = agent::generate_prompt(info.task, &info.tag);
146
147        match terminal::spawn_terminal(
148            &terminal,
149            &info.task.id,
150            &prompt,
151            &working_dir,
152            &session_name,
153        ) {
154            Ok(()) => {
155                println!(
156                    "  {} Spawned: {} | {}",
157                    "✓".green(),
158                    info.task.id.cyan(),
159                    info.task.title.dimmed()
160                );
161                spawn_session.add_agent(&info.task.id, &info.task.title, &info.tag);
162                success_count += 1;
163
164                // Track for claiming
165                if claim {
166                    claimed_tasks.push((info.task.id.clone(), info.tag.clone()));
167                }
168            }
169            Err(e) => {
170                println!("  {} Failed: {} - {}", "✗".red(), info.task.id.red(), e);
171            }
172        }
173
174        // Small delay between spawns to avoid overwhelming the system
175        if success_count < ready_tasks.len() {
176            thread::sleep(Duration::from_millis(500));
177        }
178    }
179
180    // Claim tasks (mark as in-progress) if requested
181    if claim && !claimed_tasks.is_empty() {
182        println!();
183        println!("{}", "Claiming tasks...".dimmed());
184        for (task_id, task_tag) in &claimed_tasks {
185            // Reload phase and update task status
186            match storage.load_group(task_tag) {
187                Ok(mut phase) => {
188                    if let Some(task) = phase.get_task_mut(task_id) {
189                        task.set_status(TaskStatus::InProgress);
190                        if let Err(e) = storage.update_group(task_tag, &phase) {
191                            println!(
192                                "  {} Claim failed: {} - {}",
193                                "!".yellow(),
194                                task_id,
195                                e.to_string().dimmed()
196                            );
197                        } else {
198                            println!(
199                                "  {} Claimed: {} → {}",
200                                "✓".green(),
201                                task_id.cyan(),
202                                "in-progress".yellow()
203                            );
204                        }
205                    }
206                }
207                Err(e) => {
208                    println!(
209                        "  {} Claim failed: {} - {}",
210                        "!".yellow(),
211                        task_id,
212                        e.to_string().dimmed()
213                    );
214                }
215            }
216        }
217    }
218
219    // Setup control window for tmux
220    if terminal == Terminal::Tmux {
221        if let Err(e) = terminal::setup_tmux_control_window(&session_name, &phase_tag) {
222            println!(
223                "  {} Control window setup: {}",
224                "!".yellow(),
225                e.to_string().dimmed()
226            );
227        }
228    }
229
230    // Save session metadata
231    if let Err(e) = monitor::save_session(project_root.as_ref(), &spawn_session) {
232        println!(
233            "  {} Session metadata: {}",
234            "!".yellow(),
235            e.to_string().dimmed()
236        );
237    }
238
239    // Summary
240    println!();
241    println!(
242        "{} {} of {} agents spawned",
243        "Summary:".blue().bold(),
244        success_count,
245        ready_tasks.len()
246    );
247
248    if terminal == Terminal::Tmux {
249        println!();
250        println!(
251            "To attach: {}",
252            format!("tmux attach -t {}", session_name).cyan()
253        );
254        println!(
255            "To list:   {}",
256            format!("tmux list-windows -t {}", session_name).dimmed()
257        );
258    }
259
260    // Monitor takes priority over attach
261    if monitor {
262        println!();
263        println!("Starting monitor...");
264        // Small delay to let agents start
265        thread::sleep(Duration::from_secs(1));
266        return tui::run(project_root, &session_name);
267    }
268
269    // Attach if requested (fallback)
270    if attach && terminal == Terminal::Tmux {
271        println!();
272        println!("Attaching to session...");
273        terminal::tmux_attach(&session_name)?;
274    }
275
276    Ok(())
277}
278
279/// Run the TUI monitor for a spawn session
280pub fn run_monitor(project_root: Option<PathBuf>, session: Option<String>) -> Result<()> {
281    use colored::Colorize;
282
283    // List available sessions if none specified
284    let session_name = match session {
285        Some(s) => s,
286        None => {
287            let sessions = monitor::list_sessions(project_root.as_ref())?;
288            if sessions.is_empty() {
289                anyhow::bail!("No spawn sessions found. Run: scud spawn");
290            }
291            if sessions.len() == 1 {
292                sessions[0].clone()
293            } else {
294                println!("{}", "Available sessions:".cyan().bold());
295                for (i, s) in sessions.iter().enumerate() {
296                    println!("  {} {}", format!("[{}]", i + 1).dimmed(), s);
297                }
298                anyhow::bail!("Multiple sessions found. Specify one with --session <name>");
299            }
300        }
301    };
302
303    tui::run(project_root, &session_name)
304}
305
306/// List spawn sessions
307pub fn run_sessions(project_root: Option<PathBuf>, verbose: bool) -> Result<()> {
308    use colored::Colorize;
309
310    let sessions = monitor::list_sessions(project_root.as_ref())?;
311
312    if sessions.is_empty() {
313        println!("{}", "No spawn sessions found.".dimmed());
314        println!("Run: scud spawn -m --limit 3");
315        return Ok(());
316    }
317
318    println!("{}", "Spawn Sessions:".cyan().bold());
319    println!();
320
321    for session_name in &sessions {
322        if verbose {
323            // Load full session data
324            match monitor::load_session(project_root.as_ref(), session_name) {
325                Ok(session) => {
326                    let stats = monitor::SpawnStats::from(&session);
327                    println!(
328                        "  {} {} agents ({} running, {} done)",
329                        session_name.cyan(),
330                        format!("[{}]", stats.total_agents).dimmed(),
331                        stats.running.to_string().green(),
332                        stats.completed.to_string().blue()
333                    );
334                    println!(
335                        "    {} Tag: {}, Terminal: {}",
336                        "│".dimmed(),
337                        session.tag,
338                        session.terminal
339                    );
340                    println!(
341                        "    {} Created: {}",
342                        "└".dimmed(),
343                        session.created_at.dimmed()
344                    );
345                    println!();
346                }
347                Err(_) => {
348                    println!("  {} {}", session_name, "(unable to load)".red());
349                }
350            }
351        } else {
352            println!("  {}", session_name);
353        }
354    }
355
356    if !verbose {
357        println!();
358        println!(
359            "{}",
360            "Use -v for details, or: scud monitor --session <name>".dimmed()
361        );
362    }
363
364    Ok(())
365}
366
367/// Get ready tasks for spawning
368fn get_ready_tasks<'a>(
369    all_phases: &'a std::collections::HashMap<String, crate::models::phase::Phase>,
370    all_tasks_flat: &[&Task],
371    phase_tag: &str,
372    limit: usize,
373    all_tags: bool,
374) -> Result<Vec<TaskInfo<'a>>> {
375    let mut ready_tasks: Vec<TaskInfo<'a>> = Vec::new();
376
377    if all_tags {
378        // Collect from all phases
379        for (tag, phase) in all_phases {
380            for task in &phase.tasks {
381                if is_task_ready(task, phase, all_tasks_flat) {
382                    ready_tasks.push(TaskInfo {
383                        task,
384                        tag: tag.clone(),
385                    });
386                }
387            }
388        }
389    } else {
390        // Single phase
391        let phase = all_phases
392            .get(phase_tag)
393            .ok_or_else(|| anyhow::anyhow!("Phase '{}' not found", phase_tag))?;
394
395        for task in &phase.tasks {
396            if is_task_ready(task, phase, all_tasks_flat) {
397                ready_tasks.push(TaskInfo {
398                    task,
399                    tag: phase_tag.to_string(),
400                });
401            }
402        }
403    }
404
405    // Truncate to limit
406    ready_tasks.truncate(limit);
407
408    Ok(ready_tasks)
409}
410
411/// Check if a task is ready to be spawned
412fn is_task_ready(
413    task: &Task,
414    phase: &crate::models::phase::Phase,
415    all_tasks_flat: &[&Task],
416) -> bool {
417    // Must be pending
418    if task.status != TaskStatus::Pending {
419        return false;
420    }
421
422    // Must not be expanded (we want subtasks, not parent tasks)
423    if task.is_expanded() {
424        return false;
425    }
426
427    // If it's a subtask, parent must be expanded
428    if let Some(ref parent_id) = task.parent_id {
429        let parent_expanded = phase
430            .get_task(parent_id)
431            .map(|p| p.is_expanded())
432            .unwrap_or(false);
433        if !parent_expanded {
434            return false;
435        }
436    }
437
438    // All dependencies must be met
439    task.has_dependencies_met_refs(all_tasks_flat)
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use crate::models::phase::Phase;
446    use crate::models::task::Task;
447
448    #[test]
449    fn test_is_task_ready_basic() {
450        let mut phase = Phase::new("test".to_string());
451        let task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
452        phase.add_task(task);
453
454        let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
455        assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
456    }
457
458    #[test]
459    fn test_is_task_ready_in_progress() {
460        let mut phase = Phase::new("test".to_string());
461        let mut task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
462        task.set_status(TaskStatus::InProgress);
463        phase.add_task(task);
464
465        let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
466        assert!(!is_task_ready(&phase.tasks[0], &phase, &all_tasks));
467    }
468
469    #[test]
470    fn test_is_task_ready_blocked_by_deps() {
471        let mut phase = Phase::new("test".to_string());
472
473        let task1 = Task::new("1".to_string(), "First".to_string(), "Desc".to_string());
474
475        let mut task2 = Task::new("2".to_string(), "Second".to_string(), "Desc".to_string());
476        task2.dependencies = vec!["1".to_string()];
477
478        phase.add_task(task1);
479        phase.add_task(task2);
480
481        let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
482
483        // Task 1 is ready (no deps)
484        assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
485        // Task 2 is NOT ready (dep not done)
486        assert!(!is_task_ready(&phase.tasks[1], &phase, &all_tasks));
487    }
488}