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