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