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(window_index) => {
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 session_name.dimmed(),
215 window_index.dimmed(),
216 );
217 spawn_session.add_agent(&info.task.id, &info.task.title, &info.tag);
218 success_count += 1;
219
220 if claim {
222 claimed_tasks.push((info.task.id.clone(), info.tag.clone()));
223 }
224 }
225 Err(e) => {
226 println!(" {} Failed: {} - {}", "✗".red(), info.task.id.red(), e);
227 }
228 }
229
230 if success_count < ready_tasks.len() {
232 thread::sleep(Duration::from_millis(500));
233 }
234 }
235
236 if claim && !claimed_tasks.is_empty() {
238 println!();
239 println!("{}", "Claiming tasks...".dimmed());
240 for (task_id, task_tag) in &claimed_tasks {
241 match storage.load_group(task_tag) {
243 Ok(mut phase) => {
244 if let Some(task) = phase.get_task_mut(task_id) {
245 task.set_status(TaskStatus::InProgress);
246 if let Err(e) = storage.update_group(task_tag, &phase) {
247 println!(
248 " {} Claim failed: {} - {}",
249 "!".yellow(),
250 task_id,
251 e.to_string().dimmed()
252 );
253 } else {
254 println!(
255 " {} Claimed: {} → {}",
256 "✓".green(),
257 task_id.cyan(),
258 "in-progress".yellow()
259 );
260 }
261 }
262 }
263 Err(e) => {
264 println!(
265 " {} Claim failed: {} - {}",
266 "!".yellow(),
267 task_id,
268 e.to_string().dimmed()
269 );
270 }
271 }
272 }
273 }
274
275 if let Err(e) = terminal::setup_tmux_control_window(&session_name, &phase_tag) {
277 println!(
278 " {} Control window setup: {}",
279 "!".yellow(),
280 e.to_string().dimmed()
281 );
282 }
283
284 if let Err(e) = monitor::save_session(project_root.as_ref(), &spawn_session) {
286 println!(
287 " {} Session metadata: {}",
288 "!".yellow(),
289 e.to_string().dimmed()
290 );
291 }
292
293 println!();
295 println!(
296 "{} {} of {} agents spawned",
297 "Summary:".blue().bold(),
298 success_count,
299 ready_tasks.len()
300 );
301
302 println!();
303 println!(
304 "To attach: {}",
305 format!("tmux attach -t {}", session_name).cyan()
306 );
307 println!(
308 "To list: {}",
309 format!("tmux list-windows -t {}", session_name).dimmed()
310 );
311
312 if monitor {
314 println!();
315 println!("Starting monitor...");
316 thread::sleep(Duration::from_secs(1));
318 return tui::run(project_root, &session_name);
319 }
320
321 if attach {
323 println!();
324 println!("Attaching to session...");
325 terminal::tmux_attach(&session_name)?;
326 }
327
328 Ok(())
329}
330
331pub fn run_monitor(project_root: Option<PathBuf>, session: Option<String>) -> Result<()> {
333 use colored::Colorize;
334
335 let session_name = match session {
337 Some(s) => s,
338 None => {
339 let sessions = monitor::list_sessions(project_root.as_ref())?;
340 if sessions.is_empty() {
341 anyhow::bail!("No spawn sessions found. Run: scud spawn");
342 }
343 if sessions.len() == 1 {
344 sessions[0].clone()
345 } else {
346 println!("{}", "Available sessions:".cyan().bold());
347 for (i, s) in sessions.iter().enumerate() {
348 println!(" {} {}", format!("[{}]", i + 1).dimmed(), s);
349 }
350 anyhow::bail!("Multiple sessions found. Specify one with --session <name>");
351 }
352 }
353 };
354
355 tui::run(project_root, &session_name)
356}
357
358pub fn run_sessions(project_root: Option<PathBuf>, verbose: bool) -> Result<()> {
360 use colored::Colorize;
361
362 let sessions = monitor::list_sessions(project_root.as_ref())?;
363
364 if sessions.is_empty() {
365 println!("{}", "No spawn sessions found.".dimmed());
366 println!("Run: scud spawn -m --limit 3");
367 return Ok(());
368 }
369
370 println!("{}", "Spawn Sessions:".cyan().bold());
371 println!();
372
373 for session_name in &sessions {
374 if verbose {
375 match monitor::load_session(project_root.as_ref(), session_name) {
377 Ok(session) => {
378 let stats = monitor::SpawnStats::from(&session);
379 println!(
380 " {} {} agents ({} running, {} done)",
381 session_name.cyan(),
382 format!("[{}]", stats.total_agents).dimmed(),
383 stats.running.to_string().green(),
384 stats.completed.to_string().blue()
385 );
386 println!(
387 " {} Tag: {}, Terminal: {}",
388 "│".dimmed(),
389 session.tag,
390 session.terminal
391 );
392 println!(
393 " {} Created: {}",
394 "└".dimmed(),
395 session.created_at.dimmed()
396 );
397 println!();
398 }
399 Err(_) => {
400 println!(" {} {}", session_name, "(unable to load)".red());
401 }
402 }
403 } else {
404 println!(" {}", session_name);
405 }
406 }
407
408 if !verbose {
409 println!();
410 println!(
411 "{}",
412 "Use -v for details, or: scud monitor --session <name>".dimmed()
413 );
414 }
415
416 Ok(())
417}
418
419fn get_ready_tasks<'a>(
421 all_phases: &'a std::collections::HashMap<String, crate::models::phase::Phase>,
422 all_tasks_flat: &[&Task],
423 phase_tag: &str,
424 limit: usize,
425 all_tags: bool,
426) -> Result<Vec<TaskInfo<'a>>> {
427 let mut ready_tasks: Vec<TaskInfo<'a>> = Vec::new();
428
429 if all_tags {
430 for (tag, phase) in all_phases {
432 for task in &phase.tasks {
433 if is_task_ready(task, phase, all_tasks_flat) {
434 ready_tasks.push(TaskInfo {
435 task,
436 tag: tag.clone(),
437 });
438 }
439 }
440 }
441 } else {
442 let phase = all_phases
444 .get(phase_tag)
445 .ok_or_else(|| anyhow::anyhow!("Phase '{}' not found", phase_tag))?;
446
447 for task in &phase.tasks {
448 if is_task_ready(task, phase, all_tasks_flat) {
449 ready_tasks.push(TaskInfo {
450 task,
451 tag: phase_tag.to_string(),
452 });
453 }
454 }
455 }
456
457 ready_tasks.truncate(limit);
459
460 Ok(ready_tasks)
461}
462
463fn is_task_ready(
465 task: &Task,
466 phase: &crate::models::phase::Phase,
467 all_tasks_flat: &[&Task],
468) -> bool {
469 if task.status != TaskStatus::Pending {
471 return false;
472 }
473
474 if task.is_expanded() {
476 return false;
477 }
478
479 if let Some(ref parent_id) = task.parent_id {
481 let parent_expanded = phase
482 .get_task(parent_id)
483 .map(|p| p.is_expanded())
484 .unwrap_or(false);
485 if !parent_expanded {
486 return false;
487 }
488 }
489
490 task.has_dependencies_met_refs(all_tasks_flat)
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use crate::models::phase::Phase;
498 use crate::models::task::Task;
499
500 #[test]
501 fn test_is_task_ready_basic() {
502 let mut phase = Phase::new("test".to_string());
503 let task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
504 phase.add_task(task);
505
506 let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
507 assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
508 }
509
510 #[test]
511 fn test_is_task_ready_in_progress() {
512 let mut phase = Phase::new("test".to_string());
513 let mut task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
514 task.set_status(TaskStatus::InProgress);
515 phase.add_task(task);
516
517 let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
518 assert!(!is_task_ready(&phase.tasks[0], &phase, &all_tasks));
519 }
520
521 #[test]
522 fn test_is_task_ready_blocked_by_deps() {
523 let mut phase = Phase::new("test".to_string());
524
525 let task1 = Task::new("1".to_string(), "First".to_string(), "Desc".to_string());
526
527 let mut task2 = Task::new("2".to_string(), "Second".to_string(), "Desc".to_string());
528 task2.dependencies = vec!["1".to_string()];
529
530 phase.add_task(task1);
531 phase.add_task(task2);
532
533 let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
534
535 assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
537 assert!(!is_task_ready(&phase.tasks[1], &phase, &all_tasks));
539 }
540}