Skip to main content

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    /// Cursor Agent CLI
20    Cursor,
21    /// Direct Anthropic API (no external CLI needed)
22    #[cfg(feature = "direct-api")]
23    DirectApi,
24}
25
26impl Harness {
27    /// Parse harness from string
28    pub fn parse(s: &str) -> Result<Self> {
29        match s.to_lowercase().as_str() {
30            "claude" | "claude-code" => Ok(Harness::Claude),
31            "opencode" | "open-code" | "xai" => Ok(Harness::OpenCode),
32            "cursor" | "cursor-agent" => Ok(Harness::Cursor),
33            #[cfg(feature = "direct-api")]
34            "direct-api" | "direct" | "api" => Ok(Harness::DirectApi),
35            other => anyhow::bail!("Unknown harness: '{}'. Supported: claude, opencode, cursor", other),
36        }
37    }
38
39    /// Display name
40    pub fn name(&self) -> &'static str {
41        match self {
42            Harness::Claude => "claude",
43            Harness::OpenCode => "opencode",
44            Harness::Cursor => "cursor",
45            #[cfg(feature = "direct-api")]
46            Harness::DirectApi => "direct-api",
47        }
48    }
49
50    /// Binary name to search for
51    pub fn binary_name(&self) -> &'static str {
52        match self {
53            Harness::Claude => "claude",
54            Harness::OpenCode => "opencode",
55            Harness::Cursor => "agent",
56            #[cfg(feature = "direct-api")]
57            Harness::DirectApi => "scud",
58        }
59    }
60
61    /// Generate the command to run with a prompt and optional model
62    pub fn command(&self, binary_path: &str, prompt_file: &Path, model: Option<&str>) -> String {
63        match self {
64            Harness::Claude => {
65                let model_flag = model.map(|m| format!(" --model {}", m)).unwrap_or_default();
66                format!(
67                    r#"'{}' "$(cat '{}')" --dangerously-skip-permissions{}"#,
68                    binary_path,
69                    prompt_file.display(),
70                    model_flag
71                )
72            }
73            Harness::OpenCode => {
74                let model_flag = model.map(|m| format!(" --model {}", m)).unwrap_or_default();
75                // Use --variant minimal to reduce reasoning overhead and avoid
76                // "reasoning part not found" errors with some models
77                format!(
78                    r#"'{}'{} run --variant minimal "$(cat '{}')""#,
79                    binary_path,
80                    model_flag,
81                    prompt_file.display()
82                )
83            }
84            Harness::Cursor => {
85                let model_flag = model.map(|m| format!(" --model {}", m)).unwrap_or_default();
86                format!(
87                    r#"'{}' -p{} "$(cat '{}')""#,
88                    binary_path,
89                    model_flag,
90                    prompt_file.display()
91                )
92            }
93            #[cfg(feature = "direct-api")]
94            Harness::DirectApi => {
95                let model_flag = model.map(|m| format!(" --model {}", m)).unwrap_or_default();
96                format!(
97                    r#"'{}' agent-exec --prompt-file '{}'{}"#,
98                    binary_path,
99                    prompt_file.display(),
100                    model_flag
101                )
102            }
103        }
104    }
105}
106
107/// Cached paths to harness binaries
108static CLAUDE_PATH: OnceLock<String> = OnceLock::new();
109static OPENCODE_PATH: OnceLock<String> = OnceLock::new();
110static CURSOR_PATH: OnceLock<String> = OnceLock::new();
111#[cfg(feature = "direct-api")]
112static SCUD_PATH: OnceLock<String> = OnceLock::new();
113
114/// Find the full path to a harness binary.
115/// Caches the result for subsequent calls.
116pub fn find_harness_binary(harness: Harness) -> Result<&'static str> {
117    let cache = match harness {
118        Harness::Claude => &CLAUDE_PATH,
119        Harness::OpenCode => &OPENCODE_PATH,
120        Harness::Cursor => &CURSOR_PATH,
121        #[cfg(feature = "direct-api")]
122        Harness::DirectApi => &SCUD_PATH,
123    };
124
125    // Check if already cached
126    if let Some(path) = cache.get() {
127        return Ok(path.as_str());
128    }
129
130    let binary_name = harness.binary_name();
131
132    // Try `which <binary>` to find it in PATH
133    let output = Command::new("which")
134        .arg(binary_name)
135        .output()
136        .context(format!("Failed to run 'which {}'", binary_name))?;
137
138    if output.status.success() {
139        let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
140        if !path.is_empty() {
141            // Cache and return
142            let _ = cache.set(path);
143            return Ok(cache.get().unwrap().as_str());
144        }
145    }
146
147    // Common installation paths as fallback
148    let common_paths: &[&str] = match harness {
149        Harness::Claude => &[
150            "/opt/homebrew/bin/claude",
151            "/usr/local/bin/claude",
152            "/usr/bin/claude",
153        ],
154        Harness::OpenCode => &[
155            "/opt/homebrew/bin/opencode",
156            "/usr/local/bin/opencode",
157            "/usr/bin/opencode",
158        ],
159        Harness::Cursor => &[
160            "/opt/homebrew/bin/agent",
161            "/usr/local/bin/agent",
162            "/usr/bin/agent",
163        ],
164        #[cfg(feature = "direct-api")]
165        Harness::DirectApi => &[
166            "/opt/homebrew/bin/scud",
167            "/usr/local/bin/scud",
168            "/usr/bin/scud",
169        ],
170    };
171
172    for path in common_paths {
173        if std::path::Path::new(path).exists() {
174            let _ = cache.set(path.to_string());
175            return Ok(cache.get().unwrap().as_str());
176        }
177    }
178
179    // Try home-relative paths
180    if let Ok(home) = std::env::var("HOME") {
181        let home_paths: Vec<String> = match harness {
182            Harness::Claude => vec![
183                format!("{}/.local/bin/claude", home),
184                format!("{}/.claude/local/claude", home),
185            ],
186            Harness::OpenCode => vec![
187                format!("{}/.local/bin/opencode", home),
188                format!("{}/.bun/bin/opencode", home),
189            ],
190            Harness::Cursor => vec![
191                format!("{}/.local/bin/agent", home),
192            ],
193            #[cfg(feature = "direct-api")]
194            Harness::DirectApi => vec![
195                format!("{}/.local/bin/scud", home),
196                format!("{}/.cargo/bin/scud", home),
197            ],
198        };
199
200        for path in home_paths {
201            if std::path::Path::new(&path).exists() {
202                let _ = cache.set(path);
203                return Ok(cache.get().unwrap().as_str());
204            }
205        }
206    }
207
208    let install_hint = match harness {
209        Harness::Claude => "Install with: npm install -g @anthropic-ai/claude-code",
210        Harness::OpenCode => "Install with: curl -fsSL https://opencode.ai/install | bash",
211        Harness::Cursor => "Install with: curl https://cursor.com/install -fsSL | bash",
212        #[cfg(feature = "direct-api")]
213        Harness::DirectApi => "Install with: cargo install scud-cli --features direct-api",
214    };
215
216    anyhow::bail!(
217        "Could not find '{}' binary. Please ensure it is installed and in PATH.\n{}",
218        binary_name,
219        install_hint
220    )
221}
222
223/// Find the full path to the claude binary (convenience wrapper).
224pub fn find_claude_binary() -> Result<&'static str> {
225    find_harness_binary(Harness::Claude)
226}
227
228/// Check if tmux is available
229pub fn check_tmux_available() -> Result<()> {
230    let result = Command::new("which")
231        .arg("tmux")
232        .output()
233        .context("Failed to check for tmux binary")?;
234
235    if !result.status.success() {
236        anyhow::bail!("tmux is not installed or not in PATH. Install with: brew install tmux (macOS) or apt install tmux (Linux)");
237    }
238
239    Ok(())
240}
241
242/// Spawn a new tmux window with the given command
243/// Returns the tmux window index for easy attachment (e.g., "3" for session:3)
244pub fn spawn_terminal(
245    task_id: &str,
246    prompt: &str,
247    working_dir: &Path,
248    session_name: &str,
249) -> Result<String> {
250    // Default to Claude harness for backwards compatibility
251    spawn_terminal_with_harness_and_model(
252        task_id,
253        prompt,
254        working_dir,
255        session_name,
256        Harness::Claude,
257        None,
258    )
259}
260
261/// Spawn a new tmux window with the given command using a specific harness
262/// Returns the tmux window index for easy attachment (e.g., "3" for session:3)
263pub fn spawn_terminal_with_harness(
264    task_id: &str,
265    prompt: &str,
266    working_dir: &Path,
267    session_name: &str,
268    harness: Harness,
269) -> Result<String> {
270    spawn_terminal_with_harness_and_model(task_id, prompt, working_dir, session_name, harness, None)
271}
272
273/// Spawn a new tmux window with the given command using a specific harness and model
274/// Returns the tmux window index for easy attachment (e.g., "3" for session:3)
275pub fn spawn_terminal_with_harness_and_model(
276    task_id: &str,
277    prompt: &str,
278    working_dir: &Path,
279    session_name: &str,
280    harness: Harness,
281    model: Option<&str>,
282) -> Result<String> {
283    // Find harness binary path upfront to fail fast if not found
284    let binary_path = find_harness_binary(harness)?;
285    spawn_tmux(
286        task_id,
287        prompt,
288        working_dir,
289        session_name,
290        binary_path,
291        harness,
292        model,
293        None, // No task list ID (legacy mode)
294    )
295}
296
297/// Spawn a new tmux window with Claude Code task list integration
298/// Returns the tmux window index for easy attachment (e.g., "3" for session:3)
299///
300/// This variant sets `CLAUDE_CODE_TASK_LIST_ID` so agents can see SCUD tasks
301/// via the native `TaskList` tool.
302pub fn spawn_terminal_with_task_list(
303    task_id: &str,
304    prompt: &str,
305    working_dir: &Path,
306    session_name: &str,
307    harness: Harness,
308    model: Option<&str>,
309    task_list_id: &str,
310) -> Result<String> {
311    let binary_path = find_harness_binary(harness)?;
312    spawn_tmux(
313        task_id,
314        prompt,
315        working_dir,
316        session_name,
317        binary_path,
318        harness,
319        model,
320        Some(task_list_id),
321    )
322}
323
324/// Spawn in tmux session
325/// Returns the tmux window index for easy attachment (e.g., "3" for session:3)
326#[allow(clippy::too_many_arguments)]
327fn spawn_tmux(
328    task_id: &str,
329    prompt: &str,
330    working_dir: &Path,
331    session_name: &str,
332    binary_path: &str,
333    harness: Harness,
334    model: Option<&str>,
335    task_list_id: Option<&str>,
336) -> Result<String> {
337    let window_name = format!("task-{}", task_id);
338
339    // Check if session exists
340    let session_exists = Command::new("tmux")
341        .args(["has-session", "-t", session_name])
342        .status()
343        .map(|s| s.success())
344        .unwrap_or(false);
345
346    if !session_exists {
347        // Create new session with control window
348        Command::new("tmux")
349            .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
350            .arg("-c")
351            .arg(working_dir)
352            .status()
353            .context("Failed to create tmux session")?;
354    }
355
356    // Create new window for this task and capture its index
357    // Use -P -F to print the new window's index
358    let new_window_output = Command::new("tmux")
359        .args([
360            "new-window",
361            "-t",
362            session_name,
363            "-n",
364            &window_name,
365            "-P", // Print info about new window
366            "-F",
367            "#{window_index}", // Format: just the index
368        ])
369        .arg("-c")
370        .arg(working_dir)
371        .output()
372        .context("Failed to create tmux window")?;
373
374    if !new_window_output.status.success() {
375        anyhow::bail!(
376            "Failed to create window: {}",
377            String::from_utf8_lossy(&new_window_output.stderr)
378        );
379    }
380
381    let window_index = String::from_utf8_lossy(&new_window_output.stdout)
382        .trim()
383        .to_string();
384
385    // Write prompt to temp file
386    let prompt_file = std::env::temp_dir().join(format!("scud-prompt-{}.txt", task_id));
387    std::fs::write(&prompt_file, prompt)?;
388
389    // Send command to the window BY INDEX (not name, which can be ambiguous)
390    // Interactive mode with SCUD_TASK_ID for hook integration
391    // Use full path to harness binary to avoid PATH issues in spawned shells
392    // Source shell profile to ensure PATH includes node, etc.
393    let harness_cmd = harness.command(binary_path, &prompt_file, model);
394
395    // Build the task list ID export line if provided
396    let task_list_export = task_list_id
397        .map(|id| format!("export CLAUDE_CODE_TASK_LIST_ID='{}'\n", id))
398        .unwrap_or_default();
399
400    // Write a bash script to handle shell-agnostic execution
401    // This ensures it works even if the user's shell is fish, zsh, etc.
402    let spawn_script = format!(
403        r#"#!/usr/bin/env bash
404# Source shell profile for PATH setup
405source ~/.bash_profile 2>/dev/null
406source ~/.zshrc 2>/dev/null
407export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
408[ -s "$HOME/.nvm/nvm.sh" ] && source "$HOME/.nvm/nvm.sh"
409
410export SCUD_TASK_ID='{task_id}'
411{task_list_export}{harness_cmd}
412rm -f '{prompt_file}'
413"#,
414        task_id = task_id,
415        task_list_export = task_list_export,
416        harness_cmd = harness_cmd,
417        prompt_file = prompt_file.display()
418    );
419
420    let script_file = std::env::temp_dir().join(format!("scud-spawn-{}.sh", task_id));
421    std::fs::write(&script_file, &spawn_script)?;
422
423    // Run the script with bash explicitly (works in any shell including fish)
424    let run_cmd = format!("bash '{}'", script_file.display());
425
426    let target = format!("{}:{}", session_name, window_index);
427    let send_result = Command::new("tmux")
428        .args(["send-keys", "-t", &target, &run_cmd, "Enter"])
429        .output()
430        .context("Failed to send command to tmux window")?;
431
432    if !send_result.status.success() {
433        anyhow::bail!(
434            "Failed to send keys: {}",
435            String::from_utf8_lossy(&send_result.stderr)
436        );
437    }
438
439    Ok(window_index)
440}
441
442/// Spawn a new tmux window with Ralph loop enabled
443/// The agent will keep running until the completion promise is detected
444pub fn spawn_terminal_ralph(
445    task_id: &str,
446    prompt: &str,
447    working_dir: &Path,
448    session_name: &str,
449    completion_promise: &str,
450) -> Result<()> {
451    // Default to Claude harness
452    spawn_terminal_ralph_with_harness(
453        task_id,
454        prompt,
455        working_dir,
456        session_name,
457        completion_promise,
458        Harness::Claude,
459    )
460}
461
462/// Spawn a new tmux window with Ralph loop enabled using a specific harness
463pub fn spawn_terminal_ralph_with_harness(
464    task_id: &str,
465    prompt: &str,
466    working_dir: &Path,
467    session_name: &str,
468    completion_promise: &str,
469    harness: Harness,
470) -> Result<()> {
471    // Find harness binary path upfront to fail fast if not found
472    let binary_path = find_harness_binary(harness)?;
473    spawn_tmux_ralph(
474        task_id,
475        prompt,
476        working_dir,
477        session_name,
478        completion_promise,
479        binary_path,
480        harness,
481    )
482}
483
484/// Spawn in tmux session with Ralph loop wrapper
485fn spawn_tmux_ralph(
486    task_id: &str,
487    prompt: &str,
488    working_dir: &Path,
489    session_name: &str,
490    completion_promise: &str,
491    binary_path: &str,
492    harness: Harness,
493) -> Result<()> {
494    let window_name = format!("ralph-{}", task_id);
495
496    // Check if session exists
497    let session_exists = Command::new("tmux")
498        .args(["has-session", "-t", session_name])
499        .status()
500        .map(|s| s.success())
501        .unwrap_or(false);
502
503    if !session_exists {
504        // Create new session with control window
505        Command::new("tmux")
506            .args(["new-session", "-d", "-s", session_name, "-n", "ctrl"])
507            .arg("-c")
508            .arg(working_dir)
509            .status()
510            .context("Failed to create tmux session")?;
511    }
512
513    // Create new window for this task
514    let new_window_output = Command::new("tmux")
515        .args([
516            "new-window",
517            "-t",
518            session_name,
519            "-n",
520            &window_name,
521            "-P",
522            "-F",
523            "#{window_index}",
524        ])
525        .arg("-c")
526        .arg(working_dir)
527        .output()
528        .context("Failed to create tmux window")?;
529
530    if !new_window_output.status.success() {
531        anyhow::bail!(
532            "Failed to create window: {}",
533            String::from_utf8_lossy(&new_window_output.stderr)
534        );
535    }
536
537    let window_index = String::from_utf8_lossy(&new_window_output.stdout)
538        .trim()
539        .to_string();
540
541    // Write prompt to temp file
542    let prompt_file = std::env::temp_dir().join(format!("scud-ralph-{}.txt", task_id));
543    std::fs::write(&prompt_file, prompt)?;
544
545    // Build the harness-specific command for the ralph script
546    // We need to inline this since the script is a bash heredoc
547    let harness_cmd = match harness {
548        Harness::Claude => format!(
549            "'{binary_path}' \"$(cat '{prompt_file}')\" --dangerously-skip-permissions",
550            binary_path = binary_path,
551            prompt_file = prompt_file.display()
552        ),
553        Harness::OpenCode => format!(
554            "'{binary_path}' run --variant minimal \"$(cat '{prompt_file}')\"",
555            binary_path = binary_path,
556            prompt_file = prompt_file.display()
557        ),
558        Harness::Cursor => format!(
559            "'{binary_path}' -p \"$(cat '{prompt_file}')\"",
560            binary_path = binary_path,
561            prompt_file = prompt_file.display()
562        ),
563        #[cfg(feature = "direct-api")]
564        Harness::DirectApi => format!(
565            "'{binary_path}' agent-exec --prompt-file '{prompt_file}'",
566            binary_path = binary_path,
567            prompt_file = prompt_file.display()
568        ),
569    };
570
571    // Create a Ralph loop script that:
572    // 1. Runs the harness with the prompt
573    // 2. Checks if the task was marked done (via scud show)
574    // 3. If not done, loops back and runs the harness again with the same prompt
575    // 4. Continues until task is done or max iterations
576    // Use full path to harness binary to avoid PATH issues in spawned shells
577    // Source shell profile to ensure PATH includes node, etc.
578    let ralph_script = format!(
579        r#"#!/usr/bin/env bash
580# Source shell profile for PATH setup
581[ -f /etc/profile ] && . /etc/profile
582[ -f ~/.profile ] && . ~/.profile
583[ -f ~/.bash_profile ] && . ~/.bash_profile
584[ -f ~/.bashrc ] && . ~/.bashrc
585[ -f ~/.zshrc ] && . ~/.zshrc 2>/dev/null
586export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
587[ -s "$HOME/.nvm/nvm.sh" ] && . "$HOME/.nvm/nvm.sh"
588[ -s "$HOME/.bun/_bun" ] && . "$HOME/.bun/_bun"
589
590export SCUD_TASK_ID='{task_id}'
591export RALPH_PROMISE='{promise}'
592export RALPH_MAX_ITER=50
593export RALPH_ITER=0
594
595echo "🔄 Ralph loop starting for task {task_id}"
596echo "   Harness: {harness_name}"
597echo "   Completion promise: {promise}"
598echo "   Max iterations: $RALPH_MAX_ITER"
599echo ""
600
601while true; do
602    RALPH_ITER=$((RALPH_ITER + 1))
603    echo ""
604    echo "═══════════════════════════════════════════════════════════"
605    echo "🔄 RALPH ITERATION $RALPH_ITER / $RALPH_MAX_ITER"
606    echo "═══════════════════════════════════════════════════════════"
607    echo ""
608
609    # Run harness with the prompt (using full path)
610    {harness_cmd}
611
612    # Check if task is done
613    TASK_STATUS=$(scud show {task_id} 2>/dev/null | grep -i "status:" | awk '{{print $2}}')
614
615    if [ "$TASK_STATUS" = "done" ]; then
616        echo ""
617        echo "✅ Task {task_id} completed successfully after $RALPH_ITER iterations!"
618        rm -f '{prompt_file}'
619        break
620    fi
621
622    # Check max iterations
623    if [ $RALPH_ITER -ge $RALPH_MAX_ITER ]; then
624        echo ""
625        echo "⚠️  Ralph loop: Max iterations ($RALPH_MAX_ITER) reached for task {task_id}"
626        echo "   Task status: $TASK_STATUS"
627        rm -f '{prompt_file}'
628        break
629    fi
630
631    # Small delay before next iteration
632    echo ""
633    echo "🔄 Task not yet complete (status: $TASK_STATUS). Continuing loop..."
634    sleep 2
635done
636"#,
637        task_id = task_id,
638        promise = completion_promise,
639        prompt_file = prompt_file.display(),
640        harness_name = harness.name(),
641        harness_cmd = harness_cmd,
642    );
643
644    // Write the Ralph script to a temp file
645    let script_file = std::env::temp_dir().join(format!("scud-ralph-script-{}.sh", task_id));
646    std::fs::write(&script_file, &ralph_script)?;
647
648    // Run it with bash explicitly (works in any shell including fish)
649    let cmd = format!("bash '{}'", script_file.display());
650
651    let target = format!("{}:{}", session_name, window_index);
652    let send_result = Command::new("tmux")
653        .args(["send-keys", "-t", &target, &cmd, "Enter"])
654        .output()
655        .context("Failed to send command to tmux window")?;
656
657    if !send_result.status.success() {
658        anyhow::bail!(
659            "Failed to send keys: {}",
660            String::from_utf8_lossy(&send_result.stderr)
661        );
662    }
663
664    Ok(())
665}
666
667/// Check if a tmux session exists
668pub fn tmux_session_exists(session_name: &str) -> bool {
669    Command::new("tmux")
670        .args(["has-session", "-t", session_name])
671        .status()
672        .map(|s| s.success())
673        .unwrap_or(false)
674}
675
676/// Attach to a tmux session
677pub fn tmux_attach(session_name: &str) -> Result<()> {
678    // Use exec to replace current process with tmux attach
679    let status = Command::new("tmux")
680        .args(["attach", "-t", session_name])
681        .status()
682        .context("Failed to attach to tmux session")?;
683
684    if !status.success() {
685        anyhow::bail!("tmux attach failed");
686    }
687
688    Ok(())
689}
690
691/// Setup the control window in a tmux session with monitoring script
692pub fn setup_tmux_control_window(session_name: &str, tag: &str) -> Result<()> {
693    let control_script = format!(
694        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'"#,
695        session_name, tag, tag, tag
696    );
697
698    let target = format!("{}:ctrl", session_name);
699    Command::new("tmux")
700        .args(["send-keys", "-t", &target, &control_script, "Enter"])
701        .status()
702        .context("Failed to setup control window")?;
703
704    Ok(())
705}
706
707/// Check if a specific window exists in a tmux session
708pub fn tmux_window_exists(session_name: &str, window_name: &str) -> bool {
709    let output = Command::new("tmux")
710        .args(["list-windows", "-t", session_name, "-F", "#{window_name}"])
711        .output();
712
713    match output {
714        Ok(out) if out.status.success() => {
715            let windows = String::from_utf8_lossy(&out.stdout);
716            windows
717                .lines()
718                .any(|w| w == window_name || w.starts_with(&format!("{}-", window_name)))
719        }
720        _ => false,
721    }
722}
723
724/// Check if a tmux pane shows a shell prompt (indicating process completed/crashed)
725pub fn tmux_pane_shows_prompt(session_name: &str, window_name: &str) -> bool {
726    let window_target = format!("{}:{}", session_name, window_name);
727
728    // Capture last line of pane content
729    let output = std::process::Command::new("tmux")
730        .args(["capture-pane", "-t", &window_target, "-p", "-S", "-1"])
731        .output();
732
733    let Ok(output) = output else {
734        return false;
735    };
736
737    if !output.status.success() {
738        return false;
739    }
740
741    let last_line = String::from_utf8_lossy(&output.stdout);
742    let last_line = last_line.trim();
743
744    // Common shell prompt patterns
745    // These indicate the agent process has exited and we're back at shell
746    let prompt_patterns = [
747        "$ ", // bash default
748        "% ", // zsh default
749        "> ", // fish, some custom prompts
750        "# ", // root shell
751        "❯ ", // starship, some modern prompts
752        "→ ", // some custom prompts
753    ];
754
755    // Check if line ends with a prompt pattern
756    for pattern in prompt_patterns {
757        if last_line.ends_with(pattern) || last_line.ends_with(pattern.trim()) {
758            return true;
759        }
760    }
761
762    // Also check for common prompt formats: user@host, (env), etc followed by prompt
763    if last_line.contains('@')
764        && (last_line.ends_with('$') || last_line.ends_with('%') || last_line.ends_with('>'))
765    {
766        return true;
767    }
768
769    false
770}
771
772/// Kill a specific tmux window
773pub fn kill_tmux_window(session_name: &str, window_name: &str) -> Result<()> {
774    let target = format!("{}:{}", session_name, window_name);
775    Command::new("tmux")
776        .args(["kill-window", "-t", &target])
777        .output()?;
778    Ok(())
779}
780
781/// Spawn a command in a tmux window (simpler than spawn_tmux which does more setup)
782pub fn spawn_in_tmux(
783    session_name: &str,
784    window_name: &str,
785    command: &str,
786    working_dir: &Path,
787) -> Result<()> {
788    // Check if session exists, create if not
789    let session_exists = Command::new("tmux")
790        .args(["has-session", "-t", session_name])
791        .output()
792        .map(|o| o.status.success())
793        .unwrap_or(false);
794
795    if !session_exists {
796        // Create new session with a control window
797        Command::new("tmux")
798            .args([
799                "new-session",
800                "-d",
801                "-s",
802                session_name,
803                "-n",
804                "ctrl",
805                "-c",
806                &working_dir.to_string_lossy(),
807            ])
808            .output()
809            .context("Failed to create tmux session")?;
810    }
811
812    // Create new window for this task
813    let output = Command::new("tmux")
814        .args([
815            "new-window",
816            "-t",
817            session_name,
818            "-n",
819            window_name,
820            "-c",
821            &working_dir.to_string_lossy(),
822            "-P",
823            "-F",
824            "#{window_index}",
825        ])
826        .output()
827        .context("Failed to create tmux window")?;
828
829    if !output.status.success() {
830        anyhow::bail!(
831            "Failed to create tmux window: {}",
832            String::from_utf8_lossy(&output.stderr)
833        );
834    }
835
836    let window_index = String::from_utf8_lossy(&output.stdout).trim().to_string();
837
838    // Send the command to the window
839    let send_result = Command::new("tmux")
840        .args([
841            "send-keys",
842            "-t",
843            &format!("{}:{}", session_name, window_index),
844            command,
845            "Enter",
846        ])
847        .output()
848        .context("Failed to send command to tmux window")?;
849
850    if !send_result.status.success() {
851        anyhow::bail!(
852            "Failed to send command: {}",
853            String::from_utf8_lossy(&send_result.stderr)
854        );
855    }
856
857    Ok(())
858}