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        None, // No task list ID (legacy mode)
233    )
234}
235
236/// Spawn a new tmux window with Claude Code task list integration
237/// Returns the tmux window index for easy attachment (e.g., "3" for session:3)
238///
239/// This variant sets `CLAUDE_CODE_TASK_LIST_ID` so agents can see SCUD tasks
240/// via the native `TaskList` tool.
241pub fn spawn_terminal_with_task_list(
242    task_id: &str,
243    prompt: &str,
244    working_dir: &Path,
245    session_name: &str,
246    harness: Harness,
247    model: Option<&str>,
248    task_list_id: &str,
249) -> Result<String> {
250    let binary_path = find_harness_binary(harness)?;
251    spawn_tmux(
252        task_id,
253        prompt,
254        working_dir,
255        session_name,
256        binary_path,
257        harness,
258        model,
259        Some(task_list_id),
260    )
261}
262
263/// Spawn in tmux session
264/// Returns the tmux window index for easy attachment (e.g., "3" for session:3)
265fn spawn_tmux(
266    task_id: &str,
267    prompt: &str,
268    working_dir: &Path,
269    session_name: &str,
270    binary_path: &str,
271    harness: Harness,
272    model: Option<&str>,
273    task_list_id: Option<&str>,
274) -> Result<String> {
275    let window_name = format!("task-{}", task_id);
276
277    // Check if session exists
278    let session_exists = Command::new("tmux")
279        .args(["has-session", "-t", session_name])
280        .status()
281        .map(|s| s.success())
282        .unwrap_or(false);
283
284    if !session_exists {
285        // Create new session with control window
286        Command::new("tmux")
287            .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
288            .arg("-c")
289            .arg(working_dir)
290            .status()
291            .context("Failed to create tmux session")?;
292    }
293
294    // Create new window for this task and capture its index
295    // Use -P -F to print the new window's index
296    let new_window_output = Command::new("tmux")
297        .args([
298            "new-window",
299            "-t",
300            session_name,
301            "-n",
302            &window_name,
303            "-P", // Print info about new window
304            "-F",
305            "#{window_index}", // Format: just the index
306        ])
307        .arg("-c")
308        .arg(working_dir)
309        .output()
310        .context("Failed to create tmux window")?;
311
312    if !new_window_output.status.success() {
313        anyhow::bail!(
314            "Failed to create window: {}",
315            String::from_utf8_lossy(&new_window_output.stderr)
316        );
317    }
318
319    let window_index = String::from_utf8_lossy(&new_window_output.stdout)
320        .trim()
321        .to_string();
322
323    // Write prompt to temp file
324    let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
325    std::fs::write(&prompt_file, prompt)?;
326
327    // Send command to the window BY INDEX (not name, which can be ambiguous)
328    // Interactive mode with SCUD_TASK_ID for hook integration
329    // Use full path to harness binary to avoid PATH issues in spawned shells
330    // Source shell profile to ensure PATH includes node, etc.
331    let harness_cmd = harness.command(binary_path, &prompt_file, model);
332
333    // Build the task list ID export line if provided
334    let task_list_export = task_list_id
335        .map(|id| format!("export CLAUDE_CODE_TASK_LIST_ID='{}'\n", id))
336        .unwrap_or_default();
337
338    // Write a bash script to handle shell-agnostic execution
339    // This ensures it works even if the user's shell is fish, zsh, etc.
340    let spawn_script = format!(
341        r#"#!/usr/bin/env bash
342# Source shell profile for PATH setup
343source ~/.bash_profile 2>/dev/null
344source ~/.zshrc 2>/dev/null
345export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
346[ -s "$HOME/.nvm/nvm.sh" ] && source "$HOME/.nvm/nvm.sh"
347
348export SCUD_TASK_ID='{task_id}'
349{task_list_export}{harness_cmd}
350rm -f '{prompt_file}'
351"#,
352        task_id = task_id,
353        task_list_export = task_list_export,
354        harness_cmd = harness_cmd,
355        prompt_file = prompt_file.display()
356    );
357
358    let script_file = std::env::temp_dir().join(format!("scud-spawn-{}.sh", task_id));
359    std::fs::write(&script_file, &spawn_script)?;
360
361    // Run the script with bash explicitly (works in any shell including fish)
362    let run_cmd = format!("bash '{}'", script_file.display());
363
364    let target = format!("{}:{}", session_name, window_index);
365    let send_result = Command::new("tmux")
366        .args(["send-keys", "-t", &target, &run_cmd, "Enter"])
367        .output()
368        .context("Failed to send command to tmux window")?;
369
370    if !send_result.status.success() {
371        anyhow::bail!(
372            "Failed to send keys: {}",
373            String::from_utf8_lossy(&send_result.stderr)
374        );
375    }
376
377    Ok(window_index)
378}
379
380/// Spawn a new tmux window with Ralph loop enabled
381/// The agent will keep running until the completion promise is detected
382pub fn spawn_terminal_ralph(
383    task_id: &str,
384    prompt: &str,
385    working_dir: &Path,
386    session_name: &str,
387    completion_promise: &str,
388) -> Result<()> {
389    // Default to Claude harness
390    spawn_terminal_ralph_with_harness(
391        task_id,
392        prompt,
393        working_dir,
394        session_name,
395        completion_promise,
396        Harness::Claude,
397    )
398}
399
400/// Spawn a new tmux window with Ralph loop enabled using a specific harness
401pub fn spawn_terminal_ralph_with_harness(
402    task_id: &str,
403    prompt: &str,
404    working_dir: &Path,
405    session_name: &str,
406    completion_promise: &str,
407    harness: Harness,
408) -> Result<()> {
409    // Find harness binary path upfront to fail fast if not found
410    let binary_path = find_harness_binary(harness)?;
411    spawn_tmux_ralph(
412        task_id,
413        prompt,
414        working_dir,
415        session_name,
416        completion_promise,
417        binary_path,
418        harness,
419    )
420}
421
422/// Spawn in tmux session with Ralph loop wrapper
423fn spawn_tmux_ralph(
424    task_id: &str,
425    prompt: &str,
426    working_dir: &Path,
427    session_name: &str,
428    completion_promise: &str,
429    binary_path: &str,
430    harness: Harness,
431) -> Result<()> {
432    let window_name = format!("ralph-{}", task_id);
433
434    // Check if session exists
435    let session_exists = Command::new("tmux")
436        .args(["has-session", "-t", session_name])
437        .status()
438        .map(|s| s.success())
439        .unwrap_or(false);
440
441    if !session_exists {
442        // Create new session with control window
443        Command::new("tmux")
444            .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
445            .arg("-c")
446            .arg(working_dir)
447            .status()
448            .context("Failed to create tmux session")?;
449    }
450
451    // Create new window for this task
452    let new_window_output = Command::new("tmux")
453        .args([
454            "new-window",
455            "-t",
456            session_name,
457            "-n",
458            &window_name,
459            "-P",
460            "-F",
461            "#{window_index}",
462        ])
463        .arg("-c")
464        .arg(working_dir)
465        .output()
466        .context("Failed to create tmux window")?;
467
468    if !new_window_output.status.success() {
469        anyhow::bail!(
470            "Failed to create window: {}",
471            String::from_utf8_lossy(&new_window_output.stderr)
472        );
473    }
474
475    let window_index = String::from_utf8_lossy(&new_window_output.stdout)
476        .trim()
477        .to_string();
478
479    // Write prompt to temp file
480    let prompt_file = std::env::temp_dir().join(format!("scud-ralph-{}.txt", task_id));
481    std::fs::write(&prompt_file, prompt)?;
482
483    // Build the harness-specific command for the ralph script
484    // We need to inline this since the script is a bash heredoc
485    let harness_cmd = match harness {
486        Harness::Claude => format!(
487            "'{binary_path}' \"$(cat '{prompt_file}')\" --dangerously-skip-permissions",
488            binary_path = binary_path,
489            prompt_file = prompt_file.display()
490        ),
491        Harness::OpenCode => format!(
492            "'{binary_path}' run --variant minimal \"$(cat '{prompt_file}')\"",
493            binary_path = binary_path,
494            prompt_file = prompt_file.display()
495        ),
496    };
497
498    // Create a Ralph loop script that:
499    // 1. Runs the harness with the prompt
500    // 2. Checks if the task was marked done (via scud show)
501    // 3. If not done, loops back and runs the harness again with the same prompt
502    // 4. Continues until task is done or max iterations
503    // Use full path to harness binary to avoid PATH issues in spawned shells
504    // Source shell profile to ensure PATH includes node, etc.
505    let ralph_script = format!(
506        r#"#!/usr/bin/env bash
507# Source shell profile for PATH setup
508[ -f /etc/profile ] && . /etc/profile
509[ -f ~/.profile ] && . ~/.profile
510[ -f ~/.bash_profile ] && . ~/.bash_profile
511[ -f ~/.bashrc ] && . ~/.bashrc
512[ -f ~/.zshrc ] && . ~/.zshrc 2>/dev/null
513export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
514[ -s "$HOME/.nvm/nvm.sh" ] && . "$HOME/.nvm/nvm.sh"
515[ -s "$HOME/.bun/_bun" ] && . "$HOME/.bun/_bun"
516
517export SCUD_TASK_ID='{task_id}'
518export RALPH_PROMISE='{promise}'
519export RALPH_MAX_ITER=50
520export RALPH_ITER=0
521
522echo "🔄 Ralph loop starting for task {task_id}"
523echo "   Harness: {harness_name}"
524echo "   Completion promise: {promise}"
525echo "   Max iterations: $RALPH_MAX_ITER"
526echo ""
527
528while true; do
529    RALPH_ITER=$((RALPH_ITER + 1))
530    echo ""
531    echo "═══════════════════════════════════════════════════════════"
532    echo "🔄 RALPH ITERATION $RALPH_ITER / $RALPH_MAX_ITER"
533    echo "═══════════════════════════════════════════════════════════"
534    echo ""
535
536    # Run harness with the prompt (using full path)
537    {harness_cmd}
538
539    # Check if task is done
540    TASK_STATUS=$(scud show {task_id} 2>/dev/null | grep -i "status:" | awk '{{print $2}}')
541
542    if [ "$TASK_STATUS" = "done" ]; then
543        echo ""
544        echo "✅ Task {task_id} completed successfully after $RALPH_ITER iterations!"
545        rm -f '{prompt_file}'
546        break
547    fi
548
549    # Check max iterations
550    if [ $RALPH_ITER -ge $RALPH_MAX_ITER ]; then
551        echo ""
552        echo "⚠️  Ralph loop: Max iterations ($RALPH_MAX_ITER) reached for task {task_id}"
553        echo "   Task status: $TASK_STATUS"
554        rm -f '{prompt_file}'
555        break
556    fi
557
558    # Small delay before next iteration
559    echo ""
560    echo "🔄 Task not yet complete (status: $TASK_STATUS). Continuing loop..."
561    sleep 2
562done
563"#,
564        task_id = task_id,
565        promise = completion_promise,
566        prompt_file = prompt_file.display(),
567        harness_name = harness.name(),
568        harness_cmd = harness_cmd,
569    );
570
571    // Write the Ralph script to a temp file
572    let script_file = std::env::temp_dir().join(format!("scud-ralph-script-{}.sh", task_id));
573    std::fs::write(&script_file, &ralph_script)?;
574
575    // Run it with bash explicitly (works in any shell including fish)
576    let cmd = format!("bash '{}'", script_file.display());
577
578    let target = format!("{}:{}", session_name, window_index);
579    let send_result = Command::new("tmux")
580        .args(["send-keys", "-t", &target, &cmd, "Enter"])
581        .output()
582        .context("Failed to send command to tmux window")?;
583
584    if !send_result.status.success() {
585        anyhow::bail!(
586            "Failed to send keys: {}",
587            String::from_utf8_lossy(&send_result.stderr)
588        );
589    }
590
591    Ok(())
592}
593
594/// Check if a tmux session exists
595pub fn tmux_session_exists(session_name: &str) -> bool {
596    Command::new("tmux")
597        .args(["has-session", "-t", session_name])
598        .status()
599        .map(|s| s.success())
600        .unwrap_or(false)
601}
602
603/// Attach to a tmux session
604pub fn tmux_attach(session_name: &str) -> Result<()> {
605    // Use exec to replace current process with tmux attach
606    let status = Command::new("tmux")
607        .args(["attach", "-t", session_name])
608        .status()
609        .context("Failed to attach to tmux session")?;
610
611    if !status.success() {
612        anyhow::bail!("tmux attach failed");
613    }
614
615    Ok(())
616}
617
618/// Setup the control window in a tmux session with monitoring script
619pub fn setup_tmux_control_window(session_name: &str, tag: &str) -> Result<()> {
620    let control_script = format!(
621        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'"#,
622        session_name, tag, tag, tag
623    );
624
625    let target = format!("{}:ctrl", session_name);
626    Command::new("tmux")
627        .args(["send-keys", "-t", &target, &control_script, "Enter"])
628        .status()
629        .context("Failed to setup control window")?;
630
631    Ok(())
632}
633
634/// Check if a specific window exists in a tmux session
635pub fn tmux_window_exists(session_name: &str, window_name: &str) -> bool {
636    let output = Command::new("tmux")
637        .args(["list-windows", "-t", session_name, "-F", "#{window_name}"])
638        .output();
639
640    match output {
641        Ok(out) if out.status.success() => {
642            let windows = String::from_utf8_lossy(&out.stdout);
643            windows
644                .lines()
645                .any(|w| w == window_name || w.starts_with(&format!("{}-", window_name)))
646        }
647        _ => false,
648    }
649}
650
651/// Kill a specific tmux window
652pub fn kill_tmux_window(session_name: &str, window_name: &str) -> Result<()> {
653    let target = format!("{}:{}", session_name, window_name);
654    Command::new("tmux")
655        .args(["kill-window", "-t", &target])
656        .output()?;
657    Ok(())
658}
659
660/// Spawn a command in a tmux window (simpler than spawn_tmux which does more setup)
661pub fn spawn_in_tmux(
662    session_name: &str,
663    window_name: &str,
664    command: &str,
665    working_dir: &Path,
666) -> Result<()> {
667    // Check if session exists, create if not
668    let session_exists = Command::new("tmux")
669        .args(["has-session", "-t", session_name])
670        .output()
671        .map(|o| o.status.success())
672        .unwrap_or(false);
673
674    if !session_exists {
675        // Create new session with a control window
676        Command::new("tmux")
677            .args([
678                "new-session",
679                "-d",
680                "-s",
681                session_name,
682                "-n",
683                "ctrl",
684                "-c",
685                &working_dir.to_string_lossy(),
686            ])
687            .output()
688            .context("Failed to create tmux session")?;
689    }
690
691    // Create new window for this task
692    let output = Command::new("tmux")
693        .args([
694            "new-window",
695            "-t",
696            session_name,
697            "-n",
698            window_name,
699            "-c",
700            &working_dir.to_string_lossy(),
701            "-P",
702            "-F",
703            "#{window_index}",
704        ])
705        .output()
706        .context("Failed to create tmux window")?;
707
708    if !output.status.success() {
709        anyhow::bail!(
710            "Failed to create tmux window: {}",
711            String::from_utf8_lossy(&output.stderr)
712        );
713    }
714
715    let window_index = String::from_utf8_lossy(&output.stdout).trim().to_string();
716
717    // Send the command to the window
718    let send_result = Command::new("tmux")
719        .args([
720            "send-keys",
721            "-t",
722            &format!("{}:{}", session_name, window_index),
723            command,
724            "Enter",
725        ])
726        .output()
727        .context("Failed to send command to tmux window")?;
728
729    if !send_result.status.success() {
730        anyhow::bail!(
731            "Failed to send command: {}",
732            String::from_utf8_lossy(&send_result.stderr)
733        );
734    }
735
736    Ok(())
737}