workmux 0.1.173

An opinionated workflow tool that orchestrates git worktrees and tmux
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
use crate::config::MuxMode;
use crate::multiplexer::handle::mode_label;
use crate::multiplexer::{MuxHandle, create_backend, detect_backend, util::prefixed};
use crate::prompt::{Prompt, PromptDocument, foreach_from_frontmatter};
use crate::spinner;
use crate::template::{
    TemplateEnv, WorktreeSpec, create_template_env, generate_worktree_specs, parse_foreach_matrix,
    render_prompt_body, validate_template_variables,
};
use crate::workflow::SetupOptions;
use crate::workflow::pr::detect_remote_branch;
use crate::workflow::prompt_loader::{PromptLoadArgs, load_prompt, parse_prompt_with_frontmatter};
use crate::{config, git, workflow};
use anyhow::{Context, Result, anyhow, bail};
use serde_json::Value;
use std::collections::BTreeMap;
use std::io::{IsTerminal, Read};

// Re-export the arg types that are used by the CLI
pub use super::args::{MultiArgs, PromptArgs, RescueArgs, SetupFlags};

/// Variable name exposed to templates for stdin input lines
const STDIN_INPUT_VAR: &str = "input";

/// Maximum stdin size to read (10MB) to prevent OOM from infinite streams
const STDIN_MAX_BYTES: u64 = 10 * 1024 * 1024;

/// Generate a branch name from prompt text using LLM with spinner feedback.
///
/// This helper consolidates the duplicate branch name generation logic that was
/// previously duplicated in both `run()` and `create_worktrees_from_specs()`.
fn generate_branch_name_with_spinner(
    prompt_text: Option<&str>,
    config: &config::Config,
) -> Result<String> {
    let prompt_text = prompt_text.ok_or_else(|| anyhow!("Prompt is required for --auto-name"))?;

    let model = config.auto_name.as_ref().and_then(|c| c.model.as_deref());
    let system_prompt = config
        .auto_name
        .as_ref()
        .and_then(|c| c.system_prompt.as_deref());

    // Resolve effective command: explicit config > agent profile > None (llm fallback)
    let config_command = config
        .auto_name
        .as_ref()
        .and_then(|c| c.command.as_deref())
        .map(str::trim)
        .filter(|s| !s.is_empty());
    let profile_command =
        crate::multiplexer::agent::resolve_profile(config.agent.as_deref()).auto_name_command();
    let effective_command = config_command.or(profile_command);

    tracing::info!(
        config_command = config_command,
        profile_command = profile_command,
        effective_command = effective_command,
        agent = config.agent.as_deref().unwrap_or("none"),
        "resolved auto-name command"
    );

    // Extract program name from effective command for spinner message
    let program_name = effective_command
        .and_then(|cmd| cmd.split_whitespace().next())
        .unwrap_or("llm");
    let spinner_msg = format!("Generating branch name with {}", program_name);

    let generated = spinner::with_spinner(&spinner_msg, || {
        crate::llm::generate_branch_name(prompt_text, model, system_prompt, effective_command)
    })?;
    println!("  Branch: {}", generated);

    Ok(generated)
}

/// Check for and read lines from stdin if available.
fn read_stdin_lines() -> Result<Vec<String>> {
    if std::io::stdin().is_terminal() {
        return Ok(Vec::new());
    }

    let mut buffer = String::new();
    std::io::stdin()
        .take(STDIN_MAX_BYTES)
        .read_to_string(&mut buffer)
        .context("Failed to read from stdin")?;

    let lines: Vec<String> = buffer
        .lines()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .collect();

    Ok(lines)
}

/// Check preconditions for the add command (git repo and multiplexer session).
/// Returns Ok(()) if all preconditions are met, or an error listing all failures.
fn check_preconditions() -> Result<()> {
    let is_git = git::is_git_repo()?;
    let mux = create_backend(detect_backend());
    let is_mux_running = mux.is_running()?;

    if is_git && is_mux_running {
        return Ok(());
    }

    let mut errors = Vec::new();

    if !is_mux_running {
        errors.push(format!("{} is not running.", mux.name()));
    }
    if !is_git {
        errors.push("Current directory is not a git repository.".to_string());
    }

    // Add blank line before suggestions
    errors.push("".to_string());

    if !is_mux_running {
        errors.push(format!("Please start a {} session first.", mux.name()));
    }
    if !is_git {
        errors.push("Please run this command from within a git repository.".to_string());
    }

    Err(anyhow!(errors.join("\n")))
}

