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!("Unknown terminal: {}. Supported: kitty, wezterm, iterm2, tmux, auto", other),
60    }
61}
62
63/// Check if required terminal binary is available
64pub fn check_terminal_available(terminal: &Terminal) -> Result<()> {
65    let binary = match terminal {
66        Terminal::Kitty => "kitty",
67        Terminal::Wezterm => "wezterm",
68        Terminal::ITerm2 => "osascript", // iTerm2 uses AppleScript
69        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
84/// Spawn a new terminal window/pane with the given command
85pub 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
100/// Spawn in Kitty terminal using remote control
101fn spawn_kitty(task_id: &str, prompt: &str, working_dir: &Path) -> Result<()> {
102    let title = format!("task-{}", task_id);
103
104    // Write prompt to temp file to avoid shell escaping issues
105    let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
106    std::fs::write(&prompt_file, prompt)?;
107
108    // Interactive mode with SCUD_TASK_ID for hook integration
109    // The Stop hook will read SCUD_TASK_ID and auto-complete the task
110    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
134/// Spawn in WezTerm terminal
135fn spawn_wezterm(task_id: &str, prompt: &str, working_dir: &Path) -> Result<()> {
136    // Write prompt to temp file to avoid shell escaping issues
137    let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
138    std::fs::write(&prompt_file, prompt)?;
139
140    // Interactive mode with SCUD_TASK_ID for hook integration
141    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
165/// Spawn in iTerm2 on macOS using AppleScript
166fn spawn_iterm2(task_id: &str, prompt: &str, working_dir: &Path) -> Result<()> {
167    // Write prompt to temp file
168    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    // Interactive mode with SCUD_TASK_ID for hook integration
173    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
206/// Spawn in tmux session
207fn spawn_tmux(task_id: &str, prompt: &str, working_dir: &Path, session_name: &str) -> Result<()> {
208    let window_name = format!("task-{}", task_id);
209
210    // Check if session exists
211    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        // Create new session with control window
219        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    // Create new window for this task and capture its index
228    // Use -P -F to print the new window's index
229    let new_window_output = Command::new("tmux")
230        .args([
231            "new-window",
232            "-t", session_name,
233            "-n", &window_name,
234            "-P",                    // Print info about new window
235            "-F", "#{window_index}", // Format: just the index
236        ])
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    // Write prompt to temp file
254    let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
255    std::fs::write(&prompt_file, prompt)?;
256
257    // Send command to the window BY INDEX (not name, which can be ambiguous)
258    // Interactive mode with SCUD_TASK_ID for hook integration
259    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
282/// Spawn a new terminal window/pane with Ralph loop enabled
283/// The agent will keep running until the completion promise is detected
284pub 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        // For other terminals, fall back to regular spawn
295        // Ralph loop requires bash scripting that's easier in tmux
296        _ => spawn_terminal(terminal, task_id, prompt, working_dir, session_name),
297    }
298}
299
300/// Spawn in tmux session with Ralph loop wrapper
301fn 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    // Check if session exists
311    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        // Create new session with control window
319        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    // Create new window for this task
328    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    // Write prompt to temp file
353    let prompt_file = std::env::temp_dir().join(format!("scud-ralph-{}.txt", task_id));
354    std::fs::write(&prompt_file, prompt)?;
355
356    // Create a Ralph loop script that:
357    // 1. Runs Claude with the prompt
358    // 2. Checks if the task was marked done (via scud show)
359    // 3. If not done, loops back and runs Claude again with the same prompt
360    // 4. Continues until task is done or max iterations
361    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    // Write the Ralph script to a temp file
415    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    // Make it executable and run it
419    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
441/// Check if a tmux session exists
442pub 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
450/// Attach to a tmux session
451pub fn tmux_attach(session_name: &str) -> Result<()> {
452    // Use exec to replace current process with tmux attach
453    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
465/// Setup the control window in a tmux session with monitoring script
466pub 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}