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    // List available sessions if none specified
305    let session_name = match session {
306        Some(s) => s,
307        None => {
308            let sessions = monitor::list_sessions(project_root.as_ref())?;
309            if sessions.is_empty() {
310                anyhow::bail!("No spawn sessions found. Run: scud spawn");
311            }
312            if sessions.len() == 1 {
313                sessions[0].clone()
314            } else {
315                println!("{}", "Available sessions:".cyan().bold());
316                for (i, s) in sessions.iter().enumerate() {
317                    println!("  {} {}", format!("[{}]", i + 1).dimmed(), s);
318                }
319                anyhow::bail!("Multiple sessions found. Specify one with --session <name>");
320            }
321        }
322    };
323
324    tui::run(project_root, &session_name)
325}
326
327/// List spawn sessions
328pub fn run_sessions(project_root: Option<PathBuf>, verbose: bool) -> Result<()> {
329    use colored::Colorize;
330
331    let sessions = monitor::list_sessions(project_root.as_ref())?;
332
333    if sessions.is_empty() {
334        println!("{}", "No spawn sessions found.".dimmed());
335        println!("Run: scud spawn -m --limit 3");
336        return Ok(());
337    }
338
339    println!("{}", "Spawn Sessions:".cyan().bold());
340    println!();
341
342    for session_name in &sessions {
343        if verbose {
344            // Load full session data
345            match monitor::load_session(project_root.as_ref(), session_name) {
346                Ok(session) => {
347                    let stats = monitor::SpawnStats::from(&session);
348                    println!(
349                        "  {} {} agents ({} running, {} done)",
350                        session_name.cyan(),
351                        format!("[{}]", stats.total_agents).dimmed(),
352                        stats.running.to_string().green(),
353                        stats.completed.to_string().blue()
354                    );
355                    println!(
356                        "    {} Tag: {}, Terminal: {}",
357                        "│".dimmed(),
358                        session.tag,
359                        session.terminal
360                    );
361                    println!(
362                        "    {} Created: {}",
363                        "└".dimmed(),
364                        session.created_at.dimmed()
365                    );
366                    println!();
367                }
368                Err(_) => {
369                    println!("  {} {}", session_name, "(unable to load)".red());
370                }
371            }
372        } else {
373            println!("  {}", session_name);
374        }
375    }
376
377    if !verbose {
378        println!();
379        println!(
380            "{}",
381            "Use -v for details, or: scud monitor --session <name>".dimmed()
382        );
383    }
384
385    Ok(())
386}
387
388/// Get ready tasks for spawning
389fn get_ready_tasks<'a>(
390    all_phases: &'a std::collections::HashMap<String, crate::models::phase::Phase>,
391    all_tasks_flat: &[&Task],
392    phase_tag: &str,
393    limit: usize,
394    all_tags: bool,
395) -> Result<Vec<TaskInfo<'a>>> {
396    let mut ready_tasks: Vec<TaskInfo<'a>> = Vec::new();
397
398    if all_tags {
399        // Collect from all phases
400        for (tag, phase) in all_phases {
401            for task in &phase.tasks {
402                if is_task_ready(task, phase, all_tasks_flat) {
403                    ready_tasks.push(TaskInfo {
404                        task,
405                        tag: tag.clone(),
406                    });
407                }
408            }
409        }
410    } else {
411        // Single phase
412        let phase = all_phases
413            .get(phase_tag)
414            .ok_or_else(|| anyhow::anyhow!("Phase '{}' not found", phase_tag))?;
415
416        for task in &phase.tasks {
417            if is_task_ready(task, phase, all_tasks_flat) {
418                ready_tasks.push(TaskInfo {
419                    task,
420                    tag: phase_tag.to_string(),
421                });
422            }
423        }
424    }
425
426    // Truncate to limit
427    ready_tasks.truncate(limit);
428
429    Ok(ready_tasks)
430}
431
432/// Check if a task is ready to be spawned
433fn is_task_ready(
434    task: &Task,
435    phase: &crate::models::phase::Phase,
436    all_tasks_flat: &[&Task],
437) -> bool {
438    // Must be pending
439    if task.status != TaskStatus::Pending {
440        return false;
441    }
442
443    // Must not be expanded (we want subtasks, not parent tasks)
444    if task.is_expanded() {
445        return false;
446    }
447
448    // If it's a subtask, parent must be expanded
449    if let Some(ref parent_id) = task.parent_id {
450        let parent_expanded = phase
451            .get_task(parent_id)
452            .map(|p| p.is_expanded())
453            .unwrap_or(false);
454        if !parent_expanded {
455            return false;
456        }
457    }
458
459    // All dependencies must be met
460    task.has_dependencies_met_refs(all_tasks_flat)
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use crate::models::phase::Phase;
467    use crate::models::task::Task;
468
469    #[test]
470    fn test_is_task_ready_basic() {
471        let mut phase = Phase::new("test".to_string());
472        let task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
473        phase.add_task(task);
474
475        let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
476        assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
477    }
478
479    #[test]
480    fn test_is_task_ready_in_progress() {
481        let mut phase = Phase::new("test".to_string());
482        let mut task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
483        task.set_status(TaskStatus::InProgress);
484        phase.add_task(task);
485
486        let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
487        assert!(!is_task_ready(&phase.tasks[0], &phase, &all_tasks));
488    }
489
490    #[test]
491    fn test_is_task_ready_blocked_by_deps() {
492        let mut phase = Phase::new("test".to_string());
493
494        let task1 = Task::new("1".to_string(), "First".to_string(), "Desc".to_string());
495
496        let mut task2 = Task::new("2".to_string(), "Second".to_string(), "Desc".to_string());
497        task2.dependencies = vec!["1".to_string()];
498
499        phase.add_task(task1);
500        phase.add_task(task2);
501
502        let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
503
504        // Task 1 is ready (no deps)
505        assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
506        // Task 2 is NOT ready (dep not done)
507        assert!(!is_task_ready(&phase.tasks[1], &phase, &all_tasks));
508    }
509}