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}
20
21impl Harness {
22 pub fn parse(s: &str) -> Result<Self> {
24 match s.to_lowercase().as_str() {
25 "claude" | "claude-code" => Ok(Harness::Claude),
26 "opencode" | "open-code" => Ok(Harness::OpenCode),
27 other => anyhow::bail!("Unknown harness: '{}'. Supported: claude, opencode", other),
28 }
29 }
30
31 pub fn name(&self) -> &'static str {
33 match self {
34 Harness::Claude => "claude",
35 Harness::OpenCode => "opencode",
36 }
37 }
38
39 pub fn binary_name(&self) -> &'static str {
41 match self {
42 Harness::Claude => "claude",
43 Harness::OpenCode => "opencode",
44 }
45 }
46
47 pub fn command(&self, binary_path: &str, prompt_file: &Path, model: Option<&str>) -> String {
49 match self {
50 Harness::Claude => {
51 let model_flag = model.map(|m| format!(" --model {}", m)).unwrap_or_default();
52 format!(
53 r#"'{}' "$(cat '{}')" --dangerously-skip-permissions{}"#,
54 binary_path,
55 prompt_file.display(),
56 model_flag
57 )
58 }
59 Harness::OpenCode => {
60 let model_flag = model.map(|m| format!(" --model {}", m)).unwrap_or_default();
61 format!(
64 r#"'{}'{} run --variant minimal "$(cat '{}')""#,
65 binary_path,
66 model_flag,
67 prompt_file.display()
68 )
69 }
70 }
71 }
72}
73
74static CLAUDE_PATH: OnceLock<String> = OnceLock::new();
76static OPENCODE_PATH: OnceLock<String> = OnceLock::new();
77
78pub fn find_harness_binary(harness: Harness) -> Result<&'static str> {
81 let cache = match harness {
82 Harness::Claude => &CLAUDE_PATH,
83 Harness::OpenCode => &OPENCODE_PATH,
84 };
85
86 if let Some(path) = cache.get() {
88 return Ok(path.as_str());
89 }
90
91 let binary_name = harness.binary_name();
92
93 let output = Command::new("which")
95 .arg(binary_name)
96 .output()
97 .context(format!("Failed to run 'which {}'", binary_name))?;
98
99 if output.status.success() {
100 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
101 if !path.is_empty() {
102 let _ = cache.set(path);
104 return Ok(cache.get().unwrap().as_str());
105 }
106 }
107
108 let common_paths: &[&str] = match harness {
110 Harness::Claude => &[
111 "/opt/homebrew/bin/claude",
112 "/usr/local/bin/claude",
113 "/usr/bin/claude",
114 ],
115 Harness::OpenCode => &[
116 "/opt/homebrew/bin/opencode",
117 "/usr/local/bin/opencode",
118 "/usr/bin/opencode",
119 ],
120 };
121
122 for path in common_paths {
123 if std::path::Path::new(path).exists() {
124 let _ = cache.set(path.to_string());
125 return Ok(cache.get().unwrap().as_str());
126 }
127 }
128
129 if let Ok(home) = std::env::var("HOME") {
131 let home_paths: Vec<String> = match harness {
132 Harness::Claude => vec![
133 format!("{}/.local/bin/claude", home),
134 format!("{}/.claude/local/claude", home),
135 ],
136 Harness::OpenCode => vec![
137 format!("{}/.local/bin/opencode", home),
138 format!("{}/.bun/bin/opencode", home),
139 ],
140 };
141
142 for path in home_paths {
143 if std::path::Path::new(&path).exists() {
144 let _ = cache.set(path);
145 return Ok(cache.get().unwrap().as_str());
146 }
147 }
148 }
149
150 let install_hint = match harness {
151 Harness::Claude => "Install with: npm install -g @anthropic-ai/claude-code",
152 Harness::OpenCode => "Install with: curl -fsSL https://opencode.ai/install | bash",
153 };
154
155 anyhow::bail!(
156 "Could not find '{}' binary. Please ensure it is installed and in PATH.\n{}",
157 binary_name,
158 install_hint
159 )
160}
161
162pub fn find_claude_binary() -> Result<&'static str> {
164 find_harness_binary(Harness::Claude)
165}
166
167pub fn check_tmux_available() -> Result<()> {
169 let result = Command::new("which")
170 .arg("tmux")
171 .output()
172 .context("Failed to check for tmux binary")?;
173
174 if !result.status.success() {
175 anyhow::bail!("tmux is not installed or not in PATH. Install with: brew install tmux (macOS) or apt install tmux (Linux)");
176 }
177
178 Ok(())
179}
180
181pub fn spawn_terminal(
184 task_id: &str,
185 prompt: &str,
186 working_dir: &Path,
187 session_name: &str,
188) -> Result<String> {
189 spawn_terminal_with_harness_and_model(
191 task_id,
192 prompt,
193 working_dir,
194 session_name,
195 Harness::Claude,
196 None,
197 )
198}
199
200pub fn spawn_terminal_with_harness(
203 task_id: &str,
204 prompt: &str,
205 working_dir: &Path,
206 session_name: &str,
207 harness: Harness,
208) -> Result<String> {
209 spawn_terminal_with_harness_and_model(task_id, prompt, working_dir, session_name, harness, None)
210}
211
212pub fn spawn_terminal_with_harness_and_model(
215 task_id: &str,
216 prompt: &str,
217 working_dir: &Path,
218 session_name: &str,
219 harness: Harness,
220 model: Option<&str>,
221) -> Result<String> {
222 let binary_path = find_harness_binary(harness)?;
224 spawn_tmux(
225 task_id,
226 prompt,
227 working_dir,
228 session_name,
229 binary_path,
230 harness,
231 model,
232 )
233}
234
235fn spawn_tmux(
238 task_id: &str,
239 prompt: &str,
240 working_dir: &Path,
241 session_name: &str,
242 binary_path: &str,
243 harness: Harness,
244 model: Option<&str>,
245) -> Result<String> {
246 let window_name = format!("task-{}", task_id);
247
248 let session_exists = Command::new("tmux")
250 .args(["has-session", "-t", session_name])
251 .status()
252 .map(|s| s.success())
253 .unwrap_or(false);
254
255 if !session_exists {
256 Command::new("tmux")
258 .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
259 .arg("-c")
260 .arg(working_dir)
261 .status()
262 .context("Failed to create tmux session")?;
263 }
264
265 let new_window_output = Command::new("tmux")
268 .args([
269 "new-window",
270 "-t",
271 session_name,
272 "-n",
273 &window_name,
274 "-P", "-F",
276 "#{window_index}", ])
278 .arg("-c")
279 .arg(working_dir)
280 .output()
281 .context("Failed to create tmux window")?;
282
283 if !new_window_output.status.success() {
284 anyhow::bail!(
285 "Failed to create window: {}",
286 String::from_utf8_lossy(&new_window_output.stderr)
287 );
288 }
289
290 let window_index = String::from_utf8_lossy(&new_window_output.stdout)
291 .trim()
292 .to_string();
293
294 let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
296 std::fs::write(&prompt_file, prompt)?;
297
298 let harness_cmd = harness.command(binary_path, &prompt_file, model);
303
304 let spawn_script = format!(
307 r#"#!/usr/bin/env bash
308# Source shell profile for PATH setup
309source ~/.bash_profile 2>/dev/null
310source ~/.zshrc 2>/dev/null
311export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
312[ -s "$HOME/.nvm/nvm.sh" ] && source "$HOME/.nvm/nvm.sh"
313
314export SCUD_TASK_ID='{task_id}'
315{harness_cmd}
316rm -f '{prompt_file}'
317"#,
318 task_id = task_id,
319 harness_cmd = harness_cmd,
320 prompt_file = prompt_file.display()
321 );
322
323 let script_file = std::env::temp_dir().join(format!("scud-spawn-{}.sh", task_id));
324 std::fs::write(&script_file, &spawn_script)?;
325
326 let run_cmd = format!("bash '{}'", script_file.display());
328
329 let target = format!("{}:{}", session_name, window_index);
330 let send_result = Command::new("tmux")
331 .args(["send-keys", "-t", &target, &run_cmd, "Enter"])
332 .output()
333 .context("Failed to send command to tmux window")?;
334
335 if !send_result.status.success() {
336 anyhow::bail!(
337 "Failed to send keys: {}",
338 String::from_utf8_lossy(&send_result.stderr)
339 );
340 }
341
342 Ok(window_index)
343}
344
345pub fn spawn_terminal_ralph(
348 task_id: &str,
349 prompt: &str,
350 working_dir: &Path,
351 session_name: &str,
352 completion_promise: &str,
353) -> Result<()> {
354 spawn_terminal_ralph_with_harness(
356 task_id,
357 prompt,
358 working_dir,
359 session_name,
360 completion_promise,
361 Harness::Claude,
362 )
363}
364
365pub fn spawn_terminal_ralph_with_harness(
367 task_id: &str,
368 prompt: &str,
369 working_dir: &Path,
370 session_name: &str,
371 completion_promise: &str,
372 harness: Harness,
373) -> Result<()> {
374 let binary_path = find_harness_binary(harness)?;
376 spawn_tmux_ralph(
377 task_id,
378 prompt,
379 working_dir,
380 session_name,
381 completion_promise,
382 binary_path,
383 harness,
384 )
385}
386
387fn spawn_tmux_ralph(
389 task_id: &str,
390 prompt: &str,
391 working_dir: &Path,
392 session_name: &str,
393 completion_promise: &str,
394 binary_path: &str,
395 harness: Harness,
396) -> Result<()> {
397 let window_name = format!("ralph-{}", task_id);
398
399 let session_exists = Command::new("tmux")
401 .args(["has-session", "-t", session_name])
402 .status()
403 .map(|s| s.success())
404 .unwrap_or(false);
405
406 if !session_exists {
407 Command::new("tmux")
409 .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
410 .arg("-c")
411 .arg(working_dir)
412 .status()
413 .context("Failed to create tmux session")?;
414 }
415
416 let new_window_output = Command::new("tmux")
418 .args([
419 "new-window",
420 "-t",
421 session_name,
422 "-n",
423 &window_name,
424 "-P",
425 "-F",
426 "#{window_index}",
427 ])
428 .arg("-c")
429 .arg(working_dir)
430 .output()
431 .context("Failed to create tmux window")?;
432
433 if !new_window_output.status.success() {
434 anyhow::bail!(
435 "Failed to create window: {}",
436 String::from_utf8_lossy(&new_window_output.stderr)
437 );
438 }
439
440 let window_index = String::from_utf8_lossy(&new_window_output.stdout)
441 .trim()
442 .to_string();
443
444 let prompt_file = std::env::temp_dir().join(format!("scud-ralph-{}.txt", task_id));
446 std::fs::write(&prompt_file, prompt)?;
447
448 let harness_cmd = match harness {
451 Harness::Claude => format!(
452 "'{binary_path}' \"$(cat '{prompt_file}')\" --dangerously-skip-permissions",
453 binary_path = binary_path,
454 prompt_file = prompt_file.display()
455 ),
456 Harness::OpenCode => format!(
457 "'{binary_path}' run --variant minimal \"$(cat '{prompt_file}')\"",
458 binary_path = binary_path,
459 prompt_file = prompt_file.display()
460 ),
461 };
462
463 let ralph_script = format!(
471 r#"#!/usr/bin/env bash
472# Source shell profile for PATH setup
473[ -f /etc/profile ] && . /etc/profile
474[ -f ~/.profile ] && . ~/.profile
475[ -f ~/.bash_profile ] && . ~/.bash_profile
476[ -f ~/.bashrc ] && . ~/.bashrc
477[ -f ~/.zshrc ] && . ~/.zshrc 2>/dev/null
478export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
479[ -s "$HOME/.nvm/nvm.sh" ] && . "$HOME/.nvm/nvm.sh"
480[ -s "$HOME/.bun/_bun" ] && . "$HOME/.bun/_bun"
481
482export SCUD_TASK_ID='{task_id}'
483export RALPH_PROMISE='{promise}'
484export RALPH_MAX_ITER=50
485export RALPH_ITER=0
486
487echo "🔄 Ralph loop starting for task {task_id}"
488echo " Harness: {harness_name}"
489echo " Completion promise: {promise}"
490echo " Max iterations: $RALPH_MAX_ITER"
491echo ""
492
493while true; do
494 RALPH_ITER=$((RALPH_ITER + 1))
495 echo ""
496 echo "═══════════════════════════════════════════════════════════"
497 echo "🔄 RALPH ITERATION $RALPH_ITER / $RALPH_MAX_ITER"
498 echo "═══════════════════════════════════════════════════════════"
499 echo ""
500
501 # Run harness with the prompt (using full path)
502 {harness_cmd}
503
504 # Check if task is done
505 TASK_STATUS=$(scud show {task_id} 2>/dev/null | grep -i "status:" | awk '{{print $2}}')
506
507 if [ "$TASK_STATUS" = "done" ]; then
508 echo ""
509 echo "✅ Task {task_id} completed successfully after $RALPH_ITER iterations!"
510 rm -f '{prompt_file}'
511 break
512 fi
513
514 # Check max iterations
515 if [ $RALPH_ITER -ge $RALPH_MAX_ITER ]; then
516 echo ""
517 echo "⚠️ Ralph loop: Max iterations ($RALPH_MAX_ITER) reached for task {task_id}"
518 echo " Task status: $TASK_STATUS"
519 rm -f '{prompt_file}'
520 break
521 fi
522
523 # Small delay before next iteration
524 echo ""
525 echo "🔄 Task not yet complete (status: $TASK_STATUS). Continuing loop..."
526 sleep 2
527done
528"#,
529 task_id = task_id,
530 promise = completion_promise,
531 prompt_file = prompt_file.display(),
532 harness_name = harness.name(),
533 harness_cmd = harness_cmd,
534 );
535
536 let script_file = std::env::temp_dir().join(format!("scud-ralph-script-{}.sh", task_id));
538 std::fs::write(&script_file, &ralph_script)?;
539
540 let cmd = format!("bash '{}'", script_file.display());
542
543 let target = format!("{}:{}", session_name, window_index);
544 let send_result = Command::new("tmux")
545 .args(["send-keys", "-t", &target, &cmd, "Enter"])
546 .output()
547 .context("Failed to send command to tmux window")?;
548
549 if !send_result.status.success() {
550 anyhow::bail!(
551 "Failed to send keys: {}",
552 String::from_utf8_lossy(&send_result.stderr)
553 );
554 }
555
556 Ok(())
557}
558
559pub fn tmux_session_exists(session_name: &str) -> bool {
561 Command::new("tmux")
562 .args(["has-session", "-t", session_name])
563 .status()
564 .map(|s| s.success())
565 .unwrap_or(false)
566}
567
568pub fn tmux_attach(session_name: &str) -> Result<()> {
570 let status = Command::new("tmux")
572 .args(["attach", "-t", session_name])
573 .status()
574 .context("Failed to attach to tmux session")?;
575
576 if !status.success() {
577 anyhow::bail!("tmux attach failed");
578 }
579
580 Ok(())
581}
582
583pub fn setup_tmux_control_window(session_name: &str, tag: &str) -> Result<()> {
585 let control_script = format!(
586 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'"#,
587 session_name, tag, tag, tag
588 );
589
590 let target = format!("{}:ctrl", session_name);
591 Command::new("tmux")
592 .args(["send-keys", "-t", &target, &control_script, "Enter"])
593 .status()
594 .context("Failed to setup control window")?;
595
596 Ok(())
597}
598
599pub fn tmux_window_exists(session_name: &str, window_name: &str) -> bool {
601 let output = Command::new("tmux")
602 .args(["list-windows", "-t", session_name, "-F", "#{window_name}"])
603 .output();
604
605 match output {
606 Ok(out) if out.status.success() => {
607 let windows = String::from_utf8_lossy(&out.stdout);
608 windows
609 .lines()
610 .any(|w| w == window_name || w.starts_with(&format!("{}-", window_name)))
611 }
612 _ => false,
613 }
614}
615
616pub fn kill_tmux_window(session_name: &str, window_name: &str) -> Result<()> {
618 let target = format!("{}:{}", session_name, window_name);
619 Command::new("tmux")
620 .args(["kill-window", "-t", &target])
621 .output()?;
622 Ok(())
623}
624
625pub fn spawn_in_tmux(
627 session_name: &str,
628 window_name: &str,
629 command: &str,
630 working_dir: &Path,
631) -> Result<()> {
632 let session_exists = Command::new("tmux")
634 .args(["has-session", "-t", session_name])
635 .output()
636 .map(|o| o.status.success())
637 .unwrap_or(false);
638
639 if !session_exists {
640 Command::new("tmux")
642 .args([
643 "new-session",
644 "-d",
645 "-s",
646 session_name,
647 "-n",
648 "ctrl",
649 "-c",
650 &working_dir.to_string_lossy(),
651 ])
652 .output()
653 .context("Failed to create tmux session")?;
654 }
655
656 let output = Command::new("tmux")
658 .args([
659 "new-window",
660 "-t",
661 session_name,
662 "-n",
663 window_name,
664 "-c",
665 &working_dir.to_string_lossy(),
666 "-P",
667 "-F",
668 "#{window_index}",
669 ])
670 .output()
671 .context("Failed to create tmux window")?;
672
673 if !output.status.success() {
674 anyhow::bail!(
675 "Failed to create tmux window: {}",
676 String::from_utf8_lossy(&output.stderr)
677 );
678 }
679
680 let window_index = String::from_utf8_lossy(&output.stdout).trim().to_string();
681
682 let send_result = Command::new("tmux")
684 .args([
685 "send-keys",
686 "-t",
687 &format!("{}:{}", session_name, window_index),
688 command,
689 "Enter",
690 ])
691 .output()
692 .context("Failed to send command to tmux window")?;
693
694 if !send_result.status.success() {
695 anyhow::bail!(
696 "Failed to send command: {}",
697 String::from_utf8_lossy(&send_result.stderr)
698 );
699 }
700
701 Ok(())
702}