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 window_target = match self.window_target_for(
318            &session.session_name,
319            &agent.window_name,
320            &tmux_windows,
321        ) {
322            Some(target) => target,
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 = self
375                .find_window_index(&agent.window_name, &tmux_windows)
376                .is_some();
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                // In-progress task without a backing window is likely orphaned/stuck.
391                (Some(TaskStatus::InProgress), false) => AgentStatus::Failed,
392                (_, false) => AgentStatus::Completed,
393                (_, true) => AgentStatus::Running,
394            };
395        }
396    }
397
398    /// Get list of tmux windows for a session: [(index, name), ...]
399    fn get_tmux_windows(&self, session_name: &str) -> Vec<(usize, String)> {
400        let output = Command::new("tmux")
401            .args([
402                "list-windows",
403                "-t",
404                session_name,
405                "-F",
406                "#{window_index}:#{window_name}",
407            ])
408            .output();
409
410        match output {
411            Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
412                .lines()
413                .filter_map(|line| {
414                    let parts: Vec<&str> = line.splitn(2, ':').collect();
415                    if parts.len() == 2 {
416                        parts[0].parse().ok().map(|idx| (idx, parts[1].to_string()))
417                    } else {
418                        None
419                    }
420                })
421                .collect(),
422            _ => Vec::new(),
423        }
424    }
425
426    fn window_name_matches(expected: &str, observed: &str) -> bool {
427        observed.starts_with(expected) || expected.starts_with(observed)
428    }
429
430    fn find_window_index(
431        &self,
432        window_name: &str,
433        tmux_windows: &[(usize, String)],
434    ) -> Option<usize> {
435        tmux_windows
436            .iter()
437            .find(|(_, observed_name)| Self::window_name_matches(window_name, observed_name))
438            .map(|(index, _)| *index)
439    }
440
441    fn window_target_for(
442        &self,
443        session_name: &str,
444        window_name: &str,
445        tmux_windows: &[(usize, String)],
446    ) -> Option<String> {
447        self.find_window_index(window_name, tmux_windows)
448            .map(|index| format!("{}:{}", session_name, index))
449    }
450
451    /// Periodic tick - refresh data as needed
452    pub fn tick(&mut self) -> Result<()> {
453        // Refresh session/status data periodically
454        if self.last_refresh.elapsed() >= self.refresh_interval {
455            self.refresh()?;
456            self.refresh_waves();
457        }
458
459        // Refresh live output more frequently
460        if self.last_output_refresh.elapsed() >= self.output_refresh_interval {
461            self.refresh_live_output();
462        }
463
464        // Ralph mode: auto-spawn ready tasks
465        if self.ralph_mode && self.last_ralph_check.elapsed() >= Duration::from_secs(5) {
466            self.ralph_auto_spawn();
467            self.last_ralph_check = Instant::now();
468        }
469
470        Ok(())
471    }
472
473    /// Toggle Ralph mode (autonomous wave execution)
474    pub fn toggle_ralph_mode(&mut self) {
475        self.ralph_mode = !self.ralph_mode;
476        if self.ralph_mode {
477            // Immediately check for tasks to spawn
478            self.ralph_auto_spawn();
479        }
480    }
481
482    /// Auto-spawn ready tasks in Ralph mode
483    fn ralph_auto_spawn(&mut self) {
484        // Count running agents
485        let running_count = self
486            .agents()
487            .iter()
488            .filter(|a| a.status == AgentStatus::Running || a.status == AgentStatus::Starting)
489            .count();
490
491        if running_count >= self.ralph_max_parallel {
492            return; // Already at max parallel
493        }
494
495        // Find ready tasks to spawn
496        let slots_available = self.ralph_max_parallel - running_count;
497        let mut tasks_to_spawn: Vec<String> = Vec::new();
498
499        for wave in &self.waves {
500            for task in &wave.tasks {
501                if task.state == WaveTaskState::Ready && !self.selected_tasks.contains(&task.id) {
502                    // Check if already have an agent for this task
503                    let already_spawned = self.agents().iter().any(|a| a.task_id == task.id);
504                    if !already_spawned {
505                        tasks_to_spawn.push(task.id.clone());
506                        if tasks_to_spawn.len() >= slots_available {
507                            break;
508                        }
509                    }
510                }
511            }
512            if tasks_to_spawn.len() >= slots_available {
513                break;
514            }
515        }
516
517        // Spawn the tasks with Ralph loop enabled
518        for task_id in tasks_to_spawn {
519            let _ = self.spawn_task_with_ralph(&task_id);
520        }
521    }
522
523    /// Get agents list
524    pub fn agents(&self) -> &[AgentState] {
525        self.session
526            .as_ref()
527            .map(|s| s.agents.as_slice())
528            .unwrap_or(&[])
529    }
530
531    /// Select next agent
532    pub fn next_agent(&mut self) {
533        let len = self.agents().len();
534        if len > 0 {
535            self.selected = (self.selected + 1) % len;
536            self.adjust_agents_scroll();
537            self.reset_scroll();
538            self.refresh_live_output();
539        }
540    }
541
542    /// Select previous agent
543    pub fn previous_agent(&mut self) {
544        let len = self.agents().len();
545        if len > 0 {
546            self.selected = if self.selected > 0 {
547                self.selected - 1
548            } else {
549                len - 1
550            };
551            self.adjust_agents_scroll();
552            self.reset_scroll();
553            self.refresh_live_output();
554        }
555    }
556
557    /// Adjust agents scroll offset to keep selected agent visible
558    /// Assumes roughly 8 visible lines in the agents panel
559    pub fn adjust_agents_scroll(&mut self) {
560        const VISIBLE_LINES: usize = 8;
561
562        // Scroll up if selected is above visible area
563        if self.selected < self.agents_scroll_offset {
564            self.agents_scroll_offset = self.selected;
565        }
566        // Scroll down if selected is below visible area
567        else if self.selected >= self.agents_scroll_offset + VISIBLE_LINES {
568            self.agents_scroll_offset = self.selected.saturating_sub(VISIBLE_LINES - 1);
569        }
570    }
571
572    /// Toggle fullscreen mode
573    pub fn toggle_fullscreen(&mut self) {
574        self.view_mode = match self.view_mode {
575            ViewMode::Split => ViewMode::Fullscreen,
576            ViewMode::Fullscreen => ViewMode::Split,
577            ViewMode::Input => ViewMode::Fullscreen,
578        };
579    }
580
581    /// Exit current mode (go back to split)
582    pub fn exit_fullscreen(&mut self) {
583        self.view_mode = ViewMode::Split;
584        self.input_buffer.clear();
585    }
586
587    /// Enter input mode
588    pub fn enter_input_mode(&mut self) {
589        self.view_mode = ViewMode::Input;
590        self.input_buffer.clear();
591    }
592
593    /// Add character to input buffer
594    pub fn input_char(&mut self, c: char) {
595        self.input_buffer.push(c);
596    }
597
598    /// Delete last character from input buffer
599    pub fn input_backspace(&mut self) {
600        self.input_buffer.pop();
601    }
602
603    /// Send the input buffer to the selected agent's tmux pane
604    pub fn send_input(&mut self) -> Result<()> {
605        if self.input_buffer.is_empty() {
606            return Ok(());
607        }
608
609        let session = match &self.session {
610            Some(s) => s,
611            None => {
612                self.error = Some("No session loaded".to_string());
613                return Ok(());
614            }
615        };
616
617        let agents = self.agents();
618        if agents.is_empty() || self.selected >= agents.len() {
619            self.error = Some("No agent selected".to_string());
620            return Ok(());
621        }
622
623        let agent = &agents[self.selected];
624
625        // Find window index
626        let tmux_windows = self.get_tmux_windows(&session.session_name);
627        let window_target = match self.window_target_for(
628            &session.session_name,
629            &agent.window_name,
630            &tmux_windows,
631        ) {
632            Some(target) => target,
633            None => {
634                self.error = Some(format!("Window not found for {}", agent.task_id));
635                return Ok(());
636            }
637        };
638
639        // Send the input to tmux
640        let result = Command::new("tmux")
641            .args([
642                "send-keys",
643                "-t",
644                &window_target,
645                &self.input_buffer,
646                "Enter",
647            ])
648            .output();
649
650        match result {
651            Ok(out) if out.status.success() => {
652                self.error = None;
653                self.input_buffer.clear();
654                self.view_mode = ViewMode::Fullscreen; // Go to fullscreen to see result
655                self.refresh_live_output();
656            }
657            Ok(out) => {
658                self.error = Some(format!(
659                    "Send failed: {}",
660                    String::from_utf8_lossy(&out.stderr)
661                ));
662            }
663            Err(e) => {
664                self.error = Some(format!("tmux error: {}", e));
665            }
666        }
667
668        Ok(())
669    }
670
671    /// Restart the selected agent (kill and respawn claude)
672    pub fn restart_agent(&mut self) -> Result<()> {
673        let session = match &self.session {
674            Some(s) => s,
675            None => return Ok(()),
676        };
677
678        let agents = self.agents();
679        if agents.is_empty() || self.selected >= agents.len() {
680            return Ok(());
681        }
682
683        let agent = &agents[self.selected];
684
685        // Find window
686        let tmux_windows = self.get_tmux_windows(&session.session_name);
687        if let Some(target) =
688            self.window_target_for(&session.session_name, &agent.window_name, &tmux_windows)
689        {
690            // Send Ctrl+C to interrupt current process
691            let _ = Command::new("tmux")
692                .args(["send-keys", "-t", &target, "C-c"])
693                .output();
694
695            // Small delay
696            std::thread::sleep(Duration::from_millis(200));
697
698            // Clear and show message
699            let _ = Command::new("tmux")
700                .args([
701                    "send-keys",
702                    "-t",
703                    &target,
704                    "echo 'Agent restarted by user'",
705                    "Enter",
706                ])
707                .output();
708
709            self.error = None;
710            self.refresh_live_output();
711        }
712
713        Ok(())
714    }
715
716    /// Toggle help overlay
717    pub fn toggle_help(&mut self) {
718        self.show_help = !self.show_help;
719    }
720
721    /// Scroll terminal output up (show older content)
722    pub fn scroll_up(&mut self, lines: usize) {
723        let max_scroll = self.live_output.len().saturating_sub(1);
724        self.scroll_offset = (self.scroll_offset + lines).min(max_scroll);
725        self.auto_scroll = false;
726    }
727
728    /// Scroll terminal output down (show newer content)
729    pub fn scroll_down(&mut self, lines: usize) {
730        self.scroll_offset = self.scroll_offset.saturating_sub(lines);
731        if self.scroll_offset == 0 {
732            self.auto_scroll = true;
733        }
734    }
735
736    /// Jump to bottom of terminal output
737    pub fn scroll_to_bottom(&mut self) {
738        self.scroll_offset = 0;
739        self.auto_scroll = true;
740    }
741
742    /// Reset scroll when switching agents
743    fn reset_scroll(&mut self) {
744        self.scroll_offset = 0;
745        self.auto_scroll = true;
746    }
747
748    /// Get status counts (starting, running, completed, failed)
749    pub fn status_counts(&self) -> (usize, usize, usize, usize) {
750        let agents = self.agents();
751        let starting = agents
752            .iter()
753            .filter(|a| a.status == AgentStatus::Starting)
754            .count();
755        let running = agents
756            .iter()
757            .filter(|a| a.status == AgentStatus::Running)
758            .count();
759        let completed = agents
760            .iter()
761            .filter(|a| a.status == AgentStatus::Completed)
762            .count();
763        let failed = agents
764            .iter()
765            .filter(|a| a.status == AgentStatus::Failed)
766            .count();
767        (starting, running, completed, failed)
768    }
769
770    /// Get the selected agent (if any)
771    pub fn selected_agent(&self) -> Option<&AgentState> {
772        let agents = self.agents();
773        if agents.is_empty() || self.selected >= agents.len() {
774            None
775        } else {
776            Some(&agents[self.selected])
777        }
778    }
779
780    // === Wave-related methods ===
781
782    /// Refresh waves data from phases
783    pub fn refresh_waves(&mut self) {
784        // Reload phases from storage
785        let storage = Storage::new(self.project_root.clone());
786        self.phases = storage.load_tasks().unwrap_or_default();
787
788        // In swarm mode, use actual wave data from swarm session
789        if self.swarm_mode {
790            self.waves = self.compute_swarm_waves();
791            // Update swarm progress after computing waves (now that phases are fresh)
792            self.swarm_progress = self.compute_swarm_progress();
793            return;
794        }
795
796        // Get running agent task IDs
797        let running_task_ids: HashSet<String> = self
798            .agents()
799            .iter()
800            .filter(|a| a.status == AgentStatus::Running || a.status == AgentStatus::Starting)
801            .map(|a| a.task_id.clone())
802            .collect();
803
804        // Determine which tag to use
805        let tag = self.active_tag.clone().or_else(|| {
806            // Try to get tag from session
807            self.session.as_ref().map(|s| s.tag.clone())
808        });
809
810        let Some(tag) = tag else {
811            self.waves = Vec::new();
812            return;
813        };
814
815        let Some(phase) = self.phases.get(&tag) else {
816            self.waves = Vec::new();
817            return;
818        };
819
820        // Build waves using topological sort
821        self.waves = self.compute_waves(phase, &running_task_ids);
822    }
823
824    /// Compute swarm progress from session data and phases
825    ///
826    /// This aggregates progress information from the swarm session and task phases
827    /// into a consolidated SwarmProgress struct for easy access by the UI.
828    fn compute_swarm_progress(&self) -> Option<SwarmProgress> {
829        let swarm = self.swarm_session_data.as_ref()?;
830        let phase = self.phases.get(&swarm.tag);
831
832        let total_waves = self.waves.len();
833
834        // Find current wave (first incomplete wave, or last wave if all complete)
835        let current_wave = swarm
836            .waves
837            .iter()
838            .find(|w| w.completed_at.is_none())
839            .map(|w| w.wave_number)
840            .unwrap_or_else(|| swarm.waves.last().map(|w| w.wave_number).unwrap_or(0));
841
842        // Count tasks by status from phase data
843        let (tasks_completed, tasks_in_progress, tasks_failed, tasks_total) =
844            if let Some(phase) = phase {
845                // Get all task IDs from swarm waves
846                let swarm_task_ids: HashSet<String> =
847                    swarm.waves.iter().flat_map(|w| w.all_task_ids()).collect();
848
849                let mut completed = 0;
850                let mut in_progress = 0;
851                let mut failed = 0;
852                let total = swarm_task_ids.len();
853
854                for task_id in &swarm_task_ids {
855                    if let Some(task) = phase.get_task(task_id) {
856                        match task.status {
857                            TaskStatus::Done => completed += 1,
858                            TaskStatus::InProgress => in_progress += 1,
859                            TaskStatus::Blocked => failed += 1,
860                            _ => {}
861                        }
862                    }
863                }
864
865                (completed, in_progress, failed, total)
866            } else {
867                // Fall back to counting from swarm session
868                let total = swarm.total_tasks();
869                let failed = swarm.total_failures();
870                (0, 0, failed, total)
871            };
872
873        // Count validation results
874        let (waves_validated, waves_failed_validation) =
875            swarm
876                .waves
877                .iter()
878                .fold((0, 0), |(validated, failed), wave| match &wave.validation {
879                    Some(v) if v.all_passed => (validated + 1, failed),
880                    Some(_) => (validated, failed + 1),
881                    None => (validated, failed),
882                });
883
884        // Count total repairs
885        let total_repairs: usize = swarm.waves.iter().map(|w| w.repairs.len()).sum();
886
887        Some(SwarmProgress {
888            current_wave,
889            total_waves,
890            tasks_completed,
891            tasks_total,
892            tasks_in_progress,
893            tasks_failed,
894            waves_validated,
895            waves_failed_validation,
896            total_repairs,
897        })
898    }
899
900    /// Compute waves from swarm session data (shows actual execution waves)
901    fn compute_swarm_waves(&self) -> Vec<Wave> {
902        let Some(ref swarm) = self.swarm_session_data else {
903            return Vec::new();
904        };
905
906        let tag = &swarm.tag;
907        let phase = self.phases.get(tag);
908
909        swarm
910            .waves
911            .iter()
912            .map(|wave_state| {
913                // Collect all task IDs from all rounds in this wave
914                let task_ids: Vec<String> = wave_state
915                    .rounds
916                    .iter()
917                    .flat_map(|round| round.task_ids.iter().cloned())
918                    .collect();
919
920                let tasks: Vec<WaveTask> = task_ids
921                    .iter()
922                    .map(|task_id| {
923                        // Try to get task info from phase
924                        let (title, complexity, dependencies, task_status) =
925                            if let Some(phase) = phase {
926                                if let Some(task) = phase.get_task(task_id) {
927                                    (
928                                        task.title.clone(),
929                                        task.complexity,
930                                        task.dependencies.clone(),
931                                        Some(task.status.clone()),
932                                    )
933                                } else {
934                                    (task_id.clone(), 1, vec![], None)
935                                }
936                            } else {
937                                (task_id.clone(), 1, vec![], None)
938                            };
939
940                        // Determine state based on task status and wave completion
941                        let state = match task_status {
942                            Some(TaskStatus::Done) => WaveTaskState::Done,
943                            Some(TaskStatus::InProgress) => WaveTaskState::Running,
944                            Some(TaskStatus::Blocked) => WaveTaskState::Blocked,
945                            Some(TaskStatus::Pending) => {
946                                if wave_state.completed_at.is_some() {
947                                    // Wave completed but task still pending = failed/blocked
948                                    WaveTaskState::Blocked
949                                } else {
950                                    WaveTaskState::Ready
951                                }
952                            }
953                            _ => WaveTaskState::Ready,
954                        };
955
956                        WaveTask {
957                            id: task_id.clone(),
958                            title,
959                            tag: tag.clone(),
960                            state,
961                            complexity,
962                            dependencies,
963                        }
964                    })
965                    .collect();
966
967                Wave {
968                    number: wave_state.wave_number,
969                    tasks,
970                }
971            })
972            .collect()
973    }
974
975    /// Compute execution waves for a phase
976    fn compute_waves(&self, phase: &Phase, running_task_ids: &HashSet<String>) -> Vec<Wave> {
977        // Collect actionable tasks
978        let mut actionable: Vec<&Task> = Vec::new();
979        for task in &phase.tasks {
980            if task.status == TaskStatus::Done
981                || task.status == TaskStatus::Expanded
982                || task.status == TaskStatus::Cancelled
983            {
984                continue;
985            }
986
987            // Skip parent tasks that have subtasks - only subtasks should be spawned
988            if !task.subtasks.is_empty() {
989                continue;
990            }
991
992            // If subtask, only include if parent is expanded
993            if let Some(ref parent_id) = task.parent_id {
994                let parent_expanded = phase
995                    .get_task(parent_id)
996                    .map(|p| p.is_expanded())
997                    .unwrap_or(false);
998                if !parent_expanded {
999                    continue;
1000                }
1001            }
1002
1003            actionable.push(task);
1004        }
1005
1006        if actionable.is_empty() {
1007            return Vec::new();
1008        }
1009
1010        // Build dependency graph
1011        let task_ids: HashSet<String> = actionable.iter().map(|t| t.id.clone()).collect();
1012        let mut in_degree: HashMap<String, usize> = HashMap::new();
1013        let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
1014
1015        for task in &actionable {
1016            in_degree.entry(task.id.clone()).or_insert(0);
1017
1018            for dep in &task.dependencies {
1019                if task_ids.contains(dep) {
1020                    // Internal dependency - track in graph
1021                    *in_degree.entry(task.id.clone()).or_insert(0) += 1;
1022                    dependents
1023                        .entry(dep.clone())
1024                        .or_default()
1025                        .push(task.id.clone());
1026                } else {
1027                    // External dependency - check if satisfied
1028                    // If not satisfied (e.g., Expanded with incomplete subtasks), block this task
1029                    if !self.is_dependency_satisfied(dep, phase) {
1030                        // Mark as blocked by setting very high in_degree
1031                        *in_degree.entry(task.id.clone()).or_insert(0) += 1000;
1032                    }
1033                }
1034            }
1035        }
1036
1037        // Kahn's algorithm with wave tracking
1038        let mut waves: Vec<Wave> = Vec::new();
1039        let mut remaining = in_degree.clone();
1040        let mut wave_number = 1;
1041
1042        while !remaining.is_empty() {
1043            let mut ready: Vec<String> = remaining
1044                .iter()
1045                .filter(|(_, &deg)| deg == 0)
1046                .map(|(id, _)| id.clone())
1047                .collect();
1048
1049            if ready.is_empty() {
1050                break; // Circular dependency
1051            }
1052
1053            // Sort for stable display order
1054            ready.sort();
1055
1056            // Build wave tasks with state
1057            let mut wave_tasks: Vec<WaveTask> = ready
1058                .iter()
1059                .filter_map(|task_id| {
1060                    actionable.iter().find(|t| &t.id == task_id).map(|task| {
1061                        let state = if task.status == TaskStatus::Done {
1062                            WaveTaskState::Done
1063                        } else if running_task_ids.contains(&task.id) {
1064                            WaveTaskState::Running
1065                        } else if task.status == TaskStatus::InProgress {
1066                            WaveTaskState::InProgress
1067                        } else if task.status == TaskStatus::Blocked {
1068                            WaveTaskState::Blocked
1069                        } else if self.is_task_ready(task, phase) {
1070                            WaveTaskState::Ready
1071                        } else {
1072                            WaveTaskState::Blocked
1073                        };
1074
1075                        WaveTask {
1076                            id: task.id.clone(),
1077                            title: task.title.clone(),
1078                            tag: self.active_tag.clone().unwrap_or_default(),
1079                            state,
1080                            complexity: task.complexity,
1081                            dependencies: task.dependencies.clone(),
1082                        }
1083                    })
1084                })
1085                .collect();
1086
1087            // Remove ready tasks and update dependents
1088            for task_id in &ready {
1089                remaining.remove(task_id);
1090                if let Some(deps) = dependents.get(task_id) {
1091                    for dep_id in deps {
1092                        if let Some(deg) = remaining.get_mut(dep_id) {
1093                            *deg = deg.saturating_sub(1);
1094                        }
1095                    }
1096                }
1097            }
1098
1099            if !wave_tasks.is_empty() {
1100                // Sort tasks by ID for stable display
1101                wave_tasks.sort_by(|a, b| a.id.cmp(&b.id));
1102                waves.push(Wave {
1103                    number: wave_number,
1104                    tasks: wave_tasks,
1105                });
1106            }
1107            wave_number += 1;
1108        }
1109
1110        waves
1111    }
1112
1113    /// Check if a task is ready (dependencies met, pending)
1114    fn is_task_ready(&self, task: &Task, phase: &Phase) -> bool {
1115        if task.status != TaskStatus::Pending {
1116            return false;
1117        }
1118
1119        // Check all dependencies are satisfied
1120        for dep_id in &task.dependencies {
1121            if !self.is_dependency_satisfied(dep_id, phase) {
1122                return false;
1123            }
1124        }
1125
1126        true
1127    }
1128
1129    /// Check if a dependency is satisfied (Done, or Expanded with all subtasks done)
1130    fn is_dependency_satisfied(&self, dep_id: &str, phase: &Phase) -> bool {
1131        let Some(dep) = phase.get_task(dep_id) else {
1132            return true; // If dep not found, assume external/done
1133        };
1134
1135        match dep.status {
1136            TaskStatus::Done => true,
1137            TaskStatus::Expanded => {
1138                // Expanded task is only satisfied if all its subtasks are done
1139                if dep.subtasks.is_empty() {
1140                    false // Expanded with no subtasks = not done yet
1141                } else {
1142                    dep.subtasks.iter().all(|subtask_id| {
1143                        phase
1144                            .get_task(subtask_id)
1145                            .map(|st| st.status == TaskStatus::Done)
1146                            .unwrap_or(false)
1147                    })
1148                }
1149            }
1150            _ => false, // Pending, InProgress, Blocked, Cancelled = not satisfied
1151        }
1152    }
1153
1154    /// Get flat list of all tasks in waves for navigation
1155    pub fn all_wave_tasks(&self) -> Vec<&WaveTask> {
1156        self.waves.iter().flat_map(|w| w.tasks.iter()).collect()
1157    }
1158
1159    /// Get currently selected wave task
1160    pub fn selected_wave_task(&self) -> Option<&WaveTask> {
1161        let all_tasks = self.all_wave_tasks();
1162        all_tasks.get(self.wave_task_index).copied()
1163    }
1164
1165    // === Panel navigation ===
1166
1167    /// Switch focus to next panel
1168    pub fn next_panel(&mut self) {
1169        self.focused_panel = match self.focused_panel {
1170            FocusedPanel::Waves => FocusedPanel::Agents,
1171            FocusedPanel::Agents => FocusedPanel::Output,
1172            FocusedPanel::Output => FocusedPanel::Waves,
1173        };
1174    }
1175
1176    /// Switch focus to previous panel
1177    pub fn previous_panel(&mut self) {
1178        self.focused_panel = match self.focused_panel {
1179            FocusedPanel::Waves => FocusedPanel::Output,
1180            FocusedPanel::Agents => FocusedPanel::Waves,
1181            FocusedPanel::Output => FocusedPanel::Agents,
1182        };
1183    }
1184
1185    /// Move selection up in current panel
1186    pub fn move_up(&mut self) {
1187        match self.focused_panel {
1188            FocusedPanel::Waves => {
1189                if self.wave_task_index > 0 {
1190                    self.wave_task_index -= 1;
1191                    self.adjust_wave_scroll();
1192                }
1193            }
1194            FocusedPanel::Agents => self.previous_agent(),
1195            FocusedPanel::Output => self.scroll_up(1),
1196        }
1197    }
1198
1199    /// Move selection down in current panel
1200    pub fn move_down(&mut self) {
1201        match self.focused_panel {
1202            FocusedPanel::Waves => {
1203                let max = self.all_wave_tasks().len().saturating_sub(1);
1204                if self.wave_task_index < max {
1205                    self.wave_task_index += 1;
1206                    self.adjust_wave_scroll();
1207                }
1208            }
1209            FocusedPanel::Agents => self.next_agent(),
1210            FocusedPanel::Output => self.scroll_down(1),
1211        }
1212    }
1213
1214    /// Adjust wave scroll offset to keep selected item visible
1215    /// Assumes visible height of ~4 items (plus wave headers)
1216    fn adjust_wave_scroll(&mut self) {
1217        // Calculate the line index of the current task
1218        // Each wave has 1 header line, then tasks
1219        let mut line_idx = 0;
1220        let mut found = false;
1221        let mut task_counter = 0;
1222
1223        for wave in &self.waves {
1224            line_idx += 1; // wave header
1225            for _ in &wave.tasks {
1226                if task_counter == self.wave_task_index {
1227                    found = true;
1228                    break;
1229                }
1230                line_idx += 1;
1231                task_counter += 1;
1232            }
1233            if found {
1234                break;
1235            }
1236        }
1237
1238        // Visible height is approximately 4-5 lines in the waves panel
1239        let visible_height = 4;
1240
1241        // Scroll to keep current line visible
1242        if line_idx < self.wave_scroll_offset {
1243            self.wave_scroll_offset = line_idx;
1244        } else if line_idx >= self.wave_scroll_offset + visible_height {
1245            self.wave_scroll_offset = line_idx.saturating_sub(visible_height - 1);
1246        }
1247    }
1248
1249    // === Task selection for spawning ===
1250
1251    /// Toggle selection of currently highlighted task
1252    pub fn toggle_task_selection(&mut self) {
1253        if let Some(task) = self.selected_wave_task() {
1254            let task_id = task.id.clone();
1255            if self.selected_tasks.contains(&task_id) {
1256                self.selected_tasks.remove(&task_id);
1257            } else {
1258                // Only allow selecting ready tasks
1259                if task.state == WaveTaskState::Ready {
1260                    self.selected_tasks.insert(task_id);
1261                }
1262            }
1263        }
1264    }
1265
1266    /// Select all ready tasks in waves
1267    pub fn select_all_ready(&mut self) {
1268        for wave in &self.waves {
1269            for task in &wave.tasks {
1270                if task.state == WaveTaskState::Ready {
1271                    self.selected_tasks.insert(task.id.clone());
1272                }
1273            }
1274        }
1275    }
1276
1277    /// Clear all task selections
1278    pub fn clear_selection(&mut self) {
1279        self.selected_tasks.clear();
1280    }
1281
1282    /// Get count of ready tasks
1283    pub fn ready_task_count(&self) -> usize {
1284        self.waves
1285            .iter()
1286            .flat_map(|w| &w.tasks)
1287            .filter(|t| t.state == WaveTaskState::Ready)
1288            .count()
1289    }
1290
1291    /// Get count of selected tasks
1292    pub fn selected_task_count(&self) -> usize {
1293        self.selected_tasks.len()
1294    }
1295
1296    /// Get selected tasks for spawning
1297    pub fn get_selected_tasks(&self) -> Vec<&WaveTask> {
1298        self.all_wave_tasks()
1299            .into_iter()
1300            .filter(|t| self.selected_tasks.contains(&t.id))
1301            .collect()
1302    }
1303
1304    /// Spawn the selected tasks
1305    /// Returns the number of tasks successfully spawned
1306    pub fn spawn_selected_tasks(&mut self) -> Result<usize> {
1307        use crate::commands::spawn::{agent, terminal};
1308
1309        let tasks_to_spawn: Vec<(String, String, String)> = self
1310            .get_selected_tasks()
1311            .iter()
1312            .map(|t| (t.id.clone(), t.title.clone(), t.tag.clone()))
1313            .collect();
1314
1315        if tasks_to_spawn.is_empty() {
1316            return Ok(0);
1317        }
1318
1319        // Get working directory
1320        let working_dir = self
1321            .project_root
1322            .clone()
1323            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1324
1325        // Get session info
1326        let session = match &self.session {
1327            Some(s) => s,
1328            None => {
1329                self.error = Some("No session loaded".to_string());
1330                return Ok(0);
1331            }
1332        };
1333
1334        let session_name = session.session_name.clone();
1335        let mut spawned_count = 0;
1336
1337        // Load phase to get full task data
1338        let storage = Storage::new(self.project_root.clone());
1339
1340        for (task_id, task_title, tag) in &tasks_to_spawn {
1341            // Get full task from phase
1342            let phase = match self.phases.get(tag) {
1343                Some(p) => p,
1344                None => continue,
1345            };
1346
1347            let task = match phase.get_task(task_id) {
1348                Some(t) => t,
1349                None => continue,
1350            };
1351
1352            // Generate prompt
1353            let prompt = agent::generate_prompt(task, tag);
1354
1355            // Spawn in tmux (always use tmux from TUI since we're in a session)
1356            match terminal::spawn_terminal(task_id, &prompt, &working_dir, &session_name) {
1357                Ok(_window_index) => {
1358                    spawned_count += 1;
1359
1360                    // Add to session agents
1361                    if let Some(ref mut session) = self.session {
1362                        session.add_agent(task_id, task_title, tag);
1363                    }
1364
1365                    // Mark task as in-progress
1366                    if let Ok(mut phase) = storage.load_group(tag) {
1367                        if let Some(task) = phase.get_task_mut(task_id) {
1368                            task.set_status(TaskStatus::InProgress);
1369                            let _ = storage.update_group(tag, &phase);
1370                        }
1371                    }
1372                }
1373                Err(e) => {
1374                    self.error = Some(format!("Failed to spawn {}: {}", task_id, e));
1375                }
1376            }
1377
1378            // Small delay between spawns
1379            if spawned_count < tasks_to_spawn.len() {
1380                std::thread::sleep(Duration::from_millis(300));
1381            }
1382        }
1383
1384        // Save updated session
1385        if spawned_count > 0 {
1386            if let Some(ref session) = self.session {
1387                let _ = crate::commands::spawn::monitor::save_session(
1388                    self.project_root.as_ref(),
1389                    session,
1390                );
1391            }
1392
1393            // Clear selection and refresh
1394            self.selected_tasks.clear();
1395            self.refresh()?;
1396            self.refresh_waves();
1397        }
1398
1399        Ok(spawned_count)
1400    }
1401
1402    /// Prepare to start swarm - returns swarm command and tag
1403    pub fn prepare_swarm_start(&self) -> Option<(String, String)> {
1404        // Get tag from session or active tag
1405        let tag = self
1406            .session
1407            .as_ref()
1408            .map(|s| s.tag.clone())
1409            .or_else(|| self.active_tag.clone())?;
1410
1411        // Build swarm command
1412        let session_base = self.session_name.replace("swarm-", "").replace("scud-", "");
1413        let cmd = format!("scud swarm --tag {} --session {}", tag, session_base);
1414
1415        Some((cmd, tag))
1416    }
1417
1418    /// Update the status of the currently selected agent's task
1419    pub fn set_selected_task_status(&mut self, new_status: TaskStatus) -> Result<()> {
1420        let Some(ref session) = self.session else {
1421            self.error = Some("No session loaded".to_string());
1422            return Ok(());
1423        };
1424
1425        let agents = session.agents.clone();
1426        if agents.is_empty() || self.selected >= agents.len() {
1427            self.error = Some("No agent selected".to_string());
1428            return Ok(());
1429        }
1430
1431        let agent = &agents[self.selected];
1432        let task_id = &agent.task_id;
1433        let tag = &agent.tag;
1434
1435        // Update task status in storage
1436        let storage = Storage::new(self.project_root.clone());
1437        if let Ok(mut phase) = storage.load_group(tag) {
1438            if let Some(task) = phase.get_task_mut(task_id) {
1439                task.set_status(new_status.clone());
1440                if let Err(e) = storage.update_group(tag, &phase) {
1441                    self.error = Some(format!("Failed to save: {}", e));
1442                    return Ok(());
1443                }
1444                // Show confirmation
1445                self.error = Some(format!("✓ {} → {}", task_id, new_status.as_str()));
1446            } else {
1447                self.error = Some(format!("Task {} not found", task_id));
1448            }
1449        } else {
1450            self.error = Some(format!("Failed to load phase {}", tag));
1451        }
1452
1453        // Refresh to show updated status
1454        self.refresh()?;
1455        self.refresh_waves();
1456
1457        Ok(())
1458    }
1459
1460    /// Spawn a single task with Ralph loop enabled
1461    /// The agent will keep trying until the task is marked done
1462    fn spawn_task_with_ralph(&mut self, task_id: &str) -> Result<()> {
1463        use crate::commands::spawn::{agent, terminal};
1464
1465        // Find the task in waves
1466        let task_info = self
1467            .waves
1468            .iter()
1469            .flat_map(|w| w.tasks.iter())
1470            .find(|t| t.id == task_id)
1471            .map(|t| (t.id.clone(), t.title.clone(), t.tag.clone()));
1472
1473        let (task_id, task_title, tag) = match task_info {
1474            Some(info) => info,
1475            None => return Ok(()),
1476        };
1477
1478        // Get working directory
1479        let working_dir = self
1480            .project_root
1481            .clone()
1482            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1483
1484        // Get session info
1485        let session = match &self.session {
1486            Some(s) => s,
1487            None => {
1488                self.error = Some("No session loaded".to_string());
1489                return Ok(());
1490            }
1491        };
1492
1493        let session_name = session.session_name.clone();
1494
1495        // Load phase to get full task data
1496        let storage = Storage::new(self.project_root.clone());
1497
1498        let phase = match self.phases.get(&tag) {
1499            Some(p) => p,
1500            None => return Ok(()),
1501        };
1502
1503        let task = match phase.get_task(&task_id) {
1504            Some(t) => t,
1505            None => return Ok(()),
1506        };
1507
1508        // Generate prompt with Ralph loop instructions
1509        let base_prompt = agent::generate_prompt(task, &tag);
1510        let ralph_prompt = format!(
1511            r#"{}
1512
1513═══════════════════════════════════════════════════════════
1514RALPH LOOP MODE - Autonomous Task Completion
1515═══════════════════════════════════════════════════════════
1516
1517CRITICAL: Your task ID is **{task_id}** (NOT any parent task!)
1518
1519You are in a Ralph loop. Keep working until the task is COMPLETE.
1520
1521After EACH attempt:
15221. Run EXACTLY: scud set-status {task_id} done
1523   ⚠️  Use task ID "{task_id}" - do NOT use any other task ID!
15242. Verify the task is truly done (tests pass, code works)
15253. If something failed, fix it and try again
1526
1527The loop will continue until task {task_id} is marked done.
1528Do NOT give up. Keep iterating until success.
1529
1530When you have genuinely completed task {task_id}, output:
1531<promise>TASK {task_id} COMPLETE</promise>
1532
1533DO NOT output this promise unless task {task_id} is TRULY complete!
1534═══════════════════════════════════════════════════════════
1535"#,
1536            base_prompt,
1537            task_id = task_id
1538        );
1539
1540        // Spawn in tmux with Ralph loop wrapper
1541        match terminal::spawn_terminal_ralph(
1542            &task_id,
1543            &ralph_prompt,
1544            &working_dir,
1545            &session_name,
1546            &format!("TASK {} COMPLETE", task_id),
1547        ) {
1548            Ok(()) => {
1549                // Add to session agents
1550                if let Some(ref mut session) = self.session {
1551                    session.add_agent(&task_id, &task_title, &tag);
1552                }
1553
1554                // Mark task as in-progress
1555                if let Ok(mut phase) = storage.load_group(&tag) {
1556                    if let Some(task) = phase.get_task_mut(&task_id) {
1557                        task.set_status(TaskStatus::InProgress);
1558                        let _ = storage.update_group(&tag, &phase);
1559                    }
1560                }
1561
1562                // Save session
1563                if let Some(ref session) = self.session {
1564                    let _ = crate::commands::spawn::monitor::save_session(
1565                        self.project_root.as_ref(),
1566                        session,
1567                    );
1568                }
1569
1570                // Refresh
1571                let _ = self.refresh();
1572                self.refresh_waves();
1573            }
1574            Err(e) => {
1575                self.error = Some(format!(
1576                    "Failed to spawn Ralph agent for {}: {}",
1577                    task_id, e
1578                ));
1579            }
1580        }
1581
1582        Ok(())
1583    }
1584}