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