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!(
62 r#"'{}'{} run "$(cat '{}')""#,
63 binary_path,
64 model_flag,
65 prompt_file.display()
66 )
67 }
68 }
69 }
70}
71
72static CLAUDE_PATH: OnceLock<String> = OnceLock::new();
74static OPENCODE_PATH: OnceLock<String> = OnceLock::new();
75
76pub fn find_harness_binary(harness: Harness) -> Result<&'static str> {
79 let cache = match harness {
80 Harness::Claude => &CLAUDE_PATH,
81 Harness::OpenCode => &OPENCODE_PATH,
82 };
83
84 if let Some(path) = cache.get() {
86 return Ok(path.as_str());
87 }
88
89 let binary_name = harness.binary_name();
90
91 let output = Command::new("which")
93 .arg(binary_name)
94 .output()
95 .context(format!("Failed to run 'which {}'", binary_name))?;
96
97 if output.status.success() {
98 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
99 if !path.is_empty() {
100 let _ = cache.set(path);
102 return Ok(cache.get().unwrap().as_str());
103 }
104 }
105
106 let common_paths: &[&str] = match harness {
108 Harness::Claude => &[
109 "/opt/homebrew/bin/claude",
110 "/usr/local/bin/claude",
111 "/usr/bin/claude",
112 ],
113 Harness::OpenCode => &[
114 "/opt/homebrew/bin/opencode",
115 "/usr/local/bin/opencode",
116 "/usr/bin/opencode",
117 ],
118 };
119
120 for path in common_paths {
121 if std::path::Path::new(path).exists() {
122 let _ = cache.set(path.to_string());
123 return Ok(cache.get().unwrap().as_str());
124 }
125 }
126
127 if let Ok(home) = std::env::var("HOME") {
129 let home_paths: Vec<String> = match harness {
130 Harness::Claude => vec![
131 format!("{}/.local/bin/claude", home),
132 format!("{}/.claude/local/claude", home),
133 ],
134 Harness::OpenCode => vec![
135 format!("{}/.local/bin/opencode", home),
136 format!("{}/.bun/bin/opencode", home),
137 ],
138 };
139
140 for path in home_paths {
141 if std::path::Path::new(&path).exists() {
142 let _ = cache.set(path);
143 return Ok(cache.get().unwrap().as_str());
144 }
145 }
146 }
147
148 let install_hint = match harness {
149 Harness::Claude => "Install with: npm install -g @anthropic-ai/claude-code",
150 Harness::OpenCode => "Install with: curl -fsSL https://opencode.ai/install | bash",
151 };
152
153 anyhow::bail!(
154 "Could not find '{}' binary. Please ensure it is installed and in PATH.\n{}",
155 binary_name,
156 install_hint
157 )
158}
159
160pub fn find_claude_binary() -> Result<&'static str> {
162 find_harness_binary(Harness::Claude)
163}
164
165pub fn check_tmux_available() -> Result<()> {
167 let result = Command::new("which")
168 .arg("tmux")
169 .output()
170 .context("Failed to check for tmux binary")?;
171
172 if !result.status.success() {
173 anyhow::bail!("tmux is not installed or not in PATH. Install with: brew install tmux (macOS) or apt install tmux (Linux)");
174 }
175
176 Ok(())
177}
178
179pub fn spawn_terminal(
182 task_id: &str,
183 prompt: &str,
184 working_dir: &Path,
185 session_name: &str,
186) -> Result<String> {
187 spawn_terminal_with_harness_and_model(
189 task_id,
190 prompt,
191 working_dir,
192 session_name,
193 Harness::Claude,
194 None,
195 )
196}
197
198pub fn spawn_terminal_with_harness(
201 task_id: &str,
202 prompt: &str,
203 working_dir: &Path,
204 session_name: &str,
205 harness: Harness,
206) -> Result<String> {
207 spawn_terminal_with_harness_and_model(task_id, prompt, working_dir, session_name, harness, None)
208}
209
210pub fn spawn_terminal_with_harness_and_model(
213 task_id: &str,
214 prompt: &str,
215 working_dir: &Path,
216 session_name: &str,
217 harness: Harness,
218 model: Option<&str>,
219) -> Result<String> {
220 let binary_path = find_harness_binary(harness)?;
222 spawn_tmux(
223 task_id,
224 prompt,
225 working_dir,
226 session_name,
227 binary_path,
228 harness,
229 model,
230 )
231}
232
233fn spawn_tmux(
236 task_id: &str,
237 prompt: &str,
238 working_dir: &Path,
239 session_name: &str,
240 binary_path: &str,
241 harness: Harness,
242 model: Option<&str>,
243) -> Result<String> {
244 let window_name = format!("task-{}", task_id);
245
246 let session_exists = Command::new("tmux")
248 .args(["has-session", "-t", session_name])
249 .status()
250 .map(|s| s.success())
251 .unwrap_or(false);
252
253 if !session_exists {
254 Command::new("tmux")
256 .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
257 .arg("-c")
258 .arg(working_dir)
259 .status()
260 .context("Failed to create tmux session")?;
261 }
262
263 let new_window_output = Command::new("tmux")
266 .args([
267 "new-window",
268 "-t",
269 session_name,
270 "-n",
271 &window_name,
272 "-P", "-F",
274 "#{window_index}", ])
276 .arg("-c")
277 .arg(working_dir)
278 .output()
279 .context("Failed to create tmux window")?;
280
281 if !new_window_output.status.success() {
282 anyhow::bail!(
283 "Failed to create window: {}",
284 String::from_utf8_lossy(&new_window_output.stderr)
285 );
286 }
287
288 let window_index = String::from_utf8_lossy(&new_window_output.stdout)
289 .trim()
290 .to_string();
291
292 let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
294 std::fs::write(&prompt_file, prompt)?;
295
296 let harness_cmd = harness.command(binary_path, &prompt_file, model);
301
302 let spawn_script = format!(
305 r#"#!/usr/bin/env bash
306# Source shell profile for PATH setup
307source ~/.bash_profile 2>/dev/null
308source ~/.zshrc 2>/dev/null
309export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
310[ -s "$HOME/.nvm/nvm.sh" ] && source "$HOME/.nvm/nvm.sh"
311
312export SCUD_TASK_ID='{task_id}'
313{harness_cmd}
314rm -f '{prompt_file}'
315"#,
316 task_id = task_id,
317 harness_cmd = harness_cmd,
318 prompt_file = prompt_file.display()
319 );
320
321 let script_file = std::env::temp_dir().join(format!("scud-spawn-{}.sh", task_id));
322 std::fs::write(&script_file, &spawn_script)?;
323
324 let run_cmd = format!("bash '{}'", script_file.display());
326
327 let target = format!("{}:{}", session_name, window_index);
328 let send_result = Command::new("tmux")
329 .args(["send-keys", "-t", &target, &run_cmd, "Enter"])
330 .output()
331 .context("Failed to send command to tmux window")?;
332
333 if !send_result.status.success() {
334 anyhow::bail!(
335 "Failed to send keys: {}",
336 String::from_utf8_lossy(&send_result.stderr)
337 );
338 }
339
340 Ok(window_index)
341}
342
343pub fn spawn_terminal_ralph(
346 task_id: &str,
347 prompt: &str,
348 working_dir: &Path,
349 session_name: &str,
350 completion_promise: &str,
351) -> Result<()> {
352 spawn_terminal_ralph_with_harness(
354 task_id,
355 prompt,
356 working_dir,
357 session_name,
358 completion_promise,
359 Harness::Claude,
360 )
361}
362
363pub fn spawn_terminal_ralph_with_harness(
365 task_id: &str,
366 prompt: &str,
367 working_dir: &Path,
368 session_name: &str,
369 completion_promise: &str,
370 harness: Harness,
371) -> Result<()> {
372 let binary_path = find_harness_binary(harness)?;
374 spawn_tmux_ralph(
375 task_id,
376 prompt,
377 working_dir,
378 session_name,
379 completion_promise,
380 binary_path,
381 harness,
382 )
383}
384
385fn spawn_tmux_ralph(
387 task_id: &str,
388 prompt: &str,
389 working_dir: &Path,
390 session_name: &str,
391 completion_promise: &str,
392 binary_path: &str,
393 harness: Harness,
394) -> Result<()> {
395 let window_name = format!("ralph-{}", task_id);
396
397 let session_exists = Command::new("tmux")
399 .args(["has-session", "-t", session_name])
400 .status()
401 .map(|s| s.success())
402 .unwrap_or(false);
403
404 if !session_exists {
405 Command::new("tmux")
407 .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
408 .arg("-c")
409 .arg(working_dir)
410 .status()
411 .context("Failed to create tmux session")?;
412 }
413
414 let new_window_output = Command::new("tmux")
416 .args([
417 "new-window",
418 "-t",
419 session_name,
420 "-n",
421 &window_name,
422 "-P",
423 "-F",
424 "#{window_index}",
425 ])
426 .arg("-c")
427 .arg(working_dir)
428 .output()
429 .context("Failed to create tmux window")?;
430
431 if !new_window_output.status.success() {
432 anyhow::bail!(
433 "Failed to create window: {}",
434 String::from_utf8_lossy(&new_window_output.stderr)
435 );
436 }
437
438 let window_index = String::from_utf8_lossy(&new_window_output.stdout)
439 .trim()
440 .to_string();
441
442 let prompt_file = std::env::temp_dir().join(format!("scud-ralph-{}.txt", task_id));
444 std::fs::write(&prompt_file, prompt)?;
445
446 let harness_cmd = match harness {
449 Harness::Claude => format!(
450 "'{binary_path}' \"$(cat '{prompt_file}')\" --dangerously-skip-permissions",
451 binary_path = binary_path,
452 prompt_file = prompt_file.display()
453 ),
454 Harness::OpenCode => format!(
455 "'{binary_path}' run \"$(cat '{prompt_file}')\"",
456 binary_path = binary_path,
457 prompt_file = prompt_file.display()
458 ),
459 };
460
461 let ralph_script = format!(
469 r#"#!/usr/bin/env bash
470# Source shell profile for PATH setup
471[ -f /etc/profile ] && . /etc/profile
472[ -f ~/.profile ] && . ~/.profile
473[ -f ~/.bash_profile ] && . ~/.bash_profile
474[ -f ~/.bashrc ] && . ~/.bashrc
475[ -f ~/.zshrc ] && . ~/.zshrc 2>/dev/null
476export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
477[ -s "$HOME/.nvm/nvm.sh" ] && . "$HOME/.nvm/nvm.sh"
478[ -s "$HOME/.bun/_bun" ] && . "$HOME/.bun/_bun"
479
480export SCUD_TASK_ID='{task_id}'
481export RALPH_PROMISE='{promise}'
482export RALPH_MAX_ITER=50
483export RALPH_ITER=0
484
485echo "🔄 Ralph loop starting for task {task_id}"
486echo " Harness: {harness_name}"
487echo " Completion promise: {promise}"
488echo " Max iterations: $RALPH_MAX_ITER"
489echo ""
490
491while true; do
492 RALPH_ITER=$((RALPH_ITER + 1))
493 echo ""
494 echo "═══════════════════════════════════════════════════════════"
495 echo "🔄 RALPH ITERATION $RALPH_ITER / $RALPH_MAX_ITER"
496 echo "═══════════════════════════════════════════════════════════"
497 echo ""
498
499 # Run harness with the prompt (using full path)
500 {harness_cmd}
501
502 # Check if task is done
503 TASK_STATUS=$(scud show {task_id} 2>/dev/null | grep -i "status:" | awk '{{print $2}}')
504
505 if [ "$TASK_STATUS" = "done" ]; then
506 echo ""
507 echo "✅ Task {task_id} completed successfully after $RALPH_ITER iterations!"
508 rm -f '{prompt_file}'
509 break
510 fi
511
512 # Check max iterations
513 if [ $RALPH_ITER -ge $RALPH_MAX_ITER ]; then
514 echo ""
515 echo "⚠️ Ralph loop: Max iterations ($RALPH_MAX_ITER) reached for task {task_id}"
516 echo " Task status: $TASK_STATUS"
517 rm -f '{prompt_file}'
518 break
519 fi
520
521 # Small delay before next iteration
522 echo ""
523 echo "🔄 Task not yet complete (status: $TASK_STATUS). Continuing loop..."
524 sleep 2
525done
526"#,
527 task_id = task_id,
528 promise = completion_promise,
529 prompt_file = prompt_file.display(),
530 harness_name = harness.name(),
531 harness_cmd = harness_cmd,
532 );
533
534 let script_file = std::env::temp_dir().join(format!("scud-ralph-script-{}.sh", task_id));
536 std::fs::write(&script_file, &ralph_script)?;
537
538 let cmd = format!("bash '{}'", script_file.display());
540
541 let target = format!("{}:{}", session_name, window_index);
542 let send_result = Command::new("tmux")
543 .args(["send-keys", "-t", &target, &cmd, "Enter"])
544 .output()
545 .context("Failed to send command to tmux window")?;
546
547 if !send_result.status.success() {
548 anyhow::bail!(
549 "Failed to send keys: {}",
550 String::from_utf8_lossy(&send_result.stderr)
551 );
552 }
553
554 Ok(())
555}
556
557pub fn tmux_session_exists(session_name: &str) -> bool {
559 Command::new("tmux")
560 .args(["has-session", "-t", session_name])
561 .status()
562 .map(|s| s.success())
563 .unwrap_or(false)
564}
565
566pub fn tmux_attach(session_name: &str) -> Result<()> {
568 let status = Command::new("tmux")
570 .args(["attach", "-t", session_name])
571 .status()
572 .context("Failed to attach to tmux session")?;
573
574 if !status.success() {
575 anyhow::bail!("tmux attach failed");
576 }
577
578 Ok(())
579}
580
581pub fn setup_tmux_control_window(session_name: &str, tag: &str) -> Result<()> {
583 let control_script = format!(
584 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'"#,
585 session_name, tag, tag, tag
586 );
587
588 let target = format!("{}:ctrl", session_name);
589 Command::new("tmux")
590 .args(["send-keys", "-t", &target, &control_script, "Enter"])
591 .status()
592 .context("Failed to setup control window")?;
593
594 Ok(())
595}
596
597pub fn tmux_window_exists(session_name: &str, window_name: &str) -> bool {
599 let output = Command::new("tmux")
600 .args(["list-windows", "-t", session_name, "-F", "#{window_name}"])
601 .output();
602
603 match output {
604 Ok(out) if out.status.success() => {
605 let windows = String::from_utf8_lossy(&out.stdout);
606 windows
607 .lines()
608 .any(|w| w == window_name || w.starts_with(&format!("{}-", window_name)))
609 }
610 _ => false,
611 }
612}
613
614pub fn kill_tmux_window(session_name: &str, window_name: &str) -> Result<()> {
616 let target = format!("{}:{}", session_name, window_name);
617 Command::new("tmux")
618 .args(["kill-window", "-t", &target])
619 .output()?;
620 Ok(())
621}