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!("Unknown terminal: {}. Supported: kitty, wezterm, iterm2, tmux, auto", other),
60 }
61}
62
63pub fn check_terminal_available(terminal: &Terminal) -> Result<()> {
65 let binary = match terminal {
66 Terminal::Kitty => "kitty",
67 Terminal::Wezterm => "wezterm",
68 Terminal::ITerm2 => "osascript", Terminal::Tmux => "tmux",
70 };
71
72 let result = Command::new("which")
73 .arg(binary)
74 .output()
75 .context(format!("Failed to check for {} binary", binary))?;
76
77 if !result.status.success() {
78 anyhow::bail!("{} is not installed or not in PATH", binary);
79 }
80
81 Ok(())
82}
83
84pub fn spawn_terminal(
86 terminal: &Terminal,
87 task_id: &str,
88 prompt: &str,
89 working_dir: &Path,
90 session_name: &str,
91) -> Result<()> {
92 match terminal {
93 Terminal::Kitty => spawn_kitty(task_id, prompt, working_dir),
94 Terminal::Wezterm => spawn_wezterm(task_id, prompt, working_dir),
95 Terminal::ITerm2 => spawn_iterm2(task_id, prompt, working_dir),
96 Terminal::Tmux => spawn_tmux(task_id, prompt, working_dir, session_name),
97 }
98}
99
100fn spawn_kitty(task_id: &str, prompt: &str, working_dir: &Path) -> Result<()> {
102 let title = format!("task-{}", task_id);
103
104 let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
106 std::fs::write(&prompt_file, prompt)?;
107
108 let bash_cmd = format!(
111 r#"export SCUD_TASK_ID='{}' ; claude "$(cat '{}')" --dangerously-skip-permissions ; rm -f '{}' ; exec bash"#,
112 task_id,
113 prompt_file.display(),
114 prompt_file.display()
115 );
116
117 let status = Command::new("kitty")
118 .args(["@", "launch", "--type=window"])
119 .arg(format!("--title={}", title))
120 .arg(format!("--cwd={}", working_dir.display()))
121 .arg("bash")
122 .arg("-c")
123 .arg(&bash_cmd)
124 .status()
125 .context("Failed to spawn Kitty window")?;
126
127 if !status.success() {
128 anyhow::bail!("Kitty launch failed with exit code: {:?}", status.code());
129 }
130
131 Ok(())
132}
133
134fn spawn_wezterm(task_id: &str, prompt: &str, working_dir: &Path) -> Result<()> {
136 let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
138 std::fs::write(&prompt_file, prompt)?;
139
140 let bash_cmd = format!(
142 r#"export SCUD_TASK_ID='{}' ; claude "$(cat '{}')" --dangerously-skip-permissions ; rm -f '{}' ; exec bash"#,
143 task_id,
144 prompt_file.display(),
145 prompt_file.display()
146 );
147
148 let status = Command::new("wezterm")
149 .args(["cli", "spawn", "--new-window"])
150 .arg(format!("--cwd={}", working_dir.display()))
151 .arg("--")
152 .arg("bash")
153 .arg("-c")
154 .arg(&bash_cmd)
155 .status()
156 .context("Failed to spawn WezTerm window")?;
157
158 if !status.success() {
159 anyhow::bail!("WezTerm spawn failed with exit code: {:?}", status.code());
160 }
161
162 Ok(())
163}
164
165fn spawn_iterm2(task_id: &str, prompt: &str, working_dir: &Path) -> Result<()> {
167 let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
169 std::fs::write(&prompt_file, prompt)?;
170
171 let title = format!("task-{}", task_id);
172 let claude_cmd = format!(
174 r#"cd '{}' && export SCUD_TASK_ID='{}' && claude \"$(cat '{}')\" --dangerously-skip-permissions ; rm -f '{}'"#,
175 working_dir.display(),
176 task_id,
177 prompt_file.display(),
178 prompt_file.display()
179 );
180
181 let script = format!(
182 r#"tell application "iTerm"
183 create window with default profile
184 tell current session of current window
185 set name to "{}"
186 write text "{}"
187 end tell
188end tell"#,
189 title,
190 claude_cmd.replace('\\', "\\\\").replace('"', "\\\"")
191 );
192
193 let status = Command::new("osascript")
194 .arg("-e")
195 .arg(&script)
196 .status()
197 .context("Failed to spawn iTerm2 window")?;
198
199 if !status.success() {
200 anyhow::bail!("iTerm2 spawn failed with exit code: {:?}", status.code());
201 }
202
203 Ok(())
204}
205
206fn spawn_tmux(task_id: &str, prompt: &str, working_dir: &Path, session_name: &str) -> Result<()> {
208 let window_name = format!("task-{}", task_id);
209
210 let session_exists = Command::new("tmux")
212 .args(["has-session", "-t", session_name])
213 .status()
214 .map(|s| s.success())
215 .unwrap_or(false);
216
217 if !session_exists {
218 Command::new("tmux")
220 .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
221 .arg("-c")
222 .arg(working_dir)
223 .status()
224 .context("Failed to create tmux session")?;
225 }
226
227 let new_window_output = Command::new("tmux")
230 .args([
231 "new-window",
232 "-t", session_name,
233 "-n", &window_name,
234 "-P", "-F", "#{window_index}", ])
237 .arg("-c")
238 .arg(working_dir)
239 .output()
240 .context("Failed to create tmux window")?;
241
242 if !new_window_output.status.success() {
243 anyhow::bail!(
244 "Failed to create window: {}",
245 String::from_utf8_lossy(&new_window_output.stderr)
246 );
247 }
248
249 let window_index = String::from_utf8_lossy(&new_window_output.stdout)
250 .trim()
251 .to_string();
252
253 let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
255 std::fs::write(&prompt_file, prompt)?;
256
257 let claude_cmd = format!(
260 r#"export SCUD_TASK_ID='{}' ; claude "$(cat '{}')" --dangerously-skip-permissions ; rm -f '{}'"#,
261 task_id,
262 prompt_file.display(),
263 prompt_file.display()
264 );
265
266 let target = format!("{}:{}", session_name, window_index);
267 let send_result = Command::new("tmux")
268 .args(["send-keys", "-t", &target, &claude_cmd, "Enter"])
269 .output()
270 .context("Failed to send command to tmux window")?;
271
272 if !send_result.status.success() {
273 anyhow::bail!(
274 "Failed to send keys: {}",
275 String::from_utf8_lossy(&send_result.stderr)
276 );
277 }
278
279 Ok(())
280}
281
282pub fn spawn_terminal_ralph(
285 terminal: &Terminal,
286 task_id: &str,
287 prompt: &str,
288 working_dir: &Path,
289 session_name: &str,
290 completion_promise: &str,
291) -> Result<()> {
292 match terminal {
293 Terminal::Tmux => spawn_tmux_ralph(task_id, prompt, working_dir, session_name, completion_promise),
294 _ => spawn_terminal(terminal, task_id, prompt, working_dir, session_name),
297 }
298}
299
300fn spawn_tmux_ralph(
302 task_id: &str,
303 prompt: &str,
304 working_dir: &Path,
305 session_name: &str,
306 completion_promise: &str,
307) -> Result<()> {
308 let window_name = format!("ralph-{}", task_id);
309
310 let session_exists = Command::new("tmux")
312 .args(["has-session", "-t", session_name])
313 .status()
314 .map(|s| s.success())
315 .unwrap_or(false);
316
317 if !session_exists {
318 Command::new("tmux")
320 .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
321 .arg("-c")
322 .arg(working_dir)
323 .status()
324 .context("Failed to create tmux session")?;
325 }
326
327 let new_window_output = Command::new("tmux")
329 .args([
330 "new-window",
331 "-t", session_name,
332 "-n", &window_name,
333 "-P",
334 "-F", "#{window_index}",
335 ])
336 .arg("-c")
337 .arg(working_dir)
338 .output()
339 .context("Failed to create tmux window")?;
340
341 if !new_window_output.status.success() {
342 anyhow::bail!(
343 "Failed to create window: {}",
344 String::from_utf8_lossy(&new_window_output.stderr)
345 );
346 }
347
348 let window_index = String::from_utf8_lossy(&new_window_output.stdout)
349 .trim()
350 .to_string();
351
352 let prompt_file = std::env::temp_dir().join(format!("scud-ralph-{}.txt", task_id));
354 std::fs::write(&prompt_file, prompt)?;
355
356 let ralph_script = format!(
362 r#"
363export SCUD_TASK_ID='{task_id}'
364export RALPH_PROMISE='{promise}'
365export RALPH_MAX_ITER=50
366export RALPH_ITER=0
367
368echo "🔄 Ralph loop starting for task {task_id}"
369echo " Completion promise: {promise}"
370echo " Max iterations: $RALPH_MAX_ITER"
371echo ""
372
373while true; do
374 RALPH_ITER=$((RALPH_ITER + 1))
375 echo ""
376 echo "═══════════════════════════════════════════════════════════"
377 echo "🔄 RALPH ITERATION $RALPH_ITER / $RALPH_MAX_ITER"
378 echo "═══════════════════════════════════════════════════════════"
379 echo ""
380
381 # Run Claude with the prompt
382 claude "$(cat '{prompt_file}')" --dangerously-skip-permissions
383
384 # Check if task is done
385 TASK_STATUS=$(scud show {task_id} 2>/dev/null | grep -i "status:" | awk '{{print $2}}')
386
387 if [ "$TASK_STATUS" = "done" ]; then
388 echo ""
389 echo "✅ Task {task_id} completed successfully after $RALPH_ITER iterations!"
390 rm -f '{prompt_file}'
391 break
392 fi
393
394 # Check max iterations
395 if [ $RALPH_ITER -ge $RALPH_MAX_ITER ]; then
396 echo ""
397 echo "⚠️ Ralph loop: Max iterations ($RALPH_MAX_ITER) reached for task {task_id}"
398 echo " Task status: $TASK_STATUS"
399 rm -f '{prompt_file}'
400 break
401 fi
402
403 # Small delay before next iteration
404 echo ""
405 echo "🔄 Task not yet complete (status: $TASK_STATUS). Continuing loop..."
406 sleep 2
407done
408"#,
409 task_id = task_id,
410 promise = completion_promise,
411 prompt_file = prompt_file.display(),
412 );
413
414 let script_file = std::env::temp_dir().join(format!("scud-ralph-script-{}.sh", task_id));
416 std::fs::write(&script_file, &ralph_script)?;
417
418 let cmd = format!(
420 "chmod +x '{}' && '{}'",
421 script_file.display(),
422 script_file.display()
423 );
424
425 let target = format!("{}:{}", session_name, window_index);
426 let send_result = Command::new("tmux")
427 .args(["send-keys", "-t", &target, &cmd, "Enter"])
428 .output()
429 .context("Failed to send command to tmux window")?;
430
431 if !send_result.status.success() {
432 anyhow::bail!(
433 "Failed to send keys: {}",
434 String::from_utf8_lossy(&send_result.stderr)
435 );
436 }
437
438 Ok(())
439}
440
441pub fn tmux_session_exists(session_name: &str) -> bool {
443 Command::new("tmux")
444 .args(["has-session", "-t", session_name])
445 .status()
446 .map(|s| s.success())
447 .unwrap_or(false)
448}
449
450pub fn tmux_attach(session_name: &str) -> Result<()> {
452 let status = Command::new("tmux")
454 .args(["attach", "-t", session_name])
455 .status()
456 .context("Failed to attach to tmux session")?;
457
458 if !status.success() {
459 anyhow::bail!("tmux attach failed");
460 }
461
462 Ok(())
463}
464
465pub fn setup_tmux_control_window(session_name: &str, tag: &str) -> Result<()> {
467 let control_script = format!(
468 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'"#,
469 session_name, tag, tag, tag
470 );
471
472 let target = format!("{}:ctrl", session_name);
473 Command::new("tmux")
474 .args(["send-keys", "-t", &target, &control_script, "Enter"])
475 .status()
476 .context("Failed to setup control window")?;
477
478 Ok(())
479}