1use anyhow::{Context, Result};
6use std::path::Path;
7use std::process::Command;
8
9#[derive(Debug, Clone, PartialEq)]
11pub enum Terminal {
12 Kitty,
13 Wezterm,
14 ITerm2,
15 Tmux,
16}
17
18impl Terminal {
19 pub fn name(&self) -> &'static str {
21 match self {
22 Terminal::Kitty => "kitty",
23 Terminal::Wezterm => "wezterm",
24 Terminal::ITerm2 => "iterm2",
25 Terminal::Tmux => "tmux",
26 }
27 }
28}
29
30pub fn detect_terminal() -> Terminal {
32 if std::env::var("KITTY_PID").is_ok() || std::env::var("KITTY_WINDOW_ID").is_ok() {
34 return Terminal::Kitty;
35 }
36
37 if std::env::var("WEZTERM_UNIX_SOCKET").is_ok() || std::env::var("WEZTERM_PANE").is_ok() {
39 return Terminal::Wezterm;
40 }
41
42 if std::env::var("ITERM_SESSION_ID").is_ok() {
44 return Terminal::ITerm2;
45 }
46
47 Terminal::Tmux
49}
50
51pub fn parse_terminal(name: &str) -> Result<Terminal> {
53 match name.to_lowercase().as_str() {
54 "kitty" => Ok(Terminal::Kitty),
55 "wezterm" => Ok(Terminal::Wezterm),
56 "iterm" | "iterm2" => Ok(Terminal::ITerm2),
57 "tmux" => Ok(Terminal::Tmux),
58 "auto" => Ok(detect_terminal()),
59 other => anyhow::bail!(
60 "Unknown terminal: {}. Supported: kitty, wezterm, iterm2, tmux, auto",
61 other
62 ),
63 }
64}
65
66pub fn check_terminal_available(terminal: &Terminal) -> Result<()> {
68 let binary = match terminal {
69 Terminal::Kitty => "kitty",
70 Terminal::Wezterm => "wezterm",
71 Terminal::ITerm2 => "osascript", Terminal::Tmux => "tmux",
73 };
74
75 let result = Command::new("which")
76 .arg(binary)
77 .output()
78 .context(format!("Failed to check for {} binary", binary))?;
79
80 if !result.status.success() {
81 anyhow::bail!("{} is not installed or not in PATH", binary);
82 }
83
84 Ok(())
85}
86
87pub fn spawn_terminal(
89 terminal: &Terminal,
90 task_id: &str,
91 prompt: &str,
92 working_dir: &Path,
93 session_name: &str,
94) -> Result<()> {
95 match terminal {
96 Terminal::Kitty => spawn_kitty(task_id, prompt, working_dir),
97 Terminal::Wezterm => spawn_wezterm(task_id, prompt, working_dir),
98 Terminal::ITerm2 => spawn_iterm2(task_id, prompt, working_dir),
99 Terminal::Tmux => spawn_tmux(task_id, prompt, working_dir, session_name),
100 }
101}
102
103fn spawn_kitty(task_id: &str, prompt: &str, working_dir: &Path) -> Result<()> {
105 let title = format!("task-{}", task_id);
106
107 let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
109 std::fs::write(&prompt_file, prompt)?;
110
111 let bash_cmd = format!(
114 r#"export SCUD_TASK_ID='{}' ; claude "$(cat '{}')" --dangerously-skip-permissions ; rm -f '{}' ; exec bash"#,
115 task_id,
116 prompt_file.display(),
117 prompt_file.display()
118 );
119
120 let status = Command::new("kitty")
121 .args(["@", "launch", "--type=window"])
122 .arg(format!("--title={}", title))
123 .arg(format!("--cwd={}", working_dir.display()))
124 .arg("bash")
125 .arg("-c")
126 .arg(&bash_cmd)
127 .status()
128 .context("Failed to spawn Kitty window")?;
129
130 if !status.success() {
131 anyhow::bail!("Kitty launch failed with exit code: {:?}", status.code());
132 }
133
134 Ok(())
135}
136
137fn spawn_wezterm(task_id: &str, prompt: &str, working_dir: &Path) -> Result<()> {
139 let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
141 std::fs::write(&prompt_file, prompt)?;
142
143 let bash_cmd = format!(
145 r#"export SCUD_TASK_ID='{}' ; claude "$(cat '{}')" --dangerously-skip-permissions ; rm -f '{}' ; exec bash"#,
146 task_id,
147 prompt_file.display(),
148 prompt_file.display()
149 );
150
151 let status = Command::new("wezterm")
152 .args(["cli", "spawn", "--new-window"])
153 .arg(format!("--cwd={}", working_dir.display()))
154 .arg("--")
155 .arg("bash")
156 .arg("-c")
157 .arg(&bash_cmd)
158 .status()
159 .context("Failed to spawn WezTerm window")?;
160
161 if !status.success() {
162 anyhow::bail!("WezTerm spawn failed with exit code: {:?}", status.code());
163 }
164
165 Ok(())
166}
167
168fn spawn_iterm2(task_id: &str, prompt: &str, working_dir: &Path) -> Result<()> {
170 let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
172 std::fs::write(&prompt_file, prompt)?;
173
174 let title = format!("task-{}", task_id);
175 let claude_cmd = format!(
177 r#"cd '{}' && export SCUD_TASK_ID='{}' && claude \"$(cat '{}')\" --dangerously-skip-permissions ; rm -f '{}'"#,
178 working_dir.display(),
179 task_id,
180 prompt_file.display(),
181 prompt_file.display()
182 );
183
184 let script = format!(
185 r#"tell application "iTerm"
186 create window with default profile
187 tell current session of current window
188 set name to "{}"
189 write text "{}"
190 end tell
191end tell"#,
192 title,
193 claude_cmd.replace('\\', "\\\\").replace('"', "\\\"")
194 );
195
196 let status = Command::new("osascript")
197 .arg("-e")
198 .arg(&script)
199 .status()
200 .context("Failed to spawn iTerm2 window")?;
201
202 if !status.success() {
203 anyhow::bail!("iTerm2 spawn failed with exit code: {:?}", status.code());
204 }
205
206 Ok(())
207}
208
209fn spawn_tmux(task_id: &str, prompt: &str, working_dir: &Path, session_name: &str) -> Result<()> {
211 let window_name = format!("task-{}", task_id);
212
213 let session_exists = Command::new("tmux")
215 .args(["has-session", "-t", session_name])
216 .status()
217 .map(|s| s.success())
218 .unwrap_or(false);
219
220 if !session_exists {
221 Command::new("tmux")
223 .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
224 .arg("-c")
225 .arg(working_dir)
226 .status()
227 .context("Failed to create tmux session")?;
228 }
229
230 let new_window_output = Command::new("tmux")
233 .args([
234 "new-window",
235 "-t",
236 session_name,
237 "-n",
238 &window_name,
239 "-P", "-F",
241 "#{window_index}", ])
243 .arg("-c")
244 .arg(working_dir)
245 .output()
246 .context("Failed to create tmux window")?;
247
248 if !new_window_output.status.success() {
249 anyhow::bail!(
250 "Failed to create window: {}",
251 String::from_utf8_lossy(&new_window_output.stderr)
252 );
253 }
254
255 let window_index = String::from_utf8_lossy(&new_window_output.stdout)
256 .trim()
257 .to_string();
258
259 let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
261 std::fs::write(&prompt_file, prompt)?;
262
263 let claude_cmd = format!(
266 r#"export SCUD_TASK_ID='{}' ; claude "$(cat '{}')" --dangerously-skip-permissions ; rm -f '{}'"#,
267 task_id,
268 prompt_file.display(),
269 prompt_file.display()
270 );
271
272 let target = format!("{}:{}", session_name, window_index);
273 let send_result = Command::new("tmux")
274 .args(["send-keys", "-t", &target, &claude_cmd, "Enter"])
275 .output()
276 .context("Failed to send command to tmux window")?;
277
278 if !send_result.status.success() {
279 anyhow::bail!(
280 "Failed to send keys: {}",
281 String::from_utf8_lossy(&send_result.stderr)
282 );
283 }
284
285 Ok(())
286}
287
288pub fn spawn_terminal_ralph(
291 terminal: &Terminal,
292 task_id: &str,
293 prompt: &str,
294 working_dir: &Path,
295 session_name: &str,
296 completion_promise: &str,
297) -> Result<()> {
298 match terminal {
299 Terminal::Tmux => spawn_tmux_ralph(
300 task_id,
301 prompt,
302 working_dir,
303 session_name,
304 completion_promise,
305 ),
306 _ => spawn_terminal(terminal, task_id, prompt, working_dir, session_name),
309 }
310}
311
312fn spawn_tmux_ralph(
314 task_id: &str,
315 prompt: &str,
316 working_dir: &Path,
317 session_name: &str,
318 completion_promise: &str,
319) -> Result<()> {
320 let window_name = format!("ralph-{}", task_id);
321
322 let session_exists = Command::new("tmux")
324 .args(["has-session", "-t", session_name])
325 .status()
326 .map(|s| s.success())
327 .unwrap_or(false);
328
329 if !session_exists {
330 Command::new("tmux")
332 .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
333 .arg("-c")
334 .arg(working_dir)
335 .status()
336 .context("Failed to create tmux session")?;
337 }
338
339 let new_window_output = Command::new("tmux")
341 .args([
342 "new-window",
343 "-t",
344 session_name,
345 "-n",
346 &window_name,
347 "-P",
348 "-F",
349 "#{window_index}",
350 ])
351 .arg("-c")
352 .arg(working_dir)
353 .output()
354 .context("Failed to create tmux window")?;
355
356 if !new_window_output.status.success() {
357 anyhow::bail!(
358 "Failed to create window: {}",
359 String::from_utf8_lossy(&new_window_output.stderr)
360 );
361 }
362
363 let window_index = String::from_utf8_lossy(&new_window_output.stdout)
364 .trim()
365 .to_string();
366
367 let prompt_file = std::env::temp_dir().join(format!("scud-ralph-{}.txt", task_id));
369 std::fs::write(&prompt_file, prompt)?;
370
371 let ralph_script = format!(
377 r#"
378export SCUD_TASK_ID='{task_id}'
379export RALPH_PROMISE='{promise}'
380export RALPH_MAX_ITER=50
381export RALPH_ITER=0
382
383echo "🔄 Ralph loop starting for task {task_id}"
384echo " Completion promise: {promise}"
385echo " Max iterations: $RALPH_MAX_ITER"
386echo ""
387
388while true; do
389 RALPH_ITER=$((RALPH_ITER + 1))
390 echo ""
391 echo "═══════════════════════════════════════════════════════════"
392 echo "🔄 RALPH ITERATION $RALPH_ITER / $RALPH_MAX_ITER"
393 echo "═══════════════════════════════════════════════════════════"
394 echo ""
395
396 # Run Claude with the prompt
397 claude "$(cat '{prompt_file}')" --dangerously-skip-permissions
398
399 # Check if task is done
400 TASK_STATUS=$(scud show {task_id} 2>/dev/null | grep -i "status:" | awk '{{print $2}}')
401
402 if [ "$TASK_STATUS" = "done" ]; then
403 echo ""
404 echo "✅ Task {task_id} completed successfully after $RALPH_ITER iterations!"
405 rm -f '{prompt_file}'
406 break
407 fi
408
409 # Check max iterations
410 if [ $RALPH_ITER -ge $RALPH_MAX_ITER ]; then
411 echo ""
412 echo "⚠️ Ralph loop: Max iterations ($RALPH_MAX_ITER) reached for task {task_id}"
413 echo " Task status: $TASK_STATUS"
414 rm -f '{prompt_file}'
415 break
416 fi
417
418 # Small delay before next iteration
419 echo ""
420 echo "🔄 Task not yet complete (status: $TASK_STATUS). Continuing loop..."
421 sleep 2
422done
423"#,
424 task_id = task_id,
425 promise = completion_promise,
426 prompt_file = prompt_file.display(),
427 );
428
429 let script_file = std::env::temp_dir().join(format!("scud-ralph-script-{}.sh", task_id));
431 std::fs::write(&script_file, &ralph_script)?;
432
433 let cmd = format!(
435 "chmod +x '{}' && '{}'",
436 script_file.display(),
437 script_file.display()
438 );
439
440 let target = format!("{}:{}", session_name, window_index);
441 let send_result = Command::new("tmux")
442 .args(["send-keys", "-t", &target, &cmd, "Enter"])
443 .output()
444 .context("Failed to send command to tmux window")?;
445
446 if !send_result.status.success() {
447 anyhow::bail!(
448 "Failed to send keys: {}",
449 String::from_utf8_lossy(&send_result.stderr)
450 );
451 }
452
453 Ok(())
454}
455
456pub fn tmux_session_exists(session_name: &str) -> bool {
458 Command::new("tmux")
459 .args(["has-session", "-t", session_name])
460 .status()
461 .map(|s| s.success())
462 .unwrap_or(false)
463}
464
465pub fn tmux_attach(session_name: &str) -> Result<()> {
467 let status = Command::new("tmux")
469 .args(["attach", "-t", session_name])
470 .status()
471 .context("Failed to attach to tmux session")?;
472
473 if !status.success() {
474 anyhow::bail!("tmux attach failed");
475 }
476
477 Ok(())
478}
479
480pub fn setup_tmux_control_window(session_name: &str, tag: &str) -> Result<()> {
482 let control_script = format!(
483 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'"#,
484 session_name, tag, tag, tag
485 );
486
487 let target = format!("{}:ctrl", session_name);
488 Command::new("tmux")
489 .args(["send-keys", "-t", &target, &control_script, "Enter"])
490 .status()
491 .context("Failed to setup control window")?;
492
493 Ok(())
494}