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            let spawn_config = terminal::SpawnConfig {
255                task_id: &info.task.id,
256                prompt: &config.prompt,
257                working_dir: &working_dir,
258                session_name: &session_name,
259                harness: config.harness,
260                model: config.model.as_deref(),
261                task_list_id: Some(&task_list_id),
262            };
263            match terminal::spawn_tmux_agent(&spawn_config) {
264                Ok(window_index) => {
265                    println!(
266                        "  {} Spawned: {} | {} [{}] {}:{}",
267                        "✓".green(),
268                        info.task.id.cyan(),
269                        info.task.title.dimmed(),
270                        config.display_info().dimmed(),
271                        session_name.dimmed(),
272                        window_index.dimmed(),
273                    );
274                    if let Some(ref mut session) = spawn_session {
275                        session.add_agent(&info.task.id, &info.task.title, &info.tag);
276                    }
277                    success_count += 1;
278
279                    // Track for claiming
280                    if claim {
281                        claimed_tasks.push((info.task.id.clone(), info.tag.clone()));
282                    }
283                }
284                Err(e) => {
285                    println!("  {} Failed: {} - {}", "✗".red(), info.task.id.red(), e);
286                }
287            }
288
289            // Small delay between spawns to avoid overwhelming the system
290            if success_count < ready_tasks.len() {
291                thread::sleep(Duration::from_millis(500));
292            }
293        }
294    }
295
296    // Claim tasks (mark as in-progress) if requested
297    if claim && !claimed_tasks.is_empty() {
298        println!();
299        println!("{}", "Claiming tasks...".dimmed());
300        for (task_id, task_tag) in &claimed_tasks {
301            // Reload phase and update task status
302            match storage.load_group(task_tag) {
303                Ok(mut phase) => {
304                    if let Some(task) = phase.get_task_mut(task_id) {
305                        task.set_status(TaskStatus::InProgress);
306                        if let Err(e) = storage.update_group(task_tag, &phase) {
307                            println!(
308                                "  {} Claim failed: {} - {}",
309                                "!".yellow(),
310                                task_id,
311                                e.to_string().dimmed()
312                            );
313                        } else {
314                            println!(
315                                "  {} Claimed: {} → {}",
316                                "✓".green(),
317                                task_id.cyan(),
318                                "in-progress".yellow()
319                            );
320                        }
321                    }
322                }
323                Err(e) => {
324                    println!(
325                        "  {} Claim failed: {} - {}",
326                        "!".yellow(),
327                        task_id,
328                        e.to_string().dimmed()
329                    );
330                }
331            }
332        }
333    }
334
335    // Setup control window and save session metadata (tmux mode only)
336    if !headless {
337        if let Err(e) = terminal::setup_tmux_control_window(&session_name, &phase_tag) {
338            println!(
339                "  {} Control window setup: {}",
340                "!".yellow(),
341                e.to_string().dimmed()
342            );
343        }
344
345        if let Some(ref session) = spawn_session {
346            if let Err(e) = monitor::save_session(project_root.as_ref(), session) {
347                println!(
348                    "  {} Session metadata: {}",
349                    "!".yellow(),
350                    e.to_string().dimmed()
351                );
352            }
353        }
354    }
355
356    // Summary
357    println!();
358    println!(
359        "{} {} of {} agents spawned",
360        "Summary:".blue().bold(),
361        success_count,
362        ready_tasks.len()
363    );
364
365    if headless {
366        println!();
367        println!("To resume: {}", "scud attach <task_id>".cyan());
368        println!("To list:   {}", "scud attach --list".dimmed());
369    } else {
370        println!();
371        println!(
372            "To attach: {}",
373            format!("tmux attach -t {}", session_name).cyan()
374        );
375        println!(
376            "To list:   {}",
377            format!("tmux list-windows -t {}", session_name).dimmed()
378        );
379    }
380
381    // Monitor takes priority over attach
382    if monitor {
383        println!();
384        println!("Starting monitor...");
385        // Small delay to let agents start
386        thread::sleep(Duration::from_secs(1));
387        return tui::run(project_root, &session_name, false, stream_store); // pass store for headless mode
388    }
389
390    // Attach if requested (tmux mode only)
391    if attach && !headless {
392        println!();
393        println!("Attaching to session...");
394        terminal::tmux_attach(&session_name)?;
395    }
396
397    Ok(())
398}
399
400/// Run the TUI monitor for a spawn or swarm session
401pub fn run_monitor(
402    project_root: Option<PathBuf>,
403    session: Option<String>,
404    swarm_mode: bool,
405) -> Result<()> {
406    use crate::commands::swarm::session as swarm_session;
407    use colored::Colorize;
408
409    let mode_label = if swarm_mode { "swarm" } else { "spawn" };
410
411    // List available sessions based on mode
412    let session_name = match session {
413        Some(s) => s,
414        None => {
415            let sessions = if swarm_mode {
416                swarm_session::list_sessions(project_root.as_ref())?
417            } else {
418                monitor::list_sessions(project_root.as_ref())?
419            };
420            if sessions.is_empty() {
421                let cmd = if swarm_mode {
422                    "scud swarm"
423                } else {
424                    "scud spawn"
425                };
426                anyhow::bail!("No {} sessions found. Run: {}", mode_label, cmd);
427            }
428            if sessions.len() == 1 {
429                sessions[0].clone()
430            } else {
431                println!(
432                    "{}",
433                    format!("Available {} sessions:", mode_label).cyan().bold()
434                );
435                for (i, s) in sessions.iter().enumerate() {
436                    println!("  {} {}", format!("[{}]", i + 1).dimmed(), s);
437                }
438                anyhow::bail!(
439                    "Multiple {} sessions found. Specify one with --session <name>",
440                    mode_label
441                );
442            }
443        }
444    };
445
446    tui::run(project_root, &session_name, swarm_mode, None)
447}
448
449/// List spawn sessions
450pub fn run_sessions(project_root: Option<PathBuf>, verbose: bool) -> Result<()> {
451    use colored::Colorize;
452
453    let sessions = monitor::list_sessions(project_root.as_ref())?;
454
455    if sessions.is_empty() {
456        println!("{}", "No spawn sessions found.".dimmed());
457        println!("Run: scud spawn -m --limit 3");
458        return Ok(());
459    }
460
461    println!("{}", "Spawn Sessions:".cyan().bold());
462    println!();
463
464    for session_name in &sessions {
465        if verbose {
466            // Load full session data
467            match monitor::load_session(project_root.as_ref(), session_name) {
468                Ok(session) => {
469                    let stats = monitor::SpawnStats::from(&session);
470                    println!(
471                        "  {} {} agents ({} running, {} done)",
472                        session_name.cyan(),
473                        format!("[{}]", stats.total_agents).dimmed(),
474                        stats.running.to_string().green(),
475                        stats.completed.to_string().blue()
476                    );
477                    println!(
478                        "    {} Tag: {}, Terminal: {}",
479                        "│".dimmed(),
480                        session.tag,
481                        session.terminal
482                    );
483                    println!(
484                        "    {} Created: {}",
485                        "└".dimmed(),
486                        session.created_at.dimmed()
487                    );
488                    println!();
489                }
490                Err(_) => {
491                    println!("  {} {}", session_name, "(unable to load)".red());
492                }
493            }
494        } else {
495            println!("  {}", session_name);
496        }
497    }
498
499    if !verbose {
500        println!();
501        println!(
502            "{}",
503            "Use -v for details, or: scud monitor --session <name>".dimmed()
504        );
505    }
506
507    Ok(())
508}
509
510/// Discover all tmux sessions (not just spawn sessions)
511pub fn run_discover_sessions(_project_root: Option<PathBuf>) -> Result<()> {
512    use colored::Colorize;
513
514    // Get all tmux sessions
515    let output = std::process::Command::new("tmux")
516        .args(["list-sessions", "-F", "#{session_name}:#{session_attached}"])
517        .output()
518        .map_err(|e| anyhow::anyhow!("Failed to list tmux sessions: {}", e))?;
519
520    if !output.status.success() {
521        println!("{}", "No tmux sessions found or tmux not running.".dimmed());
522        return Ok(());
523    }
524
525    let sessions_output = String::from_utf8_lossy(&output.stdout);
526    let sessions: Vec<&str> = sessions_output.lines().collect();
527
528    if sessions.is_empty() {
529        println!("{}", "No tmux sessions found.".dimmed());
530        return Ok(());
531    }
532
533    println!("{}", "Discovered Sessions:".cyan().bold());
534    println!();
535
536    for session_line in sessions {
537        if let Some((session_name, attached)) = session_line.split_once(':') {
538            let attached_indicator = if attached == "1" {
539                "(attached)".green()
540            } else {
541                "(detached)".dimmed()
542            };
543            println!("  {} {}", session_name.cyan(), attached_indicator);
544        }
545    }
546
547    println!();
548    println!(
549        "{}",
550        "Use 'scud attach <session>' to attach to a session.".dimmed()
551    );
552
553    Ok(())
554}
555
556/// Attach to a tmux session
557pub fn run_attach_session(_project_root: Option<PathBuf>, session_name: &str) -> Result<()> {
558    use colored::Colorize;
559
560    // Check if tmux is available
561    terminal::check_tmux_available()?;
562
563    // Check if session exists
564    if !terminal::tmux_session_exists(session_name) {
565        anyhow::bail!(
566            "Session '{}' does not exist. Use 'scud discover' to list available sessions.",
567            session_name
568        );
569    }
570
571    println!("Attaching to session '{}'...", session_name.cyan());
572    terminal::tmux_attach(session_name)?;
573
574    Ok(())
575}
576
577/// Detach from current tmux session
578pub fn run_detach_session(_project_root: Option<PathBuf>) -> Result<()> {
579    use colored::Colorize;
580
581    // Check if we're in a tmux session
582    if std::env::var("TMUX").is_err() {
583        println!("{}", "Not currently in a tmux session.".yellow());
584        return Ok(());
585    }
586
587    // Send detach command to tmux
588    let output = std::process::Command::new("tmux")
589        .args(["detach"])
590        .output()
591        .map_err(|e| anyhow::anyhow!("Failed to detach: {}", e))?;
592
593    if output.status.success() {
594        println!("{}", "Detached from tmux session.".green());
595    } else {
596        let stderr = String::from_utf8_lossy(&output.stderr);
597        anyhow::bail!("Failed to detach: {}", stderr);
598    }
599
600    Ok(())
601}
602
603/// Get ready tasks for spawning
604fn get_ready_tasks<'a>(
605    all_phases: &'a std::collections::HashMap<String, crate::models::phase::Phase>,
606    all_tasks_flat: &[&Task],
607    phase_tag: &str,
608    limit: usize,
609    all_tags: bool,
610) -> Result<Vec<TaskInfo<'a>>> {
611    let mut ready_tasks: Vec<TaskInfo<'a>> = Vec::new();
612
613    if all_tags {
614        // Collect from all phases
615        for (tag, phase) in all_phases {
616            for task in &phase.tasks {
617                if is_task_ready(task, phase, all_tasks_flat) {
618                    ready_tasks.push(TaskInfo {
619                        task,
620                        tag: tag.clone(),
621                    });
622                }
623            }
624        }
625    } else {
626        // Single phase
627        let phase = all_phases
628            .get(phase_tag)
629            .ok_or_else(|| anyhow::anyhow!("Phase '{}' not found", phase_tag))?;
630
631        for task in &phase.tasks {
632            if is_task_ready(task, phase, all_tasks_flat) {
633                ready_tasks.push(TaskInfo {
634                    task,
635                    tag: phase_tag.to_string(),
636                });
637            }
638        }
639    }
640
641    // Truncate to limit
642    ready_tasks.truncate(limit);
643
644    Ok(ready_tasks)
645}
646
647/// Check if a task is ready to be spawned
648fn is_task_ready(
649    task: &Task,
650    phase: &crate::models::phase::Phase,
651    all_tasks_flat: &[&Task],
652) -> bool {
653    if !is_actionable_pending_task(task, phase) {
654        return false;
655    }
656
657    // All dependencies must be met
658    task.has_dependencies_met_refs(all_tasks_flat)
659}
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664    use crate::models::phase::Phase;
665    use crate::models::task::Task;
666
667    #[test]
668    fn test_is_task_ready_basic() {
669        let mut phase = Phase::new("test".to_string());
670        let task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
671        phase.add_task(task);
672
673        let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
674        assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
675    }
676
677    #[test]
678    fn test_is_task_ready_in_progress() {
679        let mut phase = Phase::new("test".to_string());
680        let mut task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
681        task.set_status(TaskStatus::InProgress);
682        phase.add_task(task);
683
684        let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
685        assert!(!is_task_ready(&phase.tasks[0], &phase, &all_tasks));
686    }
687
688    #[test]
689    fn test_is_task_ready_blocked_by_deps() {
690        let mut phase = Phase::new("test".to_string());
691
692        let task1 = Task::new("1".to_string(), "First".to_string(), "Desc".to_string());
693
694        let mut task2 = Task::new("2".to_string(), "Second".to_string(), "Desc".to_string());
695        task2.dependencies = vec!["1".to_string()];
696
697        phase.add_task(task1);
698        phase.add_task(task2);
699
700        let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
701
702        // Task 1 is ready (no deps)
703        assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
704        // Task 2 is NOT ready (dep not done)
705        assert!(!is_task_ready(&phase.tasks[1], &phase, &all_tasks));
706    }
707}
708
709/// Spawn agents in headless mode (no tmux)
710///
711/// This function spawns agents using the HeadlessRunner infrastructure,
712/// which captures streaming JSON output instead of using tmux terminals.
713/// Session metadata is automatically saved when a session ID is assigned,
714/// enabling session continuation via `scud attach`.
715///
716/// # Arguments
717/// * `tasks` - Tasks to spawn agents for
718/// * `working_dir` - Working directory for agent processes
719/// * `harness` - Which harness to use (Claude or OpenCode)
720/// * `model` - Optional model override
721/// * `store` - StreamStore for capturing agent output
722///
723/// # Returns
724/// Vector of task IDs that were successfully spawned
725async fn spawn_headless(
726    tasks: &[TaskInfo<'_>],
727    working_dir: &std::path::Path,
728    harness: Harness,
729    model: Option<&str>,
730    store: &StreamStore,
731) -> Result<Vec<String>> {
732    use crate::commands::attach::{save_session_metadata, SessionMetadata};
733
734    // Create the appropriate runner for the harness
735    let runner = headless::create_runner(harness)?;
736
737    let mut spawned_task_ids = Vec::new();
738
739    for info in tasks {
740        // Create session in store
741        store.create_session(&info.task.id, &info.tag);
742
743        // Resolve agent config (handles agent_type, custom prompts, etc.)
744        let config = agent::resolve_agent_config(info.task, &info.tag, harness, model, working_dir);
745
746        // Start headless session
747        match runner
748            .start(
749                &info.task.id,
750                &config.prompt,
751                working_dir,
752                config.model.as_deref(),
753            )
754            .await
755        {
756            Ok(mut handle) => {
757                // Set PID in store for interruption support
758                if let Some(pid) = handle.pid() {
759                    store.set_pid(&info.task.id, pid);
760                }
761
762                println!(
763                    "  {} Spawned (headless): {} | {} [{}]",
764                    "✓".green(),
765                    info.task.id.cyan(),
766                    info.task.title.dimmed(),
767                    config.display_info().dimmed(),
768                );
769
770                spawned_task_ids.push(info.task.id.clone());
771
772                // Spawn background task to collect events and save session metadata
773                let store_clone = store.clone();
774                let task_id = info.task.id.clone();
775                let tag = info.tag.clone();
776                let harness_name = harness.name().to_string();
777                let working_dir = working_dir.to_path_buf();
778
779                tokio::spawn(async move {
780                    while let Some(event) = handle.events.recv().await {
781                        // Check for session ID assignment - save metadata for continuation
782                        if let headless::StreamEventKind::SessionAssigned { ref session_id } =
783                            event.kind
784                        {
785                            store_clone.set_session_id(&task_id, session_id);
786
787                            // Save session metadata for `scud attach` continuation
788                            let mut metadata =
789                                SessionMetadata::new(&task_id, session_id, &tag, &harness_name);
790                            if let Some(pid) = handle.pid() {
791                                metadata = metadata.with_pid(pid);
792                            }
793                            if let Err(e) = save_session_metadata(&working_dir, &metadata) {
794                                eprintln!(
795                                    "  {} Failed to save session metadata for {}: {}",
796                                    "!".yellow(),
797                                    task_id,
798                                    e
799                                );
800                            }
801                        }
802
803                        // Push event to store for TUI/GUI display
804                        store_clone.push_event(&task_id, event);
805                    }
806                });
807            }
808            Err(e) => {
809                // Record error in store
810                store.push_event(&info.task.id, headless::StreamEvent::error(e.to_string()));
811                println!(
812                    "  {} Failed (headless): {} - {}",
813                    "✗".red(),
814                    info.task.id.red(),
815                    e
816                );
817            }
818        }
819    }
820
821    Ok(spawned_task_ids)
822}