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