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!(" {} Synced tasks to: {}", "✓".green(), path_str.dimmed());
146 }
147 Err(e) => {
148 let err_str: String = e.to_string();
149 println!(" {} Task sync failed: {}", "!".yellow(), err_str.dimmed());
150 }
151 }
152 }
153 } else {
154 match claude_tasks::sync_phases(&all_phases) {
156 Ok(paths) => {
157 let count: usize = paths.len();
158 println!(
159 " {} Synced {} phases to Claude Tasks format",
160 "✓".green(),
161 count
162 );
163 }
164 Err(e) => {
165 let err_str: String = e.to_string();
166 println!(" {} Task sync failed: {}", "!".yellow(), err_str.dimmed());
167 }
168 }
169 }
170
171 let mut spawn_session = SpawnSession::new(
173 &session_name,
174 &phase_tag,
175 "tmux",
176 &working_dir.to_string_lossy(),
177 );
178
179 println!("{}", "Spawning agents...".green());
181
182 let mut success_count = 0;
183 let mut claimed_tasks: Vec<(String, String)> = Vec::new(); for info in &ready_tasks {
186 let config = agent::resolve_agent_config(
188 info.task,
189 &info.tag,
190 harness,
191 Some(model_arg),
192 &working_dir,
193 );
194
195 if info.task.agent_type.is_some() && !config.from_agent_def {
197 println!(
198 " {} Agent '{}' not found, using CLI defaults",
199 "!".yellow(),
200 info.task.agent_type.as_deref().unwrap_or("unknown")
201 );
202 }
203
204 match terminal::spawn_terminal_with_task_list(
205 &info.task.id,
206 &config.prompt,
207 &working_dir,
208 &session_name,
209 config.harness,
210 config.model.as_deref(),
211 &task_list_id,
212 ) {
213 Ok(window_index) => {
214 println!(
215 " {} Spawned: {} | {} [{}] {}:{}",
216 "✓".green(),
217 info.task.id.cyan(),
218 info.task.title.dimmed(),
219 config.display_info().dimmed(),
220 session_name.dimmed(),
221 window_index.dimmed(),
222 );
223 spawn_session.add_agent(&info.task.id, &info.task.title, &info.tag);
224 success_count += 1;
225
226 if claim {
228 claimed_tasks.push((info.task.id.clone(), info.tag.clone()));
229 }
230 }
231 Err(e) => {
232 println!(" {} Failed: {} - {}", "✗".red(), info.task.id.red(), e);
233 }
234 }
235
236 if success_count < ready_tasks.len() {
238 thread::sleep(Duration::from_millis(500));
239 }
240 }
241
242 if claim && !claimed_tasks.is_empty() {
244 println!();
245 println!("{}", "Claiming tasks...".dimmed());
246 for (task_id, task_tag) in &claimed_tasks {
247 match storage.load_group(task_tag) {
249 Ok(mut phase) => {
250 if let Some(task) = phase.get_task_mut(task_id) {
251 task.set_status(TaskStatus::InProgress);
252 if let Err(e) = storage.update_group(task_tag, &phase) {
253 println!(
254 " {} Claim failed: {} - {}",
255 "!".yellow(),
256 task_id,
257 e.to_string().dimmed()
258 );
259 } else {
260 println!(
261 " {} Claimed: {} → {}",
262 "✓".green(),
263 task_id.cyan(),
264 "in-progress".yellow()
265 );
266 }
267 }
268 }
269 Err(e) => {
270 println!(
271 " {} Claim failed: {} - {}",
272 "!".yellow(),
273 task_id,
274 e.to_string().dimmed()
275 );
276 }
277 }
278 }
279 }
280
281 if let Err(e) = terminal::setup_tmux_control_window(&session_name, &phase_tag) {
283 println!(
284 " {} Control window setup: {}",
285 "!".yellow(),
286 e.to_string().dimmed()
287 );
288 }
289
290 if let Err(e) = monitor::save_session(project_root.as_ref(), &spawn_session) {
292 println!(
293 " {} Session metadata: {}",
294 "!".yellow(),
295 e.to_string().dimmed()
296 );
297 }
298
299 println!();
301 println!(
302 "{} {} of {} agents spawned",
303 "Summary:".blue().bold(),
304 success_count,
305 ready_tasks.len()
306 );
307
308 println!();
309 println!(
310 "To attach: {}",
311 format!("tmux attach -t {}", session_name).cyan()
312 );
313 println!(
314 "To list: {}",
315 format!("tmux list-windows -t {}", session_name).dimmed()
316 );
317
318 if monitor {
320 println!();
321 println!("Starting monitor...");
322 thread::sleep(Duration::from_secs(1));
324 return tui::run(project_root, &session_name, false); }
326
327 if attach {
329 println!();
330 println!("Attaching to session...");
331 terminal::tmux_attach(&session_name)?;
332 }
333
334 Ok(())
335}
336
337pub fn run_monitor(
339 project_root: Option<PathBuf>,
340 session: Option<String>,
341 swarm_mode: bool,
342) -> Result<()> {
343 use crate::commands::swarm::session as swarm_session;
344 use colored::Colorize;
345
346 let project_root_display = project_root
348 .as_ref()
349 .and_then(|p| p.to_str())
350 .unwrap_or("current directory");
351
352 let mode_label = if swarm_mode { "swarm" } else { "spawn" };
353 eprintln!(
354 "{} Monitor ({}) looking for sessions in: {}",
355 "DEBUG:".yellow(),
356 mode_label,
357 project_root_display
358 );
359
360 let session_name = match session {
362 Some(s) => s,
363 None => {
364 let sessions = if swarm_mode {
365 swarm_session::list_sessions(project_root.as_ref())?
366 } else {
367 monitor::list_sessions(project_root.as_ref())?
368 };
369 eprintln!(
370 "{} Found {} {} session(s): {:?}",
371 "DEBUG:".yellow(),
372 sessions.len(),
373 mode_label,
374 sessions
375 );
376 if sessions.is_empty() {
377 let cmd = if swarm_mode {
378 "scud swarm"
379 } else {
380 "scud spawn"
381 };
382 eprintln!(
383 "{} No {} sessions found in: {}",
384 "DEBUG:".yellow(),
385 mode_label,
386 project_root_display
387 );
388 eprintln!(
389 "{} Run: {} --project {} (if needed)",
390 "HINT:".cyan(),
391 cmd,
392 project_root_display
393 );
394 anyhow::bail!("No {} sessions found. Run: {}", mode_label, cmd);
395 }
396 if sessions.len() == 1 {
397 sessions[0].clone()
398 } else {
399 println!(
400 "{}",
401 format!("Available {} sessions:", mode_label).cyan().bold()
402 );
403 for (i, s) in sessions.iter().enumerate() {
404 println!(" {} {}", format!("[{}]", i + 1).dimmed(), s);
405 }
406 anyhow::bail!(
407 "Multiple {} sessions found. Specify one with --session <name>",
408 mode_label
409 );
410 }
411 }
412 };
413
414 tui::run(project_root, &session_name, swarm_mode)
415}
416
417pub fn run_sessions(project_root: Option<PathBuf>, verbose: bool) -> Result<()> {
419 use colored::Colorize;
420
421 let sessions = monitor::list_sessions(project_root.as_ref())?;
422
423 if sessions.is_empty() {
424 println!("{}", "No spawn sessions found.".dimmed());
425 println!("Run: scud spawn -m --limit 3");
426 return Ok(());
427 }
428
429 println!("{}", "Spawn Sessions:".cyan().bold());
430 println!();
431
432 for session_name in &sessions {
433 if verbose {
434 match monitor::load_session(project_root.as_ref(), session_name) {
436 Ok(session) => {
437 let stats = monitor::SpawnStats::from(&session);
438 println!(
439 " {} {} agents ({} running, {} done)",
440 session_name.cyan(),
441 format!("[{}]", stats.total_agents).dimmed(),
442 stats.running.to_string().green(),
443 stats.completed.to_string().blue()
444 );
445 println!(
446 " {} Tag: {}, Terminal: {}",
447 "│".dimmed(),
448 session.tag,
449 session.terminal
450 );
451 println!(
452 " {} Created: {}",
453 "└".dimmed(),
454 session.created_at.dimmed()
455 );
456 println!();
457 }
458 Err(_) => {
459 println!(" {} {}", session_name, "(unable to load)".red());
460 }
461 }
462 } else {
463 println!(" {}", session_name);
464 }
465 }
466
467 if !verbose {
468 println!();
469 println!(
470 "{}",
471 "Use -v for details, or: scud monitor --session <name>".dimmed()
472 );
473 }
474
475 Ok(())
476}
477
478pub fn run_discover_sessions(_project_root: Option<PathBuf>) -> Result<()> {
480 use colored::Colorize;
481
482 let output = std::process::Command::new("tmux")
484 .args(["list-sessions", "-F", "#{session_name}:#{session_attached}"])
485 .output()
486 .map_err(|e| anyhow::anyhow!("Failed to list tmux sessions: {}", e))?;
487
488 if !output.status.success() {
489 println!("{}", "No tmux sessions found or tmux not running.".dimmed());
490 return Ok(());
491 }
492
493 let sessions_output = String::from_utf8_lossy(&output.stdout);
494 let sessions: Vec<&str> = sessions_output.lines().collect();
495
496 if sessions.is_empty() {
497 println!("{}", "No tmux sessions found.".dimmed());
498 return Ok(());
499 }
500
501 println!("{}", "Discovered Sessions:".cyan().bold());
502 println!();
503
504 for session_line in sessions {
505 if let Some((session_name, attached)) = session_line.split_once(':') {
506 let attached_indicator = if attached == "1" {
507 "(attached)".green()
508 } else {
509 "(detached)".dimmed()
510 };
511 println!(" {} {}", session_name.cyan(), attached_indicator);
512 }
513 }
514
515 println!();
516 println!(
517 "{}",
518 "Use 'scud attach <session>' to attach to a session.".dimmed()
519 );
520
521 Ok(())
522}
523
524pub fn run_attach_session(_project_root: Option<PathBuf>, session_name: &str) -> Result<()> {
526 use colored::Colorize;
527
528 terminal::check_tmux_available()?;
530
531 if !terminal::tmux_session_exists(session_name) {
533 anyhow::bail!(
534 "Session '{}' does not exist. Use 'scud discover' to list available sessions.",
535 session_name
536 );
537 }
538
539 println!("Attaching to session '{}'...", session_name.cyan());
540 terminal::tmux_attach(session_name)?;
541
542 Ok(())
543}
544
545pub fn run_detach_session(_project_root: Option<PathBuf>) -> Result<()> {
547 use colored::Colorize;
548
549 if std::env::var("TMUX").is_err() {
551 println!("{}", "Not currently in a tmux session.".yellow());
552 return Ok(());
553 }
554
555 let output = std::process::Command::new("tmux")
557 .args(["detach"])
558 .output()
559 .map_err(|e| anyhow::anyhow!("Failed to detach: {}", e))?;
560
561 if output.status.success() {
562 println!("{}", "Detached from tmux session.".green());
563 } else {
564 let stderr = String::from_utf8_lossy(&output.stderr);
565 anyhow::bail!("Failed to detach: {}", stderr);
566 }
567
568 Ok(())
569}
570
571fn get_ready_tasks<'a>(
573 all_phases: &'a std::collections::HashMap<String, crate::models::phase::Phase>,
574 all_tasks_flat: &[&Task],
575 phase_tag: &str,
576 limit: usize,
577 all_tags: bool,
578) -> Result<Vec<TaskInfo<'a>>> {
579 let mut ready_tasks: Vec<TaskInfo<'a>> = Vec::new();
580
581 if all_tags {
582 for (tag, phase) in all_phases {
584 for task in &phase.tasks {
585 if is_task_ready(task, phase, all_tasks_flat) {
586 ready_tasks.push(TaskInfo {
587 task,
588 tag: tag.clone(),
589 });
590 }
591 }
592 }
593 } else {
594 let phase = all_phases
596 .get(phase_tag)
597 .ok_or_else(|| anyhow::anyhow!("Phase '{}' not found", phase_tag))?;
598
599 for task in &phase.tasks {
600 if is_task_ready(task, phase, all_tasks_flat) {
601 ready_tasks.push(TaskInfo {
602 task,
603 tag: phase_tag.to_string(),
604 });
605 }
606 }
607 }
608
609 ready_tasks.truncate(limit);
611
612 Ok(ready_tasks)
613}
614
615fn is_task_ready(
617 task: &Task,
618 phase: &crate::models::phase::Phase,
619 all_tasks_flat: &[&Task],
620) -> bool {
621 if task.status != TaskStatus::Pending {
623 return false;
624 }
625
626 if task.is_expanded() {
628 return false;
629 }
630
631 if let Some(ref parent_id) = task.parent_id {
633 let parent_expanded = phase
634 .get_task(parent_id)
635 .map(|p| p.is_expanded())
636 .unwrap_or(false);
637 if !parent_expanded {
638 return false;
639 }
640 }
641
642 task.has_dependencies_met_refs(all_tasks_flat)
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649 use crate::models::phase::Phase;
650 use crate::models::task::Task;
651
652 #[test]
653 fn test_is_task_ready_basic() {
654 let mut phase = Phase::new("test".to_string());
655 let task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
656 phase.add_task(task);
657
658 let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
659 assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
660 }
661
662 #[test]
663 fn test_is_task_ready_in_progress() {
664 let mut phase = Phase::new("test".to_string());
665 let mut task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
666 task.set_status(TaskStatus::InProgress);
667 phase.add_task(task);
668
669 let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
670 assert!(!is_task_ready(&phase.tasks[0], &phase, &all_tasks));
671 }
672
673 #[test]
674 fn test_is_task_ready_blocked_by_deps() {
675 let mut phase = Phase::new("test".to_string());
676
677 let task1 = Task::new("1".to_string(), "First".to_string(), "Desc".to_string());
678
679 let mut task2 = Task::new("2".to_string(), "Second".to_string(), "Desc".to_string());
680 task2.dependencies = vec!["1".to_string()];
681
682 phase.add_task(task1);
683 phase.add_task(task2);
684
685 let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
686
687 assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
689 assert!(!is_task_ready(&phase.tasks[1], &phase, &all_tasks));
691 }
692}