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
302    // Write a bash script to handle shell-agnostic execution
303    // This ensures it works even if the user's shell is fish, zsh, etc.
304    let spawn_script = format!(
305        r#"#!/usr/bin/env bash
306# Source shell profile for PATH setup
307source ~/.bash_profile 2>/dev/null
308source ~/.zshrc 2>/dev/null
309export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
310[ -s "$HOME/.nvm/nvm.sh" ] && source "$HOME/.nvm/nvm.sh"
311
312export SCUD_TASK_ID='{task_id}'
313{harness_cmd}
314rm -f '{prompt_file}'
315"#,
316        task_id = task_id,
317        harness_cmd = harness_cmd,
318        prompt_file = prompt_file.display()
319    );
320
321    let script_file = std::env::temp_dir().join(format!("scud-spawn-{}.sh", task_id));
322    std::fs::write(&script_file, &spawn_script)?;
323
324    // Run the script with bash explicitly (works in any shell including fish)
325    let run_cmd = format!("bash '{}'", script_file.display());
326
327    let target = format!("{}:{}", session_name, window_index);
328    let send_result = Command::new("tmux")
329        .args(["send-keys", "-t", &target, &run_cmd, "Enter"])
330        .output()
331        .context("Failed to send command to tmux window")?;
332
333    if !send_result.status.success() {
334        anyhow::bail!(
335            "Failed to send keys: {}",
336            String::from_utf8_lossy(&send_result.stderr)
337        );
338    }
339
340    Ok(window_index)
341}
342
343/// Spawn a new tmux window with Ralph loop enabled
344/// The agent will keep running until the completion promise is detected
345pub fn spawn_terminal_ralph(
346    task_id: &str,
347    prompt: &str,
348    working_dir: &Path,
349    session_name: &str,
350    completion_promise: &str,
351) -> Result<()> {
352    // Default to Claude harness
353    spawn_terminal_ralph_with_harness(
354        task_id,
355        prompt,
356        working_dir,
357        session_name,
358        completion_promise,
359        Harness::Claude,
360    )
361}
362
363/// Spawn a new tmux window with Ralph loop enabled using a specific harness
364pub fn spawn_terminal_ralph_with_harness(
365    task_id: &str,
366    prompt: &str,
367    working_dir: &Path,
368    session_name: &str,
369    completion_promise: &str,
370    harness: Harness,
371) -> Result<()> {
372    // Find harness binary path upfront to fail fast if not found
373    let binary_path = find_harness_binary(harness)?;
374    spawn_tmux_ralph(
375        task_id,
376        prompt,
377        working_dir,
378        session_name,
379        completion_promise,
380        binary_path,
381        harness,
382    )
383}
384
385/// Spawn in tmux session with Ralph loop wrapper
386fn spawn_tmux_ralph(
387    task_id: &str,
388    prompt: &str,
389    working_dir: &Path,
390    session_name: &str,
391    completion_promise: &str,
392    binary_path: &str,
393    harness: Harness,
394) -> Result<()> {
395    let window_name = format!("ralph-{}", task_id);
396
397    // Check if session exists
398    let session_exists = Command::new("tmux")
399        .args(["has-session", "-t", session_name])
400        .status()
401        .map(|s| s.success())
402        .unwrap_or(false);
403
404    if !session_exists {
405        // Create new session with control window
406        Command::new("tmux")
407            .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
408            .arg("-c")
409            .arg(working_dir)
410            .status()
411            .context("Failed to create tmux session")?;
412    }
413
414    // Create new window for this task
415    let new_window_output = Command::new("tmux")
416        .args([
417            "new-window",
418            "-t",
419            session_name,
420            "-n",
421            &window_name,
422            "-P",
423            "-F",
424            "#{window_index}",
425        ])
426        .arg("-c")
427        .arg(working_dir)
428        .output()
429        .context("Failed to create tmux window")?;
430
431    if !new_window_output.status.success() {
432        anyhow::bail!(
433            "Failed to create window: {}",
434            String::from_utf8_lossy(&new_window_output.stderr)
435        );
436    }
437
438    let window_index = String::from_utf8_lossy(&new_window_output.stdout)
439        .trim()
440        .to_string();
441
442    // Write prompt to temp file
443    let prompt_file = std::env::temp_dir().join(format!("scud-ralph-{}.txt", task_id));
444    std::fs::write(&prompt_file, prompt)?;
445
446    // Build the harness-specific command for the ralph script
447    // We need to inline this since the script is a bash heredoc
448    let harness_cmd = match harness {
449        Harness::Claude => format!(
450            "'{binary_path}' \"$(cat '{prompt_file}')\" --dangerously-skip-permissions",
451            binary_path = binary_path,
452            prompt_file = prompt_file.display()
453        ),
454        Harness::OpenCode => format!(
455            "'{binary_path}' run \"$(cat '{prompt_file}')\"",
456            binary_path = binary_path,
457            prompt_file = prompt_file.display()
458        ),
459    };
460
461    // Create a Ralph loop script that:
462    // 1. Runs the harness with the prompt
463    // 2. Checks if the task was marked done (via scud show)
464    // 3. If not done, loops back and runs the harness again with the same prompt
465    // 4. Continues until task is done or max iterations
466    // Use full path to harness binary to avoid PATH issues in spawned shells
467    // Source shell profile to ensure PATH includes node, etc.
468    let ralph_script = format!(
469        r#"#!/usr/bin/env bash
470# Source shell profile for PATH setup
471[ -f /etc/profile ] && . /etc/profile
472[ -f ~/.profile ] && . ~/.profile
473[ -f ~/.bash_profile ] && . ~/.bash_profile
474[ -f ~/.bashrc ] && . ~/.bashrc
475[ -f ~/.zshrc ] && . ~/.zshrc 2>/dev/null
476export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
477[ -s "$HOME/.nvm/nvm.sh" ] && . "$HOME/.nvm/nvm.sh"
478[ -s "$HOME/.bun/_bun" ] && . "$HOME/.bun/_bun"
479
480export SCUD_TASK_ID='{task_id}'
481export RALPH_PROMISE='{promise}'
482export RALPH_MAX_ITER=50
483export RALPH_ITER=0
484
485echo "🔄 Ralph loop starting for task {task_id}"
486echo "   Harness: {harness_name}"
487echo "   Completion promise: {promise}"
488echo "   Max iterations: $RALPH_MAX_ITER"
489echo ""
490
491while true; do
492    RALPH_ITER=$((RALPH_ITER + 1))
493    echo ""
494    echo "═══════════════════════════════════════════════════════════"
495    echo "🔄 RALPH ITERATION $RALPH_ITER / $RALPH_MAX_ITER"
496    echo "═══════════════════════════════════════════════════════════"
497    echo ""
498
499    # Run harness with the prompt (using full path)
500    {harness_cmd}
501
502    # Check if task is done
503    TASK_STATUS=$(scud show {task_id} 2>/dev/null | grep -i "status:" | awk '{{print $2}}')
504
505    if [ "$TASK_STATUS" = "done" ]; then
506        echo ""
507        echo "✅ Task {task_id} completed successfully after $RALPH_ITER iterations!"
508        rm -f '{prompt_file}'
509        break
510    fi
511
512    # Check max iterations
513    if [ $RALPH_ITER -ge $RALPH_MAX_ITER ]; then
514        echo ""
515        echo "⚠️  Ralph loop: Max iterations ($RALPH_MAX_ITER) reached for task {task_id}"
516        echo "   Task status: $TASK_STATUS"
517        rm -f '{prompt_file}'
518        break
519    fi
520
521    # Small delay before next iteration
522    echo ""
523    echo "🔄 Task not yet complete (status: $TASK_STATUS). Continuing loop..."
524    sleep 2
525done
526"#,
527        task_id = task_id,
528        promise = completion_promise,
529        prompt_file = prompt_file.display(),
530        harness_name = harness.name(),
531        harness_cmd = harness_cmd,
532    );
533
534    // Write the Ralph script to a temp file
535    let script_file = std::env::temp_dir().join(format!("scud-ralph-script-{}.sh", task_id));
536    std::fs::write(&script_file, &ralph_script)?;
537
538    // Run it with bash explicitly (works in any shell including fish)
539    let cmd = format!("bash '{}'", script_file.display());
540
541    let target = format!("{}:{}", session_name, window_index);
542    let send_result = Command::new("tmux")
543        .args(["send-keys", "-t", &target, &cmd, "Enter"])
544        .output()
545        .context("Failed to send command to tmux window")?;
546
547    if !send_result.status.success() {
548        anyhow::bail!(
549            "Failed to send keys: {}",
550            String::from_utf8_lossy(&send_result.stderr)
551        );
552    }
553
554    Ok(())
555}
556
557/// Check if a tmux session exists
558pub fn tmux_session_exists(session_name: &str) -> bool {
559    Command::new("tmux")
560        .args(["has-session", "-t", session_name])
561        .status()
562        .map(|s| s.success())
563        .unwrap_or(false)
564}
565
566/// Attach to a tmux session
567pub fn tmux_attach(session_name: &str) -> Result<()> {
568    // Use exec to replace current process with tmux attach
569    let status = Command::new("tmux")
570        .args(["attach", "-t", session_name])
571        .status()
572        .context("Failed to attach to tmux session")?;
573
574    if !status.success() {
575        anyhow::bail!("tmux attach failed");
576    }
577
578    Ok(())
579}
580
581/// Setup the control window in a tmux session with monitoring script
582pub fn setup_tmux_control_window(session_name: &str, tag: &str) -> Result<()> {
583    let control_script = format!(
584        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'"#,
585        session_name, tag, tag, tag
586    );
587
588    let target = format!("{}:ctrl", session_name);
589    Command::new("tmux")
590        .args(["send-keys", "-t", &target, &control_script, "Enter"])
591        .status()
592        .context("Failed to setup control window")?;
593
594    Ok(())
595}
596
597/// Check if a specific window exists in a tmux session
598pub fn tmux_window_exists(session_name: &str, window_name: &str) -> bool {
599    let output = Command::new("tmux")
600        .args(["list-windows", "-t", session_name, "-F", "#{window_name}"])
601        .output();
602
603    match output {
604        Ok(out) if out.status.success() => {
605            let windows = String::from_utf8_lossy(&out.stdout);
606            windows
607                .lines()
608                .any(|w| w == window_name || w.starts_with(&format!("{}-", window_name)))
609        }
610        _ => false,
611    }
612}
613
614/// Kill a specific tmux window
615pub fn kill_tmux_window(session_name: &str, window_name: &str) -> Result<()> {
616    let target = format!("{}:{}", session_name, window_name);
617    Command::new("tmux")
618        .args(["kill-window", "-t", &target])
619        .output()?;
620    Ok(())
621}