diff --git a/.claude-plugin/skills/agent-cli-dev/SKILL.md b/.claude-plugin/skills/agent-cli-dev/SKILL.md index 9c4e52f55..2fe014980 100644 --- a/.claude-plugin/skills/agent-cli-dev/SKILL.md +++ b/.claude-plugin/skills/agent-cli-dev/SKILL.md @@ -1,12 +1,16 @@ --- name: agent-cli-dev -description: Spawns AI coding agents in isolated git worktrees. Use when the user asks to spawn or launch an agent, delegate a task to a separate agent, work in a separate worktree, or parallelize development across features. +description: Spawns AI coding agents in isolated git worktrees. Use when the user asks to spawn or launch an agent, delegate a task to a separate agent, or parallelize development across features. Only create a worktree without starting an agent if the user explicitly wants setup only. --- # Parallel Development with agent-cli dev This skill teaches you how to spawn parallel AI coding agents in isolated git worktrees using the `agent-cli dev` command. +`agent-cli dev` supports two complementary patterns: +- Separate worktrees for isolated implementation/review tasks +- Multiple agents on the same worktree using `dev agent -m tmux` + ## Installation If `agent-cli` is not available, install it first: @@ -58,6 +62,16 @@ This creates: **Important**: Use `--prompt-file` for prompts longer than a single line. The `--prompt` option passes text through the shell, which can cause issues with special characters (exclamation marks, dollar signs, backticks, quotes) in ZSH and other shells. Using `--prompt-file` avoids all shell quoting issues. +## Automation rule + +When an assistant is executing this workflow on the user's behalf, the spawn is not complete unless the agent receives a prompt at launch time. + +- Prefer `--prompt-file`; create the prompt file first, then launch the agent +- Use `dev new ... --agent --prompt-file ...` for a new delegated task +- Use `dev agent ... --prompt-file ...` for another agent in an existing worktree +- Do not stop after `dev new ...` alone if the user's intent was to delegate work immediately +- Do not run `dev new ... --agent` or `dev agent ... -m tmux` without `--prompt` or `--prompt-file` unless the user explicitly wants an interactive session that they will drive manually + ## Writing effective prompts for spawned agents Spawned agents work in isolation, so prompts must be **self-contained**. Include: @@ -76,7 +90,7 @@ For any prompt longer than a single sentence: Example workflow: ```bash -# 1. Write prompt to file (Claude does this with the Write tool) +# 1. Write prompt to file # 2. Spawn agent with the file agent-cli dev new my-feature --agent --prompt-file .claude/spawn-prompt.md # 3. Optionally clean up @@ -114,6 +128,54 @@ agent-cli dev run cat .claude/REPORT.md agent-cli dev editor ``` +## Same-branch multi-agent workflow + +Use this when several agents should inspect or validate the same code at once without separate worktrees. + +```bash +# Create the worktree once. This step only prepares the shared workspace. +agent-cli dev new review-auth --from HEAD + +# Then launch the actual agents with prompts. +agent-cli dev agent review-auth -m tmux --prompt-file .claude/review-security.md +agent-cli dev agent review-auth -m tmux --prompt-file .claude/review-performance.md +agent-cli dev agent review-auth -m tmux --prompt-file .claude/review-tests.md +``` + +Key rules for same-worktree launches: +- Use `dev agent`, not `dev new`, after the worktree already exists +- Use `-m tmux` for headless or scripted launching; it works even when not already inside tmux +- Each launch joins the same deterministic repo-scoped tmux session, so related agents stay grouped together +- Ask each agent to write to a unique report path such as `.claude/REPORT-security-.md` or `.claude/REPORT-tests-.md` +- If you rerun the same prompt repeatedly, include a timestamp or other run id in the report filename so later runs do not overwrite earlier ones +- Do not rely on `.claude/TASK.md` as per-agent state in shared worktrees; later launches overwrite it + +### Prompt guidance for shared worktrees + +When multiple agents share a worktree, explicitly assign both a focus area and a unique report file. If you rerun the same review prompt often, prefer a timestamped filename such as `.claude/REPORT-security-20260319-153045-123.md`. + +Prompt pattern: + +```text +Review the auth module for security issues only. + +When complete, write findings to .claude/REPORT-security-20260319-153045-123.md including: +- Summary +- Issues found with file/line references +- Suggested fixes +``` + +## Headless/scripted orchestration + +For non-interactive contexts (scripts, cron jobs, other assistants), combine `--prompt-file` with `-m tmux`: + +```bash +agent-cli dev new validation-a --from HEAD --agent --with-agent codex -m tmux \ + --prompt-file .claude/validation-a.md +``` + +This works without an attached terminal. `agent-cli` creates or reuses a detached tmux session and returns a pane handle plus attach command. + ## Example: Multi-feature implementation If asked to implement auth, payments, and notifications: diff --git a/.claude-plugin/skills/agent-cli-dev/examples.md b/.claude-plugin/skills/agent-cli-dev/examples.md index 5a06a8d2c..dfcb22a16 100644 --- a/.claude-plugin/skills/agent-cli-dev/examples.md +++ b/.claude-plugin/skills/agent-cli-dev/examples.md @@ -8,6 +8,8 @@ Real-world scenarios for spawning parallel AI coding agents, optimized for Claud > # Write prompt to file, then spawn > agent-cli dev new my-feature --agent --prompt-file .claude/spawn-prompt.md > ``` +> +> When an assistant is executing these commands, do not launch `dev new` or `dev agent` without `--prompt` or `--prompt-file` unless the user explicitly wants a manual interactive session. ## Prompt structure guidelines @@ -19,6 +21,7 @@ Each prompt for a spawned agent should follow this structure: 4. **Context with motivation** - Explain why patterns matter 5. **Focused scope** - Keep solutions minimal, implement only what's requested 6. **Structured report** - Write conclusions to `.claude/REPORT.md` +7. **No interactive gap** - The launch command itself should include `--prompt` or `--prompt-file` so the agent starts working immediately ## Scenario 1: Code review of current branch @@ -524,6 +527,96 @@ When complete, write to .claude/REPORT.md: " ``` +## Scenario 6: Multi-reviewer on the same branch + +**User request**: "Get 3 agents to review this code" or "Run multiple reviewers on this branch" + +**Strategy**: Create one review worktree from the current branch, then launch several agents into that same worktree with different focus areas. + +```bash +run_id="$(python -c 'import time; print(int(time.time() * 1000))')" + +# Create the shared review worktree once. This does not start an agent yet. +agent-cli dev new review-auth --from HEAD + +# Launch three reviewers into the same worktree/session +agent-cli dev agent review-auth -m tmux --prompt "Review the auth module for security issues only. + + +Do not fix code. Review only. + + + +Write findings to .claude/REPORT-security-$run_id.md: +- Summary +- Issues with file:line references +- Suggested fixes +" + +agent-cli dev agent review-auth -m tmux --prompt "Review the auth module for performance issues only. + + +Review only. Focus on query patterns, repeated work, and unnecessary allocations. + + + +Write findings to .claude/REPORT-performance-$run_id.md: +- Summary +- Issues with file:line references +- Suggested fixes +" + +agent-cli dev agent review-auth -m tmux --prompt "Review the auth module for test coverage gaps only. + + +Review only. Identify missing or weak tests. + + + +Write findings to .claude/REPORT-tests-$run_id.md: +- Summary +- Missing test cases +- Suggested follow-up tests +" +``` + +**Important**: +- Same-worktree launches should use unique report files, not `.claude/REPORT.md` +- If you rerun the same prompt often, include a timestamp or run id in the filename so reports do not get replaced +- `-m tmux` works even when the caller is not already inside tmux +- All three agents land in the same deterministic tmux session for that repo +- `.claude/TASK.md` is shared state and may be overwritten by later launches, so keep prompt files outside that convention + +## Scenario 7: Parallel test validation + +**User request**: "Run the test checklist across 8 sections in parallel" + +**Strategy**: Create one worktree per section, then launch each validation agent headlessly in tmux so the workflow works from scripts or non-terminal orchestrators. + +```bash +for section in 1 2 3 4 5 6 7 8; do + agent-cli dev new "test-section-$section" --from HEAD --agent --with-agent codex -m tmux \ + --prompt-file ".claude/test-section-$section.md" +done +``` + +Monitor and collect results: + +```bash +# Track the worktrees +agent-cli dev status + +# Read each report after completion +for section in 1 2 3 4 5 6 7 8; do + agent-cli dev run "test-section-$section" cat .claude/REPORT.md +done +``` + +**When writing the per-section prompts**: +- Assign one checklist section per agent +- Require real test execution where possible +- Require a structured `.claude/REPORT.md` with pass/fail status, evidence, and follow-up actions + ## Reviewing results After agents complete their work: @@ -569,3 +662,8 @@ Any items that need human review or clarification ``` This consistent format makes it easy to review work from multiple agents. + +For shared-worktree runs, keep the same structure but use unique filenames such as: +- `.claude/REPORT-security.md` +- `.claude/REPORT-performance.md` +- `.claude/REPORT-tests.md` diff --git a/.claude/skills/agent-cli-dev/SKILL.md b/.claude/skills/agent-cli-dev/SKILL.md index 9c4e52f55..2fe014980 100644 --- a/.claude/skills/agent-cli-dev/SKILL.md +++ b/.claude/skills/agent-cli-dev/SKILL.md @@ -1,12 +1,16 @@ --- name: agent-cli-dev -description: Spawns AI coding agents in isolated git worktrees. Use when the user asks to spawn or launch an agent, delegate a task to a separate agent, work in a separate worktree, or parallelize development across features. +description: Spawns AI coding agents in isolated git worktrees. Use when the user asks to spawn or launch an agent, delegate a task to a separate agent, or parallelize development across features. Only create a worktree without starting an agent if the user explicitly wants setup only. --- # Parallel Development with agent-cli dev This skill teaches you how to spawn parallel AI coding agents in isolated git worktrees using the `agent-cli dev` command. +`agent-cli dev` supports two complementary patterns: +- Separate worktrees for isolated implementation/review tasks +- Multiple agents on the same worktree using `dev agent -m tmux` + ## Installation If `agent-cli` is not available, install it first: @@ -58,6 +62,16 @@ This creates: **Important**: Use `--prompt-file` for prompts longer than a single line. The `--prompt` option passes text through the shell, which can cause issues with special characters (exclamation marks, dollar signs, backticks, quotes) in ZSH and other shells. Using `--prompt-file` avoids all shell quoting issues. +## Automation rule + +When an assistant is executing this workflow on the user's behalf, the spawn is not complete unless the agent receives a prompt at launch time. + +- Prefer `--prompt-file`; create the prompt file first, then launch the agent +- Use `dev new ... --agent --prompt-file ...` for a new delegated task +- Use `dev agent ... --prompt-file ...` for another agent in an existing worktree +- Do not stop after `dev new ...` alone if the user's intent was to delegate work immediately +- Do not run `dev new ... --agent` or `dev agent ... -m tmux` without `--prompt` or `--prompt-file` unless the user explicitly wants an interactive session that they will drive manually + ## Writing effective prompts for spawned agents Spawned agents work in isolation, so prompts must be **self-contained**. Include: @@ -76,7 +90,7 @@ For any prompt longer than a single sentence: Example workflow: ```bash -# 1. Write prompt to file (Claude does this with the Write tool) +# 1. Write prompt to file # 2. Spawn agent with the file agent-cli dev new my-feature --agent --prompt-file .claude/spawn-prompt.md # 3. Optionally clean up @@ -114,6 +128,54 @@ agent-cli dev run cat .claude/REPORT.md agent-cli dev editor ``` +## Same-branch multi-agent workflow + +Use this when several agents should inspect or validate the same code at once without separate worktrees. + +```bash +# Create the worktree once. This step only prepares the shared workspace. +agent-cli dev new review-auth --from HEAD + +# Then launch the actual agents with prompts. +agent-cli dev agent review-auth -m tmux --prompt-file .claude/review-security.md +agent-cli dev agent review-auth -m tmux --prompt-file .claude/review-performance.md +agent-cli dev agent review-auth -m tmux --prompt-file .claude/review-tests.md +``` + +Key rules for same-worktree launches: +- Use `dev agent`, not `dev new`, after the worktree already exists +- Use `-m tmux` for headless or scripted launching; it works even when not already inside tmux +- Each launch joins the same deterministic repo-scoped tmux session, so related agents stay grouped together +- Ask each agent to write to a unique report path such as `.claude/REPORT-security-.md` or `.claude/REPORT-tests-.md` +- If you rerun the same prompt repeatedly, include a timestamp or other run id in the report filename so later runs do not overwrite earlier ones +- Do not rely on `.claude/TASK.md` as per-agent state in shared worktrees; later launches overwrite it + +### Prompt guidance for shared worktrees + +When multiple agents share a worktree, explicitly assign both a focus area and a unique report file. If you rerun the same review prompt often, prefer a timestamped filename such as `.claude/REPORT-security-20260319-153045-123.md`. + +Prompt pattern: + +```text +Review the auth module for security issues only. + +When complete, write findings to .claude/REPORT-security-20260319-153045-123.md including: +- Summary +- Issues found with file/line references +- Suggested fixes +``` + +## Headless/scripted orchestration + +For non-interactive contexts (scripts, cron jobs, other assistants), combine `--prompt-file` with `-m tmux`: + +```bash +agent-cli dev new validation-a --from HEAD --agent --with-agent codex -m tmux \ + --prompt-file .claude/validation-a.md +``` + +This works without an attached terminal. `agent-cli` creates or reuses a detached tmux session and returns a pane handle plus attach command. + ## Example: Multi-feature implementation If asked to implement auth, payments, and notifications: diff --git a/.claude/skills/agent-cli-dev/examples.md b/.claude/skills/agent-cli-dev/examples.md index 5a06a8d2c..dfcb22a16 100644 --- a/.claude/skills/agent-cli-dev/examples.md +++ b/.claude/skills/agent-cli-dev/examples.md @@ -8,6 +8,8 @@ Real-world scenarios for spawning parallel AI coding agents, optimized for Claud > # Write prompt to file, then spawn > agent-cli dev new my-feature --agent --prompt-file .claude/spawn-prompt.md > ``` +> +> When an assistant is executing these commands, do not launch `dev new` or `dev agent` without `--prompt` or `--prompt-file` unless the user explicitly wants a manual interactive session. ## Prompt structure guidelines @@ -19,6 +21,7 @@ Each prompt for a spawned agent should follow this structure: 4. **Context with motivation** - Explain why patterns matter 5. **Focused scope** - Keep solutions minimal, implement only what's requested 6. **Structured report** - Write conclusions to `.claude/REPORT.md` +7. **No interactive gap** - The launch command itself should include `--prompt` or `--prompt-file` so the agent starts working immediately ## Scenario 1: Code review of current branch @@ -524,6 +527,96 @@ When complete, write to .claude/REPORT.md: " ``` +## Scenario 6: Multi-reviewer on the same branch + +**User request**: "Get 3 agents to review this code" or "Run multiple reviewers on this branch" + +**Strategy**: Create one review worktree from the current branch, then launch several agents into that same worktree with different focus areas. + +```bash +run_id="$(python -c 'import time; print(int(time.time() * 1000))')" + +# Create the shared review worktree once. This does not start an agent yet. +agent-cli dev new review-auth --from HEAD + +# Launch three reviewers into the same worktree/session +agent-cli dev agent review-auth -m tmux --prompt "Review the auth module for security issues only. + + +Do not fix code. Review only. + + + +Write findings to .claude/REPORT-security-$run_id.md: +- Summary +- Issues with file:line references +- Suggested fixes +" + +agent-cli dev agent review-auth -m tmux --prompt "Review the auth module for performance issues only. + + +Review only. Focus on query patterns, repeated work, and unnecessary allocations. + + + +Write findings to .claude/REPORT-performance-$run_id.md: +- Summary +- Issues with file:line references +- Suggested fixes +" + +agent-cli dev agent review-auth -m tmux --prompt "Review the auth module for test coverage gaps only. + + +Review only. Identify missing or weak tests. + + + +Write findings to .claude/REPORT-tests-$run_id.md: +- Summary +- Missing test cases +- Suggested follow-up tests +" +``` + +**Important**: +- Same-worktree launches should use unique report files, not `.claude/REPORT.md` +- If you rerun the same prompt often, include a timestamp or run id in the filename so reports do not get replaced +- `-m tmux` works even when the caller is not already inside tmux +- All three agents land in the same deterministic tmux session for that repo +- `.claude/TASK.md` is shared state and may be overwritten by later launches, so keep prompt files outside that convention + +## Scenario 7: Parallel test validation + +**User request**: "Run the test checklist across 8 sections in parallel" + +**Strategy**: Create one worktree per section, then launch each validation agent headlessly in tmux so the workflow works from scripts or non-terminal orchestrators. + +```bash +for section in 1 2 3 4 5 6 7 8; do + agent-cli dev new "test-section-$section" --from HEAD --agent --with-agent codex -m tmux \ + --prompt-file ".claude/test-section-$section.md" +done +``` + +Monitor and collect results: + +```bash +# Track the worktrees +agent-cli dev status + +# Read each report after completion +for section in 1 2 3 4 5 6 7 8; do + agent-cli dev run "test-section-$section" cat .claude/REPORT.md +done +``` + +**When writing the per-section prompts**: +- Assign one checklist section per agent +- Require real test execution where possible +- Require a structured `.claude/REPORT.md` with pass/fail status, evidence, and follow-up actions + ## Reviewing results After agents complete their work: @@ -569,3 +662,8 @@ Any items that need human review or clarification ``` This consistent format makes it easy to review work from multiple agents. + +For shared-worktree runs, keep the same structure but use unique filenames such as: +- `.claude/REPORT-security.md` +- `.claude/REPORT-performance.md` +- `.claude/REPORT-tests.md` diff --git a/agent_cli/dev/cli.py b/agent_cli/dev/cli.py index 8d4163c69..d0278f4e5 100644 --- a/agent_cli/dev/cli.py +++ b/agent_cli/dev/cli.py @@ -4,6 +4,7 @@ import json import os +import shlex import shutil import subprocess from pathlib import Path @@ -339,6 +340,15 @@ def new( readable=True, ), ] = None, + multiplexer: Annotated[ + Literal["tmux"] | None, + typer.Option( + "--multiplexer", + "-m", + case_sensitive=False, + help="Launch the agent in a specific multiplexer. Currently supported: tmux. When started outside tmux, creates or reuses a detached session and reports the pane handle", + ), + ] = None, verbose: Annotated[ bool, typer.Option( @@ -435,16 +445,39 @@ def new( if resolved_editor and resolved_editor.is_available(): launch_editor(result.path, resolved_editor) + agent_handle = None if resolved_agent and resolved_agent.is_available(): merged_args = merge_agent_args(resolved_agent, agent_args) agent_env = get_agent_env(resolved_agent) - launch_agent(result.path, resolved_agent, merged_args, prompt, task_file, agent_env) + agent_handle = launch_agent( + result.path, + resolved_agent, + merged_args, + prompt, + task_file, + agent_env, + multiplexer_name=multiplexer, + ) # Print summary + summary_lines = [ + f"[bold]Dev environment created:[/bold] {result.path}", + f"[bold]Branch:[/bold] {result.branch}", + ] + if agent_handle: + summary_lines.append( + f"[bold]Agent Handle:[/bold] {agent_handle.handle} ({agent_handle.terminal_name})", + ) + if agent_handle.session_name: + summary_lines.append(f"[bold]tmux Session:[/bold] {agent_handle.session_name}") + summary_lines.append( + f"[bold]Attach:[/bold] tmux attach -t {shlex.quote(agent_handle.session_name)}", + ) + console.print() console.print( Panel( - f"[bold]Dev environment created:[/bold] {result.path}\n[bold]Branch:[/bold] {result.branch}", + "\n".join(summary_lines), title="[green]Success[/green]", border_style="green", ), @@ -848,10 +881,20 @@ def start_agent( readable=True, ), ] = None, + multiplexer: Annotated[ + Literal["tmux"] | None, + typer.Option( + "--multiplexer", + "-m", + case_sensitive=False, + help="Launch the agent in a specific multiplexer instead of the current terminal. Currently supported: tmux", + ), + ] = None, ) -> None: """Start an AI coding agent in an existing dev environment. - Launches the agent directly in your current terminal. + Launches the agent directly in your current terminal unless + ``--multiplexer`` is used. **Examples:** @@ -893,6 +936,27 @@ def start_agent( merged_args = merge_agent_args(agent, agent_args) agent_env = get_agent_env(agent) + if multiplexer: + handle = launch_agent( + wt.path, + agent, + merged_args, + prompt, + task_file, + agent_env, + multiplexer_name=multiplexer, + ) + if handle: + info( + f"{handle.terminal_name} handle: {handle.handle}" + + ( + f" (attach with: tmux attach -t {shlex.quote(handle.session_name)})" + if handle.session_name + else "" + ), + ) + return + info(f"Starting {agent.name} in {wt.path}...") try: os.chdir(wt.path) diff --git a/agent_cli/dev/launch.py b/agent_cli/dev/launch.py index 8c7e67e39..83ee9812b 100644 --- a/agent_cli/dev/launch.py +++ b/agent_cli/dev/launch.py @@ -18,6 +18,7 @@ if TYPE_CHECKING: from .coding_agents.base import CodingAgent from .editors.base import Editor + from .terminals import TerminalHandle def resolve_editor( @@ -247,6 +248,113 @@ def _create_prompt_wrapper_script( return script_path +def _resolve_launch_terminal(multiplexer_name: str | None) -> terminals.Terminal | None: + """Resolve the terminal or multiplexer to use for launching.""" + terminal = terminals.get_terminal(multiplexer_name) if multiplexer_name else None + if terminal is not None and not terminal.is_available(): + warn(f"{terminal.name} is not installed") + return None + return terminal or terminals.detect_current_terminal() + + +def _build_agent_launch_command( + path: Path, + agent: CodingAgent, + extra_args: list[str] | None, + prompt: str | None, + task_file: Path | None, + env: dict[str, str] | None, + terminal: terminals.Terminal | None, +) -> str: + """Build the command string used to launch an agent in a terminal.""" + if task_file and terminal is not None: + script_path = _create_prompt_wrapper_script(path, agent, task_file, extra_args, env) + return f"bash {shlex.quote(str(script_path))}" + + agent_cmd = shlex.join(agent.launch_command(path, extra_args, prompt)) + env_prefix = _format_env_prefix(env or {}) + return env_prefix + agent_cmd + + +def _tab_name_for_path(path: Path) -> tuple[Path | None, str]: + """Build the terminal tab name for a worktree path.""" + repo_root = worktree.get_main_repo_root(path) + branch = worktree.get_current_branch(path) + repo_name = repo_root.name if repo_root else path.name + tab_name = f"{repo_name}@{branch}" if branch else repo_name + return repo_root, tab_name + + +def _launch_in_tmux( + path: Path, + agent: CodingAgent, + terminal: terminals.Terminal, + full_cmd: str, + tab_name: str, + repo_root: Path | None, + multiplexer_name: str | None, +) -> TerminalHandle | None: + """Launch an agent via tmux and return its pane handle.""" + from .terminals.tmux import Tmux # noqa: PLC0415 + + if not isinstance(terminal, Tmux): + warn("Could not open new tab in tmux") + return None + + requested_tmux = multiplexer_name == "tmux" + session_name = None + if requested_tmux and not terminal.detect(): + session_name = terminal.session_name_for_repo(repo_root or path) + + handle = terminal.open_in_session( + path, + full_cmd, + tab_name=tab_name, + session_name=session_name, + ) + if handle is None: + warn("Could not open new tab in tmux") + return None + + session_label = ( + f" in tmux session {handle.session_name}" + if requested_tmux and handle.session_name + else " in new tmux tab" + ) + success(f"Started {agent.name}{session_label}") + return handle + + +def _launch_in_terminal( + path: Path, + agent: CodingAgent, + terminal: terminals.Terminal, + full_cmd: str, + tab_name: str, + repo_root: Path | None, + multiplexer_name: str | None, +) -> tuple[bool, TerminalHandle | None]: + """Launch an agent in the resolved terminal.""" + if terminal.name == "tmux": + handle = _launch_in_tmux( + path, + agent, + terminal, + full_cmd, + tab_name, + repo_root, + multiplexer_name, + ) + return handle is not None, handle + + if terminal.open_new_tab(path, full_cmd, tab_name=tab_name): + success(f"Started {agent.name} in new {terminal.name} tab") + return True, None + + warn(f"Could not open new tab in {terminal.name}") + return False, None + + def launch_agent( path: Path, agent: CodingAgent, @@ -254,36 +362,31 @@ def launch_agent( prompt: str | None = None, task_file: Path | None = None, env: dict[str, str] | None = None, -) -> None: + multiplexer_name: str | None = None, +) -> TerminalHandle | None: """Launch agent in a new terminal tab. Agents are interactive TUIs that need a proper terminal. Priority: tmux/zellij tab > terminal tab > print instructions. """ - terminal = terminals.detect_current_terminal() - - # Use wrapper script when opening in a terminal tab - all terminals pass commands - # through a shell, so special characters get interpreted. Reading from file avoids this. - if task_file and terminal is not None: - script_path = _create_prompt_wrapper_script(path, agent, task_file, extra_args, env) - full_cmd = f"bash {shlex.quote(str(script_path))}" - else: - agent_cmd = shlex.join(agent.launch_command(path, extra_args, prompt)) - env_prefix = _format_env_prefix(env or {}) - full_cmd = env_prefix + agent_cmd - - if terminal: - # We're in a multiplexer (tmux/zellij) or supported terminal (kitty/iTerm2) - # Tab name format: repo@branch - repo_root = worktree.get_main_repo_root(path) - branch = worktree.get_current_branch(path) - repo_name = repo_root.name if repo_root else path.name - tab_name = f"{repo_name}@{branch}" if branch else repo_name - - if terminal.open_new_tab(path, full_cmd, tab_name=tab_name): - success(f"Started {agent.name} in new {terminal.name} tab") - return - warn(f"Could not open new tab in {terminal.name}") + terminal = _resolve_launch_terminal(multiplexer_name) + full_cmd = _build_agent_launch_command( + path, agent, extra_args, prompt, task_file, env, terminal + ) + + if terminal is not None: + repo_root, tab_name = _tab_name_for_path(path) + launched, handle = _launch_in_terminal( + path, + agent, + terminal, + full_cmd, + tab_name, + repo_root, + multiplexer_name, + ) + if launched: + return handle # No terminal detected or failed - print instructions if _is_ssh_session(): @@ -293,3 +396,4 @@ def launch_agent( console.print(f"\n[bold]To start {agent.name}:[/bold]") console.print(f" cd {path}") console.print(f" {full_cmd}") + return None diff --git a/agent_cli/dev/skill/SKILL.md b/agent_cli/dev/skill/SKILL.md index 9c4e52f55..2fe014980 100644 --- a/agent_cli/dev/skill/SKILL.md +++ b/agent_cli/dev/skill/SKILL.md @@ -1,12 +1,16 @@ --- name: agent-cli-dev -description: Spawns AI coding agents in isolated git worktrees. Use when the user asks to spawn or launch an agent, delegate a task to a separate agent, work in a separate worktree, or parallelize development across features. +description: Spawns AI coding agents in isolated git worktrees. Use when the user asks to spawn or launch an agent, delegate a task to a separate agent, or parallelize development across features. Only create a worktree without starting an agent if the user explicitly wants setup only. --- # Parallel Development with agent-cli dev This skill teaches you how to spawn parallel AI coding agents in isolated git worktrees using the `agent-cli dev` command. +`agent-cli dev` supports two complementary patterns: +- Separate worktrees for isolated implementation/review tasks +- Multiple agents on the same worktree using `dev agent -m tmux` + ## Installation If `agent-cli` is not available, install it first: @@ -58,6 +62,16 @@ This creates: **Important**: Use `--prompt-file` for prompts longer than a single line. The `--prompt` option passes text through the shell, which can cause issues with special characters (exclamation marks, dollar signs, backticks, quotes) in ZSH and other shells. Using `--prompt-file` avoids all shell quoting issues. +## Automation rule + +When an assistant is executing this workflow on the user's behalf, the spawn is not complete unless the agent receives a prompt at launch time. + +- Prefer `--prompt-file`; create the prompt file first, then launch the agent +- Use `dev new ... --agent --prompt-file ...` for a new delegated task +- Use `dev agent ... --prompt-file ...` for another agent in an existing worktree +- Do not stop after `dev new ...` alone if the user's intent was to delegate work immediately +- Do not run `dev new ... --agent` or `dev agent ... -m tmux` without `--prompt` or `--prompt-file` unless the user explicitly wants an interactive session that they will drive manually + ## Writing effective prompts for spawned agents Spawned agents work in isolation, so prompts must be **self-contained**. Include: @@ -76,7 +90,7 @@ For any prompt longer than a single sentence: Example workflow: ```bash -# 1. Write prompt to file (Claude does this with the Write tool) +# 1. Write prompt to file # 2. Spawn agent with the file agent-cli dev new my-feature --agent --prompt-file .claude/spawn-prompt.md # 3. Optionally clean up @@ -114,6 +128,54 @@ agent-cli dev run cat .claude/REPORT.md agent-cli dev editor ``` +## Same-branch multi-agent workflow + +Use this when several agents should inspect or validate the same code at once without separate worktrees. + +```bash +# Create the worktree once. This step only prepares the shared workspace. +agent-cli dev new review-auth --from HEAD + +# Then launch the actual agents with prompts. +agent-cli dev agent review-auth -m tmux --prompt-file .claude/review-security.md +agent-cli dev agent review-auth -m tmux --prompt-file .claude/review-performance.md +agent-cli dev agent review-auth -m tmux --prompt-file .claude/review-tests.md +``` + +Key rules for same-worktree launches: +- Use `dev agent`, not `dev new`, after the worktree already exists +- Use `-m tmux` for headless or scripted launching; it works even when not already inside tmux +- Each launch joins the same deterministic repo-scoped tmux session, so related agents stay grouped together +- Ask each agent to write to a unique report path such as `.claude/REPORT-security-.md` or `.claude/REPORT-tests-.md` +- If you rerun the same prompt repeatedly, include a timestamp or other run id in the report filename so later runs do not overwrite earlier ones +- Do not rely on `.claude/TASK.md` as per-agent state in shared worktrees; later launches overwrite it + +### Prompt guidance for shared worktrees + +When multiple agents share a worktree, explicitly assign both a focus area and a unique report file. If you rerun the same review prompt often, prefer a timestamped filename such as `.claude/REPORT-security-20260319-153045-123.md`. + +Prompt pattern: + +```text +Review the auth module for security issues only. + +When complete, write findings to .claude/REPORT-security-20260319-153045-123.md including: +- Summary +- Issues found with file/line references +- Suggested fixes +``` + +## Headless/scripted orchestration + +For non-interactive contexts (scripts, cron jobs, other assistants), combine `--prompt-file` with `-m tmux`: + +```bash +agent-cli dev new validation-a --from HEAD --agent --with-agent codex -m tmux \ + --prompt-file .claude/validation-a.md +``` + +This works without an attached terminal. `agent-cli` creates or reuses a detached tmux session and returns a pane handle plus attach command. + ## Example: Multi-feature implementation If asked to implement auth, payments, and notifications: diff --git a/agent_cli/dev/skill/examples.md b/agent_cli/dev/skill/examples.md index 5a06a8d2c..dfcb22a16 100644 --- a/agent_cli/dev/skill/examples.md +++ b/agent_cli/dev/skill/examples.md @@ -8,6 +8,8 @@ Real-world scenarios for spawning parallel AI coding agents, optimized for Claud > # Write prompt to file, then spawn > agent-cli dev new my-feature --agent --prompt-file .claude/spawn-prompt.md > ``` +> +> When an assistant is executing these commands, do not launch `dev new` or `dev agent` without `--prompt` or `--prompt-file` unless the user explicitly wants a manual interactive session. ## Prompt structure guidelines @@ -19,6 +21,7 @@ Each prompt for a spawned agent should follow this structure: 4. **Context with motivation** - Explain why patterns matter 5. **Focused scope** - Keep solutions minimal, implement only what's requested 6. **Structured report** - Write conclusions to `.claude/REPORT.md` +7. **No interactive gap** - The launch command itself should include `--prompt` or `--prompt-file` so the agent starts working immediately ## Scenario 1: Code review of current branch @@ -524,6 +527,96 @@ When complete, write to .claude/REPORT.md: " ``` +## Scenario 6: Multi-reviewer on the same branch + +**User request**: "Get 3 agents to review this code" or "Run multiple reviewers on this branch" + +**Strategy**: Create one review worktree from the current branch, then launch several agents into that same worktree with different focus areas. + +```bash +run_id="$(python -c 'import time; print(int(time.time() * 1000))')" + +# Create the shared review worktree once. This does not start an agent yet. +agent-cli dev new review-auth --from HEAD + +# Launch three reviewers into the same worktree/session +agent-cli dev agent review-auth -m tmux --prompt "Review the auth module for security issues only. + + +Do not fix code. Review only. + + + +Write findings to .claude/REPORT-security-$run_id.md: +- Summary +- Issues with file:line references +- Suggested fixes +" + +agent-cli dev agent review-auth -m tmux --prompt "Review the auth module for performance issues only. + + +Review only. Focus on query patterns, repeated work, and unnecessary allocations. + + + +Write findings to .claude/REPORT-performance-$run_id.md: +- Summary +- Issues with file:line references +- Suggested fixes +" + +agent-cli dev agent review-auth -m tmux --prompt "Review the auth module for test coverage gaps only. + + +Review only. Identify missing or weak tests. + + + +Write findings to .claude/REPORT-tests-$run_id.md: +- Summary +- Missing test cases +- Suggested follow-up tests +" +``` + +**Important**: +- Same-worktree launches should use unique report files, not `.claude/REPORT.md` +- If you rerun the same prompt often, include a timestamp or run id in the filename so reports do not get replaced +- `-m tmux` works even when the caller is not already inside tmux +- All three agents land in the same deterministic tmux session for that repo +- `.claude/TASK.md` is shared state and may be overwritten by later launches, so keep prompt files outside that convention + +## Scenario 7: Parallel test validation + +**User request**: "Run the test checklist across 8 sections in parallel" + +**Strategy**: Create one worktree per section, then launch each validation agent headlessly in tmux so the workflow works from scripts or non-terminal orchestrators. + +```bash +for section in 1 2 3 4 5 6 7 8; do + agent-cli dev new "test-section-$section" --from HEAD --agent --with-agent codex -m tmux \ + --prompt-file ".claude/test-section-$section.md" +done +``` + +Monitor and collect results: + +```bash +# Track the worktrees +agent-cli dev status + +# Read each report after completion +for section in 1 2 3 4 5 6 7 8; do + agent-cli dev run "test-section-$section" cat .claude/REPORT.md +done +``` + +**When writing the per-section prompts**: +- Assign one checklist section per agent +- Require real test execution where possible +- Require a structured `.claude/REPORT.md` with pass/fail status, evidence, and follow-up actions + ## Reviewing results After agents complete their work: @@ -569,3 +662,8 @@ Any items that need human review or clarification ``` This consistent format makes it easy to review work from multiple agents. + +For shared-worktree runs, keep the same structure but use unique filenames such as: +- `.claude/REPORT-security.md` +- `.claude/REPORT-performance.md` +- `.claude/REPORT-tests.md` diff --git a/agent_cli/dev/terminals/__init__.py b/agent_cli/dev/terminals/__init__.py index f8e835834..d69f5e8e1 100644 --- a/agent_cli/dev/terminals/__init__.py +++ b/agent_cli/dev/terminals/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .base import Terminal +from .base import Terminal, TerminalHandle from .registry import ( detect_current_terminal, get_all_terminals, @@ -12,6 +12,7 @@ __all__ = [ "Terminal", + "TerminalHandle", "detect_current_terminal", "get_all_terminals", "get_available_terminals", diff --git a/agent_cli/dev/terminals/base.py b/agent_cli/dev/terminals/base.py index 491788460..e47882665 100644 --- a/agent_cli/dev/terminals/base.py +++ b/agent_cli/dev/terminals/base.py @@ -4,12 +4,22 @@ import os from abc import ABC, abstractmethod +from dataclasses import dataclass from typing import TYPE_CHECKING if TYPE_CHECKING: from pathlib import Path +@dataclass(frozen=True) +class TerminalHandle: + """Handle for a launched terminal target.""" + + terminal_name: str + handle: str + session_name: str | None = None + + class Terminal(ABC): """Abstract base class for terminal adapters.""" diff --git a/agent_cli/dev/terminals/tmux.py b/agent_cli/dev/terminals/tmux.py index 83fc2a14a..26e0f71c6 100644 --- a/agent_cli/dev/terminals/tmux.py +++ b/agent_cli/dev/terminals/tmux.py @@ -2,12 +2,14 @@ from __future__ import annotations +import hashlib import os +import re import shutil import subprocess from typing import TYPE_CHECKING -from .base import Terminal +from .base import Terminal, TerminalHandle if TYPE_CHECKING: from pathlib import Path @@ -27,6 +29,65 @@ def is_available(self) -> bool: """Check if tmux is available.""" return shutil.which("tmux") is not None + def session_name_for_repo(self, repo_root: Path) -> str: + """Build a deterministic tmux session name for a repo.""" + repo_slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", repo_root.name).strip("-") or "repo" + repo_hash = hashlib.sha256(str(repo_root).encode()).hexdigest()[:8] + return f"agent-cli-{repo_slug[:24]}-{repo_hash}" + + def current_session_name(self) -> str | None: + """Get the current tmux session name.""" + try: + result = subprocess.run( + ["tmux", "display-message", "-p", "#{session_name}"], # noqa: S607 + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError: + return None + session_name = result.stdout.strip() + return session_name or None + + def session_exists(self, session_name: str) -> bool: + """Check if a tmux session exists.""" + try: + subprocess.run( + ["tmux", "has-session", "-t", session_name], # noqa: S607 + check=True, + capture_output=True, + text=True, + ) + return True + except subprocess.CalledProcessError: + return False + + def open_in_session( + self, + path: Path, + command: str | None = None, + tab_name: str | None = None, + *, + session_name: str | None = None, + ) -> TerminalHandle | None: + """Open a tmux window and return its pane handle. + + If ``session_name`` is omitted, the current tmux session is used. + When a named session does not exist yet, it is created in detached mode. + """ + if not self.is_available(): + return None + + if session_name is None: + session_name = self.current_session_name() + if session_name is None: + return None + return self._open_window(path, command, tab_name, session_name=session_name) + + if self.session_exists(session_name): + return self._open_window(path, command, tab_name, session_name=session_name) + return self._create_session(path, command, tab_name, session_name=session_name) + def open_new_tab( self, path: Path, @@ -37,22 +98,66 @@ def open_new_tab( Creates a new tmux window (similar to a tab) in the current session. """ - if not self.is_available(): - return False - - try: - # Create new window in current session - # -c sets the working directory, so no need for cd in command - cmd = ["tmux", "new-window", "-c", str(path)] + return self.open_in_session(path, command, tab_name) is not None - if tab_name: - cmd.extend(["-n", tab_name]) + def _open_window( + self, + path: Path, + command: str | None, + tab_name: str | None, + *, + session_name: str, + ) -> TerminalHandle | None: + """Open a new window in an existing tmux session.""" + return self._spawn_target( + ["tmux", "new-window", "-t", session_name], + path=path, + command=command, + tab_name=tab_name, + session_name=session_name, + ) - if command: - # Run command in new window (cwd already set by -c) - cmd.append(command) + def _create_session( + self, + path: Path, + command: str | None, + tab_name: str | None, + *, + session_name: str, + ) -> TerminalHandle | None: + """Create a detached tmux session and return its initial pane handle.""" + return self._spawn_target( + ["tmux", "new-session", "-d", "-s", session_name], + path=path, + command=command, + tab_name=tab_name, + session_name=session_name, + ) - subprocess.run(cmd, check=True, capture_output=True) - return True + def _spawn_target( + self, + base_cmd: list[str], + *, + path: Path, + command: str | None, + tab_name: str | None, + session_name: str, + ) -> TerminalHandle | None: + """Run a tmux new-window/new-session command and capture its pane handle.""" + cmd = [*base_cmd, "-P", "-F", "#{pane_id}", "-c", str(path)] + if tab_name: + cmd.extend(["-n", tab_name]) + if command: + cmd.append(command) + try: + result = subprocess.run(cmd, check=True, capture_output=True, text=True) except subprocess.CalledProcessError: - return False + return None + pane_id = result.stdout.strip() + if not pane_id: + return None + return TerminalHandle( + terminal_name=self.name, + handle=pane_id, + session_name=session_name, + ) diff --git a/docs/commands/dev.md b/docs/commands/dev.md index 6beb999f5..a8474466b 100644 --- a/docs/commands/dev.md +++ b/docs/commands/dev.md @@ -31,6 +31,9 @@ agent-cli dev new my-feature # Create a dev environment and open in editor + start AI agent agent-cli dev new my-feature -e -a +# Create a dev environment and launch the agent in a detached tmux session +agent-cli dev new my-feature -a -m tmux + # List all dev environments agent-cli dev list @@ -83,6 +86,7 @@ agent-cli dev new [BRANCH] [OPTIONS] | `--agent-args` | - | Extra CLI args for the agent. Can be repeated. Example: --agent-args='--dangerously-skip-permissions' | | `--prompt, -p` | - | Initial task for the AI agent. Saved to .claude/TASK.md. Implies --agent. Example: --prompt='Fix the login bug' | | `--prompt-file, -P` | - | Read the agent prompt from a file. Useful for long prompts to avoid shell quoting. Implies --agent | +| `--multiplexer, -m` | - | Launch the agent in a specific multiplexer. Currently supported: tmux. When started outside tmux, creates or reuses a detached session and reports the pane handle | | `--verbose, -v` | `false` | Stream output from setup commands instead of hiding it | @@ -94,10 +98,10 @@ agent-cli dev new [BRANCH] [OPTIONS] # Create dev environment from a specific commit agent-cli dev new hotfix --from v1.2.3 -# Create dev environment with Cursor and Claude +# Create an interactive dev environment with Cursor and Claude agent-cli dev new feature --with-editor cursor --with-agent claude -# Quick dev environment with defaults from config +# Quick interactive dev environment with defaults from config agent-cli dev new -e -a # Create dev environment with an initial prompt for the agent @@ -105,8 +109,13 @@ agent-cli dev new fix-bug -a --prompt "Fix the login validation bug in auth.py" # Use --prompt-file for long prompts (avoids shell quoting issues) agent-cli dev new refactor -a --prompt-file task.md + +# Launch the agent in tmux even when you're not already inside tmux +agent-cli dev new feature -a -m tmux --prompt-file task.md ``` +For automated or headless use, pass `--prompt` or `--prompt-file` so the agent starts working immediately. A bare `-a` or `-m tmux` launch is mainly useful when a human plans to attach and drive the session interactively. + ### `dev list` List all dev environments (worktrees) for the current repository. @@ -266,7 +275,7 @@ agent-cli dev editor NAME [--editor/-e EDITOR] Start an AI coding agent in a dev environment. ```bash -agent-cli dev agent NAME [--agent/-a AGENT] [--agent-args ARGS] [--prompt/-p PROMPT] +agent-cli dev agent NAME [--agent/-a AGENT] [--agent-args ARGS] [--prompt/-p PROMPT] [--multiplexer/-m tmux] ``` **Options:** @@ -285,6 +294,7 @@ agent-cli dev agent NAME [--agent/-a AGENT] [--agent-args ARGS] [--prompt/-p PRO | `--agent-args` | - | Extra CLI args for the agent. Example: --agent-args='--dangerously-skip-permissions' | | `--prompt, -p` | - | Initial task for the agent. Saved to .claude/TASK.md. Example: --prompt='Add unit tests for auth' | | `--prompt-file, -P` | - | Read the agent prompt from a file instead of command line | +| `--multiplexer, -m` | - | Launch the agent in a specific multiplexer instead of the current terminal. Currently supported: tmux | @@ -297,8 +307,13 @@ agent-cli dev agent my-feature --prompt "Continue implementing the user settings # Start aider with a specific task agent-cli dev agent my-feature -a aider --prompt "Add unit tests for the auth module" + +# Start an agent in a detached tmux session and get its pane handle +agent-cli dev agent my-feature -a codex -m tmux --prompt-file continue-task.md ``` +For automated use, prefer `--prompt-file` or `--prompt`. Without either, the agent starts interactively and may wait for input. + ### `dev run` Run a command in a dev environment. @@ -695,8 +710,42 @@ The generated `.envrc` is automatically trusted with `direnv allow`. When launching an AI agent, the dev command automatically: 1. Detects if you're in tmux/zellij and opens a new tab there -2. Falls back to supported terminals (kitty, iTerm2) -3. Prints instructions if no terminal is detected +2. With `-m tmux`, creates or reuses a detached tmux session even when you're not already inside tmux +3. Returns the tmux pane handle and an attach command for explicit tmux launches +4. Falls back to supported terminals (kitty, iTerm2) +5. Prints instructions if no terminal is detected + +### Multi-agent Workflows + +Use `dev agent -m tmux` when you want multiple agents on the same worktree instead of multiple worktrees: + +```bash +# Create the worktree once. This is setup only; no agent starts yet. +agent-cli dev new review-auth --from HEAD + +# Launch multiple reviewers into the same worktree +agent-cli dev agent review-auth -m tmux --prompt-file .claude/review-security.md +agent-cli dev agent review-auth -m tmux --prompt-file .claude/review-performance.md +agent-cli dev agent review-auth -m tmux --prompt-file .claude/review-tests.md +``` + +This is useful for: +- Multiple reviewers on the same branch +- Parallel validation agents working on one codebase +- Headless orchestration from scripts or other assistants + +All explicit tmux launches for the same repository are grouped into the same deterministic tmux session (`agent-cli--`), which keeps related windows together even when the command is run outside tmux. + +For fully headless orchestration, combine `--prompt-file` with `-m tmux`: + +```bash +for section in 1 2 3 4; do + agent-cli dev new "validate-$section" --from HEAD --agent --with-agent codex -m tmux \ + --prompt-file ".claude/validate-$section.md" +done +``` + +If multiple agents share one worktree, do not have them all write to `.claude/REPORT.md` because they will overwrite each other. Instead, assign unique report paths such as `.claude/REPORT-security-.md` and `.claude/REPORT-tests-.md`. If you rerun the same prompt repeatedly, use a timestamp or other run id so later runs do not replace earlier results. The same applies to `.claude/TASK.md`: it reflects the most recent launch, not stable per-agent state. ## Shell Integration diff --git a/tests/dev/test_cli.py b/tests/dev/test_cli.py index 28234cbaf..0d7a36706 100644 --- a/tests/dev/test_cli.py +++ b/tests/dev/test_cli.py @@ -26,6 +26,7 @@ get_config_agent_args, get_config_agent_env, ) +from agent_cli.dev.terminals import TerminalHandle from agent_cli.dev.worktree import CreateWorktreeResult, WorktreeInfo runner = CliRunner(env={"NO_COLOR": "1", "TERM": "dumb"}) @@ -510,6 +511,54 @@ def test_new_uses_dev_config_defaults_for_branch_naming(self, tmp_path: Path) -> ) assert mock_create.call_args.kwargs["fetch"] is False + def test_new_shows_tmux_handle_when_requested(self, tmp_path: Path) -> None: + """`dev new -m tmux` shows the returned tmux handle in the summary.""" + wt_path = tmp_path / "repo-worktrees" / "feature" + wt_path.mkdir(parents=True) + + with ( + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch( + "agent_cli.dev.worktree.create_worktree", + return_value=CreateWorktreeResult( + success=True, + path=wt_path, + branch="feature", + ), + ), + patch("agent_cli.dev.cli.resolve_editor", return_value=None), + patch("agent_cli.dev.cli.resolve_agent") as mock_resolve_agent, + patch("agent_cli.dev.cli.merge_agent_args", return_value=None), + patch("agent_cli.dev.cli.get_agent_env", return_value={}), + patch( + "agent_cli.dev.cli.launch_agent", + return_value=TerminalHandle("tmux", "%42", "agent-cli-repo-1234"), + ) as mock_launch, + ): + mock_agent = mock_resolve_agent.return_value + mock_agent.is_available.return_value = True + result = runner.invoke( + app, + [ + "dev", + "new", + "feature", + "-a", + "-m", + "tmux", + "--no-setup", + "--no-copy-env", + "--no-fetch", + "--no-direnv", + ], + ) + + assert result.exit_code == 0 + assert mock_launch.call_args.kwargs["multiplexer_name"] == "tmux" + assert "Agent Handle:" in result.output + assert "%42" in result.output + assert "tmux attach -t agent-cli-repo-1234" in result.output + class TestDevHelp: """Tests for dev command help.""" @@ -524,6 +573,73 @@ def test_dev_help(self) -> None: assert "rm" in result.output +class TestDevAgent: + """Tests for `dev agent`.""" + + def test_agent_can_launch_in_requested_tmux(self) -> None: + """`dev agent -m tmux` delegates to the tmux launch path and shows the handle.""" + wt = WorktreeInfo( + path=Path("/repo-worktrees/feature"), + branch="feature", + commit="abc", + is_main=False, + is_detached=False, + is_locked=False, + is_prunable=False, + ) + + with ( + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.worktree.find_worktree_by_name", return_value=wt), + patch("agent_cli.dev.cli.coding_agents.detect_current_agent") as mock_detect_current, + patch("agent_cli.dev.cli.merge_agent_args", return_value=None), + patch("agent_cli.dev.cli.get_agent_env", return_value={}), + patch( + "agent_cli.dev.cli.launch_agent", + return_value=TerminalHandle("tmux", "%42", "agent-cli-repo-1234"), + ) as mock_launch, + ): + current_agent = mock_detect_current.return_value + current_agent.name = "codex" + current_agent.is_available.return_value = True + result = runner.invoke(app, ["dev", "agent", "feature", "-m", "tmux"]) + + assert result.exit_code == 0 + assert mock_launch.call_args.kwargs["multiplexer_name"] == "tmux" + assert "tmux handle: %42" in result.output + + def test_agent_quotes_tmux_attach_hint(self) -> None: + """Attach hint should quote session names that contain spaces.""" + wt = WorktreeInfo( + path=Path("/repo-worktrees/feature"), + branch="feature", + commit="abc", + is_main=False, + is_detached=False, + is_locked=False, + is_prunable=False, + ) + + with ( + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.worktree.find_worktree_by_name", return_value=wt), + patch("agent_cli.dev.cli.coding_agents.detect_current_agent") as mock_detect_current, + patch("agent_cli.dev.cli.merge_agent_args", return_value=None), + patch("agent_cli.dev.cli.get_agent_env", return_value={}), + patch( + "agent_cli.dev.cli.launch_agent", + return_value=TerminalHandle("tmux", "%42", "my session"), + ), + ): + current_agent = mock_detect_current.return_value + current_agent.name = "codex" + current_agent.is_available.return_value = True + result = runner.invoke(app, ["dev", "agent", "feature", "-m", "tmux"]) + + assert result.exit_code == 0 + assert "tmux attach -t 'my session'" in result.output + + class TestDevAgents: """Tests for dev agents command.""" diff --git a/tests/dev/test_launch.py b/tests/dev/test_launch.py new file mode 100644 index 000000000..20d8abba0 --- /dev/null +++ b/tests/dev/test_launch.py @@ -0,0 +1,149 @@ +"""Tests for dev launch helpers.""" + +from __future__ import annotations + +import shlex +from pathlib import Path +from unittest.mock import MagicMock, patch + +from agent_cli.dev.launch import launch_agent +from agent_cli.dev.terminals import TerminalHandle +from agent_cli.dev.terminals.tmux import Tmux + + +class TestLaunchAgent: + """Tests for agent launch behavior.""" + + def test_uses_requested_tmux_outside_tmux(self, tmp_path: Path) -> None: + """Explicit tmux launch uses a detached repo session when not already in tmux.""" + agent = MagicMock() + agent.name = "codex" + agent.launch_command.return_value = ["codex"] + + tmux_terminal = Tmux() + handle = TerminalHandle("tmux", "%42", "agent-cli-repo-1234") + + with ( + patch("agent_cli.dev.launch.terminals.get_terminal", return_value=tmux_terminal), + patch("agent_cli.dev.launch.terminals.detect_current_terminal", return_value=None), + patch.object(tmux_terminal, "is_available", return_value=True), + patch.object(tmux_terminal, "detect", return_value=False), + patch.object( + tmux_terminal, + "session_name_for_repo", + return_value="agent-cli-repo-1234", + ) as mock_session_name, + patch.object(tmux_terminal, "open_in_session", return_value=handle) as mock_open, + patch("agent_cli.dev.launch.worktree.get_main_repo_root", return_value=Path("/repo")), + patch("agent_cli.dev.launch.worktree.get_current_branch", return_value="feature"), + ): + result = launch_agent(tmp_path, agent, multiplexer_name="tmux") + + assert result == handle + mock_session_name.assert_called_once_with(Path("/repo")) + mock_open.assert_called_once_with( + tmp_path, + "codex", + tab_name="repo@feature", + session_name="agent-cli-repo-1234", + ) + + def test_uses_wrapper_script_for_requested_tmux(self, tmp_path: Path) -> None: + """Prompt launches still use the wrapper script for explicit tmux sessions.""" + task_file = tmp_path / ".claude" / "TASK.md" + task_file.parent.mkdir(parents=True) + task_file.write_text("Fix the bug\n") + wrapper_script = tmp_path / "agent-wrapper.sh" + + agent = MagicMock() + agent.name = "codex" + + tmux_terminal = Tmux() + handle = TerminalHandle("tmux", "%7", "agent-cli-repo-1234") + + with ( + patch("agent_cli.dev.launch.terminals.get_terminal", return_value=tmux_terminal), + patch("agent_cli.dev.launch.terminals.detect_current_terminal", return_value=None), + patch.object(tmux_terminal, "is_available", return_value=True), + patch.object(tmux_terminal, "detect", return_value=False), + patch.object( + tmux_terminal, + "session_name_for_repo", + return_value="agent-cli-repo-1234", + ), + patch( + "agent_cli.dev.launch._create_prompt_wrapper_script", + return_value=wrapper_script, + ), + patch.object(tmux_terminal, "open_in_session", return_value=handle) as mock_open, + patch("agent_cli.dev.launch.worktree.get_main_repo_root", return_value=Path("/repo")), + patch("agent_cli.dev.launch.worktree.get_current_branch", return_value="feature"), + ): + result = launch_agent( + tmp_path, + agent, + task_file=task_file, + multiplexer_name="tmux", + ) + + assert result == handle + mock_open.assert_called_once_with( + tmp_path, + f"bash {shlex.quote(str(wrapper_script))}", + tab_name="repo@feature", + session_name="agent-cli-repo-1234", + ) + + def test_non_tmux_success_does_not_fall_back_to_manual_instructions( + self, + tmp_path: Path, + ) -> None: + """Successful non-tmux launches should not print fallback instructions.""" + agent = MagicMock() + agent.name = "codex" + agent.launch_command.return_value = ["codex"] + + terminal = MagicMock() + terminal.name = "kitty" + terminal.is_available.return_value = True + terminal.open_new_tab.return_value = True + + with ( + patch("agent_cli.dev.launch.terminals.get_terminal", return_value=terminal), + patch("agent_cli.dev.launch.terminals.detect_current_terminal", return_value=None), + patch("agent_cli.dev.launch.worktree.get_main_repo_root", return_value=Path("/repo")), + patch("agent_cli.dev.launch.worktree.get_current_branch", return_value="feature"), + patch("agent_cli.dev.launch.console.print") as mock_print, + ): + result = launch_agent(tmp_path, agent, multiplexer_name="kitty") + + assert result is None + terminal.open_new_tab.assert_called_once_with(tmp_path, "codex", tab_name="repo@feature") + printed = "\n".join(call.args[0] for call in mock_print.call_args_list if call.args) + assert "Started codex in new kitty tab" in printed + assert "To start codex:" not in printed + + def test_requested_tmux_unavailable_falls_back_to_manual_instructions( + self, + tmp_path: Path, + ) -> None: + """Requested tmux launch should fall back cleanly when tmux is unavailable.""" + agent = MagicMock() + agent.name = "codex" + agent.launch_command.return_value = ["codex"] + + tmux_terminal = Tmux() + + with ( + patch("agent_cli.dev.launch.terminals.get_terminal", return_value=tmux_terminal), + patch("agent_cli.dev.launch.terminals.detect_current_terminal", return_value=None), + patch.object(tmux_terminal, "is_available", return_value=False), + patch("agent_cli.dev.launch._is_ssh_session", return_value=False), + patch("agent_cli.dev.launch.console.print") as mock_print, + ): + result = launch_agent(tmp_path, agent, multiplexer_name="tmux") + + assert result is None + printed = "\n".join(call.args[0] for call in mock_print.call_args_list if call.args) + assert "To start codex:" in printed + assert f"cd {tmp_path}" in printed diff --git a/tests/dev/test_terminals.py b/tests/dev/test_terminals.py index 3a5d8fa68..a219fddb9 100644 --- a/tests/dev/test_terminals.py +++ b/tests/dev/test_terminals.py @@ -46,6 +46,7 @@ def test_open_new_tab(self) -> None: mock_run = MagicMock(return_value=MagicMock(returncode=0)) with ( patch("subprocess.run", mock_run), + patch.object(terminal, "current_session_name", return_value="current-session"), patch("shutil.which", return_value="/usr/bin/tmux"), ): result = terminal.open_new_tab(Path("/some/path"), "echo hello", tab_name="test") @@ -55,6 +56,64 @@ def test_open_new_tab(self) -> None: call_args = mock_run.call_args assert "new-window" in call_args[0][0] + def test_session_name_for_repo(self) -> None: + """Repo session names are deterministic and shell-safe.""" + terminal = Tmux() + session_name = terminal.session_name_for_repo(Path("/workspace/my repo")) + assert session_name.startswith("agent-cli-my-repo-") + + def test_open_in_session_creates_detached_session_when_missing(self) -> None: + """Outside tmux, a named session is created in detached mode if absent.""" + terminal = Tmux() + mock_run = MagicMock(return_value=MagicMock(stdout="%42\n")) + with ( + patch("subprocess.run", mock_run), + patch.object(terminal, "session_exists", return_value=False), + patch("shutil.which", return_value="/usr/bin/tmux"), + ): + handle = terminal.open_in_session( + Path("/some/path"), + "echo hello", + tab_name="test", + session_name="repo-session", + ) + + assert handle is not None + assert handle.handle == "%42" + assert handle.session_name == "repo-session" + cmd = mock_run.call_args[0][0] + assert "new-session" in cmd + assert "-d" in cmd + assert "-P" in cmd + assert "-F" in cmd + assert "#{pane_id}" in cmd + assert "-s" in cmd + assert "repo-session" in cmd + + def test_open_in_session_reuses_existing_session(self) -> None: + """Outside tmux, a named session gets a new window when it already exists.""" + terminal = Tmux() + mock_run = MagicMock(return_value=MagicMock(stdout="%5\n")) + with ( + patch("subprocess.run", mock_run), + patch.object(terminal, "session_exists", return_value=True), + patch("shutil.which", return_value="/usr/bin/tmux"), + ): + handle = terminal.open_in_session( + Path("/some/path"), + "echo hello", + tab_name="test", + session_name="repo-session", + ) + + assert handle is not None + assert handle.handle == "%5" + assert handle.session_name == "repo-session" + cmd = mock_run.call_args[0][0] + assert "new-window" in cmd + assert "-t" in cmd + assert "repo-session" in cmd + class TestZellij: """Tests for Zellij terminal."""