Skip to main content

scud/commands/spawn/tui/
app.rs

1//! Application state for TUI monitor
2//!
3//! Three-panel design:
4//! - Top: Waves/Tasks panel showing tasks by execution wave
5//! - Middle: Agents panel showing running agents
6//! - Bottom: Live terminal output from selected agent
7//!
8//! Tab switches focus between panels. Space toggles task selection for spawning.
9
10use anyhow::Result;
11use std::collections::{HashMap, HashSet};
12use std::path::PathBuf;
13use std::process::Command;
14use std::time::{Duration, Instant};
15
16use crate::commands::spawn::headless::StreamStore;
17use crate::commands::spawn::monitor::{
18    load_session, save_session, AgentState, AgentStatus, SpawnSession,
19};
20use crate::commands::swarm::session as swarm_session;
21use crate::models::phase::Phase;
22use crate::models::task::{Task, TaskStatus};
23use crate::storage::Storage;
24
25/// View mode for the TUI
26#[derive(Debug, Clone, PartialEq)]
27pub enum ViewMode {
28    /// Three-panel view: waves + agents + output
29    Split,
30    /// Fullscreen: just the selected agent's terminal
31    Fullscreen,
32    /// Input mode: typing a command to send to agent
33    Input,
34}
35
36/// Which panel is focused
37#[derive(Debug, Clone, Copy, PartialEq)]
38pub enum FocusedPanel {
39    Waves,
40    Agents,
41    Output,
42}
43
44/// Task state in the waves view
45#[derive(Debug, Clone, PartialEq)]
46pub enum WaveTaskState {
47    /// Ready to spawn (dependencies met, pending)
48    Ready,
49    /// Currently running (agent spawned)
50    Running,
51    /// Completed
52    Done,
53    /// Blocked by dependencies
54    Blocked,
55    /// In progress but no agent visible
56    InProgress,
57}
58
59/// Information about a task in a wave
60#[derive(Debug, Clone)]
61pub struct WaveTask {
62    pub id: String,
63    pub title: String,
64    pub tag: String,
65    pub state: WaveTaskState,
66    pub complexity: u32,
67    pub dependencies: Vec<String>,
68}
69
70/// A wave of tasks that can run in parallel
71#[derive(Debug, Clone)]
72pub struct Wave {
73    pub number: usize,
74    pub tasks: Vec<WaveTask>,
75}
76
77/// Progress tracking for swarm execution
78///
79/// Provides a consolidated view of swarm progress including:
80/// - Current/total waves
81/// - Task completion counts
82/// - Validation status
83#[derive(Debug, Clone, Default)]
84pub struct SwarmProgress {
85    /// Current wave number being executed (1-indexed, 0 if not started)
86    pub current_wave: usize,
87    /// Total number of waves in the execution plan
88    pub total_waves: usize,
89    /// Number of tasks completed successfully (status = Done)
90    pub tasks_completed: usize,
91    /// Total number of tasks in the swarm
92    pub tasks_total: usize,
93    /// Number of tasks currently in progress
94    pub tasks_in_progress: usize,
95    /// Number of tasks that failed or are blocked
96    pub tasks_failed: usize,
97    /// Number of waves that have passed validation
98    pub waves_validated: usize,
99    /// Number of waves that failed validation
100    pub waves_failed_validation: usize,
101    /// Total repair attempts across all waves
102    pub total_repairs: usize,
103}
104
105/// Application state
106pub struct App {
107    /// Project root directory
108    pub project_root: Option<PathBuf>,
109    /// Session name being monitored
110    pub session_name: String,
111    /// Current spawn session data
112    pub session: Option<SpawnSession>,
113    /// Selected agent index
114    pub selected: usize,
115    /// Current view mode
116    pub view_mode: ViewMode,
117    /// Show help overlay
118    pub show_help: bool,
119    /// Last refresh time
120    last_refresh: Instant,
121    /// Refresh interval
122    refresh_interval: Duration,
123    /// Error message to display
124    pub error: Option<String>,
125    /// Live terminal output for selected agent (cached)
126    pub live_output: Vec<String>,
127    /// Last output refresh
128    last_output_refresh: Instant,
129    /// Output refresh interval (faster than status refresh)
130    output_refresh_interval: Duration,
131    /// Input buffer for sending commands to agent
132    pub input_buffer: String,
133    /// Scroll offset for terminal output (0 = bottom, positive = scrolled up)
134    pub scroll_offset: usize,
135    /// Auto-scroll to bottom on new output
136    pub auto_scroll: bool,
137
138    // === New three-panel state ===
139    /// Which panel is currently focused
140    pub focused_panel: FocusedPanel,
141    /// Execution waves with tasks
142    pub waves: Vec<Wave>,
143    /// Selected task IDs for batch spawning
144    pub selected_tasks: HashSet<String>,
145    /// Selected task index within waves panel
146    pub wave_task_index: usize,
147    /// Scroll offset for waves panel (first visible line)
148    pub wave_scroll_offset: usize,
149    /// Scroll offset for agents panel (first visible line)
150    pub agents_scroll_offset: usize,
151    /// Active tag for loading waves
152    pub active_tag: Option<String>,
153    /// All phases data (cached)
154    phases: HashMap<String, Phase>,
155
156    // === Ralph Mode (autonomous wave execution) ===
157    /// Whether Ralph mode is enabled (auto-spawn ready tasks)
158    pub ralph_mode: bool,
159    /// Maximum parallel agents in Ralph mode
160    pub ralph_max_parallel: usize,
161    /// Last time we checked for new tasks to spawn in Ralph mode
162    last_ralph_check: Instant,
163
164    // === Swarm Mode ===
165    /// Whether we're monitoring a swarm session (vs spawn session)
166    pub swarm_mode: bool,
167    /// Swarm session data (when swarm_mode is true)
168    pub swarm_session_data: Option<swarm_session::SwarmSession>,
169    /// Swarm progress tracking (computed from swarm_session_data and phases)
170    pub swarm_progress: Option<SwarmProgress>,
171
172    // === Headless Mode ===
173    /// Stream store for headless mode (None = tmux mode)
174    pub stream_store: Option<StreamStore>,
175}
176
177impl App {
178    /// Create new app state
179    ///
180    /// # Arguments
181    /// * `project_root` - Optional project root directory
182    /// * `session_name` - Name of the session to monitor
183    /// * `swarm_mode` - Whether to monitor a swarm session
184    /// * `stream_store` - Optional StreamStore for headless mode (None = tmux mode)
185    pub fn new(
186        project_root: Option<PathBuf>,
187        session_name: &str,
188        swarm_mode: bool,
189        stream_store: Option<StreamStore>,
190    ) -> Result<Self> {
191        // Load storage to get active tag and phases
192        let storage = Storage::new(project_root.clone());
193        let active_tag = storage.get_active_group().ok().flatten();
194        let phases = storage.load_tasks().unwrap_or_default();
195
196        let mut app = Self {
197            project_root,
198            session_name: session_name.to_string(),
199            session: None,
200            selected: 0,
201            view_mode: ViewMode::Split,
202            show_help: false,
203            last_refresh: Instant::now(),
204            refresh_interval: Duration::from_secs(2),
205            error: None,
206            live_output: Vec::new(),
207            last_output_refresh: Instant::now(),
208            output_refresh_interval: Duration::from_millis(500),
209            input_buffer: String::new(),
210            scroll_offset: 0,
211            auto_scroll: true,
212            // New fields
213            focused_panel: FocusedPanel::Waves,
214            waves: Vec::new(),
215            selected_tasks: HashSet::new(),
216            wave_task_index: 0,
217            wave_scroll_offset: 0,
218            agents_scroll_offset: 0,
219            active_tag,
220            phases,
221            // Ralph mode
222            ralph_mode: false,
223            ralph_max_parallel: 5,
224            last_ralph_check: Instant::now(),
225            // Swarm mode
226            swarm_mode,
227            swarm_session_data: None,
228            swarm_progress: None,
229            // Headless mode
230            stream_store,
231        };
232        app.refresh()?;
233        app.refresh_waves();
234        app.refresh_live_output();
235        Ok(app)
236    }
237
238    /// Refresh session data from disk and update agent statuses
239    pub fn refresh(&mut self) -> Result<()> {
240        if self.swarm_mode {
241            // Load swarm session
242            match swarm_session::load_session(self.project_root.as_ref(), &self.session_name) {
243                Ok(session) => {
244                    self.swarm_session_data = Some(session);
245                    self.error = None;
246                    // Update swarm progress after loading session
247                    self.swarm_progress = self.compute_swarm_progress();
248                }
249                Err(e) => {
250                    self.error = Some(format!("Failed to load swarm session: {}", e));
251                    self.swarm_progress = None;
252                }
253            }
254        } else {
255            // Load spawn session
256            match load_session(self.project_root.as_ref(), &self.session_name) {
257                Ok(mut session) => {
258                    // Update agent statuses from tmux and SCUD task status
259                    self.refresh_agent_statuses(&mut session);
260
261                    // Save updated session back to disk
262                    let _ = save_session(self.project_root.as_ref(), &session);
263
264                    self.session = Some(session);
265                    self.error = None;
266                }
267                Err(e) => {
268                    self.error = Some(format!("Failed to load session: {}", e));
269                }
270            }
271        }
272        self.last_refresh = Instant::now();
273        Ok(())
274    }
275
276    /// Refresh live output from the selected agent
277    ///
278    /// In headless mode, reads from StreamStore. Otherwise, captures tmux pane content.
279    pub fn refresh_live_output(&mut self) {
280        // If we have a stream store (headless mode), read from it
281        if let Some(ref store) = self.stream_store {
282            let agents = self.agents();
283            if agents.is_empty() || self.selected >= agents.len() {
284                self.live_output = vec!["No agent selected".to_string()];
285                return;
286            }
287
288            let agent = &agents[self.selected];
289            self.live_output = store.get_output(&agent.task_id, 100);
290
291            if self.live_output.is_empty() {
292                self.live_output = vec!["Waiting for output...".to_string()];
293            }
294
295            self.last_output_refresh = Instant::now();
296            return;
297        }
298
299        // Fall back to tmux capture-pane (default mode)
300        let agents = self.agents();
301        if agents.is_empty() || self.selected >= agents.len() {
302            self.live_output = vec!["No agent selected".to_string()];
303            return;
304        }
305
306        let agent = &agents[self.selected];
307        let session = match &self.session {
308            Some(s) => s,
309            None => {
310                self.live_output = vec!["No session loaded".to_string()];
311                return;
312            }
313        };
314
315        // Get tmux windows to find the correct window index
316        let tmux_windows = self.get_tmux_windows(&session.session_name);
317        let matching_window = tmux_windows.iter().find(|(_, name)| {
318            name.starts_with(&agent.window_name) || agent.window_name.starts_with(name)
319        });
320
321        let window_target = match matching_window {
322            Some((index, _)) => format!("{}:{}", session.session_name, index),
323            None => {
324                self.live_output = vec![format!("Window '{}' not found", agent.window_name)];
325                return;
326            }
327        };
328
329        // Capture pane content with scrollback
330        let output = Command::new("tmux")
331            .args([
332                "capture-pane",
333                "-t",
334                &window_target,
335                "-p", // print to stdout
336                "-S",
337                "-100", // start from 100 lines back
338            ])
339            .output();
340
341        match output {
342            Ok(out) if out.status.success() => {
343                let content = String::from_utf8_lossy(&out.stdout);
344                self.live_output = content.lines().map(|s| s.to_string()).collect();
345
346                // Remove trailing empty lines
347                while self
348                    .live_output
349                    .last()
350                    .map(|s| s.trim().is_empty())
351                    .unwrap_or(false)
352                {
353                    self.live_output.pop();
354                }
355            }
356            Ok(out) => {
357                self.live_output = vec![format!("Error: {}", String::from_utf8_lossy(&out.stderr))];
358            }
359            Err(e) => {
360                self.live_output = vec![format!("tmux error: {}", e)];
361            }
362        }
363
364        self.last_output_refresh = Instant::now();
365    }
366
367    /// Refresh agent statuses by checking tmux windows and SCUD task status
368    fn refresh_agent_statuses(&self, session: &mut SpawnSession) {
369        let tmux_windows = self.get_tmux_windows(&session.session_name);
370        let storage = Storage::new(self.project_root.clone());
371        let all_phases = storage.load_tasks().ok();
372
373        for agent in &mut session.agents {
374            let window_exists = tmux_windows.iter().any(|(_, name)| {
375                name.starts_with(&agent.window_name) || agent.window_name.starts_with(name)
376            });
377
378            let task_status = all_phases.as_ref().and_then(|phases| {
379                phases.get(&agent.tag).and_then(|phase| {
380                    phase
381                        .get_task(&agent.task_id)
382                        .map(|task| task.status.clone())
383                })
384            });
385
386            agent.status = match (&task_status, window_exists) {
387                (Some(TaskStatus::Done), _) => AgentStatus::Completed,
388                (Some(TaskStatus::Blocked), _) => AgentStatus::Failed,
389                (Some(TaskStatus::InProgress), true) => AgentStatus::Running,
390                (Some(TaskStatus::InProgress), false) => AgentStatus::Completed,
391                (_, false) => AgentStatus::Completed,
392                (_, true) => AgentStatus::Running,
393            };
394        }
395    }
396
397    /// Get list of tmux windows for a session: [(index, name), ...]
398    fn get_tmux_windows(&self, session_name: &str) -> Vec<(usize, String)> {
399        let output = Command::new("tmux")
400            .args([
401                "list-windows",
402                "-t",
403                session_name,
404                "-F",
405                "#{window_index}:#{window_name}",
406            ])
407            .output();
408
409        match output {
410            Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
411                .lines()
412                .filter_map(|line| {
413                    let parts: Vec<&str> = line.splitn(2, ':').collect();
414                    if parts.len() == 2 {
415                        parts[0].parse().ok().map(|idx| (idx, parts[1].to_string()))
416                    } else {
417                        None
418                    }
419                })
420                .collect(),
421            _ => Vec::new(),
422        }
423    }
424
425    /// Periodic tick - refresh data as needed
426    pub fn tick(&mut self) -> Result<()> {
427        // Refresh session/status data periodically
428        if self.last_refresh.elapsed() >= self.refresh_interval {
429            self.refresh()?;
430            self.refresh_waves();
431        }
432
433        // Refresh live output more frequently
434        if self.last_output_refresh.elapsed() >= self.output_refresh_interval {
435            self.refresh_live_output();
436        }
437
438        // Ralph mode: auto-spawn ready tasks
439        if self.ralph_mode && self.last_ralph_check.elapsed() >= Duration::from_secs(5) {
440            self.ralph_auto_spawn();
441            self.last_ralph_check = Instant::now();
442        }
443
444        Ok(())
445    }
446
447    /// Toggle Ralph mode (autonomous wave execution)
448    pub fn toggle_ralph_mode(&mut self) {
449        self.ralph_mode = !self.ralph_mode;
450        if self.ralph_mode {
451            // Immediately check for tasks to spawn
452            self.ralph_auto_spawn();
453        }
454    }
455
456    /// Auto-spawn ready tasks in Ralph mode
457    fn ralph_auto_spawn(&mut self) {
458        // Count running agents
459        let running_count = self
460            .agents()
461            .iter()
462            .filter(|a| a.status == AgentStatus::Running || a.status == AgentStatus::Starting)
463            .count();
464
465        if running_count >= self.ralph_max_parallel {
466            return; // Already at max parallel
467        }
468
469        // Find ready tasks to spawn
470        let slots_available = self.ralph_max_parallel - running_count;
471        let mut tasks_to_spawn: Vec<String> = Vec::new();
472
473        for wave in &self.waves {
474            for task in &wave.tasks {
475                if task.state == WaveTaskState::Ready && !self.selected_tasks.contains(&task.id) {
476                    // Check if already have an agent for this task
477                    let already_spawned = self.agents().iter().any(|a| a.task_id == task.id);
478                    if !already_spawned {
479                        tasks_to_spawn.push(task.id.clone());
480                        if tasks_to_spawn.len() >= slots_available {
481                            break;
482                        }
483                    }
484                }
485            }
486            if tasks_to_spawn.len() >= slots_available {
487                break;
488            }
489        }
490
491        // Spawn the tasks with Ralph loop enabled
492        for task_id in tasks_to_spawn {
493            let _ = self.spawn_task_with_ralph(&task_id);
494        }
495    }
496
497    /// Get agents list
498    pub fn agents(&self) -> &[AgentState] {
499        self.session
500            .as_ref()
501            .map(|s| s.agents.as_slice())
502            .unwrap_or(&[])
503    }
504
505    /// Select next agent
506    pub fn next_agent(&mut self) {
507        let len = self.agents().len();
508        if len > 0 {
509            self.selected = (self.selected + 1) % len;
510            self.adjust_agents_scroll();
511            self.reset_scroll();
512            self.refresh_live_output();
513        }
514    }
515
516    /// Select previous agent
517    pub fn previous_agent(&mut self) {
518        let len = self.agents().len();
519        if len > 0 {
520            self.selected = if self.selected > 0 {
521                self.selected - 1
522            } else {
523                len - 1
524            };
525            self.adjust_agents_scroll();
526            self.reset_scroll();
527            self.refresh_live_output();
528        }
529    }
530
531    /// Adjust agents scroll offset to keep selected agent visible
532    /// Assumes roughly 8 visible lines in the agents panel
533    pub fn adjust_agents_scroll(&mut self) {
534        const VISIBLE_LINES: usize = 8;
535
536        // Scroll up if selected is above visible area
537        if self.selected < self.agents_scroll_offset {
538            self.agents_scroll_offset = self.selected;
539        }
540        // Scroll down if selected is below visible area
541        else if self.selected >= self.agents_scroll_offset + VISIBLE_LINES {
542            self.agents_scroll_offset = self.selected.saturating_sub(VISIBLE_LINES - 1);
543        }
544    }
545
546    /// Toggle fullscreen mode
547    pub fn toggle_fullscreen(&mut self) {
548        self.view_mode = match self.view_mode {
549            ViewMode::Split => ViewMode::Fullscreen,
550            ViewMode::Fullscreen => ViewMode::Split,
551            ViewMode::Input => ViewMode::Fullscreen,
552        };
553    }
554
555    /// Exit current mode (go back to split)
556    pub fn exit_fullscreen(&mut self) {
557        self.view_mode = ViewMode::Split;
558        self.input_buffer.clear();
559    }
560
561    /// Enter input mode
562    pub fn enter_input_mode(&mut self) {
563        self.view_mode = ViewMode::Input;
564        self.input_buffer.clear();
565    }
566
567    /// Add character to input buffer
568    pub fn input_char(&mut self, c: char) {
569        self.input_buffer.push(c);
570    }
571
572    /// Delete last character from input buffer
573    pub fn input_backspace(&mut self) {
574        self.input_buffer.pop();
575    }
576
577    /// Send the input buffer to the selected agent's tmux pane
578    pub fn send_input(&mut self) -> Result<()> {
579        if self.input_buffer.is_empty() {
580            return Ok(());
581        }
582
583        let session = match &self.session {
584            Some(s) => s,
585            None => {
586                self.error = Some("No session loaded".to_string());
587                return Ok(());
588            }
589        };
590
591        let agents = self.agents();
592        if agents.is_empty() || self.selected >= agents.len() {
593            self.error = Some("No agent selected".to_string());
594            return Ok(());
595        }
596
597        let agent = &agents[self.selected];
598
599        // Find window index
600        let tmux_windows = self.get_tmux_windows(&session.session_name);
601        let matching_window = tmux_windows.iter().find(|(_, name)| {
602            name.starts_with(&agent.window_name) || agent.window_name.starts_with(name)
603        });
604
605        let window_target = match matching_window {
606            Some((index, _)) => format!("{}:{}", session.session_name, index),
607            None => {
608                self.error = Some(format!("Window not found for {}", agent.task_id));
609                return Ok(());
610            }
611        };
612
613        // Send the input to tmux
614        let result = Command::new("tmux")
615            .args([
616                "send-keys",
617                "-t",
618                &window_target,
619                &self.input_buffer,
620                "Enter",
621            ])
622            .output();
623
624        match result {
625            Ok(out) if out.status.success() => {
626                self.error = None;
627                self.input_buffer.clear();
628                self.view_mode = ViewMode::Fullscreen; // Go to fullscreen to see result
629                self.refresh_live_output();
630            }
631            Ok(out) => {
632                self.error = Some(format!(
633                    "Send failed: {}",
634                    String::from_utf8_lossy(&out.stderr)
635                ));
636            }
637            Err(e) => {
638                self.error = Some(format!("tmux error: {}", e));
639            }
640        }
641
642        Ok(())
643    }
644
645    /// Restart the selected agent (kill and respawn claude)
646    pub fn restart_agent(&mut self) -> Result<()> {
647        let session = match &self.session {
648            Some(s) => s,
649            None => return Ok(()),
650        };
651
652        let agents = self.agents();
653        if agents.is_empty() || self.selected >= agents.len() {
654            return Ok(());
655        }
656
657        let agent = &agents[self.selected];
658
659        // Find window
660        let tmux_windows = self.get_tmux_windows(&session.session_name);
661        let matching_window = tmux_windows.iter().find(|(_, name)| {
662            name.starts_with(&agent.window_name) || agent.window_name.starts_with(name)
663        });
664
665        if let Some((index, _)) = matching_window {
666            let target = format!("{}:{}", session.session_name, index);
667
668            // Send Ctrl+C to interrupt current process
669            let _ = Command::new("tmux")
670                .args(["send-keys", "-t", &target, "C-c"])
671                .output();
672
673            // Small delay
674            std::thread::sleep(Duration::from_millis(200));
675
676            // Clear and show message
677            let _ = Command::new("tmux")
678                .args([
679                    "send-keys",
680                    "-t",
681                    &target,
682                    "echo 'Agent restarted by user'",
683                    "Enter",
684                ])
685                .output();
686
687            self.error = None;
688            self.refresh_live_output();
689        }
690
691        Ok(())
692    }
693
694    /// Toggle help overlay
695    pub fn toggle_help(&mut self) {
696        self.show_help = !self.show_help;
697    }
698
699    /// Scroll terminal output up (show older content)
700    pub fn scroll_up(&mut self, lines: usize) {
701        let max_scroll = self.live_output.len().saturating_sub(1);
702        self.scroll_offset = (self.scroll_offset + lines).min(max_scroll);
703        self.auto_scroll = false;
704    }
705
706    /// Scroll terminal output down (show newer content)
707    pub fn scroll_down(&mut self, lines: usize) {
708        self.scroll_offset = self.scroll_offset.saturating_sub(lines);
709        if self.scroll_offset == 0 {
710            self.auto_scroll = true;
711        }
712    }
713
714    /// Jump to bottom of terminal output
715    pub fn scroll_to_bottom(&mut self) {
716        self.scroll_offset = 0;
717        self.auto_scroll = true;
718    }
719
720    /// Reset scroll when switching agents
721    fn reset_scroll(&mut self) {
722        self.scroll_offset = 0;
723        self.auto_scroll = true;
724    }
725
726    /// Get status counts (starting, running, completed, failed)
727    pub fn status_counts(&self) -> (usize, usize, usize, usize) {
728        let agents = self.agents();
729        let starting = agents
730            .iter()
731            .filter(|a| a.status == AgentStatus::Starting)
732            .count();
733        let running = agents
734            .iter()
735            .filter(|a| a.status == AgentStatus::Running)
736            .count();
737        let completed = agents
738            .iter()
739            .filter(|a| a.status == AgentStatus::Completed)
740            .count();
741        let failed = agents
742            .iter()
743            .filter(|a| a.status == AgentStatus::Failed)
744            .count();
745        (starting, running, completed, failed)
746    }
747
748    /// Get the selected agent (if any)
749    pub fn selected_agent(&self) -> Option<&AgentState> {
750        let agents = self.agents();
751        if agents.is_empty() || self.selected >= agents.len() {
752            None
753        } else {
754            Some(&agents[self.selected])
755        }
756    }
757
758    // === Wave-related methods ===
759
760    /// Refresh waves data from phases
761    pub fn refresh_waves(&mut self) {
762        // Reload phases from storage
763        let storage = Storage::new(self.project_root.clone());
764        self.phases = storage.load_tasks().unwrap_or_default();
765
766        // In swarm mode, use actual wave data from swarm session
767        if self.swarm_mode {
768            self.waves = self.compute_swarm_waves();
769            // Update swarm progress after computing waves (now that phases are fresh)
770            self.swarm_progress = self.compute_swarm_progress();
771            return;
772        }
773
774        // Get running agent task IDs
775        let running_task_ids: HashSet<String> = self
776            .agents()
777            .iter()
778            .filter(|a| a.status == AgentStatus::Running || a.status == AgentStatus::Starting)
779            .map(|a| a.task_id.clone())
780            .collect();
781
782        // Determine which tag to use
783        let tag = self.active_tag.clone().or_else(|| {
784            // Try to get tag from session
785            self.session.as_ref().map(|s| s.tag.clone())
786        });
787
788        let Some(tag) = tag else {
789            self.waves = Vec::new();
790            return;
791        };
792
793        let Some(phase) = self.phases.get(&tag) else {
794            self.waves = Vec::new();
795            return;
796        };
797
798        // Build waves using topological sort
799        self.waves = self.compute_waves(phase, &running_task_ids);
800    }
801
802    /// Compute swarm progress from session data and phases
803    ///
804    /// This aggregates progress information from the swarm session and task phases
805    /// into a consolidated SwarmProgress struct for easy access by the UI.
806    fn compute_swarm_progress(&self) -> Option<SwarmProgress> {
807        let swarm = self.swarm_session_data.as_ref()?;
808        let phase = self.phases.get(&swarm.tag);
809
810        let total_waves = self.waves.len();
811
812        // Find current wave (first incomplete wave, or last wave if all complete)
813        let current_wave = swarm
814            .waves
815            .iter()
816            .find(|w| w.completed_at.is_none())
817            .map(|w| w.wave_number)
818            .unwrap_or_else(|| swarm.waves.last().map(|w| w.wave_number).unwrap_or(0));
819
820        // Count tasks by status from phase data
821        let (tasks_completed, tasks_in_progress, tasks_failed, tasks_total) =
822            if let Some(phase) = phase {
823                // Get all task IDs from swarm waves
824                let swarm_task_ids: HashSet<String> =
825                    swarm.waves.iter().flat_map(|w| w.all_task_ids()).collect();
826
827                let mut completed = 0;
828                let mut in_progress = 0;
829                let mut failed = 0;
830                let total = swarm_task_ids.len();
831
832                for task_id in &swarm_task_ids {
833                    if let Some(task) = phase.get_task(task_id) {
834                        match task.status {
835                            TaskStatus::Done => completed += 1,
836                            TaskStatus::InProgress => in_progress += 1,
837                            TaskStatus::Blocked => failed += 1,
838                            _ => {}
839                        }
840                    }
841                }
842
843                (completed, in_progress, failed, total)
844            } else {
845                // Fall back to counting from swarm session
846                let total = swarm.total_tasks();
847                let failed = swarm.total_failures();
848                (0, 0, failed, total)
849            };
850
851        // Count validation results
852        let (waves_validated, waves_failed_validation) =
853            swarm
854                .waves
855                .iter()
856                .fold((0, 0), |(validated, failed), wave| match &wave.validation {
857                    Some(v) if v.all_passed => (validated + 1, failed),
858                    Some(_) => (validated, failed + 1),
859                    None => (validated, failed),
860                });
861
862        // Count total repairs
863        let total_repairs: usize = swarm.waves.iter().map(|w| w.repairs.len()).sum();
864
865        Some(SwarmProgress {
866            current_wave,
867            total_waves,
868            tasks_completed,
869            tasks_total,
870            tasks_in_progress,
871            tasks_failed,
872            waves_validated,
873            waves_failed_validation,
874            total_repairs,
875        })
876    }
877
878    /// Compute waves from swarm session data (shows actual execution waves)
879    fn compute_swarm_waves(&self) -> Vec<Wave> {
880        let Some(ref swarm) = self.swarm_session_data else {
881            return Vec::new();
882        };
883
884        let tag = &swarm.tag;
885        let phase = self.phases.get(tag);
886
887        swarm
888            .waves
889            .iter()
890            .map(|wave_state| {
891                // Collect all task IDs from all rounds in this wave
892                let task_ids: Vec<String> = wave_state
893                    .rounds
894                    .iter()
895                    .flat_map(|round| round.task_ids.iter().cloned())
896                    .collect();
897
898                let tasks: Vec<WaveTask> = task_ids
899                    .iter()
900                    .map(|task_id| {
901                        // Try to get task info from phase
902                        let (title, complexity, dependencies, task_status) =
903                            if let Some(phase) = phase {
904                                if let Some(task) = phase.get_task(task_id) {
905                                    (
906                                        task.title.clone(),
907                                        task.complexity,
908                                        task.dependencies.clone(),
909                                        Some(task.status.clone()),
910                                    )
911                                } else {
912                                    (task_id.clone(), 1, vec![], None)
913                                }
914                            } else {
915                                (task_id.clone(), 1, vec![], None)
916                            };
917
918                        // Determine state based on task status and wave completion
919                        let state = match task_status {
920                            Some(TaskStatus::Done) => WaveTaskState::Done,
921                            Some(TaskStatus::InProgress) => WaveTaskState::Running,
922                            Some(TaskStatus::Blocked) => WaveTaskState::Blocked,
923                            Some(TaskStatus::Pending) => {
924                                if wave_state.completed_at.is_some() {
925                                    // Wave completed but task still pending = failed/blocked
926                                    WaveTaskState::Blocked
927                                } else {
928                                    WaveTaskState::Ready
929                                }
930                            }
931                            _ => WaveTaskState::Ready,
932                        };
933
934                        WaveTask {
935                            id: task_id.clone(),
936                            title,
937                            tag: tag.clone(),
938                            state,
939                            complexity,
940                            dependencies,
941                        }
942                    })
943                    .collect();
944
945                Wave {
946                    number: wave_state.wave_number,
947                    tasks,
948                }
949            })
950            .collect()
951    }
952
953    /// Compute execution waves for a phase
954    fn compute_waves(&self, phase: &Phase, running_task_ids: &HashSet<String>) -> Vec<Wave> {
955        // Collect actionable tasks
956        let mut actionable: Vec<&Task> = Vec::new();
957        for task in &phase.tasks {
958            if task.status == TaskStatus::Done
959                || task.status == TaskStatus::Expanded
960                || task.status == TaskStatus::Cancelled
961            {
962                continue;
963            }
964
965            // Skip parent tasks that have subtasks - only subtasks should be spawned
966            if !task.subtasks.is_empty() {
967                continue;
968            }
969
970            // If subtask, only include if parent is expanded
971            if let Some(ref parent_id) = task.parent_id {
972                let parent_expanded = phase
973                    .get_task(parent_id)
974                    .map(|p| p.is_expanded())
975                    .unwrap_or(false);
976                if !parent_expanded {
977                    continue;
978                }
979            }
980
981            actionable.push(task);
982        }
983
984        if actionable.is_empty() {
985            return Vec::new();
986        }
987
988        // Build dependency graph
989        let task_ids: HashSet<String> = actionable.iter().map(|t| t.id.clone()).collect();
990        let mut in_degree: HashMap<String, usize> = HashMap::new();
991        let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
992
993        for task in &actionable {
994            in_degree.entry(task.id.clone()).or_insert(0);
995
996            for dep in &task.dependencies {
997                if task_ids.contains(dep) {
998                    // Internal dependency - track in graph
999                    *in_degree.entry(task.id.clone()).or_insert(0) += 1;
1000                    dependents
1001                        .entry(dep.clone())
1002                        .or_default()
1003                        .push(task.id.clone());
1004                } else {
1005                    // External dependency - check if satisfied
1006                    // If not satisfied (e.g., Expanded with incomplete subtasks), block this task
1007                    if !self.is_dependency_satisfied(dep, phase) {
1008                        // Mark as blocked by setting very high in_degree
1009                        *in_degree.entry(task.id.clone()).or_insert(0) += 1000;
1010                    }
1011                }
1012            }
1013        }
1014
1015        // Kahn's algorithm with wave tracking
1016        let mut waves: Vec<Wave> = Vec::new();
1017        let mut remaining = in_degree.clone();
1018        let mut wave_number = 1;
1019
1020        while !remaining.is_empty() {
1021            let mut ready: Vec<String> = remaining
1022                .iter()
1023                .filter(|(_, &deg)| deg == 0)
1024                .map(|(id, _)| id.clone())
1025                .collect();
1026
1027            if ready.is_empty() {
1028                break; // Circular dependency
1029            }
1030
1031            // Sort for stable display order
1032            ready.sort();
1033
1034            // Build wave tasks with state
1035            let mut wave_tasks: Vec<WaveTask> = ready
1036                .iter()
1037                .filter_map(|task_id| {
1038                    actionable.iter().find(|t| &t.id == task_id).map(|task| {
1039                        let state = if task.status == TaskStatus::Done {
1040                            WaveTaskState::Done
1041                        } else if running_task_ids.contains(&task.id) {
1042                            WaveTaskState::Running
1043                        } else if task.status == TaskStatus::InProgress {
1044                            WaveTaskState::InProgress
1045                        } else if task.status == TaskStatus::Blocked {
1046                            WaveTaskState::Blocked
1047                        } else if self.is_task_ready(task, phase) {
1048                            WaveTaskState::Ready
1049                        } else {
1050                            WaveTaskState::Blocked
1051                        };
1052
1053                        WaveTask {
1054                            id: task.id.clone(),
1055                            title: task.title.clone(),
1056                            tag: self.active_tag.clone().unwrap_or_default(),
1057                            state,
1058                            complexity: task.complexity,
1059                            dependencies: task.dependencies.clone(),
1060                        }
1061                    })
1062                })
1063                .collect();
1064
1065            // Remove ready tasks and update dependents
1066            for task_id in &ready {
1067                remaining.remove(task_id);
1068                if let Some(deps) = dependents.get(task_id) {
1069                    for dep_id in deps {
1070                        if let Some(deg) = remaining.get_mut(dep_id) {
1071                            *deg = deg.saturating_sub(1);
1072                        }
1073                    }
1074                }
1075            }
1076
1077            if !wave_tasks.is_empty() {
1078                // Sort tasks by ID for stable display
1079                wave_tasks.sort_by(|a, b| a.id.cmp(&b.id));
1080                waves.push(Wave {
1081                    number: wave_number,
1082                    tasks: wave_tasks,
1083                });
1084            }
1085            wave_number += 1;
1086        }
1087
1088        waves
1089    }
1090
1091    /// Check if a task is ready (dependencies met, pending)
1092    fn is_task_ready(&self, task: &Task, phase: &Phase) -> bool {
1093        if task.status != TaskStatus::Pending {
1094            return false;
1095        }
1096
1097        // Check all dependencies are satisfied
1098        for dep_id in &task.dependencies {
1099            if !self.is_dependency_satisfied(dep_id, phase) {
1100                return false;
1101            }
1102        }
1103
1104        true
1105    }
1106
1107    /// Check if a dependency is satisfied (Done, or Expanded with all subtasks done)
1108    fn is_dependency_satisfied(&self, dep_id: &str, phase: &Phase) -> bool {
1109        let Some(dep) = phase.get_task(dep_id) else {
1110            return true; // If dep not found, assume external/done
1111        };
1112
1113        match dep.status {
1114            TaskStatus::Done => true,
1115            TaskStatus::Expanded => {
1116                // Expanded task is only satisfied if all its subtasks are done
1117                if dep.subtasks.is_empty() {
1118                    false // Expanded with no subtasks = not done yet
1119                } else {
1120                    dep.subtasks.iter().all(|subtask_id| {
1121                        phase
1122                            .get_task(subtask_id)
1123                            .map(|st| st.status == TaskStatus::Done)
1124                            .unwrap_or(false)
1125                    })
1126                }
1127            }
1128            _ => false, // Pending, InProgress, Blocked, Cancelled = not satisfied
1129        }
1130    }
1131
1132    /// Get flat list of all tasks in waves for navigation
1133    pub fn all_wave_tasks(&self) -> Vec<&WaveTask> {
1134        self.waves.iter().flat_map(|w| w.tasks.iter()).collect()
1135    }
1136
1137    /// Get currently selected wave task
1138    pub fn selected_wave_task(&self) -> Option<&WaveTask> {
1139        let all_tasks = self.all_wave_tasks();
1140        all_tasks.get(self.wave_task_index).copied()
1141    }
1142
1143    // === Panel navigation ===
1144
1145    /// Switch focus to next panel
1146    pub fn next_panel(&mut self) {
1147        self.focused_panel = match self.focused_panel {
1148            FocusedPanel::Waves => FocusedPanel::Agents,
1149            FocusedPanel::Agents => FocusedPanel::Output,
1150            FocusedPanel::Output => FocusedPanel::Waves,
1151        };
1152    }
1153
1154    /// Switch focus to previous panel
1155    pub fn previous_panel(&mut self) {
1156        self.focused_panel = match self.focused_panel {
1157            FocusedPanel::Waves => FocusedPanel::Output,
1158            FocusedPanel::Agents => FocusedPanel::Waves,
1159            FocusedPanel::Output => FocusedPanel::Agents,
1160        };
1161    }
1162
1163    /// Move selection up in current panel
1164    pub fn move_up(&mut self) {
1165        match self.focused_panel {
1166            FocusedPanel::Waves => {
1167                if self.wave_task_index > 0 {
1168                    self.wave_task_index -= 1;
1169                    self.adjust_wave_scroll();
1170                }
1171            }
1172            FocusedPanel::Agents => self.previous_agent(),
1173            FocusedPanel::Output => self.scroll_up(1),
1174        }
1175    }
1176
1177    /// Move selection down in current panel
1178    pub fn move_down(&mut self) {
1179        match self.focused_panel {
1180            FocusedPanel::Waves => {
1181                let max = self.all_wave_tasks().len().saturating_sub(1);
1182                if self.wave_task_index < max {
1183                    self.wave_task_index += 1;
1184                    self.adjust_wave_scroll();
1185                }
1186            }
1187            FocusedPanel::Agents => self.next_agent(),
1188            FocusedPanel::Output => self.scroll_down(1),
1189        }
1190    }
1191
1192    /// Adjust wave scroll offset to keep selected item visible
1193    /// Assumes visible height of ~4 items (plus wave headers)
1194    fn adjust_wave_scroll(&mut self) {
1195        // Calculate the line index of the current task
1196        // Each wave has 1 header line, then tasks
1197        let mut line_idx = 0;
1198        let mut found = false;
1199        let mut task_counter = 0;
1200
1201        for wave in &self.waves {
1202            line_idx += 1; // wave header
1203            for _ in &wave.tasks {
1204                if task_counter == self.wave_task_index {
1205                    found = true;
1206                    break;
1207                }
1208                line_idx += 1;
1209                task_counter += 1;
1210            }
1211            if found {
1212                break;
1213            }
1214        }
1215
1216        // Visible height is approximately 4-5 lines in the waves panel
1217        let visible_height = 4;
1218
1219        // Scroll to keep current line visible
1220        if line_idx < self.wave_scroll_offset {
1221            self.wave_scroll_offset = line_idx;
1222        } else if line_idx >= self.wave_scroll_offset + visible_height {
1223            self.wave_scroll_offset = line_idx.saturating_sub(visible_height - 1);
1224        }
1225    }
1226
1227    // === Task selection for spawning ===
1228
1229    /// Toggle selection of currently highlighted task
1230    pub fn toggle_task_selection(&mut self) {
1231        if let Some(task) = self.selected_wave_task() {
1232            let task_id = task.id.clone();
1233            if self.selected_tasks.contains(&task_id) {
1234                self.selected_tasks.remove(&task_id);
1235            } else {
1236                // Only allow selecting ready tasks
1237                if task.state == WaveTaskState::Ready {
1238                    self.selected_tasks.insert(task_id);
1239                }
1240            }
1241        }
1242    }
1243
1244    /// Select all ready tasks in waves
1245    pub fn select_all_ready(&mut self) {
1246        for wave in &self.waves {
1247            for task in &wave.tasks {
1248                if task.state == WaveTaskState::Ready {
1249                    self.selected_tasks.insert(task.id.clone());
1250                }
1251            }
1252        }
1253    }
1254
1255    /// Clear all task selections
1256    pub fn clear_selection(&mut self) {
1257        self.selected_tasks.clear();
1258    }
1259
1260    /// Get count of ready tasks
1261    pub fn ready_task_count(&self) -> usize {
1262        self.waves
1263            .iter()
1264            .flat_map(|w| &w.tasks)
1265            .filter(|t| t.state == WaveTaskState::Ready)
1266            .count()
1267    }
1268
1269    /// Get count of selected tasks
1270    pub fn selected_task_count(&self) -> usize {
1271        self.selected_tasks.len()
1272    }
1273
1274    /// Get selected tasks for spawning
1275    pub fn get_selected_tasks(&self) -> Vec<&WaveTask> {
1276        self.all_wave_tasks()
1277            .into_iter()
1278            .filter(|t| self.selected_tasks.contains(&t.id))
1279            .collect()
1280    }
1281
1282    /// Spawn the selected tasks
1283    /// Returns the number of tasks successfully spawned
1284    pub fn spawn_selected_tasks(&mut self) -> Result<usize> {
1285        use crate::commands::spawn::{agent, terminal};
1286
1287        let tasks_to_spawn: Vec<(String, String, String)> = self
1288            .get_selected_tasks()
1289            .iter()
1290            .map(|t| (t.id.clone(), t.title.clone(), t.tag.clone()))
1291            .collect();
1292
1293        if tasks_to_spawn.is_empty() {
1294            return Ok(0);
1295        }
1296
1297        // Get working directory
1298        let working_dir = self
1299            .project_root
1300            .clone()
1301            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1302
1303        // Get session info
1304        let session = match &self.session {
1305            Some(s) => s,
1306            None => {
1307                self.error = Some("No session loaded".to_string());
1308                return Ok(0);
1309            }
1310        };
1311
1312        let session_name = session.session_name.clone();
1313        let mut spawned_count = 0;
1314
1315        // Load phase to get full task data
1316        let storage = Storage::new(self.project_root.clone());
1317
1318        for (task_id, task_title, tag) in &tasks_to_spawn {
1319            // Get full task from phase
1320            let phase = match self.phases.get(tag) {
1321                Some(p) => p,
1322                None => continue,
1323            };
1324
1325            let task = match phase.get_task(task_id) {
1326                Some(t) => t,
1327                None => continue,
1328            };
1329
1330            // Generate prompt
1331            let prompt = agent::generate_prompt(task, tag);
1332
1333            // Spawn in tmux (always use tmux from TUI since we're in a session)
1334            match terminal::spawn_terminal(task_id, &prompt, &working_dir, &session_name) {
1335                Ok(_window_index) => {
1336                    spawned_count += 1;
1337
1338                    // Add to session agents
1339                    if let Some(ref mut session) = self.session {
1340                        session.add_agent(task_id, task_title, tag);
1341                    }
1342
1343                    // Mark task as in-progress
1344                    if let Ok(mut phase) = storage.load_group(tag) {
1345                        if let Some(task) = phase.get_task_mut(task_id) {
1346                            task.set_status(TaskStatus::InProgress);
1347                            let _ = storage.update_group(tag, &phase);
1348                        }
1349                    }
1350                }
1351                Err(e) => {
1352                    self.error = Some(format!("Failed to spawn {}: {}", task_id, e));
1353                }
1354            }
1355
1356            // Small delay between spawns
1357            if spawned_count < tasks_to_spawn.len() {
1358                std::thread::sleep(Duration::from_millis(300));
1359            }
1360        }
1361
1362        // Save updated session
1363        if spawned_count > 0 {
1364            if let Some(ref session) = self.session {
1365                let _ = crate::commands::spawn::monitor::save_session(
1366                    self.project_root.as_ref(),
1367                    session,
1368                );
1369            }
1370
1371            // Clear selection and refresh
1372            self.selected_tasks.clear();
1373            self.refresh()?;
1374            self.refresh_waves();
1375        }
1376
1377        Ok(spawned_count)
1378    }
1379
1380    /// Prepare to start swarm - returns swarm command and tag
1381    pub fn prepare_swarm_start(&self) -> Option<(String, String)> {
1382        // Get tag from session or active tag
1383        let tag = self
1384            .session
1385            .as_ref()
1386            .map(|s| s.tag.clone())
1387            .or_else(|| self.active_tag.clone())?;
1388
1389        // Build swarm command
1390        let session_base = self.session_name.replace("swarm-", "").replace("scud-", "");
1391        let cmd = format!("scud swarm --tag {} --session {}", tag, session_base);
1392
1393        Some((cmd, tag))
1394    }
1395
1396    /// Update the status of the currently selected agent's task
1397    pub fn set_selected_task_status(&mut self, new_status: TaskStatus) -> Result<()> {
1398        let Some(ref session) = self.session else {
1399            self.error = Some("No session loaded".to_string());
1400            return Ok(());
1401        };
1402
1403        let agents = session.agents.clone();
1404        if agents.is_empty() || self.selected >= agents.len() {
1405            self.error = Some("No agent selected".to_string());
1406            return Ok(());
1407        }
1408
1409        let agent = &agents[self.selected];
1410        let task_id = &agent.task_id;
1411        let tag = &agent.tag;
1412
1413        // Update task status in storage
1414        let storage = Storage::new(self.project_root.clone());
1415        if let Ok(mut phase) = storage.load_group(tag) {
1416            if let Some(task) = phase.get_task_mut(task_id) {
1417                task.set_status(new_status.clone());
1418                if let Err(e) = storage.update_group(tag, &phase) {
1419                    self.error = Some(format!("Failed to save: {}", e));
1420                    return Ok(());
1421                }
1422                // Show confirmation
1423                self.error = Some(format!("✓ {} → {}", task_id, new_status.as_str()));
1424            } else {
1425                self.error = Some(format!("Task {} not found", task_id));
1426            }
1427        } else {
1428            self.error = Some(format!("Failed to load phase {}", tag));
1429        }
1430
1431        // Refresh to show updated status
1432        self.refresh()?;
1433        self.refresh_waves();
1434
1435        Ok(())
1436    }
1437
1438    /// Spawn a single task with Ralph loop enabled
1439    /// The agent will keep trying until the task is marked done
1440    fn spawn_task_with_ralph(&mut self, task_id: &str) -> Result<()> {
1441        use crate::commands::spawn::{agent, terminal};
1442
1443        // Find the task in waves
1444        let task_info = self
1445            .waves
1446            .iter()
1447            .flat_map(|w| w.tasks.iter())
1448            .find(|t| t.id == task_id)
1449            .map(|t| (t.id.clone(), t.title.clone(), t.tag.clone()));
1450
1451        let (task_id, task_title, tag) = match task_info {
1452            Some(info) => info,
1453            None => return Ok(()),
1454        };
1455
1456        // Get working directory
1457        let working_dir = self
1458            .project_root
1459            .clone()
1460            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1461
1462        // Get session info
1463        let session = match &self.session {
1464            Some(s) => s,
1465            None => {
1466                self.error = Some("No session loaded".to_string());
1467                return Ok(());
1468            }
1469        };
1470
1471        let session_name = session.session_name.clone();
1472
1473        // Load phase to get full task data
1474        let storage = Storage::new(self.project_root.clone());
1475
1476        let phase = match self.phases.get(&tag) {
1477            Some(p) => p,
1478            None => return Ok(()),
1479        };
1480
1481        let task = match phase.get_task(&task_id) {
1482            Some(t) => t,
1483            None => return Ok(()),
1484        };
1485
1486        // Generate prompt with Ralph loop instructions
1487        let base_prompt = agent::generate_prompt(task, &tag);
1488        let ralph_prompt = format!(
1489            r#"{}
1490
1491═══════════════════════════════════════════════════════════
1492RALPH LOOP MODE - Autonomous Task Completion
1493═══════════════════════════════════════════════════════════
1494
1495CRITICAL: Your task ID is **{task_id}** (NOT any parent task!)
1496
1497You are in a Ralph loop. Keep working until the task is COMPLETE.
1498
1499After EACH attempt:
15001. Run EXACTLY: scud set-status {task_id} done
1501   ⚠️  Use task ID "{task_id}" - do NOT use any other task ID!
15022. Verify the task is truly done (tests pass, code works)
15033. If something failed, fix it and try again
1504
1505The loop will continue until task {task_id} is marked done.
1506Do NOT give up. Keep iterating until success.
1507
1508When you have genuinely completed task {task_id}, output:
1509<promise>TASK {task_id} COMPLETE</promise>
1510
1511DO NOT output this promise unless task {task_id} is TRULY complete!
1512═══════════════════════════════════════════════════════════
1513"#,
1514            base_prompt,
1515            task_id = task_id
1516        );
1517
1518        // Spawn in tmux with Ralph loop wrapper
1519        match terminal::spawn_terminal_ralph(
1520            &task_id,
1521            &ralph_prompt,
1522            &working_dir,
1523            &session_name,
1524            &format!("TASK {} COMPLETE", task_id),
1525        ) {
1526            Ok(()) => {
1527                // Add to session agents
1528                if let Some(ref mut session) = self.session {
1529                    session.add_agent(&task_id, &task_title, &tag);
1530                }
1531
1532                // Mark task as in-progress
1533                if let Ok(mut phase) = storage.load_group(&tag) {
1534                    if let Some(task) = phase.get_task_mut(&task_id) {
1535                        task.set_status(TaskStatus::InProgress);
1536                        let _ = storage.update_group(&tag, &phase);
1537                    }
1538                }
1539
1540                // Save session
1541                if let Some(ref session) = self.session {
1542                    let _ = crate::commands::spawn::monitor::save_session(
1543                        self.project_root.as_ref(),
1544                        session,
1545                    );
1546                }
1547
1548                // Refresh
1549                let _ = self.refresh();
1550                self.refresh_waves();
1551            }
1552            Err(e) => {
1553                self.error = Some(format!(
1554                    "Failed to spawn Ralph agent for {}: {}",
1555                    task_id, e
1556                ));
1557            }
1558        }
1559
1560        Ok(())
1561    }
1562}