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}
22
23impl Harness {
24 pub fn parse(s: &str) -> Result<Self> {
26 match s.to_lowercase().as_str() {
27 "claude" | "claude-code" => Ok(Harness::Claude),
28 "opencode" | "open-code" => Ok(Harness::OpenCode),
29 "cursor" | "cursor-agent" => Ok(Harness::Cursor),
30 other => anyhow::bail!("Unknown harness: '{}'. Supported: claude, opencode, cursor", other),
31 }
32 }
33
34 pub fn name(&self) -> &'static str {
36 match self {
37 Harness::Claude => "claude",
38 Harness::OpenCode => "opencode",
39 Harness::Cursor => "cursor",
40 }
41 }
42
43 pub fn binary_name(&self) -> &'static str {
45 match self {
46 Harness::Claude => "claude",
47 Harness::OpenCode => "opencode",
48 Harness::Cursor => "agent",
49 }
50 }
51
52 pub fn command(&self, binary_path: &str, prompt_file: &Path, model: Option<&str>) -> String {
54 match self {
55 Harness::Claude => {
56 let model_flag = model.map(|m| format!(" --model {}", m)).unwrap_or_default();
57 format!(
58 r#"'{}' "$(cat '{}')" --dangerously-skip-permissions{}"#,
59 binary_path,
60 prompt_file.display(),
61 model_flag
62 )
63 }
64 Harness::OpenCode => {
65 let model_flag = model.map(|m| format!(" --model {}", m)).unwrap_or_default();
66 format!(
69 r#"'{}'{} run --variant minimal "$(cat '{}')""#,
70 binary_path,
71 model_flag,
72 prompt_file.display()
73 )
74 }
75 Harness::Cursor => {
76 let model_flag = model.map(|m| format!(" --model {}", m)).unwrap_or_default();
77 format!(
78 r#"'{}' -p{} "$(cat '{}')""#,
79 binary_path,
80 model_flag,
81 prompt_file.display()
82 )
83 }
84 }
85 }
86}
87
88static CLAUDE_PATH: OnceLock<String> = OnceLock::new();
90static OPENCODE_PATH: OnceLock<String> = OnceLock::new();
91static CURSOR_PATH: OnceLock<String> = OnceLock::new();
92
93pub fn find_harness_binary(harness: Harness) -> Result<&'static str> {
96 let cache = match harness {
97 Harness::Claude => &CLAUDE_PATH,
98 Harness::OpenCode => &OPENCODE_PATH,
99 Harness::Cursor => &CURSOR_PATH,
100 };
101
102 if let Some(path) = cache.get() {
104 return Ok(path.as_str());
105 }
106
107 let binary_name = harness.binary_name();
108
109 let output = Command::new("which")
111 .arg(binary_name)
112 .output()
113 .context(format!("Failed to run 'which {}'", binary_name))?;
114
115 if output.status.success() {
116 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
117 if !path.is_empty() {
118 let _ = cache.set(path);
120 return Ok(cache.get().unwrap().as_str());
121 }
122 }
123
124 let common_paths: &[&str] = match harness {
126 Harness::Claude => &[
127 "/opt/homebrew/bin/claude",
128 "/usr/local/bin/claude",
129 "/usr/bin/claude",
130 ],
131 Harness::OpenCode => &[
132 "/opt/homebrew/bin/opencode",
133 "/usr/local/bin/opencode",
134 "/usr/bin/opencode",
135 ],
136 Harness::Cursor => &[
137 "/opt/homebrew/bin/agent",
138 "/usr/local/bin/agent",
139 "/usr/bin/agent",
140 ],
141 };
142
143 for path in common_paths {
144 if std::path::Path::new(path).exists() {
145 let _ = cache.set(path.to_string());
146 return Ok(cache.get().unwrap().as_str());
147 }
148 }
149
150 if let Ok(home) = std::env::var("HOME") {
152 let home_paths: Vec<String> = match harness {
153 Harness::Claude => vec![
154 format!("{}/.local/bin/claude", home),
155 format!("{}/.claude/local/claude", home),
156 ],
157 Harness::OpenCode => vec![
158 format!("{}/.local/bin/opencode", home),
159 format!("{}/.bun/bin/opencode", home),
160 ],
161 Harness::Cursor => vec![
162 format!("{}/.local/bin/agent", home),
163 ],
164 };
165
166 for path in home_paths {
167 if std::path::Path::new(&path).exists() {
168 let _ = cache.set(path);
169 return Ok(cache.get().unwrap().as_str());
170 }
171 }
172 }
173
174 let install_hint = match harness {
175 Harness::Claude => "Install with: npm install -g @anthropic-ai/claude-code",
176 Harness::OpenCode => "Install with: curl -fsSL https://opencode.ai/install | bash",
177 Harness::Cursor => "Install with: curl https://cursor.com/install -fsSL | bash",
178 };
179
180 anyhow::bail!(
181 "Could not find '{}' binary. Please ensure it is installed and in PATH.\n{}",
182 binary_name,
183 install_hint
184 )
185}
186
187pub fn find_claude_binary() -> Result<&'static str> {
189 find_harness_binary(Harness::Claude)
190}
191
192pub fn check_tmux_available() -> Result<()> {
194 let result = Command::new("which")
195 .arg("tmux")
196 .output()
197 .context("Failed to check for tmux binary")?;
198
199 if !result.status.success() {
200 anyhow::bail!("tmux is not installed or not in PATH. Install with: brew install tmux (macOS) or apt install tmux (Linux)");
201 }
202
203 Ok(())
204}
205
206pub fn spawn_terminal(
209 task_id: &str,
210 prompt: &str,
211 working_dir: &Path,
212 session_name: &str,
213) -> Result<String> {
214 spawn_terminal_with_harness_and_model(
216 task_id,
217 prompt,
218 working_dir,
219 session_name,
220 Harness::Claude,
221 None,
222 )
223}
224
225pub fn spawn_terminal_with_harness(
228 task_id: &str,
229 prompt: &str,
230 working_dir: &Path,
231 session_name: &str,
232 harness: Harness,
233) -> Result<String> {
234 spawn_terminal_with_harness_and_model(task_id, prompt, working_dir, session_name, harness, None)
235}
236
237pub fn spawn_terminal_with_harness_and_model(
240 task_id: &str,
241 prompt: &str,
242 working_dir: &Path,
243 session_name: &str,
244 harness: Harness,
245 model: Option<&str>,
246) -> Result<String> {
247 let binary_path = find_harness_binary(harness)?;
249 spawn_tmux(
250 task_id,
251 prompt,
252 working_dir,
253 session_name,
254 binary_path,
255 harness,
256 model,
257 None, )
259}
260
261pub fn spawn_terminal_with_task_list(
267 task_id: &str,
268 prompt: &str,
269 working_dir: &Path,
270 session_name: &str,
271 harness: Harness,
272 model: Option<&str>,
273 task_list_id: &str,
274) -> Result<String> {
275 let binary_path = find_harness_binary(harness)?;
276 spawn_tmux(
277 task_id,
278 prompt,
279 working_dir,
280 session_name,
281 binary_path,
282 harness,
283 model,
284 Some(task_list_id),
285 )
286}
287
288fn spawn_tmux(
291 task_id: &str,
292 prompt: &str,
293 working_dir: &Path,
294 session_name: &str,
295 binary_path: &str,
296 harness: Harness,
297 model: Option<&str>,
298 task_list_id: Option<&str>,
299) -> Result<String> {
300 let window_name = format!("task-{}", task_id);
301
302 let session_exists = Command::new("tmux")
304 .args(["has-session", "-t", session_name])
305 .status()
306 .map(|s| s.success())
307 .unwrap_or(false);
308
309 if !session_exists {
310 Command::new("tmux")
312 .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
313 .arg("-c")
314 .arg(working_dir)
315 .status()
316 .context("Failed to create tmux session")?;
317 }
318
319 let new_window_output = Command::new("tmux")
322 .args([
323 "new-window",
324 "-t",
325 session_name,
326 "-n",
327 &window_name,
328 "-P", "-F",
330 "#{window_index}", ])
332 .arg("-c")
333 .arg(working_dir)
334 .output()
335 .context("Failed to create tmux window")?;
336
337 if !new_window_output.status.success() {
338 anyhow::bail!(
339 "Failed to create window: {}",
340 String::from_utf8_lossy(&new_window_output.stderr)
341 );
342 }
343
344 let window_index = String::from_utf8_lossy(&new_window_output.stdout)
345 .trim()
346 .to_string();
347
348 let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
350 std::fs::write(&prompt_file, prompt)?;
351
352 let harness_cmd = harness.command(binary_path, &prompt_file, model);
357
358 let task_list_export = task_list_id
360 .map(|id| format!("export CLAUDE_CODE_TASK_LIST_ID='{}'\n", id))
361 .unwrap_or_default();
362
363 let spawn_script = format!(
366 r#"#!/usr/bin/env bash
367# Source shell profile for PATH setup
368source ~/.bash_profile 2>/dev/null
369source ~/.zshrc 2>/dev/null
370export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
371[ -s "$HOME/.nvm/nvm.sh" ] && source "$HOME/.nvm/nvm.sh"
372
373export SCUD_TASK_ID='{task_id}'
374{task_list_export}{harness_cmd}
375rm -f '{prompt_file}'
376"#,
377 task_id = task_id,
378 task_list_export = task_list_export,
379 harness_cmd = harness_cmd,
380 prompt_file = prompt_file.display()
381 );
382
383 let script_file = std::env::temp_dir().join(format!("scud-spawn-{}.sh", task_id));
384 std::fs::write(&script_file, &spawn_script)?;
385
386 let run_cmd = format!("bash '{}'", script_file.display());
388
389 let target = format!("{}:{}", session_name, window_index);
390 let send_result = Command::new("tmux")
391 .args(["send-keys", "-t", &target, &run_cmd, "Enter"])
392 .output()
393 .context("Failed to send command to tmux window")?;
394
395 if !send_result.status.success() {
396 anyhow::bail!(
397 "Failed to send keys: {}",
398 String::from_utf8_lossy(&send_result.stderr)
399 );
400 }
401
402 Ok(window_index)
403}
404
405pub fn spawn_terminal_ralph(
408 task_id: &str,
409 prompt: &str,
410 working_dir: &Path,
411 session_name: &str,
412 completion_promise: &str,
413) -> Result<()> {
414 spawn_terminal_ralph_with_harness(
416 task_id,
417 prompt,
418 working_dir,
419 session_name,
420 completion_promise,
421 Harness::Claude,
422 )
423}
424
425pub fn spawn_terminal_ralph_with_harness(
427 task_id: &str,
428 prompt: &str,
429 working_dir: &Path,
430 session_name: &str,
431 completion_promise: &str,
432 harness: Harness,
433) -> Result<()> {
434 let binary_path = find_harness_binary(harness)?;
436 spawn_tmux_ralph(
437 task_id,
438 prompt,
439 working_dir,
440 session_name,
441 completion_promise,
442 binary_path,
443 harness,
444 )
445}
446
447fn spawn_tmux_ralph(
449 task_id: &str,
450 prompt: &str,
451 working_dir: &Path,
452 session_name: &str,
453 completion_promise: &str,
454 binary_path: &str,
455 harness: Harness,
456) -> Result<()> {
457 let window_name = format!("ralph-{}", task_id);
458
459 let session_exists = Command::new("tmux")
461 .args(["has-session", "-t", session_name])
462 .status()
463 .map(|s| s.success())
464 .unwrap_or(false);
465
466 if !session_exists {
467 Command::new("tmux")
469 .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
470 .arg("-c")
471 .arg(working_dir)
472 .status()
473 .context("Failed to create tmux session")?;
474 }
475
476 let new_window_output = Command::new("tmux")
478 .args([
479 "new-window",
480 "-t",
481 session_name,
482 "-n",
483 &window_name,
484 "-P",
485 "-F",
486 "#{window_index}",
487 ])
488 .arg("-c")
489 .arg(working_dir)
490 .output()
491 .context("Failed to create tmux window")?;
492
493 if !new_window_output.status.success() {
494 anyhow::bail!(
495 "Failed to create window: {}",
496 String::from_utf8_lossy(&new_window_output.stderr)
497 );
498 }
499
500 let window_index = String::from_utf8_lossy(&new_window_output.stdout)
501 .trim()
502 .to_string();
503
504 let prompt_file = std::env::temp_dir().join(format!("scud-ralph-{}.txt", task_id));
506 std::fs::write(&prompt_file, prompt)?;
507
508 let harness_cmd = match harness {
511 Harness::Claude => format!(
512 "'{binary_path}' \"$(cat '{prompt_file}')\" --dangerously-skip-permissions",
513 binary_path = binary_path,
514 prompt_file = prompt_file.display()
515 ),
516 Harness::OpenCode => format!(
517 "'{binary_path}' run --variant minimal \"$(cat '{prompt_file}')\"",
518 binary_path = binary_path,
519 prompt_file = prompt_file.display()
520 ),
521 Harness::Cursor => format!(
522 "'{binary_path}' -p \"$(cat '{prompt_file}')\"",
523 binary_path = binary_path,
524 prompt_file = prompt_file.display()
525 ),
526 };
527
528 let ralph_script = format!(
536 r#"#!/usr/bin/env bash
537# Source shell profile for PATH setup
538[ -f /etc/profile ] && . /etc/profile
539[ -f ~/.profile ] && . ~/.profile
540[ -f ~/.bash_profile ] && . ~/.bash_profile
541[ -f ~/.bashrc ] && . ~/.bashrc
542[ -f ~/.zshrc ] && . ~/.zshrc 2>/dev/null
543export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
544[ -s "$HOME/.nvm/nvm.sh" ] && . "$HOME/.nvm/nvm.sh"
545[ -s "$HOME/.bun/_bun" ] && . "$HOME/.bun/_bun"
546
547export SCUD_TASK_ID='{task_id}'
548export RALPH_PROMISE='{promise}'
549export RALPH_MAX_ITER=50
550export RALPH_ITER=0
551
552echo "🔄 Ralph loop starting for task {task_id}"
553echo " Harness: {harness_name}"
554echo " Completion promise: {promise}"
555echo " Max iterations: $RALPH_MAX_ITER"
556echo ""
557
558while true; do
559 RALPH_ITER=$((RALPH_ITER + 1))
560 echo ""
561 echo "═══════════════════════════════════════════════════════════"
562 echo "🔄 RALPH ITERATION $RALPH_ITER / $RALPH_MAX_ITER"
563 echo "═══════════════════════════════════════════════════════════"
564 echo ""
565
566 # Run harness with the prompt (using full path)
567 {harness_cmd}
568
569 # Check if task is done
570 TASK_STATUS=$(scud show {task_id} 2>/dev/null | grep -i "status:" | awk '{{print $2}}')
571
572 if [ "$TASK_STATUS" = "done" ]; then
573 echo ""
574 echo "✅ Task {task_id} completed successfully after $RALPH_ITER iterations!"
575 rm -f '{prompt_file}'
576 break
577 fi
578
579 # Check max iterations
580 if [ $RALPH_ITER -ge $RALPH_MAX_ITER ]; then
581 echo ""
582 echo "⚠️ Ralph loop: Max iterations ($RALPH_MAX_ITER) reached for task {task_id}"
583 echo " Task status: $TASK_STATUS"
584 rm -f '{prompt_file}'
585 break
586 fi
587
588 # Small delay before next iteration
589 echo ""
590 echo "🔄 Task not yet complete (status: $TASK_STATUS). Continuing loop..."
591 sleep 2
592done
593"#,
594 task_id = task_id,
595 promise = completion_promise,
596 prompt_file = prompt_file.display(),
597 harness_name = harness.name(),
598 harness_cmd = harness_cmd,
599 );
600
601 let script_file = std::env::temp_dir().join(format!("scud-ralph-script-{}.sh", task_id));
603 std::fs::write(&script_file, &ralph_script)?;
604
605 let cmd = format!("bash '{}'", script_file.display());
607
608 let target = format!("{}:{}", session_name, window_index);
609 let send_result = Command::new("tmux")
610 .args(["send-keys", "-t", &target, &cmd, "Enter"])
611 .output()
612 .context("Failed to send command to tmux window")?;
613
614 if !send_result.status.success() {
615 anyhow::bail!(
616 "Failed to send keys: {}",
617 String::from_utf8_lossy(&send_result.stderr)
618 );
619 }
620
621 Ok(())
622}
623
624pub fn tmux_session_exists(session_name: &str) -> bool {
626 Command::new("tmux")
627 .args(["has-session", "-t", session_name])
628 .status()
629 .map(|s| s.success())
630 .unwrap_or(false)
631}
632
633pub fn tmux_attach(session_name: &str) -> Result<()> {
635 let status = Command::new("tmux")
637 .args(["attach", "-t", session_name])
638 .status()
639 .context("Failed to attach to tmux session")?;
640
641 if !status.success() {
642 anyhow::bail!("tmux attach failed");
643 }
644
645 Ok(())
646}
647
648pub fn setup_tmux_control_window(session_name: &str, tag: &str) -> Result<()> {
650 let control_script = format!(
651 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'"#,
652 session_name, tag, tag, tag
653 );
654
655 let target = format!("{}:ctrl", session_name);
656 Command::new("tmux")
657 .args(["send-keys", "-t", &target, &control_script, "Enter"])
658 .status()
659 .context("Failed to setup control window")?;
660
661 Ok(())
662}
663
664pub fn tmux_window_exists(session_name: &str, window_name: &str) -> bool {
666 let output = Command::new("tmux")
667 .args(["list-windows", "-t", session_name, "-F", "#{window_name}"])
668 .output();
669
670 match output {
671 Ok(out) if out.status.success() => {
672 let windows = String::from_utf8_lossy(&out.stdout);
673 windows
674 .lines()
675 .any(|w| w == window_name || w.starts_with(&format!("{}-", window_name)))
676 }
677 _ => false,
678 }
679}
680
681pub fn tmux_pane_shows_prompt(session_name: &str, window_name: &str) -> bool {
683 let window_target = format!("{}:{}", session_name, window_name);
684
685 let output = std::process::Command::new("tmux")
687 .args(["capture-pane", "-t", &window_target, "-p", "-S", "-1"])
688 .output();
689
690 let Ok(output) = output else {
691 return false;
692 };
693
694 if !output.status.success() {
695 return false;
696 }
697
698 let last_line = String::from_utf8_lossy(&output.stdout);
699 let last_line = last_line.trim();
700
701 let prompt_patterns = [
704 "$ ", "% ", "> ", "# ", "❯ ", "→ ", ];
711
712 for pattern in prompt_patterns {
714 if last_line.ends_with(pattern) || last_line.ends_with(pattern.trim()) {
715 return true;
716 }
717 }
718
719 if last_line.contains('@')
721 && (last_line.ends_with('$') || last_line.ends_with('%') || last_line.ends_with('>'))
722 {
723 return true;
724 }
725
726 false
727}
728
729pub fn kill_tmux_window(session_name: &str, window_name: &str) -> Result<()> {
731 let target = format!("{}:{}", session_name, window_name);
732 Command::new("tmux")
733 .args(["kill-window", "-t", &target])
734 .output()?;
735 Ok(())
736}
737
738pub fn spawn_in_tmux(
740 session_name: &str,
741 window_name: &str,
742 command: &str,
743 working_dir: &Path,
744) -> Result<()> {
745 let session_exists = Command::new("tmux")
747 .args(["has-session", "-t", session_name])
748 .output()
749 .map(|o| o.status.success())
750 .unwrap_or(false);
751
752 if !session_exists {
753 Command::new("tmux")
755 .args([
756 "new-session",
757 "-d",
758 "-s",
759 session_name,
760 "-n",
761 "ctrl",
762 "-c",
763 &working_dir.to_string_lossy(),
764 ])
765 .output()
766 .context("Failed to create tmux session")?;
767 }
768
769 let output = Command::new("tmux")
771 .args([
772 "new-window",
773 "-t",
774 session_name,
775 "-n",
776 window_name,
777 "-c",
778 &working_dir.to_string_lossy(),
779 "-P",
780 "-F",
781 "#{window_index}",
782 ])
783 .output()
784 .context("Failed to create tmux window")?;
785
786 if !output.status.success() {
787 anyhow::bail!(
788 "Failed to create tmux window: {}",
789 String::from_utf8_lossy(&output.stderr)
790 );
791 }
792
793 let window_index = String::from_utf8_lossy(&output.stdout).trim().to_string();
794
795 let send_result = Command::new("tmux")
797 .args([
798 "send-keys",
799 "-t",
800 &format!("{}:{}", session_name, window_index),
801 command,
802 "Enter",
803 ])
804 .output()
805 .context("Failed to send command to tmux window")?;
806
807 if !send_result.status.success() {
808 anyhow::bail!(
809 "Failed to send command: {}",
810 String::from_utf8_lossy(&send_result.stderr)
811 );
812 }
813
814 Ok(())
815}