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 headless;
11pub mod hooks;
12pub mod monitor;
13pub mod terminal;
14pub mod tui;
15
16use anyhow::Result;
17use colored::Colorize;
18use std::path::PathBuf;
19use std::thread;
20use std::time::Duration;
21
22use crate::commands::helpers::{flatten_all_tasks, resolve_group_tag};
23use crate::commands::task_selection::is_actionable_pending_task;
24use crate::models::task::{Task, TaskStatus};
25use crate::storage::Storage;
26use crate::sync::claude_tasks;
27
28use self::headless::StreamStore;
29use self::monitor::SpawnSession;
30use self::terminal::{normalize_model_override, Harness};
31
32/// Information about a task to spawn
33struct TaskInfo<'a> {
34    task: &'a Task,
35    tag: String,
36}
37
38/// Main entry point for the spawn command
39#[allow(clippy::too_many_arguments)]
40pub fn run(
41    project_root: Option<PathBuf>,
42    tag: Option<&str>,
43    limit: usize,
44    all_tags: bool,
45    dry_run: bool,
46    session: Option<String>,
47    attach: bool,
48    monitor: bool,
49    claim: bool,
50    headless: bool,
51    harness_arg: &str,
52    model_arg: &str,
53) -> Result<()> {
54    let storage = Storage::new(project_root.clone());
55
56    if !storage.is_initialized() {
57        anyhow::bail!("SCUD not initialized. Run: scud init");
58    }
59
60    // Check tmux is available (only needed for non-headless mode)
61    if !headless {
62        terminal::check_tmux_available()?;
63    }
64
65    // Load all phases for cross-tag dependency checking
66    let all_phases = storage.load_tasks()?;
67    let all_tasks_flat = flatten_all_tasks(&all_phases);
68
69    // Determine phase tag
70    let phase_tag = if all_tags {
71        "all".to_string()
72    } else {
73        resolve_group_tag(&storage, tag, true)?
74    };
75
76    // Get ready tasks
77    let ready_tasks = get_ready_tasks(&all_phases, &all_tasks_flat, &phase_tag, limit, all_tags)?;
78
79    if ready_tasks.is_empty() {
80        println!("{}", "No ready tasks to spawn.".yellow());
81        println!("Check: scud list --status pending");
82        return Ok(());
83    }
84
85    // Parse harness
86    let harness = Harness::parse(harness_arg)?;
87    let model_override = normalize_model_override(harness, model_arg);
88
89    // Generate session name
90    let session_name = session.unwrap_or_else(|| format!("scud-{}", phase_tag));
91
92    // Display spawn plan
93    let terminal_type = if headless { "headless" } else { "tmux" };
94    println!("{}", "SCUD Spawn".cyan().bold());
95    println!("{}", "═".repeat(50));
96    println!("{:<20} {}", "Mode:".dimmed(), terminal_type.green());
97    println!("{:<20} {}", "Harness:".dimmed(), harness.name().green());
98    let model_display = model_override.unwrap_or("default");
99    println!("{:<20} {}", "Model:".dimmed(), model_display.green());
100    if !headless {
101        println!("{:<20} {}", "Session:".dimmed(), session_name.cyan());
102    }
103    println!("{:<20} {}", "Tasks:".dimmed(), ready_tasks.len());
104    println!();
105
106    for (i, info) in ready_tasks.iter().enumerate() {
107        println!(
108            "  {} {} {} | {}",
109            format!("[{}]", i + 1).dimmed(),
110            info.tag.dimmed(),
111            info.task.id.cyan(),
112            info.task.title
113        );
114    }
115    println!();
116
117    if dry_run {
118        println!("{}", "Dry run - no terminals spawned.".yellow());
119        return Ok(());
120    }
121
122    // Create stream store for headless mode
123    let stream_store = if headless {
124        Some(StreamStore::new())
125    } else {
126        None
127    };
128
129    // Get working directory
130    let working_dir = project_root
131        .clone()
132        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
133
134    // Check and install Claude Code hooks for automatic task completion
135    if !hooks::hooks_installed(&working_dir) {
136        println!(
137            "{}",
138            "Installing Claude Code hooks for task completion...".dimmed()
139        );
140        if let Err(e) = hooks::install_hooks(&working_dir) {
141            println!(
142                "  {} Hook installation: {}",
143                "!".yellow(),
144                e.to_string().dimmed()
145            );
146        } else {
147            println!(
148                "  {} Hooks installed (tasks auto-complete on agent stop)",
149                "✓".green()
150            );
151        }
152    }
153
154    // Sync tasks to Claude Code's Tasks format
155    // This enables agents to see tasks via TaskList tool
156    let task_list_id = claude_tasks::task_list_id(&phase_tag);
157    if !all_tags {
158        // Single tag mode - sync the specific phase
159        if let Some(phase) = all_phases.get(&phase_tag) {
160            match claude_tasks::sync_phase(phase, &phase_tag) {
161                Ok(sync_path) => {
162                    let path_str: String = sync_path.display().to_string();
163                    println!("  {} Synced tasks to: {}", "✓".green(), path_str.dimmed());
164                }
165                Err(e) => {
166                    let err_str: String = e.to_string();
167                    println!("  {} Task sync failed: {}", "!".yellow(), err_str.dimmed());
168                }
169            }
170        }
171    } else {
172        // All tags mode - sync all phases
173        match claude_tasks::sync_phases(&all_phases) {
174            Ok(paths) => {
175                let count: usize = paths.len();
176                println!(
177                    "  {} Synced {} phases to Claude Tasks format",
178                    "✓".green(),
179                    count
180                );
181            }
182            Err(e) => {
183                let err_str: String = e.to_string();
184                println!("  {} Task sync failed: {}", "!".yellow(), err_str.dimmed());
185            }
186        }
187    }
188
189    // Create spawn session metadata (only for tmux mode)
190    let mut spawn_session = if !headless {
191        Some(SpawnSession::new(
192            &session_name,
193            &phase_tag,
194            "tmux",
195            &working_dir.to_string_lossy(),
196        ))
197    } else {
198        None
199    };
200
201    // Spawn agents
202    println!("{}", "Spawning agents...".green());
203
204    let mut success_count = 0;
205    let mut claimed_tasks: Vec<(String, String)> = Vec::new(); // (task_id, tag) pairs for claiming
206
207    if headless {
208        // Headless mode - use streaming runners
209        let store = stream_store
210            .as_ref()
211            .expect("stream_store should be Some in headless mode");
212
213        // Use tokio runtime for async headless spawning
214        let rt = tokio::runtime::Runtime::new()?;
215        let spawned_ids = rt.block_on(spawn_headless(
216            &ready_tasks,
217            &working_dir,
218            harness,
219            model_override,
220            store,
221        ))?;
222
223        success_count = spawned_ids.len();
224
225        // Track for claiming
226        if claim {
227            for task_id in &spawned_ids {
228                if let Some(info) = ready_tasks.iter().find(|t| t.task.id == *task_id) {
229                    claimed_tasks.push((task_id.clone(), info.tag.clone()));
230                }
231            }
232        }
233    } else {
234        // tmux mode - use terminal spawning
235        for info in &ready_tasks {
236            // Resolve agent config (harness, model, prompt) from task's agent_type
237            let config = agent::resolve_agent_config(
238                info.task,
239                &info.tag,
240                harness,
241                model_override,
242                &working_dir,
243            );
244
245            // Warn if agent type was specified but definition not found
246            if info.task.agent_type.is_some() && !config.from_agent_def {
247                println!(
248                    "  {} Agent '{}' not found, using CLI defaults",
249                    "!".yellow(),
250                    info.task.agent_type.as_deref().unwrap_or("unknown")
251                );
252            }
253
254            match terminal::spawn_terminal_with_task_list(
255                &info.task.id,
256                &config.prompt,
257                &working_dir,
258                &session_name,
259                config.harness,
260                config.model.as_deref(),
261                &task_list_id,
262            ) {
263                Ok(window_index) => {
264                    println!(
265                        "  {} Spawned: {} | {} [{}] {}:{}",
266                        "✓".green(),
267                        info.task.id.cyan(),
268                        info.task.title.dimmed(),
269                        config.display_info().dimmed(),
270                        session_name.dimmed(),
271                        window_index.dimmed(),
272                    );
273                    if let Some(ref mut session) = spawn_session {
274                        session.add_agent(&info.task.id, &info.task.title, &info.tag);
275                    }
276                    success_count += 1;
277
278                    // Track for claiming
279                    if claim {
280                        claimed_tasks.push((info.task.id.clone(), info.tag.clone()));
281                    }
282                }
283                Err(e) => {
284                    println!("  {} Failed: {} - {}", "✗".red(), info.task.id.red(), e);
285                }
286            }
287
288            // Small delay between spawns to avoid overwhelming the system
289            if success_count < ready_tasks.len() {
290                thread::sleep(Duration::from_millis(500));
291            }
292        }
293    }
294
295    // Claim tasks (mark as in-progress) if requested
296    if claim && !claimed_tasks.is_empty() {
297        println!();
298        println!("{}", "Claiming tasks...".dimmed());
299        for (task_id, task_tag) in &claimed_tasks {
300            // Reload phase and update task status
301            match storage.load_group(task_tag) {
302                Ok(mut phase) => {
303                    if let Some(task) = phase.get_task_mut(task_id) {
304                        task.set_status(TaskStatus::InProgress);
305                        if let Err(e) = storage.update_group(task_tag, &phase) {
306                            println!(
307                                "  {} Claim failed: {} - {}",
308                                "!".yellow(),
309                                task_id,
310                                e.to_string().dimmed()
311                            );
312                        } else {
313                            println!(
314                                "  {} Claimed: {} → {}",
315                                "✓".green(),
316                                task_id.cyan(),
317                                "in-progress".yellow()
318                            );
319                        }
320                    }
321                }
322                Err(e) => {
323                    println!(
324                        "  {} Claim failed: {} - {}",
325                        "!".yellow(),
326                        task_id,
327                        e.to_string().dimmed()
328                    );
329                }
330            }
331        }
332    }
333
334    // Setup control window and save session metadata (tmux mode only)
335    if !headless {
336        if let Err(e) = terminal::setup_tmux_control_window(&session_name, &phase_tag) {
337            println!(
338                "  {} Control window setup: {}",
339                "!".yellow(),
340                e.to_string().dimmed()
341            );
342        }
343
344        if let Some(ref session) = spawn_session {
345            if let Err(e) = monitor::save_session(project_root.as_ref(), session) {
346                println!(
347                    "  {} Session metadata: {}",
348                    "!".yellow(),
349                    e.to_string().dimmed()
350                );
351            }
352        }
353    }
354
355    // Summary
356    println!();
357    println!(
358        "{} {} of {} agents spawned",
359        "Summary:".blue().bold(),
360        success_count,
361        ready_tasks.len()
362    );
363
364    if headless {
365        println!();
366        println!("To resume: {}", "scud attach <task_id>".cyan());
367        println!("To list:   {}", "scud attach --list".dimmed());
368    } else {
369        println!();
370        println!(
371            "To attach: {}",
372            format!("tmux attach -t {}", session_name).cyan()
373        );
374        println!(
375            "To list:   {}",
376            format!("tmux list-windows -t {}", session_name).dimmed()
377        );
378    }
379
380    // Monitor takes priority over attach
381    if monitor {
382        println!();
383        println!("Starting monitor...");
384        // Small delay to let agents start
385        thread::sleep(Duration::from_secs(1));
386        return tui::run(project_root, &session_name, false, stream_store); // pass store for headless mode
387    }
388
389    // Attach if requested (tmux mode only)
390    if attach && !headless {
391        println!();
392        println!("Attaching to session...");
393        terminal::tmux_attach(&session_name)?;
394    }
395
396    Ok(())
397}
398
399/// Run the TUI monitor for a spawn or swarm session
400pub fn run_monitor(
401    project_root: Option<PathBuf>,
402    session: Option<String>,
403    swarm_mode: bool,
404) -> Result<()> {
405    use crate::commands::swarm::session as swarm_session;
406    use colored::Colorize;
407
408    let mode_label = if swarm_mode { "swarm" } else { "spawn" };
409
410    // List available sessions based on mode
411    let session_name = match session {
412        Some(s) => s,
413        None => {
414            let sessions = if swarm_mode {
415                swarm_session::list_sessions(project_root.as_ref())?
416            } else {
417                monitor::list_sessions(project_root.as_ref())?
418            };
419            if sessions.is_empty() {
420                let cmd = if swarm_mode {
421                    "scud swarm"
422                } else {
423                    "scud spawn"
424                };
425                anyhow::bail!("No {} sessions found. Run: {}", mode_label, cmd);
426            }
427            if sessions.len() == 1 {
428                sessions[0].clone()
429            } else {
430                println!(
431                    "{}",
432                    format!("Available {} sessions:", mode_label).cyan().bold()
433                );
434                for (i, s) in sessions.iter().enumerate() {
435                    println!("  {} {}", format!("[{}]", i + 1).dimmed(), s);
436                }
437                anyhow::bail!(
438                    "Multiple {} sessions found. Specify one with --session <name>",
439                    mode_label
440                );
441            }
442        }
443    };
444
445    tui::run(project_root, &session_name, swarm_mode, None)
446}
447
448/// List spawn sessions
449pub fn run_sessions(project_root: Option<PathBuf>, verbose: bool) -> Result<()> {
450    use colored::Colorize;
451
452    let sessions = monitor::list_sessions(project_root.as_ref())?;
453
454    if sessions.is_empty() {
455        println!("{}", "No spawn sessions found.".dimmed());
456        println!("Run: scud spawn -m --limit 3");
457        return Ok(());
458    }
459
460    println!("{}", "Spawn Sessions:".cyan().bold());
461    println!();
462
463    for session_name in &sessions {
464        if verbose {
465            // Load full session data
466            match monitor::load_session(project_root.as_ref(), session_name) {
467                Ok(session) => {
468                    let stats = monitor::SpawnStats::from(&session);
469                    println!(
470                        "  {} {} agents ({} running, {} done)",
471                        session_name.cyan(),
472                        format!("[{}]", stats.total_agents).dimmed(),
473                        stats.running.to_string().green(),
474                        stats.completed.to_string().blue()
475                    );
476                    println!(
477                        "    {} Tag: {}, Terminal: {}",
478                        "│".dimmed(),
479                        session.tag,
480                        session.terminal
481                    );
482                    println!(
483                        "    {} Created: {}",
484                        "└".dimmed(),
485                        session.created_at.dimmed()
486                    );
487                    println!();
488                }
489                Err(_) => {
490                    println!("  {} {}", session_name, "(unable to load)".red());
491                }
492            }
493        } else {
494            println!("  {}", session_name);
495        }
496    }
497
498    if !verbose {
499        println!();
500        println!(
501            "{}",
502            "Use -v for details, or: scud monitor --session <name>".dimmed()
503        );
504    }
505
506    Ok(())
507}
508
509/// Discover all tmux sessions (not just spawn sessions)
510pub fn run_discover_sessions(_project_root: Option<PathBuf>) -> Result<()> {
511    use colored::Colorize;
512
513    // Get all tmux sessions
514    let output = std::process::Command::new("tmux")
515        .args(["list-sessions", "-F", "#{session_name}:#{session_attached}"])
516        .output()
517        .map_err(|e| anyhow::anyhow!("Failed to list tmux sessions: {}", e))?;
518
519    if !output.status.success() {
520        println!("{}", "No tmux sessions found or tmux not running.".dimmed());
521        return Ok(());
522    }
523
524    let sessions_output = String::from_utf8_lossy(&output.stdout);
525    let sessions: Vec<&str> = sessions_output.lines().collect();
526
527    if sessions.is_empty() {
528        println!("{}", "No tmux sessions found.".dimmed());
529        return Ok(());
530    }
531
532    println!("{}", "Discovered Sessions:".cyan().bold());
533    println!();
534
535    for session_line in sessions {
536        if let Some((session_name, attached)) = session_line.split_once(':') {
537            let attached_indicator = if attached == "1" {
538                "(attached)".green()
539            } else {
540                "(detached)".dimmed()
541            };
542            println!("  {} {}", session_name.cyan(), attached_indicator);
543        }
544    }
545
546    println!();
547    println!(
548        "{}",
549        "Use 'scud attach <session>' to attach to a session.".dimmed()
550    );
551
552    Ok(())
553}
554
555/// Attach to a tmux session
556pub fn run_attach_session(_project_root: Option<PathBuf>, session_name: &str) -> Result<()> {
557    use colored::Colorize;
558
559    // Check if tmux is available
560    terminal::check_tmux_available()?;
561
562    // Check if session exists
563    if !terminal::tmux_session_exists(session_name) {
564        anyhow::bail!(
565            "Session '{}' does not exist. Use 'scud discover' to list available sessions.",
566            session_name
567        );
568    }
569
570    println!("Attaching to session '{}'...", session_name.cyan());
571    terminal::tmux_attach(session_name)?;
572
573    Ok(())
574}
575
576/// Detach from current tmux session
577pub fn run_detach_session(_project_root: Option<PathBuf>) -> Result<()> {
578    use colored::Colorize;
579
580    // Check if we're in a tmux session
581    if std::env::var("TMUX").is_err() {
582        println!("{}", "Not currently in a tmux session.".yellow());
583        return Ok(());
584    }
585
586    // Send detach command to tmux
587    let output = std::process::Command::new("tmux")
588        .args(["detach"])
589        .output()
590        .map_err(|e| anyhow::anyhow!("Failed to detach: {}", e))?;
591
592    if output.status.success() {
593        println!("{}", "Detached from tmux session.".green());
594    } else {
595        let stderr = String::from_utf8_lossy(&output.stderr);
596        anyhow::bail!("Failed to detach: {}", stderr);
597    }
598
599    Ok(())
600}
601
602/// Get ready tasks for spawning
603fn get_ready_tasks<'a>(
604    all_phases: &'a std::collections::HashMap<String, crate::models::phase::Phase>,
605    all_tasks_flat: &[&Task],
606    phase_tag: &str,
607    limit: usize,
608    all_tags: bool,
609) -> Result<Vec<TaskInfo<'a>>> {
610    let mut ready_tasks: Vec<TaskInfo<'a>> = Vec::new();
611
612    if all_tags {
613        // Collect from all phases
614        for (tag, phase) in all_phases {
615            for task in &phase.tasks {
616                if is_task_ready(task, phase, all_tasks_flat) {
617                    ready_tasks.push(TaskInfo {
618                        task,
619                        tag: tag.clone(),
620                    });
621                }
622            }
623        }
624    } else {
625        // Single phase
626        let phase = all_phases
627            .get(phase_tag)
628            .ok_or_else(|| anyhow::anyhow!("Phase '{}' not found", phase_tag))?;
629
630        for task in &phase.tasks {
631            if is_task_ready(task, phase, all_tasks_flat) {
632                ready_tasks.push(TaskInfo {
633                    task,
634                    tag: phase_tag.to_string(),
635                });
636            }
637        }
638    }
639
640    // Truncate to limit
641    ready_tasks.truncate(limit);
642
643    Ok(ready_tasks)
644}
645
646/// Check if a task is ready to be spawned
647fn is_task_ready(
648    task: &Task,
649    phase: &crate::models::phase::Phase,
650    all_tasks_flat: &[&Task],
651) -> bool {
652    if !is_actionable_pending_task(task, phase) {
653        return false;
654    }
655
656    // All dependencies must be met
657    task.has_dependencies_met_refs(all_tasks_flat)
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663    use crate::models::phase::Phase;
664    use crate::models::task::Task;
665
666    #[test]
667    fn test_is_task_ready_basic() {
668        let mut phase = Phase::new("test".to_string());
669        let task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
670        phase.add_task(task);
671
672        let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
673        assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
674    }
675
676    #[test]
677    fn test_is_task_ready_in_progress() {
678        let mut phase = Phase::new("test".to_string());
679        let mut task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
680        task.set_status(TaskStatus::InProgress);
681        phase.add_task(task);
682
683        let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
684        assert!(!is_task_ready(&phase.tasks[0], &phase, &all_tasks));
685    }
686
687    #[test]
688    fn test_is_task_ready_blocked_by_deps() {
689        let mut phase = Phase::new("test".to_string());
690
691        let task1 = Task::new("1".to_string(), "First".to_string(), "Desc".to_string());
692
693        let mut task2 = Task::new("2".to_string(), "Second".to_string(), "Desc".to_string());
694        task2.dependencies = vec!["1".to_string()];
695
696        phase.add_task(task1);
697        phase.add_task(task2);
698
699        let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
700
701        // Task 1 is ready (no deps)
702        assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
703        // Task 2 is NOT ready (dep not done)
704        assert!(!is_task_ready(&phase.tasks[1], &phase, &all_tasks));
705    }
706}
707
708/// Spawn agents in headless mode (no tmux)
709///
710/// This function spawns agents using the HeadlessRunner infrastructure,
711/// which captures streaming JSON output instead of using tmux terminals.
712/// Session metadata is automatically saved when a session ID is assigned,
713/// enabling session continuation via `scud attach`.
714///
715/// # Arguments
716/// * `tasks` - Tasks to spawn agents for
717/// * `working_dir` - Working directory for agent processes
718/// * `harness` - Which harness to use (Claude or OpenCode)
719/// * `model` - Optional model override
720/// * `store` - StreamStore for capturing agent output
721///
722/// # Returns
723/// Vector of task IDs that were successfully spawned
724async fn spawn_headless(
725    tasks: &[TaskInfo<'_>],
726    working_dir: &std::path::Path,
727    harness: Harness,
728    model: Option<&str>,
729    store: &StreamStore,
730) -> Result<Vec<String>> {
731    use crate::commands::attach::{save_session_metadata, SessionMetadata};
732
733    // Create the appropriate runner for the harness
734    let runner = headless::create_runner(harness)?;
735
736    let mut spawned_task_ids = Vec::new();
737
738    for info in tasks {
739        // Create session in store
740        store.create_session(&info.task.id, &info.tag);
741
742        // Resolve agent config (handles agent_type, custom prompts, etc.)
743        let config = agent::resolve_agent_config(info.task, &info.tag, harness, model, working_dir);
744
745        // Start headless session
746        match runner
747            .start(
748                &info.task.id,
749                &config.prompt,
750                working_dir,
751                config.model.as_deref(),
752            )
753            .await
754        {
755            Ok(mut handle) => {
756                // Set PID in store for interruption support
757                if let Some(pid) = handle.pid() {
758                    store.set_pid(&info.task.id, pid);
759                }
760
761                println!(
762                    "  {} Spawned (headless): {} | {} [{}]",
763                    "✓".green(),
764                    info.task.id.cyan(),
765                    info.task.title.dimmed(),
766                    config.display_info().dimmed(),
767                );
768
769                spawned_task_ids.push(info.task.id.clone());
770
771                // Spawn background task to collect events and save session metadata
772                let store_clone = store.clone();
773                let task_id = info.task.id.clone();
774                let tag = info.tag.clone();
775                let harness_name = harness.name().to_string();
776                let working_dir = working_dir.to_path_buf();
777
778                tokio::spawn(async move {
779                    while let Some(event) = handle.events.recv().await {
780                        // Check for session ID assignment - save metadata for continuation
781                        if let headless::StreamEventKind::SessionAssigned { ref session_id } =
782                            event.kind
783                        {
784                            store_clone.set_session_id(&task_id, session_id);
785
786                            // Save session metadata for `scud attach` continuation
787                            let mut metadata =
788                                SessionMetadata::new(&task_id, session_id, &tag, &harness_name);
789                            if let Some(pid) = handle.pid() {
790                                metadata = metadata.with_pid(pid);
791                            }
792                            if let Err(e) = save_session_metadata(&working_dir, &metadata) {
793                                eprintln!(
794                                    "  {} Failed to save session metadata for {}: {}",
795                                    "!".yellow(),
796                                    task_id,
797                                    e
798                                );
799                            }
800                        }
801
802                        // Push event to store for TUI/GUI display
803                        store_clone.push_event(&task_id, event);
804                    }
805                });
806            }
807            Err(e) => {
808                // Record error in store
809                store.push_event(&info.task.id, headless::StreamEvent::error(e.to_string()));
810                println!(
811                    "  {} Failed (headless): {} - {}",
812                    "✗".red(),
813                    info.task.id.red(),
814                    e
815                );
816            }
817        }
818    }
819
820    Ok(spawned_task_ids)
821}