1use anyhow::{Context, Result};
7use std::path::Path;
8use std::process::Command;
9use std::sync::OnceLock;
10
11#[derive(Debug, Clone, Copy, PartialEq, Default)]
13pub enum Harness {
14 #[default]
16 Claude,
17 OpenCode,
19 Cursor,
21 #[cfg(feature = "direct-api")]
23 DirectApi,
24}
25
26impl Harness {
27 pub fn parse(s: &str) -> Result<Self> {
29 match s.to_lowercase().as_str() {
30 "claude" | "claude-code" => Ok(Harness::Claude),
31 "opencode" | "open-code" => Ok(Harness::OpenCode),
32 "cursor" | "cursor-agent" => Ok(Harness::Cursor),
33 #[cfg(feature = "direct-api")]
34 "direct-api" | "direct" | "api" => Ok(Harness::DirectApi),
35 other => anyhow::bail!("Unknown harness: '{}'. Supported: claude, opencode, cursor", other),
36 }
37 }
38
39 pub fn name(&self) -> &'static str {
41 match self {
42 Harness::Claude => "claude",
43 Harness::OpenCode => "opencode",
44 Harness::Cursor => "cursor",
45 #[cfg(feature = "direct-api")]
46 Harness::DirectApi => "direct-api",
47 }
48 }
49
50 pub fn binary_name(&self) -> &'static str {
52 match self {
53 Harness::Claude => "claude",
54 Harness::OpenCode => "opencode",
55 Harness::Cursor => "agent",
56 #[cfg(feature = "direct-api")]
57 Harness::DirectApi => "scud",
58 }
59 }
60
61 pub fn command(&self, binary_path: &str, prompt_file: &Path, model: Option<&str>) -> String {
63 match self {
64 Harness::Claude => {
65 let model_flag = model.map(|m| format!(" --model {}", m)).unwrap_or_default();
66 format!(
67 r#"'{}' "$(cat '{}')" --dangerously-skip-permissions{}"#,
68 binary_path,
69 prompt_file.display(),
70 model_flag
71 )
72 }
73 Harness::OpenCode => {
74 let model_flag = model.map(|m| format!(" --model {}", m)).unwrap_or_default();
75 format!(
78 r#"'{}'{} run --variant minimal "$(cat '{}')""#,
79 binary_path,
80 model_flag,
81 prompt_file.display()
82 )
83 }
84 Harness::Cursor => {
85 let model_flag = model.map(|m| format!(" --model {}", m)).unwrap_or_default();
86 format!(
87 r#"'{}' -p{} "$(cat '{}')""#,
88 binary_path,
89 model_flag,
90 prompt_file.display()
91 )
92 }
93 #[cfg(feature = "direct-api")]
94 Harness::DirectApi => {
95 let model_flag = model.map(|m| format!(" --model {}", m)).unwrap_or_default();
96 format!(
97 r#"'{}' agent-exec --prompt-file '{}'{}"#,
98 binary_path,
99 prompt_file.display(),
100 model_flag
101 )
102 }
103 }
104 }
105}
106
107static CLAUDE_PATH: OnceLock<String> = OnceLock::new();
109static OPENCODE_PATH: OnceLock<String> = OnceLock::new();
110static CURSOR_PATH: OnceLock<String> = OnceLock::new();
111#[cfg(feature = "direct-api")]
112static SCUD_PATH: OnceLock<String> = OnceLock::new();
113
114pub fn find_harness_binary(harness: Harness) -> Result<&'static str> {
117 let cache = match harness {
118 Harness::Claude => &CLAUDE_PATH,
119 Harness::OpenCode => &OPENCODE_PATH,
120 Harness::Cursor => &CURSOR_PATH,
121 #[cfg(feature = "direct-api")]
122 Harness::DirectApi => &SCUD_PATH,
123 };
124
125 if let Some(path) = cache.get() {
127 return Ok(path.as_str());
128 }
129
130 let binary_name = harness.binary_name();
131
132 let output = Command::new("which")
134 .arg(binary_name)
135 .output()
136 .context(format!("Failed to run 'which {}'", binary_name))?;
137
138 if output.status.success() {
139 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
140 if !path.is_empty() {
141 let _ = cache.set(path);
143 return Ok(cache.get().unwrap().as_str());
144 }
145 }
146
147 let common_paths: &[&str] = match harness {
149 Harness::Claude => &[
150 "/opt/homebrew/bin/claude",
151 "/usr/local/bin/claude",
152 "/usr/bin/claude",
153 ],
154 Harness::OpenCode => &[
155 "/opt/homebrew/bin/opencode",
156 "/usr/local/bin/opencode",
157 "/usr/bin/opencode",
158 ],
159 Harness::Cursor => &[
160 "/opt/homebrew/bin/agent",
161 "/usr/local/bin/agent",
162 "/usr/bin/agent",
163 ],
164 #[cfg(feature = "direct-api")]
165 Harness::DirectApi => &[
166 "/opt/homebrew/bin/scud",
167 "/usr/local/bin/scud",
168 "/usr/bin/scud",
169 ],
170 };
171
172 for path in common_paths {
173 if std::path::Path::new(path).exists() {
174 let _ = cache.set(path.to_string());
175 return Ok(cache.get().unwrap().as_str());
176 }
177 }
178
179 if let Ok(home) = std::env::var("HOME") {
181 let home_paths: Vec<String> = match harness {
182 Harness::Claude => vec![
183 format!("{}/.local/bin/claude", home),
184 format!("{}/.claude/local/claude", home),
185 ],
186 Harness::OpenCode => vec![
187 format!("{}/.local/bin/opencode", home),
188 format!("{}/.bun/bin/opencode", home),
189 ],
190 Harness::Cursor => vec![
191 format!("{}/.local/bin/agent", home),
192 ],
193 #[cfg(feature = "direct-api")]
194 Harness::DirectApi => vec![
195 format!("{}/.local/bin/scud", home),
196 format!("{}/.cargo/bin/scud", home),
197 ],
198 };
199
200 for path in home_paths {
201 if std::path::Path::new(&path).exists() {
202 let _ = cache.set(path);
203 return Ok(cache.get().unwrap().as_str());
204 }
205 }
206 }
207
208 let install_hint = match harness {
209 Harness::Claude => "Install with: npm install -g @anthropic-ai/claude-code",
210 Harness::OpenCode => "Install with: curl -fsSL https://opencode.ai/install | bash",
211 Harness::Cursor => "Install with: curl https://cursor.com/install -fsSL | bash",
212 #[cfg(feature = "direct-api")]
213 Harness::DirectApi => "Install with: cargo install scud-cli --features direct-api",
214 };
215
216 anyhow::bail!(
217 "Could not find '{}' binary. Please ensure it is installed and in PATH.\n{}",
218 binary_name,
219 install_hint
220 )
221}
222
223pub fn find_claude_binary() -> Result<&'static str> {
225 find_harness_binary(Harness::Claude)
226}
227
228pub fn check_tmux_available() -> Result<()> {
230 let result = Command::new("which")
231 .arg("tmux")
232 .output()
233 .context("Failed to check for tmux binary")?;
234
235 if !result.status.success() {
236 anyhow::bail!("tmux is not installed or not in PATH. Install with: brew install tmux (macOS) or apt install tmux (Linux)");
237 }
238
239 Ok(())
240}
241
242pub fn spawn_terminal(
245 task_id: &str,
246 prompt: &str,
247 working_dir: &Path,
248 session_name: &str,
249) -> Result<String> {
250 spawn_terminal_with_harness_and_model(
252 task_id,
253 prompt,
254 working_dir,
255 session_name,
256 Harness::Claude,
257 None,
258 )
259}
260
261pub fn spawn_terminal_with_harness(
264 task_id: &str,
265 prompt: &str,
266 working_dir: &Path,
267 session_name: &str,
268 harness: Harness,
269) -> Result<String> {
270 spawn_terminal_with_harness_and_model(task_id, prompt, working_dir, session_name, harness, None)
271}
272
273pub fn spawn_terminal_with_harness_and_model(
276 task_id: &str,
277 prompt: &str,
278 working_dir: &Path,
279 session_name: &str,
280 harness: Harness,
281 model: Option<&str>,
282) -> Result<String> {
283 let binary_path = find_harness_binary(harness)?;
285 spawn_tmux(
286 task_id,
287 prompt,
288 working_dir,
289 session_name,
290 binary_path,
291 harness,
292 model,
293 None, )
295}
296
297pub fn spawn_terminal_with_task_list(
303 task_id: &str,
304 prompt: &str,
305 working_dir: &Path,
306 session_name: &str,
307 harness: Harness,
308 model: Option<&str>,
309 task_list_id: &str,
310) -> Result<String> {
311 let binary_path = find_harness_binary(harness)?;
312 spawn_tmux(
313 task_id,
314 prompt,
315 working_dir,
316 session_name,
317 binary_path,
318 harness,
319 model,
320 Some(task_list_id),
321 )
322}
323
324#[allow(clippy::too_many_arguments)]
327fn spawn_tmux(
328 task_id: &str,
329 prompt: &str,
330 working_dir: &Path,
331 session_name: &str,
332 binary_path: &str,
333 harness: Harness,
334 model: Option<&str>,
335 task_list_id: Option<&str>,
336) -> Result<String> {
337 let window_name = format!("task-{}", task_id);
338
339 let session_exists = Command::new("tmux")
341 .args(["has-session", "-t", session_name])
342 .status()
343 .map(|s| s.success())
344 .unwrap_or(false);
345
346 if !session_exists {
347 Command::new("tmux")
349 .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
350 .arg("-c")
351 .arg(working_dir)
352 .status()
353 .context("Failed to create tmux session")?;
354 }
355
356 let new_window_output = Command::new("tmux")
359 .args([
360 "new-window",
361 "-t",
362 session_name,
363 "-n",
364 &window_name,
365 "-P", "-F",
367 "#{window_index}", ])
369 .arg("-c")
370 .arg(working_dir)
371 .output()
372 .context("Failed to create tmux window")?;
373
374 if !new_window_output.status.success() {
375 anyhow::bail!(
376 "Failed to create window: {}",
377 String::from_utf8_lossy(&new_window_output.stderr)
378 );
379 }
380
381 let window_index = String::from_utf8_lossy(&new_window_output.stdout)
382 .trim()
383 .to_string();
384
385 let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
387 std::fs::write(&prompt_file, prompt)?;
388
389 let harness_cmd = harness.command(binary_path, &prompt_file, model);
394
395 let task_list_export = task_list_id
397 .map(|id| format!("export CLAUDE_CODE_TASK_LIST_ID='{}'\n", id))
398 .unwrap_or_default();
399
400 let spawn_script = format!(
403 r#"#!/usr/bin/env bash
404# Source shell profile for PATH setup
405source ~/.bash_profile 2>/dev/null
406source ~/.zshrc 2>/dev/null
407export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
408[ -s "$HOME/.nvm/nvm.sh" ] && source "$HOME/.nvm/nvm.sh"
409
410export SCUD_TASK_ID='{task_id}'
411{task_list_export}{harness_cmd}
412rm -f '{prompt_file}'
413"#,
414 task_id = task_id,
415 task_list_export = task_list_export,
416 harness_cmd = harness_cmd,
417 prompt_file = prompt_file.display()
418 );
419
420 let script_file = std::env::temp_dir().join(format!("scud-spawn-{}.sh", task_id));
421 std::fs::write(&script_file, &spawn_script)?;
422
423 let run_cmd = format!("bash '{}'", script_file.display());
425
426 let target = format!("{}:{}", session_name, window_index);
427 let send_result = Command::new("tmux")
428 .args(["send-keys", "-t", &target, &run_cmd, "Enter"])
429 .output()
430 .context("Failed to send command to tmux window")?;
431
432 if !send_result.status.success() {
433 anyhow::bail!(
434 "Failed to send keys: {}",
435 String::from_utf8_lossy(&send_result.stderr)
436 );
437 }
438
439 Ok(window_index)
440}
441
442pub fn spawn_terminal_ralph(
445 task_id: &str,
446 prompt: &str,
447 working_dir: &Path,
448 session_name: &str,
449 completion_promise: &str,
450) -> Result<()> {
451 spawn_terminal_ralph_with_harness(
453 task_id,
454 prompt,
455 working_dir,
456 session_name,
457 completion_promise,
458 Harness::Claude,
459 )
460}
461
462pub fn spawn_terminal_ralph_with_harness(
464 task_id: &str,
465 prompt: &str,
466 working_dir: &Path,
467 session_name: &str,
468 completion_promise: &str,
469 harness: Harness,
470) -> Result<()> {
471 let binary_path = find_harness_binary(harness)?;
473 spawn_tmux_ralph(
474 task_id,
475 prompt,
476 working_dir,
477 session_name,
478 completion_promise,
479 binary_path,
480 harness,
481 )
482}
483
484fn spawn_tmux_ralph(
486 task_id: &str,
487 prompt: &str,
488 working_dir: &Path,
489 session_name: &str,
490 completion_promise: &str,
491 binary_path: &str,
492 harness: Harness,
493) -> Result<()> {
494 let window_name = format!("ralph-{}", task_id);
495
496 let session_exists = Command::new("tmux")
498 .args(["has-session", "-t", session_name])
499 .status()
500 .map(|s| s.success())
501 .unwrap_or(false);
502
503 if !session_exists {
504 Command::new("tmux")
506 .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
507 .arg("-c")
508 .arg(working_dir)
509 .status()
510 .context("Failed to create tmux session")?;
511 }
512
513 let new_window_output = Command::new("tmux")
515 .args([
516 "new-window",
517 "-t",
518 session_name,
519 "-n",
520 &window_name,
521 "-P",
522 "-F",
523 "#{window_index}",
524 ])
525 .arg("-c")
526 .arg(working_dir)
527 .output()
528 .context("Failed to create tmux window")?;
529
530 if !new_window_output.status.success() {
531 anyhow::bail!(
532 "Failed to create window: {}",
533 String::from_utf8_lossy(&new_window_output.stderr)
534 );
535 }
536
537 let window_index = String::from_utf8_lossy(&new_window_output.stdout)
538 .trim()
539 .to_string();
540
541 let prompt_file = std::env::temp_dir().join(format!("scud-ralph-{}.txt", task_id));
543 std::fs::write(&prompt_file, prompt)?;
544
545 let harness_cmd = match harness {
548 Harness::Claude => format!(
549 "'{binary_path}' \"$(cat '{prompt_file}')\" --dangerously-skip-permissions",
550 binary_path = binary_path,
551 prompt_file = prompt_file.display()
552 ),
553 Harness::OpenCode => format!(
554 "'{binary_path}' run --variant minimal \"$(cat '{prompt_file}')\"",
555 binary_path = binary_path,
556 prompt_file = prompt_file.display()
557 ),
558 Harness::Cursor => format!(
559 "'{binary_path}' -p \"$(cat '{prompt_file}')\"",
560 binary_path = binary_path,
561 prompt_file = prompt_file.display()
562 ),
563 #[cfg(feature = "direct-api")]
564 Harness::DirectApi => format!(
565 "'{binary_path}' agent-exec --prompt-file '{prompt_file}'",
566 binary_path = binary_path,
567 prompt_file = prompt_file.display()
568 ),
569 };
570
571 let ralph_script = format!(
579 r#"#!/usr/bin/env bash
580# Source shell profile for PATH setup
581[ -f /etc/profile ] && . /etc/profile
582[ -f ~/.profile ] && . ~/.profile
583[ -f ~/.bash_profile ] && . ~/.bash_profile
584[ -f ~/.bashrc ] && . ~/.bashrc
585[ -f ~/.zshrc ] && . ~/.zshrc 2>/dev/null
586export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
587[ -s "$HOME/.nvm/nvm.sh" ] && . "$HOME/.nvm/nvm.sh"
588[ -s "$HOME/.bun/_bun" ] && . "$HOME/.bun/_bun"
589
590export SCUD_TASK_ID='{task_id}'
591export RALPH_PROMISE='{promise}'
592export RALPH_MAX_ITER=50
593export RALPH_ITER=0
594
595echo "🔄 Ralph loop starting for task {task_id}"
596echo " Harness: {harness_name}"
597echo " Completion promise: {promise}"
598echo " Max iterations: $RALPH_MAX_ITER"
599echo ""
600
601while true; do
602 RALPH_ITER=$((RALPH_ITER + 1))
603 echo ""
604 echo "═══════════════════════════════════════════════════════════"
605 echo "🔄 RALPH ITERATION $RALPH_ITER / $RALPH_MAX_ITER"
606 echo "═══════════════════════════════════════════════════════════"
607 echo ""
608
609 # Run harness with the prompt (using full path)
610 {harness_cmd}
611
612 # Check if task is done
613 TASK_STATUS=$(scud show {task_id} 2>/dev/null | grep -i "status:" | awk '{{print $2}}')
614
615 if [ "$TASK_STATUS" = "done" ]; then
616 echo ""
617 echo "✅ Task {task_id} completed successfully after $RALPH_ITER iterations!"
618 rm -f '{prompt_file}'
619 break
620 fi
621
622 # Check max iterations
623 if [ $RALPH_ITER -ge $RALPH_MAX_ITER ]; then
624 echo ""
625 echo "⚠️ Ralph loop: Max iterations ($RALPH_MAX_ITER) reached for task {task_id}"
626 echo " Task status: $TASK_STATUS"
627 rm -f '{prompt_file}'
628 break
629 fi
630
631 # Small delay before next iteration
632 echo ""
633 echo "🔄 Task not yet complete (status: $TASK_STATUS). Continuing loop..."
634 sleep 2
635done
636"#,
637 task_id = task_id,
638 promise = completion_promise,
639 prompt_file = prompt_file.display(),
640 harness_name = harness.name(),
641 harness_cmd = harness_cmd,
642 );
643
644 let script_file = std::env::temp_dir().join(format!("scud-ralph-script-{}.sh", task_id));
646 std::fs::write(&script_file, &ralph_script)?;
647
648 let cmd = format!("bash '{}'", script_file.display());
650
651 let target = format!("{}:{}", session_name, window_index);
652 let send_result = Command::new("tmux")
653 .args(["send-keys", "-t", &target, &cmd, "Enter"])
654 .output()
655 .context("Failed to send command to tmux window")?;
656
657 if !send_result.status.success() {
658 anyhow::bail!(
659 "Failed to send keys: {}",
660 String::from_utf8_lossy(&send_result.stderr)
661 );
662 }
663
664 Ok(())
665}
666
667pub fn tmux_session_exists(session_name: &str) -> bool {
669 Command::new("tmux")
670 .args(["has-session", "-t", session_name])
671 .status()
672 .map(|s| s.success())
673 .unwrap_or(false)
674}
675
676pub fn tmux_attach(session_name: &str) -> Result<()> {
678 let status = Command::new("tmux")
680 .args(["attach", "-t", session_name])
681 .status()
682 .context("Failed to attach to tmux session")?;
683
684 if !status.success() {
685 anyhow::bail!("tmux attach failed");
686 }
687
688 Ok(())
689}
690
691pub fn setup_tmux_control_window(session_name: &str, tag: &str) -> Result<()> {
693 let control_script = format!(
694 r#"watch -n 5 'echo "=== SCUD Spawn Monitor: {} ===" && echo && scud stats --tag {} && echo && scud whois --tag {} && echo && echo "Ready tasks:" && scud next-batch --tag {} --limit 5 2>/dev/null | head -20'"#,
695 session_name, tag, tag, tag
696 );
697
698 let target = format!("{}:ctrl", session_name);
699 Command::new("tmux")
700 .args(["send-keys", "-t", &target, &control_script, "Enter"])
701 .status()
702 .context("Failed to setup control window")?;
703
704 Ok(())
705}
706
707pub fn tmux_window_exists(session_name: &str, window_name: &str) -> bool {
709 let output = Command::new("tmux")
710 .args(["list-windows", "-t", session_name, "-F", "#{window_name}"])
711 .output();
712
713 match output {
714 Ok(out) if out.status.success() => {
715 let windows = String::from_utf8_lossy(&out.stdout);
716 windows
717 .lines()
718 .any(|w| w == window_name || w.starts_with(&format!("{}-", window_name)))
719 }
720 _ => false,
721 }
722}
723
724pub fn tmux_pane_shows_prompt(session_name: &str, window_name: &str) -> bool {
726 let window_target = format!("{}:{}", session_name, window_name);
727
728 let output = std::process::Command::new("tmux")
730 .args(["capture-pane", "-t", &window_target, "-p", "-S", "-1"])
731 .output();
732
733 let Ok(output) = output else {
734 return false;
735 };
736
737 if !output.status.success() {
738 return false;
739 }
740
741 let last_line = String::from_utf8_lossy(&output.stdout);
742 let last_line = last_line.trim();
743
744 let prompt_patterns = [
747 "$ ", "% ", "> ", "# ", "❯ ", "→ ", ];
754
755 for pattern in prompt_patterns {
757 if last_line.ends_with(pattern) || last_line.ends_with(pattern.trim()) {
758 return true;
759 }
760 }
761
762 if last_line.contains('@')
764 && (last_line.ends_with('$') || last_line.ends_with('%') || last_line.ends_with('>'))
765 {
766 return true;
767 }
768
769 false
770}
771
772pub fn kill_tmux_window(session_name: &str, window_name: &str) -> Result<()> {
774 let target = format!("{}:{}", session_name, window_name);
775 Command::new("tmux")
776 .args(["kill-window", "-t", &target])
777 .output()?;
778 Ok(())
779}
780
781pub fn spawn_in_tmux(
783 session_name: &str,
784 window_name: &str,
785 command: &str,
786 working_dir: &Path,
787) -> Result<()> {
788 let session_exists = Command::new("tmux")
790 .args(["has-session", "-t", session_name])
791 .output()
792 .map(|o| o.status.success())
793 .unwrap_or(false);
794
795 if !session_exists {
796 Command::new("tmux")
798 .args([
799 "new-session",
800 "-d",
801 "-s",
802 session_name,
803 "-n",
804 "ctrl",
805 "-c",
806 &working_dir.to_string_lossy(),
807 ])
808 .output()
809 .context("Failed to create tmux session")?;
810 }
811
812 let output = Command::new("tmux")
814 .args([
815 "new-window",
816 "-t",
817 session_name,
818 "-n",
819 window_name,
820 "-c",
821 &working_dir.to_string_lossy(),
822 "-P",
823 "-F",
824 "#{window_index}",
825 ])
826 .output()
827 .context("Failed to create tmux window")?;
828
829 if !output.status.success() {
830 anyhow::bail!(
831 "Failed to create tmux window: {}",
832 String::from_utf8_lossy(&output.stderr)
833 );
834 }
835
836 let window_index = String::from_utf8_lossy(&output.stdout).trim().to_string();
837
838 let send_result = Command::new("tmux")
840 .args([
841 "send-keys",
842 "-t",
843 &format!("{}:{}", session_name, window_index),
844 command,
845 "Enter",
846 ])
847 .output()
848 .context("Failed to send command to tmux window")?;
849
850 if !send_result.status.success() {
851 anyhow::bail!(
852 "Failed to send command: {}",
853 String::from_utf8_lossy(&send_result.stderr)
854 );
855 }
856
857 Ok(())
858}