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(()) => {
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                );
215                spawn_session.add_agent(&info.task.id, &info.task.title, &info.tag);
216                success_count += 1;
217
218                // Track for claiming
219                if claim {
220                    claimed_tasks.push((info.task.id.clone(), info.tag.clone()));
221                }
222            }
223            Err(e) => {
224                println!("  {} Failed: {} - {}", "✗".red(), info.task.id.red(), e);
225            }
226        }
227
228        // Small delay between spawns to avoid overwhelming the system
229        if success_count < ready_tasks.len() {
230            thread::sleep(Duration::from_millis(500));
231        }
232    }
233
234    // Claim tasks (mark as in-progress) if requested
235    if claim && !claimed_tasks.is_empty() {
236        println!();
237        println!("{}", "Claiming tasks...".dimmed());
238        for (task_id, task_tag) in &claimed_tasks {
239            // Reload phase and update task status
240            match storage.load_group(task_tag) {
241                Ok(mut phase) => {
242                    if let Some(task) = phase.get_task_mut(task_id) {
243                        task.set_status(TaskStatus::InProgress);
244                        if let Err(e) = storage.update_group(task_tag, &phase) {
245                            println!(
246                                "  {} Claim failed: {} - {}",
247                                "!".yellow(),
248                                task_id,
249                                e.to_string().dimmed()
250                            );
251                        } else {
252                            println!(
253                                "  {} Claimed: {} → {}",
254                                "✓".green(),
255                                task_id.cyan(),
256                                "in-progress".yellow()
257                            );
258                        }
259                    }
260                }
261                Err(e) => {
262                    println!(
263                        "  {} Claim failed: {} - {}",
264                        "!".yellow(),
265                        task_id,
266                        e.to_string().dimmed()
267                    );
268                }
269            }
270        }
271    }
272
273    // Setup control window for tmux
274    if let Err(e) = terminal::setup_tmux_control_window(&session_name, &phase_tag) {
275        println!(
276            "  {} Control window setup: {}",
277            "!".yellow(),
278            e.to_string().dimmed()
279        );
280    }
281
282    // Save session metadata
283    if let Err(e) = monitor::save_session(project_root.as_ref(), &spawn_session) {
284        println!(
285            "  {} Session metadata: {}",
286            "!".yellow(),
287            e.to_string().dimmed()
288        );
289    }
290
291    // Summary
292    println!();
293    println!(
294        "{} {} of {} agents spawned",
295        "Summary:".blue().bold(),
296        success_count,
297        ready_tasks.len()
298    );
299
300    println!();
301    println!(
302        "To attach: {}",
303        format!("tmux attach -t {}", session_name).cyan()
304    );
305    println!(
306        "To list:   {}",
307        format!("tmux list-windows -t {}", session_name).dimmed()
308    );
309
310    // Monitor takes priority over attach
311    if monitor {
312        println!();
313        println!("Starting monitor...");
314        // Small delay to let agents start
315        thread::sleep(Duration::from_secs(1));
316        return tui::run(project_root, &session_name);
317    }
318
319    // Attach if requested
320    if attach {
321        println!();
322        println!("Attaching to session...");
323        terminal::tmux_attach(&session_name)?;
324    }
325
326    Ok(())
327}
328
329/// Run the TUI monitor for a spawn session
330pub fn run_monitor(project_root: Option<PathBuf>, session: Option<String>) -> Result<()> {
331    use colored::Colorize;
332
333    // List available sessions if none specified
334    let session_name = match session {
335        Some(s) => s,
336        None => {
337            let sessions = monitor::list_sessions(project_root.as_ref())?;
338            if sessions.is_empty() {
339                anyhow::bail!("No spawn sessions found. Run: scud spawn");
340            }
341            if sessions.len() == 1 {
342                sessions[0].clone()
343            } else {
344                println!("{}", "Available sessions:".cyan().bold());
345                for (i, s) in sessions.iter().enumerate() {
346                    println!("  {} {}", format!("[{}]", i + 1).dimmed(), s);
347                }
348                anyhow::bail!("Multiple sessions found. Specify one with --session <name>");
349            }
350        }
351    };
352
353    tui::run(project_root, &session_name)
354}
355
356/// List spawn sessions
357pub fn run_sessions(project_root: Option<PathBuf>, verbose: bool) -> Result<()> {
358    use colored::Colorize;
359
360    let sessions = monitor::list_sessions(project_root.as_ref())?;
361
362    if sessions.is_empty() {
363        println!("{}", "No spawn sessions found.".dimmed());
364        println!("Run: scud spawn -m --limit 3");
365        return Ok(());
366    }
367
368    println!("{}", "Spawn Sessions:".cyan().bold());
369    println!();
370
371    for session_name in &sessions {
372        if verbose {
373            // Load full session data
374            match monitor::load_session(project_root.as_ref(), session_name) {
375                Ok(session) => {
376                    let stats = monitor::SpawnStats::from(&session);
377                    println!(
378                        "  {} {} agents ({} running, {} done)",
379                        session_name.cyan(),
380                        format!("[{}]", stats.total_agents).dimmed(),
381                        stats.running.to_string().green(),
382                        stats.completed.to_string().blue()
383                    );
384                    println!(
385                        "    {} Tag: {}, Terminal: {}",
386                        "│".dimmed(),
387                        session.tag,
388                        session.terminal
389                    );
390                    println!(
391                        "    {} Created: {}",
392                        "└".dimmed(),
393                        session.created_at.dimmed()
394                    );
395                    println!();
396                }
397                Err(_) => {
398                    println!("  {} {}", session_name, "(unable to load)".red());
399                }
400            }
401        } else {
402            println!("  {}", session_name);
403        }
404    }
405
406    if !verbose {
407        println!();
408        println!(
409            "{}",
410            "Use -v for details, or: scud monitor --session <name>".dimmed()
411        );
412    }
413
414    Ok(())
415}
416
417/// Get ready tasks for spawning
418fn get_ready_tasks<'a>(
419    all_phases: &'a std::collections::HashMap<String, crate::models::phase::Phase>,
420    all_tasks_flat: &[&Task],
421    phase_tag: &str,
422    limit: usize,
423    all_tags: bool,
424) -> Result<Vec<TaskInfo<'a>>> {
425    let mut ready_tasks: Vec<TaskInfo<'a>> = Vec::new();
426
427    if all_tags {
428        // Collect from all phases
429        for (tag, phase) in all_phases {
430            for task in &phase.tasks {
431                if is_task_ready(task, phase, all_tasks_flat) {
432                    ready_tasks.push(TaskInfo {
433                        task,
434                        tag: tag.clone(),
435                    });
436                }
437            }
438        }
439    } else {
440        // Single phase
441        let phase = all_phases
442            .get(phase_tag)
443            .ok_or_else(|| anyhow::anyhow!("Phase '{}' not found", phase_tag))?;
444
445        for task in &phase.tasks {
446            if is_task_ready(task, phase, all_tasks_flat) {
447                ready_tasks.push(TaskInfo {
448                    task,
449                    tag: phase_tag.to_string(),
450                });
451            }
452        }
453    }
454
455    // Truncate to limit
456    ready_tasks.truncate(limit);
457
458    Ok(ready_tasks)
459}
460
461/// Check if a task is ready to be spawned
462fn is_task_ready(
463    task: &Task,
464    phase: &crate::models::phase::Phase,
465    all_tasks_flat: &[&Task],
466) -> bool {
467    // Must be pending
468    if task.status != TaskStatus::Pending {
469        return false;
470    }
471
472    // Must not be expanded (we want subtasks, not parent tasks)
473    if task.is_expanded() {
474        return false;
475    }
476
477    // If it's a subtask, parent must be expanded
478    if let Some(ref parent_id) = task.parent_id {
479        let parent_expanded = phase
480            .get_task(parent_id)
481            .map(|p| p.is_expanded())
482            .unwrap_or(false);
483        if !parent_expanded {
484            return false;
485        }
486    }
487
488    // All dependencies must be met
489    task.has_dependencies_met_refs(all_tasks_flat)
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use crate::models::phase::Phase;
496    use crate::models::task::Task;
497
498    #[test]
499    fn test_is_task_ready_basic() {
500        let mut phase = Phase::new("test".to_string());
501        let task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
502        phase.add_task(task);
503
504        let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
505        assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
506    }
507
508    #[test]
509    fn test_is_task_ready_in_progress() {
510        let mut phase = Phase::new("test".to_string());
511        let mut task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
512        task.set_status(TaskStatus::InProgress);
513        phase.add_task(task);
514
515        let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
516        assert!(!is_task_ready(&phase.tasks[0], &phase, &all_tasks));
517    }
518
519    #[test]
520    fn test_is_task_ready_blocked_by_deps() {
521        let mut phase = Phase::new("test".to_string());
522
523        let task1 = Task::new("1".to_string(), "First".to_string(), "Desc".to_string());
524
525        let mut task2 = Task::new("2".to_string(), "Second".to_string(), "Desc".to_string());
526        task2.dependencies = vec!["1".to_string()];
527
528        phase.add_task(task1);
529        phase.add_task(task2);
530
531        let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
532
533        // Task 1 is ready (no deps)
534        assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
535        // Task 2 is NOT ready (dep not done)
536        assert!(!is_task_ready(&phase.tasks[1], &phase, &all_tasks));
537    }
538}