Skip to main content

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;
24use crate::sync::claude_tasks;
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    // Sync tasks to Claude Code's Tasks format
137    // This enables agents to see tasks via TaskList tool
138    let task_list_id = claude_tasks::task_list_id(&phase_tag);
139    if !all_tags {
140        // Single tag mode - sync the specific phase
141        if let Some(phase) = all_phases.get(&phase_tag) {
142            match claude_tasks::sync_phase(phase, &phase_tag) {
143                Ok(sync_path) => {
144                    let path_str: String = sync_path.display().to_string();
145                    println!(
146                        "  {} Synced tasks to: {}",
147                        "✓".green(),
148                        path_str.dimmed()
149                    );
150                }
151                Err(e) => {
152                    let err_str: String = e.to_string();
153                    println!(
154                        "  {} Task sync failed: {}",
155                        "!".yellow(),
156                        err_str.dimmed()
157                    );
158                }
159            }
160        }
161    } else {
162        // All tags mode - sync all phases
163        match claude_tasks::sync_phases(&all_phases) {
164            Ok(paths) => {
165                let count: usize = paths.len();
166                println!(
167                    "  {} Synced {} phases to Claude Tasks format",
168                    "✓".green(),
169                    count
170                );
171            }
172            Err(e) => {
173                let err_str: String = e.to_string();
174                println!(
175                    "  {} Task sync failed: {}",
176                    "!".yellow(),
177                    err_str.dimmed()
178                );
179            }
180        }
181    }
182
183    // Create spawn session metadata
184    let mut spawn_session = SpawnSession::new(
185        &session_name,
186        &phase_tag,
187        "tmux",
188        &working_dir.to_string_lossy(),
189    );
190
191    // Spawn agents
192    println!("{}", "Spawning agents...".green());
193
194    let mut success_count = 0;
195    let mut claimed_tasks: Vec<(String, String)> = Vec::new(); // (task_id, tag) pairs for claiming
196
197    for info in &ready_tasks {
198        // Resolve agent config (harness, model, prompt) from task's agent_type
199        let config = agent::resolve_agent_config(
200            info.task,
201            &info.tag,
202            harness,
203            Some(model_arg),
204            &working_dir,
205        );
206
207        // Warn if agent type was specified but definition not found
208        if info.task.agent_type.is_some() && !config.from_agent_def {
209            println!(
210                "  {} Agent '{}' not found, using CLI defaults",
211                "!".yellow(),
212                info.task.agent_type.as_deref().unwrap_or("unknown")
213            );
214        }
215
216        match terminal::spawn_terminal_with_task_list(
217            &info.task.id,
218            &config.prompt,
219            &working_dir,
220            &session_name,
221            config.harness,
222            config.model.as_deref(),
223            &task_list_id,
224        ) {
225            Ok(window_index) => {
226                println!(
227                    "  {} Spawned: {} | {} [{}] {}:{}",
228                    "✓".green(),
229                    info.task.id.cyan(),
230                    info.task.title.dimmed(),
231                    config.display_info().dimmed(),
232                    session_name.dimmed(),
233                    window_index.dimmed(),
234                );
235                spawn_session.add_agent(&info.task.id, &info.task.title, &info.tag);
236                success_count += 1;
237
238                // Track for claiming
239                if claim {
240                    claimed_tasks.push((info.task.id.clone(), info.tag.clone()));
241                }
242            }
243            Err(e) => {
244                println!("  {} Failed: {} - {}", "✗".red(), info.task.id.red(), e);
245            }
246        }
247
248        // Small delay between spawns to avoid overwhelming the system
249        if success_count < ready_tasks.len() {
250            thread::sleep(Duration::from_millis(500));
251        }
252    }
253
254    // Claim tasks (mark as in-progress) if requested
255    if claim && !claimed_tasks.is_empty() {
256        println!();
257        println!("{}", "Claiming tasks...".dimmed());
258        for (task_id, task_tag) in &claimed_tasks {
259            // Reload phase and update task status
260            match storage.load_group(task_tag) {
261                Ok(mut phase) => {
262                    if let Some(task) = phase.get_task_mut(task_id) {
263                        task.set_status(TaskStatus::InProgress);
264                        if let Err(e) = storage.update_group(task_tag, &phase) {
265                            println!(
266                                "  {} Claim failed: {} - {}",
267                                "!".yellow(),
268                                task_id,
269                                e.to_string().dimmed()
270                            );
271                        } else {
272                            println!(
273                                "  {} Claimed: {} → {}",
274                                "✓".green(),
275                                task_id.cyan(),
276                                "in-progress".yellow()
277                            );
278                        }
279                    }
280                }
281                Err(e) => {
282                    println!(
283                        "  {} Claim failed: {} - {}",
284                        "!".yellow(),
285                        task_id,
286                        e.to_string().dimmed()
287                    );
288                }
289            }
290        }
291    }
292
293    // Setup control window for tmux
294    if let Err(e) = terminal::setup_tmux_control_window(&session_name, &phase_tag) {
295        println!(
296            "  {} Control window setup: {}",
297            "!".yellow(),
298            e.to_string().dimmed()
299        );
300    }
301
302    // Save session metadata
303    if let Err(e) = monitor::save_session(project_root.as_ref(), &spawn_session) {
304        println!(
305            "  {} Session metadata: {}",
306            "!".yellow(),
307            e.to_string().dimmed()
308        );
309    }
310
311    // Summary
312    println!();
313    println!(
314        "{} {} of {} agents spawned",
315        "Summary:".blue().bold(),
316        success_count,
317        ready_tasks.len()
318    );
319
320    println!();
321    println!(
322        "To attach: {}",
323        format!("tmux attach -t {}", session_name).cyan()
324    );
325    println!(
326        "To list:   {}",
327        format!("tmux list-windows -t {}", session_name).dimmed()
328    );
329
330    // Monitor takes priority over attach
331    if monitor {
332        println!();
333        println!("Starting monitor...");
334        // Small delay to let agents start
335        thread::sleep(Duration::from_secs(1));
336        return tui::run(project_root, &session_name, false); // spawn mode, not swarm
337    }
338
339    // Attach if requested
340    if attach {
341        println!();
342        println!("Attaching to session...");
343        terminal::tmux_attach(&session_name)?;
344    }
345
346    Ok(())
347}
348
349/// Run the TUI monitor for a spawn or swarm session
350pub fn run_monitor(
351    project_root: Option<PathBuf>,
352    session: Option<String>,
353    swarm_mode: bool,
354) -> Result<()> {
355    use crate::commands::swarm::session as swarm_session;
356    use colored::Colorize;
357
358    // Debug: show project root being used
359    let project_root_display = project_root
360        .as_ref()
361        .and_then(|p| p.to_str())
362        .unwrap_or("current directory");
363
364    let mode_label = if swarm_mode { "swarm" } else { "spawn" };
365    eprintln!(
366        "{} Monitor ({}) looking for sessions in: {}",
367        "DEBUG:".yellow(),
368        mode_label,
369        project_root_display
370    );
371
372    // List available sessions based on mode
373    let session_name = match session {
374        Some(s) => s,
375        None => {
376            let sessions = if swarm_mode {
377                swarm_session::list_sessions(project_root.as_ref())?
378            } else {
379                monitor::list_sessions(project_root.as_ref())?
380            };
381            eprintln!(
382                "{} Found {} {} session(s): {:?}",
383                "DEBUG:".yellow(),
384                sessions.len(),
385                mode_label,
386                sessions
387            );
388            if sessions.is_empty() {
389                let cmd = if swarm_mode { "scud swarm" } else { "scud spawn" };
390                eprintln!(
391                    "{} No {} sessions found in: {}",
392                    "DEBUG:".yellow(),
393                    mode_label,
394                    project_root_display
395                );
396                eprintln!(
397                    "{} Run: {} --project {} (if needed)",
398                    "HINT:".cyan(),
399                    cmd,
400                    project_root_display
401                );
402                anyhow::bail!("No {} sessions found. Run: {}", mode_label, cmd);
403            }
404            if sessions.len() == 1 {
405                sessions[0].clone()
406            } else {
407                println!("{}", format!("Available {} sessions:", mode_label).cyan().bold());
408                for (i, s) in sessions.iter().enumerate() {
409                    println!("  {} {}", format!("[{}]", i + 1).dimmed(), s);
410                }
411                anyhow::bail!(
412                    "Multiple {} sessions found. Specify one with --session <name>",
413                    mode_label
414                );
415            }
416        }
417    };
418
419    tui::run(project_root, &session_name, swarm_mode)
420}
421
422/// List spawn sessions
423pub fn run_sessions(project_root: Option<PathBuf>, verbose: bool) -> Result<()> {
424    use colored::Colorize;
425
426    let sessions = monitor::list_sessions(project_root.as_ref())?;
427
428    if sessions.is_empty() {
429        println!("{}", "No spawn sessions found.".dimmed());
430        println!("Run: scud spawn -m --limit 3");
431        return Ok(());
432    }
433
434    println!("{}", "Spawn Sessions:".cyan().bold());
435    println!();
436
437    for session_name in &sessions {
438        if verbose {
439            // Load full session data
440            match monitor::load_session(project_root.as_ref(), session_name) {
441                Ok(session) => {
442                    let stats = monitor::SpawnStats::from(&session);
443                    println!(
444                        "  {} {} agents ({} running, {} done)",
445                        session_name.cyan(),
446                        format!("[{}]", stats.total_agents).dimmed(),
447                        stats.running.to_string().green(),
448                        stats.completed.to_string().blue()
449                    );
450                    println!(
451                        "    {} Tag: {}, Terminal: {}",
452                        "│".dimmed(),
453                        session.tag,
454                        session.terminal
455                    );
456                    println!(
457                        "    {} Created: {}",
458                        "└".dimmed(),
459                        session.created_at.dimmed()
460                    );
461                    println!();
462                }
463                Err(_) => {
464                    println!("  {} {}", session_name, "(unable to load)".red());
465                }
466            }
467        } else {
468            println!("  {}", session_name);
469        }
470    }
471
472    if !verbose {
473        println!();
474        println!(
475            "{}",
476            "Use -v for details, or: scud monitor --session <name>".dimmed()
477        );
478    }
479
480    Ok(())
481}
482
483/// Get ready tasks for spawning
484fn get_ready_tasks<'a>(
485    all_phases: &'a std::collections::HashMap<String, crate::models::phase::Phase>,
486    all_tasks_flat: &[&Task],
487    phase_tag: &str,
488    limit: usize,
489    all_tags: bool,
490) -> Result<Vec<TaskInfo<'a>>> {
491    let mut ready_tasks: Vec<TaskInfo<'a>> = Vec::new();
492
493    if all_tags {
494        // Collect from all phases
495        for (tag, phase) in all_phases {
496            for task in &phase.tasks {
497                if is_task_ready(task, phase, all_tasks_flat) {
498                    ready_tasks.push(TaskInfo {
499                        task,
500                        tag: tag.clone(),
501                    });
502                }
503            }
504        }
505    } else {
506        // Single phase
507        let phase = all_phases
508            .get(phase_tag)
509            .ok_or_else(|| anyhow::anyhow!("Phase '{}' not found", phase_tag))?;
510
511        for task in &phase.tasks {
512            if is_task_ready(task, phase, all_tasks_flat) {
513                ready_tasks.push(TaskInfo {
514                    task,
515                    tag: phase_tag.to_string(),
516                });
517            }
518        }
519    }
520
521    // Truncate to limit
522    ready_tasks.truncate(limit);
523
524    Ok(ready_tasks)
525}
526
527/// Check if a task is ready to be spawned
528fn is_task_ready(
529    task: &Task,
530    phase: &crate::models::phase::Phase,
531    all_tasks_flat: &[&Task],
532) -> bool {
533    // Must be pending
534    if task.status != TaskStatus::Pending {
535        return false;
536    }
537
538    // Must not be expanded (we want subtasks, not parent tasks)
539    if task.is_expanded() {
540        return false;
541    }
542
543    // If it's a subtask, parent must be expanded
544    if let Some(ref parent_id) = task.parent_id {
545        let parent_expanded = phase
546            .get_task(parent_id)
547            .map(|p| p.is_expanded())
548            .unwrap_or(false);
549        if !parent_expanded {
550            return false;
551        }
552    }
553
554    // All dependencies must be met
555    task.has_dependencies_met_refs(all_tasks_flat)
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561    use crate::models::phase::Phase;
562    use crate::models::task::Task;
563
564    #[test]
565    fn test_is_task_ready_basic() {
566        let mut phase = Phase::new("test".to_string());
567        let task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
568        phase.add_task(task);
569
570        let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
571        assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
572    }
573
574    #[test]
575    fn test_is_task_ready_in_progress() {
576        let mut phase = Phase::new("test".to_string());
577        let mut task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
578        task.set_status(TaskStatus::InProgress);
579        phase.add_task(task);
580
581        let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
582        assert!(!is_task_ready(&phase.tasks[0], &phase, &all_tasks));
583    }
584
585    #[test]
586    fn test_is_task_ready_blocked_by_deps() {
587        let mut phase = Phase::new("test".to_string());
588
589        let task1 = Task::new("1".to_string(), "First".to_string(), "Desc".to_string());
590
591        let mut task2 = Task::new("2".to_string(), "Second".to_string(), "Desc".to_string());
592        task2.dependencies = vec!["1".to_string()];
593
594        phase.add_task(task1);
595        phase.add_task(task2);
596
597        let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
598
599        // Task 1 is ready (no deps)
600        assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
601        // Task 2 is NOT ready (dep not done)
602        assert!(!is_task_ready(&phase.tasks[1], &phase, &all_tasks));
603    }
604}