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