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