/// Resolve a named layout by replacing `config.panes` with the layout's panes.
fn resolve_layout(config: &mut config::Config, layout_name: &str) -> Result<()> {
    let layouts = config.layouts.as_ref().ok_or_else(|| {
        anyhow!(
            "Layout '{}' requested but no layouts are defined in config",
            layout_name
        )
    })?;
    let layout = layouts.get(layout_name).ok_or_else(|| {
        let mut available: Vec<_> = layouts.keys().collect();
        available.sort();
        anyhow!(
            "Layout '{}' not found. Available layouts: {}",
            layout_name,
            available
                .iter()
                .map(|s| s.as_str())
                .collect::<Vec<_>>()
                .join(", ")
        )
    })?;
    config.panes = Some(layout.panes.clone());
    config.windows = None; // Layout overrides session windows
    Ok(())
}

#[allow(clippy::too_many_arguments)]
pub fn run(
    branch_name: Option<&str>,
    pr: Option<u32>,
    auto_name: bool,
    base: Option<&str>,
    name: Option<String>,
    prompt_args: PromptArgs,
    setup: SetupFlags,
    rescue: RescueArgs,
    multi: MultiArgs,
    layout: Option<String>,
    wait: bool,
    session: bool,
) -> Result<()> {
    // Inside a sandbox guest, route through RPC to the host supervisor
    if crate::sandbox::guest::is_sandbox_guest() {
        if layout.is_some() {
            bail!("--layout is not supported from inside a sandbox");
        }
        return run_add_via_rpc(
            branch_name,
            auto_name,
            &prompt_args,
            &setup,
            &rescue,
            &multi,
            base,
            pr,
            name.as_deref(),
            wait,
            session,
        );
    }

    // Ensure preconditions are met (git repo and multiplexer session)
    check_preconditions()?;

    // Extract sandbox override before consuming setup flags
    let sandbox_override = setup.sandbox;

    // Load config early to determine mode (CLI flag overrides config)
    let mut initial_config = config::Config::load(multi.agent.first().map(|s| s.as_str()))?;
    let mode = if session {
        MuxMode::Session
    } else {
        initial_config.mode()
    };

    // Validate layout early to fail fast before any LLM calls
    if let Some(layout_name) = &layout {
        resolve_layout(&mut initial_config, layout_name)?;
    }

    // Construct setup options from flags
    let mut options = SetupOptions::new(!setup.no_hooks, !setup.no_file_ops, !setup.no_pane_cmds);
    options.focus_window = !setup.background;
    options.open_if_exists = setup.open_if_exists;
    options.mode = mode;

    // If using --auto-name and config has auto_name.background = true, run in background
    if auto_name && options.focus_window {
        let config = config::Config::load(multi.agent.first().map(|s| s.as_str()))?;
        if config
            .auto_name
            .as_ref()
            .and_then(|c| c.background)
            .unwrap_or(false)
        {
            options.focus_window = false;
        }
    }

    // Detect stdin input early
    let stdin_lines = read_stdin_lines()?;
    let has_stdin = !stdin_lines.is_empty();

    // Determine if we're in explicit multi-worktree mode (before loading prompt)
    let is_explicit_multi =
        has_stdin || multi.foreach.is_some() || multi.count.is_some() || multi.agent.len() > 1;

    // Handle auto-name: load prompt first, generate branch name
    // In multi-worktree mode with auto-name, we defer LLM generation to the loop
    let (final_branch_name, preloaded_prompt, remote_branch_for_pr, deferred_auto_name) =
        if auto_name {
            // Use editor if no prompt source specified, otherwise use provided source
            let use_editor = prompt_args.prompt.is_none() && prompt_args.prompt_file.is_none();

            // Cannot use interactive editor when stdin is piped (editor can't read terminal)
            if has_stdin && (prompt_args.prompt_editor || use_editor) {
                return Err(anyhow!(
                    "Cannot use interactive prompt editor when piping input from stdin.\n\
                    Please provide a prompt via --prompt or --prompt-file."
                ));
            }

            let prompt = load_prompt(&PromptLoadArgs {
                prompt_editor: use_editor || prompt_args.prompt_editor,
                prompt_inline: prompt_args.prompt.as_deref(),
                prompt_file: prompt_args.prompt_file.as_ref(),
            })?
            .ok_or_else(|| anyhow!("Prompt is required for --auto-name"))?;

            // Check if we need to defer auto-name generation to the loop
            // This happens when we have multi-worktree mode OR frontmatter foreach
            let prompt_doc_preview = parse_prompt_with_frontmatter(&prompt, true)?;
            let has_frontmatter_foreach = prompt_doc_preview.meta.foreach.is_some();

            if is_explicit_multi || has_frontmatter_foreach {
                // Defer LLM generation - use placeholder branch name
                ("deferred".to_string(), Some(prompt), None, true)
            } else {
                // Single worktree mode - generate branch name now
                let prompt_text = prompt.read_content()?;
                let config = config::Config::load(multi.agent.first().map(|s| s.as_str()))?;
                let generated = generate_branch_name_with_spinner(Some(&prompt_text), &config)?;
                (generated, Some(prompt), None, false)
            }
        } else if let Some(pr_number) = pr {
            // Handle PR checkout if --pr flag is provided
            let result = workflow::pr::resolve_pr_ref(pr_number, branch_name)?;
            (result.local_branch, None, Some(result.remote_branch), false)
        } else {
            // Normal flow: use provided branch name
            (
                branch_name
                    .expect("branch_name required when --pr and --auto-name not provided")
                    .to_string(),
                None,
                None,
                false,
            )
        };

    // Use the determined branch name and override base if from PR
    let branch_name = &final_branch_name;
    let cli_base = if remote_branch_for_pr.is_some() {
        None
    } else {
        base
    };
    let config_base = initial_config.base_branch.as_deref();

    // Validate --with-changes compatibility
    if rescue.with_changes && multi.agent.len() > 1 {
        return Err(anyhow!(
            "--with-changes cannot be used with multiple --agent flags. Use zero or one --agent."
        ));
    }

    // Validate --name compatibility with multi-worktree generation
    let has_multi_worktree = multi.agent.len() > 1
        || multi.count.is_some_and(|c| c > 1)
        || multi.foreach.is_some()
        || has_stdin;
    if name.is_some() && has_multi_worktree {
        return Err(anyhow!(
            "--name cannot be used with multi-worktree generation (multiple --agent, --count, --foreach, or stdin).\n\
             Use the default naming or set worktree_naming/worktree_prefix in config instead."
        ));
    }

    // Handle rescue flow early if requested
    if rescue.with_changes {
        let (mut rescue_config, rescue_location) =
            config::Config::load_with_location(multi.agent.first().map(|s| s.as_str()))?;
        if sandbox_override {
            rescue_config.sandbox.enabled = Some(true);
        }
        if let Some(layout_name) = &layout {
            resolve_layout(&mut rescue_config, layout_name)?;
        }
        let mux = create_backend(detect_backend());
        let rescue_context = workflow::WorkflowContext::new(rescue_config, mux, rescue_location)?;
        // Derive handle for rescue flow (uses config for naming strategy/prefix)
        let handle =
            crate::naming::derive_handle(branch_name, name.as_deref(), &rescue_context.config)?;
        if handle_rescue_flow(
            branch_name,
            &handle,
            &rescue,
            &rescue_context,
            options.clone(),
            wait,
        )? {
            return Ok(());
        }
    }

    // Use preloaded prompt (from auto-name) OR load it now (standard flow)
    let prompt_template = if let Some(p) = preloaded_prompt {
        Some(p)
    } else {
        load_prompt(&PromptLoadArgs {
            prompt_editor: prompt_args.prompt_editor,
            prompt_inline: prompt_args.prompt.as_deref(),
            prompt_file: prompt_args.prompt_file.as_ref(),
        })?
    };

    // Parse prompt document to extract frontmatter (if applicable)
    let prompt_doc = if let Some(ref prompt_src) = prompt_template {
        // Account for implicit editor usage triggered by auto_name
        let implicit_editor =
            auto_name && prompt_args.prompt.is_none() && prompt_args.prompt_file.is_none();
        let from_editor_or_file = prompt_args.prompt_editor
            || implicit_editor
            || matches!(prompt_src, Prompt::FromFile(_));
        Some(parse_prompt_with_frontmatter(
            prompt_src,
            from_editor_or_file,
        )?)
    } else {
        None
    };

    // Validate multi-worktree arguments
    if multi.count.is_some() && multi.agent.len() > 1 {
        return Err(anyhow!(
            "--count can only be used with zero or one --agent, but {} were provided",
            multi.agent.len()
        ));
    }

    let has_foreach_in_prompt = prompt_doc
        .as_ref()
        .and_then(|d| d.meta.foreach.as_ref())
        .is_some();

    if has_foreach_in_prompt && !multi.agent.is_empty() {
        return Err(anyhow!(
            "Cannot use --agent when 'foreach' is defined in the prompt frontmatter. \
            These multi-worktree generation methods are mutually exclusive."
        ));
    }

    // Create template environment
    let env = create_template_env();

    // Detect remote branch and extract base name
    // If we have a PR remote branch, use that; otherwise detect from branch_name
    // Only pass CLI --base to detect_remote_branch; config base_branch should not
    // interfere with remote/fork branch detection.
    let (remote_branch, template_base_name) = if let Some(ref pr_remote) = remote_branch_for_pr {
        (Some(pr_remote.clone()), branch_name.to_string())
    } else {
        detect_remote_branch(branch_name, cli_base)?
    };
    let resolved_base = if remote_branch.is_some() {
        None
    } else {
        cli_base.or(config_base)
    };

    // Determine effective foreach matrix
    let effective_foreach_rows =
        determine_foreach_matrix(&multi, prompt_doc.as_ref(), stdin_lines)?;

    // Generate worktree specifications
    let specs = generate_worktree_specs(
        &template_base_name,
        &multi.agent,
        multi.count,
        effective_foreach_rows.as_deref(),
        &env,
        &multi.branch_template,
    )?;

    if specs.is_empty() {
        return Err(anyhow!("No worktree specifications were generated"));
    }

    // Validate prompt template variables before proceeding to create worktrees.
    // We use the context from the first spec (variable schema is consistent across specs).
    if let Some(doc) = &prompt_doc
        && let Some(first_spec) = specs.first()
    {
        validate_template_variables(&env, &doc.body, &first_spec.template_context)
            .context("Prompt template uses undefined variables")?;
    }

    // Determine prompt_file_only from CLI flag or config
    let prompt_file_only =
        prompt_args.prompt_file_only || initial_config.prompt_file_only.unwrap_or(false);

    // Create worktrees from specs
    let plan = CreationPlan {
        specs: &specs,
        resolved_base,
        remote_branch: remote_branch.as_deref(),
        prompt_doc: prompt_doc.as_ref(),
        options,
        env: &env,
        explicit_name: name.as_deref(),
        wait,
        deferred_auto_name,
        max_concurrent: multi.max_concurrent,
        sandbox_override,
        prompt_file_only,
        layout: layout.as_deref(),
    };
    plan.execute()
}

