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                // Use --variant minimal to reduce reasoning overhead and avoid
62                // "reasoning part not found" errors with some models
63                format!(
64                    r#"'{}'{} run --variant minimal "$(cat '{}')""#,
65                    binary_path,
66                    model_flag,
67                    prompt_file.display()
68                )
69            }
70        }
71    }
72}
73
74/// Cached paths to harness binaries
75static CLAUDE_PATH: OnceLock<String> = OnceLock::new();
76static OPENCODE_PATH: OnceLock<String> = OnceLock::new();
77
78/// Find the full path to a harness binary.
79/// Caches the result for subsequent calls.
80pub fn find_harness_binary(harness: Harness) -> Result<&'static str> {
81    let cache = match harness {
82        Harness::Claude => &CLAUDE_PATH,
83        Harness::OpenCode => &OPENCODE_PATH,
84    };
85
86    // Check if already cached
87    if let Some(path) = cache.get() {
88        return Ok(path.as_str());
89    }
90
91    let binary_name = harness.binary_name();
92
93    // Try `which <binary>` to find it in PATH
94    let output = Command::new("which")
95        .arg(binary_name)
96        .output()
97        .context(format!("Failed to run 'which {}'", binary_name))?;
98
99    if output.status.success() {
100        let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
101        if !path.is_empty() {
102            // Cache and return
103            let _ = cache.set(path);
104            return Ok(cache.get().unwrap().as_str());
105        }
106    }
107
108    // Common installation paths as fallback
109    let common_paths: &[&str] = match harness {
110        Harness::Claude => &[
111            "/opt/homebrew/bin/claude",
112            "/usr/local/bin/claude",
113            "/usr/bin/claude",
114        ],
115        Harness::OpenCode => &[
116            "/opt/homebrew/bin/opencode",
117            "/usr/local/bin/opencode",
118            "/usr/bin/opencode",
119        ],
120    };
121
122    for path in common_paths {
123        if std::path::Path::new(path).exists() {
124            let _ = cache.set(path.to_string());
125            return Ok(cache.get().unwrap().as_str());
126        }
127    }
128
129    // Try home-relative paths
130    if let Ok(home) = std::env::var("HOME") {
131        let home_paths: Vec<String> = match harness {
132            Harness::Claude => vec![
133                format!("{}/.local/bin/claude", home),
134                format!("{}/.claude/local/claude", home),
135            ],
136            Harness::OpenCode => vec![
137                format!("{}/.local/bin/opencode", home),
138                format!("{}/.bun/bin/opencode", home),
139            ],
140        };
141
142        for path in home_paths {
143            if std::path::Path::new(&path).exists() {
144                let _ = cache.set(path);
145                return Ok(cache.get().unwrap().as_str());
146            }
147        }
148    }
149
150    let install_hint = match harness {
151        Harness::Claude => "Install with: npm install -g @anthropic-ai/claude-code",
152        Harness::OpenCode => "Install with: curl -fsSL https://opencode.ai/install | bash",
153    };
154
155    anyhow::bail!(
156        "Could not find '{}' binary. Please ensure it is installed and in PATH.\n{}",
157        binary_name,
158        install_hint
159    )
160}
161
162/// Find the full path to the claude binary (convenience wrapper).
163pub fn find_claude_binary() -> Result<&'static str> {
164    find_harness_binary(Harness::Claude)
165}
166
167/// Check if tmux is available
168pub fn check_tmux_available() -> Result<()> {
169    let result = Command::new("which")
170        .arg("tmux")
171        .output()
172        .context("Failed to check for tmux binary")?;
173
174    if !result.status.success() {
175        anyhow::bail!("tmux is not installed or not in PATH. Install with: brew install tmux (macOS) or apt install tmux (Linux)");
176    }
177
178    Ok(())
179}
180
181/// Spawn a new tmux window with the given command
182/// Returns the tmux window index for easy attachment (e.g., "3" for session:3)
183pub fn spawn_terminal(
184    task_id: &str,
185    prompt: &str,
186    working_dir: &Path,
187    session_name: &str,
188) -> Result<String> {
189    // Default to Claude harness for backwards compatibility
190    spawn_terminal_with_harness_and_model(
191        task_id,
192        prompt,
193        working_dir,
194        session_name,
195        Harness::Claude,
196        None,
197    )
198}
199
200/// Spawn a new tmux window with the given command using a specific harness
201/// Returns the tmux window index for easy attachment (e.g., "3" for session:3)
202pub fn spawn_terminal_with_harness(
203    task_id: &str,
204    prompt: &str,
205    working_dir: &Path,
206    session_name: &str,
207    harness: Harness,
208) -> Result<String> {
209    spawn_terminal_with_harness_and_model(task_id, prompt, working_dir, session_name, harness, None)
210}
211
212/// Spawn a new tmux window with the given command using a specific harness and model
213/// Returns the tmux window index for easy attachment (e.g., "3" for session:3)
214pub fn spawn_terminal_with_harness_and_model(
215    task_id: &str,
216    prompt: &str,
217    working_dir: &Path,
218    session_name: &str,
219    harness: Harness,
220    model: Option<&str>,
221) -> Result<String> {
222    // Find harness binary path upfront to fail fast if not found
223    let binary_path = find_harness_binary(harness)?;
224    spawn_tmux(
225        task_id,
226        prompt,
227        working_dir,
228        session_name,
229        binary_path,
230        harness,
231        model,
232    )
233}
234
235/// Spawn in tmux session
236/// Returns the tmux window index for easy attachment (e.g., "3" for session:3)
237fn spawn_tmux(
238    task_id: &str,
239    prompt: &str,
240    working_dir: &Path,
241    session_name: &str,
242    binary_path: &str,
243    harness: Harness,
244    model: Option<&str>,
245) -> Result<String> {
246    let window_name = format!("task-{}", task_id);
247
248    // Check if session exists
249    let session_exists = Command::new("tmux")
250        .args(["has-session", "-t", session_name])
251        .status()
252        .map(|s| s.success())
253        .unwrap_or(false);
254
255    if !session_exists {
256        // Create new session with control window
257        Command::new("tmux")
258            .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
259            .arg("-c")
260            .arg(working_dir)
261            .status()
262            .context("Failed to create tmux session")?;
263    }
264
265    // Create new window for this task and capture its index
266    // Use -P -F to print the new window's index
267    let new_window_output = Command::new("tmux")
268        .args([
269            "new-window",
270            "-t",
271            session_name,
272            "-n",
273            &window_name,
274            "-P", // Print info about new window
275            "-F",
276            "#{window_index}", // Format: just the index
277        ])
278        .arg("-c")
279        .arg(working_dir)
280        .output()
281        .context("Failed to create tmux window")?;
282
283    if !new_window_output.status.success() {
284        anyhow::bail!(
285            "Failed to create window: {}",
286            String::from_utf8_lossy(&new_window_output.stderr)
287        );
288    }
289
290    let window_index = String::from_utf8_lossy(&new_window_output.stdout)
291        .trim()
292        .to_string();
293
294    // Write prompt to temp file
295    let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
296    std::fs::write(&prompt_file, prompt)?;
297
298    // Send command to the window BY INDEX (not name, which can be ambiguous)
299    // Interactive mode with SCUD_TASK_ID for hook integration
300    // Use full path to harness binary to avoid PATH issues in spawned shells
301    // Source shell profile to ensure PATH includes node, etc.
302    let harness_cmd = harness.command(binary_path, &prompt_file, model);
303
304    // Write a bash script to handle shell-agnostic execution
305    // This ensures it works even if the user's shell is fish, zsh, etc.
306    let spawn_script = format!(
307        r#"#!/usr/bin/env bash
308# Source shell profile for PATH setup
309source ~/.bash_profile 2>/dev/null
310source ~/.zshrc 2>/dev/null
311export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
312[ -s "$HOME/.nvm/nvm.sh" ] && source "$HOME/.nvm/nvm.sh"
313
314export SCUD_TASK_ID='{task_id}'
315{harness_cmd}
316rm -f '{prompt_file}'
317"#,
318        task_id = task_id,
319        harness_cmd = harness_cmd,
320        prompt_file = prompt_file.display()
321    );
322
323    let script_file = std::env::temp_dir().join(format!("scud-spawn-{}.sh", task_id));
324    std::fs::write(&script_file, &spawn_script)?;
325
326    // Run the script with bash explicitly (works in any shell including fish)
327    let run_cmd = format!("bash '{}'", script_file.display());
328
329    let target = format!("{}:{}", session_name, window_index);
330    let send_result = Command::new("tmux")
331        .args(["send-keys", "-t", &target, &run_cmd, "Enter"])
332        .output()
333        .context("Failed to send command to tmux window")?;
334
335    if !send_result.status.success() {
336        anyhow::bail!(
337            "Failed to send keys: {}",
338            String::from_utf8_lossy(&send_result.stderr)
339        );
340    }
341
342    Ok(window_index)
343}
344
345/// Spawn a new tmux window with Ralph loop enabled
346/// The agent will keep running until the completion promise is detected
347pub fn spawn_terminal_ralph(
348    task_id: &str,
349    prompt: &str,
350    working_dir: &Path,
351    session_name: &str,
352    completion_promise: &str,
353) -> Result<()> {
354    // Default to Claude harness
355    spawn_terminal_ralph_with_harness(
356        task_id,
357        prompt,
358        working_dir,
359        session_name,
360        completion_promise,
361        Harness::Claude,
362    )
363}
364
365/// Spawn a new tmux window with Ralph loop enabled using a specific harness
366pub fn spawn_terminal_ralph_with_harness(
367    task_id: &str,
368    prompt: &str,
369    working_dir: &Path,
370    session_name: &str,
371    completion_promise: &str,
372    harness: Harness,
373) -> Result<()> {
374    // Find harness binary path upfront to fail fast if not found
375    let binary_path = find_harness_binary(harness)?;
376    spawn_tmux_ralph(
377        task_id,
378        prompt,
379        working_dir,
380        session_name,
381        completion_promise,
382        binary_path,
383        harness,
384    )
385}
386
387/// Spawn in tmux session with Ralph loop wrapper
388fn spawn_tmux_ralph(
389    task_id: &str,
390    prompt: &str,
391    working_dir: &Path,
392    session_name: &str,
393    completion_promise: &str,
394    binary_path: &str,
395    harness: Harness,
396) -> Result<()> {
397    let window_name = format!("ralph-{}", task_id);
398
399    // Check if session exists
400    let session_exists = Command::new("tmux")
401        .args(["has-session", "-t", session_name])
402        .status()
403        .map(|s| s.success())
404        .unwrap_or(false);
405
406    if !session_exists {
407        // Create new session with control window
408        Command::new("tmux")
409            .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
410            .arg("-c")
411            .arg(working_dir)
412            .status()
413            .context("Failed to create tmux session")?;
414    }
415
416    // Create new window for this task
417    let new_window_output = Command::new("tmux")
418        .args([
419            "new-window",
420            "-t",
421            session_name,
422            "-n",
423            &window_name,
424            "-P",
425            "-F",
426            "#{window_index}",
427        ])
428        .arg("-c")
429        .arg(working_dir)
430        .output()
431        .context("Failed to create tmux window")?;
432
433    if !new_window_output.status.success() {
434        anyhow::bail!(
435            "Failed to create window: {}",
436            String::from_utf8_lossy(&new_window_output.stderr)
437        );
438    }
439
440    let window_index = String::from_utf8_lossy(&new_window_output.stdout)
441        .trim()
442        .to_string();
443
444    // Write prompt to temp file
445    let prompt_file = std::env::temp_dir().join(format!("scud-ralph-{}.txt", task_id));
446    std::fs::write(&prompt_file, prompt)?;
447
448    // Build the harness-specific command for the ralph script
449    // We need to inline this since the script is a bash heredoc
450    let harness_cmd = match harness {
451        Harness::Claude => format!(
452            "'{binary_path}' \"$(cat '{prompt_file}')\" --dangerously-skip-permissions",
453            binary_path = binary_path,
454            prompt_file = prompt_file.display()
455        ),
456        Harness::OpenCode => format!(
457            "'{binary_path}' run --variant minimal \"$(cat '{prompt_file}')\"",
458            binary_path = binary_path,
459            prompt_file = prompt_file.display()
460        ),
461    };
462
463    // Create a Ralph loop script that:
464    // 1. Runs the harness with the prompt
465    // 2. Checks if the task was marked done (via scud show)
466    // 3. If not done, loops back and runs the harness again with the same prompt
467    // 4. Continues until task is done or max iterations
468    // Use full path to harness binary to avoid PATH issues in spawned shells
469    // Source shell profile to ensure PATH includes node, etc.
470    let ralph_script = format!(
471        r#"#!/usr/bin/env bash
472# Source shell profile for PATH setup
473[ -f /etc/profile ] && . /etc/profile
474[ -f ~/.profile ] && . ~/.profile
475[ -f ~/.bash_profile ] && . ~/.bash_profile
476[ -f ~/.bashrc ] && . ~/.bashrc
477[ -f ~/.zshrc ] && . ~/.zshrc 2>/dev/null
478export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
479[ -s "$HOME/.nvm/nvm.sh" ] && . "$HOME/.nvm/nvm.sh"
480[ -s "$HOME/.bun/_bun" ] && . "$HOME/.bun/_bun"
481
482export SCUD_TASK_ID='{task_id}'
483export RALPH_PROMISE='{promise}'
484export RALPH_MAX_ITER=50
485export RALPH_ITER=0
486
487echo "🔄 Ralph loop starting for task {task_id}"
488echo "   Harness: {harness_name}"
489echo "   Completion promise: {promise}"
490echo "   Max iterations: $RALPH_MAX_ITER"
491echo ""
492
493while true; do
494    RALPH_ITER=$((RALPH_ITER + 1))
495    echo ""
496    echo "═══════════════════════════════════════════════════════════"
497    echo "🔄 RALPH ITERATION $RALPH_ITER / $RALPH_MAX_ITER"
498    echo "═══════════════════════════════════════════════════════════"
499    echo ""
500
501    # Run harness with the prompt (using full path)
502    {harness_cmd}
503
504    # Check if task is done
505    TASK_STATUS=$(scud show {task_id} 2>/dev/null | grep -i "status:" | awk '{{print $2}}')
506
507    if [ "$TASK_STATUS" = "done" ]; then
508        echo ""
509        echo "✅ Task {task_id} completed successfully after $RALPH_ITER iterations!"
510        rm -f '{prompt_file}'
511        break
512    fi
513
514    # Check max iterations
515    if [ $RALPH_ITER -ge $RALPH_MAX_ITER ]; then
516        echo ""
517        echo "⚠️  Ralph loop: Max iterations ($RALPH_MAX_ITER) reached for task {task_id}"
518        echo "   Task status: $TASK_STATUS"
519        rm -f '{prompt_file}'
520        break
521    fi
522
523    # Small delay before next iteration
524    echo ""
525    echo "🔄 Task not yet complete (status: $TASK_STATUS). Continuing loop..."
526    sleep 2
527done
528"#,
529        task_id = task_id,
530        promise = completion_promise,
531        prompt_file = prompt_file.display(),
532        harness_name = harness.name(),
533        harness_cmd = harness_cmd,
534    );
535
536    // Write the Ralph script to a temp file
537    let script_file = std::env::temp_dir().join(format!("scud-ralph-script-{}.sh", task_id));
538    std::fs::write(&script_file, &ralph_script)?;
539
540    // Run it with bash explicitly (works in any shell including fish)
541    let cmd = format!("bash '{}'", script_file.display());
542
543    let target = format!("{}:{}", session_name, window_index);
544    let send_result = Command::new("tmux")
545        .args(["send-keys", "-t", &target, &cmd, "Enter"])
546        .output()
547        .context("Failed to send command to tmux window")?;
548
549    if !send_result.status.success() {
550        anyhow::bail!(
551            "Failed to send keys: {}",
552            String::from_utf8_lossy(&send_result.stderr)
553        );
554    }
555
556    Ok(())
557}
558
559/// Check if a tmux session exists
560pub fn tmux_session_exists(session_name: &str) -> bool {
561    Command::new("tmux")
562        .args(["has-session", "-t", session_name])
563        .status()
564        .map(|s| s.success())
565        .unwrap_or(false)
566}
567
568/// Attach to a tmux session
569pub fn tmux_attach(session_name: &str) -> Result<()> {
570    // Use exec to replace current process with tmux attach
571    let status = Command::new("tmux")
572        .args(["attach", "-t", session_name])
573        .status()
574        .context("Failed to attach to tmux session")?;
575
576    if !status.success() {
577        anyhow::bail!("tmux attach failed");
578    }
579
580    Ok(())
581}
582
583/// Setup the control window in a tmux session with monitoring script
584pub fn setup_tmux_control_window(session_name: &str, tag: &str) -> Result<()> {
585    let control_script = format!(
586        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'"#,
587        session_name, tag, tag, tag
588    );
589
590    let target = format!("{}:ctrl", session_name);
591    Command::new("tmux")
592        .args(["send-keys", "-t", &target, &control_script, "Enter"])
593        .status()
594        .context("Failed to setup control window")?;
595
596    Ok(())
597}
598
599/// Check if a specific window exists in a tmux session
600pub fn tmux_window_exists(session_name: &str, window_name: &str) -> bool {
601    let output = Command::new("tmux")
602        .args(["list-windows", "-t", session_name, "-F", "#{window_name}"])
603        .output();
604
605    match output {
606        Ok(out) if out.status.success() => {
607            let windows = String::from_utf8_lossy(&out.stdout);
608            windows
609                .lines()
610                .any(|w| w == window_name || w.starts_with(&format!("{}-", window_name)))
611        }
612        _ => false,
613    }
614}
615
616/// Kill a specific tmux window
617pub fn kill_tmux_window(session_name: &str, window_name: &str) -> Result<()> {
618    let target = format!("{}:{}", session_name, window_name);
619    Command::new("tmux")
620        .args(["kill-window", "-t", &target])
621        .output()?;
622    Ok(())
623}
624
625/// Spawn a command in a tmux window (simpler than spawn_tmux which does more setup)
626pub fn spawn_in_tmux(
627    session_name: &str,
628    window_name: &str,
629    command: &str,
630    working_dir: &Path,
631) -> Result<()> {
632    // Check if session exists, create if not
633    let session_exists = Command::new("tmux")
634        .args(["has-session", "-t", session_name])
635        .output()
636        .map(|o| o.status.success())
637        .unwrap_or(false);
638
639    if !session_exists {
640        // Create new session with a control window
641        Command::new("tmux")
642            .args([
643                "new-session",
644                "-d",
645                "-s",
646                session_name,
647                "-n",
648                "ctrl",
649                "-c",
650                &working_dir.to_string_lossy(),
651            ])
652            .output()
653            .context("Failed to create tmux session")?;
654    }
655
656    // Create new window for this task
657    let output = Command::new("tmux")
658        .args([
659            "new-window",
660            "-t",
661            session_name,
662            "-n",
663            window_name,
664            "-c",
665            &working_dir.to_string_lossy(),
666            "-P",
667            "-F",
668            "#{window_index}",
669        ])
670        .output()
671        .context("Failed to create tmux window")?;
672
673    if !output.status.success() {
674        anyhow::bail!(
675            "Failed to create tmux window: {}",
676            String::from_utf8_lossy(&output.stderr)
677        );
678    }
679
680    let window_index = String::from_utf8_lossy(&output.stdout).trim().to_string();
681
682    // Send the command to the window
683    let send_result = Command::new("tmux")
684        .args([
685            "send-keys",
686            "-t",
687            &format!("{}:{}", session_name, window_index),
688            command,
689            "Enter",
690        ])
691        .output()
692        .context("Failed to send command to tmux window")?;
693
694    if !send_result.status.success() {
695        anyhow::bail!(
696            "Failed to send command: {}",
697            String::from_utf8_lossy(&send_result.stderr)
698        );
699    }
700
701    Ok(())
702}