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::agents::AgentDef;
22use crate::commands::helpers::{flatten_all_tasks, resolve_group_tag};
23use crate::models::task::{Task, TaskStatus};
24use crate::storage::Storage;
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 mut spawn_session = SpawnSession::new(
138 &session_name,
139 &phase_tag,
140 "tmux",
141 &working_dir.to_string_lossy(),
142 );
143
144 println!("{}", "Spawning agents...".green());
146
147 let mut success_count = 0;
148 let mut claimed_tasks: Vec<(String, String)> = Vec::new(); for info in &ready_tasks {
151 let (effective_harness, effective_model, prompt) =
153 if let Some(ref agent_type) = info.task.agent_type {
154 match AgentDef::try_load(agent_type, &working_dir) {
156 Some(agent_def) => {
157 let h = agent_def.harness().unwrap_or(harness);
158 let m = agent_def
159 .model()
160 .map(String::from)
161 .unwrap_or_else(|| model_arg.to_string());
162 let p = match agent_def.prompt_template(&working_dir) {
164 Some(template) => agent::generate_prompt_with_template(
165 info.task, &info.tag, &template,
166 ),
167 None => agent::generate_prompt(info.task, &info.tag),
168 };
169 (h, m, p)
170 }
171 None => {
172 println!(
174 " {} Agent '{}' not found, using CLI defaults",
175 "!".yellow(),
176 agent_type
177 );
178 (
179 harness,
180 model_arg.to_string(),
181 agent::generate_prompt(info.task, &info.tag),
182 )
183 }
184 }
185 } else {
186 (
188 harness,
189 model_arg.to_string(),
190 agent::generate_prompt(info.task, &info.tag),
191 )
192 };
193
194 match terminal::spawn_terminal_with_harness_and_model(
195 &info.task.id,
196 &prompt,
197 &working_dir,
198 &session_name,
199 effective_harness,
200 Some(&effective_model),
201 ) {
202 Ok(()) => {
203 let agent_info = if info.task.agent_type.is_some() {
204 format!("{}:{}", effective_harness.name(), effective_model)
205 } else {
206 format!("{}:{}", harness.name(), model_arg)
207 };
208 println!(
209 " {} Spawned: {} | {} [{}]",
210 "✓".green(),
211 info.task.id.cyan(),
212 info.task.title.dimmed(),
213 agent_info.dimmed(),
214 );
215 spawn_session.add_agent(&info.task.id, &info.task.title, &info.tag);
216 success_count += 1;
217
218 if claim {
220 claimed_tasks.push((info.task.id.clone(), info.tag.clone()));
221 }
222 }
223 Err(e) => {
224 println!(" {} Failed: {} - {}", "✗".red(), info.task.id.red(), e);
225 }
226 }
227
228 if success_count < ready_tasks.len() {
230 thread::sleep(Duration::from_millis(500));
231 }
232 }
233
234 if claim && !claimed_tasks.is_empty() {
236 println!();
237 println!("{}", "Claiming tasks...".dimmed());
238 for (task_id, task_tag) in &claimed_tasks {
239 match storage.load_group(task_tag) {
241 Ok(mut phase) => {
242 if let Some(task) = phase.get_task_mut(task_id) {
243 task.set_status(TaskStatus::InProgress);
244 if let Err(e) = storage.update_group(task_tag, &phase) {
245 println!(
246 " {} Claim failed: {} - {}",
247 "!".yellow(),
248 task_id,
249 e.to_string().dimmed()
250 );
251 } else {
252 println!(
253 " {} Claimed: {} → {}",
254 "✓".green(),
255 task_id.cyan(),
256 "in-progress".yellow()
257 );
258 }
259 }
260 }
261 Err(e) => {
262 println!(
263 " {} Claim failed: {} - {}",
264 "!".yellow(),
265 task_id,
266 e.to_string().dimmed()
267 );
268 }
269 }
270 }
271 }
272
273 if let Err(e) = terminal::setup_tmux_control_window(&session_name, &phase_tag) {
275 println!(
276 " {} Control window setup: {}",
277 "!".yellow(),
278 e.to_string().dimmed()
279 );
280 }
281
282 if let Err(e) = monitor::save_session(project_root.as_ref(), &spawn_session) {
284 println!(
285 " {} Session metadata: {}",
286 "!".yellow(),
287 e.to_string().dimmed()
288 );
289 }
290
291 println!();
293 println!(
294 "{} {} of {} agents spawned",
295 "Summary:".blue().bold(),
296 success_count,
297 ready_tasks.len()
298 );
299
300 println!();
301 println!(
302 "To attach: {}",
303 format!("tmux attach -t {}", session_name).cyan()
304 );
305 println!(
306 "To list: {}",
307 format!("tmux list-windows -t {}", session_name).dimmed()
308 );
309
310 if monitor {
312 println!();
313 println!("Starting monitor...");
314 thread::sleep(Duration::from_secs(1));
316 return tui::run(project_root, &session_name);
317 }
318
319 if attach {
321 println!();
322 println!("Attaching to session...");
323 terminal::tmux_attach(&session_name)?;
324 }
325
326 Ok(())
327}
328
329pub fn run_monitor(project_root: Option<PathBuf>, session: Option<String>) -> Result<()> {
331 use colored::Colorize;
332
333 let session_name = match session {
335 Some(s) => s,
336 None => {
337 let sessions = monitor::list_sessions(project_root.as_ref())?;
338 if sessions.is_empty() {
339 anyhow::bail!("No spawn sessions found. Run: scud spawn");
340 }
341 if sessions.len() == 1 {
342 sessions[0].clone()
343 } else {
344 println!("{}", "Available sessions:".cyan().bold());
345 for (i, s) in sessions.iter().enumerate() {
346 println!(" {} {}", format!("[{}]", i + 1).dimmed(), s);
347 }
348 anyhow::bail!("Multiple sessions found. Specify one with --session <name>");
349 }
350 }
351 };
352
353 tui::run(project_root, &session_name)
354}
355
356pub fn run_sessions(project_root: Option<PathBuf>, verbose: bool) -> Result<()> {
358 use colored::Colorize;
359
360 let sessions = monitor::list_sessions(project_root.as_ref())?;
361
362 if sessions.is_empty() {
363 println!("{}", "No spawn sessions found.".dimmed());
364 println!("Run: scud spawn -m --limit 3");
365 return Ok(());
366 }
367
368 println!("{}", "Spawn Sessions:".cyan().bold());
369 println!();
370
371 for session_name in &sessions {
372 if verbose {
373 match monitor::load_session(project_root.as_ref(), session_name) {
375 Ok(session) => {
376 let stats = monitor::SpawnStats::from(&session);
377 println!(
378 " {} {} agents ({} running, {} done)",
379 session_name.cyan(),
380 format!("[{}]", stats.total_agents).dimmed(),
381 stats.running.to_string().green(),
382 stats.completed.to_string().blue()
383 );
384 println!(
385 " {} Tag: {}, Terminal: {}",
386 "│".dimmed(),
387 session.tag,
388 session.terminal
389 );
390 println!(
391 " {} Created: {}",
392 "└".dimmed(),
393 session.created_at.dimmed()
394 );
395 println!();
396 }
397 Err(_) => {
398 println!(" {} {}", session_name, "(unable to load)".red());
399 }
400 }
401 } else {
402 println!(" {}", session_name);
403 }
404 }
405
406 if !verbose {
407 println!();
408 println!(
409 "{}",
410 "Use -v for details, or: scud monitor --session <name>".dimmed()
411 );
412 }
413
414 Ok(())
415}
416
417fn get_ready_tasks<'a>(
419 all_phases: &'a std::collections::HashMap<String, crate::models::phase::Phase>,
420 all_tasks_flat: &[&Task],
421 phase_tag: &str,
422 limit: usize,
423 all_tags: bool,
424) -> Result<Vec<TaskInfo<'a>>> {
425 let mut ready_tasks: Vec<TaskInfo<'a>> = Vec::new();
426
427 if all_tags {
428 for (tag, phase) in all_phases {
430 for task in &phase.tasks {
431 if is_task_ready(task, phase, all_tasks_flat) {
432 ready_tasks.push(TaskInfo {
433 task,
434 tag: tag.clone(),
435 });
436 }
437 }
438 }
439 } else {
440 let phase = all_phases
442 .get(phase_tag)
443 .ok_or_else(|| anyhow::anyhow!("Phase '{}' not found", phase_tag))?;
444
445 for task in &phase.tasks {
446 if is_task_ready(task, phase, all_tasks_flat) {
447 ready_tasks.push(TaskInfo {
448 task,
449 tag: phase_tag.to_string(),
450 });
451 }
452 }
453 }
454
455 ready_tasks.truncate(limit);
457
458 Ok(ready_tasks)
459}
460
461fn is_task_ready(
463 task: &Task,
464 phase: &crate::models::phase::Phase,
465 all_tasks_flat: &[&Task],
466) -> bool {
467 if task.status != TaskStatus::Pending {
469 return false;
470 }
471
472 if task.is_expanded() {
474 return false;
475 }
476
477 if let Some(ref parent_id) = task.parent_id {
479 let parent_expanded = phase
480 .get_task(parent_id)
481 .map(|p| p.is_expanded())
482 .unwrap_or(false);
483 if !parent_expanded {
484 return false;
485 }
486 }
487
488 task.has_dependencies_met_refs(all_tasks_flat)
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use crate::models::phase::Phase;
496 use crate::models::task::Task;
497
498 #[test]
499 fn test_is_task_ready_basic() {
500 let mut phase = Phase::new("test".to_string());
501 let task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
502 phase.add_task(task);
503
504 let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
505 assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
506 }
507
508 #[test]
509 fn test_is_task_ready_in_progress() {
510 let mut phase = Phase::new("test".to_string());
511 let mut task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
512 task.set_status(TaskStatus::InProgress);
513 phase.add_task(task);
514
515 let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
516 assert!(!is_task_ready(&phase.tasks[0], &phase, &all_tasks));
517 }
518
519 #[test]
520 fn test_is_task_ready_blocked_by_deps() {
521 let mut phase = Phase::new("test".to_string());
522
523 let task1 = Task::new("1".to_string(), "First".to_string(), "Desc".to_string());
524
525 let mut task2 = Task::new("2".to_string(), "Second".to_string(), "Desc".to_string());
526 task2.dependencies = vec!["1".to_string()];
527
528 phase.add_task(task1);
529 phase.add_task(task2);
530
531 let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
532
533 assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
535 assert!(!is_task_ready(&phase.tasks[1], &phase, &all_tasks));
537 }
538}