1pub mod agent;
10pub mod hooks;
11pub mod monitor;
12pub mod terminal;
13pub mod tui;
14
15use anyhow::Result;
16use colored::Colorize;
17use std::path::PathBuf;
18use std::thread;
19use std::time::Duration;
20
21use crate::commands::helpers::{flatten_all_tasks, resolve_group_tag};
22use crate::models::task::{Task, TaskStatus};
23use crate::storage::Storage;
24use crate::sync::claude_tasks;
25
26use self::monitor::SpawnSession;
27use self::terminal::Harness;
28
29struct TaskInfo<'a> {
31 task: &'a Task,
32 tag: String,
33}
34
35#[allow(clippy::too_many_arguments)]
37pub fn run(
38 project_root: Option<PathBuf>,
39 tag: Option<&str>,
40 limit: usize,
41 all_tags: bool,
42 dry_run: bool,
43 session: Option<String>,
44 attach: bool,
45 monitor: bool,
46 claim: bool,
47 harness_arg: &str,
48 model_arg: &str,
49) -> Result<()> {
50 let storage = Storage::new(project_root.clone());
51
52 if !storage.is_initialized() {
53 anyhow::bail!("SCUD not initialized. Run: scud init");
54 }
55
56 terminal::check_tmux_available()?;
58
59 let all_phases = storage.load_tasks()?;
61 let all_tasks_flat = flatten_all_tasks(&all_phases);
62
63 let phase_tag = if all_tags {
65 "all".to_string()
66 } else {
67 resolve_group_tag(&storage, tag, true)?
68 };
69
70 let ready_tasks = get_ready_tasks(&all_phases, &all_tasks_flat, &phase_tag, limit, all_tags)?;
72
73 if ready_tasks.is_empty() {
74 println!("{}", "No ready tasks to spawn.".yellow());
75 println!("Check: scud list --status pending");
76 return Ok(());
77 }
78
79 let harness = Harness::parse(harness_arg)?;
81
82 let session_name = session.unwrap_or_else(|| format!("scud-{}", phase_tag));
84
85 println!("{}", "SCUD Spawn".cyan().bold());
87 println!("{}", "═".repeat(50));
88 println!("{:<20} {}", "Terminal:".dimmed(), "tmux".green());
89 println!("{:<20} {}", "Harness:".dimmed(), harness.name().green());
90 println!("{:<20} {}", "Model:".dimmed(), model_arg.green());
91 println!("{:<20} {}", "Session:".dimmed(), session_name.cyan());
92 println!("{:<20} {}", "Tasks:".dimmed(), ready_tasks.len());
93 println!();
94
95 for (i, info) in ready_tasks.iter().enumerate() {
96 println!(
97 " {} {} {} | {}",
98 format!("[{}]", i + 1).dimmed(),
99 info.tag.dimmed(),
100 info.task.id.cyan(),
101 info.task.title
102 );
103 }
104 println!();
105
106 if dry_run {
107 println!("{}", "Dry run - no terminals spawned.".yellow());
108 return Ok(());
109 }
110
111 let working_dir = project_root
113 .clone()
114 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
115
116 if !hooks::hooks_installed(&working_dir) {
118 println!(
119 "{}",
120 "Installing Claude Code hooks for task completion...".dimmed()
121 );
122 if let Err(e) = hooks::install_hooks(&working_dir) {
123 println!(
124 " {} Hook installation: {}",
125 "!".yellow(),
126 e.to_string().dimmed()
127 );
128 } else {
129 println!(
130 " {} Hooks installed (tasks auto-complete on agent stop)",
131 "✓".green()
132 );
133 }
134 }
135
136 let task_list_id = claude_tasks::task_list_id(&phase_tag);
139 if !all_tags {
140 if let Some(phase) = all_phases.get(&phase_tag) {
142 match claude_tasks::sync_phase(phase, &phase_tag) {
143 Ok(sync_path) => {
144 let path_str: String = sync_path.display().to_string();
145 println!(
146 " {} Synced tasks to: {}",
147 "✓".green(),
148 path_str.dimmed()
149 );
150 }
151 Err(e) => {
152 let err_str: String = e.to_string();
153 println!(
154 " {} Task sync failed: {}",
155 "!".yellow(),
156 err_str.dimmed()
157 );
158 }
159 }
160 }
161 } else {
162 match claude_tasks::sync_phases(&all_phases) {
164 Ok(paths) => {
165 let count: usize = paths.len();
166 println!(
167 " {} Synced {} phases to Claude Tasks format",
168 "✓".green(),
169 count
170 );
171 }
172 Err(e) => {
173 let err_str: String = e.to_string();
174 println!(
175 " {} Task sync failed: {}",
176 "!".yellow(),
177 err_str.dimmed()
178 );
179 }
180 }
181 }
182
183 let mut spawn_session = SpawnSession::new(
185 &session_name,
186 &phase_tag,
187 "tmux",
188 &working_dir.to_string_lossy(),
189 );
190
191 println!("{}", "Spawning agents...".green());
193
194 let mut success_count = 0;
195 let mut claimed_tasks: Vec<(String, String)> = Vec::new(); for info in &ready_tasks {
198 let config = agent::resolve_agent_config(
200 info.task,
201 &info.tag,
202 harness,
203 Some(model_arg),
204 &working_dir,
205 );
206
207 if info.task.agent_type.is_some() && !config.from_agent_def {
209 println!(
210 " {} Agent '{}' not found, using CLI defaults",
211 "!".yellow(),
212 info.task.agent_type.as_deref().unwrap_or("unknown")
213 );
214 }
215
216 match terminal::spawn_terminal_with_task_list(
217 &info.task.id,
218 &config.prompt,
219 &working_dir,
220 &session_name,
221 config.harness,
222 config.model.as_deref(),
223 &task_list_id,
224 ) {
225 Ok(window_index) => {
226 println!(
227 " {} Spawned: {} | {} [{}] {}:{}",
228 "✓".green(),
229 info.task.id.cyan(),
230 info.task.title.dimmed(),
231 config.display_info().dimmed(),
232 session_name.dimmed(),
233 window_index.dimmed(),
234 );
235 spawn_session.add_agent(&info.task.id, &info.task.title, &info.tag);
236 success_count += 1;
237
238 if claim {
240 claimed_tasks.push((info.task.id.clone(), info.tag.clone()));
241 }
242 }
243 Err(e) => {
244 println!(" {} Failed: {} - {}", "✗".red(), info.task.id.red(), e);
245 }
246 }
247
248 if success_count < ready_tasks.len() {
250 thread::sleep(Duration::from_millis(500));
251 }
252 }
253
254 if claim && !claimed_tasks.is_empty() {
256 println!();
257 println!("{}", "Claiming tasks...".dimmed());
258 for (task_id, task_tag) in &claimed_tasks {
259 match storage.load_group(task_tag) {
261 Ok(mut phase) => {
262 if let Some(task) = phase.get_task_mut(task_id) {
263 task.set_status(TaskStatus::InProgress);
264 if let Err(e) = storage.update_group(task_tag, &phase) {
265 println!(
266 " {} Claim failed: {} - {}",
267 "!".yellow(),
268 task_id,
269 e.to_string().dimmed()
270 );
271 } else {
272 println!(
273 " {} Claimed: {} → {}",
274 "✓".green(),
275 task_id.cyan(),
276 "in-progress".yellow()
277 );
278 }
279 }
280 }
281 Err(e) => {
282 println!(
283 " {} Claim failed: {} - {}",
284 "!".yellow(),
285 task_id,
286 e.to_string().dimmed()
287 );
288 }
289 }
290 }
291 }
292
293 if let Err(e) = terminal::setup_tmux_control_window(&session_name, &phase_tag) {
295 println!(
296 " {} Control window setup: {}",
297 "!".yellow(),
298 e.to_string().dimmed()
299 );
300 }
301
302 if let Err(e) = monitor::save_session(project_root.as_ref(), &spawn_session) {
304 println!(
305 " {} Session metadata: {}",
306 "!".yellow(),
307 e.to_string().dimmed()
308 );
309 }
310
311 println!();
313 println!(
314 "{} {} of {} agents spawned",
315 "Summary:".blue().bold(),
316 success_count,
317 ready_tasks.len()
318 );
319
320 println!();
321 println!(
322 "To attach: {}",
323 format!("tmux attach -t {}", session_name).cyan()
324 );
325 println!(
326 "To list: {}",
327 format!("tmux list-windows -t {}", session_name).dimmed()
328 );
329
330 if monitor {
332 println!();
333 println!("Starting monitor...");
334 thread::sleep(Duration::from_secs(1));
336 return tui::run(project_root, &session_name, false); }
338
339 if attach {
341 println!();
342 println!("Attaching to session...");
343 terminal::tmux_attach(&session_name)?;
344 }
345
346 Ok(())
347}
348
349pub fn run_monitor(
351 project_root: Option<PathBuf>,
352 session: Option<String>,
353 swarm_mode: bool,
354) -> Result<()> {
355 use crate::commands::swarm::session as swarm_session;
356 use colored::Colorize;
357
358 let project_root_display = project_root
360 .as_ref()
361 .and_then(|p| p.to_str())
362 .unwrap_or("current directory");
363
364 let mode_label = if swarm_mode { "swarm" } else { "spawn" };
365 eprintln!(
366 "{} Monitor ({}) looking for sessions in: {}",
367 "DEBUG:".yellow(),
368 mode_label,
369 project_root_display
370 );
371
372 let session_name = match session {
374 Some(s) => s,
375 None => {
376 let sessions = if swarm_mode {
377 swarm_session::list_sessions(project_root.as_ref())?
378 } else {
379 monitor::list_sessions(project_root.as_ref())?
380 };
381 eprintln!(
382 "{} Found {} {} session(s): {:?}",
383 "DEBUG:".yellow(),
384 sessions.len(),
385 mode_label,
386 sessions
387 );
388 if sessions.is_empty() {
389 let cmd = if swarm_mode { "scud swarm" } else { "scud spawn" };
390 eprintln!(
391 "{} No {} sessions found in: {}",
392 "DEBUG:".yellow(),
393 mode_label,
394 project_root_display
395 );
396 eprintln!(
397 "{} Run: {} --project {} (if needed)",
398 "HINT:".cyan(),
399 cmd,
400 project_root_display
401 );
402 anyhow::bail!("No {} sessions found. Run: {}", mode_label, cmd);
403 }
404 if sessions.len() == 1 {
405 sessions[0].clone()
406 } else {
407 println!("{}", format!("Available {} sessions:", mode_label).cyan().bold());
408 for (i, s) in sessions.iter().enumerate() {
409 println!(" {} {}", format!("[{}]", i + 1).dimmed(), s);
410 }
411 anyhow::bail!(
412 "Multiple {} sessions found. Specify one with --session <name>",
413 mode_label
414 );
415 }
416 }
417 };
418
419 tui::run(project_root, &session_name, swarm_mode)
420}
421
422pub fn run_sessions(project_root: Option<PathBuf>, verbose: bool) -> Result<()> {
424 use colored::Colorize;
425
426 let sessions = monitor::list_sessions(project_root.as_ref())?;
427
428 if sessions.is_empty() {
429 println!("{}", "No spawn sessions found.".dimmed());
430 println!("Run: scud spawn -m --limit 3");
431 return Ok(());
432 }
433
434 println!("{}", "Spawn Sessions:".cyan().bold());
435 println!();
436
437 for session_name in &sessions {
438 if verbose {
439 match monitor::load_session(project_root.as_ref(), session_name) {
441 Ok(session) => {
442 let stats = monitor::SpawnStats::from(&session);
443 println!(
444 " {} {} agents ({} running, {} done)",
445 session_name.cyan(),
446 format!("[{}]", stats.total_agents).dimmed(),
447 stats.running.to_string().green(),
448 stats.completed.to_string().blue()
449 );
450 println!(
451 " {} Tag: {}, Terminal: {}",
452 "│".dimmed(),
453 session.tag,
454 session.terminal
455 );
456 println!(
457 " {} Created: {}",
458 "└".dimmed(),
459 session.created_at.dimmed()
460 );
461 println!();
462 }
463 Err(_) => {
464 println!(" {} {}", session_name, "(unable to load)".red());
465 }
466 }
467 } else {
468 println!(" {}", session_name);
469 }
470 }
471
472 if !verbose {
473 println!();
474 println!(
475 "{}",
476 "Use -v for details, or: scud monitor --session <name>".dimmed()
477 );
478 }
479
480 Ok(())
481}
482
483fn get_ready_tasks<'a>(
485 all_phases: &'a std::collections::HashMap<String, crate::models::phase::Phase>,
486 all_tasks_flat: &[&Task],
487 phase_tag: &str,
488 limit: usize,
489 all_tags: bool,
490) -> Result<Vec<TaskInfo<'a>>> {
491 let mut ready_tasks: Vec<TaskInfo<'a>> = Vec::new();
492
493 if all_tags {
494 for (tag, phase) in all_phases {
496 for task in &phase.tasks {
497 if is_task_ready(task, phase, all_tasks_flat) {
498 ready_tasks.push(TaskInfo {
499 task,
500 tag: tag.clone(),
501 });
502 }
503 }
504 }
505 } else {
506 let phase = all_phases
508 .get(phase_tag)
509 .ok_or_else(|| anyhow::anyhow!("Phase '{}' not found", phase_tag))?;
510
511 for task in &phase.tasks {
512 if is_task_ready(task, phase, all_tasks_flat) {
513 ready_tasks.push(TaskInfo {
514 task,
515 tag: phase_tag.to_string(),
516 });
517 }
518 }
519 }
520
521 ready_tasks.truncate(limit);
523
524 Ok(ready_tasks)
525}
526
527fn is_task_ready(
529 task: &Task,
530 phase: &crate::models::phase::Phase,
531 all_tasks_flat: &[&Task],
532) -> bool {
533 if task.status != TaskStatus::Pending {
535 return false;
536 }
537
538 if task.is_expanded() {
540 return false;
541 }
542
543 if let Some(ref parent_id) = task.parent_id {
545 let parent_expanded = phase
546 .get_task(parent_id)
547 .map(|p| p.is_expanded())
548 .unwrap_or(false);
549 if !parent_expanded {
550 return false;
551 }
552 }
553
554 task.has_dependencies_met_refs(all_tasks_flat)
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561 use crate::models::phase::Phase;
562 use crate::models::task::Task;
563
564 #[test]
565 fn test_is_task_ready_basic() {
566 let mut phase = Phase::new("test".to_string());
567 let task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
568 phase.add_task(task);
569
570 let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
571 assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
572 }
573
574 #[test]
575 fn test_is_task_ready_in_progress() {
576 let mut phase = Phase::new("test".to_string());
577 let mut task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
578 task.set_status(TaskStatus::InProgress);
579 phase.add_task(task);
580
581 let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
582 assert!(!is_task_ready(&phase.tasks[0], &phase, &all_tasks));
583 }
584
585 #[test]
586 fn test_is_task_ready_blocked_by_deps() {
587 let mut phase = Phase::new("test".to_string());
588
589 let task1 = Task::new("1".to_string(), "First".to_string(), "Desc".to_string());
590
591 let mut task2 = Task::new("2".to_string(), "Second".to_string(), "Desc".to_string());
592 task2.dependencies = vec!["1".to_string()];
593
594 phase.add_task(task1);
595 phase.add_task(task2);
596
597 let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
598
599 assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
601 assert!(!is_task_ready(&phase.tasks[1], &phase, &all_tasks));
603 }
604}