scud/commands/spawn/
terminal.rs

1//! Terminal detection and spawning functionality
2//!
3//! Supports Kitty, WezTerm, iTerm2, Zellij, 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    Zellij,
16    Tmux,
17}
18
19impl Terminal {
20    /// Display name for the terminal
21    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
32/// Detect the current terminal emulator from environment variables
33pub fn detect_terminal() -> Terminal {
34    // Check for Kitty
35    if std::env::var("KITTY_PID").is_ok() || std::env::var("KITTY_WINDOW_ID").is_ok() {
36        return Terminal::Kitty;
37    }
38
39    // Check for WezTerm
40    if std::env::var("WEZTERM_UNIX_SOCKET").is_ok() || std::env::var("WEZTERM_PANE").is_ok() {
41        return Terminal::Wezterm;
42    }
43
44    // Check for iTerm2 (macOS)
45    if std::env::var("ITERM_SESSION_ID").is_ok() {
46        return Terminal::ITerm2;
47    }
48
49    // Check for Zellij
50    if std::env::var("ZELLIJ").is_ok() || std::env::var("ZELLIJ_SESSION_NAME").is_ok() {
51        return Terminal::Zellij;
52    }
53
54    // Default to tmux as universal fallback
55    Terminal::Tmux
56}
57
58/// Parse terminal name from string argument
59pub 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
74/// Check if required terminal binary is available
75pub fn check_terminal_available(terminal: &Terminal) -> Result<()> {
76    let binary = match terminal {
77        Terminal::Kitty => "kitty",
78        Terminal::Wezterm => "wezterm",
79        Terminal::ITerm2 => "osascript", // iTerm2 uses AppleScript
80        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
96/// Spawn a new terminal window/pane with the given command
97pub 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
113/// Spawn in Kitty terminal using remote control
114fn spawn_kitty(task_id: &str, prompt: &str, working_dir: &Path) -> Result<()> {
115    let title = format!("task-{}", task_id);
116
117    // Write prompt to temp file to avoid shell escaping issues
118    let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
119    std::fs::write(&prompt_file, prompt)?;
120
121    // Interactive mode with SCUD_TASK_ID for hook integration
122    // The Stop hook will read SCUD_TASK_ID and auto-complete the task
123    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
147/// Spawn in WezTerm terminal
148fn spawn_wezterm(task_id: &str, prompt: &str, working_dir: &Path) -> Result<()> {
149    // Write prompt to temp file to avoid shell escaping issues
150    let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
151    std::fs::write(&prompt_file, prompt)?;
152
153    // Interactive mode with SCUD_TASK_ID for hook integration
154    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
178/// Spawn in iTerm2 on macOS using AppleScript
179fn spawn_iterm2(task_id: &str, prompt: &str, working_dir: &Path) -> Result<()> {
180    // Write prompt to temp file
181    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    // Interactive mode with SCUD_TASK_ID for hook integration
186    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
219/// Spawn in Zellij terminal using pane management
220///
221/// Creates a tab if needed via `zellij action new-tab --name <session>`,
222/// then spawns a named pane via `zellij action new-pane --name task-{id} --direction right`.
223/// Sets SCUD_TASK_ID environment variable for hook integration.
224fn 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    // Write prompt to temp file to avoid shell escaping issues
233    let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
234    std::fs::write(&prompt_file, prompt)?;
235
236    // Check if we're inside a Zellij session
237    let inside_zellij = std::env::var("ZELLIJ").is_ok();
238
239    if inside_zellij {
240        // We're inside Zellij - use `zellij action` commands
241
242        // First, try to create a new tab with the session name if it doesn't exist
243        // Zellij doesn't have a way to check if a tab exists, so we just try to
244        // create one and use the current tab if spawning in an existing session
245        let _ = Command::new("zellij")
246            .args(["action", "new-tab", "--name", session_name])
247            .output();
248
249        // Interactive mode with SCUD_TASK_ID for hook integration
250        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        // Spawn a new pane to the right with the task name
259        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        // We're outside Zellij - need to start a new session or attach to existing one
280        // Use `zellij run` to spawn with a command in a new session
281
282        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        // Start a new Zellij session with the command
291        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
318/// Focus a Zellij pane by name for attach functionality
319///
320/// Uses `zellij action go-to-tab-name` to switch to the tab containing the pane.
321pub fn focus_zellij_pane(session_name: &str) -> Result<()> {
322    // Check if we're inside Zellij
323    let inside_zellij = std::env::var("ZELLIJ").is_ok();
324
325    if inside_zellij {
326        // Switch to the tab with the given name
327        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        // Attach to the Zellij session from outside
341        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
358/// Check if a Zellij session exists
359pub 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
371/// Spawn in tmux session
372fn spawn_tmux(task_id: &str, prompt: &str, working_dir: &Path, session_name: &str) -> Result<()> {
373    let window_name = format!("task-{}", task_id);
374
375    // Check if session exists
376    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        // Create new session with control window
384        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    // Create new window for this task and capture its index
393    // Use -P -F to print the new window's index
394    let new_window_output = Command::new("tmux")
395        .args([
396            "new-window",
397            "-t",
398            session_name,
399            "-n",
400            &window_name,
401            "-P", // Print info about new window
402            "-F",
403            "#{window_index}", // Format: just the index
404        ])
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    // Write prompt to temp file
422    let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
423    std::fs::write(&prompt_file, prompt)?;
424
425    // Send command to the window BY INDEX (not name, which can be ambiguous)
426    // Interactive mode with SCUD_TASK_ID for hook integration
427    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
450/// Spawn a new terminal window/pane with Ralph loop enabled
451/// The agent will keep running until the completion promise is detected
452pub 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        // For other terminals, fall back to regular spawn
469        // Ralph loop requires bash scripting that's easier in tmux
470        _ => spawn_terminal(terminal, task_id, prompt, working_dir, session_name),
471    }
472}
473
474/// Spawn in tmux session with Ralph loop wrapper
475fn 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    // Check if session exists
485    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        // Create new session with control window
493        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    // Create new window for this task
502    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    // Write prompt to temp file
530    let prompt_file = std::env::temp_dir().join(format!("scud-ralph-{}.txt", task_id));
531    std::fs::write(&prompt_file, prompt)?;
532
533    // Create a Ralph loop script that:
534    // 1. Runs Claude with the prompt
535    // 2. Checks if the task was marked done (via scud show)
536    // 3. If not done, loops back and runs Claude again with the same prompt
537    // 4. Continues until task is done or max iterations
538    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    // Write the Ralph script to a temp file
592    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    // Make it executable and run it
596    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
618/// Check if a tmux session exists
619pub 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
627/// Attach to a tmux session
628pub fn tmux_attach(session_name: &str) -> Result<()> {
629    // Use exec to replace current process with tmux attach
630    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
642/// Setup the control window in a tmux session with monitoring script
643pub 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}