scud/commands/spawn/
terminal.rs

1//! Terminal spawning functionality
2//!
3//! Spawns AI coding agents in tmux sessions for parallel task execution.
4//! Supports multiple AI harnesses: Claude Code, OpenCode.
5
6use anyhow::{Context, Result};
7use std::path::Path;
8use std::process::Command;
9use std::sync::OnceLock;
10
11/// Supported AI coding harnesses
12#[derive(Debug, Clone, Copy, PartialEq, Default)]
13pub enum Harness {
14    /// Claude Code CLI (default)
15    #[default]
16    Claude,
17    /// OpenCode CLI
18    OpenCode,
19}
20
21impl Harness {
22    /// Parse harness from string
23    pub fn parse(s: &str) -> Result<Self> {
24        match s.to_lowercase().as_str() {
25            "claude" | "claude-code" => Ok(Harness::Claude),
26            "opencode" | "open-code" => Ok(Harness::OpenCode),
27            other => anyhow::bail!("Unknown harness: '{}'. Supported: claude, opencode", other),
28        }
29    }
30
31    /// Display name
32    pub fn name(&self) -> &'static str {
33        match self {
34            Harness::Claude => "claude",
35            Harness::OpenCode => "opencode",
36        }
37    }
38
39    /// Binary name to search for
40    pub fn binary_name(&self) -> &'static str {
41        match self {
42            Harness::Claude => "claude",
43            Harness::OpenCode => "opencode",
44        }
45    }
46
47    /// Generate the command to run with a prompt and optional model
48    pub fn command(&self, binary_path: &str, prompt_file: &Path, model: Option<&str>) -> String {
49        match self {
50            Harness::Claude => {
51                let model_flag = model.map(|m| format!(" --model {}", m)).unwrap_or_default();
52                format!(
53                    r#"'{}' "$(cat '{}')" --dangerously-skip-permissions{}"#,
54                    binary_path,
55                    prompt_file.display(),
56                    model_flag
57                )
58            }
59            Harness::OpenCode => {
60                let model_flag = model.map(|m| format!(" --model {}", m)).unwrap_or_default();
61                format!(
62                    r#"'{}'{} run "$(cat '{}')""#,
63                    binary_path,
64                    model_flag,
65                    prompt_file.display()
66                )
67            }
68        }
69    }
70}
71
72/// Cached paths to harness binaries
73static CLAUDE_PATH: OnceLock<String> = OnceLock::new();
74static OPENCODE_PATH: OnceLock<String> = OnceLock::new();
75
76/// Find the full path to a harness binary.
77/// Caches the result for subsequent calls.
78pub fn find_harness_binary(harness: Harness) -> Result<&'static str> {
79    let cache = match harness {
80        Harness::Claude => &CLAUDE_PATH,
81        Harness::OpenCode => &OPENCODE_PATH,
82    };
83
84    // Check if already cached
85    if let Some(path) = cache.get() {
86        return Ok(path.as_str());
87    }
88
89    let binary_name = harness.binary_name();
90
91    // Try `which <binary>` to find it in PATH
92    let output = Command::new("which")
93        .arg(binary_name)
94        .output()
95        .context(format!("Failed to run 'which {}'", binary_name))?;
96
97    if output.status.success() {
98        let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
99        if !path.is_empty() {
100            // Cache and return
101            let _ = cache.set(path);
102            return Ok(cache.get().unwrap().as_str());
103        }
104    }
105
106    // Common installation paths as fallback
107    let common_paths: &[&str] = match harness {
108        Harness::Claude => &[
109            "/opt/homebrew/bin/claude",
110            "/usr/local/bin/claude",
111            "/usr/bin/claude",
112        ],
113        Harness::OpenCode => &[
114            "/opt/homebrew/bin/opencode",
115            "/usr/local/bin/opencode",
116            "/usr/bin/opencode",
117        ],
118    };
119
120    for path in common_paths {
121        if std::path::Path::new(path).exists() {
122            let _ = cache.set(path.to_string());
123            return Ok(cache.get().unwrap().as_str());
124        }
125    }
126
127    // Try home-relative paths
128    if let Ok(home) = std::env::var("HOME") {
129        let home_paths: Vec<String> = match harness {
130            Harness::Claude => vec![
131                format!("{}/.local/bin/claude", home),
132                format!("{}/.claude/local/claude", home),
133            ],
134            Harness::OpenCode => vec![
135                format!("{}/.local/bin/opencode", home),
136                format!("{}/.bun/bin/opencode", home),
137            ],
138        };
139
140        for path in home_paths {
141            if std::path::Path::new(&path).exists() {
142                let _ = cache.set(path);
143                return Ok(cache.get().unwrap().as_str());
144            }
145        }
146    }
147
148    let install_hint = match harness {
149        Harness::Claude => "Install with: npm install -g @anthropic-ai/claude-code",
150        Harness::OpenCode => "Install with: curl -fsSL https://opencode.ai/install | bash",
151    };
152
153    anyhow::bail!(
154        "Could not find '{}' binary. Please ensure it is installed and in PATH.\n{}",
155        binary_name,
156        install_hint
157    )
158}
159
160/// Find the full path to the claude binary (convenience wrapper).
161pub fn find_claude_binary() -> Result<&'static str> {
162    find_harness_binary(Harness::Claude)
163}
164
165/// Check if tmux is available
166pub fn check_tmux_available() -> Result<()> {
167    let result = Command::new("which")
168        .arg("tmux")
169        .output()
170        .context("Failed to check for tmux binary")?;
171
172    if !result.status.success() {
173        anyhow::bail!("tmux is not installed or not in PATH. Install with: brew install tmux (macOS) or apt install tmux (Linux)");
174    }
175
176    Ok(())
177}
178
179/// Spawn a new tmux window with the given command
180/// Returns the tmux window index for easy attachment (e.g., "3" for session:3)
181pub fn spawn_terminal(
182    task_id: &str,
183    prompt: &str,
184    working_dir: &Path,
185    session_name: &str,
186) -> Result<String> {
187    // Default to Claude harness for backwards compatibility
188    spawn_terminal_with_harness_and_model(
189        task_id,
190        prompt,
191        working_dir,
192        session_name,
193        Harness::Claude,
194        None,
195    )
196}
197
198/// Spawn a new tmux window with the given command using a specific harness
199/// Returns the tmux window index for easy attachment (e.g., "3" for session:3)
200pub fn spawn_terminal_with_harness(
201    task_id: &str,
202    prompt: &str,
203    working_dir: &Path,
204    session_name: &str,
205    harness: Harness,
206) -> Result<String> {
207    spawn_terminal_with_harness_and_model(task_id, prompt, working_dir, session_name, harness, None)
208}
209
210/// Spawn a new tmux window with the given command using a specific harness and model
211/// Returns the tmux window index for easy attachment (e.g., "3" for session:3)
212pub fn spawn_terminal_with_harness_and_model(
213    task_id: &str,
214    prompt: &str,
215    working_dir: &Path,
216    session_name: &str,
217    harness: Harness,
218    model: Option<&str>,
219) -> Result<String> {
220    // Find harness binary path upfront to fail fast if not found
221    let binary_path = find_harness_binary(harness)?;
222    spawn_tmux(
223        task_id,
224        prompt,
225        working_dir,
226        session_name,
227        binary_path,
228        harness,
229        model,
230    )
231}
232
233/// Spawn in tmux session
234/// Returns the tmux window index for easy attachment (e.g., "3" for session:3)
235fn spawn_tmux(
236    task_id: &str,
237    prompt: &str,
238    working_dir: &Path,
239    session_name: &str,
240    binary_path: &str,
241    harness: Harness,
242    model: Option<&str>,
243) -> Result<String> {
244    let window_name = format!("task-{}", task_id);
245
246    // Check if session exists
247    let session_exists = Command::new("tmux")
248        .args(["has-session", "-t", session_name])
249        .status()
250        .map(|s| s.success())
251        .unwrap_or(false);
252
253    if !session_exists {
254        // Create new session with control window
255        Command::new("tmux")
256            .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
257            .arg("-c")
258            .arg(working_dir)
259            .status()
260            .context("Failed to create tmux session")?;
261    }
262
263    // Create new window for this task and capture its index
264    // Use -P -F to print the new window's index
265    let new_window_output = Command::new("tmux")
266        .args([
267            "new-window",
268            "-t",
269            session_name,
270            "-n",
271            &window_name,
272            "-P", // Print info about new window
273            "-F",
274            "#{window_index}", // Format: just the index
275        ])
276        .arg("-c")
277        .arg(working_dir)
278        .output()
279        .context("Failed to create tmux window")?;
280
281    if !new_window_output.status.success() {
282        anyhow::bail!(
283            "Failed to create window: {}",
284            String::from_utf8_lossy(&new_window_output.stderr)
285        );
286    }
287
288    let window_index = String::from_utf8_lossy(&new_window_output.stdout)
289        .trim()
290        .to_string();
291
292    // Write prompt to temp file
293    let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
294    std::fs::write(&prompt_file, prompt)?;
295
296    // Send command to the window BY INDEX (not name, which can be ambiguous)
297    // Interactive mode with SCUD_TASK_ID for hook integration
298    // Use full path to harness binary to avoid PATH issues in spawned shells
299    // Source shell profile to ensure PATH includes node, etc.
300    let harness_cmd = harness.command(binary_path, &prompt_file, model);
301    // For tmux, we send a multi-line script via send-keys
302    // First source profiles, then run the harness command
303    let full_cmd = format!(
304        r#"source ~/.bash_profile 2>/dev/null; source ~/.zshrc 2>/dev/null; export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"; [ -s "$HOME/.nvm/nvm.sh" ] && source "$HOME/.nvm/nvm.sh"; export SCUD_TASK_ID='{}' ; {} ; rm -f '{}'"#,
305        task_id,
306        harness_cmd,
307        prompt_file.display()
308    );
309
310    let target = format!("{}:{}", session_name, window_index);
311    let send_result = Command::new("tmux")
312        .args(["send-keys", "-t", &target, &full_cmd, "Enter"])
313        .output()
314        .context("Failed to send command to tmux window")?;
315
316    if !send_result.status.success() {
317        anyhow::bail!(
318            "Failed to send keys: {}",
319            String::from_utf8_lossy(&send_result.stderr)
320        );
321    }
322
323    Ok(window_index)
324}
325
326/// Spawn a new tmux window with Ralph loop enabled
327/// The agent will keep running until the completion promise is detected
328pub fn spawn_terminal_ralph(
329    task_id: &str,
330    prompt: &str,
331    working_dir: &Path,
332    session_name: &str,
333    completion_promise: &str,
334) -> Result<()> {
335    // Default to Claude harness
336    spawn_terminal_ralph_with_harness(
337        task_id,
338        prompt,
339        working_dir,
340        session_name,
341        completion_promise,
342        Harness::Claude,
343    )
344}
345
346/// Spawn a new tmux window with Ralph loop enabled using a specific harness
347pub fn spawn_terminal_ralph_with_harness(
348    task_id: &str,
349    prompt: &str,
350    working_dir: &Path,
351    session_name: &str,
352    completion_promise: &str,
353    harness: Harness,
354) -> Result<()> {
355    // Find harness binary path upfront to fail fast if not found
356    let binary_path = find_harness_binary(harness)?;
357    spawn_tmux_ralph(
358        task_id,
359        prompt,
360        working_dir,
361        session_name,
362        completion_promise,
363        binary_path,
364        harness,
365    )
366}
367
368/// Spawn in tmux session with Ralph loop wrapper
369fn spawn_tmux_ralph(
370    task_id: &str,
371    prompt: &str,
372    working_dir: &Path,
373    session_name: &str,
374    completion_promise: &str,
375    binary_path: &str,
376    harness: Harness,
377) -> Result<()> {
378    let window_name = format!("ralph-{}", task_id);
379
380    // Check if session exists
381    let session_exists = Command::new("tmux")
382        .args(["has-session", "-t", session_name])
383        .status()
384        .map(|s| s.success())
385        .unwrap_or(false);
386
387    if !session_exists {
388        // Create new session with control window
389        Command::new("tmux")
390            .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
391            .arg("-c")
392            .arg(working_dir)
393            .status()
394            .context("Failed to create tmux session")?;
395    }
396
397    // Create new window for this task
398    let new_window_output = Command::new("tmux")
399        .args([
400            "new-window",
401            "-t",
402            session_name,
403            "-n",
404            &window_name,
405            "-P",
406            "-F",
407            "#{window_index}",
408        ])
409        .arg("-c")
410        .arg(working_dir)
411        .output()
412        .context("Failed to create tmux window")?;
413
414    if !new_window_output.status.success() {
415        anyhow::bail!(
416            "Failed to create window: {}",
417            String::from_utf8_lossy(&new_window_output.stderr)
418        );
419    }
420
421    let window_index = String::from_utf8_lossy(&new_window_output.stdout)
422        .trim()
423        .to_string();
424
425    // Write prompt to temp file
426    let prompt_file = std::env::temp_dir().join(format!("scud-ralph-{}.txt", task_id));
427    std::fs::write(&prompt_file, prompt)?;
428
429    // Build the harness-specific command for the ralph script
430    // We need to inline this since the script is a bash heredoc
431    let harness_cmd = match harness {
432        Harness::Claude => format!(
433            "'{binary_path}' \"$(cat '{prompt_file}')\" --dangerously-skip-permissions",
434            binary_path = binary_path,
435            prompt_file = prompt_file.display()
436        ),
437        Harness::OpenCode => format!(
438            "'{binary_path}' run \"$(cat '{prompt_file}')\"",
439            binary_path = binary_path,
440            prompt_file = prompt_file.display()
441        ),
442    };
443
444    // Create a Ralph loop script that:
445    // 1. Runs the harness with the prompt
446    // 2. Checks if the task was marked done (via scud show)
447    // 3. If not done, loops back and runs the harness again with the same prompt
448    // 4. Continues until task is done or max iterations
449    // Use full path to harness binary to avoid PATH issues in spawned shells
450    // Source shell profile to ensure PATH includes node, etc.
451    let ralph_script = format!(
452        r#"
453# Source shell profile for PATH setup
454[ -f /etc/profile ] && . /etc/profile
455[ -f ~/.profile ] && . ~/.profile
456[ -f ~/.bash_profile ] && . ~/.bash_profile
457[ -f ~/.bashrc ] && . ~/.bashrc
458[ -f ~/.zshrc ] && . ~/.zshrc 2>/dev/null
459export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
460[ -s "$HOME/.nvm/nvm.sh" ] && . "$HOME/.nvm/nvm.sh"
461[ -s "$HOME/.bun/_bun" ] && . "$HOME/.bun/_bun"
462
463export SCUD_TASK_ID='{task_id}'
464export RALPH_PROMISE='{promise}'
465export RALPH_MAX_ITER=50
466export RALPH_ITER=0
467
468echo "🔄 Ralph loop starting for task {task_id}"
469echo "   Harness: {harness_name}"
470echo "   Completion promise: {promise}"
471echo "   Max iterations: $RALPH_MAX_ITER"
472echo ""
473
474while true; do
475    RALPH_ITER=$((RALPH_ITER + 1))
476    echo ""
477    echo "═══════════════════════════════════════════════════════════"
478    echo "🔄 RALPH ITERATION $RALPH_ITER / $RALPH_MAX_ITER"
479    echo "═══════════════════════════════════════════════════════════"
480    echo ""
481
482    # Run harness with the prompt (using full path)
483    {harness_cmd}
484
485    # Check if task is done
486    TASK_STATUS=$(scud show {task_id} 2>/dev/null | grep -i "status:" | awk '{{print $2}}')
487
488    if [ "$TASK_STATUS" = "done" ]; then
489        echo ""
490        echo "✅ Task {task_id} completed successfully after $RALPH_ITER iterations!"
491        rm -f '{prompt_file}'
492        break
493    fi
494
495    # Check max iterations
496    if [ $RALPH_ITER -ge $RALPH_MAX_ITER ]; then
497        echo ""
498        echo "⚠️  Ralph loop: Max iterations ($RALPH_MAX_ITER) reached for task {task_id}"
499        echo "   Task status: $TASK_STATUS"
500        rm -f '{prompt_file}'
501        break
502    fi
503
504    # Small delay before next iteration
505    echo ""
506    echo "🔄 Task not yet complete (status: $TASK_STATUS). Continuing loop..."
507    sleep 2
508done
509"#,
510        task_id = task_id,
511        promise = completion_promise,
512        prompt_file = prompt_file.display(),
513        harness_name = harness.name(),
514        harness_cmd = harness_cmd,
515    );
516
517    // Write the Ralph script to a temp file
518    let script_file = std::env::temp_dir().join(format!("scud-ralph-script-{}.sh", task_id));
519    std::fs::write(&script_file, &ralph_script)?;
520
521    // Make it executable and run it
522    let cmd = format!(
523        "chmod +x '{}' && '{}'",
524        script_file.display(),
525        script_file.display()
526    );
527
528    let target = format!("{}:{}", session_name, window_index);
529    let send_result = Command::new("tmux")
530        .args(["send-keys", "-t", &target, &cmd, "Enter"])
531        .output()
532        .context("Failed to send command to tmux window")?;
533
534    if !send_result.status.success() {
535        anyhow::bail!(
536            "Failed to send keys: {}",
537            String::from_utf8_lossy(&send_result.stderr)
538        );
539    }
540
541    Ok(())
542}
543
544/// Check if a tmux session exists
545pub fn tmux_session_exists(session_name: &str) -> bool {
546    Command::new("tmux")
547        .args(["has-session", "-t", session_name])
548        .status()
549        .map(|s| s.success())
550        .unwrap_or(false)
551}
552
553/// Attach to a tmux session
554pub fn tmux_attach(session_name: &str) -> Result<()> {
555    // Use exec to replace current process with tmux attach
556    let status = Command::new("tmux")
557        .args(["attach", "-t", session_name])
558        .status()
559        .context("Failed to attach to tmux session")?;
560
561    if !status.success() {
562        anyhow::bail!("tmux attach failed");
563    }
564
565    Ok(())
566}
567
568/// Setup the control window in a tmux session with monitoring script
569pub fn setup_tmux_control_window(session_name: &str, tag: &str) -> Result<()> {
570    let control_script = format!(
571        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'"#,
572        session_name, tag, tag, tag
573    );
574
575    let target = format!("{}:ctrl", session_name);
576    Command::new("tmux")
577        .args(["send-keys", "-t", &target, &control_script, "Enter"])
578        .status()
579        .context("Failed to setup control window")?;
580
581    Ok(())
582}