/// Handle the rescue flow (--with-changes).
/// Returns Ok(true) if rescue flow was handled, Ok(false) if normal flow should continue.
fn handle_rescue_flow(
    branch_name: &str,
    handle: &str,
    rescue: &RescueArgs,
    context: &workflow::WorkflowContext,
    options: SetupOptions,
    wait: bool,
) -> Result<bool> {
    if !rescue.with_changes {
        return Ok(false);
    }

    // Capture mode before options is moved
    let mode = options.mode;

    let result = workflow::create_with_changes(
        branch_name,
        handle,
        rescue.include_untracked,
        rescue.patch,
        context,
        options,
    )
    .context("Failed to move uncommitted changes")?;

    println!(
        "✓ Moved uncommitted changes to new worktree for branch '{}'\n  Worktree: {}\n  Original worktree is now clean",
        result.branch_name,
        result.worktree_path.display()
    );

    if wait {
        MuxHandle::new(context.mux.as_ref(), mode, &context.prefix, handle).wait_until_closed()?;
    }

    Ok(true)
}

/// Determine the effective foreach matrix from CLI, stdin, or frontmatter.
/// Priority: CLI --foreach > stdin > frontmatter foreach
fn determine_foreach_matrix(
    multi: &MultiArgs,
    prompt_doc: Option<&PromptDocument>,
    stdin_lines: Vec<String>,
) -> Result<Option<Vec<BTreeMap<String, String>>>> {
    let has_stdin = !stdin_lines.is_empty();
    let has_frontmatter_foreach = prompt_doc.and_then(|d| d.meta.foreach.as_ref()).is_some();

    // Stdin conflicts with --foreach
    if has_stdin && multi.foreach.is_some() {
        return Err(anyhow!("Cannot use --foreach when piping input from stdin"));
    }

    // Handle stdin input - converts lines to matrix
    // Supports both plain text (becomes {{ input }}) and JSON lines (each key becomes a variable)
    if has_stdin {
        if has_frontmatter_foreach {
            eprintln!("Warning: stdin input overrides prompt frontmatter 'foreach'");
        }

        let rows = stdin_lines
            .into_iter()
            .map(|line| {
                let mut map = BTreeMap::new();

                // Always set {{ input }} to the raw line
                map.insert(STDIN_INPUT_VAR.to_string(), line.clone());

                // Try to parse as JSON if it looks like an object
                if line.starts_with('{')
                    && let Ok(Value::Object(obj)) = serde_json::from_str(&line)
                {
                    for (k, v) in obj {
                        // Convert JSON values to strings
                        let val_str = match v {
                            Value::String(s) => s,
                            Value::Null => String::new(),
                            other => other.to_string(),
                        };
                        // JSON keys can overwrite {{ input }} if explicitly provided
                        map.insert(k, val_str);
                    }
                }

                map
            })
            .collect();

        return Ok(Some(rows));
    }

    // Fall back to existing CLI/frontmatter logic
    match (
        &multi.foreach,
        prompt_doc.and_then(|d| d.meta.foreach.as_ref()),
    ) {
        (Some(cli_str), Some(_frontmatter_map)) => {
            eprintln!("Warning: --foreach overrides prompt frontmatter");
            Ok(Some(parse_foreach_matrix(cli_str)?))
        }
        (Some(cli_str), None) => Ok(Some(parse_foreach_matrix(cli_str)?)),
        (None, Some(frontmatter_map)) => Ok(Some(foreach_from_frontmatter(frontmatter_map)?)),
        (None, None) => Ok(None),
    }
}

