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