scud/commands/spawn/
mod.rs

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