/// Polling interval for checking window status in worker pool mode
const WORKER_POOL_POLL_MS: u64 = 250;

/// Encapsulates all parameters needed for worktree creation.
struct CreationPlan<'a> {
    specs: &'a [WorktreeSpec],
    resolved_base: Option<&'a str>,
    remote_branch: Option<&'a str>,
    prompt_doc: Option<&'a PromptDocument>,
    options: SetupOptions,
    env: &'a TemplateEnv,
    explicit_name: Option<&'a str>,
    wait: bool,
    deferred_auto_name: bool,
    max_concurrent: Option<u32>,
    sandbox_override: bool,
    prompt_file_only: bool,
    layout: Option<&'a str>,
}

impl<'a> CreationPlan<'a> {
    /// Execute the creation plan, creating all worktrees according to the specs.
    fn execute(&self) -> Result<()> {
        self.create_worktrees()
    }

    fn create_worktrees(&self) -> Result<()> {
        if self.specs.len() > 1 {
            println!("Preparing to create {} worktrees...", self.specs.len());
        }

        // Create backend once for all specs
        let mux = create_backend(detect_backend());

        // Track targets for --wait (all created windows/sessions)
        let mut created_targets = Vec::new();
        // Track currently active targets for --max-concurrent
        let mut active_targets: Vec<String> = Vec::new();
        let mode = self.options.mode;

        for (i, spec) in self.specs.iter().enumerate() {
            // Concurrency control: wait for a slot if at limit
            if let Some(limit) = self.max_concurrent {
                let limit = limit as usize;
                // Only enter polling loop if we're at capacity
                if active_targets.len() >= limit {
                    loop {
                        // Filter to only targets that still exist
                        if mode == MuxMode::Session {
                            let live_sessions = mux.get_all_session_names()?;
                            active_targets.retain(|t| live_sessions.contains(t));
                        } else {
                            active_targets = mux.filter_active_windows(&active_targets)?;
                        }
                        if active_targets.len() < limit {
                            break;
                        }
                        std::thread::sleep(std::time::Duration::from_millis(WORKER_POOL_POLL_MS));
                    }
                }
            }
            // Load config for this specific agent to ensure correct agent resolution
            let (mut config, config_location) =
                config::Config::load_with_location(spec.agent.as_deref())?;
            if self.sandbox_override {
                config.sandbox.enabled = Some(true);
            }

            // Resolve layout: replace top-level panes with layout's panes
            if let Some(layout_name) = self.layout {
                resolve_layout(&mut config, layout_name)?;
            }

            // Render prompt first (needed for deferred auto-name)
            let rendered_prompt = if let Some(doc) = self.prompt_doc {
                Some(
                    render_prompt_body(&doc.body, self.env, &spec.template_context)
                        .with_context(|| format!("Failed to render prompt for spec index {}", i))?,
                )
            } else {
                None
            };

            // If auto-name was deferred, run it now using the rendered prompt
            let final_branch_name = if self.deferred_auto_name {
                generate_branch_name_with_spinner(rendered_prompt.as_deref(), &config)?
            } else {
                spec.branch_name.clone()
            };

            if self.specs.len() > 1 {
                println!(
                    "\n--- [{}/{}] Creating worktree: {} ---",
                    i + 1,
                    self.specs.len(),
                    final_branch_name
                );
            }

            // Derive handle from branch name, optional explicit name, and config
            // For single specs, explicit_name overrides; for multi-specs, it's None (disallowed)
            let handle =
                crate::naming::derive_handle(&final_branch_name, self.explicit_name, &config)?;

            let prompt_for_spec = rendered_prompt.map(Prompt::Inline);

            super::announce_hooks(&config, Some(&self.options), super::HookPhase::PostCreate);

            // Create a WorkflowContext for this spec's config (reuse shared mux)
            let context = workflow::WorkflowContext::new(config, mux.clone(), config_location)?;

            let result = workflow::create(
                &context,
                workflow::CreateArgs {
                    branch_name: &final_branch_name,
                    handle: &handle,
                    base_branch: self.resolved_base,
                    remote_branch: self.remote_branch,
                    prompt: prompt_for_spec.as_ref(),
                    options: self.options.clone(),
                    agent: spec.agent.as_deref(),
                    is_explicit_name: self.explicit_name.is_some(),
                    prompt_file_only: self.prompt_file_only,
                },
            )
            .with_context(|| {
                format!(
                    "Failed to create worktree environment for branch '{}'",
                    final_branch_name
                )
            })?;

            // Use resolved handle for tracking (may differ from original if auto-suffixed)
            let full_window_name = prefixed(&context.prefix, &result.resolved_handle);

            if self.wait {
                created_targets.push(full_window_name.clone());
            }

            // Track for concurrency control
            if self.max_concurrent.is_some() {
                active_targets.push(full_window_name);
            }

            if result.post_create_hooks_run > 0 {
                println!("✓ Setup complete");
            }

            println!(
                "✓ Successfully created worktree and tmux {} for '{}'",
                mode_label(mode),
                result.branch_name
            );
            if let Some(ref base) = result.base_branch {
                println!("  Base: {}", base);
            }
            println!("  Worktree: {}", result.worktree_path.display());
        }

        if self.wait && !created_targets.is_empty() {
            if mode == MuxMode::Session {
                // For sessions, wait for each one to close
                for session_name in &created_targets {
                    mux.wait_until_session_closed(session_name)?;
                }
            } else {
                mux.wait_until_windows_closed(&created_targets)?;
            }
        }

        Ok(())
    }
}

