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 None, )
234}
235
236pub fn spawn_terminal_with_task_list(
242 task_id: &str,
243 prompt: &str,
244 working_dir: &Path,
245 session_name: &str,
246 harness: Harness,
247 model: Option<&str>,
248 task_list_id: &str,
249) -> Result<String> {
250 let binary_path = find_harness_binary(harness)?;
251 spawn_tmux(
252 task_id,
253 prompt,
254 working_dir,
255 session_name,
256 binary_path,
257 harness,
258 model,
259 Some(task_list_id),
260 )
261}
262
263fn spawn_tmux(
266 task_id: &str,
267 prompt: &str,
268 working_dir: &Path,
269 session_name: &str,
270 binary_path: &str,
271 harness: Harness,
272 model: Option<&str>,
273 task_list_id: Option<&str>,
274) -> Result<String> {
275 let window_name = format!("task-{}", task_id);
276
277 let session_exists = Command::new("tmux")
279 .args(["has-session", "-t", session_name])
280 .status()
281 .map(|s| s.success())
282 .unwrap_or(false);
283
284 if !session_exists {
285 Command::new("tmux")
287 .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
288 .arg("-c")
289 .arg(working_dir)
290 .status()
291 .context("Failed to create tmux session")?;
292 }
293
294 let new_window_output = Command::new("tmux")
297 .args([
298 "new-window",
299 "-t",
300 session_name,
301 "-n",
302 &window_name,
303 "-P", "-F",
305 "#{window_index}", ])
307 .arg("-c")
308 .arg(working_dir)
309 .output()
310 .context("Failed to create tmux window")?;
311
312 if !new_window_output.status.success() {
313 anyhow::bail!(
314 "Failed to create window: {}",
315 String::from_utf8_lossy(&new_window_output.stderr)
316 );
317 }
318
319 let window_index = String::from_utf8_lossy(&new_window_output.stdout)
320 .trim()
321 .to_string();
322
323 let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
325 std::fs::write(&prompt_file, prompt)?;
326
327 let harness_cmd = harness.command(binary_path, &prompt_file, model);
332
333 let task_list_export = task_list_id
335 .map(|id| format!("export CLAUDE_CODE_TASK_LIST_ID='{}'\n", id))
336 .unwrap_or_default();
337
338 let spawn_script = format!(
341 r#"#!/usr/bin/env bash
342# Source shell profile for PATH setup
343source ~/.bash_profile 2>/dev/null
344source ~/.zshrc 2>/dev/null
345export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
346[ -s "$HOME/.nvm/nvm.sh" ] && source "$HOME/.nvm/nvm.sh"
347
348export SCUD_TASK_ID='{task_id}'
349{task_list_export}{harness_cmd}
350rm -f '{prompt_file}'
351"#,
352 task_id = task_id,
353 task_list_export = task_list_export,
354 harness_cmd = harness_cmd,
355 prompt_file = prompt_file.display()
356 );
357
358 let script_file = std::env::temp_dir().join(format!("scud-spawn-{}.sh", task_id));
359 std::fs::write(&script_file, &spawn_script)?;
360
361 let run_cmd = format!("bash '{}'", script_file.display());
363
364 let target = format!("{}:{}", session_name, window_index);
365 let send_result = Command::new("tmux")
366 .args(["send-keys", "-t", &target, &run_cmd, "Enter"])
367 .output()
368 .context("Failed to send command to tmux window")?;
369
370 if !send_result.status.success() {
371 anyhow::bail!(
372 "Failed to send keys: {}",
373 String::from_utf8_lossy(&send_result.stderr)
374 );
375 }
376
377 Ok(window_index)
378}
379
380pub fn spawn_terminal_ralph(
383 task_id: &str,
384 prompt: &str,
385 working_dir: &Path,
386 session_name: &str,
387 completion_promise: &str,
388) -> Result<()> {
389 spawn_terminal_ralph_with_harness(
391 task_id,
392 prompt,
393 working_dir,
394 session_name,
395 completion_promise,
396 Harness::Claude,
397 )
398}
399
400pub fn spawn_terminal_ralph_with_harness(
402 task_id: &str,
403 prompt: &str,
404 working_dir: &Path,
405 session_name: &str,
406 completion_promise: &str,
407 harness: Harness,
408) -> Result<()> {
409 let binary_path = find_harness_binary(harness)?;
411 spawn_tmux_ralph(
412 task_id,
413 prompt,
414 working_dir,
415 session_name,
416 completion_promise,
417 binary_path,
418 harness,
419 )
420}
421
422fn spawn_tmux_ralph(
424 task_id: &str,
425 prompt: &str,
426 working_dir: &Path,
427 session_name: &str,
428 completion_promise: &str,
429 binary_path: &str,
430 harness: Harness,
431) -> Result<()> {
432 let window_name = format!("ralph-{}", task_id);
433
434 let session_exists = Command::new("tmux")
436 .args(["has-session", "-t", session_name])
437 .status()
438 .map(|s| s.success())
439 .unwrap_or(false);
440
441 if !session_exists {
442 Command::new("tmux")
444 .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
445 .arg("-c")
446 .arg(working_dir)
447 .status()
448 .context("Failed to create tmux session")?;
449 }
450
451 let new_window_output = Command::new("tmux")
453 .args([
454 "new-window",
455 "-t",
456 session_name,
457 "-n",
458 &window_name,
459 "-P",
460 "-F",
461 "#{window_index}",
462 ])
463 .arg("-c")
464 .arg(working_dir)
465 .output()
466 .context("Failed to create tmux window")?;
467
468 if !new_window_output.status.success() {
469 anyhow::bail!(
470 "Failed to create window: {}",
471 String::from_utf8_lossy(&new_window_output.stderr)
472 );
473 }
474
475 let window_index = String::from_utf8_lossy(&new_window_output.stdout)
476 .trim()
477 .to_string();
478
479 let prompt_file = std::env::temp_dir().join(format!("scud-ralph-{}.txt", task_id));
481 std::fs::write(&prompt_file, prompt)?;
482
483 let harness_cmd = match harness {
486 Harness::Claude => format!(
487 "'{binary_path}' \"$(cat '{prompt_file}')\" --dangerously-skip-permissions",
488 binary_path = binary_path,
489 prompt_file = prompt_file.display()
490 ),
491 Harness::OpenCode => format!(
492 "'{binary_path}' run --variant minimal \"$(cat '{prompt_file}')\"",
493 binary_path = binary_path,
494 prompt_file = prompt_file.display()
495 ),
496 };
497
498 let ralph_script = format!(
506 r#"#!/usr/bin/env bash
507# Source shell profile for PATH setup
508[ -f /etc/profile ] && . /etc/profile
509[ -f ~/.profile ] && . ~/.profile
510[ -f ~/.bash_profile ] && . ~/.bash_profile
511[ -f ~/.bashrc ] && . ~/.bashrc
512[ -f ~/.zshrc ] && . ~/.zshrc 2>/dev/null
513export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
514[ -s "$HOME/.nvm/nvm.sh" ] && . "$HOME/.nvm/nvm.sh"
515[ -s "$HOME/.bun/_bun" ] && . "$HOME/.bun/_bun"
516
517export SCUD_TASK_ID='{task_id}'
518export RALPH_PROMISE='{promise}'
519export RALPH_MAX_ITER=50
520export RALPH_ITER=0
521
522echo "🔄 Ralph loop starting for task {task_id}"
523echo " Harness: {harness_name}"
524echo " Completion promise: {promise}"
525echo " Max iterations: $RALPH_MAX_ITER"
526echo ""
527
528while true; do
529 RALPH_ITER=$((RALPH_ITER + 1))
530 echo ""
531 echo "═══════════════════════════════════════════════════════════"
532 echo "🔄 RALPH ITERATION $RALPH_ITER / $RALPH_MAX_ITER"
533 echo "═══════════════════════════════════════════════════════════"
534 echo ""
535
536 # Run harness with the prompt (using full path)
537 {harness_cmd}
538
539 # Check if task is done
540 TASK_STATUS=$(scud show {task_id} 2>/dev/null | grep -i "status:" | awk '{{print $2}}')
541
542 if [ "$TASK_STATUS" = "done" ]; then
543 echo ""
544 echo "✅ Task {task_id} completed successfully after $RALPH_ITER iterations!"
545 rm -f '{prompt_file}'
546 break
547 fi
548
549 # Check max iterations
550 if [ $RALPH_ITER -ge $RALPH_MAX_ITER ]; then
551 echo ""
552 echo "⚠️ Ralph loop: Max iterations ($RALPH_MAX_ITER) reached for task {task_id}"
553 echo " Task status: $TASK_STATUS"
554 rm -f '{prompt_file}'
555 break
556 fi
557
558 # Small delay before next iteration
559 echo ""
560 echo "🔄 Task not yet complete (status: $TASK_STATUS). Continuing loop..."
561 sleep 2
562done
563"#,
564 task_id = task_id,
565 promise = completion_promise,
566 prompt_file = prompt_file.display(),
567 harness_name = harness.name(),
568 harness_cmd = harness_cmd,
569 );
570
571 let script_file = std::env::temp_dir().join(format!("scud-ralph-script-{}.sh", task_id));
573 std::fs::write(&script_file, &ralph_script)?;
574
575 let cmd = format!("bash '{}'", script_file.display());
577
578 let target = format!("{}:{}", session_name, window_index);
579 let send_result = Command::new("tmux")
580 .args(["send-keys", "-t", &target, &cmd, "Enter"])
581 .output()
582 .context("Failed to send command to tmux window")?;
583
584 if !send_result.status.success() {
585 anyhow::bail!(
586 "Failed to send keys: {}",
587 String::from_utf8_lossy(&send_result.stderr)
588 );
589 }
590
591 Ok(())
592}
593
594pub fn tmux_session_exists(session_name: &str) -> bool {
596 Command::new("tmux")
597 .args(["has-session", "-t", session_name])
598 .status()
599 .map(|s| s.success())
600 .unwrap_or(false)
601}
602
603pub fn tmux_attach(session_name: &str) -> Result<()> {
605 let status = Command::new("tmux")
607 .args(["attach", "-t", session_name])
608 .status()
609 .context("Failed to attach to tmux session")?;
610
611 if !status.success() {
612 anyhow::bail!("tmux attach failed");
613 }
614
615 Ok(())
616}
617
618pub fn setup_tmux_control_window(session_name: &str, tag: &str) -> Result<()> {
620 let control_script = format!(
621 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'"#,
622 session_name, tag, tag, tag
623 );
624
625 let target = format!("{}:ctrl", session_name);
626 Command::new("tmux")
627 .args(["send-keys", "-t", &target, &control_script, "Enter"])
628 .status()
629 .context("Failed to setup control window")?;
630
631 Ok(())
632}
633
634pub fn tmux_window_exists(session_name: &str, window_name: &str) -> bool {
636 let output = Command::new("tmux")
637 .args(["list-windows", "-t", session_name, "-F", "#{window_name}"])
638 .output();
639
640 match output {
641 Ok(out) if out.status.success() => {
642 let windows = String::from_utf8_lossy(&out.stdout);
643 windows
644 .lines()
645 .any(|w| w == window_name || w.starts_with(&format!("{}-", window_name)))
646 }
647 _ => false,
648 }
649}
650
651pub fn kill_tmux_window(session_name: &str, window_name: &str) -> Result<()> {
653 let target = format!("{}:{}", session_name, window_name);
654 Command::new("tmux")
655 .args(["kill-window", "-t", &target])
656 .output()?;
657 Ok(())
658}
659
660pub fn spawn_in_tmux(
662 session_name: &str,
663 window_name: &str,
664 command: &str,
665 working_dir: &Path,
666) -> Result<()> {
667 let session_exists = Command::new("tmux")
669 .args(["has-session", "-t", session_name])
670 .output()
671 .map(|o| o.status.success())
672 .unwrap_or(false);
673
674 if !session_exists {
675 Command::new("tmux")
677 .args([
678 "new-session",
679 "-d",
680 "-s",
681 session_name,
682 "-n",
683 "ctrl",
684 "-c",
685 &working_dir.to_string_lossy(),
686 ])
687 .output()
688 .context("Failed to create tmux session")?;
689 }
690
691 let output = Command::new("tmux")
693 .args([
694 "new-window",
695 "-t",
696 session_name,
697 "-n",
698 window_name,
699 "-c",
700 &working_dir.to_string_lossy(),
701 "-P",
702 "-F",
703 "#{window_index}",
704 ])
705 .output()
706 .context("Failed to create tmux window")?;
707
708 if !output.status.success() {
709 anyhow::bail!(
710 "Failed to create tmux window: {}",
711 String::from_utf8_lossy(&output.stderr)
712 );
713 }
714
715 let window_index = String::from_utf8_lossy(&output.stdout).trim().to_string();
716
717 let send_result = Command::new("tmux")
719 .args([
720 "send-keys",
721 "-t",
722 &format!("{}:{}", session_name, window_index),
723 command,
724 "Enter",
725 ])
726 .output()
727 .context("Failed to send command to tmux window")?;
728
729 if !send_result.status.success() {
730 anyhow::bail!(
731 "Failed to send command: {}",
732 String::from_utf8_lossy(&send_result.stderr)
733 );
734 }
735
736 Ok(())
737}