scud/commands/spawn/
terminal.rs

1//! Terminal detection and spawning functionality
2//!
3//! Supports Kitty, WezTerm, iTerm2, and tmux with auto-detection based on environment variables.
4
5use anyhow::{Context, Result};
6use std::path::Path;
7use std::process::Command;
8
9/// Supported terminal emulators
10#[derive(Debug, Clone, PartialEq)]
11pub enum Terminal {
12    Kitty,
13    Wezterm,
14    ITerm2,
15    Tmux,
16}
17
18impl Terminal {
19    /// Display name for the terminal
20    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
30/// Detect the current terminal emulator from environment variables
31pub fn detect_terminal() -> Terminal {
32    // Check for Kitty
33    if std::env::var("KITTY_PID").is_ok() || std::env::var("KITTY_WINDOW_ID").is_ok() {
34        return Terminal::Kitty;
35    }
36
37    // Check for WezTerm
38    if std::env::var("WEZTERM_UNIX_SOCKET").is_ok() || std::env::var("WEZTERM_PANE").is_ok() {
39        return Terminal::Wezterm;
40    }
41
42    // Check for iTerm2 (macOS)
43    if std::env::var("ITERM_SESSION_ID").is_ok() {
44        return Terminal::ITerm2;
45    }
46
47    // Default to tmux as universal fallback
48    Terminal::Tmux
49}
50
51/// Parse terminal name from string argument
52pub 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
66/// Check if required terminal binary is available
67pub fn check_terminal_available(terminal: &Terminal) -> Result<()> {
68    let binary = match terminal {
69        Terminal::Kitty => "kitty",
70        Terminal::Wezterm => "wezterm",
71        Terminal::ITerm2 => "osascript", // iTerm2 uses AppleScript
72        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
87/// Spawn a new terminal window/pane with the given command
88pub 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
103/// Spawn in Kitty terminal using remote control
104fn spawn_kitty(task_id: &str, prompt: &str, working_dir: &Path) -> Result<()> {
105    let title = format!("task-{}", task_id);
106
107    // Write prompt to temp file to avoid shell escaping issues
108    let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
109    std::fs::write(&prompt_file, prompt)?;
110
111    // Interactive mode with SCUD_TASK_ID for hook integration
112    // The Stop hook will read SCUD_TASK_ID and auto-complete the task
113    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
137/// Spawn in WezTerm terminal
138fn spawn_wezterm(task_id: &str, prompt: &str, working_dir: &Path) -> Result<()> {
139    // Write prompt to temp file to avoid shell escaping issues
140    let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
141    std::fs::write(&prompt_file, prompt)?;
142
143    // Interactive mode with SCUD_TASK_ID for hook integration
144    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
168/// Spawn in iTerm2 on macOS using AppleScript
169fn spawn_iterm2(task_id: &str, prompt: &str, working_dir: &Path) -> Result<()> {
170    // Write prompt to temp file
171    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    // Interactive mode with SCUD_TASK_ID for hook integration
176    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
209/// Spawn in tmux session
210fn spawn_tmux(task_id: &str, prompt: &str, working_dir: &Path, session_name: &str) -> Result<()> {
211    let window_name = format!("task-{}", task_id);
212
213    // Check if session exists
214    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        // Create new session with control window
222        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    // Create new window for this task and capture its index
231    // Use -P -F to print the new window's index
232    let new_window_output = Command::new("tmux")
233        .args([
234            "new-window",
235            "-t",
236            session_name,
237            "-n",
238            &window_name,
239            "-P", // Print info about new window
240            "-F",
241            "#{window_index}", // Format: just the index
242        ])
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    // Write prompt to temp file
260    let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
261    std::fs::write(&prompt_file, prompt)?;
262
263    // Send command to the window BY INDEX (not name, which can be ambiguous)
264    // Interactive mode with SCUD_TASK_ID for hook integration
265    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
288/// Spawn a new terminal window/pane with Ralph loop enabled
289/// The agent will keep running until the completion promise is detected
290pub 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        // For other terminals, fall back to regular spawn
307        // Ralph loop requires bash scripting that's easier in tmux
308        _ => spawn_terminal(terminal, task_id, prompt, working_dir, session_name),
309    }
310}
311
312/// Spawn in tmux session with Ralph loop wrapper
313fn 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    // Check if session exists
323    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        // Create new session with control window
331        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    // Create new window for this task
340    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    // Write prompt to temp file
368    let prompt_file = std::env::temp_dir().join(format!("scud-ralph-{}.txt", task_id));
369    std::fs::write(&prompt_file, prompt)?;
370
371    // Create a Ralph loop script that:
372    // 1. Runs Claude with the prompt
373    // 2. Checks if the task was marked done (via scud show)
374    // 3. If not done, loops back and runs Claude again with the same prompt
375    // 4. Continues until task is done or max iterations
376    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    // Write the Ralph script to a temp file
430    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    // Make it executable and run it
434    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
456/// Check if a tmux session exists
457pub 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
465/// Attach to a tmux session
466pub fn tmux_attach(session_name: &str) -> Result<()> {
467    // Use exec to replace current process with tmux attach
468    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
480/// Setup the control window in a tmux session with monitoring script
481pub 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}