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 Zellij,
16 Tmux,
17}
18
19impl Terminal {
20 pub fn name(&self) -> &'static str {
22 match self {
23 Terminal::Kitty => "kitty",
24 Terminal::Wezterm => "wezterm",
25 Terminal::ITerm2 => "iterm2",
26 Terminal::Zellij => "zellij",
27 Terminal::Tmux => "tmux",
28 }
29 }
30}
31
32pub fn detect_terminal() -> Terminal {
34 if std::env::var("KITTY_PID").is_ok() || std::env::var("KITTY_WINDOW_ID").is_ok() {
36 return Terminal::Kitty;
37 }
38
39 if std::env::var("WEZTERM_UNIX_SOCKET").is_ok() || std::env::var("WEZTERM_PANE").is_ok() {
41 return Terminal::Wezterm;
42 }
43
44 if std::env::var("ITERM_SESSION_ID").is_ok() {
46 return Terminal::ITerm2;
47 }
48
49 if std::env::var("ZELLIJ").is_ok() || std::env::var("ZELLIJ_SESSION_NAME").is_ok() {
51 return Terminal::Zellij;
52 }
53
54 Terminal::Tmux
56}
57
58pub fn parse_terminal(name: &str) -> Result<Terminal> {
60 match name.to_lowercase().as_str() {
61 "kitty" => Ok(Terminal::Kitty),
62 "wezterm" => Ok(Terminal::Wezterm),
63 "iterm" | "iterm2" => Ok(Terminal::ITerm2),
64 "zellij" => Ok(Terminal::Zellij),
65 "tmux" => Ok(Terminal::Tmux),
66 "auto" => Ok(detect_terminal()),
67 other => anyhow::bail!(
68 "Unknown terminal: {}. Supported: kitty, wezterm, iterm2, zellij, tmux, auto",
69 other
70 ),
71 }
72}
73
74pub fn check_terminal_available(terminal: &Terminal) -> Result<()> {
76 let binary = match terminal {
77 Terminal::Kitty => "kitty",
78 Terminal::Wezterm => "wezterm",
79 Terminal::ITerm2 => "osascript", Terminal::Zellij => "zellij",
81 Terminal::Tmux => "tmux",
82 };
83
84 let result = Command::new("which")
85 .arg(binary)
86 .output()
87 .context(format!("Failed to check for {} binary", binary))?;
88
89 if !result.status.success() {
90 anyhow::bail!("{} is not installed or not in PATH", binary);
91 }
92
93 Ok(())
94}
95
96pub fn spawn_terminal(
98 terminal: &Terminal,
99 task_id: &str,
100 prompt: &str,
101 working_dir: &Path,
102 session_name: &str,
103) -> Result<()> {
104 match terminal {
105 Terminal::Kitty => spawn_kitty(task_id, prompt, working_dir),
106 Terminal::Wezterm => spawn_wezterm(task_id, prompt, working_dir),
107 Terminal::ITerm2 => spawn_iterm2(task_id, prompt, working_dir),
108 Terminal::Zellij => spawn_zellij(task_id, prompt, working_dir, session_name),
109 Terminal::Tmux => spawn_tmux(task_id, prompt, working_dir, session_name),
110 }
111}
112
113fn spawn_kitty(task_id: &str, prompt: &str, working_dir: &Path) -> Result<()> {
115 let title = format!("task-{}", task_id);
116
117 let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
119 std::fs::write(&prompt_file, prompt)?;
120
121 let bash_cmd = format!(
124 r#"export SCUD_TASK_ID='{}' ; claude "$(cat '{}')" --dangerously-skip-permissions ; rm -f '{}' ; exec bash"#,
125 task_id,
126 prompt_file.display(),
127 prompt_file.display()
128 );
129
130 let status = Command::new("kitty")
131 .args(["@", "launch", "--type=window"])
132 .arg(format!("--title={}", title))
133 .arg(format!("--cwd={}", working_dir.display()))
134 .arg("bash")
135 .arg("-c")
136 .arg(&bash_cmd)
137 .status()
138 .context("Failed to spawn Kitty window")?;
139
140 if !status.success() {
141 anyhow::bail!("Kitty launch failed with exit code: {:?}", status.code());
142 }
143
144 Ok(())
145}
146
147fn spawn_wezterm(task_id: &str, prompt: &str, working_dir: &Path) -> Result<()> {
149 let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
151 std::fs::write(&prompt_file, prompt)?;
152
153 let bash_cmd = format!(
155 r#"export SCUD_TASK_ID='{}' ; claude "$(cat '{}')" --dangerously-skip-permissions ; rm -f '{}' ; exec bash"#,
156 task_id,
157 prompt_file.display(),
158 prompt_file.display()
159 );
160
161 let status = Command::new("wezterm")
162 .args(["cli", "spawn", "--new-window"])
163 .arg(format!("--cwd={}", working_dir.display()))
164 .arg("--")
165 .arg("bash")
166 .arg("-c")
167 .arg(&bash_cmd)
168 .status()
169 .context("Failed to spawn WezTerm window")?;
170
171 if !status.success() {
172 anyhow::bail!("WezTerm spawn failed with exit code: {:?}", status.code());
173 }
174
175 Ok(())
176}
177
178fn spawn_iterm2(task_id: &str, prompt: &str, working_dir: &Path) -> Result<()> {
180 let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
182 std::fs::write(&prompt_file, prompt)?;
183
184 let title = format!("task-{}", task_id);
185 let claude_cmd = format!(
187 r#"cd '{}' && export SCUD_TASK_ID='{}' && claude \"$(cat '{}')\" --dangerously-skip-permissions ; rm -f '{}'"#,
188 working_dir.display(),
189 task_id,
190 prompt_file.display(),
191 prompt_file.display()
192 );
193
194 let script = format!(
195 r#"tell application "iTerm"
196 create window with default profile
197 tell current session of current window
198 set name to "{}"
199 write text "{}"
200 end tell
201end tell"#,
202 title,
203 claude_cmd.replace('\\', "\\\\").replace('"', "\\\"")
204 );
205
206 let status = Command::new("osascript")
207 .arg("-e")
208 .arg(&script)
209 .status()
210 .context("Failed to spawn iTerm2 window")?;
211
212 if !status.success() {
213 anyhow::bail!("iTerm2 spawn failed with exit code: {:?}", status.code());
214 }
215
216 Ok(())
217}
218
219fn spawn_zellij(
225 task_id: &str,
226 prompt: &str,
227 working_dir: &Path,
228 session_name: &str,
229) -> Result<()> {
230 let pane_name = format!("task-{}", task_id);
231
232 let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
234 std::fs::write(&prompt_file, prompt)?;
235
236 let inside_zellij = std::env::var("ZELLIJ").is_ok();
238
239 if inside_zellij {
240 let _ = Command::new("zellij")
246 .args(["action", "new-tab", "--name", session_name])
247 .output();
248
249 let bash_cmd = format!(
251 r#"cd '{}' && export SCUD_TASK_ID='{}' ; claude "$(cat '{}')" --dangerously-skip-permissions ; rm -f '{}' ; exec bash"#,
252 working_dir.display(),
253 task_id,
254 prompt_file.display(),
255 prompt_file.display()
256 );
257
258 let status = Command::new("zellij")
260 .args([
261 "action",
262 "new-pane",
263 "--name",
264 &pane_name,
265 "--direction",
266 "right",
267 "--",
268 "bash",
269 "-c",
270 &bash_cmd,
271 ])
272 .status()
273 .context("Failed to spawn Zellij pane")?;
274
275 if !status.success() {
276 anyhow::bail!("Zellij pane spawn failed with exit code: {:?}", status.code());
277 }
278 } else {
279 let bash_cmd = format!(
283 r#"cd '{}' && export SCUD_TASK_ID='{}' ; claude "$(cat '{}')" --dangerously-skip-permissions ; rm -f '{}' ; exec bash"#,
284 working_dir.display(),
285 task_id,
286 prompt_file.display(),
287 prompt_file.display()
288 );
289
290 let status = Command::new("zellij")
292 .args([
293 "--session",
294 session_name,
295 "run",
296 "--name",
297 &pane_name,
298 "--",
299 "bash",
300 "-c",
301 &bash_cmd,
302 ])
303 .current_dir(working_dir)
304 .status()
305 .context("Failed to start Zellij session")?;
306
307 if !status.success() {
308 anyhow::bail!(
309 "Zellij session start failed with exit code: {:?}",
310 status.code()
311 );
312 }
313 }
314
315 Ok(())
316}
317
318pub fn focus_zellij_pane(session_name: &str) -> Result<()> {
322 let inside_zellij = std::env::var("ZELLIJ").is_ok();
324
325 if inside_zellij {
326 let status = Command::new("zellij")
328 .args(["action", "go-to-tab-name", session_name])
329 .status()
330 .context("Failed to switch Zellij tab")?;
331
332 if !status.success() {
333 anyhow::bail!(
334 "Failed to switch to Zellij tab '{}': exit code {:?}",
335 session_name,
336 status.code()
337 );
338 }
339 } else {
340 let status = Command::new("zellij")
342 .args(["attach", session_name])
343 .status()
344 .context("Failed to attach to Zellij session")?;
345
346 if !status.success() {
347 anyhow::bail!(
348 "Failed to attach to Zellij session '{}': exit code {:?}",
349 session_name,
350 status.code()
351 );
352 }
353 }
354
355 Ok(())
356}
357
358pub fn zellij_session_exists(session_name: &str) -> bool {
360 Command::new("zellij")
361 .args(["list-sessions"])
362 .output()
363 .map(|output| {
364 String::from_utf8_lossy(&output.stdout)
365 .lines()
366 .any(|line| line.trim() == session_name || line.starts_with(&format!("{} ", session_name)))
367 })
368 .unwrap_or(false)
369}
370
371fn spawn_tmux(task_id: &str, prompt: &str, working_dir: &Path, session_name: &str) -> Result<()> {
373 let window_name = format!("task-{}", task_id);
374
375 let session_exists = Command::new("tmux")
377 .args(["has-session", "-t", session_name])
378 .status()
379 .map(|s| s.success())
380 .unwrap_or(false);
381
382 if !session_exists {
383 Command::new("tmux")
385 .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
386 .arg("-c")
387 .arg(working_dir)
388 .status()
389 .context("Failed to create tmux session")?;
390 }
391
392 let new_window_output = Command::new("tmux")
395 .args([
396 "new-window",
397 "-t",
398 session_name,
399 "-n",
400 &window_name,
401 "-P", "-F",
403 "#{window_index}", ])
405 .arg("-c")
406 .arg(working_dir)
407 .output()
408 .context("Failed to create tmux window")?;
409
410 if !new_window_output.status.success() {
411 anyhow::bail!(
412 "Failed to create window: {}",
413 String::from_utf8_lossy(&new_window_output.stderr)
414 );
415 }
416
417 let window_index = String::from_utf8_lossy(&new_window_output.stdout)
418 .trim()
419 .to_string();
420
421 let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
423 std::fs::write(&prompt_file, prompt)?;
424
425 let claude_cmd = format!(
428 r#"export SCUD_TASK_ID='{}' ; claude "$(cat '{}')" --dangerously-skip-permissions ; rm -f '{}'"#,
429 task_id,
430 prompt_file.display(),
431 prompt_file.display()
432 );
433
434 let target = format!("{}:{}", session_name, window_index);
435 let send_result = Command::new("tmux")
436 .args(["send-keys", "-t", &target, &claude_cmd, "Enter"])
437 .output()
438 .context("Failed to send command to tmux window")?;
439
440 if !send_result.status.success() {
441 anyhow::bail!(
442 "Failed to send keys: {}",
443 String::from_utf8_lossy(&send_result.stderr)
444 );
445 }
446
447 Ok(())
448}
449
450pub fn spawn_terminal_ralph(
453 terminal: &Terminal,
454 task_id: &str,
455 prompt: &str,
456 working_dir: &Path,
457 session_name: &str,
458 completion_promise: &str,
459) -> Result<()> {
460 match terminal {
461 Terminal::Tmux => spawn_tmux_ralph(
462 task_id,
463 prompt,
464 working_dir,
465 session_name,
466 completion_promise,
467 ),
468 _ => spawn_terminal(terminal, task_id, prompt, working_dir, session_name),
471 }
472}
473
474fn spawn_tmux_ralph(
476 task_id: &str,
477 prompt: &str,
478 working_dir: &Path,
479 session_name: &str,
480 completion_promise: &str,
481) -> Result<()> {
482 let window_name = format!("ralph-{}", task_id);
483
484 let session_exists = Command::new("tmux")
486 .args(["has-session", "-t", session_name])
487 .status()
488 .map(|s| s.success())
489 .unwrap_or(false);
490
491 if !session_exists {
492 Command::new("tmux")
494 .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
495 .arg("-c")
496 .arg(working_dir)
497 .status()
498 .context("Failed to create tmux session")?;
499 }
500
501 let new_window_output = Command::new("tmux")
503 .args([
504 "new-window",
505 "-t",
506 session_name,
507 "-n",
508 &window_name,
509 "-P",
510 "-F",
511 "#{window_index}",
512 ])
513 .arg("-c")
514 .arg(working_dir)
515 .output()
516 .context("Failed to create tmux window")?;
517
518 if !new_window_output.status.success() {
519 anyhow::bail!(
520 "Failed to create window: {}",
521 String::from_utf8_lossy(&new_window_output.stderr)
522 );
523 }
524
525 let window_index = String::from_utf8_lossy(&new_window_output.stdout)
526 .trim()
527 .to_string();
528
529 let prompt_file = std::env::temp_dir().join(format!("scud-ralph-{}.txt", task_id));
531 std::fs::write(&prompt_file, prompt)?;
532
533 let ralph_script = format!(
539 r#"
540export SCUD_TASK_ID='{task_id}'
541export RALPH_PROMISE='{promise}'
542export RALPH_MAX_ITER=50
543export RALPH_ITER=0
544
545echo "🔄 Ralph loop starting for task {task_id}"
546echo " Completion promise: {promise}"
547echo " Max iterations: $RALPH_MAX_ITER"
548echo ""
549
550while true; do
551 RALPH_ITER=$((RALPH_ITER + 1))
552 echo ""
553 echo "═══════════════════════════════════════════════════════════"
554 echo "🔄 RALPH ITERATION $RALPH_ITER / $RALPH_MAX_ITER"
555 echo "═══════════════════════════════════════════════════════════"
556 echo ""
557
558 # Run Claude with the prompt
559 claude "$(cat '{prompt_file}')" --dangerously-skip-permissions
560
561 # Check if task is done
562 TASK_STATUS=$(scud show {task_id} 2>/dev/null | grep -i "status:" | awk '{{print $2}}')
563
564 if [ "$TASK_STATUS" = "done" ]; then
565 echo ""
566 echo "✅ Task {task_id} completed successfully after $RALPH_ITER iterations!"
567 rm -f '{prompt_file}'
568 break
569 fi
570
571 # Check max iterations
572 if [ $RALPH_ITER -ge $RALPH_MAX_ITER ]; then
573 echo ""
574 echo "⚠️ Ralph loop: Max iterations ($RALPH_MAX_ITER) reached for task {task_id}"
575 echo " Task status: $TASK_STATUS"
576 rm -f '{prompt_file}'
577 break
578 fi
579
580 # Small delay before next iteration
581 echo ""
582 echo "🔄 Task not yet complete (status: $TASK_STATUS). Continuing loop..."
583 sleep 2
584done
585"#,
586 task_id = task_id,
587 promise = completion_promise,
588 prompt_file = prompt_file.display(),
589 );
590
591 let script_file = std::env::temp_dir().join(format!("scud-ralph-script-{}.sh", task_id));
593 std::fs::write(&script_file, &ralph_script)?;
594
595 let cmd = format!(
597 "chmod +x '{}' && '{}'",
598 script_file.display(),
599 script_file.display()
600 );
601
602 let target = format!("{}:{}", session_name, window_index);
603 let send_result = Command::new("tmux")
604 .args(["send-keys", "-t", &target, &cmd, "Enter"])
605 .output()
606 .context("Failed to send command to tmux window")?;
607
608 if !send_result.status.success() {
609 anyhow::bail!(
610 "Failed to send keys: {}",
611 String::from_utf8_lossy(&send_result.stderr)
612 );
613 }
614
615 Ok(())
616}
617
618pub fn tmux_session_exists(session_name: &str) -> bool {
620 Command::new("tmux")
621 .args(["has-session", "-t", session_name])
622 .status()
623 .map(|s| s.success())
624 .unwrap_or(false)
625}
626
627pub fn tmux_attach(session_name: &str) -> Result<()> {
629 let status = Command::new("tmux")
631 .args(["attach", "-t", session_name])
632 .status()
633 .context("Failed to attach to tmux session")?;
634
635 if !status.success() {
636 anyhow::bail!("tmux attach failed");
637 }
638
639 Ok(())
640}
641
642pub fn setup_tmux_control_window(session_name: &str, tag: &str) -> Result<()> {
644 let control_script = format!(
645 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'"#,
646 session_name, tag, tag, tag
647 );
648
649 let target = format!("{}:ctrl", session_name);
650 Command::new("tmux")
651 .args(["send-keys", "-t", &target, &control_script, "Enter"])
652 .status()
653 .context("Failed to setup control window")?;
654
655 Ok(())
656}