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