/// Route `workmux add` through SpawnAgent RPC when running inside a sandbox.
///
/// Only a subset of `add` flags are supported over RPC. Unsupported flags
/// are explicitly rejected with a clear error rather than silently ignored.
#[allow(clippy::too_many_arguments)]
fn run_add_via_rpc(
    branch_name: Option<&str>,
    auto_name: bool,
    prompt_args: &PromptArgs,
    setup: &SetupFlags,
    rescue: &RescueArgs,
    multi: &MultiArgs,
    base: Option<&str>,
    pr: Option<u32>,
    name: Option<&str>,
    wait: bool,
    session: bool,
) -> Result<()> {
    use crate::sandbox::rpc::{RpcClient, RpcRequest, RpcResponse};
    use crate::workflow::prompt_loader::{PromptLoadArgs, load_prompt};

    // --- Validate: reject unsupported flags explicitly ---
    if base.is_some() {
        bail!("--base is not supported from inside a sandbox");
    }
    if pr.is_some() {
        bail!("--pr is not supported from inside a sandbox");
    }
    if name.is_some() {
        bail!("--name is not supported from inside a sandbox");
    }
    if wait {
        bail!("--wait is not supported from inside a sandbox");
    }
    if rescue.with_changes {
        bail!("--with-changes is not supported from inside a sandbox");
    }
    if !multi.agent.is_empty() {
        bail!("--agent is not supported from inside a sandbox (uses host config)");
    }
    if multi.count.is_some() {
        bail!(
            "--count is not supported from inside a sandbox. Call workmux add multiple times instead."
        );
    }
    if multi.foreach.is_some() {
        bail!("--foreach is not supported from inside a sandbox");
    }
    if session {
        bail!(
            "--session is not supported from inside a sandbox (host controls mode via its config)"
        );
    }
    if prompt_args.prompt_file_only {
        bail!("--prompt-file-only is not supported from inside a sandbox");
    }

    // --- Resolve prompt via existing loader (handles -p, -P, -e) ---
    let prompt_content = load_prompt(&PromptLoadArgs {
        prompt_editor: prompt_args.prompt_editor,
        prompt_inline: prompt_args.prompt.as_deref(),
        prompt_file: prompt_args.prompt_file.as_ref(),
    })?;
    let prompt_text = match prompt_content {
        Some(Prompt::Inline(text)) => Some(text),
        Some(Prompt::FromFile(path)) => Some(
            std::fs::read_to_string(&path)
                .with_context(|| format!("Failed to read prompt file: {}", path.display()))?,
        ),
        None => None,
    };

    // --- Build RPC request ---
    let rpc_branch = if auto_name {
        None
    } else {
        branch_name.map(|s| s.to_string())
    };

    let mut client = RpcClient::from_env().context(
        "Failed to connect to host RPC server. Is this running inside a workmux sandbox?",
    )?;

    let resp = client.call(&RpcRequest::SpawnAgent {
        prompt: prompt_text.unwrap_or_default(),
        branch_name: rpc_branch.clone(),
        background: if setup.background { Some(true) } else { None },
    })?;

    match resp {
        RpcResponse::Ok => {
            let display_name = rpc_branch.as_deref().unwrap_or("(auto-named)");
            println!("✓ Spawned agent: {}", display_name);
            Ok(())
        }
        RpcResponse::Error { message } => {
            bail!("Host failed to spawn agent: {}", message)
        }
        other => bail!("Unexpected RPC response: {:?}", other),
    }
}