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 let full_cmd = format!(
304 r#"source ~/.bash_profile 2>/dev/null; source ~/.zshrc 2>/dev/null; export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"; [ -s "$HOME/.nvm/nvm.sh" ] && source "$HOME/.nvm/nvm.sh"; export SCUD_TASK_ID='{}' ; {} ; rm -f '{}'"#,
305 task_id,
306 harness_cmd,
307 prompt_file.display()
308 );
309
310 let target = format!("{}:{}", session_name, window_index);
311 let send_result = Command::new("tmux")
312 .args(["send-keys", "-t", &target, &full_cmd, "Enter"])
313 .output()
314 .context("Failed to send command to tmux window")?;
315
316 if !send_result.status.success() {
317 anyhow::bail!(
318 "Failed to send keys: {}",
319 String::from_utf8_lossy(&send_result.stderr)
320 );
321 }
322
323 Ok(window_index)
324}
325
326pub fn spawn_terminal_ralph(
329 task_id: &str,
330 prompt: &str,
331 working_dir: &Path,
332 session_name: &str,
333 completion_promise: &str,
334) -> Result<()> {
335 spawn_terminal_ralph_with_harness(
337 task_id,
338 prompt,
339 working_dir,
340 session_name,
341 completion_promise,
342 Harness::Claude,
343 )
344}
345
346pub fn spawn_terminal_ralph_with_harness(
348 task_id: &str,
349 prompt: &str,
350 working_dir: &Path,
351 session_name: &str,
352 completion_promise: &str,
353 harness: Harness,
354) -> Result<()> {
355 let binary_path = find_harness_binary(harness)?;
357 spawn_tmux_ralph(
358 task_id,
359 prompt,
360 working_dir,
361 session_name,
362 completion_promise,
363 binary_path,
364 harness,
365 )
366}
367
368fn spawn_tmux_ralph(
370 task_id: &str,
371 prompt: &str,
372 working_dir: &Path,
373 session_name: &str,
374 completion_promise: &str,
375 binary_path: &str,
376 harness: Harness,
377) -> Result<()> {
378 let window_name = format!("ralph-{}", task_id);
379
380 let session_exists = Command::new("tmux")
382 .args(["has-session", "-t", session_name])
383 .status()
384 .map(|s| s.success())
385 .unwrap_or(false);
386
387 if !session_exists {
388 Command::new("tmux")
390 .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
391 .arg("-c")
392 .arg(working_dir)
393 .status()
394 .context("Failed to create tmux session")?;
395 }
396
397 let new_window_output = Command::new("tmux")
399 .args([
400 "new-window",
401 "-t",
402 session_name,
403 "-n",
404 &window_name,
405 "-P",
406 "-F",
407 "#{window_index}",
408 ])
409 .arg("-c")
410 .arg(working_dir)
411 .output()
412 .context("Failed to create tmux window")?;
413
414 if !new_window_output.status.success() {
415 anyhow::bail!(
416 "Failed to create window: {}",
417 String::from_utf8_lossy(&new_window_output.stderr)
418 );
419 }
420
421 let window_index = String::from_utf8_lossy(&new_window_output.stdout)
422 .trim()
423 .to_string();
424
425 let prompt_file = std::env::temp_dir().join(format!("scud-ralph-{}.txt", task_id));
427 std::fs::write(&prompt_file, prompt)?;
428
429 let harness_cmd = match harness {
432 Harness::Claude => format!(
433 "'{binary_path}' \"$(cat '{prompt_file}')\" --dangerously-skip-permissions",
434 binary_path = binary_path,
435 prompt_file = prompt_file.display()
436 ),
437 Harness::OpenCode => format!(
438 "'{binary_path}' run \"$(cat '{prompt_file}')\"",
439 binary_path = binary_path,
440 prompt_file = prompt_file.display()
441 ),
442 };
443
444 let ralph_script = format!(
452 r#"
453# Source shell profile for PATH setup
454[ -f /etc/profile ] && . /etc/profile
455[ -f ~/.profile ] && . ~/.profile
456[ -f ~/.bash_profile ] && . ~/.bash_profile
457[ -f ~/.bashrc ] && . ~/.bashrc
458[ -f ~/.zshrc ] && . ~/.zshrc 2>/dev/null
459export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
460[ -s "$HOME/.nvm/nvm.sh" ] && . "$HOME/.nvm/nvm.sh"
461[ -s "$HOME/.bun/_bun" ] && . "$HOME/.bun/_bun"
462
463export SCUD_TASK_ID='{task_id}'
464export RALPH_PROMISE='{promise}'
465export RALPH_MAX_ITER=50
466export RALPH_ITER=0
467
468echo "🔄 Ralph loop starting for task {task_id}"
469echo " Harness: {harness_name}"
470echo " Completion promise: {promise}"
471echo " Max iterations: $RALPH_MAX_ITER"
472echo ""
473
474while true; do
475 RALPH_ITER=$((RALPH_ITER + 1))
476 echo ""
477 echo "═══════════════════════════════════════════════════════════"
478 echo "🔄 RALPH ITERATION $RALPH_ITER / $RALPH_MAX_ITER"
479 echo "═══════════════════════════════════════════════════════════"
480 echo ""
481
482 # Run harness with the prompt (using full path)
483 {harness_cmd}
484
485 # Check if task is done
486 TASK_STATUS=$(scud show {task_id} 2>/dev/null | grep -i "status:" | awk '{{print $2}}')
487
488 if [ "$TASK_STATUS" = "done" ]; then
489 echo ""
490 echo "✅ Task {task_id} completed successfully after $RALPH_ITER iterations!"
491 rm -f '{prompt_file}'
492 break
493 fi
494
495 # Check max iterations
496 if [ $RALPH_ITER -ge $RALPH_MAX_ITER ]; then
497 echo ""
498 echo "⚠️ Ralph loop: Max iterations ($RALPH_MAX_ITER) reached for task {task_id}"
499 echo " Task status: $TASK_STATUS"
500 rm -f '{prompt_file}'
501 break
502 fi
503
504 # Small delay before next iteration
505 echo ""
506 echo "🔄 Task not yet complete (status: $TASK_STATUS). Continuing loop..."
507 sleep 2
508done
509"#,
510 task_id = task_id,
511 promise = completion_promise,
512 prompt_file = prompt_file.display(),
513 harness_name = harness.name(),
514 harness_cmd = harness_cmd,
515 );
516
517 let script_file = std::env::temp_dir().join(format!("scud-ralph-script-{}.sh", task_id));
519 std::fs::write(&script_file, &ralph_script)?;
520
521 let cmd = format!(
523 "chmod +x '{}' && '{}'",
524 script_file.display(),
525 script_file.display()
526 );
527
528 let target = format!("{}:{}", session_name, window_index);
529 let send_result = Command::new("tmux")
530 .args(["send-keys", "-t", &target, &cmd, "Enter"])
531 .output()
532 .context("Failed to send command to tmux window")?;
533
534 if !send_result.status.success() {
535 anyhow::bail!(
536 "Failed to send keys: {}",
537 String::from_utf8_lossy(&send_result.stderr)
538 );
539 }
540
541 Ok(())
542}
543
544pub fn tmux_session_exists(session_name: &str) -> bool {
546 Command::new("tmux")
547 .args(["has-session", "-t", session_name])
548 .status()
549 .map(|s| s.success())
550 .unwrap_or(false)
551}
552
553pub fn tmux_attach(session_name: &str) -> Result<()> {
555 let status = Command::new("tmux")
557 .args(["attach", "-t", session_name])
558 .status()
559 .context("Failed to attach to tmux session")?;
560
561 if !status.success() {
562 anyhow::bail!("tmux attach failed");
563 }
564
565 Ok(())
566}
567
568pub fn setup_tmux_control_window(session_name: &str, tag: &str) -> Result<()> {
570 let control_script = format!(
571 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'"#,
572 session_name, tag, tag, tag
573 );
574
575 let target = format!("{}:ctrl", session_name);
576 Command::new("tmux")
577 .args(["send-keys", "-t", &target, &control_script, "Enter"])
578 .status()
579 .context("Failed to setup control window")?;
580
581 Ok(())
582}