From a8fa9ea7036662cfd082e29f27f2eb9859a21ba7 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 9 Feb 2026 17:58:39 -0800 Subject: [PATCH 01/20] feat(dev): add sub-agent orchestration for tmux Add primitives for spawning, monitoring, and interacting with AI coding agents in tmux panes. This enables workflows like: spawn an implementer, wait for completion, spawn a reviewer in the same worktree, read review output, and send corrections back. New modules: - tmux_ops.py: low-level tmux pane operations (open, capture, send, exists) - agent_state.py: JSON-based state tracking for spawned agents - poller.py: background daemon for polling agent status New CLI commands: - dev poll: show status of all tracked agents (table or --json) - dev output: capture and display agent's terminal output - dev send: send text/commands to an agent's tmux pane - dev wait: block until an agent reaches done/dead status Modified: - _launch_agent(): tracks agents in tmux via pane IDs, auto-starts poller - dev agent: added --tab (launch in new tmux tab) and --name flags - Completion detection via Claude Code Stop hooks (.claude/DONE sentinel) with fallback to output quiescence (SHA-256 hash comparison) --- agent_cli/dev/agent_state.py | 169 +++++++++++ agent_cli/dev/cli.py | 472 +++++++++++++++++++++++++++++- agent_cli/dev/poller.py | 146 ++++++++++ agent_cli/dev/tmux_ops.py | 87 ++++++ tests/dev/test_orchestration.py | 500 ++++++++++++++++++++++++++++++++ 5 files changed, 1360 insertions(+), 14 deletions(-) create mode 100644 agent_cli/dev/agent_state.py create mode 100644 agent_cli/dev/poller.py create mode 100644 agent_cli/dev/tmux_ops.py create mode 100644 tests/dev/test_orchestration.py diff --git a/agent_cli/dev/agent_state.py b/agent_cli/dev/agent_state.py new file mode 100644 index 00000000..ff90168c --- /dev/null +++ b/agent_cli/dev/agent_state.py @@ -0,0 +1,169 @@ +"""Agent state tracking for orchestration.""" + +from __future__ import annotations + +import json +import re +import time +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Literal + +STATE_BASE = Path.home() / ".cache" / "agent-cli" + +AgentStatus = Literal["running", "idle", "done", "dead"] + + +@dataclass +class TrackedAgent: + """A single tracked agent instance.""" + + name: str + pane_id: str + worktree_path: str + agent_type: str + started_at: float + status: AgentStatus = "running" + last_output_hash: str = "" + last_change_at: float = 0.0 + + +@dataclass +class AgentStateFile: + """State file for one repository's tracked agents.""" + + repo_root: str + agents: dict[str, TrackedAgent] = field(default_factory=dict) + last_poll_at: float = 0.0 + + +def _repo_slug(repo_root: Path) -> str: + """Convert a repo root path to a filesystem-safe slug.""" + # Use the last two path components for readability, e.g. "Work_my-project" + parts = repo_root.parts[-2:] + slug = "_".join(parts) + # Sanitize: keep only alphanumeric, dash, underscore + return re.sub(r"[^a-zA-Z0-9_-]", "_", slug) + + +def _state_dir(repo_root: Path) -> Path: + """Return the state directory for a repo.""" + return STATE_BASE / _repo_slug(repo_root) + + +def _state_file_path(repo_root: Path) -> Path: + """Return the path to the agents.json state file.""" + return _state_dir(repo_root) / "agents.json" + + +def load_state(repo_root: Path) -> AgentStateFile: + """Load agent state from disk. + + Returns an empty state if the file does not exist or is corrupt. + """ + path = _state_file_path(repo_root) + if not path.exists(): + return AgentStateFile(repo_root=str(repo_root)) + + try: + data = json.loads(path.read_text()) + agents = {} + for name, agent_data in data.get("agents", {}).items(): + agents[name] = TrackedAgent(**agent_data) + return AgentStateFile( + repo_root=data.get("repo_root", str(repo_root)), + agents=agents, + last_poll_at=data.get("last_poll_at", 0.0), + ) + except (json.JSONDecodeError, TypeError, KeyError): + return AgentStateFile(repo_root=str(repo_root)) + + +def save_state(repo_root: Path, state: AgentStateFile) -> None: + """Atomically write state to disk.""" + path = _state_file_path(repo_root) + path.parent.mkdir(parents=True, exist_ok=True) + data = { + "repo_root": state.repo_root, + "agents": {name: asdict(agent) for name, agent in state.agents.items()}, + "last_poll_at": state.last_poll_at, + } + # Write to temp file then rename for atomicity + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(data, indent=2) + "\n") + tmp.rename(path) + + +def register_agent( + repo_root: Path, + name: str, + pane_id: str, + worktree_path: Path, + agent_type: str, +) -> TrackedAgent: + """Register a new tracked agent in the state file.""" + state = load_state(repo_root) + now = time.time() + agent = TrackedAgent( + name=name, + pane_id=pane_id, + worktree_path=str(worktree_path), + agent_type=agent_type, + started_at=now, + last_change_at=now, + ) + state.agents[name] = agent + save_state(repo_root, state) + return agent + + +def unregister_agent(repo_root: Path, name: str) -> bool: + """Remove an agent from the state file. + + Returns ``True`` if the agent was found and removed. + """ + state = load_state(repo_root) + if name not in state.agents: + return False + del state.agents[name] + save_state(repo_root, state) + return True + + +def generate_agent_name( + repo_root: Path, + worktree_path: Path, + agent_type: str, + explicit_name: str | None = None, +) -> str: + """Generate a unique agent name. + + If *explicit_name* is given, uses that (raises if it collides). + Otherwise auto-generates from the worktree branch name. + """ + state = load_state(repo_root) + existing = set(state.agents.keys()) + + if explicit_name: + if explicit_name in existing: + msg = f"Agent name '{explicit_name}' already exists. Use a different --name." + raise ValueError(msg) + return explicit_name + + # Use worktree directory name as base (which is the branch name) + base = worktree_path.name + + # First agent in this worktree: just use the branch name + if base not in existing: + return base + + # Subsequent agents: append agent type + candidate = f"{base}-{agent_type}" + if candidate not in existing: + return candidate + + # Still collides: add numeric suffix + n = 2 + while f"{candidate}-{n}" in existing: + n += 1 + return f"{candidate}-{n}" diff --git a/agent_cli/dev/cli.py b/agent_cli/dev/cli.py index d8eb6668..9b8592ce 100644 --- a/agent_cli/dev/cli.py +++ b/agent_cli/dev/cli.py @@ -34,6 +34,7 @@ ) if TYPE_CHECKING: + from . import agent_state from .coding_agents.base import CodingAgent from .editors.base import Editor @@ -370,21 +371,23 @@ def _launch_agent( prompt: str | None = None, task_file: Path | None = None, env: dict[str, str] | None = None, -) -> None: + *, + track: bool = True, + agent_name: str | None = None, +) -> str | 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. - Args: - path: Directory to launch the agent in - agent: The coding agent to launch - extra_args: Additional CLI arguments for the agent - prompt: Optional initial prompt (used when task_file is not available) - task_file: Path to file containing the prompt (preferred, avoids shell quoting issues) - env: Environment variables to set for the agent + When *track* is ``True`` and tmux is detected, the agent is registered + in the orchestration state file so it can be monitored with ``dev poll``, + ``dev output``, ``dev send``, and ``dev wait``. + Returns the tracked agent name if tracking was successful, else ``None``. """ + from .terminals.tmux import Tmux # noqa: PLC0415 + terminal = terminals.detect_current_terminal() # Use wrapper script when opening in a terminal tab - all terminals pass commands @@ -404,10 +407,25 @@ def _launch_agent( 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): + + # Use tmux_ops for tracked launch when in tmux + if isinstance(terminal, Tmux) and track: + from . import agent_state, tmux_ops # noqa: PLC0415 + + pane_id = tmux_ops.open_window_with_pane_id(path, full_cmd, tab_name=tab_name) + if pane_id: + root = repo_root or path + name = agent_state.generate_agent_name(root, path, agent.name, agent_name) + agent_state.register_agent(root, name, pane_id, path, agent.name) + _inject_completion_hook(path, agent.name) + _success(f"Started {agent.name} in new tmux tab (tracking as [cyan]{name}[/cyan])") + return name + _warn("Could not open new tmux window") + elif 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}") + return None + else: + _warn(f"Could not open new tab in {terminal.name}") # No terminal detected or failed - print instructions if _is_ssh_session(): @@ -417,6 +435,39 @@ 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 + + +def _inject_completion_hook(worktree_path: Path, agent_type: str) -> None: + """Inject a Stop hook into .claude/settings.json for completion detection. + + Only applies to Claude Code agents. Merges with existing settings. + """ + if agent_type != "claude": + return + + settings_path = worktree_path / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + + settings: dict = {} + if settings_path.exists(): + try: + settings = json.loads(settings_path.read_text()) + except json.JSONDecodeError: + settings = {} + + # Merge Stop hook + hooks = settings.setdefault("hooks", {}) + stop_hooks = hooks.setdefault("Stop", []) + + # Check if our hook is already present + sentinel_cmd = "touch .claude/DONE" + for entry in stop_hooks: + if sentinel_cmd in entry.get("hooks", []): + return # Already injected + + stop_hooks.append({"matcher": "", "hooks": [sentinel_cmd]}) + settings_path.write_text(json.dumps(settings, indent=2) + "\n") @app.command("new") @@ -1122,17 +1173,34 @@ def start_agent( readable=True, ), ] = None, + tab: Annotated[ + bool, + typer.Option( + "--tab", + help="Launch in a new tmux tab (tracked) instead of the current terminal", + ), + ] = False, + tracked_name: Annotated[ + str | None, + typer.Option( + "--name", + help="Explicit name for tracking (used with --tab). Auto-generated if omitted", + ), + ] = None, ) -> None: """Start an AI coding agent in an existing dev environment. - Launches the agent directly in your current terminal (not a new tab). - Use this when the worktree already exists and you want to start/resume work. + By default, launches the agent directly in your current terminal. + With ``--tab``, launches in a new tmux tab with orchestration tracking + (can then use ``dev poll``, ``dev output``, ``dev send``, ``dev wait``). **Examples:** - - `dev agent my-feature` — Start auto-detected agent in worktree + - `dev agent my-feature` — Start agent in current terminal - `dev agent my-feature -a claude` — Start Claude specifically - `dev agent my-feature -p "Continue the auth refactor"` — Start with a task + - `dev agent my-feature --tab` — Start in new tracked tmux tab + - `dev agent my-feature --tab --name reviewer -p "Review the changes"` — Named tracked agent """ # Handle prompt-file option (takes precedence over --prompt) if prompt_file is not None: @@ -1160,12 +1228,29 @@ def start_agent( _error(f"{agent.name} is not installed. Install from: {agent.install_url}") # Write prompt to worktree (makes task available to the agent) + task_file = None if prompt: task_file = _write_prompt_to_worktree(wt.path, prompt) _success(f"Wrote task to {task_file.relative_to(wt.path)}") merged_args = _merge_agent_args(agent, agent_args) agent_env = _get_agent_env(agent) + + if tab: + # Launch in a new tmux tab with tracking + _ensure_tmux() + _launch_agent( + wt.path, + agent, + merged_args, + prompt, + task_file, + agent_env, + track=True, + agent_name=tracked_name, + ) + return + _info(f"Starting {agent.name} in {wt.path}...") try: os.chdir(wt.path) @@ -1805,3 +1890,362 @@ def install_skill( console.print("[dim]Skill files:[/dim]") for f in sorted(skill_dest.iterdir()): console.print(f" • {f.name}") + + +# --------------------------------------------------------------------------- +# Orchestration commands (tmux-only) +# --------------------------------------------------------------------------- + + +def _ensure_tmux() -> None: + """Exit with an error if not running inside tmux.""" + if not os.environ.get("TMUX"): + _error("Agent tracking requires tmux. Start a tmux session first.") + + +def _lookup_agent(name: str) -> tuple[Path, agent_state.TrackedAgent]: + """Look up a tracked agent by name. Exits on error.""" + from . import agent_state # noqa: PLC0415 + + repo_root = _ensure_git_repo() + state = agent_state.load_state(repo_root) + agent = state.agents.get(name) + if agent is None: + _error(f"Agent '{name}' not found. Run 'dev poll' to see tracked agents.") + return repo_root, agent + + +def _format_duration(seconds: float) -> str: + """Format seconds into a human-readable duration.""" + if seconds < 60: # noqa: PLR2004 + return f"{int(seconds)}s" + minutes = int(seconds // 60) + secs = int(seconds % 60) + if minutes < 60: # noqa: PLR2004 + return f"{minutes}m {secs}s" + hours = int(minutes // 60) + mins = minutes % 60 + return f"{hours}h {mins}m" + + +def _status_style(status: str) -> str: + """Return a Rich-styled status string.""" + styles = { + "running": "[bold green]running[/bold green]", + "idle": "[bold yellow]idle[/bold yellow]", + "done": "[bold cyan]done[/bold cyan]", + "dead": "[bold red]dead[/bold red]", + } + return styles.get(status, status) + + +@app.command("poll") +def poll_cmd( + json_output: Annotated[ + bool, + typer.Option("--json", help="Output as JSON"), + ] = False, +) -> None: + """Check status of all tracked agents. + + Performs a single poll of all tracked agents (checks tmux panes, + output quiescence, and completion sentinels) then displays results. + + **Status values:** + + - **running** — Agent output is still changing + - **idle** — Agent output has not changed since last poll + - **done** — Agent wrote a completion sentinel (.claude/DONE) + - **dead** — tmux pane no longer exists + + **Examples:** + + - `dev poll` — Show status table + - `dev poll --json` — Machine-readable output + """ + import time # noqa: PLC0415 + + from . import agent_state, tmux_ops # noqa: PLC0415 + + _ensure_tmux() + repo_root = _ensure_git_repo() + state = agent_state.load_state(repo_root) + + if not state.agents: + _info("No tracked agents. Launch one with 'dev new -a' or 'dev agent --tab'.") + return + + now = time.time() + + # Poll each agent + for agent in state.agents.values(): + # Check if pane still exists + if not tmux_ops.pane_exists(agent.pane_id): + agent.status = "dead" + continue + + # Check for completion sentinel (Claude Code hook) + done_path = Path(agent.worktree_path) / ".claude" / "DONE" + if done_path.exists(): + agent.status = "done" + continue + + # Quiescence detection: compare output hash + output = tmux_ops.capture_pane(agent.pane_id) + if output is not None: + h = tmux_ops.hash_output(output) + if h != agent.last_output_hash: + agent.last_output_hash = h + agent.last_change_at = now + agent.status = "running" + else: + agent.status = "idle" + + state.last_poll_at = now + agent_state.save_state(repo_root, state) + + if json_output: + data = { + "agents": [ + { + "name": a.name, + "status": a.status, + "agent_type": a.agent_type, + "worktree_path": a.worktree_path, + "pane_id": a.pane_id, + "started_at": a.started_at, + "duration_seconds": round(now - a.started_at), + "last_change_at": a.last_change_at, + } + for a in state.agents.values() + ], + "last_poll_at": state.last_poll_at, + } + print(json.dumps(data, indent=2)) + return + + table = Table(title="Agent Status") + table.add_column("Name", style="cyan") + table.add_column("Status") + table.add_column("Agent", style="dim") + table.add_column("Worktree", style="dim") + table.add_column("Duration", style="dim") + + for a in state.agents.values(): + table.add_row( + a.name, + _status_style(a.status), + a.agent_type, + Path(a.worktree_path).name, + _format_duration(now - a.started_at), + ) + + console.print(table) + + # Summary line + total = len(state.agents) + by_status: dict[str, int] = {} + for a in state.agents.values(): + by_status[a.status] = by_status.get(a.status, 0) + 1 + parts = [f"{total} agent{'s' if total != 1 else ''}"] + parts.extend( + f"{count} {status}" + for status in ("running", "idle", "done", "dead") + if (count := by_status.get(status, 0)) + ) + console.print(f"\n[dim]{' · '.join(parts)}[/dim]") + + +@app.command("output") +def output_cmd( + name: Annotated[ + str, + typer.Argument(help="Agent name (from 'dev poll')"), + ], + lines: Annotated[ + int, + typer.Option("--lines", "-n", help="Number of lines to capture"), + ] = 50, + follow: Annotated[ + bool, + typer.Option("--follow", "-f", help="Continuously stream output (Ctrl+C to stop)"), + ] = False, +) -> None: + """Get recent terminal output from a tracked agent. + + Captures the last N lines from the agent's tmux pane. + + **Examples:** + + - `dev output my-feature` — Last 50 lines + - `dev output my-feature -n 200` — Last 200 lines + - `dev output my-feature -f` — Follow output continuously + """ + import time as _time # noqa: PLC0415 + + from . import tmux_ops # noqa: PLC0415 + + _ensure_tmux() + _repo_root, agent = _lookup_agent(name) + + if agent.status == "dead": + _error(f"Agent '{name}' is dead (tmux pane closed). No output available.") + + if not follow: + output = tmux_ops.capture_pane(agent.pane_id, lines) + if output is None: + _error(f"Could not capture output from pane {agent.pane_id}") + print(output, end="") + return + + # Follow mode + try: + prev = "" + while True: + output = tmux_ops.capture_pane(agent.pane_id, lines) + if output is None: + _warn("Pane closed.") + break + if output != prev: + # Clear and reprint (simple approach) + print(output, end="", flush=True) + prev = output + _time.sleep(1.0) + except KeyboardInterrupt: + pass + + +@app.command("send") +def send_cmd( + name: Annotated[ + str, + typer.Argument(help="Agent name (from 'dev poll')"), + ], + message: Annotated[ + str, + typer.Argument(help="Text to send to the agent's terminal"), + ], + no_enter: Annotated[ + bool, + typer.Option("--no-enter", help="Don't press Enter after sending"), + ] = False, +) -> None: + """Send text input to a running agent's terminal. + + Types the message into the agent's tmux pane using ``tmux send-keys``. + By default, presses Enter after the message. + + **Examples:** + + - `dev send my-feature "Fix the failing tests"` — Send a message + - `dev send my-feature "/exit" --no-enter` — Send without pressing Enter + """ + from . import tmux_ops # noqa: PLC0415 + + _ensure_tmux() + _repo_root, agent = _lookup_agent(name) + + if agent.status == "dead": + _error(f"Agent '{name}' is dead (tmux pane closed). Cannot send messages.") + + if tmux_ops.send_keys(agent.pane_id, message, enter=not no_enter): + _success(f"Sent message to {name}") + else: + _error(f"Failed to send message to pane {agent.pane_id}") + + +@app.command("wait") +def wait_cmd( + name: Annotated[ + str, + typer.Argument(help="Agent name (from 'dev poll')"), + ], + timeout: Annotated[ + float, + typer.Option("--timeout", "-t", help="Timeout in seconds (0 = no timeout)"), + ] = 0, + interval: Annotated[ + float, + typer.Option("--interval", "-i", help="Poll interval in seconds"), + ] = 5.0, +) -> None: + """Block until a tracked agent finishes. + + Polls the agent's tmux pane until it reaches idle, done, or dead status. + Useful for orchestration: launch an agent, wait for it, then act on results. + + **Exit codes:** + + - 0 — Agent finished (idle or done) + - 1 — Agent died (pane closed unexpectedly) + - 2 — Timeout reached + + **Examples:** + + - `dev wait my-feature` — Wait indefinitely + - `dev wait my-feature --timeout 300` — Wait up to 5 minutes + - `dev wait my-feature -i 2` — Poll every 2 seconds + """ + import time as _time # noqa: PLC0415 + + from . import agent_state, tmux_ops # noqa: PLC0415 + + _ensure_tmux() + repo_root, agent = _lookup_agent(name) + + if agent.status in ("done", "dead", "idle"): + console.print(f"Agent '{name}' is already {_status_style(agent.status)}") + raise typer.Exit(0 if agent.status != "dead" else 1) + + _info(f"Waiting for agent '{name}' to finish (polling every {interval}s)...") + + start = _time.time() + consecutive_idle = 0 + + while True: + elapsed = _time.time() - start + if timeout > 0 and elapsed >= timeout: + _warn(f"Timeout after {_format_duration(elapsed)}") + raise typer.Exit(2) + + # Check pane existence + if not tmux_ops.pane_exists(agent.pane_id): + agent.status = "dead" + state = agent_state.load_state(repo_root) + if name in state.agents: + state.agents[name].status = "dead" + agent_state.save_state(repo_root, state) + _warn(f"Agent '{name}' died (pane closed) after {_format_duration(elapsed)}") + raise typer.Exit(1) + + # Check completion sentinel + done_path = Path(agent.worktree_path) / ".claude" / "DONE" + if done_path.exists(): + state = agent_state.load_state(repo_root) + if name in state.agents: + state.agents[name].status = "done" + agent_state.save_state(repo_root, state) + _success(f"Agent '{name}' completed after {_format_duration(elapsed)}") + raise typer.Exit(0) + + # Quiescence detection + output = tmux_ops.capture_pane(agent.pane_id) + if output is not None: + h = tmux_ops.hash_output(output) + if h == agent.last_output_hash: + consecutive_idle += 1 + else: + consecutive_idle = 0 + agent.last_output_hash = h + agent.last_change_at = _time.time() + + # Require 2 consecutive idle polls to confirm + if consecutive_idle >= 2: # noqa: PLR2004 + state = agent_state.load_state(repo_root) + if name in state.agents: + state.agents[name].status = "idle" + agent_state.save_state(repo_root, state) + _success(f"Agent '{name}' is idle after {_format_duration(elapsed)}") + raise typer.Exit(0) + + _time.sleep(interval) diff --git a/agent_cli/dev/poller.py b/agent_cli/dev/poller.py new file mode 100644 index 00000000..3b6b4826 --- /dev/null +++ b/agent_cli/dev/poller.py @@ -0,0 +1,146 @@ +"""Background poller daemon for agent orchestration.""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + +from . import agent_state, tmux_ops + + +def poll_once(repo_root: Path) -> dict[str, str]: + """Perform a single poll of all tracked agents. + + Checks pane existence, completion sentinels, and output quiescence. + Updates the state file and returns ``{agent_name: status}``. + """ + state = agent_state.load_state(repo_root) + now = time.time() + result: dict[str, str] = {} + + for agent in state.agents.values(): + if not tmux_ops.pane_exists(agent.pane_id): + agent.status = "dead" + result[agent.name] = "dead" + continue + + done_path = Path(agent.worktree_path) / ".claude" / "DONE" + if done_path.exists(): + agent.status = "done" + result[agent.name] = "done" + continue + + output = tmux_ops.capture_pane(agent.pane_id) + if output is not None: + h = tmux_ops.hash_output(output) + if h != agent.last_output_hash: + agent.last_output_hash = h + agent.last_change_at = now + agent.status = "running" + else: + agent.status = "idle" + result[agent.name] = agent.status + + state.last_poll_at = now + agent_state.save_state(repo_root, state) + return result + + +def _pid_file_path(repo_root: Path) -> Path: + """Return the PID file path for the poller daemon.""" + return agent_state._state_dir(repo_root) / "poller.pid" + + +def is_poller_running(repo_root: Path) -> bool: + """Check if the poller daemon is running.""" + pid_file = _pid_file_path(repo_root) + if not pid_file.exists(): + return False + try: + pid = int(pid_file.read_text().strip()) + os.kill(pid, 0) + return True + except (ValueError, ProcessLookupError, PermissionError): + pid_file.unlink(missing_ok=True) + return False + + +def run_poller_daemon(repo_root: Path, interval: float = 5.0) -> None: + """Run the poller loop (called inside the daemon process). + + Handles SIGTERM/SIGINT for graceful shutdown. + Auto-stops when all agents are done or dead. + """ + running = True + + def _handle_signal(_signum: int, _frame: object) -> None: + nonlocal running + running = False + + signal.signal(signal.SIGTERM, _handle_signal) + signal.signal(signal.SIGINT, _handle_signal) + + pid_file = _pid_file_path(repo_root) + pid_file.parent.mkdir(parents=True, exist_ok=True) + pid_file.write_text(str(os.getpid())) + + try: + while running: + statuses = poll_once(repo_root) + # Auto-stop if no agents are running or idle + if statuses and all(s in ("done", "dead") for s in statuses.values()): + break + time.sleep(interval) + finally: + pid_file.unlink(missing_ok=True) + + +def start_poller(repo_root: Path, interval: float = 5.0) -> int | None: + """Start the background poller as a detached subprocess. + + Returns the PID of the poller process, or ``None`` on failure. + """ + if is_poller_running(repo_root): + return None + + cmd = [ + sys.executable, + "-m", + "agent_cli.dev.poller", + str(repo_root), + str(interval), + ] + proc = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + return proc.pid + + +def stop_poller(repo_root: Path) -> bool: + """Stop the background poller by sending SIGTERM.""" + pid_file = _pid_file_path(repo_root) + if not pid_file.exists(): + return False + try: + pid = int(pid_file.read_text().strip()) + os.kill(pid, signal.SIGTERM) + pid_file.unlink(missing_ok=True) + return True + except (ValueError, ProcessLookupError, PermissionError): + pid_file.unlink(missing_ok=True) + return False + + +if __name__ == "__main__": + # Entry point for the daemon subprocess + if len(sys.argv) >= 2: # noqa: PLR2004 + _repo_root = Path(sys.argv[1]) + _interval = float(sys.argv[2]) if len(sys.argv) >= 3 else 5.0 # noqa: PLR2004 + run_poller_daemon(_repo_root, _interval) diff --git a/agent_cli/dev/tmux_ops.py b/agent_cli/dev/tmux_ops.py new file mode 100644 index 00000000..2bf4422a --- /dev/null +++ b/agent_cli/dev/tmux_ops.py @@ -0,0 +1,87 @@ +"""Low-level tmux operations for agent orchestration.""" + +from __future__ import annotations + +import hashlib +import subprocess +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + + +def open_window_with_pane_id( + path: Path, + command: str | None = None, + tab_name: str | None = None, +) -> str | None: + """Create a new tmux window and return the pane ID. + + Uses ``tmux new-window -P -F '#{pane_id}'`` to get the stable pane ID + (e.g. ``%42``). Pane IDs are globally unique within a tmux server and + remain stable when panes are moved or reordered. + + Returns the pane ID string, or ``None`` on failure. + """ + cmd = ["tmux", "new-window", "-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) + pane_id = result.stdout.strip() + return pane_id or None + except subprocess.CalledProcessError: + return None + + +def capture_pane(pane_id: str, lines: int = 200) -> str | None: + """Capture the last *lines* lines from a tmux pane. + + Returns the captured text, or ``None`` if the pane does not exist. + """ + try: + result = subprocess.run( + ["tmux", "capture-pane", "-p", "-t", pane_id, "-S", f"-{lines}"], # noqa: S607 + check=True, + capture_output=True, + text=True, + ) + return result.stdout + except subprocess.CalledProcessError: + return None + + +def send_keys(pane_id: str, text: str, *, enter: bool = True) -> bool: + """Send text to a tmux pane. + + Returns ``True`` on success. + """ + cmd = ["tmux", "send-keys", "-t", pane_id, text] + if enter: + cmd.append("Enter") + try: + subprocess.run(cmd, check=True, capture_output=True) + return True + except subprocess.CalledProcessError: + return False + + +def pane_exists(pane_id: str) -> bool: + """Check if a tmux pane still exists.""" + try: + result = subprocess.run( + ["tmux", "list-panes", "-a", "-F", "#{pane_id}"], # noqa: S607 + check=True, + capture_output=True, + text=True, + ) + return pane_id in result.stdout.splitlines() + except subprocess.CalledProcessError: + return False + + +def hash_output(text: str) -> str: + """Compute a SHA-256 hash of text for quiescence detection.""" + return hashlib.sha256(text.encode()).hexdigest() diff --git a/tests/dev/test_orchestration.py b/tests/dev/test_orchestration.py new file mode 100644 index 00000000..c3751d97 --- /dev/null +++ b/tests/dev/test_orchestration.py @@ -0,0 +1,500 @@ +"""Tests for agent orchestration (tmux_ops, agent_state, poll/output/send/wait).""" + +from __future__ import annotations + +import json +import subprocess +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from agent_cli.cli import app +from agent_cli.dev import agent_state, tmux_ops +from agent_cli.dev.cli import _inject_completion_hook + +runner = CliRunner(env={"NO_COLOR": "1", "TERM": "dumb"}) + +_TMUX_ENV = {"TMUX": "/tmp/tmux-1000/default,12345,0"} # noqa: S108 + + +# --------------------------------------------------------------------------- +# tmux_ops tests +# --------------------------------------------------------------------------- + + +class TestTmuxOps: + """Tests for low-level tmux operations.""" + + def test_open_window_with_pane_id(self) -> None: + """Returns pane ID from tmux new-window -P.""" + mock_result = MagicMock(stdout="%42\n") + with patch("subprocess.run", return_value=mock_result) as mock_run: + pane_id = tmux_ops.open_window_with_pane_id(Path("/tmp/test")) # noqa: S108 + assert pane_id == "%42" + cmd = mock_run.call_args[0][0] + assert "new-window" in cmd + assert "-P" in cmd + assert "#{pane_id}" in cmd + + def test_open_window_with_tab_name_and_command(self) -> None: + """Passes tab name and command to tmux.""" + mock_result = MagicMock(stdout="%5\n") + with patch("subprocess.run", return_value=mock_result) as mock_run: + pane_id = tmux_ops.open_window_with_pane_id( + Path("/tmp/test"), # noqa: S108 + command="echo hello", + tab_name="test-tab", + ) + assert pane_id == "%5" + cmd = mock_run.call_args[0][0] + assert "-n" in cmd + assert "test-tab" in cmd + assert "echo hello" in cmd + + def test_open_window_returns_none_on_failure(self) -> None: + """Returns None when tmux command fails.""" + with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "tmux")): + assert tmux_ops.open_window_with_pane_id(Path("/tmp")) is None # noqa: S108 + + def test_capture_pane(self) -> None: + """Captures pane output from tmux.""" + mock_result = MagicMock(stdout="line1\nline2\n") + with patch("subprocess.run", return_value=mock_result) as mock_run: + output = tmux_ops.capture_pane("%3", lines=100) + assert output == "line1\nline2\n" + cmd = mock_run.call_args[0][0] + assert "-t" in cmd + assert "%3" in cmd + assert "-100" in cmd + + def test_capture_pane_returns_none_on_failure(self) -> None: + """Returns None when pane doesn't exist.""" + with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "tmux")): + assert tmux_ops.capture_pane("%99") is None + + def test_send_keys(self) -> None: + """Sends keys to tmux pane.""" + with patch("subprocess.run") as mock_run: + assert tmux_ops.send_keys("%3", "hello") is True + cmd = mock_run.call_args[0][0] + assert "send-keys" in cmd + assert "%3" in cmd + assert "hello" in cmd + assert "Enter" in cmd + + def test_send_keys_no_enter(self) -> None: + """Sends keys without pressing Enter.""" + with patch("subprocess.run") as mock_run: + tmux_ops.send_keys("%3", "hello", enter=False) + cmd = mock_run.call_args[0][0] + assert "Enter" not in cmd + + def test_send_keys_returns_false_on_failure(self) -> None: + """Returns False when tmux command fails.""" + with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "tmux")): + assert tmux_ops.send_keys("%3", "hello") is False + + def test_pane_exists(self) -> None: + """Checks pane existence via list-panes.""" + mock_result = MagicMock(stdout="%1\n%3\n%5\n") + with patch("subprocess.run", return_value=mock_result): + assert tmux_ops.pane_exists("%3") is True + assert tmux_ops.pane_exists("%99") is False + + def test_pane_exists_returns_false_on_failure(self) -> None: + """Returns False when tmux is not available.""" + with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "tmux")): + assert tmux_ops.pane_exists("%3") is False + + def test_hash_output(self) -> None: + """Produces consistent SHA-256 hashes.""" + h1 = tmux_ops.hash_output("hello") + h2 = tmux_ops.hash_output("hello") + h3 = tmux_ops.hash_output("world") + assert h1 == h2 + assert h1 != h3 + assert len(h1) == 64 # SHA-256 hex digest + + +# --------------------------------------------------------------------------- +# agent_state tests +# --------------------------------------------------------------------------- + + +class TestAgentState: + """Tests for agent state management.""" + + def test_repo_slug(self) -> None: + """Generates filesystem-safe slug from path.""" + slug = agent_state._repo_slug(Path("/home/user/Work/my-project")) + assert "my-project" in slug + assert "/" not in slug + + def test_load_empty_state(self, tmp_path: Path) -> None: + """Returns empty state when no file exists.""" + with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): + state = agent_state.load_state(tmp_path / "repo") + assert state.agents == {} + assert state.last_poll_at == 0.0 + + def test_save_and_load_state(self, tmp_path: Path) -> None: + """Round-trips state through JSON.""" + with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): + repo = tmp_path / "repo" + agent = agent_state.register_agent( + repo, + "test-agent", + "%42", + tmp_path / "worktree", + "claude", + ) + assert agent.name == "test-agent" + assert agent.pane_id == "%42" + + state = agent_state.load_state(repo) + assert "test-agent" in state.agents + assert state.agents["test-agent"].pane_id == "%42" + assert state.agents["test-agent"].agent_type == "claude" + + def test_unregister_agent(self, tmp_path: Path) -> None: + """Removes agent from state.""" + with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): + repo = tmp_path / "repo" + agent_state.register_agent(repo, "a1", "%1", tmp_path / "wt", "claude") + assert agent_state.unregister_agent(repo, "a1") is True + assert agent_state.unregister_agent(repo, "a1") is False + + state = agent_state.load_state(repo) + assert "a1" not in state.agents + + def test_generate_agent_name_first(self, tmp_path: Path) -> None: + """First agent in worktree uses branch name.""" + with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): + repo = tmp_path / "repo" + name = agent_state.generate_agent_name(repo, tmp_path / "auth", "claude") + assert name == "auth" + + def test_generate_agent_name_second(self, tmp_path: Path) -> None: + """Second agent appends agent type.""" + with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): + repo = tmp_path / "repo" + agent_state.register_agent(repo, "auth", "%1", tmp_path / "auth", "claude") + name = agent_state.generate_agent_name(repo, tmp_path / "auth", "claude") + assert name == "auth-claude" + + def test_generate_agent_name_collision(self, tmp_path: Path) -> None: + """Numeric suffix on further collisions.""" + with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): + repo = tmp_path / "repo" + agent_state.register_agent(repo, "auth", "%1", tmp_path / "auth", "claude") + agent_state.register_agent(repo, "auth-claude", "%2", tmp_path / "auth", "claude") + name = agent_state.generate_agent_name(repo, tmp_path / "auth", "claude") + assert name == "auth-claude-2" + + def test_generate_agent_name_explicit(self, tmp_path: Path) -> None: + """Explicit name is used directly.""" + with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): + repo = tmp_path / "repo" + name = agent_state.generate_agent_name( + repo, + tmp_path / "auth", + "claude", + explicit_name="reviewer", + ) + assert name == "reviewer" + + def test_generate_agent_name_explicit_collision(self, tmp_path: Path) -> None: + """Explicit name raises ValueError on collision.""" + with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): + repo = tmp_path / "repo" + agent_state.register_agent(repo, "reviewer", "%1", tmp_path / "auth", "claude") + with pytest.raises(ValueError, match="already exists"): + agent_state.generate_agent_name( + repo, + tmp_path / "auth", + "claude", + explicit_name="reviewer", + ) + + def test_load_corrupt_state(self, tmp_path: Path) -> None: + """Returns empty state on corrupt JSON.""" + with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): + path = agent_state._state_file_path(tmp_path / "repo") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("not valid json{{{") + state = agent_state.load_state(tmp_path / "repo") + assert state.agents == {} + + +# --------------------------------------------------------------------------- +# CLI command tests +# --------------------------------------------------------------------------- + + +class TestPollCommand: + """Tests for dev poll command.""" + + def test_poll_no_agents(self) -> None: + """Shows message when no agents are tracked.""" + with ( + patch.dict("os.environ", _TMUX_ENV), + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch( + "agent_cli.dev.agent_state.load_state", + return_value=agent_state.AgentStateFile(repo_root="/repo"), + ), + ): + result = runner.invoke(app, ["dev", "poll"]) + assert result.exit_code == 0 + assert "No tracked agents" in result.output + + def test_poll_json_output(self) -> None: + """Returns JSON with agent status.""" + state = agent_state.AgentStateFile(repo_root="/repo") + state.agents["test"] = agent_state.TrackedAgent( + name="test", + pane_id="%3", + worktree_path="/tmp/wt", # noqa: S108 + agent_type="claude", + started_at=time.time() - 60, + ) + + with ( + patch.dict("os.environ", _TMUX_ENV), + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.agent_state.load_state", return_value=state), + patch("agent_cli.dev.agent_state.save_state"), + patch("agent_cli.dev.tmux_ops.pane_exists", return_value=True), + patch("agent_cli.dev.tmux_ops.capture_pane", return_value="output"), + patch("agent_cli.dev.tmux_ops.hash_output", return_value="abc123"), + ): + result = runner.invoke(app, ["dev", "poll", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data["agents"]) == 1 + assert data["agents"][0]["name"] == "test" + + def test_poll_detects_dead_agent(self) -> None: + """Marks agent as dead when pane is gone.""" + state = agent_state.AgentStateFile(repo_root="/repo") + state.agents["test"] = agent_state.TrackedAgent( + name="test", + pane_id="%3", + worktree_path="/tmp/wt", # noqa: S108 + agent_type="claude", + started_at=time.time(), + ) + + with ( + patch.dict("os.environ", _TMUX_ENV), + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.agent_state.load_state", return_value=state), + patch("agent_cli.dev.agent_state.save_state"), + patch("agent_cli.dev.tmux_ops.pane_exists", return_value=False), + ): + result = runner.invoke(app, ["dev", "poll", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["agents"][0]["status"] == "dead" + + +class TestOutputCommand: + """Tests for dev output command.""" + + def test_output_captures_pane(self) -> None: + """Captures and prints pane output.""" + state = agent_state.AgentStateFile(repo_root="/repo") + state.agents["test"] = agent_state.TrackedAgent( + name="test", + pane_id="%3", + worktree_path="/tmp/wt", # noqa: S108 + agent_type="claude", + started_at=time.time(), + status="running", + ) + + with ( + patch.dict("os.environ", _TMUX_ENV), + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.agent_state.load_state", return_value=state), + patch("agent_cli.dev.tmux_ops.capture_pane", return_value="hello world\n"), + ): + result = runner.invoke(app, ["dev", "output", "test"]) + assert result.exit_code == 0 + assert "hello world" in result.output + + def test_output_agent_not_found(self) -> None: + """Errors when agent name doesn't exist.""" + with ( + patch.dict("os.environ", _TMUX_ENV), + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch( + "agent_cli.dev.agent_state.load_state", + return_value=agent_state.AgentStateFile(repo_root="/repo"), + ), + ): + result = runner.invoke(app, ["dev", "output", "nonexistent"]) + assert result.exit_code == 1 + assert "not found" in result.output + + +class TestSendCommand: + """Tests for dev send command.""" + + def test_send_keys_to_agent(self) -> None: + """Sends keys to agent's tmux pane.""" + state = agent_state.AgentStateFile(repo_root="/repo") + state.agents["test"] = agent_state.TrackedAgent( + name="test", + pane_id="%3", + worktree_path="/tmp/wt", # noqa: S108 + agent_type="claude", + started_at=time.time(), + status="running", + ) + + with ( + patch.dict("os.environ", _TMUX_ENV), + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.agent_state.load_state", return_value=state), + patch("agent_cli.dev.tmux_ops.send_keys", return_value=True) as mock_send, + ): + result = runner.invoke(app, ["dev", "send", "test", "fix the tests"]) + assert result.exit_code == 0 + mock_send.assert_called_once_with("%3", "fix the tests", enter=True) + + def test_send_to_dead_agent(self) -> None: + """Errors when sending to dead agent.""" + state = agent_state.AgentStateFile(repo_root="/repo") + state.agents["test"] = agent_state.TrackedAgent( + name="test", + pane_id="%3", + worktree_path="/tmp/wt", # noqa: S108 + agent_type="claude", + started_at=time.time(), + status="dead", + ) + + with ( + patch.dict("os.environ", _TMUX_ENV), + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.agent_state.load_state", return_value=state), + ): + result = runner.invoke(app, ["dev", "send", "test", "hello"]) + assert result.exit_code == 1 + assert "dead" in result.output + + +class TestWaitCommand: + """Tests for dev wait command.""" + + def test_wait_already_done(self) -> None: + """Returns immediately if agent is already done.""" + state = agent_state.AgentStateFile(repo_root="/repo") + state.agents["test"] = agent_state.TrackedAgent( + name="test", + pane_id="%3", + worktree_path="/tmp/wt", # noqa: S108 + agent_type="claude", + started_at=time.time(), + status="done", + ) + + with ( + patch.dict("os.environ", _TMUX_ENV), + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.agent_state.load_state", return_value=state), + ): + result = runner.invoke(app, ["dev", "wait", "test"]) + assert result.exit_code == 0 + assert "already" in result.output + + def test_wait_already_dead(self) -> None: + """Returns exit code 1 if agent is dead.""" + state = agent_state.AgentStateFile(repo_root="/repo") + state.agents["test"] = agent_state.TrackedAgent( + name="test", + pane_id="%3", + worktree_path="/tmp/wt", # noqa: S108 + agent_type="claude", + started_at=time.time(), + status="dead", + ) + + with ( + patch.dict("os.environ", _TMUX_ENV), + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.agent_state.load_state", return_value=state), + ): + result = runner.invoke(app, ["dev", "wait", "test"]) + assert result.exit_code == 1 + + +class TestNotInTmux: + """Tests for tmux requirement enforcement.""" + + def test_poll_requires_tmux(self) -> None: + """Poll command fails outside tmux.""" + with ( + patch.dict("os.environ", {}, clear=True), + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + ): + result = runner.invoke(app, ["dev", "poll"]) + assert result.exit_code == 1 + assert "tmux" in result.output.lower() + + def test_send_requires_tmux(self) -> None: + """Send command fails outside tmux.""" + with ( + patch.dict("os.environ", {}, clear=True), + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + ): + result = runner.invoke(app, ["dev", "send", "test", "hello"]) + assert result.exit_code == 1 + + +class TestInjectCompletionHook: + """Tests for Claude Code hook injection.""" + + def test_injects_stop_hook(self, tmp_path: Path) -> None: + """Creates .claude/settings.json with Stop hook.""" + _inject_completion_hook(tmp_path, "claude") + + settings_path = tmp_path / ".claude" / "settings.json" + assert settings_path.exists() + settings = json.loads(settings_path.read_text()) + assert "hooks" in settings + assert "Stop" in settings["hooks"] + hooks = settings["hooks"]["Stop"] + assert any("touch .claude/DONE" in h.get("hooks", []) for h in hooks) + + def test_merges_with_existing_settings(self, tmp_path: Path) -> None: + """Preserves existing settings when injecting hook.""" + settings_path = tmp_path / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True) + settings_path.write_text(json.dumps({"model": "opus", "hooks": {"PreToolUse": []}})) + + _inject_completion_hook(tmp_path, "claude") + + settings = json.loads(settings_path.read_text()) + assert settings["model"] == "opus" + assert "PreToolUse" in settings["hooks"] + assert "Stop" in settings["hooks"] + + def test_skips_non_claude_agents(self, tmp_path: Path) -> None: + """Does nothing for non-Claude agents.""" + _inject_completion_hook(tmp_path, "aider") + assert not (tmp_path / ".claude" / "settings.json").exists() + + def test_idempotent(self, tmp_path: Path) -> None: + """Doesn't duplicate hook on repeated calls.""" + _inject_completion_hook(tmp_path, "claude") + _inject_completion_hook(tmp_path, "claude") + + settings = json.loads((tmp_path / ".claude" / "settings.json").read_text()) + stop_hooks = settings["hooks"]["Stop"] + sentinel_count = sum(1 for h in stop_hooks if "touch .claude/DONE" in h.get("hooks", [])) + assert sentinel_count == 1 From c852b0c18fb20281be1f3f070a4f14a4aca557cf Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 9 Feb 2026 18:05:59 -0800 Subject: [PATCH 02/20] refactor(dev): extract orchestration logic from cli.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move business logic out of cli.py into appropriate modules: - inject_completion_hook → agent_state.py (config/state management) - is_tmux() → agent_state.py (environment check) - wait_for_agent() → poller.py (blocking wait with timeout) - _update_agent_status() → poller.py (shared by poll_once and wait) poll_cmd now calls poller.poll_once() instead of duplicating the polling logic inline. wait_cmd delegates to poller.wait_for_agent() which raises TimeoutError/KeyError instead of mixing business logic with CLI exit codes. --- agent_cli/dev/agent_state.py | 38 +++++++++ agent_cli/dev/cli.py | 142 ++++++-------------------------- agent_cli/dev/poller.py | 62 ++++++++++++++ tests/dev/test_orchestration.py | 12 +-- 4 files changed, 131 insertions(+), 123 deletions(-) diff --git a/agent_cli/dev/agent_state.py b/agent_cli/dev/agent_state.py index ff90168c..8f6a1ebb 100644 --- a/agent_cli/dev/agent_state.py +++ b/agent_cli/dev/agent_state.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os import re import time from dataclasses import asdict, dataclass, field @@ -167,3 +168,40 @@ def generate_agent_name( while f"{candidate}-{n}" in existing: n += 1 return f"{candidate}-{n}" + + +def inject_completion_hook(worktree_path: Path, agent_type: str) -> None: + """Inject a Stop hook into .claude/settings.json for completion detection. + + Only applies to Claude Code agents. Merges with existing settings. + """ + if agent_type != "claude": + return + + settings_path = worktree_path / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + + settings: dict = {} + if settings_path.exists(): + try: + settings = json.loads(settings_path.read_text()) + except json.JSONDecodeError: + settings = {} + + # Merge Stop hook + hooks = settings.setdefault("hooks", {}) + stop_hooks = hooks.setdefault("Stop", []) + + # Check if our hook is already present + sentinel_cmd = "touch .claude/DONE" + for entry in stop_hooks: + if sentinel_cmd in entry.get("hooks", []): + return # Already injected + + stop_hooks.append({"matcher": "", "hooks": [sentinel_cmd]}) + settings_path.write_text(json.dumps(settings, indent=2) + "\n") + + +def is_tmux() -> bool: + """Check if we're running inside tmux.""" + return bool(os.environ.get("TMUX")) diff --git a/agent_cli/dev/cli.py b/agent_cli/dev/cli.py index 9b8592ce..c62cc283 100644 --- a/agent_cli/dev/cli.py +++ b/agent_cli/dev/cli.py @@ -417,7 +417,7 @@ def _launch_agent( root = repo_root or path name = agent_state.generate_agent_name(root, path, agent.name, agent_name) agent_state.register_agent(root, name, pane_id, path, agent.name) - _inject_completion_hook(path, agent.name) + agent_state.inject_completion_hook(path, agent.name) _success(f"Started {agent.name} in new tmux tab (tracking as [cyan]{name}[/cyan])") return name _warn("Could not open new tmux window") @@ -438,38 +438,6 @@ def _launch_agent( return None -def _inject_completion_hook(worktree_path: Path, agent_type: str) -> None: - """Inject a Stop hook into .claude/settings.json for completion detection. - - Only applies to Claude Code agents. Merges with existing settings. - """ - if agent_type != "claude": - return - - settings_path = worktree_path / ".claude" / "settings.json" - settings_path.parent.mkdir(parents=True, exist_ok=True) - - settings: dict = {} - if settings_path.exists(): - try: - settings = json.loads(settings_path.read_text()) - except json.JSONDecodeError: - settings = {} - - # Merge Stop hook - hooks = settings.setdefault("hooks", {}) - stop_hooks = hooks.setdefault("Stop", []) - - # Check if our hook is already present - sentinel_cmd = "touch .claude/DONE" - for entry in stop_hooks: - if sentinel_cmd in entry.get("hooks", []): - return # Already injected - - stop_hooks.append({"matcher": "", "hooks": [sentinel_cmd]}) - settings_path.write_text(json.dumps(settings, indent=2) + "\n") - - @app.command("new") def new( # noqa: C901, PLR0912, PLR0915 branch: Annotated[ @@ -1899,7 +1867,9 @@ def install_skill( def _ensure_tmux() -> None: """Exit with an error if not running inside tmux.""" - if not os.environ.get("TMUX"): + from . import agent_state # noqa: PLC0415 + + if not agent_state.is_tmux(): _error("Agent tracking requires tmux. Start a tmux session first.") @@ -1965,7 +1935,8 @@ def poll_cmd( """ import time # noqa: PLC0415 - from . import agent_state, tmux_ops # noqa: PLC0415 + from . import agent_state # noqa: PLC0415 + from .poller import poll_once # noqa: PLC0415 _ensure_tmux() repo_root = _ensure_git_repo() @@ -1975,34 +1946,11 @@ def poll_cmd( _info("No tracked agents. Launch one with 'dev new -a' or 'dev agent --tab'.") return - now = time.time() + poll_once(repo_root) - # Poll each agent - for agent in state.agents.values(): - # Check if pane still exists - if not tmux_ops.pane_exists(agent.pane_id): - agent.status = "dead" - continue - - # Check for completion sentinel (Claude Code hook) - done_path = Path(agent.worktree_path) / ".claude" / "DONE" - if done_path.exists(): - agent.status = "done" - continue - - # Quiescence detection: compare output hash - output = tmux_ops.capture_pane(agent.pane_id) - if output is not None: - h = tmux_ops.hash_output(output) - if h != agent.last_output_hash: - agent.last_output_hash = h - agent.last_change_at = now - agent.status = "running" - else: - agent.status = "idle" - - state.last_poll_at = now - agent_state.save_state(repo_root, state) + # Reload state after polling + state = agent_state.load_state(repo_root) + now = time.time() if json_output: data = { @@ -2107,7 +2055,6 @@ def output_cmd( _warn("Pane closed.") break if output != prev: - # Clear and reprint (simple approach) print(output, end="", flush=True) prev = output _time.sleep(1.0) @@ -2186,66 +2133,27 @@ def wait_cmd( - `dev wait my-feature --timeout 300` — Wait up to 5 minutes - `dev wait my-feature -i 2` — Poll every 2 seconds """ - import time as _time # noqa: PLC0415 - - from . import agent_state, tmux_ops # noqa: PLC0415 + from .poller import wait_for_agent # noqa: PLC0415 _ensure_tmux() - repo_root, agent = _lookup_agent(name) + _repo_root, agent = _lookup_agent(name) if agent.status in ("done", "dead", "idle"): console.print(f"Agent '{name}' is already {_status_style(agent.status)}") raise typer.Exit(0 if agent.status != "dead" else 1) + repo_root = _ensure_git_repo() _info(f"Waiting for agent '{name}' to finish (polling every {interval}s)...") - start = _time.time() - consecutive_idle = 0 - - while True: - elapsed = _time.time() - start - if timeout > 0 and elapsed >= timeout: - _warn(f"Timeout after {_format_duration(elapsed)}") - raise typer.Exit(2) - - # Check pane existence - if not tmux_ops.pane_exists(agent.pane_id): - agent.status = "dead" - state = agent_state.load_state(repo_root) - if name in state.agents: - state.agents[name].status = "dead" - agent_state.save_state(repo_root, state) - _warn(f"Agent '{name}' died (pane closed) after {_format_duration(elapsed)}") - raise typer.Exit(1) - - # Check completion sentinel - done_path = Path(agent.worktree_path) / ".claude" / "DONE" - if done_path.exists(): - state = agent_state.load_state(repo_root) - if name in state.agents: - state.agents[name].status = "done" - agent_state.save_state(repo_root, state) - _success(f"Agent '{name}' completed after {_format_duration(elapsed)}") - raise typer.Exit(0) - - # Quiescence detection - output = tmux_ops.capture_pane(agent.pane_id) - if output is not None: - h = tmux_ops.hash_output(output) - if h == agent.last_output_hash: - consecutive_idle += 1 - else: - consecutive_idle = 0 - agent.last_output_hash = h - agent.last_change_at = _time.time() - - # Require 2 consecutive idle polls to confirm - if consecutive_idle >= 2: # noqa: PLR2004 - state = agent_state.load_state(repo_root) - if name in state.agents: - state.agents[name].status = "idle" - agent_state.save_state(repo_root, state) - _success(f"Agent '{name}' is idle after {_format_duration(elapsed)}") - raise typer.Exit(0) - - _time.sleep(interval) + try: + status, elapsed = wait_for_agent(repo_root, name, timeout=timeout, interval=interval) + except TimeoutError: + _warn(f"Timeout after {_format_duration(timeout)}") + raise typer.Exit(2) from None + + if status == "dead": + _warn(f"Agent '{name}' died (pane closed) after {_format_duration(elapsed)}") + raise typer.Exit(1) + + _success(f"Agent '{name}' is {status} after {_format_duration(elapsed)}") + raise typer.Exit(0) diff --git a/agent_cli/dev/poller.py b/agent_cli/dev/poller.py index 3b6b4826..97181838 100644 --- a/agent_cli/dev/poller.py +++ b/agent_cli/dev/poller.py @@ -123,6 +123,68 @@ def start_poller(repo_root: Path, interval: float = 5.0) -> int | None: return proc.pid +def wait_for_agent( + repo_root: Path, + name: str, + timeout: float = 0, + interval: float = 5.0, +) -> tuple[str, float]: + """Block until a tracked agent finishes. + + Returns ``(final_status, elapsed_seconds)``. + Raises ``TimeoutError`` if *timeout* > 0 and is exceeded. + Raises ``KeyError`` if the agent is not found. + """ + state = agent_state.load_state(repo_root) + agent = state.agents.get(name) + if agent is None: + msg = f"Agent '{name}' not found" + raise KeyError(msg) + + start = time.time() + consecutive_idle = 0 + + while True: + elapsed = time.time() - start + if timeout > 0 and elapsed >= timeout: + msg = f"Timeout after {elapsed:.0f}s" + raise TimeoutError(msg) + + if not tmux_ops.pane_exists(agent.pane_id): + _update_agent_status(repo_root, name, "dead") + return "dead", elapsed + + done_path = Path(agent.worktree_path) / ".claude" / "DONE" + if done_path.exists(): + _update_agent_status(repo_root, name, "done") + return "done", elapsed + + output = tmux_ops.capture_pane(agent.pane_id) + if output is not None: + h = tmux_ops.hash_output(output) + if h == agent.last_output_hash: + consecutive_idle += 1 + else: + consecutive_idle = 0 + agent.last_output_hash = h + agent.last_change_at = time.time() + + # Require 2 consecutive idle polls to confirm + if consecutive_idle >= 2: # noqa: PLR2004 + _update_agent_status(repo_root, name, "idle") + return "idle", elapsed + + time.sleep(interval) + + +def _update_agent_status(repo_root: Path, name: str, status: agent_state.AgentStatus) -> None: + """Update a single agent's status in the state file.""" + state = agent_state.load_state(repo_root) + if name in state.agents: + state.agents[name].status = status + agent_state.save_state(repo_root, state) + + def stop_poller(repo_root: Path) -> bool: """Stop the background poller by sending SIGTERM.""" pid_file = _pid_file_path(repo_root) diff --git a/tests/dev/test_orchestration.py b/tests/dev/test_orchestration.py index c3751d97..95c29340 100644 --- a/tests/dev/test_orchestration.py +++ b/tests/dev/test_orchestration.py @@ -13,7 +13,7 @@ from agent_cli.cli import app from agent_cli.dev import agent_state, tmux_ops -from agent_cli.dev.cli import _inject_completion_hook +from agent_cli.dev.agent_state import inject_completion_hook runner = CliRunner(env={"NO_COLOR": "1", "TERM": "dumb"}) @@ -461,7 +461,7 @@ class TestInjectCompletionHook: def test_injects_stop_hook(self, tmp_path: Path) -> None: """Creates .claude/settings.json with Stop hook.""" - _inject_completion_hook(tmp_path, "claude") + inject_completion_hook(tmp_path, "claude") settings_path = tmp_path / ".claude" / "settings.json" assert settings_path.exists() @@ -477,7 +477,7 @@ def test_merges_with_existing_settings(self, tmp_path: Path) -> None: settings_path.parent.mkdir(parents=True) settings_path.write_text(json.dumps({"model": "opus", "hooks": {"PreToolUse": []}})) - _inject_completion_hook(tmp_path, "claude") + inject_completion_hook(tmp_path, "claude") settings = json.loads(settings_path.read_text()) assert settings["model"] == "opus" @@ -486,13 +486,13 @@ def test_merges_with_existing_settings(self, tmp_path: Path) -> None: def test_skips_non_claude_agents(self, tmp_path: Path) -> None: """Does nothing for non-Claude agents.""" - _inject_completion_hook(tmp_path, "aider") + inject_completion_hook(tmp_path, "aider") assert not (tmp_path / ".claude" / "settings.json").exists() def test_idempotent(self, tmp_path: Path) -> None: """Doesn't duplicate hook on repeated calls.""" - _inject_completion_hook(tmp_path, "claude") - _inject_completion_hook(tmp_path, "claude") + inject_completion_hook(tmp_path, "claude") + inject_completion_hook(tmp_path, "claude") settings = json.loads((tmp_path / ".claude" / "settings.json").read_text()) stop_hooks = settings["hooks"]["Stop"] From 639d165b511b026ac489b4a168db4c6dc58a4fe1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Feb 2026 02:09:25 +0000 Subject: [PATCH 03/20] Update auto-generated docs --- docs/commands/dev.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/commands/dev.md b/docs/commands/dev.md index f4fecc92..d2a13d88 100644 --- a/docs/commands/dev.md +++ b/docs/commands/dev.md @@ -285,6 +285,8 @@ 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 | +| `--tab` | `false` | Launch in a new tmux tab (tracked) instead of the current terminal | +| `--name` | - | Explicit name for tracking (used with --tab). Auto-generated if omitted | From 836c907544a21a33b633442311f7f4589befb09e Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 9 Feb 2026 19:02:44 -0800 Subject: [PATCH 04/20] refactor(dev): extract cleanup and launch logic from cli.py Move agent/editor resolution, launching, and config helpers into launch.py. Move worktree cleanup business logic into cleanup.py. This reduces cli.py by ~400 lines, keeping it focused on CLI presentation (option parsing, table/JSON formatting) while business logic lives in dedicated modules. --- agent_cli/dev/cleanup.py | 112 ++++++++++ agent_cli/dev/cli.py | 435 ++++----------------------------------- agent_cli/dev/launch.py | 331 +++++++++++++++++++++++++++++ tests/dev/test_cli.py | 49 +++-- 4 files changed, 508 insertions(+), 419 deletions(-) create mode 100644 agent_cli/dev/cleanup.py create mode 100644 agent_cli/dev/launch.py diff --git a/agent_cli/dev/cleanup.py b/agent_cli/dev/cleanup.py new file mode 100644 index 00000000..0c0dbc57 --- /dev/null +++ b/agent_cli/dev/cleanup.py @@ -0,0 +1,112 @@ +"""Worktree cleanup operations.""" + +from __future__ import annotations + +import json +import subprocess +from typing import TYPE_CHECKING + +from . import worktree + +if TYPE_CHECKING: + from pathlib import Path + + +def find_worktrees_with_no_commits(repo_root: Path) -> list[worktree.WorktreeInfo]: + """Find worktrees whose branches have no commits ahead of the default branch.""" + worktrees_list = worktree.list_worktrees() + default_branch = worktree.get_default_branch(repo_root) + to_remove: list[worktree.WorktreeInfo] = [] + + for wt in worktrees_list: + if wt.is_main or not wt.branch: + continue + + # Check if branch has any commits ahead of default branch + result = subprocess.run( + ["git", "rev-list", f"{default_branch}..{wt.branch}", "--count"], # noqa: S607 + capture_output=True, + text=True, + cwd=repo_root, + check=False, + ) + if result.returncode == 0 and result.stdout.strip() == "0": + to_remove.append(wt) + + return to_remove + + +def find_worktrees_with_merged_prs( + repo_root: Path, +) -> list[tuple[worktree.WorktreeInfo, str]]: + """Find worktrees whose PRs have been merged on GitHub. + + Returns a list of tuples containing (worktree_info, pr_url). + """ + worktrees_list = worktree.list_worktrees() + to_remove: list[tuple[worktree.WorktreeInfo, str]] = [] + + for wt in worktrees_list: + if wt.is_main or not wt.branch: + continue + + # Check if PR for this branch is merged + result = subprocess.run( + ["gh", "pr", "list", "--head", wt.branch, "--state", "merged", "--json", "number,url"], # noqa: S607 + capture_output=True, + text=True, + cwd=repo_root, + check=False, + ) + if result.returncode == 0 and result.stdout.strip() not in ("", "[]"): + prs = json.loads(result.stdout) + pr_url = prs[0]["url"] if prs else "" + to_remove.append((wt, pr_url)) + + return to_remove + + +def check_gh_available() -> tuple[bool, str]: + """Check if GitHub CLI is available and authenticated. + + Returns (ok, error_message). + """ + gh_version = subprocess.run( + ["gh", "--version"], # noqa: S607 + capture_output=True, + check=False, + ) + if gh_version.returncode != 0: + return False, "GitHub CLI (gh) not found. Install from: https://cli.github.com/" + + gh_auth = subprocess.run( + ["gh", "auth", "status"], # noqa: S607 + capture_output=True, + check=False, + ) + if gh_auth.returncode != 0: + return False, "Not authenticated with GitHub. Run: gh auth login" + + return True, "" + + +def remove_worktrees( + worktrees_to_remove: list[worktree.WorktreeInfo], + repo_root: Path, + *, + force: bool = False, +) -> list[tuple[str, bool, str | None]]: + """Remove a list of worktrees. + + Returns list of (branch_name, success, error_message) tuples. + """ + results: list[tuple[str, bool, str | None]] = [] + for wt in worktrees_to_remove: + success, error = worktree.remove_worktree( + wt.path, + force=force, + delete_branch=True, + repo_path=repo_root, + ) + results.append((wt.branch or wt.path.name, success, error)) + return results diff --git a/agent_cli/dev/cli.py b/agent_cli/dev/cli.py index c62cc283..4f39f8b8 100644 --- a/agent_cli/dev/cli.py +++ b/agent_cli/dev/cli.py @@ -4,10 +4,8 @@ import json import os -import shlex import shutil import subprocess -import tempfile from pathlib import Path from typing import TYPE_CHECKING, Annotated, Literal, NoReturn @@ -17,7 +15,6 @@ from agent_cli.cli import app as main_app from agent_cli.cli import set_config_defaults -from agent_cli.config import load_config from agent_cli.core.process import set_process_title from agent_cli.core.utils import console, err_console @@ -25,6 +22,27 @@ from ._branch_name import AGENTS as _BRANCH_NAME_AGENTS from ._branch_name import generate_ai_branch_name as _generate_ai_branch_name from ._branch_name import generate_random_branch_name as _generate_branch_name +from .launch import ( + get_agent_env as _get_agent_env, +) +from .launch import ( + launch_agent as _launch_agent, +) +from .launch import ( + launch_editor as _launch_editor, +) +from .launch import ( + merge_agent_args as _merge_agent_args, +) +from .launch import ( + resolve_agent as _resolve_agent, +) +from .launch import ( + resolve_editor as _resolve_editor, +) +from .launch import ( + write_prompt_to_worktree as _write_prompt_to_worktree, +) from .project import ( copy_env_files, detect_project_type, @@ -35,8 +53,6 @@ if TYPE_CHECKING: from . import agent_state - from .coding_agents.base import CodingAgent - from .editors.base import Editor app = typer.Typer( name="dev", @@ -137,307 +153,6 @@ def _ensure_git_repo() -> Path: return repo_root -def _resolve_editor( - use_editor: bool, - editor_name: str | None, - default_editor: str | None = None, -) -> Editor | None: - """Resolve which editor to use based on flags and config defaults.""" - # Use explicit name if provided - if editor_name: - editor = editors.get_editor(editor_name) - if editor is None: - _warn(f"Editor '{editor_name}' not found") - return editor - - # If no flag and no default, don't use an editor - if not use_editor and not default_editor: - return None - - # If default is set in config, use it - if default_editor: - editor = editors.get_editor(default_editor) - if editor is not None: - return editor - _warn(f"Default editor '{default_editor}' from config not found") - - # Auto-detect current or first available - editor = editors.detect_current_editor() - if editor is None: - available = editors.get_available_editors() - return available[0] if available else None - return editor - - -def _resolve_agent( - use_agent: bool, - agent_name: str | None, - default_agent: str | None = None, -) -> CodingAgent | None: - """Resolve which coding agent to use based on flags and config defaults.""" - # Use explicit name if provided - if agent_name: - agent = coding_agents.get_agent(agent_name) - if agent is None: - _warn(f"Agent '{agent_name}' not found") - return agent - - # If no flag and no default, don't use an agent - if not use_agent and not default_agent: - return None - - # If default is set in config, use it - if default_agent: - agent = coding_agents.get_agent(default_agent) - if agent is not None: - return agent - _warn(f"Default agent '{default_agent}' from config not found") - - # Auto-detect current or first available - agent = coding_agents.detect_current_agent() - if agent is None: - available = coding_agents.get_available_agents() - return available[0] if available else None - return agent - - -def _get_config_agent_args() -> dict[str, list[str]] | None: - """Load agent_args from config file. - - Config format: - [dev.agent_args] - claude = ["--dangerously-skip-permissions"] - - Note: The config loader may flatten section names, so we check both - nested structure and flattened 'dev.agent_args' key. - """ - config = load_config(None) - - # First try the simple nested structure (for testing/mocks) - dev_config = config.get("dev", {}) - if isinstance(dev_config, dict) and "agent_args" in dev_config: - return dev_config["agent_args"] - - # Handle flattened key "dev.agent_args" - return config.get("dev.agent_args") - - -def _get_config_agent_env() -> dict[str, dict[str, str]] | None: - """Load agent_env from config file. - - Config format: - [dev.agent_env] - claude = { CLAUDE_CODE_USE_VERTEX = "1", ANTHROPIC_MODEL = "opus" } - - Note: The config loader flattens nested dicts, so keys like - 'dev.agent_env.claude' become top-level. We reconstruct the - agent_env dict from these flattened keys. - """ - config = load_config(None) - - # First try the simple nested structure (for testing/mocks) - dev_config = config.get("dev", {}) - if isinstance(dev_config, dict) and "agent_env" in dev_config: - return dev_config["agent_env"] - - # Handle flattened keys like "dev.agent_env.claude" - prefix = "dev.agent_env." - result: dict[str, dict[str, str]] = {} - for key, value in config.items(): - if key.startswith(prefix) and isinstance(value, dict): - agent_name = key[len(prefix) :] - result[agent_name] = value - - return result or None - - -def _get_agent_env(agent: CodingAgent) -> dict[str, str]: - """Get environment variables for an agent. - - Merges config env vars with agent's built-in env vars. - Config env vars take precedence. - """ - # Start with agent's built-in env vars - env = agent.get_env().copy() - - # Add config env vars (these override built-in ones) - config_env = _get_config_agent_env() - if config_env and agent.name in config_env: - env.update(config_env[agent.name]) - - return env - - -def _merge_agent_args( - agent: CodingAgent, - cli_args: list[str] | None, -) -> list[str] | None: - """Merge CLI args with config args for an agent. - - Config args are applied first, CLI args are appended (and can override). - """ - config_args = _get_config_agent_args() - result: list[str] = [] - - # Add config args for this agent - if config_args and agent.name in config_args: - result.extend(config_args[agent.name]) - - # Add CLI args (these override/extend config args) - if cli_args: - result.extend(cli_args) - - return result or None - - -def _is_ssh_session() -> bool: - """Check if we're in an SSH session.""" - return bool(os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_CLIENT")) - - -def _launch_editor(path: Path, editor: Editor) -> None: - """Launch editor via subprocess (editors are GUI apps that detach).""" - try: - subprocess.Popen(editor.open_command(path)) - _success(f"Opened {editor.name}") - except Exception as e: - _warn(f"Could not open editor: {e}") - - -def _write_prompt_to_worktree(worktree_path: Path, prompt: str) -> Path: - """Write the prompt to .claude/TASK.md in the worktree. - - This makes the task description available to the spawned agent - and provides a record of what was requested. - """ - claude_dir = worktree_path / ".claude" - claude_dir.mkdir(parents=True, exist_ok=True) - task_file = claude_dir / "TASK.md" - task_file.write_text(prompt + "\n") - return task_file - - -def _format_env_prefix(env: dict[str, str]) -> str: - """Format environment variables as shell prefix. - - Returns a string like 'VAR1=value1 VAR2=value2 ' that can be - prepended to a command. - """ - if not env: - return "" - # Quote values that contain spaces or special characters - parts = [f"{k}={shlex.quote(v)}" for k, v in sorted(env.items())] - return " ".join(parts) + " " - - -def _create_prompt_wrapper_script( - worktree_path: Path, - agent: CodingAgent, - task_file: Path, - extra_args: list[str] | None = None, - env: dict[str, str] | None = None, -) -> Path: - """Create a wrapper script that reads prompt from file to avoid shell quoting issues.""" - script_path = Path(tempfile.gettempdir()) / f"agent-cli-{worktree_path.name}.sh" - - # Build the agent command without the prompt - exe = agent.get_executable() - if exe is None: - msg = f"{agent.name} is not installed" - raise RuntimeError(msg) - - cmd_parts = [shlex.quote(exe)] - if extra_args: - cmd_parts.extend(shlex.quote(arg) for arg in extra_args) - - agent_cmd = " ".join(cmd_parts) - env_prefix = _format_env_prefix(env or {}) - - task_file_rel = task_file.relative_to(worktree_path) - script_content = f"""#!/usr/bin/env bash -# Auto-generated script to launch agent with prompt -# Reads prompt from file to avoid shell parsing issues with special characters -{env_prefix}exec {agent_cmd} "$(cat {shlex.quote(str(task_file_rel))})" -""" - script_path.write_text(script_content) - script_path.chmod(0o755) - return script_path - - -def _launch_agent( - path: Path, - agent: CodingAgent, - extra_args: list[str] | None = None, - prompt: str | None = None, - task_file: Path | None = None, - env: dict[str, str] | None = None, - *, - track: bool = True, - agent_name: str | None = None, -) -> str | 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. - - When *track* is ``True`` and tmux is detected, the agent is registered - in the orchestration state file so it can be monitored with ``dev poll``, - ``dev output``, ``dev send``, and ``dev wait``. - - Returns the tracked agent name if tracking was successful, else ``None``. - """ - from .terminals.tmux import Tmux # noqa: PLC0415 - - 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 - - # Use tmux_ops for tracked launch when in tmux - if isinstance(terminal, Tmux) and track: - from . import agent_state, tmux_ops # noqa: PLC0415 - - pane_id = tmux_ops.open_window_with_pane_id(path, full_cmd, tab_name=tab_name) - if pane_id: - root = repo_root or path - name = agent_state.generate_agent_name(root, path, agent.name, agent_name) - agent_state.register_agent(root, name, pane_id, path, agent.name) - agent_state.inject_completion_hook(path, agent.name) - _success(f"Started {agent.name} in new tmux tab (tracking as [cyan]{name}[/cyan])") - return name - _warn("Could not open new tmux window") - elif terminal.open_new_tab(path, full_cmd, tab_name=tab_name): - _success(f"Started {agent.name} in new {terminal.name} tab") - return None - else: - _warn(f"Could not open new tab in {terminal.name}") - - # No terminal detected or failed - print instructions - if _is_ssh_session(): - console.print("\n[yellow]SSH session without terminal multiplexer.[/yellow]") - console.print("[bold]Start a multiplexer first, then run:[/bold]") - else: - console.print(f"\n[bold]To start {agent.name}:[/bold]") - console.print(f" cd {path}") - console.print(f" {full_cmd}") - return None - - @app.command("new") def new( # noqa: C901, PLR0912, PLR0915 branch: Annotated[ @@ -1452,60 +1167,6 @@ def run_cmd( _error(f"Command not found: {command[0]}") -def _find_worktrees_with_no_commits(repo_root: Path) -> list[worktree.WorktreeInfo]: - """Find worktrees whose branches have no commits ahead of the default branch.""" - worktrees_list = worktree.list_worktrees() - default_branch = worktree.get_default_branch(repo_root) - to_remove: list[worktree.WorktreeInfo] = [] - - for wt in worktrees_list: - if wt.is_main or not wt.branch: - continue - - # Check if branch has any commits ahead of default branch - result = subprocess.run( - ["git", "rev-list", f"{default_branch}..{wt.branch}", "--count"], # noqa: S607 - capture_output=True, - text=True, - cwd=repo_root, - check=False, - ) - if result.returncode == 0 and result.stdout.strip() == "0": - to_remove.append(wt) - - return to_remove - - -def _find_worktrees_with_merged_prs( - repo_root: Path, -) -> list[tuple[worktree.WorktreeInfo, str]]: - """Find worktrees whose PRs have been merged on GitHub. - - Returns a list of tuples containing (worktree_info, pr_url). - """ - worktrees_list = worktree.list_worktrees() - to_remove: list[tuple[worktree.WorktreeInfo, str]] = [] - - for wt in worktrees_list: - if wt.is_main or not wt.branch: - continue - - # Check if PR for this branch is merged - result = subprocess.run( - ["gh", "pr", "list", "--head", wt.branch, "--state", "merged", "--json", "number,url"], # noqa: S607 - capture_output=True, - text=True, - cwd=repo_root, - check=False, - ) - if result.returncode == 0 and result.stdout.strip() not in ("", "[]"): - prs = json.loads(result.stdout) - pr_url = prs[0]["url"] if prs else "" - to_remove.append((wt, pr_url)) - - return to_remove - - def _clean_merged_worktrees( repo_root: Path, dry_run: bool, @@ -1514,27 +1175,15 @@ def _clean_merged_worktrees( force: bool = False, ) -> None: """Remove worktrees with merged PRs (requires gh CLI).""" - _info("Checking for worktrees with merged PRs...") + from . import cleanup # noqa: PLC0415 - # Check if gh CLI is available - gh_version = subprocess.run( - ["gh", "--version"], # noqa: S607 - capture_output=True, - check=False, - ) - if gh_version.returncode != 0: - _error("GitHub CLI (gh) not found. Install from: https://cli.github.com/") + _info("Checking for worktrees with merged PRs...") - # Check if gh is authenticated - gh_auth = subprocess.run( - ["gh", "auth", "status"], # noqa: S607 - capture_output=True, - check=False, - ) - if gh_auth.returncode != 0: - _error("Not authenticated with GitHub. Run: gh auth login") + ok, error_msg = cleanup.check_gh_available() + if not ok: + _error(error_msg) - to_remove = _find_worktrees_with_merged_prs(repo_root) + to_remove = cleanup.find_worktrees_with_merged_prs(repo_root) if not to_remove: _info("No worktrees with merged PRs found") @@ -1549,17 +1198,12 @@ def _clean_merged_worktrees( if dry_run: _info("[dry-run] Would remove the above worktrees") elif yes or typer.confirm("\nRemove these worktrees?"): - for wt, _pr_url in to_remove: - success, error = worktree.remove_worktree( - wt.path, - force=force, - delete_branch=True, - repo_path=repo_root, - ) + results = cleanup.remove_worktrees([wt for wt, _ in to_remove], repo_root, force=force) + for branch, success, error in results: if success: - _success(f"Removed {wt.branch}") + _success(f"Removed {branch}") else: - _warn(f"Failed to remove {wt.branch}: {error}") + _warn(f"Failed to remove {branch}: {error}") def _clean_no_commits_worktrees( @@ -1570,9 +1214,11 @@ def _clean_no_commits_worktrees( force: bool = False, ) -> None: """Remove worktrees with no commits ahead of the default branch.""" + from . import cleanup # noqa: PLC0415 + _info("Checking for worktrees with no commits...") - to_remove = _find_worktrees_with_no_commits(repo_root) + to_remove = cleanup.find_worktrees_with_no_commits(repo_root) if not to_remove: _info("No worktrees with zero commits found") @@ -1588,17 +1234,12 @@ def _clean_no_commits_worktrees( if dry_run: _info("[dry-run] Would remove the above worktrees") elif yes or typer.confirm("\nRemove these worktrees?"): - for wt in to_remove: - success, error = worktree.remove_worktree( - wt.path, - force=force, - delete_branch=True, - repo_path=repo_root, - ) + results = cleanup.remove_worktrees(to_remove, repo_root, force=force) + for branch, success, error in results: if success: - _success(f"Removed {wt.branch}") + _success(f"Removed {branch}") else: - _warn(f"Failed to remove {wt.branch}: {error}") + _warn(f"Failed to remove {branch}: {error}") @app.command("clean") diff --git a/agent_cli/dev/launch.py b/agent_cli/dev/launch.py new file mode 100644 index 00000000..45fa6a68 --- /dev/null +++ b/agent_cli/dev/launch.py @@ -0,0 +1,331 @@ +"""Agent and editor resolution, configuration, and launching.""" + +from __future__ import annotations + +import os +import shlex +import subprocess +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING + +from agent_cli.config import load_config +from agent_cli.core.utils import console + +from . import coding_agents, editors, terminals, worktree + +if TYPE_CHECKING: + from .coding_agents.base import CodingAgent + from .editors.base import Editor + + +def resolve_editor( + use_editor: bool, + editor_name: str | None, + default_editor: str | None = None, +) -> Editor | None: + """Resolve which editor to use based on flags and config defaults.""" + # Use explicit name if provided + if editor_name: + editor = editors.get_editor(editor_name) + if editor is None: + from .cli import _warn # noqa: PLC0415 + + _warn(f"Editor '{editor_name}' not found") + return editor + + # If no flag and no default, don't use an editor + if not use_editor and not default_editor: + return None + + # If default is set in config, use it + if default_editor: + editor = editors.get_editor(default_editor) + if editor is not None: + return editor + from .cli import _warn # noqa: PLC0415 + + _warn(f"Default editor '{default_editor}' from config not found") + + # Auto-detect current or first available + editor = editors.detect_current_editor() + if editor is None: + available = editors.get_available_editors() + return available[0] if available else None + return editor + + +def resolve_agent( + use_agent: bool, + agent_name: str | None, + default_agent: str | None = None, +) -> CodingAgent | None: + """Resolve which coding agent to use based on flags and config defaults.""" + # Use explicit name if provided + if agent_name: + agent = coding_agents.get_agent(agent_name) + if agent is None: + from .cli import _warn # noqa: PLC0415 + + _warn(f"Agent '{agent_name}' not found") + return agent + + # If no flag and no default, don't use an agent + if not use_agent and not default_agent: + return None + + # If default is set in config, use it + if default_agent: + agent = coding_agents.get_agent(default_agent) + if agent is not None: + return agent + from .cli import _warn # noqa: PLC0415 + + _warn(f"Default agent '{default_agent}' from config not found") + + # Auto-detect current or first available + agent = coding_agents.detect_current_agent() + if agent is None: + available = coding_agents.get_available_agents() + return available[0] if available else None + return agent + + +def get_config_agent_args() -> dict[str, list[str]] | None: + """Load agent_args from config file. + + Config format: + [dev.agent_args] + claude = ["--dangerously-skip-permissions"] + + Note: The config loader may flatten section names, so we check both + nested structure and flattened 'dev.agent_args' key. + """ + config = load_config(None) + + # First try the simple nested structure (for testing/mocks) + dev_config = config.get("dev", {}) + if isinstance(dev_config, dict) and "agent_args" in dev_config: + return dev_config["agent_args"] + + # Handle flattened key "dev.agent_args" + return config.get("dev.agent_args") + + +def get_config_agent_env() -> dict[str, dict[str, str]] | None: + """Load agent_env from config file. + + Config format: + [dev.agent_env] + claude = { CLAUDE_CODE_USE_VERTEX = "1", ANTHROPIC_MODEL = "opus" } + + Note: The config loader flattens nested dicts, so keys like + 'dev.agent_env.claude' become top-level. We reconstruct the + agent_env dict from these flattened keys. + """ + config = load_config(None) + + # First try the simple nested structure (for testing/mocks) + dev_config = config.get("dev", {}) + if isinstance(dev_config, dict) and "agent_env" in dev_config: + return dev_config["agent_env"] + + # Handle flattened keys like "dev.agent_env.claude" + prefix = "dev.agent_env." + result: dict[str, dict[str, str]] = {} + for key, value in config.items(): + if key.startswith(prefix) and isinstance(value, dict): + agent_name = key[len(prefix) :] + result[agent_name] = value + + return result or None + + +def get_agent_env(agent: CodingAgent) -> dict[str, str]: + """Get environment variables for an agent. + + Merges config env vars with agent's built-in env vars. + Config env vars take precedence. + """ + # Start with agent's built-in env vars + env = agent.get_env().copy() + + # Add config env vars (these override built-in ones) + config_env = get_config_agent_env() + if config_env and agent.name in config_env: + env.update(config_env[agent.name]) + + return env + + +def merge_agent_args( + agent: CodingAgent, + cli_args: list[str] | None, +) -> list[str] | None: + """Merge CLI args with config args for an agent. + + Config args are applied first, CLI args are appended (and can override). + """ + config_args = get_config_agent_args() + result: list[str] = [] + + # Add config args for this agent + if config_args and agent.name in config_args: + result.extend(config_args[agent.name]) + + # Add CLI args (these override/extend config args) + if cli_args: + result.extend(cli_args) + + return result or None + + +def is_ssh_session() -> bool: + """Check if we're in an SSH session.""" + return bool(os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_CLIENT")) + + +def launch_editor(path: Path, editor: Editor) -> None: + """Launch editor via subprocess (editors are GUI apps that detach).""" + from .cli import _success, _warn # noqa: PLC0415 + + try: + subprocess.Popen(editor.open_command(path)) + _success(f"Opened {editor.name}") + except Exception as e: + _warn(f"Could not open editor: {e}") + + +def write_prompt_to_worktree(worktree_path: Path, prompt: str) -> Path: + """Write the prompt to .claude/TASK.md in the worktree. + + This makes the task description available to the spawned agent + and provides a record of what was requested. + """ + claude_dir = worktree_path / ".claude" + claude_dir.mkdir(parents=True, exist_ok=True) + task_file = claude_dir / "TASK.md" + task_file.write_text(prompt + "\n") + return task_file + + +def format_env_prefix(env: dict[str, str]) -> str: + """Format environment variables as shell prefix. + + Returns a string like 'VAR1=value1 VAR2=value2 ' that can be + prepended to a command. + """ + if not env: + return "" + # Quote values that contain spaces or special characters + parts = [f"{k}={shlex.quote(v)}" for k, v in sorted(env.items())] + return " ".join(parts) + " " + + +def create_prompt_wrapper_script( + worktree_path: Path, + agent: CodingAgent, + task_file: Path, + extra_args: list[str] | None = None, + env: dict[str, str] | None = None, +) -> Path: + """Create a wrapper script that reads prompt from file to avoid shell quoting issues.""" + script_path = Path(tempfile.gettempdir()) / f"agent-cli-{worktree_path.name}.sh" + + # Build the agent command without the prompt + exe = agent.get_executable() + if exe is None: + msg = f"{agent.name} is not installed" + raise RuntimeError(msg) + + cmd_parts = [shlex.quote(exe)] + if extra_args: + cmd_parts.extend(shlex.quote(arg) for arg in extra_args) + + agent_cmd = " ".join(cmd_parts) + env_prefix = format_env_prefix(env or {}) + + task_file_rel = task_file.relative_to(worktree_path) + script_content = f"""#!/usr/bin/env bash +# Auto-generated script to launch agent with prompt +# Reads prompt from file to avoid shell parsing issues with special characters +{env_prefix}exec {agent_cmd} "$(cat {shlex.quote(str(task_file_rel))})" +""" + script_path.write_text(script_content) + script_path.chmod(0o755) + return script_path + + +def launch_agent( + path: Path, + agent: CodingAgent, + extra_args: list[str] | None = None, + prompt: str | None = None, + task_file: Path | None = None, + env: dict[str, str] | None = None, + *, + track: bool = True, + agent_name: str | None = None, +) -> str | 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. + + When *track* is ``True`` and tmux is detected, the agent is registered + in the orchestration state file so it can be monitored with ``dev poll``, + ``dev output``, ``dev send``, and ``dev wait``. + + Returns the tracked agent name if tracking was successful, else ``None``. + """ + from .cli import _success, _warn # noqa: PLC0415 + from .terminals.tmux import Tmux # noqa: PLC0415 + + 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 + + # Use tmux_ops for tracked launch when in tmux + if isinstance(terminal, Tmux) and track: + from . import agent_state, tmux_ops # noqa: PLC0415 + + pane_id = tmux_ops.open_window_with_pane_id(path, full_cmd, tab_name=tab_name) + if pane_id: + root = repo_root or path + name = agent_state.generate_agent_name(root, path, agent.name, agent_name) + agent_state.register_agent(root, name, pane_id, path, agent.name) + agent_state.inject_completion_hook(path, agent.name) + _success(f"Started {agent.name} in new tmux tab (tracking as [cyan]{name}[/cyan])") + return name + _warn("Could not open new tmux window") + elif terminal.open_new_tab(path, full_cmd, tab_name=tab_name): + _success(f"Started {agent.name} in new {terminal.name} tab") + return None + else: + _warn(f"Could not open new tab in {terminal.name}") + + # No terminal detected or failed - print instructions + if is_ssh_session(): + console.print("\n[yellow]SSH session without terminal multiplexer.[/yellow]") + console.print("[bold]Start a multiplexer first, then run:[/bold]") + else: + 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/tests/dev/test_cli.py b/tests/dev/test_cli.py index e0584dc9..145f75e4 100644 --- a/tests/dev/test_cli.py +++ b/tests/dev/test_cli.py @@ -18,14 +18,20 @@ generate_ai_branch_name, generate_random_branch_name, ) -from agent_cli.dev.cli import ( - _clean_no_commits_worktrees, - _format_env_prefix, - _get_agent_env, - _get_config_agent_args, - _get_config_agent_env, -) +from agent_cli.dev.cli import _clean_no_commits_worktrees from agent_cli.dev.coding_agents.base import CodingAgent +from agent_cli.dev.launch import ( + format_env_prefix as _format_env_prefix, +) +from agent_cli.dev.launch import ( + get_agent_env as _get_agent_env, +) +from agent_cli.dev.launch import ( + get_config_agent_args as _get_config_agent_args, +) +from agent_cli.dev.launch import ( + get_config_agent_env as _get_config_agent_env, +) from agent_cli.dev.worktree import CreateWorktreeResult, WorktreeInfo runner = CliRunner(env={"NO_COLOR": "1", "TERM": "dumb"}) @@ -251,11 +257,10 @@ def test_clean_no_commits_force_passed_to_remove_worktree(self) -> None: is_prunable=False, ) with ( - patch("agent_cli.dev.cli._find_worktrees_with_no_commits", return_value=[wt]), - patch("agent_cli.dev.cli.worktree.get_default_branch", return_value="main"), + patch("agent_cli.dev.cleanup.find_worktrees_with_no_commits", return_value=[wt]), patch( - "agent_cli.dev.cli.worktree.remove_worktree", - return_value=(True, None), + "agent_cli.dev.cleanup.remove_worktrees", + return_value=[("feature", True, None)], ) as mock_remove, ): _clean_no_commits_worktrees( @@ -778,7 +783,7 @@ class TestGetConfigAgentArgs: def test_returns_none_when_no_config(self) -> None: """Returns None when no agent_args in config.""" - with patch("agent_cli.dev.cli.load_config", return_value={}): + with patch("agent_cli.dev.launch.load_config", return_value={}): result = _get_config_agent_args() assert result is None @@ -791,7 +796,7 @@ def test_returns_agent_args_nested(self) -> None: }, }, } - with patch("agent_cli.dev.cli.load_config", return_value=config): + with patch("agent_cli.dev.launch.load_config", return_value=config): result = _get_config_agent_args() assert result == {"claude": ["--dangerously-skip-permissions"]} @@ -803,7 +808,7 @@ def test_returns_agent_args_from_flattened_key(self) -> None: "aider": ["--model", "gpt-4o"], }, } - with patch("agent_cli.dev.cli.load_config", return_value=config): + with patch("agent_cli.dev.launch.load_config", return_value=config): result = _get_config_agent_args() assert result == { "claude": ["--dangerously-skip-permissions"], @@ -816,13 +821,13 @@ class TestGetConfigAgentEnv: def test_returns_none_when_no_config(self) -> None: """Returns None when no agent_env in config.""" - with patch("agent_cli.dev.cli.load_config", return_value={}): + with patch("agent_cli.dev.launch.load_config", return_value={}): result = _get_config_agent_env() assert result is None def test_returns_none_when_no_dev_section(self) -> None: """Returns None when no dev section in config.""" - with patch("agent_cli.dev.cli.load_config", return_value={"other": {}}): + with patch("agent_cli.dev.launch.load_config", return_value={"other": {}}): result = _get_config_agent_env() assert result is None @@ -835,7 +840,7 @@ def test_returns_agent_env(self) -> None: }, }, } - with patch("agent_cli.dev.cli.load_config", return_value=config): + with patch("agent_cli.dev.launch.load_config", return_value=config): result = _get_config_agent_env() assert result == {"claude": {"CLAUDE_CODE_USE_VERTEX": "1", "ANTHROPIC_MODEL": "opus"}} @@ -846,7 +851,7 @@ def test_returns_agent_env_from_flattened_keys(self) -> None: "dev.agent_env.claude": {"CLAUDE_CODE_USE_VERTEX": "1", "ANTHROPIC_MODEL": "opus"}, "dev.agent_env.aider": {"OPENAI_API_KEY": "sk-xxx"}, } - with patch("agent_cli.dev.cli.load_config", return_value=config): + with patch("agent_cli.dev.launch.load_config", return_value=config): result = _get_config_agent_env() assert result == { "claude": {"CLAUDE_CODE_USE_VERTEX": "1", "ANTHROPIC_MODEL": "opus"}, @@ -871,7 +876,7 @@ class TestGetAgentEnv: def test_returns_builtin_env_when_no_config(self) -> None: """Returns agent's built-in env when no config.""" agent = _MockAgent() - with patch("agent_cli.dev.cli._get_config_agent_env", return_value=None): + with patch("agent_cli.dev.launch.get_config_agent_env", return_value=None): result = _get_agent_env(agent) assert result == {"BUILTIN_VAR": "builtin_value"} @@ -879,7 +884,7 @@ def test_config_overrides_builtin(self) -> None: """Config env vars override built-in env vars.""" agent = _MockAgent() config_env = {"mock": {"BUILTIN_VAR": "overridden", "NEW_VAR": "new_value"}} - with patch("agent_cli.dev.cli._get_config_agent_env", return_value=config_env): + with patch("agent_cli.dev.launch.get_config_agent_env", return_value=config_env): result = _get_agent_env(agent) assert result == {"BUILTIN_VAR": "overridden", "NEW_VAR": "new_value"} @@ -887,7 +892,7 @@ def test_merges_builtin_and_config(self) -> None: """Config env vars are merged with built-in env vars.""" agent = _MockAgent() config_env = {"mock": {"CONFIG_VAR": "config_value"}} - with patch("agent_cli.dev.cli._get_config_agent_env", return_value=config_env): + with patch("agent_cli.dev.launch.get_config_agent_env", return_value=config_env): result = _get_agent_env(agent) assert result == {"BUILTIN_VAR": "builtin_value", "CONFIG_VAR": "config_value"} @@ -895,7 +900,7 @@ def test_ignores_other_agents(self) -> None: """Config for other agents is ignored.""" agent = _MockAgent() config_env = {"other": {"OTHER_VAR": "other_value"}} - with patch("agent_cli.dev.cli._get_config_agent_env", return_value=config_env): + with patch("agent_cli.dev.launch.get_config_agent_env", return_value=config_env): result = _get_agent_env(agent) assert result == {"BUILTIN_VAR": "builtin_value"} From 9a49f83ac0d70c93d89339e56ba72b2d13944036 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 9 Feb 2026 19:35:20 -0800 Subject: [PATCH 05/20] refactor(dev): extract output helpers and orchestration commands from cli.py - Create _output.py with shared console helpers (_error, _success, _info, _warn), fixing the circular import between launch.py and cli.py - Move orchestration commands (poll, output, send, wait) to orchestration.py - Decompose `new` command with _resolve_branch_name and _setup_worktree_env helpers - Update launch.py to import from _output instead of cli - Update test mock paths to match new module locations --- agent_cli/dev/_output.py | 37 +++ agent_cli/dev/cli.py | 545 ++++++++------------------------ agent_cli/dev/launch.py | 12 +- agent_cli/dev/orchestration.py | 323 +++++++++++++++++++ tests/dev/test_orchestration.py | 22 +- 5 files changed, 506 insertions(+), 433 deletions(-) create mode 100644 agent_cli/dev/_output.py create mode 100644 agent_cli/dev/orchestration.py diff --git a/agent_cli/dev/_output.py b/agent_cli/dev/_output.py new file mode 100644 index 00000000..3e7e7c0a --- /dev/null +++ b/agent_cli/dev/_output.py @@ -0,0 +1,37 @@ +"""Console output helpers for the dev module.""" + +from __future__ import annotations + +from typing import NoReturn + +import typer + +from agent_cli.core.utils import console, err_console + + +def _error(msg: str) -> NoReturn: + """Print an error message and exit.""" + err_console.print(f"[bold red]Error:[/bold red] {msg}") + raise typer.Exit(1) + + +def _success(msg: str) -> None: + """Print a success message.""" + console.print(f"[bold green]✓[/bold green] {msg}") + + +def _info(msg: str) -> None: + """Print an info message, with special styling for commands.""" + # Style commands (messages starting with "Running: ") + if msg.startswith("Running: "): + cmd = msg[9:] # Remove "Running: " prefix + # Escape brackets to prevent Rich from interpreting them as markup + cmd = cmd.replace("[", r"\[") + console.print(f"[dim]→[/dim] Running: [bold cyan]{cmd}[/bold cyan]") + else: + console.print(f"[dim]→[/dim] {msg}") + + +def _warn(msg: str) -> None: + """Print a warning message.""" + console.print(f"[yellow]Warning:[/yellow] {msg}") diff --git a/agent_cli/dev/cli.py b/agent_cli/dev/cli.py index 4f39f8b8..067be96e 100644 --- a/agent_cli/dev/cli.py +++ b/agent_cli/dev/cli.py @@ -7,7 +7,7 @@ import shutil import subprocess from pathlib import Path -from typing import TYPE_CHECKING, Annotated, Literal, NoReturn +from typing import Annotated, Literal import typer from rich.panel import Panel @@ -16,12 +16,13 @@ from agent_cli.cli import app as main_app from agent_cli.cli import set_config_defaults from agent_cli.core.process import set_process_title -from agent_cli.core.utils import console, err_console +from agent_cli.core.utils import console from . import coding_agents, editors, terminals, worktree from ._branch_name import AGENTS as _BRANCH_NAME_AGENTS from ._branch_name import generate_ai_branch_name as _generate_ai_branch_name from ._branch_name import generate_random_branch_name as _generate_branch_name +from ._output import _error, _info, _success, _warn from .launch import ( get_agent_env as _get_agent_env, ) @@ -51,9 +52,6 @@ setup_direnv, ) -if TYPE_CHECKING: - from . import agent_state - app = typer.Typer( name="dev", help="""Parallel development environment manager using git worktrees. @@ -113,34 +111,6 @@ def dev_callback( set_process_title(f"dev-{ctx.invoked_subcommand}") -def _error(msg: str) -> NoReturn: - """Print an error message and exit.""" - err_console.print(f"[bold red]Error:[/bold red] {msg}") - raise typer.Exit(1) - - -def _success(msg: str) -> None: - """Print a success message.""" - console.print(f"[bold green]✓[/bold green] {msg}") - - -def _info(msg: str) -> None: - """Print an info message, with special styling for commands.""" - # Style commands (messages starting with "Running: ") - if msg.startswith("Running: "): - cmd = msg[9:] # Remove "Running: " prefix - # Escape brackets to prevent Rich from interpreting them as markup - cmd = cmd.replace("[", r"\[") - console.print(f"[dim]→[/dim] Running: [bold cyan]{cmd}[/bold cyan]") - else: - console.print(f"[dim]→[/dim] {msg}") - - -def _warn(msg: str) -> None: - """Print a warning message.""" - console.print(f"[yellow]Warning:[/yellow] {msg}") - - def _ensure_git_repo() -> Path: """Ensure we're in a git repository and return the repo root.""" if not worktree.git_available(): @@ -153,8 +123,107 @@ def _ensure_git_repo() -> Path: return repo_root +def _resolve_branch_name( + branch: str | None, + branch_name_mode: str, + prompt: str | None, + agent_name: str | None, + branch_name_agent: str | None, + branch_name_timeout: float, + repo_root: Path, + from_ref: str | None, +) -> str: + """Generate or validate the branch name for a new worktree.""" + if branch is not None: + return branch + + existing = {wt.branch for wt in worktree.list_worktrees() if wt.branch} + + # In auto mode, only use AI naming when we have task context. + use_ai = branch_name_mode != "random" and (branch_name_mode != "auto" or bool(prompt)) + + if not use_ai: + branch = _generate_branch_name(existing, repo_root=repo_root) + _info(f"Generated branch name: {branch}") + return branch + + effective_agent = branch_name_agent + if effective_agent is None and agent_name: + candidate = agent_name.lower().strip() + if candidate in _BRANCH_NAME_AGENTS: + effective_agent = candidate + + branch = _generate_ai_branch_name( + repo_root, + existing, + prompt, + from_ref, + effective_agent, + branch_name_timeout, + ) + if branch: + _info(f"AI-generated branch name: {branch}") + return branch + + _warn("Could not generate branch name with AI. Falling back to random naming.") + branch = _generate_branch_name(existing, repo_root=repo_root) + _info(f"Generated branch name: {branch}") + return branch + + +def _setup_worktree_env( + worktree_path: Path, + repo_root: Path, + *, + copy_env: bool, + setup: bool, + direnv: bool | None, + verbose: bool, +) -> None: + """Copy env files, run project setup, and configure direnv.""" + if copy_env: + copied = copy_env_files(repo_root, worktree_path) + if copied: + names = ", ".join(f.name for f in copied) + _success(f"Copied env file(s): {names}") + + project = None + if setup: + project = detect_project_type(worktree_path) + if project: + _info(f"Detected {project.description}") + success, output = run_setup( + worktree_path, + project, + on_log=_info, + capture_output=not verbose, + ) + if success: + _success("Project setup complete") + else: + _warn(f"Setup failed: {output}") + + use_direnv = direnv if direnv is not None else is_direnv_available() + if use_direnv: + if is_direnv_available(): + success, msg = setup_direnv( + worktree_path, + project, + on_log=_info, + capture_output=not verbose, + ) + if success and ("created" in msg or "allowed" in msg): + _success(msg) + elif success: + _info(msg) + else: + _warn(msg) + elif direnv is True: + _warn("direnv not installed, skipping .envrc setup") + + @app.command("new") -def new( # noqa: C901, PLR0912, PLR0915 +def new( branch: Annotated[ str | None, typer.Argument( @@ -327,40 +396,16 @@ def new( # noqa: C901, PLR0912, PLR0915 repo_root = _ensure_git_repo() - # Generate branch name if not provided - if branch is None: - # Get existing branches to avoid collisions - existing = {wt.branch for wt in worktree.list_worktrees() if wt.branch} - # In auto mode, only use AI naming when we have task context. - if branch_name_mode == "auto" and not prompt: - use_ai = False - else: - use_ai = branch_name_mode != "random" - - if not use_ai: - branch = _generate_branch_name(existing, repo_root=repo_root) - _info(f"Generated branch name: {branch}") - else: - effective_branch_name_agent = branch_name_agent - if effective_branch_name_agent is None and agent_name: - candidate = agent_name.lower().strip() - if candidate in _BRANCH_NAME_AGENTS: - effective_branch_name_agent = candidate - - branch = _generate_ai_branch_name( - repo_root, - existing, - prompt, - from_ref, - effective_branch_name_agent, - branch_name_timeout, - ) - if branch: - _info(f"AI-generated branch name: {branch}") - else: - _warn("Could not generate branch name with AI. Falling back to random naming.") - branch = _generate_branch_name(existing, repo_root=repo_root) - _info(f"Generated branch name: {branch}") + branch = _resolve_branch_name( + branch, + branch_name_mode, + prompt, + agent_name, + branch_name_agent, + branch_name_timeout, + repo_root, + from_ref, + ) # Create the worktree _info(f"Creating worktree for branch '{branch}'...") @@ -372,61 +417,23 @@ def new( # noqa: C901, PLR0912, PLR0915 on_log=_info, capture_output=not verbose, ) - if not result.success: _error(result.error or "Failed to create worktree") assert result.path is not None _success(f"Created worktree at {result.path}") - - # Show warning if --from was ignored if result.warning: _warn(result.warning) - # Copy env files - if copy_env: - copied = copy_env_files(repo_root, result.path) - if copied: - names = ", ".join(f.name for f in copied) - _success(f"Copied env file(s): {names}") - - # Detect and run project setup - project = None - if setup: - project = detect_project_type(result.path) - if project: - _info(f"Detected {project.description}") - success, output = run_setup( - result.path, - project, - on_log=_info, - capture_output=not verbose, - ) - if success: - _success("Project setup complete") - else: - _warn(f"Setup failed: {output}") - - # Set up direnv (default: enabled if direnv is installed) - use_direnv = direnv if direnv is not None else is_direnv_available() - if use_direnv: - if is_direnv_available(): - success, msg = setup_direnv( - result.path, - project, - on_log=_info, - capture_output=not verbose, - ) - # Show success for meaningful actions (created or allowed) - if success and ("created" in msg or "allowed" in msg): - _success(msg) - elif success: - _info(msg) - else: - _warn(msg) - elif direnv is True: - # Only warn if user explicitly requested direnv - _warn("direnv not installed, skipping .envrc setup") + # Environment setup (env files, project setup, direnv) + _setup_worktree_env( + result.path, + repo_root, + copy_env=copy_env, + setup=setup, + direnv=direnv, + verbose=verbose, + ) # Write prompt to worktree (makes task available to the spawned agent) task_file = None @@ -434,15 +441,13 @@ def new( # noqa: C901, PLR0912, PLR0915 task_file = _write_prompt_to_worktree(result.path, prompt) _success(f"Wrote task to {task_file.relative_to(result.path)}") - # Resolve editor and agent + # Resolve and launch editor/agent resolved_editor = _resolve_editor(editor, editor_name, default_editor) resolved_agent = _resolve_agent(agent, agent_name, default_agent) - # Launch editor (GUI app - subprocess works) if resolved_editor and resolved_editor.is_available(): _launch_editor(result.path, resolved_editor) - # Launch agent (interactive TUI - needs terminal tab) if resolved_agent and resolved_agent.is_available(): merged_args = _merge_agent_args(resolved_agent, agent_args) agent_env = _get_agent_env(resolved_agent) @@ -921,7 +926,10 @@ def start_agent( if tab: # Launch in a new tmux tab with tracking - _ensure_tmux() + from . import agent_state as _agent_state # noqa: PLC0415 + + if not _agent_state.is_tmux(): + _error("Agent tracking requires tmux. Start a tmux session first.") _launch_agent( wt.path, agent, @@ -1501,300 +1509,5 @@ def install_skill( console.print(f" • {f.name}") -# --------------------------------------------------------------------------- -# Orchestration commands (tmux-only) -# --------------------------------------------------------------------------- - - -def _ensure_tmux() -> None: - """Exit with an error if not running inside tmux.""" - from . import agent_state # noqa: PLC0415 - - if not agent_state.is_tmux(): - _error("Agent tracking requires tmux. Start a tmux session first.") - - -def _lookup_agent(name: str) -> tuple[Path, agent_state.TrackedAgent]: - """Look up a tracked agent by name. Exits on error.""" - from . import agent_state # noqa: PLC0415 - - repo_root = _ensure_git_repo() - state = agent_state.load_state(repo_root) - agent = state.agents.get(name) - if agent is None: - _error(f"Agent '{name}' not found. Run 'dev poll' to see tracked agents.") - return repo_root, agent - - -def _format_duration(seconds: float) -> str: - """Format seconds into a human-readable duration.""" - if seconds < 60: # noqa: PLR2004 - return f"{int(seconds)}s" - minutes = int(seconds // 60) - secs = int(seconds % 60) - if minutes < 60: # noqa: PLR2004 - return f"{minutes}m {secs}s" - hours = int(minutes // 60) - mins = minutes % 60 - return f"{hours}h {mins}m" - - -def _status_style(status: str) -> str: - """Return a Rich-styled status string.""" - styles = { - "running": "[bold green]running[/bold green]", - "idle": "[bold yellow]idle[/bold yellow]", - "done": "[bold cyan]done[/bold cyan]", - "dead": "[bold red]dead[/bold red]", - } - return styles.get(status, status) - - -@app.command("poll") -def poll_cmd( - json_output: Annotated[ - bool, - typer.Option("--json", help="Output as JSON"), - ] = False, -) -> None: - """Check status of all tracked agents. - - Performs a single poll of all tracked agents (checks tmux panes, - output quiescence, and completion sentinels) then displays results. - - **Status values:** - - - **running** — Agent output is still changing - - **idle** — Agent output has not changed since last poll - - **done** — Agent wrote a completion sentinel (.claude/DONE) - - **dead** — tmux pane no longer exists - - **Examples:** - - - `dev poll` — Show status table - - `dev poll --json` — Machine-readable output - """ - import time # noqa: PLC0415 - - from . import agent_state # noqa: PLC0415 - from .poller import poll_once # noqa: PLC0415 - - _ensure_tmux() - repo_root = _ensure_git_repo() - state = agent_state.load_state(repo_root) - - if not state.agents: - _info("No tracked agents. Launch one with 'dev new -a' or 'dev agent --tab'.") - return - - poll_once(repo_root) - - # Reload state after polling - state = agent_state.load_state(repo_root) - now = time.time() - - if json_output: - data = { - "agents": [ - { - "name": a.name, - "status": a.status, - "agent_type": a.agent_type, - "worktree_path": a.worktree_path, - "pane_id": a.pane_id, - "started_at": a.started_at, - "duration_seconds": round(now - a.started_at), - "last_change_at": a.last_change_at, - } - for a in state.agents.values() - ], - "last_poll_at": state.last_poll_at, - } - print(json.dumps(data, indent=2)) - return - - table = Table(title="Agent Status") - table.add_column("Name", style="cyan") - table.add_column("Status") - table.add_column("Agent", style="dim") - table.add_column("Worktree", style="dim") - table.add_column("Duration", style="dim") - - for a in state.agents.values(): - table.add_row( - a.name, - _status_style(a.status), - a.agent_type, - Path(a.worktree_path).name, - _format_duration(now - a.started_at), - ) - - console.print(table) - - # Summary line - total = len(state.agents) - by_status: dict[str, int] = {} - for a in state.agents.values(): - by_status[a.status] = by_status.get(a.status, 0) + 1 - parts = [f"{total} agent{'s' if total != 1 else ''}"] - parts.extend( - f"{count} {status}" - for status in ("running", "idle", "done", "dead") - if (count := by_status.get(status, 0)) - ) - console.print(f"\n[dim]{' · '.join(parts)}[/dim]") - - -@app.command("output") -def output_cmd( - name: Annotated[ - str, - typer.Argument(help="Agent name (from 'dev poll')"), - ], - lines: Annotated[ - int, - typer.Option("--lines", "-n", help="Number of lines to capture"), - ] = 50, - follow: Annotated[ - bool, - typer.Option("--follow", "-f", help="Continuously stream output (Ctrl+C to stop)"), - ] = False, -) -> None: - """Get recent terminal output from a tracked agent. - - Captures the last N lines from the agent's tmux pane. - - **Examples:** - - - `dev output my-feature` — Last 50 lines - - `dev output my-feature -n 200` — Last 200 lines - - `dev output my-feature -f` — Follow output continuously - """ - import time as _time # noqa: PLC0415 - - from . import tmux_ops # noqa: PLC0415 - - _ensure_tmux() - _repo_root, agent = _lookup_agent(name) - - if agent.status == "dead": - _error(f"Agent '{name}' is dead (tmux pane closed). No output available.") - - if not follow: - output = tmux_ops.capture_pane(agent.pane_id, lines) - if output is None: - _error(f"Could not capture output from pane {agent.pane_id}") - print(output, end="") - return - - # Follow mode - try: - prev = "" - while True: - output = tmux_ops.capture_pane(agent.pane_id, lines) - if output is None: - _warn("Pane closed.") - break - if output != prev: - print(output, end="", flush=True) - prev = output - _time.sleep(1.0) - except KeyboardInterrupt: - pass - - -@app.command("send") -def send_cmd( - name: Annotated[ - str, - typer.Argument(help="Agent name (from 'dev poll')"), - ], - message: Annotated[ - str, - typer.Argument(help="Text to send to the agent's terminal"), - ], - no_enter: Annotated[ - bool, - typer.Option("--no-enter", help="Don't press Enter after sending"), - ] = False, -) -> None: - """Send text input to a running agent's terminal. - - Types the message into the agent's tmux pane using ``tmux send-keys``. - By default, presses Enter after the message. - - **Examples:** - - - `dev send my-feature "Fix the failing tests"` — Send a message - - `dev send my-feature "/exit" --no-enter` — Send without pressing Enter - """ - from . import tmux_ops # noqa: PLC0415 - - _ensure_tmux() - _repo_root, agent = _lookup_agent(name) - - if agent.status == "dead": - _error(f"Agent '{name}' is dead (tmux pane closed). Cannot send messages.") - - if tmux_ops.send_keys(agent.pane_id, message, enter=not no_enter): - _success(f"Sent message to {name}") - else: - _error(f"Failed to send message to pane {agent.pane_id}") - - -@app.command("wait") -def wait_cmd( - name: Annotated[ - str, - typer.Argument(help="Agent name (from 'dev poll')"), - ], - timeout: Annotated[ - float, - typer.Option("--timeout", "-t", help="Timeout in seconds (0 = no timeout)"), - ] = 0, - interval: Annotated[ - float, - typer.Option("--interval", "-i", help="Poll interval in seconds"), - ] = 5.0, -) -> None: - """Block until a tracked agent finishes. - - Polls the agent's tmux pane until it reaches idle, done, or dead status. - Useful for orchestration: launch an agent, wait for it, then act on results. - - **Exit codes:** - - - 0 — Agent finished (idle or done) - - 1 — Agent died (pane closed unexpectedly) - - 2 — Timeout reached - - **Examples:** - - - `dev wait my-feature` — Wait indefinitely - - `dev wait my-feature --timeout 300` — Wait up to 5 minutes - - `dev wait my-feature -i 2` — Poll every 2 seconds - """ - from .poller import wait_for_agent # noqa: PLC0415 - - _ensure_tmux() - _repo_root, agent = _lookup_agent(name) - - if agent.status in ("done", "dead", "idle"): - console.print(f"Agent '{name}' is already {_status_style(agent.status)}") - raise typer.Exit(0 if agent.status != "dead" else 1) - - repo_root = _ensure_git_repo() - _info(f"Waiting for agent '{name}' to finish (polling every {interval}s)...") - - try: - status, elapsed = wait_for_agent(repo_root, name, timeout=timeout, interval=interval) - except TimeoutError: - _warn(f"Timeout after {_format_duration(timeout)}") - raise typer.Exit(2) from None - - if status == "dead": - _warn(f"Agent '{name}' died (pane closed) after {_format_duration(elapsed)}") - raise typer.Exit(1) - - _success(f"Agent '{name}' is {status} after {_format_duration(elapsed)}") - raise typer.Exit(0) +# Register orchestration commands (poll, output, send, wait) +from . import orchestration # noqa: E402, F401 diff --git a/agent_cli/dev/launch.py b/agent_cli/dev/launch.py index 45fa6a68..d1fbbc56 100644 --- a/agent_cli/dev/launch.py +++ b/agent_cli/dev/launch.py @@ -29,7 +29,7 @@ def resolve_editor( if editor_name: editor = editors.get_editor(editor_name) if editor is None: - from .cli import _warn # noqa: PLC0415 + from ._output import _warn # noqa: PLC0415 _warn(f"Editor '{editor_name}' not found") return editor @@ -43,7 +43,7 @@ def resolve_editor( editor = editors.get_editor(default_editor) if editor is not None: return editor - from .cli import _warn # noqa: PLC0415 + from ._output import _warn # noqa: PLC0415 _warn(f"Default editor '{default_editor}' from config not found") @@ -65,7 +65,7 @@ def resolve_agent( if agent_name: agent = coding_agents.get_agent(agent_name) if agent is None: - from .cli import _warn # noqa: PLC0415 + from ._output import _warn # noqa: PLC0415 _warn(f"Agent '{agent_name}' not found") return agent @@ -79,7 +79,7 @@ def resolve_agent( agent = coding_agents.get_agent(default_agent) if agent is not None: return agent - from .cli import _warn # noqa: PLC0415 + from ._output import _warn # noqa: PLC0415 _warn(f"Default agent '{default_agent}' from config not found") @@ -187,7 +187,7 @@ def is_ssh_session() -> bool: def launch_editor(path: Path, editor: Editor) -> None: """Launch editor via subprocess (editors are GUI apps that detach).""" - from .cli import _success, _warn # noqa: PLC0415 + from ._output import _success, _warn # noqa: PLC0415 try: subprocess.Popen(editor.open_command(path)) @@ -278,7 +278,7 @@ def launch_agent( Returns the tracked agent name if tracking was successful, else ``None``. """ - from .cli import _success, _warn # noqa: PLC0415 + from ._output import _success, _warn # noqa: PLC0415 from .terminals.tmux import Tmux # noqa: PLC0415 terminal = terminals.detect_current_terminal() diff --git a/agent_cli/dev/orchestration.py b/agent_cli/dev/orchestration.py new file mode 100644 index 00000000..d001af10 --- /dev/null +++ b/agent_cli/dev/orchestration.py @@ -0,0 +1,323 @@ +"""Orchestration CLI commands for tracked agents (tmux-only).""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import TYPE_CHECKING, Annotated + +import typer + +from agent_cli.core.utils import console + +from . import worktree +from ._output import _error, _info, _success, _warn +from .cli import app + +if TYPE_CHECKING: + from . import agent_state + + +def _ensure_git_repo() -> Path: + """Ensure we're in a git repository and return the repo root.""" + if not worktree.git_available(): + _error("Git is not installed or not in PATH") + + repo_root = worktree.get_main_repo_root() + if repo_root is None: + _error("Not in a git repository") + + return repo_root + + +def _ensure_tmux() -> None: + """Exit with an error if not running inside tmux.""" + from . import agent_state as _agent_state # noqa: PLC0415 + + if not _agent_state.is_tmux(): + _error("Agent tracking requires tmux. Start a tmux session first.") + + +def _lookup_agent(name: str) -> tuple[Path, agent_state.TrackedAgent]: + """Look up a tracked agent by name. Exits on error.""" + from . import agent_state as _agent_state # noqa: PLC0415 + + repo_root = _ensure_git_repo() + state = _agent_state.load_state(repo_root) + agent = state.agents.get(name) + if agent is None: + _error(f"Agent '{name}' not found. Run 'dev poll' to see tracked agents.") + return repo_root, agent + + +def _format_duration(seconds: float) -> str: + """Format seconds into a human-readable duration.""" + if seconds < 60: # noqa: PLR2004 + return f"{int(seconds)}s" + minutes = int(seconds // 60) + secs = int(seconds % 60) + if minutes < 60: # noqa: PLR2004 + return f"{minutes}m {secs}s" + hours = int(minutes // 60) + mins = minutes % 60 + return f"{hours}h {mins}m" + + +def _status_style(status: str) -> str: + """Return a Rich-styled status string.""" + styles = { + "running": "[bold green]running[/bold green]", + "idle": "[bold yellow]idle[/bold yellow]", + "done": "[bold cyan]done[/bold cyan]", + "dead": "[bold red]dead[/bold red]", + } + return styles.get(status, status) + + +@app.command("poll") +def poll_cmd( + json_output: Annotated[ + bool, + typer.Option("--json", help="Output as JSON"), + ] = False, +) -> None: + """Check status of all tracked agents. + + Performs a single poll of all tracked agents (checks tmux panes, + output quiescence, and completion sentinels) then displays results. + + **Status values:** + + - **running** — Agent output is still changing + - **idle** — Agent output has not changed since last poll + - **done** — Agent wrote a completion sentinel (.claude/DONE) + - **dead** — tmux pane no longer exists + + **Examples:** + + - `dev poll` — Show status table + - `dev poll --json` — Machine-readable output + """ + from . import agent_state as _agent_state # noqa: PLC0415 + from .poller import poll_once # noqa: PLC0415 + + _ensure_tmux() + repo_root = _ensure_git_repo() + state = _agent_state.load_state(repo_root) + + if not state.agents: + _info("No tracked agents. Launch one with 'dev new -a' or 'dev agent --tab'.") + return + + poll_once(repo_root) + + # Reload state after polling + state = _agent_state.load_state(repo_root) + now = time.time() + + if json_output: + data = { + "agents": [ + { + "name": a.name, + "status": a.status, + "agent_type": a.agent_type, + "worktree_path": a.worktree_path, + "pane_id": a.pane_id, + "started_at": a.started_at, + "duration_seconds": round(now - a.started_at), + "last_change_at": a.last_change_at, + } + for a in state.agents.values() + ], + "last_poll_at": state.last_poll_at, + } + print(json.dumps(data, indent=2)) + return + + from rich.table import Table # noqa: PLC0415 + + table = Table(title="Agent Status") + table.add_column("Name", style="cyan") + table.add_column("Status") + table.add_column("Agent", style="dim") + table.add_column("Worktree", style="dim") + table.add_column("Duration", style="dim") + + for a in state.agents.values(): + table.add_row( + a.name, + _status_style(a.status), + a.agent_type, + Path(a.worktree_path).name, + _format_duration(now - a.started_at), + ) + + console.print(table) + + # Summary line + total = len(state.agents) + by_status: dict[str, int] = {} + for a in state.agents.values(): + by_status[a.status] = by_status.get(a.status, 0) + 1 + parts = [f"{total} agent{'s' if total != 1 else ''}"] + parts.extend( + f"{count} {status}" + for status in ("running", "idle", "done", "dead") + if (count := by_status.get(status, 0)) + ) + console.print(f"\n[dim]{' · '.join(parts)}[/dim]") + + +@app.command("output") +def output_cmd( + name: Annotated[ + str, + typer.Argument(help="Agent name (from 'dev poll')"), + ], + lines: Annotated[ + int, + typer.Option("--lines", "-n", help="Number of lines to capture"), + ] = 50, + follow: Annotated[ + bool, + typer.Option("--follow", "-f", help="Continuously stream output (Ctrl+C to stop)"), + ] = False, +) -> None: + """Get recent terminal output from a tracked agent. + + Captures the last N lines from the agent's tmux pane. + + **Examples:** + + - `dev output my-feature` — Last 50 lines + - `dev output my-feature -n 200` — Last 200 lines + - `dev output my-feature -f` — Follow output continuously + """ + from . import tmux_ops # noqa: PLC0415 + + _ensure_tmux() + _repo_root, agent = _lookup_agent(name) + + if agent.status == "dead": + _error(f"Agent '{name}' is dead (tmux pane closed). No output available.") + + if not follow: + output = tmux_ops.capture_pane(agent.pane_id, lines) + if output is None: + _error(f"Could not capture output from pane {agent.pane_id}") + print(output, end="") + return + + # Follow mode + try: + prev = "" + while True: + output = tmux_ops.capture_pane(agent.pane_id, lines) + if output is None: + _warn("Pane closed.") + break + if output != prev: + print(output, end="", flush=True) + prev = output + time.sleep(1.0) + except KeyboardInterrupt: + pass + + +@app.command("send") +def send_cmd( + name: Annotated[ + str, + typer.Argument(help="Agent name (from 'dev poll')"), + ], + message: Annotated[ + str, + typer.Argument(help="Text to send to the agent's terminal"), + ], + no_enter: Annotated[ + bool, + typer.Option("--no-enter", help="Don't press Enter after sending"), + ] = False, +) -> None: + """Send text input to a running agent's terminal. + + Types the message into the agent's tmux pane using ``tmux send-keys``. + By default, presses Enter after the message. + + **Examples:** + + - `dev send my-feature "Fix the failing tests"` — Send a message + - `dev send my-feature "/exit" --no-enter` — Send without pressing Enter + """ + from . import tmux_ops # noqa: PLC0415 + + _ensure_tmux() + _repo_root, agent = _lookup_agent(name) + + if agent.status == "dead": + _error(f"Agent '{name}' is dead (tmux pane closed). Cannot send messages.") + + if tmux_ops.send_keys(agent.pane_id, message, enter=not no_enter): + _success(f"Sent message to {name}") + else: + _error(f"Failed to send message to pane {agent.pane_id}") + + +@app.command("wait") +def wait_cmd( + name: Annotated[ + str, + typer.Argument(help="Agent name (from 'dev poll')"), + ], + timeout: Annotated[ + float, + typer.Option("--timeout", "-t", help="Timeout in seconds (0 = no timeout)"), + ] = 0, + interval: Annotated[ + float, + typer.Option("--interval", "-i", help="Poll interval in seconds"), + ] = 5.0, +) -> None: + """Block until a tracked agent finishes. + + Polls the agent's tmux pane until it reaches idle, done, or dead status. + Useful for orchestration: launch an agent, wait for it, then act on results. + + **Exit codes:** + + - 0 — Agent finished (idle or done) + - 1 — Agent died (pane closed unexpectedly) + - 2 — Timeout reached + + **Examples:** + + - `dev wait my-feature` — Wait indefinitely + - `dev wait my-feature --timeout 300` — Wait up to 5 minutes + - `dev wait my-feature -i 2` — Poll every 2 seconds + """ + from .poller import wait_for_agent # noqa: PLC0415 + + _ensure_tmux() + _repo_root, agent = _lookup_agent(name) + + if agent.status in ("done", "dead", "idle"): + console.print(f"Agent '{name}' is already {_status_style(agent.status)}") + raise typer.Exit(0 if agent.status != "dead" else 1) + + repo_root = _ensure_git_repo() + _info(f"Waiting for agent '{name}' to finish (polling every {interval}s)...") + + try: + status, elapsed = wait_for_agent(repo_root, name, timeout=timeout, interval=interval) + except TimeoutError: + _warn(f"Timeout after {_format_duration(timeout)}") + raise typer.Exit(2) from None + + if status == "dead": + _warn(f"Agent '{name}' died (pane closed) after {_format_duration(elapsed)}") + raise typer.Exit(1) + + _success(f"Agent '{name}' is {status} after {_format_duration(elapsed)}") + raise typer.Exit(0) diff --git a/tests/dev/test_orchestration.py b/tests/dev/test_orchestration.py index 95c29340..e647ce92 100644 --- a/tests/dev/test_orchestration.py +++ b/tests/dev/test_orchestration.py @@ -241,7 +241,7 @@ def test_poll_no_agents(self) -> None: """Shows message when no agents are tracked.""" with ( patch.dict("os.environ", _TMUX_ENV), - patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.orchestration._ensure_git_repo", return_value=Path("/repo")), patch( "agent_cli.dev.agent_state.load_state", return_value=agent_state.AgentStateFile(repo_root="/repo"), @@ -264,7 +264,7 @@ def test_poll_json_output(self) -> None: with ( patch.dict("os.environ", _TMUX_ENV), - patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.orchestration._ensure_git_repo", return_value=Path("/repo")), patch("agent_cli.dev.agent_state.load_state", return_value=state), patch("agent_cli.dev.agent_state.save_state"), patch("agent_cli.dev.tmux_ops.pane_exists", return_value=True), @@ -290,7 +290,7 @@ def test_poll_detects_dead_agent(self) -> None: with ( patch.dict("os.environ", _TMUX_ENV), - patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.orchestration._ensure_git_repo", return_value=Path("/repo")), patch("agent_cli.dev.agent_state.load_state", return_value=state), patch("agent_cli.dev.agent_state.save_state"), patch("agent_cli.dev.tmux_ops.pane_exists", return_value=False), @@ -318,7 +318,7 @@ def test_output_captures_pane(self) -> None: with ( patch.dict("os.environ", _TMUX_ENV), - patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.orchestration._ensure_git_repo", return_value=Path("/repo")), patch("agent_cli.dev.agent_state.load_state", return_value=state), patch("agent_cli.dev.tmux_ops.capture_pane", return_value="hello world\n"), ): @@ -330,7 +330,7 @@ def test_output_agent_not_found(self) -> None: """Errors when agent name doesn't exist.""" with ( patch.dict("os.environ", _TMUX_ENV), - patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.orchestration._ensure_git_repo", return_value=Path("/repo")), patch( "agent_cli.dev.agent_state.load_state", return_value=agent_state.AgentStateFile(repo_root="/repo"), @@ -358,7 +358,7 @@ def test_send_keys_to_agent(self) -> None: with ( patch.dict("os.environ", _TMUX_ENV), - patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.orchestration._ensure_git_repo", return_value=Path("/repo")), patch("agent_cli.dev.agent_state.load_state", return_value=state), patch("agent_cli.dev.tmux_ops.send_keys", return_value=True) as mock_send, ): @@ -380,7 +380,7 @@ def test_send_to_dead_agent(self) -> None: with ( patch.dict("os.environ", _TMUX_ENV), - patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.orchestration._ensure_git_repo", return_value=Path("/repo")), patch("agent_cli.dev.agent_state.load_state", return_value=state), ): result = runner.invoke(app, ["dev", "send", "test", "hello"]) @@ -405,7 +405,7 @@ def test_wait_already_done(self) -> None: with ( patch.dict("os.environ", _TMUX_ENV), - patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.orchestration._ensure_git_repo", return_value=Path("/repo")), patch("agent_cli.dev.agent_state.load_state", return_value=state), ): result = runner.invoke(app, ["dev", "wait", "test"]) @@ -426,7 +426,7 @@ def test_wait_already_dead(self) -> None: with ( patch.dict("os.environ", _TMUX_ENV), - patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.orchestration._ensure_git_repo", return_value=Path("/repo")), patch("agent_cli.dev.agent_state.load_state", return_value=state), ): result = runner.invoke(app, ["dev", "wait", "test"]) @@ -440,7 +440,7 @@ def test_poll_requires_tmux(self) -> None: """Poll command fails outside tmux.""" with ( patch.dict("os.environ", {}, clear=True), - patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.orchestration._ensure_git_repo", return_value=Path("/repo")), ): result = runner.invoke(app, ["dev", "poll"]) assert result.exit_code == 1 @@ -450,7 +450,7 @@ def test_send_requires_tmux(self) -> None: """Send command fails outside tmux.""" with ( patch.dict("os.environ", {}, clear=True), - patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch("agent_cli.dev.orchestration._ensure_git_repo", return_value=Path("/repo")), ): result = runner.invoke(app, ["dev", "send", "test", "hello"]) assert result.exit_code == 1 From 608e3698dfcf9470e6808b845e41a24901fd35d0 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 9 Feb 2026 19:41:48 -0800 Subject: [PATCH 06/20] refactor(dev): remove import aliases and privatize internal helpers - Stop aliasing imports with underscores in cli.py (import directly) - Make launch.py internal helpers private: _is_ssh_session, _format_env_prefix, _create_prompt_wrapper_script - Update test mock paths and imports to match --- agent_cli/dev/cli.py | 61 ++++++++++++------------------ agent_cli/dev/launch.py | 14 +++---- tests/dev/test_cli.py | 84 +++++++++++++++++++---------------------- 3 files changed, 70 insertions(+), 89 deletions(-) diff --git a/agent_cli/dev/cli.py b/agent_cli/dev/cli.py index 067be96e..3b78a050 100644 --- a/agent_cli/dev/cli.py +++ b/agent_cli/dev/cli.py @@ -19,30 +19,17 @@ from agent_cli.core.utils import console from . import coding_agents, editors, terminals, worktree -from ._branch_name import AGENTS as _BRANCH_NAME_AGENTS -from ._branch_name import generate_ai_branch_name as _generate_ai_branch_name -from ._branch_name import generate_random_branch_name as _generate_branch_name +from ._branch_name import AGENTS as BRANCH_NAME_AGENTS +from ._branch_name import generate_ai_branch_name, generate_random_branch_name from ._output import _error, _info, _success, _warn from .launch import ( - get_agent_env as _get_agent_env, -) -from .launch import ( - launch_agent as _launch_agent, -) -from .launch import ( - launch_editor as _launch_editor, -) -from .launch import ( - merge_agent_args as _merge_agent_args, -) -from .launch import ( - resolve_agent as _resolve_agent, -) -from .launch import ( - resolve_editor as _resolve_editor, -) -from .launch import ( - write_prompt_to_worktree as _write_prompt_to_worktree, + get_agent_env, + launch_agent, + launch_editor, + merge_agent_args, + resolve_agent, + resolve_editor, + write_prompt_to_worktree, ) from .project import ( copy_env_files, @@ -143,17 +130,17 @@ def _resolve_branch_name( use_ai = branch_name_mode != "random" and (branch_name_mode != "auto" or bool(prompt)) if not use_ai: - branch = _generate_branch_name(existing, repo_root=repo_root) + branch = generate_random_branch_name(existing, repo_root=repo_root) _info(f"Generated branch name: {branch}") return branch effective_agent = branch_name_agent if effective_agent is None and agent_name: candidate = agent_name.lower().strip() - if candidate in _BRANCH_NAME_AGENTS: + if candidate in BRANCH_NAME_AGENTS: effective_agent = candidate - branch = _generate_ai_branch_name( + branch = generate_ai_branch_name( repo_root, existing, prompt, @@ -166,7 +153,7 @@ def _resolve_branch_name( return branch _warn("Could not generate branch name with AI. Falling back to random naming.") - branch = _generate_branch_name(existing, repo_root=repo_root) + branch = generate_random_branch_name(existing, repo_root=repo_root) _info(f"Generated branch name: {branch}") return branch @@ -438,20 +425,20 @@ def new( # Write prompt to worktree (makes task available to the spawned agent) task_file = None if prompt: - task_file = _write_prompt_to_worktree(result.path, prompt) + task_file = write_prompt_to_worktree(result.path, prompt) _success(f"Wrote task to {task_file.relative_to(result.path)}") # Resolve and launch editor/agent - resolved_editor = _resolve_editor(editor, editor_name, default_editor) - resolved_agent = _resolve_agent(agent, agent_name, default_agent) + resolved_editor = resolve_editor(editor, editor_name, default_editor) + resolved_agent = resolve_agent(agent, agent_name, default_agent) if resolved_editor and resolved_editor.is_available(): - _launch_editor(result.path, resolved_editor) + launch_editor(result.path, resolved_editor) 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) + 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) # Print summary console.print() @@ -918,11 +905,11 @@ def start_agent( # Write prompt to worktree (makes task available to the agent) task_file = None if prompt: - task_file = _write_prompt_to_worktree(wt.path, prompt) + task_file = write_prompt_to_worktree(wt.path, prompt) _success(f"Wrote task to {task_file.relative_to(wt.path)}") - merged_args = _merge_agent_args(agent, agent_args) - agent_env = _get_agent_env(agent) + merged_args = merge_agent_args(agent, agent_args) + agent_env = get_agent_env(agent) if tab: # Launch in a new tmux tab with tracking @@ -930,7 +917,7 @@ def start_agent( if not _agent_state.is_tmux(): _error("Agent tracking requires tmux. Start a tmux session first.") - _launch_agent( + launch_agent( wt.path, agent, merged_args, diff --git a/agent_cli/dev/launch.py b/agent_cli/dev/launch.py index d1fbbc56..82f4e1ef 100644 --- a/agent_cli/dev/launch.py +++ b/agent_cli/dev/launch.py @@ -180,7 +180,7 @@ def merge_agent_args( return result or None -def is_ssh_session() -> bool: +def _is_ssh_session() -> bool: """Check if we're in an SSH session.""" return bool(os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_CLIENT")) @@ -209,7 +209,7 @@ def write_prompt_to_worktree(worktree_path: Path, prompt: str) -> Path: return task_file -def format_env_prefix(env: dict[str, str]) -> str: +def _format_env_prefix(env: dict[str, str]) -> str: """Format environment variables as shell prefix. Returns a string like 'VAR1=value1 VAR2=value2 ' that can be @@ -222,7 +222,7 @@ def format_env_prefix(env: dict[str, str]) -> str: return " ".join(parts) + " " -def create_prompt_wrapper_script( +def _create_prompt_wrapper_script( worktree_path: Path, agent: CodingAgent, task_file: Path, @@ -243,7 +243,7 @@ def create_prompt_wrapper_script( cmd_parts.extend(shlex.quote(arg) for arg in extra_args) agent_cmd = " ".join(cmd_parts) - env_prefix = format_env_prefix(env or {}) + env_prefix = _format_env_prefix(env or {}) task_file_rel = task_file.relative_to(worktree_path) script_content = f"""#!/usr/bin/env bash @@ -286,11 +286,11 @@ def launch_agent( # 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) + 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 {}) + env_prefix = _format_env_prefix(env or {}) full_cmd = env_prefix + agent_cmd if terminal: @@ -321,7 +321,7 @@ def launch_agent( _warn(f"Could not open new tab in {terminal.name}") # No terminal detected or failed - print instructions - if is_ssh_session(): + if _is_ssh_session(): console.print("\n[yellow]SSH session without terminal multiplexer.[/yellow]") console.print("[bold]Start a multiplexer first, then run:[/bold]") else: diff --git a/tests/dev/test_cli.py b/tests/dev/test_cli.py index 145f75e4..28234cba 100644 --- a/tests/dev/test_cli.py +++ b/tests/dev/test_cli.py @@ -21,16 +21,10 @@ from agent_cli.dev.cli import _clean_no_commits_worktrees from agent_cli.dev.coding_agents.base import CodingAgent from agent_cli.dev.launch import ( - format_env_prefix as _format_env_prefix, -) -from agent_cli.dev.launch import ( - get_agent_env as _get_agent_env, -) -from agent_cli.dev.launch import ( - get_config_agent_args as _get_config_agent_args, -) -from agent_cli.dev.launch import ( - get_config_agent_env as _get_config_agent_env, + _format_env_prefix, + get_agent_env, + get_config_agent_args, + get_config_agent_env, ) from agent_cli.dev.worktree import CreateWorktreeResult, WorktreeInfo @@ -285,11 +279,11 @@ def test_new_uses_ai_branch_name_when_enabled(self, tmp_path: Path) -> None: patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), patch("agent_cli.dev.worktree.list_worktrees", return_value=[]), patch( - "agent_cli.dev.cli._generate_ai_branch_name", + "agent_cli.dev.cli.generate_ai_branch_name", return_value="feat/login-retry", ) as mock_ai, patch( - "agent_cli.dev.cli._generate_branch_name", + "agent_cli.dev.cli.generate_random_branch_name", return_value="happy-fox", ) as mock_random, patch( @@ -301,11 +295,11 @@ def test_new_uses_ai_branch_name_when_enabled(self, tmp_path: Path) -> None: ), ), patch( - "agent_cli.dev.cli._write_prompt_to_worktree", + "agent_cli.dev.cli.write_prompt_to_worktree", return_value=wt_path / ".claude/TASK.md", ), - patch("agent_cli.dev.cli._resolve_editor", return_value=None), - patch("agent_cli.dev.cli._resolve_agent", return_value=None), + patch("agent_cli.dev.cli.resolve_editor", return_value=None), + patch("agent_cli.dev.cli.resolve_agent", return_value=None), ): result = runner.invoke( app, @@ -337,11 +331,11 @@ def test_new_uses_with_agent_for_branch_naming_when_supported(self, tmp_path: Pa patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), patch("agent_cli.dev.worktree.list_worktrees", return_value=[]), patch( - "agent_cli.dev.cli._generate_ai_branch_name", + "agent_cli.dev.cli.generate_ai_branch_name", return_value="feat/login-retry", ) as mock_ai, patch( - "agent_cli.dev.cli._generate_branch_name", + "agent_cli.dev.cli.generate_random_branch_name", return_value="happy-fox", ) as mock_random, patch( @@ -352,8 +346,8 @@ def test_new_uses_with_agent_for_branch_naming_when_supported(self, tmp_path: Pa branch="feat/login-retry", ), ), - patch("agent_cli.dev.cli._resolve_editor", return_value=None), - patch("agent_cli.dev.cli._resolve_agent", return_value=None), + patch("agent_cli.dev.cli.resolve_editor", return_value=None), + patch("agent_cli.dev.cli.resolve_agent", return_value=None), ): result = runner.invoke( app, @@ -391,11 +385,11 @@ def test_new_falls_back_to_random_when_ai_fails(self, tmp_path: Path) -> None: patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), patch("agent_cli.dev.worktree.list_worktrees", return_value=[]), patch( - "agent_cli.dev.cli._generate_ai_branch_name", + "agent_cli.dev.cli.generate_ai_branch_name", return_value=None, ), patch( - "agent_cli.dev.cli._generate_branch_name", + "agent_cli.dev.cli.generate_random_branch_name", return_value="happy-fox", ) as mock_random, patch( @@ -406,8 +400,8 @@ def test_new_falls_back_to_random_when_ai_fails(self, tmp_path: Path) -> None: branch="happy-fox", ), ), - patch("agent_cli.dev.cli._resolve_editor", return_value=None), - patch("agent_cli.dev.cli._resolve_agent", return_value=None), + patch("agent_cli.dev.cli.resolve_editor", return_value=None), + patch("agent_cli.dev.cli.resolve_agent", return_value=None), ): result = runner.invoke( app, @@ -436,8 +430,8 @@ def test_new_auto_without_prompt_uses_random(self, tmp_path: Path) -> None: with ( patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), patch("agent_cli.dev.worktree.list_worktrees", return_value=[]), - patch("agent_cli.dev.cli._generate_ai_branch_name") as mock_ai, - patch("agent_cli.dev.cli._generate_branch_name", return_value="happy-fox"), + patch("agent_cli.dev.cli.generate_ai_branch_name") as mock_ai, + patch("agent_cli.dev.cli.generate_random_branch_name", return_value="happy-fox"), patch( "agent_cli.dev.worktree.create_worktree", return_value=CreateWorktreeResult( @@ -446,8 +440,8 @@ def test_new_auto_without_prompt_uses_random(self, tmp_path: Path) -> None: branch="happy-fox", ), ), - patch("agent_cli.dev.cli._resolve_editor", return_value=None), - patch("agent_cli.dev.cli._resolve_agent", return_value=None), + patch("agent_cli.dev.cli.resolve_editor", return_value=None), + patch("agent_cli.dev.cli.resolve_agent", return_value=None), ): result = runner.invoke( app, @@ -487,10 +481,10 @@ def test_new_uses_dev_config_defaults_for_branch_naming(self, tmp_path: Path) -> patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), patch("agent_cli.dev.worktree.list_worktrees", return_value=[]), patch( - "agent_cli.dev.cli._generate_ai_branch_name", + "agent_cli.dev.cli.generate_ai_branch_name", return_value="feat/login-retry", ) as mock_ai, - patch("agent_cli.dev.cli._generate_branch_name") as mock_random, + patch("agent_cli.dev.cli.generate_random_branch_name") as mock_random, patch( "agent_cli.dev.worktree.create_worktree", return_value=CreateWorktreeResult( @@ -499,8 +493,8 @@ def test_new_uses_dev_config_defaults_for_branch_naming(self, tmp_path: Path) -> branch="feat/login-retry", ), ) as mock_create, - patch("agent_cli.dev.cli._resolve_editor", return_value=None), - patch("agent_cli.dev.cli._resolve_agent", return_value=None), + patch("agent_cli.dev.cli.resolve_editor", return_value=None), + patch("agent_cli.dev.cli.resolve_agent", return_value=None), ): result = runner.invoke(app, ["dev", "--config", str(config_path), "new"]) @@ -779,12 +773,12 @@ def test_quotes_empty_value(self) -> None: class TestGetConfigAgentArgs: - """Tests for _get_config_agent_args function.""" + """Tests for get_config_agent_args function.""" def test_returns_none_when_no_config(self) -> None: """Returns None when no agent_args in config.""" with patch("agent_cli.dev.launch.load_config", return_value={}): - result = _get_config_agent_args() + result = get_config_agent_args() assert result is None def test_returns_agent_args_nested(self) -> None: @@ -797,7 +791,7 @@ def test_returns_agent_args_nested(self) -> None: }, } with patch("agent_cli.dev.launch.load_config", return_value=config): - result = _get_config_agent_args() + result = get_config_agent_args() assert result == {"claude": ["--dangerously-skip-permissions"]} def test_returns_agent_args_from_flattened_key(self) -> None: @@ -809,7 +803,7 @@ def test_returns_agent_args_from_flattened_key(self) -> None: }, } with patch("agent_cli.dev.launch.load_config", return_value=config): - result = _get_config_agent_args() + result = get_config_agent_args() assert result == { "claude": ["--dangerously-skip-permissions"], "aider": ["--model", "gpt-4o"], @@ -817,18 +811,18 @@ def test_returns_agent_args_from_flattened_key(self) -> None: class TestGetConfigAgentEnv: - """Tests for _get_config_agent_env function.""" + """Tests for get_config_agent_env function.""" def test_returns_none_when_no_config(self) -> None: """Returns None when no agent_env in config.""" with patch("agent_cli.dev.launch.load_config", return_value={}): - result = _get_config_agent_env() + result = get_config_agent_env() assert result is None def test_returns_none_when_no_dev_section(self) -> None: """Returns None when no dev section in config.""" with patch("agent_cli.dev.launch.load_config", return_value={"other": {}}): - result = _get_config_agent_env() + result = get_config_agent_env() assert result is None def test_returns_agent_env(self) -> None: @@ -841,7 +835,7 @@ def test_returns_agent_env(self) -> None: }, } with patch("agent_cli.dev.launch.load_config", return_value=config): - result = _get_config_agent_env() + result = get_config_agent_env() assert result == {"claude": {"CLAUDE_CODE_USE_VERTEX": "1", "ANTHROPIC_MODEL": "opus"}} def test_returns_agent_env_from_flattened_keys(self) -> None: @@ -852,7 +846,7 @@ def test_returns_agent_env_from_flattened_keys(self) -> None: "dev.agent_env.aider": {"OPENAI_API_KEY": "sk-xxx"}, } with patch("agent_cli.dev.launch.load_config", return_value=config): - result = _get_config_agent_env() + result = get_config_agent_env() assert result == { "claude": {"CLAUDE_CODE_USE_VERTEX": "1", "ANTHROPIC_MODEL": "opus"}, "aider": {"OPENAI_API_KEY": "sk-xxx"}, @@ -871,13 +865,13 @@ def get_env(self) -> dict[str, str]: class TestGetAgentEnv: - """Tests for _get_agent_env function.""" + """Tests for get_agent_env function.""" def test_returns_builtin_env_when_no_config(self) -> None: """Returns agent's built-in env when no config.""" agent = _MockAgent() with patch("agent_cli.dev.launch.get_config_agent_env", return_value=None): - result = _get_agent_env(agent) + result = get_agent_env(agent) assert result == {"BUILTIN_VAR": "builtin_value"} def test_config_overrides_builtin(self) -> None: @@ -885,7 +879,7 @@ def test_config_overrides_builtin(self) -> None: agent = _MockAgent() config_env = {"mock": {"BUILTIN_VAR": "overridden", "NEW_VAR": "new_value"}} with patch("agent_cli.dev.launch.get_config_agent_env", return_value=config_env): - result = _get_agent_env(agent) + result = get_agent_env(agent) assert result == {"BUILTIN_VAR": "overridden", "NEW_VAR": "new_value"} def test_merges_builtin_and_config(self) -> None: @@ -893,7 +887,7 @@ def test_merges_builtin_and_config(self) -> None: agent = _MockAgent() config_env = {"mock": {"CONFIG_VAR": "config_value"}} with patch("agent_cli.dev.launch.get_config_agent_env", return_value=config_env): - result = _get_agent_env(agent) + result = get_agent_env(agent) assert result == {"BUILTIN_VAR": "builtin_value", "CONFIG_VAR": "config_value"} def test_ignores_other_agents(self) -> None: @@ -901,7 +895,7 @@ def test_ignores_other_agents(self) -> None: agent = _MockAgent() config_env = {"other": {"OTHER_VAR": "other_value"}} with patch("agent_cli.dev.launch.get_config_agent_env", return_value=config_env): - result = _get_agent_env(agent) + result = get_agent_env(agent) assert result == {"BUILTIN_VAR": "builtin_value"} From f20faa06ddf100edd2a0cbb353a2ef00f37c151b Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 9 Feb 2026 19:45:01 -0800 Subject: [PATCH 07/20] refactor(dev): make output helpers public and fix variable shadowing Rename _error/_success/_info/_warn to error/success/info/warn in _output.py. Fix local variables in cli.py that shadowed the newly public names (success, error) causing TypeError at runtime. --- agent_cli/dev/_output.py | 8 +- agent_cli/dev/cli.py | 162 ++++++++++++++++----------------- agent_cli/dev/launch.py | 32 +++---- agent_cli/dev/orchestration.py | 32 +++---- 4 files changed, 117 insertions(+), 117 deletions(-) diff --git a/agent_cli/dev/_output.py b/agent_cli/dev/_output.py index 3e7e7c0a..ba7de3bf 100644 --- a/agent_cli/dev/_output.py +++ b/agent_cli/dev/_output.py @@ -9,18 +9,18 @@ from agent_cli.core.utils import console, err_console -def _error(msg: str) -> NoReturn: +def error(msg: str) -> NoReturn: """Print an error message and exit.""" err_console.print(f"[bold red]Error:[/bold red] {msg}") raise typer.Exit(1) -def _success(msg: str) -> None: +def success(msg: str) -> None: """Print a success message.""" console.print(f"[bold green]✓[/bold green] {msg}") -def _info(msg: str) -> None: +def info(msg: str) -> None: """Print an info message, with special styling for commands.""" # Style commands (messages starting with "Running: ") if msg.startswith("Running: "): @@ -32,6 +32,6 @@ def _info(msg: str) -> None: console.print(f"[dim]→[/dim] {msg}") -def _warn(msg: str) -> None: +def warn(msg: str) -> None: """Print a warning message.""" console.print(f"[yellow]Warning:[/yellow] {msg}") diff --git a/agent_cli/dev/cli.py b/agent_cli/dev/cli.py index 3b78a050..400aafce 100644 --- a/agent_cli/dev/cli.py +++ b/agent_cli/dev/cli.py @@ -21,7 +21,7 @@ from . import coding_agents, editors, terminals, worktree from ._branch_name import AGENTS as BRANCH_NAME_AGENTS from ._branch_name import generate_ai_branch_name, generate_random_branch_name -from ._output import _error, _info, _success, _warn +from ._output import error, info, success, warn from .launch import ( get_agent_env, launch_agent, @@ -101,11 +101,11 @@ def dev_callback( def _ensure_git_repo() -> Path: """Ensure we're in a git repository and return the repo root.""" if not worktree.git_available(): - _error("Git is not installed or not in PATH") + error("Git is not installed or not in PATH") repo_root = worktree.get_main_repo_root() if repo_root is None: - _error("Not in a git repository") + error("Not in a git repository") return repo_root @@ -131,7 +131,7 @@ def _resolve_branch_name( if not use_ai: branch = generate_random_branch_name(existing, repo_root=repo_root) - _info(f"Generated branch name: {branch}") + info(f"Generated branch name: {branch}") return branch effective_agent = branch_name_agent @@ -149,12 +149,12 @@ def _resolve_branch_name( branch_name_timeout, ) if branch: - _info(f"AI-generated branch name: {branch}") + info(f"AI-generated branch name: {branch}") return branch - _warn("Could not generate branch name with AI. Falling back to random naming.") + warn("Could not generate branch name with AI. Falling back to random naming.") branch = generate_random_branch_name(existing, repo_root=repo_root) - _info(f"Generated branch name: {branch}") + info(f"Generated branch name: {branch}") return branch @@ -172,41 +172,41 @@ def _setup_worktree_env( copied = copy_env_files(repo_root, worktree_path) if copied: names = ", ".join(f.name for f in copied) - _success(f"Copied env file(s): {names}") + success(f"Copied env file(s): {names}") project = None if setup: project = detect_project_type(worktree_path) if project: - _info(f"Detected {project.description}") - success, output = run_setup( + info(f"Detected {project.description}") + setup_ok, output = run_setup( worktree_path, project, - on_log=_info, + on_log=info, capture_output=not verbose, ) - if success: - _success("Project setup complete") + if setup_ok: + success("Project setup complete") else: - _warn(f"Setup failed: {output}") + warn(f"Setup failed: {output}") use_direnv = direnv if direnv is not None else is_direnv_available() if use_direnv: if is_direnv_available(): - success, msg = setup_direnv( + direnv_ok, msg = setup_direnv( worktree_path, project, - on_log=_info, + on_log=info, capture_output=not verbose, ) - if success and ("created" in msg or "allowed" in msg): - _success(msg) - elif success: - _info(msg) + if direnv_ok and ("created" in msg or "allowed" in msg): + success(msg) + elif direnv_ok: + info(msg) else: - _warn(msg) + warn(msg) elif direnv is True: - _warn("direnv not installed, skipping .envrc setup") + warn("direnv not installed, skipping .envrc setup") @app.command("new") @@ -395,22 +395,22 @@ def new( ) # Create the worktree - _info(f"Creating worktree for branch '{branch}'...") + info(f"Creating worktree for branch '{branch}'...") result = worktree.create_worktree( branch, repo_path=repo_root, from_ref=from_ref, fetch=fetch, - on_log=_info, + on_log=info, capture_output=not verbose, ) if not result.success: - _error(result.error or "Failed to create worktree") + error(result.error or "Failed to create worktree") assert result.path is not None - _success(f"Created worktree at {result.path}") + success(f"Created worktree at {result.path}") if result.warning: - _warn(result.warning) + warn(result.warning) # Environment setup (env files, project setup, direnv) _setup_worktree_env( @@ -426,7 +426,7 @@ def new( task_file = None if prompt: task_file = write_prompt_to_worktree(result.path, prompt) - _success(f"Wrote task to {task_file.relative_to(result.path)}") + success(f"Wrote task to {task_file.relative_to(result.path)}") # Resolve and launch editor/agent resolved_editor = resolve_editor(editor, editor_name, default_editor) @@ -712,10 +712,10 @@ def remove( wt = worktree.find_worktree_by_name(name, repo_root) if wt is None: - _error(f"Worktree not found: {name}") + error(f"Worktree not found: {name}") if wt.is_main: - _error("Cannot remove the main worktree") + error("Cannot remove the main worktree") if not yes and not force: console.print(f"[bold]Will remove:[/bold] {wt.path}") @@ -724,17 +724,17 @@ def remove( if not typer.confirm("Continue?"): raise typer.Abort - success, error = worktree.remove_worktree( + removed, remove_err = worktree.remove_worktree( wt.path, force=force, delete_branch=delete_branch, repo_path=repo_root, ) - if success: - _success(f"Removed worktree: {wt.path}") + if removed: + success(f"Removed worktree: {wt.path}") else: - _error(error or "Failed to remove worktree") + error(remove_err or "Failed to remove worktree") @app.command("path") @@ -754,7 +754,7 @@ def path_cmd( wt = worktree.find_worktree_by_name(name, repo_root) if wt is None: - _error(f"Worktree not found: {name}") + error(f"Worktree not found: {name}") print(wt.path.as_posix()) @@ -783,28 +783,28 @@ def open_editor( wt = worktree.find_worktree_by_name(name, repo_root) if wt is None: - _error(f"Worktree not found: {name}") + error(f"Worktree not found: {name}") if editor_name: editor = editors.get_editor(editor_name) if editor is None: - _error(f"Editor not found: {editor_name}") + error(f"Editor not found: {editor_name}") else: editor = editors.detect_current_editor() if editor is None: available = editors.get_available_editors() if not available: - _error("No editors available") + error("No editors available") editor = available[0] if not editor.is_available(): - _error(f"{editor.name} is not installed") + error(f"{editor.name} is not installed") try: subprocess.Popen(editor.open_command(wt.path)) - _success(f"Opened {wt.path} in {editor.name}") + success(f"Opened {wt.path} in {editor.name}") except Exception as e: - _error(f"Failed to open editor: {e}") + error(f"Failed to open editor: {e}") @app.command("agent") @@ -885,28 +885,28 @@ def start_agent( wt = worktree.find_worktree_by_name(name, repo_root) if wt is None: - _error(f"Worktree not found: {name}") + error(f"Worktree not found: {name}") if agent_name: agent = coding_agents.get_agent(agent_name) if agent is None: - _error(f"Agent not found: {agent_name}") + error(f"Agent not found: {agent_name}") else: agent = coding_agents.detect_current_agent() if agent is None: available = coding_agents.get_available_agents() if not available: - _error("No AI coding agents available") + error("No AI coding agents available") agent = available[0] if not agent.is_available(): - _error(f"{agent.name} is not installed. Install from: {agent.install_url}") + error(f"{agent.name} is not installed. Install from: {agent.install_url}") # Write prompt to worktree (makes task available to the agent) task_file = None if prompt: task_file = write_prompt_to_worktree(wt.path, prompt) - _success(f"Wrote task to {task_file.relative_to(wt.path)}") + success(f"Wrote task to {task_file.relative_to(wt.path)}") merged_args = merge_agent_args(agent, agent_args) agent_env = get_agent_env(agent) @@ -916,7 +916,7 @@ def start_agent( from . import agent_state as _agent_state # noqa: PLC0415 if not _agent_state.is_tmux(): - _error("Agent tracking requires tmux. Start a tmux session first.") + error("Agent tracking requires tmux. Start a tmux session first.") launch_agent( wt.path, agent, @@ -929,7 +929,7 @@ def start_agent( ) return - _info(f"Starting {agent.name} in {wt.path}...") + info(f"Starting {agent.name} in {wt.path}...") try: os.chdir(wt.path) # Merge agent env with current environment @@ -941,7 +941,7 @@ def start_agent( env=run_env, ) except Exception as e: - _error(f"Failed to start agent: {e}") + error(f"Failed to start agent: {e}") @app.command("agents") @@ -1103,7 +1103,7 @@ def _print_item_status( """Print status of an item (editor, agent, terminal).""" if available: note = " [yellow](current)[/yellow]" if is_current else "" - _success(f"{name}{note}") + success(f"{name}{note}") else: console.print(f" [dim]○[/dim] {name} ({not_available_msg})") @@ -1112,13 +1112,13 @@ def _doctor_check_git() -> None: """Check git status for doctor command.""" console.print("[bold]Git:[/bold]") if worktree.git_available(): - _success("Git is available") + success("Git is available") else: console.print(" [red]✗[/red] Git is not installed") repo_root = worktree.get_main_repo_root() if repo_root: - _success(f"In git repository: {repo_root}") + success(f"In git repository: {repo_root}") else: console.print(" [yellow]○[/yellow] Not in a git repository") @@ -1149,17 +1149,17 @@ def run_cmd( wt = worktree.find_worktree_by_name(name, repo_root) if wt is None: - _error(f"Worktree not found: {name}") + error(f"Worktree not found: {name}") if not command: - _error("No command specified") + error("No command specified") - _info(f"Running in {wt.path}: {' '.join(command)}") + info(f"Running in {wt.path}: {' '.join(command)}") try: result = subprocess.run(command, cwd=wt.path, check=False) raise typer.Exit(result.returncode) except FileNotFoundError: - _error(f"Command not found: {command[0]}") + error(f"Command not found: {command[0]}") def _clean_merged_worktrees( @@ -1172,16 +1172,16 @@ def _clean_merged_worktrees( """Remove worktrees with merged PRs (requires gh CLI).""" from . import cleanup # noqa: PLC0415 - _info("Checking for worktrees with merged PRs...") + info("Checking for worktrees with merged PRs...") ok, error_msg = cleanup.check_gh_available() if not ok: - _error(error_msg) + error(error_msg) to_remove = cleanup.find_worktrees_with_merged_prs(repo_root) if not to_remove: - _info("No worktrees with merged PRs found") + info("No worktrees with merged PRs found") return console.print(f"\n[bold]Found {len(to_remove)} worktree(s) with merged PRs:[/bold]") @@ -1191,14 +1191,14 @@ def _clean_merged_worktrees( console.print(f" PR: [link={pr_url}]{pr_url}[/link]") if dry_run: - _info("[dry-run] Would remove the above worktrees") + info("[dry-run] Would remove the above worktrees") elif yes or typer.confirm("\nRemove these worktrees?"): results = cleanup.remove_worktrees([wt for wt, _ in to_remove], repo_root, force=force) - for branch, success, error in results: - if success: - _success(f"Removed {branch}") + for branch, ok, remove_err in results: + if ok: + success(f"Removed {branch}") else: - _warn(f"Failed to remove {branch}: {error}") + warn(f"Failed to remove {branch}: {remove_err}") def _clean_no_commits_worktrees( @@ -1211,12 +1211,12 @@ def _clean_no_commits_worktrees( """Remove worktrees with no commits ahead of the default branch.""" from . import cleanup # noqa: PLC0415 - _info("Checking for worktrees with no commits...") + info("Checking for worktrees with no commits...") to_remove = cleanup.find_worktrees_with_no_commits(repo_root) if not to_remove: - _info("No worktrees with zero commits found") + info("No worktrees with zero commits found") return default_branch = worktree.get_default_branch(repo_root) @@ -1227,14 +1227,14 @@ def _clean_no_commits_worktrees( console.print(f" • {wt.branch} ({wt.path})") if dry_run: - _info("[dry-run] Would remove the above worktrees") + info("[dry-run] Would remove the above worktrees") elif yes or typer.confirm("\nRemove these worktrees?"): results = cleanup.remove_worktrees(to_remove, repo_root, force=force) - for branch, success, error in results: - if success: - _success(f"Removed {branch}") + for branch, ok, remove_err in results: + if ok: + success(f"Removed {branch}") else: - _warn(f"Failed to remove {branch}: {error}") + warn(f"Failed to remove {branch}: {remove_err}") @app.command("clean") @@ -1295,7 +1295,7 @@ def clean( repo_root = _ensure_git_repo() # Run git worktree prune - _info("Pruning stale worktree references...") + info("Pruning stale worktree references...") result = subprocess.run( ["git", "worktree", "prune"], # noqa: S607 cwd=repo_root, @@ -1304,9 +1304,9 @@ def clean( check=False, ) if result.returncode == 0: - _success("Pruned stale worktree administrative files") + success("Pruned stale worktree administrative files") else: - _warn(f"Prune failed: {result.stderr}") + warn(f"Prune failed: {result.stderr}") # Find and remove empty directories in worktrees base dir base_dir = worktree.resolve_worktree_base_dir(repo_root) @@ -1315,13 +1315,13 @@ def clean( for item in base_dir.iterdir(): if item.is_dir() and not any(item.iterdir()): if dry_run: - _info(f"[dry-run] Would remove empty directory: {item.name}") + info(f"[dry-run] Would remove empty directory: {item.name}") else: item.rmdir() - _info(f"Removed empty directory: {item.name}") + info(f"Removed empty directory: {item.name}") cleaned += 1 if cleaned > 0: - _success(f"Cleaned {cleaned} empty director{'y' if cleaned == 1 else 'ies'}") + success(f"Cleaned {cleaned} empty director{'y' if cleaned == 1 else 'ies'}") # --merged mode: remove worktrees with merged PRs if merged: @@ -1460,18 +1460,18 @@ def install_skill( # Use current repo root (works in worktrees too) repo_root = _get_current_repo_root() if repo_root is None: - _error("Not in a git repository") + error("Not in a git repository") skill_source = _get_skill_source_dir() skill_dest = repo_root / ".claude" / "skills" / "agent-cli-dev" # Check if skill source exists if not skill_source.exists(): - _error(f"Skill source not found: {skill_source}") + error(f"Skill source not found: {skill_source}") # Check if already installed if skill_dest.exists() and not force: - _warn(f"Skill already installed at {skill_dest}") + warn(f"Skill already installed at {skill_dest}") console.print("[dim]Use --force to overwrite[/dim]") raise typer.Exit(0) @@ -1484,7 +1484,7 @@ def install_skill( shutil.copytree(skill_source, skill_dest) - _success(f"Installed skill to {skill_dest}") + success(f"Installed skill to {skill_dest}") console.print() console.print("[bold]What's next?[/bold]") console.print(" • Claude Code will automatically use this skill when relevant") diff --git a/agent_cli/dev/launch.py b/agent_cli/dev/launch.py index 82f4e1ef..69d896e3 100644 --- a/agent_cli/dev/launch.py +++ b/agent_cli/dev/launch.py @@ -29,9 +29,9 @@ def resolve_editor( if editor_name: editor = editors.get_editor(editor_name) if editor is None: - from ._output import _warn # noqa: PLC0415 + from ._output import warn # noqa: PLC0415 - _warn(f"Editor '{editor_name}' not found") + warn(f"Editor '{editor_name}' not found") return editor # If no flag and no default, don't use an editor @@ -43,9 +43,9 @@ def resolve_editor( editor = editors.get_editor(default_editor) if editor is not None: return editor - from ._output import _warn # noqa: PLC0415 + from ._output import warn # noqa: PLC0415 - _warn(f"Default editor '{default_editor}' from config not found") + warn(f"Default editor '{default_editor}' from config not found") # Auto-detect current or first available editor = editors.detect_current_editor() @@ -65,9 +65,9 @@ def resolve_agent( if agent_name: agent = coding_agents.get_agent(agent_name) if agent is None: - from ._output import _warn # noqa: PLC0415 + from ._output import warn # noqa: PLC0415 - _warn(f"Agent '{agent_name}' not found") + warn(f"Agent '{agent_name}' not found") return agent # If no flag and no default, don't use an agent @@ -79,9 +79,9 @@ def resolve_agent( agent = coding_agents.get_agent(default_agent) if agent is not None: return agent - from ._output import _warn # noqa: PLC0415 + from ._output import warn # noqa: PLC0415 - _warn(f"Default agent '{default_agent}' from config not found") + warn(f"Default agent '{default_agent}' from config not found") # Auto-detect current or first available agent = coding_agents.detect_current_agent() @@ -187,13 +187,13 @@ def _is_ssh_session() -> bool: def launch_editor(path: Path, editor: Editor) -> None: """Launch editor via subprocess (editors are GUI apps that detach).""" - from ._output import _success, _warn # noqa: PLC0415 + from ._output import success, warn # noqa: PLC0415 try: subprocess.Popen(editor.open_command(path)) - _success(f"Opened {editor.name}") + success(f"Opened {editor.name}") except Exception as e: - _warn(f"Could not open editor: {e}") + warn(f"Could not open editor: {e}") def write_prompt_to_worktree(worktree_path: Path, prompt: str) -> Path: @@ -278,7 +278,7 @@ def launch_agent( Returns the tracked agent name if tracking was successful, else ``None``. """ - from ._output import _success, _warn # noqa: PLC0415 + from ._output import success, warn # noqa: PLC0415 from .terminals.tmux import Tmux # noqa: PLC0415 terminal = terminals.detect_current_terminal() @@ -311,14 +311,14 @@ def launch_agent( name = agent_state.generate_agent_name(root, path, agent.name, agent_name) agent_state.register_agent(root, name, pane_id, path, agent.name) agent_state.inject_completion_hook(path, agent.name) - _success(f"Started {agent.name} in new tmux tab (tracking as [cyan]{name}[/cyan])") + success(f"Started {agent.name} in new tmux tab (tracking as [cyan]{name}[/cyan])") return name - _warn("Could not open new tmux window") + warn("Could not open new tmux window") elif terminal.open_new_tab(path, full_cmd, tab_name=tab_name): - _success(f"Started {agent.name} in new {terminal.name} tab") + success(f"Started {agent.name} in new {terminal.name} tab") return None else: - _warn(f"Could not open new tab in {terminal.name}") + warn(f"Could not open new tab in {terminal.name}") # No terminal detected or failed - print instructions if _is_ssh_session(): diff --git a/agent_cli/dev/orchestration.py b/agent_cli/dev/orchestration.py index d001af10..91f16f8a 100644 --- a/agent_cli/dev/orchestration.py +++ b/agent_cli/dev/orchestration.py @@ -12,7 +12,7 @@ from agent_cli.core.utils import console from . import worktree -from ._output import _error, _info, _success, _warn +from ._output import error, info, success, warn from .cli import app if TYPE_CHECKING: @@ -22,11 +22,11 @@ def _ensure_git_repo() -> Path: """Ensure we're in a git repository and return the repo root.""" if not worktree.git_available(): - _error("Git is not installed or not in PATH") + error("Git is not installed or not in PATH") repo_root = worktree.get_main_repo_root() if repo_root is None: - _error("Not in a git repository") + error("Not in a git repository") return repo_root @@ -36,7 +36,7 @@ def _ensure_tmux() -> None: from . import agent_state as _agent_state # noqa: PLC0415 if not _agent_state.is_tmux(): - _error("Agent tracking requires tmux. Start a tmux session first.") + error("Agent tracking requires tmux. Start a tmux session first.") def _lookup_agent(name: str) -> tuple[Path, agent_state.TrackedAgent]: @@ -47,7 +47,7 @@ def _lookup_agent(name: str) -> tuple[Path, agent_state.TrackedAgent]: state = _agent_state.load_state(repo_root) agent = state.agents.get(name) if agent is None: - _error(f"Agent '{name}' not found. Run 'dev poll' to see tracked agents.") + error(f"Agent '{name}' not found. Run 'dev poll' to see tracked agents.") return repo_root, agent @@ -107,7 +107,7 @@ def poll_cmd( state = _agent_state.load_state(repo_root) if not state.agents: - _info("No tracked agents. Launch one with 'dev new -a' or 'dev agent --tab'.") + info("No tracked agents. Launch one with 'dev new -a' or 'dev agent --tab'.") return poll_once(repo_root) @@ -201,12 +201,12 @@ def output_cmd( _repo_root, agent = _lookup_agent(name) if agent.status == "dead": - _error(f"Agent '{name}' is dead (tmux pane closed). No output available.") + error(f"Agent '{name}' is dead (tmux pane closed). No output available.") if not follow: output = tmux_ops.capture_pane(agent.pane_id, lines) if output is None: - _error(f"Could not capture output from pane {agent.pane_id}") + error(f"Could not capture output from pane {agent.pane_id}") print(output, end="") return @@ -216,7 +216,7 @@ def output_cmd( while True: output = tmux_ops.capture_pane(agent.pane_id, lines) if output is None: - _warn("Pane closed.") + warn("Pane closed.") break if output != prev: print(output, end="", flush=True) @@ -257,12 +257,12 @@ def send_cmd( _repo_root, agent = _lookup_agent(name) if agent.status == "dead": - _error(f"Agent '{name}' is dead (tmux pane closed). Cannot send messages.") + error(f"Agent '{name}' is dead (tmux pane closed). Cannot send messages.") if tmux_ops.send_keys(agent.pane_id, message, enter=not no_enter): - _success(f"Sent message to {name}") + success(f"Sent message to {name}") else: - _error(f"Failed to send message to pane {agent.pane_id}") + error(f"Failed to send message to pane {agent.pane_id}") @app.command("wait") @@ -307,17 +307,17 @@ def wait_cmd( raise typer.Exit(0 if agent.status != "dead" else 1) repo_root = _ensure_git_repo() - _info(f"Waiting for agent '{name}' to finish (polling every {interval}s)...") + info(f"Waiting for agent '{name}' to finish (polling every {interval}s)...") try: status, elapsed = wait_for_agent(repo_root, name, timeout=timeout, interval=interval) except TimeoutError: - _warn(f"Timeout after {_format_duration(timeout)}") + warn(f"Timeout after {_format_duration(timeout)}") raise typer.Exit(2) from None if status == "dead": - _warn(f"Agent '{name}' died (pane closed) after {_format_duration(elapsed)}") + warn(f"Agent '{name}' died (pane closed) after {_format_duration(elapsed)}") raise typer.Exit(1) - _success(f"Agent '{name}' is {status} after {_format_duration(elapsed)}") + success(f"Agent '{name}' is {status} after {_format_duration(elapsed)}") raise typer.Exit(0) From 8f8179e0b0d7c428a7fa51da2ea6c195b7013d17 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 9 Feb 2026 20:24:09 -0800 Subject: [PATCH 08/20] fix(dev): address PR review issues in orchestration code - Change `track` default to False in launch_agent to prevent dev new from silently attempting tmux tracking - Fix follow mode in dev output to show only new lines instead of reprinting the entire buffer - Extract shared _check_agent_status helper to deduplicate polling logic between poll_once and wait_for_agent - Remove dead daemon code (start_poller, run_poller_daemon, stop_poller, is_poller_running, __main__ block) - Use repo_root from _lookup_agent in wait_cmd instead of calling _ensure_git_repo twice --- agent_cli/dev/launch.py | 2 +- agent_cli/dev/orchestration.py | 21 ++-- agent_cli/dev/poller.py | 177 +++++++-------------------------- 3 files changed, 50 insertions(+), 150 deletions(-) diff --git a/agent_cli/dev/launch.py b/agent_cli/dev/launch.py index 8b67675a..0a791985 100644 --- a/agent_cli/dev/launch.py +++ b/agent_cli/dev/launch.py @@ -255,7 +255,7 @@ def launch_agent( task_file: Path | None = None, env: dict[str, str] | None = None, *, - track: bool = True, + track: bool = False, agent_name: str | None = None, ) -> str | None: """Launch agent in a new terminal tab. diff --git a/agent_cli/dev/orchestration.py b/agent_cli/dev/orchestration.py index 91f16f8a..c556b87c 100644 --- a/agent_cli/dev/orchestration.py +++ b/agent_cli/dev/orchestration.py @@ -210,17 +210,25 @@ def output_cmd( print(output, end="") return - # Follow mode + # Follow mode: only print new lines that appear since last capture try: - prev = "" + prev_lines: list[str] = [] while True: output = tmux_ops.capture_pane(agent.pane_id, lines) if output is None: warn("Pane closed.") break - if output != prev: - print(output, end="", flush=True) - prev = output + cur_lines = output.splitlines(keepends=True) + if cur_lines != prev_lines: + # Find first diverging line + common = 0 + limit = min(len(prev_lines), len(cur_lines)) + while common < limit and prev_lines[common] == cur_lines[common]: + common += 1 + new_content = cur_lines[common:] + if new_content: + print("".join(new_content), end="", flush=True) + prev_lines = cur_lines time.sleep(1.0) except KeyboardInterrupt: pass @@ -300,13 +308,12 @@ def wait_cmd( from .poller import wait_for_agent # noqa: PLC0415 _ensure_tmux() - _repo_root, agent = _lookup_agent(name) + repo_root, agent = _lookup_agent(name) if agent.status in ("done", "dead", "idle"): console.print(f"Agent '{name}' is already {_status_style(agent.status)}") raise typer.Exit(0 if agent.status != "dead" else 1) - repo_root = _ensure_git_repo() info(f"Waiting for agent '{name}' to finish (polling every {interval}s)...") try: diff --git a/agent_cli/dev/poller.py b/agent_cli/dev/poller.py index 97181838..afa724ee 100644 --- a/agent_cli/dev/poller.py +++ b/agent_cli/dev/poller.py @@ -1,17 +1,35 @@ -"""Background poller daemon for agent orchestration.""" +"""Polling logic for agent orchestration.""" from __future__ import annotations -import os -import signal -import subprocess -import sys import time from pathlib import Path from . import agent_state, tmux_ops +def _check_agent_status(agent: agent_state.TrackedAgent, now: float) -> None: + """Check and update a single agent's status in-place.""" + if not tmux_ops.pane_exists(agent.pane_id): + agent.status = "dead" + return + + done_path = Path(agent.worktree_path) / ".claude" / "DONE" + if done_path.exists(): + agent.status = "done" + return + + output = tmux_ops.capture_pane(agent.pane_id) + if output is not None: + h = tmux_ops.hash_output(output) + if h != agent.last_output_hash: + agent.last_output_hash = h + agent.last_change_at = now + agent.status = "running" + else: + agent.status = "idle" + + def poll_once(repo_root: Path) -> dict[str, str]: """Perform a single poll of all tracked agents. @@ -20,107 +38,13 @@ def poll_once(repo_root: Path) -> dict[str, str]: """ state = agent_state.load_state(repo_root) now = time.time() - result: dict[str, str] = {} for agent in state.agents.values(): - if not tmux_ops.pane_exists(agent.pane_id): - agent.status = "dead" - result[agent.name] = "dead" - continue - - done_path = Path(agent.worktree_path) / ".claude" / "DONE" - if done_path.exists(): - agent.status = "done" - result[agent.name] = "done" - continue - - output = tmux_ops.capture_pane(agent.pane_id) - if output is not None: - h = tmux_ops.hash_output(output) - if h != agent.last_output_hash: - agent.last_output_hash = h - agent.last_change_at = now - agent.status = "running" - else: - agent.status = "idle" - result[agent.name] = agent.status + _check_agent_status(agent, now) state.last_poll_at = now agent_state.save_state(repo_root, state) - return result - - -def _pid_file_path(repo_root: Path) -> Path: - """Return the PID file path for the poller daemon.""" - return agent_state._state_dir(repo_root) / "poller.pid" - - -def is_poller_running(repo_root: Path) -> bool: - """Check if the poller daemon is running.""" - pid_file = _pid_file_path(repo_root) - if not pid_file.exists(): - return False - try: - pid = int(pid_file.read_text().strip()) - os.kill(pid, 0) - return True - except (ValueError, ProcessLookupError, PermissionError): - pid_file.unlink(missing_ok=True) - return False - - -def run_poller_daemon(repo_root: Path, interval: float = 5.0) -> None: - """Run the poller loop (called inside the daemon process). - - Handles SIGTERM/SIGINT for graceful shutdown. - Auto-stops when all agents are done or dead. - """ - running = True - - def _handle_signal(_signum: int, _frame: object) -> None: - nonlocal running - running = False - - signal.signal(signal.SIGTERM, _handle_signal) - signal.signal(signal.SIGINT, _handle_signal) - - pid_file = _pid_file_path(repo_root) - pid_file.parent.mkdir(parents=True, exist_ok=True) - pid_file.write_text(str(os.getpid())) - - try: - while running: - statuses = poll_once(repo_root) - # Auto-stop if no agents are running or idle - if statuses and all(s in ("done", "dead") for s in statuses.values()): - break - time.sleep(interval) - finally: - pid_file.unlink(missing_ok=True) - - -def start_poller(repo_root: Path, interval: float = 5.0) -> int | None: - """Start the background poller as a detached subprocess. - - Returns the PID of the poller process, or ``None`` on failure. - """ - if is_poller_running(repo_root): - return None - - cmd = [ - sys.executable, - "-m", - "agent_cli.dev.poller", - str(repo_root), - str(interval), - ] - proc = subprocess.Popen( - cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, - ) - return proc.pid + return {a.name: a.status for a in state.agents.values()} def wait_for_agent( @@ -150,24 +74,16 @@ def wait_for_agent( msg = f"Timeout after {elapsed:.0f}s" raise TimeoutError(msg) - if not tmux_ops.pane_exists(agent.pane_id): - _update_agent_status(repo_root, name, "dead") - return "dead", elapsed - - done_path = Path(agent.worktree_path) / ".claude" / "DONE" - if done_path.exists(): - _update_agent_status(repo_root, name, "done") - return "done", elapsed - - output = tmux_ops.capture_pane(agent.pane_id) - if output is not None: - h = tmux_ops.hash_output(output) - if h == agent.last_output_hash: - consecutive_idle += 1 - else: - consecutive_idle = 0 - agent.last_output_hash = h - agent.last_change_at = time.time() + _check_agent_status(agent, time.time()) + + if agent.status in ("dead", "done"): + _update_agent_status(repo_root, name, agent.status) + return agent.status, elapsed + + if agent.status == "idle": + consecutive_idle += 1 + else: + consecutive_idle = 0 # Require 2 consecutive idle polls to confirm if consecutive_idle >= 2: # noqa: PLR2004 @@ -183,26 +99,3 @@ def _update_agent_status(repo_root: Path, name: str, status: agent_state.AgentSt if name in state.agents: state.agents[name].status = status agent_state.save_state(repo_root, state) - - -def stop_poller(repo_root: Path) -> bool: - """Stop the background poller by sending SIGTERM.""" - pid_file = _pid_file_path(repo_root) - if not pid_file.exists(): - return False - try: - pid = int(pid_file.read_text().strip()) - os.kill(pid, signal.SIGTERM) - pid_file.unlink(missing_ok=True) - return True - except (ValueError, ProcessLookupError, PermissionError): - pid_file.unlink(missing_ok=True) - return False - - -if __name__ == "__main__": - # Entry point for the daemon subprocess - if len(sys.argv) >= 2: # noqa: PLR2004 - _repo_root = Path(sys.argv[1]) - _interval = float(sys.argv[2]) if len(sys.argv) >= 3 else 5.0 # noqa: PLR2004 - run_poller_daemon(_repo_root, _interval) From b0f24c567ca2dc34dca6d979c8b4ebcd9aeaf091 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 9 Feb 2026 20:26:07 -0800 Subject: [PATCH 09/20] refactor(dev): remove --follow flag from dev output Just switch to the tmux pane instead. --- agent_cli/dev/orchestration.py | 38 ++++------------------------------ 1 file changed, 4 insertions(+), 34 deletions(-) diff --git a/agent_cli/dev/orchestration.py b/agent_cli/dev/orchestration.py index c556b87c..ec41cce4 100644 --- a/agent_cli/dev/orchestration.py +++ b/agent_cli/dev/orchestration.py @@ -180,10 +180,6 @@ def output_cmd( int, typer.Option("--lines", "-n", help="Number of lines to capture"), ] = 50, - follow: Annotated[ - bool, - typer.Option("--follow", "-f", help="Continuously stream output (Ctrl+C to stop)"), - ] = False, ) -> None: """Get recent terminal output from a tracked agent. @@ -193,7 +189,6 @@ def output_cmd( - `dev output my-feature` — Last 50 lines - `dev output my-feature -n 200` — Last 200 lines - - `dev output my-feature -f` — Follow output continuously """ from . import tmux_ops # noqa: PLC0415 @@ -203,35 +198,10 @@ def output_cmd( if agent.status == "dead": error(f"Agent '{name}' is dead (tmux pane closed). No output available.") - if not follow: - output = tmux_ops.capture_pane(agent.pane_id, lines) - if output is None: - error(f"Could not capture output from pane {agent.pane_id}") - print(output, end="") - return - - # Follow mode: only print new lines that appear since last capture - try: - prev_lines: list[str] = [] - while True: - output = tmux_ops.capture_pane(agent.pane_id, lines) - if output is None: - warn("Pane closed.") - break - cur_lines = output.splitlines(keepends=True) - if cur_lines != prev_lines: - # Find first diverging line - common = 0 - limit = min(len(prev_lines), len(cur_lines)) - while common < limit and prev_lines[common] == cur_lines[common]: - common += 1 - new_content = cur_lines[common:] - if new_content: - print("".join(new_content), end="", flush=True) - prev_lines = cur_lines - time.sleep(1.0) - except KeyboardInterrupt: - pass + output = tmux_ops.capture_pane(agent.pane_id, lines) + if output is None: + error(f"Could not capture output from pane {agent.pane_id}") + print(output, end="") @app.command("send") From 372e8b81cb542208db21c56e3f1c74e056bbb032 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 9 Feb 2026 20:29:48 -0800 Subject: [PATCH 10/20] feat(dev): group dev subcommands with rich_help_panel Organizes the dev help output into logical sections: Environments, Launch, Info, Setup, Agent Orchestration. --- agent_cli/dev/cli.py | 28 ++++++++++++++-------------- agent_cli/dev/orchestration.py | 8 ++++---- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/agent_cli/dev/cli.py b/agent_cli/dev/cli.py index ddfffc25..0f554332 100644 --- a/agent_cli/dev/cli.py +++ b/agent_cli/dev/cli.py @@ -209,7 +209,7 @@ def _setup_worktree_env( warn("direnv not installed, skipping .envrc setup") -@app.command("new") +@app.command("new", rich_help_panel="Environments") def new( branch: Annotated[ str | None, @@ -452,7 +452,7 @@ def new( console.print(f'[dim]To enter the worktree:[/dim] cd "$(ag dev path {branch})"') -@app.command("list") +@app.command("list", rich_help_panel="Environments") def list_envs( json_output: Annotated[ bool, @@ -562,7 +562,7 @@ def _is_stale(status: worktree.WorktreeStatus, stale_days: int) -> bool: return days_since >= stale_days -@app.command("status") +@app.command("status", rich_help_panel="Environments") def status_cmd( # noqa: PLR0915 stale_days: Annotated[ int, @@ -673,7 +673,7 @@ def status_cmd( # noqa: PLR0915 console.print("\n" + " · ".join(summary_parts)) -@app.command("rm") +@app.command("rm", rich_help_panel="Environments") def remove( name: Annotated[ str, @@ -737,7 +737,7 @@ def remove( error(remove_err or "Failed to remove worktree") -@app.command("path") +@app.command("path", rich_help_panel="Environments") def path_cmd( name: Annotated[ str, @@ -759,7 +759,7 @@ def path_cmd( print(wt.path.as_posix()) -@app.command("editor") +@app.command("editor", rich_help_panel="Launch") def open_editor( name: Annotated[ str, @@ -807,7 +807,7 @@ def open_editor( error(f"Failed to open editor: {e}") -@app.command("agent") +@app.command("agent", rich_help_panel="Launch") def start_agent( name: Annotated[ str, @@ -944,7 +944,7 @@ def start_agent( error(f"Failed to start agent: {e}") -@app.command("agents") +@app.command("agents", rich_help_panel="Info") def list_agents( json_output: Annotated[ bool, @@ -996,7 +996,7 @@ def list_agents( console.print(table) -@app.command("editors") +@app.command("editors", rich_help_panel="Info") def list_editors_cmd( json_output: Annotated[ bool, @@ -1048,7 +1048,7 @@ def list_editors_cmd( console.print(table) -@app.command("terminals") +@app.command("terminals", rich_help_panel="Info") def list_terminals_cmd( json_output: Annotated[ bool, @@ -1123,7 +1123,7 @@ def _doctor_check_git() -> None: console.print(" [yellow]○[/yellow] Not in a git repository") -@app.command("run") +@app.command("run", rich_help_panel="Launch") def run_cmd( name: Annotated[ str, @@ -1233,7 +1233,7 @@ def _clean_no_commits_worktrees( warn(f"Failed to remove {branch}: {remove_err}") -@app.command("clean") +@app.command("clean", rich_help_panel="Environments") def clean( merged: Annotated[ bool, @@ -1328,7 +1328,7 @@ def clean( _clean_no_commits_worktrees(repo_root, dry_run, yes, force=force) -@app.command("doctor") +@app.command("doctor", rich_help_panel="Info") def doctor( json_output: Annotated[ bool, @@ -1430,7 +1430,7 @@ def _get_current_repo_root() -> Path | None: return None -@app.command("install-skill") +@app.command("install-skill", rich_help_panel="Setup") def install_skill( force: Annotated[ bool, diff --git a/agent_cli/dev/orchestration.py b/agent_cli/dev/orchestration.py index ec41cce4..8898510d 100644 --- a/agent_cli/dev/orchestration.py +++ b/agent_cli/dev/orchestration.py @@ -75,7 +75,7 @@ def _status_style(status: str) -> str: return styles.get(status, status) -@app.command("poll") +@app.command("poll", rich_help_panel="Agent Orchestration") def poll_cmd( json_output: Annotated[ bool, @@ -170,7 +170,7 @@ def poll_cmd( console.print(f"\n[dim]{' · '.join(parts)}[/dim]") -@app.command("output") +@app.command("output", rich_help_panel="Agent Orchestration") def output_cmd( name: Annotated[ str, @@ -204,7 +204,7 @@ def output_cmd( print(output, end="") -@app.command("send") +@app.command("send", rich_help_panel="Agent Orchestration") def send_cmd( name: Annotated[ str, @@ -243,7 +243,7 @@ def send_cmd( error(f"Failed to send message to pane {agent.pane_id}") -@app.command("wait") +@app.command("wait", rich_help_panel="Agent Orchestration") def wait_cmd( name: Annotated[ str, From 9c389fe0fba38004a79928f34f98fee129dc9d93 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 9 Feb 2026 20:39:31 -0800 Subject: [PATCH 11/20] fix(dev): harden orchestration tracking edge cases --- agent_cli/dev/agent_state.py | 66 ++++++++++--- agent_cli/dev/launch.py | 14 ++- agent_cli/dev/poller.py | 9 +- tests/dev/test_orchestration.py | 158 +++++++++++++++++++++++++++++++- 4 files changed, 227 insertions(+), 20 deletions(-) diff --git a/agent_cli/dev/agent_state.py b/agent_cli/dev/agent_state.py index 8f6a1ebb..dacb2a51 100644 --- a/agent_cli/dev/agent_state.py +++ b/agent_cli/dev/agent_state.py @@ -6,7 +6,9 @@ import os import re import time +from contextlib import suppress from dataclasses import asdict, dataclass, field +from hashlib import sha256 from pathlib import Path from typing import Literal @@ -39,7 +41,18 @@ class AgentStateFile: def _repo_slug(repo_root: Path) -> str: - """Convert a repo root path to a filesystem-safe slug.""" + """Convert a repo root path to a filesystem-safe slug. + + Includes a short path hash to avoid collisions between repositories with + the same trailing directory names. + """ + slug = _legacy_repo_slug(repo_root) + digest = sha256(str(repo_root.expanduser().resolve()).encode()).hexdigest()[:10] + return f"{slug}_{digest}" + + +def _legacy_repo_slug(repo_root: Path) -> str: + """Legacy slug format used before path hashing.""" # Use the last two path components for readability, e.g. "Work_my-project" parts = repo_root.parts[-2:] slug = "_".join(parts) @@ -57,15 +70,15 @@ def _state_file_path(repo_root: Path) -> Path: return _state_dir(repo_root) / "agents.json" -def load_state(repo_root: Path) -> AgentStateFile: - """Load agent state from disk. +def _legacy_state_file_path(repo_root: Path) -> Path: + """Return the pre-hash legacy path for agents.json.""" + return STATE_BASE / _legacy_repo_slug(repo_root) / "agents.json" - Returns an empty state if the file does not exist or is corrupt. - """ - path = _state_file_path(repo_root) - if not path.exists(): - return AgentStateFile(repo_root=str(repo_root)) +def _load_state_from_path(path: Path, repo_root: Path) -> AgentStateFile | None: + """Load state from one path, returning ``None`` when unavailable.""" + if not path.exists(): + return None try: data = json.loads(path.read_text()) agents = {} @@ -76,8 +89,27 @@ def load_state(repo_root: Path) -> AgentStateFile: agents=agents, last_poll_at=data.get("last_poll_at", 0.0), ) - except (json.JSONDecodeError, TypeError, KeyError): - return AgentStateFile(repo_root=str(repo_root)) + except (OSError, json.JSONDecodeError, TypeError, KeyError): + return None + + +def load_state(repo_root: Path) -> AgentStateFile: + """Load agent state from disk. + + Returns an empty state if the file does not exist or is corrupt. + """ + state = _load_state_from_path(_state_file_path(repo_root), repo_root) + if state is not None: + return state + + # Backward compatibility with pre-hash state path. + legacy_state = _load_state_from_path(_legacy_state_file_path(repo_root), repo_root) + if legacy_state is not None: + with suppress(OSError): + save_state(repo_root, legacy_state) + return legacy_state + + return AgentStateFile(repo_root=str(repo_root)) def save_state(repo_root: Path, state: AgentStateFile) -> None: @@ -104,6 +136,11 @@ def register_agent( ) -> TrackedAgent: """Register a new tracked agent in the state file.""" state = load_state(repo_root) + # Keep only active agents; terminal entries should not reserve names forever. + for existing_name in list(state.agents): + if state.agents[existing_name].status in ("done", "dead"): + del state.agents[existing_name] + now = time.time() agent = TrackedAgent( name=name, @@ -139,11 +176,16 @@ def generate_agent_name( ) -> str: """Generate a unique agent name. - If *explicit_name* is given, uses that (raises if it collides). + If *explicit_name* is given, uses that (raises if it collides with an active + agent). Otherwise auto-generates from the worktree branch name. """ state = load_state(repo_root) - existing = set(state.agents.keys()) + existing = { + name + for name, existing_agent in state.agents.items() + if existing_agent.status in ("running", "idle") + } if explicit_name: if explicit_name in existing: diff --git a/agent_cli/dev/launch.py b/agent_cli/dev/launch.py index 0a791985..e5e7413e 100644 --- a/agent_cli/dev/launch.py +++ b/agent_cli/dev/launch.py @@ -13,7 +13,7 @@ from agent_cli.core.utils import console from . import coding_agents, editors, terminals, worktree -from ._output import success, warn +from ._output import error, success, warn if TYPE_CHECKING: from .coding_agents.base import CodingAgent @@ -295,10 +295,18 @@ def launch_agent( if isinstance(terminal, Tmux) and track: from . import agent_state, tmux_ops # noqa: PLC0415 + root = repo_root or path + try: + name = agent_state.generate_agent_name(root, path, agent.name, agent_name) + except ValueError as exc: + error(str(exc)) + + # Remove stale completion sentinel before launching a new tracked Claude run. + if agent.name == "claude": + (path / ".claude" / "DONE").unlink(missing_ok=True) + pane_id = tmux_ops.open_window_with_pane_id(path, full_cmd, tab_name=tab_name) if pane_id: - root = repo_root or path - name = agent_state.generate_agent_name(root, path, agent.name, agent_name) agent_state.register_agent(root, name, pane_id, path, agent.name) agent_state.inject_completion_hook(path, agent.name) success(f"Started {agent.name} in new tmux tab (tracking as [cyan]{name}[/cyan])") diff --git a/agent_cli/dev/poller.py b/agent_cli/dev/poller.py index afa724ee..83a8a555 100644 --- a/agent_cli/dev/poller.py +++ b/agent_cli/dev/poller.py @@ -14,10 +14,11 @@ def _check_agent_status(agent: agent_state.TrackedAgent, now: float) -> None: agent.status = "dead" return - done_path = Path(agent.worktree_path) / ".claude" / "DONE" - if done_path.exists(): - agent.status = "done" - return + if agent.agent_type == "claude": + done_path = Path(agent.worktree_path) / ".claude" / "DONE" + if done_path.exists(): + agent.status = "done" + return output = tmux_ops.capture_pane(agent.pane_id) if output is not None: diff --git a/tests/dev/test_orchestration.py b/tests/dev/test_orchestration.py index e647ce92..3ce21d45 100644 --- a/tests/dev/test_orchestration.py +++ b/tests/dev/test_orchestration.py @@ -9,11 +9,13 @@ from unittest.mock import MagicMock, patch import pytest +import typer from typer.testing import CliRunner from agent_cli.cli import app -from agent_cli.dev import agent_state, tmux_ops +from agent_cli.dev import agent_state, launch, poller, tmux_ops from agent_cli.dev.agent_state import inject_completion_hook +from agent_cli.dev.terminals.tmux import Tmux runner = CliRunner(env={"NO_COLOR": "1", "TERM": "dumb"}) @@ -133,6 +135,12 @@ def test_repo_slug(self) -> None: assert "my-project" in slug assert "/" not in slug + def test_repo_slug_avoids_cross_clone_collisions(self) -> None: + """Distinct roots with same tail produce different slugs.""" + slug1 = agent_state._repo_slug(Path("/Users/alice/Work/my-project")) + slug2 = agent_state._repo_slug(Path("/Volumes/external/Work/my-project")) + assert slug1 != slug2 + def test_load_empty_state(self, tmp_path: Path) -> None: """Returns empty state when no file exists.""" with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): @@ -140,6 +148,36 @@ def test_load_empty_state(self, tmp_path: Path) -> None: assert state.agents == {} assert state.last_poll_at == 0.0 + def test_load_legacy_state_file(self, tmp_path: Path) -> None: + """Loads state from legacy non-hashed slug path.""" + with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): + repo = tmp_path / "repo" + legacy_path = agent_state._legacy_state_file_path(repo) + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text( + json.dumps( + { + "repo_root": str(repo), + "agents": { + "legacy-agent": { + "name": "legacy-agent", + "pane_id": "%42", + "worktree_path": str(tmp_path / "wt"), + "agent_type": "claude", + "started_at": 123.0, + "status": "running", + "last_output_hash": "", + "last_change_at": 123.0, + }, + }, + "last_poll_at": 0.0, + }, + ), + ) + + state = agent_state.load_state(repo) + assert "legacy-agent" in state.agents + def test_save_and_load_state(self, tmp_path: Path) -> None: """Round-trips state through JSON.""" with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): @@ -219,6 +257,24 @@ def test_generate_agent_name_explicit_collision(self, tmp_path: Path) -> None: explicit_name="reviewer", ) + def test_generate_agent_name_allows_reuse_after_done(self, tmp_path: Path) -> None: + """Explicit names can be reused when prior run is terminal.""" + with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): + repo = tmp_path / "repo" + agent_state.register_agent(repo, "reviewer", "%1", tmp_path / "auth", "claude") + + state = agent_state.load_state(repo) + state.agents["reviewer"].status = "done" + agent_state.save_state(repo, state) + + name = agent_state.generate_agent_name( + repo, + tmp_path / "auth", + "claude", + explicit_name="reviewer", + ) + assert name == "reviewer" + def test_load_corrupt_state(self, tmp_path: Path) -> None: """Returns empty state on corrupt JSON.""" with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): @@ -229,6 +285,106 @@ def test_load_corrupt_state(self, tmp_path: Path) -> None: assert state.agents == {} +# --------------------------------------------------------------------------- +# launch/poller regression tests +# --------------------------------------------------------------------------- + + +class TestLaunchTracking: + """Tests for tracked launch edge cases.""" + + def test_tracked_launch_validates_name_before_opening_tmux_window(self, tmp_path: Path) -> None: + """Duplicate tracked name should fail without creating a tmux window.""" + agent = MagicMock() + agent.name = "claude" + agent.launch_command.return_value = ["claude"] + + with ( + patch("agent_cli.dev.launch.terminals.detect_current_terminal", return_value=Tmux()), + patch("agent_cli.dev.launch.worktree.get_main_repo_root", return_value=tmp_path), + patch("agent_cli.dev.launch.worktree.get_current_branch", return_value="feature"), + patch( + "agent_cli.dev.agent_state.generate_agent_name", + side_effect=ValueError("name exists"), + ), + patch("agent_cli.dev.tmux_ops.open_window_with_pane_id") as mock_open_window, + ): + with pytest.raises(typer.Exit) as exc: + launch.launch_agent(tmp_path, agent, track=True, agent_name="reviewer") + assert exc.value.exit_code == 1 + mock_open_window.assert_not_called() + + def test_tracked_claude_launch_clears_stale_done_file(self, tmp_path: Path) -> None: + """Tracked Claude launch removes stale completion sentinel before spawn.""" + done_path = tmp_path / ".claude" / "DONE" + done_path.parent.mkdir(parents=True, exist_ok=True) + done_path.write_text("stale\n") + + agent = MagicMock() + agent.name = "claude" + agent.launch_command.return_value = ["claude"] + + with ( + patch("agent_cli.dev.launch.terminals.detect_current_terminal", return_value=Tmux()), + patch("agent_cli.dev.launch.worktree.get_main_repo_root", return_value=tmp_path), + patch("agent_cli.dev.launch.worktree.get_current_branch", return_value="feature"), + patch("agent_cli.dev.agent_state.generate_agent_name", return_value="reviewer"), + patch("agent_cli.dev.tmux_ops.open_window_with_pane_id", return_value="%7"), + patch("agent_cli.dev.agent_state.register_agent"), + patch("agent_cli.dev.agent_state.inject_completion_hook"), + ): + result = launch.launch_agent(tmp_path, agent, track=True) + assert result == "reviewer" + assert not done_path.exists() + + +class TestPollerRegression: + """Regression tests for completion detection logic.""" + + def test_poll_ignores_done_sentinel_for_non_claude_agents(self, tmp_path: Path) -> None: + """Non-Claude agents should not be marked done by stale Claude sentinels.""" + with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): + repo = tmp_path / "repo" + wt = tmp_path / "worktree" + done_path = wt / ".claude" / "DONE" + done_path.parent.mkdir(parents=True, exist_ok=True) + done_path.write_text("stale\n") + + agent_state.register_agent(repo, "worker", "%3", wt, "codex") + + with ( + patch("agent_cli.dev.tmux_ops.pane_exists", return_value=True), + patch("agent_cli.dev.tmux_ops.capture_pane", return_value="output"), + patch("agent_cli.dev.tmux_ops.hash_output", return_value="h1"), + ): + statuses = poller.poll_once(repo) + assert statuses["worker"] == "running" + + def test_wait_ignores_done_sentinel_for_non_claude_agents(self, tmp_path: Path) -> None: + """wait_for_agent should use quiescence for non-Claude agents even if DONE exists.""" + with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): + repo = tmp_path / "repo" + wt = tmp_path / "worktree" + done_path = wt / ".claude" / "DONE" + done_path.parent.mkdir(parents=True, exist_ok=True) + done_path.write_text("stale\n") + + agent_state.register_agent(repo, "worker", "%3", wt, "codex") + + # Seed hash so repeated identical output transitions to idle. + state = agent_state.load_state(repo) + state.agents["worker"].last_output_hash = "h1" + agent_state.save_state(repo, state) + + with ( + patch("agent_cli.dev.tmux_ops.pane_exists", return_value=True), + patch("agent_cli.dev.tmux_ops.capture_pane", return_value="output"), + patch("agent_cli.dev.tmux_ops.hash_output", return_value="h1"), + ): + status, _elapsed = poller.wait_for_agent(repo, "worker", timeout=1, interval=0) + assert status == "idle" + + # --------------------------------------------------------------------------- # CLI command tests # --------------------------------------------------------------------------- From c2ff134c624ba5d4d61a0adcd12bf51e97ff8f46 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 9 Feb 2026 20:50:51 -0800 Subject: [PATCH 12/20] refactor(dev): simplify orchestration state model --- agent_cli/dev/agent_state.py | 90 +++++++++++++-------------------- agent_cli/dev/orchestration.py | 26 ++++++---- agent_cli/dev/poller.py | 45 ++++++++--------- tests/dev/test_orchestration.py | 46 ++++++++--------- 4 files changed, 94 insertions(+), 113 deletions(-) diff --git a/agent_cli/dev/agent_state.py b/agent_cli/dev/agent_state.py index dacb2a51..3cd30c03 100644 --- a/agent_cli/dev/agent_state.py +++ b/agent_cli/dev/agent_state.py @@ -6,7 +6,6 @@ import os import re import time -from contextlib import suppress from dataclasses import asdict, dataclass, field from hashlib import sha256 from pathlib import Path @@ -14,7 +13,7 @@ STATE_BASE = Path.home() / ".cache" / "agent-cli" -AgentStatus = Literal["running", "idle", "done", "dead"] +AgentStatus = Literal["running", "done", "dead"] @dataclass @@ -27,15 +26,12 @@ class TrackedAgent: agent_type: str started_at: float status: AgentStatus = "running" - last_output_hash: str = "" - last_change_at: float = 0.0 @dataclass class AgentStateFile: """State file for one repository's tracked agents.""" - repo_root: str agents: dict[str, TrackedAgent] = field(default_factory=dict) last_poll_at: float = 0.0 @@ -46,18 +42,11 @@ def _repo_slug(repo_root: Path) -> str: Includes a short path hash to avoid collisions between repositories with the same trailing directory names. """ - slug = _legacy_repo_slug(repo_root) - digest = sha256(str(repo_root.expanduser().resolve()).encode()).hexdigest()[:10] - return f"{slug}_{digest}" - - -def _legacy_repo_slug(repo_root: Path) -> str: - """Legacy slug format used before path hashing.""" - # Use the last two path components for readability, e.g. "Work_my-project" parts = repo_root.parts[-2:] slug = "_".join(parts) - # Sanitize: keep only alphanumeric, dash, underscore - return re.sub(r"[^a-zA-Z0-9_-]", "_", slug) + slug = re.sub(r"[^a-zA-Z0-9_-]", "_", slug) + digest = sha256(str(repo_root.expanduser().resolve()).encode()).hexdigest()[:10] + return f"{slug}_{digest}" def _state_dir(repo_root: Path) -> Path: @@ -70,46 +59,43 @@ def _state_file_path(repo_root: Path) -> Path: return _state_dir(repo_root) / "agents.json" -def _legacy_state_file_path(repo_root: Path) -> Path: - """Return the pre-hash legacy path for agents.json.""" - return STATE_BASE / _legacy_repo_slug(repo_root) / "agents.json" - - -def _load_state_from_path(path: Path, repo_root: Path) -> AgentStateFile | None: - """Load state from one path, returning ``None`` when unavailable.""" - if not path.exists(): - return None - try: - data = json.loads(path.read_text()) - agents = {} - for name, agent_data in data.get("agents", {}).items(): - agents[name] = TrackedAgent(**agent_data) - return AgentStateFile( - repo_root=data.get("repo_root", str(repo_root)), - agents=agents, - last_poll_at=data.get("last_poll_at", 0.0), - ) - except (OSError, json.JSONDecodeError, TypeError, KeyError): - return None - - def load_state(repo_root: Path) -> AgentStateFile: """Load agent state from disk. Returns an empty state if the file does not exist or is corrupt. """ - state = _load_state_from_path(_state_file_path(repo_root), repo_root) - if state is not None: - return state - - # Backward compatibility with pre-hash state path. - legacy_state = _load_state_from_path(_legacy_state_file_path(repo_root), repo_root) - if legacy_state is not None: - with suppress(OSError): - save_state(repo_root, legacy_state) - return legacy_state + path = _state_file_path(repo_root) + if not path.exists(): + return AgentStateFile() - return AgentStateFile(repo_root=str(repo_root)) + try: + data = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError, TypeError): + return AgentStateFile() + + agents: dict[str, TrackedAgent] = {} + for name, agent_data in data.get("agents", {}).items(): + status = agent_data.get("status", "running") + if status not in ("running", "done", "dead"): + status = "running" + try: + agents[name] = TrackedAgent( + name=str(agent_data["name"]), + pane_id=str(agent_data["pane_id"]), + worktree_path=str(agent_data["worktree_path"]), + agent_type=str(agent_data["agent_type"]), + started_at=float(agent_data["started_at"]), + status=status, + ) + except (KeyError, TypeError, ValueError): + continue + + raw_last_poll_at = data.get("last_poll_at", 0.0) + try: + last_poll_at = float(raw_last_poll_at) + except (TypeError, ValueError): + last_poll_at = 0.0 + return AgentStateFile(agents=agents, last_poll_at=last_poll_at) def save_state(repo_root: Path, state: AgentStateFile) -> None: @@ -117,7 +103,6 @@ def save_state(repo_root: Path, state: AgentStateFile) -> None: path = _state_file_path(repo_root) path.parent.mkdir(parents=True, exist_ok=True) data = { - "repo_root": state.repo_root, "agents": {name: asdict(agent) for name, agent in state.agents.items()}, "last_poll_at": state.last_poll_at, } @@ -148,7 +133,6 @@ def register_agent( worktree_path=str(worktree_path), agent_type=agent_type, started_at=now, - last_change_at=now, ) state.agents[name] = agent save_state(repo_root, state) @@ -182,9 +166,7 @@ def generate_agent_name( """ state = load_state(repo_root) existing = { - name - for name, existing_agent in state.agents.items() - if existing_agent.status in ("running", "idle") + name for name, existing_agent in state.agents.items() if existing_agent.status == "running" } if explicit_name: diff --git a/agent_cli/dev/orchestration.py b/agent_cli/dev/orchestration.py index 8898510d..5cf9243a 100644 --- a/agent_cli/dev/orchestration.py +++ b/agent_cli/dev/orchestration.py @@ -68,7 +68,6 @@ def _status_style(status: str) -> str: """Return a Rich-styled status string.""" styles = { "running": "[bold green]running[/bold green]", - "idle": "[bold yellow]idle[/bold yellow]", "done": "[bold cyan]done[/bold cyan]", "dead": "[bold red]dead[/bold red]", } @@ -84,13 +83,12 @@ def poll_cmd( ) -> None: """Check status of all tracked agents. - Performs a single poll of all tracked agents (checks tmux panes, - output quiescence, and completion sentinels) then displays results. + Performs a single poll of all tracked agents (checks tmux panes and + completion sentinels) then displays results. **Status values:** - - **running** — Agent output is still changing - - **idle** — Agent output has not changed since last poll + - **running** — Agent pane exists and task is still in progress - **done** — Agent wrote a completion sentinel (.claude/DONE) - **dead** — tmux pane no longer exists @@ -127,7 +125,6 @@ def poll_cmd( "pane_id": a.pane_id, "started_at": a.started_at, "duration_seconds": round(now - a.started_at), - "last_change_at": a.last_change_at, } for a in state.agents.values() ], @@ -164,7 +161,7 @@ def poll_cmd( parts = [f"{total} agent{'s' if total != 1 else ''}"] parts.extend( f"{count} {status}" - for status in ("running", "idle", "done", "dead") + for status in ("running", "done", "dead") if (count := by_status.get(status, 0)) ) console.print(f"\n[dim]{' · '.join(parts)}[/dim]") @@ -260,12 +257,13 @@ def wait_cmd( ) -> None: """Block until a tracked agent finishes. - Polls the agent's tmux pane until it reaches idle, done, or dead status. + Polls the agent's tmux pane until it reaches done/dead, with output + quiescence as a fallback for agents without completion sentinels. Useful for orchestration: launch an agent, wait for it, then act on results. **Exit codes:** - - 0 — Agent finished (idle or done) + - 0 — Agent finished (done or quiet fallback) - 1 — Agent died (pane closed unexpectedly) - 2 — Timeout reached @@ -280,7 +278,7 @@ def wait_cmd( _ensure_tmux() repo_root, agent = _lookup_agent(name) - if agent.status in ("done", "dead", "idle"): + if agent.status in ("done", "dead"): console.print(f"Agent '{name}' is already {_status_style(agent.status)}") raise typer.Exit(0 if agent.status != "dead" else 1) @@ -296,5 +294,11 @@ def wait_cmd( warn(f"Agent '{name}' died (pane closed) after {_format_duration(elapsed)}") raise typer.Exit(1) - success(f"Agent '{name}' is {status} after {_format_duration(elapsed)}") + if status == "quiet": + success( + f"Agent '{name}' appears quiet after {_format_duration(elapsed)} " + "(no output changes detected)", + ) + else: + success(f"Agent '{name}' is {status} after {_format_duration(elapsed)}") raise typer.Exit(0) diff --git a/agent_cli/dev/poller.py b/agent_cli/dev/poller.py index 83a8a555..1eaea155 100644 --- a/agent_cli/dev/poller.py +++ b/agent_cli/dev/poller.py @@ -8,7 +8,7 @@ from . import agent_state, tmux_ops -def _check_agent_status(agent: agent_state.TrackedAgent, now: float) -> None: +def _check_agent_status(agent: agent_state.TrackedAgent) -> None: """Check and update a single agent's status in-place.""" if not tmux_ops.pane_exists(agent.pane_id): agent.status = "dead" @@ -20,28 +20,20 @@ def _check_agent_status(agent: agent_state.TrackedAgent, now: float) -> None: agent.status = "done" return - output = tmux_ops.capture_pane(agent.pane_id) - if output is not None: - h = tmux_ops.hash_output(output) - if h != agent.last_output_hash: - agent.last_output_hash = h - agent.last_change_at = now - agent.status = "running" - else: - agent.status = "idle" + agent.status = "running" def poll_once(repo_root: Path) -> dict[str, str]: """Perform a single poll of all tracked agents. - Checks pane existence, completion sentinels, and output quiescence. + Checks pane existence and completion sentinels. Updates the state file and returns ``{agent_name: status}``. """ state = agent_state.load_state(repo_root) now = time.time() for agent in state.agents.values(): - _check_agent_status(agent, now) + _check_agent_status(agent) state.last_poll_at = now agent_state.save_state(repo_root, state) @@ -56,7 +48,8 @@ def wait_for_agent( ) -> tuple[str, float]: """Block until a tracked agent finishes. - Returns ``(final_status, elapsed_seconds)``. + Returns ``(final_status, elapsed_seconds)`` where ``final_status`` is one + of ``done``, ``dead``, or ``quiet``. Raises ``TimeoutError`` if *timeout* > 0 and is exceeded. Raises ``KeyError`` if the agent is not found. """ @@ -67,7 +60,8 @@ def wait_for_agent( raise KeyError(msg) start = time.time() - consecutive_idle = 0 + consecutive_quiet = 0 + previous_output_hash = "" while True: elapsed = time.time() - start @@ -75,21 +69,26 @@ def wait_for_agent( msg = f"Timeout after {elapsed:.0f}s" raise TimeoutError(msg) - _check_agent_status(agent, time.time()) + _check_agent_status(agent) if agent.status in ("dead", "done"): _update_agent_status(repo_root, name, agent.status) return agent.status, elapsed - if agent.status == "idle": - consecutive_idle += 1 + output = tmux_ops.capture_pane(agent.pane_id) + if output is None: + consecutive_quiet = 0 else: - consecutive_idle = 0 - - # Require 2 consecutive idle polls to confirm - if consecutive_idle >= 2: # noqa: PLR2004 - _update_agent_status(repo_root, name, "idle") - return "idle", elapsed + output_hash = tmux_ops.hash_output(output) + if output_hash == previous_output_hash: + consecutive_quiet += 1 + else: + previous_output_hash = output_hash + consecutive_quiet = 0 + + # Require two quiet polls to infer completion for agents without sentinels. + if consecutive_quiet >= 2: # noqa: PLR2004 + return "quiet", elapsed time.sleep(interval) diff --git a/tests/dev/test_orchestration.py b/tests/dev/test_orchestration.py index 3ce21d45..df09c3f5 100644 --- a/tests/dev/test_orchestration.py +++ b/tests/dev/test_orchestration.py @@ -148,35 +148,36 @@ def test_load_empty_state(self, tmp_path: Path) -> None: assert state.agents == {} assert state.last_poll_at == 0.0 - def test_load_legacy_state_file(self, tmp_path: Path) -> None: - """Loads state from legacy non-hashed slug path.""" + def test_load_state_ignores_unknown_agent_fields(self, tmp_path: Path) -> None: + """Loads agent rows even when old schema includes extra fields.""" with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): repo = tmp_path / "repo" - legacy_path = agent_state._legacy_state_file_path(repo) - legacy_path.parent.mkdir(parents=True, exist_ok=True) - legacy_path.write_text( + state_path = agent_state._state_file_path(repo) + state_path.parent.mkdir(parents=True, exist_ok=True) + state_path.write_text( json.dumps( { - "repo_root": str(repo), "agents": { - "legacy-agent": { - "name": "legacy-agent", + "agent": { + "name": "agent", "pane_id": "%42", "worktree_path": str(tmp_path / "wt"), "agent_type": "claude", "started_at": 123.0, "status": "running", + # Older schema fields should be ignored. "last_output_hash": "", "last_change_at": 123.0, }, }, - "last_poll_at": 0.0, + "last_poll_at": 12.5, }, ), ) state = agent_state.load_state(repo) - assert "legacy-agent" in state.agents + assert "agent" in state.agents + assert state.last_poll_at == 12.5 def test_save_and_load_state(self, tmp_path: Path) -> None: """Round-trips state through JSON.""" @@ -371,18 +372,13 @@ def test_wait_ignores_done_sentinel_for_non_claude_agents(self, tmp_path: Path) agent_state.register_agent(repo, "worker", "%3", wt, "codex") - # Seed hash so repeated identical output transitions to idle. - state = agent_state.load_state(repo) - state.agents["worker"].last_output_hash = "h1" - agent_state.save_state(repo, state) - with ( patch("agent_cli.dev.tmux_ops.pane_exists", return_value=True), patch("agent_cli.dev.tmux_ops.capture_pane", return_value="output"), patch("agent_cli.dev.tmux_ops.hash_output", return_value="h1"), ): status, _elapsed = poller.wait_for_agent(repo, "worker", timeout=1, interval=0) - assert status == "idle" + assert status == "quiet" # --------------------------------------------------------------------------- @@ -400,7 +396,7 @@ def test_poll_no_agents(self) -> None: patch("agent_cli.dev.orchestration._ensure_git_repo", return_value=Path("/repo")), patch( "agent_cli.dev.agent_state.load_state", - return_value=agent_state.AgentStateFile(repo_root="/repo"), + return_value=agent_state.AgentStateFile(), ), ): result = runner.invoke(app, ["dev", "poll"]) @@ -409,7 +405,7 @@ def test_poll_no_agents(self) -> None: def test_poll_json_output(self) -> None: """Returns JSON with agent status.""" - state = agent_state.AgentStateFile(repo_root="/repo") + state = agent_state.AgentStateFile() state.agents["test"] = agent_state.TrackedAgent( name="test", pane_id="%3", @@ -435,7 +431,7 @@ def test_poll_json_output(self) -> None: def test_poll_detects_dead_agent(self) -> None: """Marks agent as dead when pane is gone.""" - state = agent_state.AgentStateFile(repo_root="/repo") + state = agent_state.AgentStateFile() state.agents["test"] = agent_state.TrackedAgent( name="test", pane_id="%3", @@ -462,7 +458,7 @@ class TestOutputCommand: def test_output_captures_pane(self) -> None: """Captures and prints pane output.""" - state = agent_state.AgentStateFile(repo_root="/repo") + state = agent_state.AgentStateFile() state.agents["test"] = agent_state.TrackedAgent( name="test", pane_id="%3", @@ -489,7 +485,7 @@ def test_output_agent_not_found(self) -> None: patch("agent_cli.dev.orchestration._ensure_git_repo", return_value=Path("/repo")), patch( "agent_cli.dev.agent_state.load_state", - return_value=agent_state.AgentStateFile(repo_root="/repo"), + return_value=agent_state.AgentStateFile(), ), ): result = runner.invoke(app, ["dev", "output", "nonexistent"]) @@ -502,7 +498,7 @@ class TestSendCommand: def test_send_keys_to_agent(self) -> None: """Sends keys to agent's tmux pane.""" - state = agent_state.AgentStateFile(repo_root="/repo") + state = agent_state.AgentStateFile() state.agents["test"] = agent_state.TrackedAgent( name="test", pane_id="%3", @@ -524,7 +520,7 @@ def test_send_keys_to_agent(self) -> None: def test_send_to_dead_agent(self) -> None: """Errors when sending to dead agent.""" - state = agent_state.AgentStateFile(repo_root="/repo") + state = agent_state.AgentStateFile() state.agents["test"] = agent_state.TrackedAgent( name="test", pane_id="%3", @@ -549,7 +545,7 @@ class TestWaitCommand: def test_wait_already_done(self) -> None: """Returns immediately if agent is already done.""" - state = agent_state.AgentStateFile(repo_root="/repo") + state = agent_state.AgentStateFile() state.agents["test"] = agent_state.TrackedAgent( name="test", pane_id="%3", @@ -570,7 +566,7 @@ def test_wait_already_done(self) -> None: def test_wait_already_dead(self) -> None: """Returns exit code 1 if agent is dead.""" - state = agent_state.AgentStateFile(repo_root="/repo") + state = agent_state.AgentStateFile() state.agents["test"] = agent_state.TrackedAgent( name="test", pane_id="%3", From 9fa6b86a47fce9822b9a6dbbbd0f12ce69aab23f Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 9 Feb 2026 21:07:52 -0800 Subject: [PATCH 13/20] fix(dev): address orchestration bugs found during live testing - Fix inject_completion_hook to use new Claude Code hook format (hooks entries must be {type, command} objects, not strings) - Fix is_tmux() to also detect reachable tmux server (not just $TMUX env) - Fix inject_completion_hook to handle non-dict JSON in settings file - Fix quiescence counter reset on transient capture_pane failures - Increase quiescence threshold from 2 to 6 polls (30s at default interval) - Remove unused unregister_agent function --- agent_cli/dev/agent_state.py | 48 +++++++++++++++++++-------------- agent_cli/dev/poller.py | 9 +++---- tests/dev/test_orchestration.py | 28 ++++++++++--------- 3 files changed, 47 insertions(+), 38 deletions(-) diff --git a/agent_cli/dev/agent_state.py b/agent_cli/dev/agent_state.py index 3cd30c03..af645449 100644 --- a/agent_cli/dev/agent_state.py +++ b/agent_cli/dev/agent_state.py @@ -139,19 +139,6 @@ def register_agent( return agent -def unregister_agent(repo_root: Path, name: str) -> bool: - """Remove an agent from the state file. - - Returns ``True`` if the agent was found and removed. - """ - state = load_state(repo_root) - if name not in state.agents: - return False - del state.agents[name] - save_state(repo_root, state) - return True - - def generate_agent_name( repo_root: Path, worktree_path: Path, @@ -208,7 +195,8 @@ def inject_completion_hook(worktree_path: Path, agent_type: str) -> None: settings: dict = {} if settings_path.exists(): try: - settings = json.loads(settings_path.read_text()) + raw = json.loads(settings_path.read_text()) + settings = raw if isinstance(raw, dict) else {} except json.JSONDecodeError: settings = {} @@ -219,13 +207,33 @@ def inject_completion_hook(worktree_path: Path, agent_type: str) -> None: # Check if our hook is already present sentinel_cmd = "touch .claude/DONE" for entry in stop_hooks: - if sentinel_cmd in entry.get("hooks", []): - return # Already injected - - stop_hooks.append({"matcher": "", "hooks": [sentinel_cmd]}) + for hook in entry.get("hooks", []): + cmd = hook.get("command", "") if isinstance(hook, dict) else hook + if sentinel_cmd in cmd: + return # Already injected + + stop_hooks.append( + { + "matcher": "", + "hooks": [{"type": "command", "command": sentinel_cmd}], + }, + ) settings_path.write_text(json.dumps(settings, indent=2) + "\n") def is_tmux() -> bool: - """Check if we're running inside tmux.""" - return bool(os.environ.get("TMUX")) + """Check if tmux is available (inside a session or server is reachable).""" + if os.environ.get("TMUX"): + return True + # Not inside tmux, but check if a tmux server is running + import subprocess # noqa: PLC0415 + + try: + subprocess.run( + ["tmux", "list-sessions"], # noqa: S607 + check=True, + capture_output=True, + ) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False diff --git a/agent_cli/dev/poller.py b/agent_cli/dev/poller.py index 1eaea155..e00ddc2b 100644 --- a/agent_cli/dev/poller.py +++ b/agent_cli/dev/poller.py @@ -76,9 +76,7 @@ def wait_for_agent( return agent.status, elapsed output = tmux_ops.capture_pane(agent.pane_id) - if output is None: - consecutive_quiet = 0 - else: + if output is not None: output_hash = tmux_ops.hash_output(output) if output_hash == previous_output_hash: consecutive_quiet += 1 @@ -86,8 +84,9 @@ def wait_for_agent( previous_output_hash = output_hash consecutive_quiet = 0 - # Require two quiet polls to infer completion for agents without sentinels. - if consecutive_quiet >= 2: # noqa: PLR2004 + # Require several quiet polls to infer completion for agents without sentinels. + # At the default 5s interval, 6 polls = 30s of unchanged output. + if consecutive_quiet >= 6: # noqa: PLR2004 return "quiet", elapsed time.sleep(interval) diff --git a/tests/dev/test_orchestration.py b/tests/dev/test_orchestration.py index df09c3f5..7734a80c 100644 --- a/tests/dev/test_orchestration.py +++ b/tests/dev/test_orchestration.py @@ -198,17 +198,6 @@ def test_save_and_load_state(self, tmp_path: Path) -> None: assert state.agents["test-agent"].pane_id == "%42" assert state.agents["test-agent"].agent_type == "claude" - def test_unregister_agent(self, tmp_path: Path) -> None: - """Removes agent from state.""" - with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): - repo = tmp_path / "repo" - agent_state.register_agent(repo, "a1", "%1", tmp_path / "wt", "claude") - assert agent_state.unregister_agent(repo, "a1") is True - assert agent_state.unregister_agent(repo, "a1") is False - - state = agent_state.load_state(repo) - assert "a1" not in state.agents - def test_generate_agent_name_first(self, tmp_path: Path) -> None: """First agent in worktree uses branch name.""" with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): @@ -621,7 +610,13 @@ def test_injects_stop_hook(self, tmp_path: Path) -> None: assert "hooks" in settings assert "Stop" in settings["hooks"] hooks = settings["hooks"]["Stop"] - assert any("touch .claude/DONE" in h.get("hooks", []) for h in hooks) + assert any( + any( + isinstance(hook, dict) and hook.get("command") == "touch .claude/DONE" + for hook in h.get("hooks", []) + ) + for h in hooks + ) def test_merges_with_existing_settings(self, tmp_path: Path) -> None: """Preserves existing settings when injecting hook.""" @@ -648,5 +643,12 @@ def test_idempotent(self, tmp_path: Path) -> None: settings = json.loads((tmp_path / ".claude" / "settings.json").read_text()) stop_hooks = settings["hooks"]["Stop"] - sentinel_count = sum(1 for h in stop_hooks if "touch .claude/DONE" in h.get("hooks", [])) + sentinel_count = sum( + 1 + for h in stop_hooks + if any( + isinstance(hook, dict) and hook.get("command") == "touch .claude/DONE" + for hook in h.get("hooks", []) + ) + ) assert sentinel_count == 1 From 2668f78b8e3a89c12b93a80a2b3d799b8d0d0ed8 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 9 Feb 2026 22:22:49 -0800 Subject: [PATCH 14/20] fix(dev): use settings.local.json for completion hook injection Use .claude/settings.local.json instead of .claude/settings.json to avoid dirtying tracked files in the worktree. The local settings file is automatically gitignored by Claude Code. --- agent_cli/dev/agent_state.py | 5 +++-- tests/dev/test_orchestration.py | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/agent_cli/dev/agent_state.py b/agent_cli/dev/agent_state.py index af645449..aedbb888 100644 --- a/agent_cli/dev/agent_state.py +++ b/agent_cli/dev/agent_state.py @@ -182,14 +182,15 @@ def generate_agent_name( def inject_completion_hook(worktree_path: Path, agent_type: str) -> None: - """Inject a Stop hook into .claude/settings.json for completion detection. + """Inject a Stop hook into .claude/settings.local.json for completion detection. + Uses settings.local.json (not settings.json) to avoid dirtying tracked files. Only applies to Claude Code agents. Merges with existing settings. """ if agent_type != "claude": return - settings_path = worktree_path / ".claude" / "settings.json" + settings_path = worktree_path / ".claude" / "settings.local.json" settings_path.parent.mkdir(parents=True, exist_ok=True) settings: dict = {} diff --git a/tests/dev/test_orchestration.py b/tests/dev/test_orchestration.py index 7734a80c..f420131c 100644 --- a/tests/dev/test_orchestration.py +++ b/tests/dev/test_orchestration.py @@ -601,10 +601,10 @@ class TestInjectCompletionHook: """Tests for Claude Code hook injection.""" def test_injects_stop_hook(self, tmp_path: Path) -> None: - """Creates .claude/settings.json with Stop hook.""" + """Creates .claude/settings.local.json with Stop hook.""" inject_completion_hook(tmp_path, "claude") - settings_path = tmp_path / ".claude" / "settings.json" + settings_path = tmp_path / ".claude" / "settings.local.json" assert settings_path.exists() settings = json.loads(settings_path.read_text()) assert "hooks" in settings @@ -620,7 +620,7 @@ def test_injects_stop_hook(self, tmp_path: Path) -> None: def test_merges_with_existing_settings(self, tmp_path: Path) -> None: """Preserves existing settings when injecting hook.""" - settings_path = tmp_path / ".claude" / "settings.json" + settings_path = tmp_path / ".claude" / "settings.local.json" settings_path.parent.mkdir(parents=True) settings_path.write_text(json.dumps({"model": "opus", "hooks": {"PreToolUse": []}})) @@ -634,14 +634,14 @@ def test_merges_with_existing_settings(self, tmp_path: Path) -> None: def test_skips_non_claude_agents(self, tmp_path: Path) -> None: """Does nothing for non-Claude agents.""" inject_completion_hook(tmp_path, "aider") - assert not (tmp_path / ".claude" / "settings.json").exists() + assert not (tmp_path / ".claude" / "settings.local.json").exists() def test_idempotent(self, tmp_path: Path) -> None: """Doesn't duplicate hook on repeated calls.""" inject_completion_hook(tmp_path, "claude") inject_completion_hook(tmp_path, "claude") - settings = json.loads((tmp_path / ".claude" / "settings.json").read_text()) + settings = json.loads((tmp_path / ".claude" / "settings.local.json").read_text()) stop_hooks = settings["hooks"]["Stop"] sentinel_count = sum( 1 From 3696ca097ef4f77f3e4861734f936ff5ee3f7f81 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 9 Feb 2026 22:38:50 -0800 Subject: [PATCH 15/20] fix(dev): harden state files against corrupt schemas and concurrent writes Fix save_state race condition where concurrent writers used the same temp file name, causing FileNotFoundError. Use PID-suffixed temp files instead. Guard load_state and inject_completion_hook against non-dict JSON values that would crash on .items()/.setdefault() calls. --- agent_cli/dev/agent_state.py | 22 ++++++++++----- tests/dev/test_orchestration.py | 47 +++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/agent_cli/dev/agent_state.py b/agent_cli/dev/agent_state.py index aedbb888..d227ace7 100644 --- a/agent_cli/dev/agent_state.py +++ b/agent_cli/dev/agent_state.py @@ -74,7 +74,10 @@ def load_state(repo_root: Path) -> AgentStateFile: return AgentStateFile() agents: dict[str, TrackedAgent] = {} - for name, agent_data in data.get("agents", {}).items(): + raw_agents = data.get("agents", {}) + if not isinstance(raw_agents, dict): + raw_agents = {} + for name, agent_data in raw_agents.items(): status = agent_data.get("status", "running") if status not in ("running", "done", "dead"): status = "running" @@ -106,8 +109,9 @@ def save_state(repo_root: Path, state: AgentStateFile) -> None: "agents": {name: asdict(agent) for name, agent in state.agents.items()}, "last_poll_at": state.last_poll_at, } - # Write to temp file then rename for atomicity - tmp = path.with_suffix(".tmp") + # Write to temp file then rename for atomicity. + # Use PID in suffix to avoid races between concurrent writers. + tmp = path.with_suffix(f".{os.getpid()}.tmp") tmp.write_text(json.dumps(data, indent=2) + "\n") tmp.rename(path) @@ -201,9 +205,15 @@ def inject_completion_hook(worktree_path: Path, agent_type: str) -> None: except json.JSONDecodeError: settings = {} - # Merge Stop hook - hooks = settings.setdefault("hooks", {}) - stop_hooks = hooks.setdefault("Stop", []) + # Merge Stop hook — guard against non-dict values from corrupt files + hooks = settings.get("hooks") + if not isinstance(hooks, dict): + hooks = {} + settings["hooks"] = hooks + stop_hooks = hooks.get("Stop") + if not isinstance(stop_hooks, list): + stop_hooks = [] + hooks["Stop"] = stop_hooks # Check if our hook is already present sentinel_cmd = "touch .claude/DONE" diff --git a/tests/dev/test_orchestration.py b/tests/dev/test_orchestration.py index f420131c..c33ed10b 100644 --- a/tests/dev/test_orchestration.py +++ b/tests/dev/test_orchestration.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os import subprocess import time from pathlib import Path @@ -274,6 +275,28 @@ def test_load_corrupt_state(self, tmp_path: Path) -> None: state = agent_state.load_state(tmp_path / "repo") assert state.agents == {} + def test_load_state_agents_is_list(self, tmp_path: Path) -> None: + """Returns empty agents when 'agents' is a list instead of dict.""" + with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): + path = agent_state._state_file_path(tmp_path / "repo") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"agents": [], "last_poll_at": 0.0})) + state = agent_state.load_state(tmp_path / "repo") + assert state.agents == {} + + def test_save_state_uses_pid_in_temp_file(self, tmp_path: Path) -> None: + """Temp file includes PID to avoid races between concurrent writers.""" + with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): + repo = tmp_path / "repo" + state = agent_state.AgentStateFile() + agent_state.save_state(repo, state) + # Verify the final file exists and no stale .tmp files remain + path = agent_state._state_file_path(repo) + assert path.exists() + # Ensure PID-suffixed temp doesn't linger + pid_tmp = path.with_suffix(f".{os.getpid()}.tmp") + assert not pid_tmp.exists() + # --------------------------------------------------------------------------- # launch/poller regression tests @@ -652,3 +675,27 @@ def test_idempotent(self, tmp_path: Path) -> None: ) ) assert sentinel_count == 1 + + def test_hooks_value_is_list_not_dict(self, tmp_path: Path) -> None: + """Handles corrupt settings where 'hooks' is a list instead of dict.""" + settings_path = tmp_path / ".claude" / "settings.local.json" + settings_path.parent.mkdir(parents=True) + settings_path.write_text(json.dumps({"hooks": []})) + + inject_completion_hook(tmp_path, "claude") + + settings = json.loads(settings_path.read_text()) + assert isinstance(settings["hooks"], dict) + assert "Stop" in settings["hooks"] + + def test_stop_value_is_not_list(self, tmp_path: Path) -> None: + """Handles corrupt settings where 'Stop' is not a list.""" + settings_path = tmp_path / ".claude" / "settings.local.json" + settings_path.parent.mkdir(parents=True) + settings_path.write_text(json.dumps({"hooks": {"Stop": "invalid"}})) + + inject_completion_hook(tmp_path, "claude") + + settings = json.loads(settings_path.read_text()) + assert isinstance(settings["hooks"]["Stop"], list) + assert len(settings["hooks"]["Stop"]) == 1 From 37e1d8743328280e62697da8e5bcd8d1a3ba1900 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 9 Feb 2026 22:44:48 -0800 Subject: [PATCH 16/20] feat(dev): add quiescence detection to poll command Track output hash and consecutive unchanged polls in the state file so poll_once can detect when an agent's output has stabilized. Agents without completion sentinels (e.g., codex) now transition to "quiet" status after 6 consecutive polls with identical output, instead of showing "running" forever. --- agent_cli/dev/agent_state.py | 10 ++++-- agent_cli/dev/orchestration.py | 6 ++-- agent_cli/dev/poller.py | 60 ++++++++++++++++----------------- tests/dev/test_orchestration.py | 50 +++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 36 deletions(-) diff --git a/agent_cli/dev/agent_state.py b/agent_cli/dev/agent_state.py index d227ace7..8e7aa1b3 100644 --- a/agent_cli/dev/agent_state.py +++ b/agent_cli/dev/agent_state.py @@ -13,7 +13,7 @@ STATE_BASE = Path.home() / ".cache" / "agent-cli" -AgentStatus = Literal["running", "done", "dead"] +AgentStatus = Literal["running", "done", "dead", "quiet"] @dataclass @@ -26,6 +26,8 @@ class TrackedAgent: agent_type: str started_at: float status: AgentStatus = "running" + last_output_hash: str = "" + consecutive_quiet: int = 0 @dataclass @@ -79,7 +81,7 @@ def load_state(repo_root: Path) -> AgentStateFile: raw_agents = {} for name, agent_data in raw_agents.items(): status = agent_data.get("status", "running") - if status not in ("running", "done", "dead"): + if status not in ("running", "done", "dead", "quiet"): status = "running" try: agents[name] = TrackedAgent( @@ -89,6 +91,8 @@ def load_state(repo_root: Path) -> AgentStateFile: agent_type=str(agent_data["agent_type"]), started_at=float(agent_data["started_at"]), status=status, + last_output_hash=str(agent_data.get("last_output_hash", "")), + consecutive_quiet=int(agent_data.get("consecutive_quiet", 0)), ) except (KeyError, TypeError, ValueError): continue @@ -127,7 +131,7 @@ def register_agent( state = load_state(repo_root) # Keep only active agents; terminal entries should not reserve names forever. for existing_name in list(state.agents): - if state.agents[existing_name].status in ("done", "dead"): + if state.agents[existing_name].status in ("done", "dead", "quiet"): del state.agents[existing_name] now = time.time() diff --git a/agent_cli/dev/orchestration.py b/agent_cli/dev/orchestration.py index 5cf9243a..cca3b0e4 100644 --- a/agent_cli/dev/orchestration.py +++ b/agent_cli/dev/orchestration.py @@ -69,6 +69,7 @@ def _status_style(status: str) -> str: styles = { "running": "[bold green]running[/bold green]", "done": "[bold cyan]done[/bold cyan]", + "quiet": "[bold cyan]quiet[/bold cyan]", "dead": "[bold red]dead[/bold red]", } return styles.get(status, status) @@ -90,6 +91,7 @@ def poll_cmd( - **running** — Agent pane exists and task is still in progress - **done** — Agent wrote a completion sentinel (.claude/DONE) + - **quiet** — Agent output unchanged for several consecutive polls - **dead** — tmux pane no longer exists **Examples:** @@ -161,7 +163,7 @@ def poll_cmd( parts = [f"{total} agent{'s' if total != 1 else ''}"] parts.extend( f"{count} {status}" - for status in ("running", "done", "dead") + for status in ("running", "done", "quiet", "dead") if (count := by_status.get(status, 0)) ) console.print(f"\n[dim]{' · '.join(parts)}[/dim]") @@ -278,7 +280,7 @@ def wait_cmd( _ensure_tmux() repo_root, agent = _lookup_agent(name) - if agent.status in ("done", "dead"): + if agent.status in ("done", "quiet", "dead"): console.print(f"Agent '{name}' is already {_status_style(agent.status)}") raise typer.Exit(0 if agent.status != "dead" else 1) diff --git a/agent_cli/dev/poller.py b/agent_cli/dev/poller.py index e00ddc2b..a079cdfa 100644 --- a/agent_cli/dev/poller.py +++ b/agent_cli/dev/poller.py @@ -7,6 +7,10 @@ from . import agent_state, tmux_ops +# Number of consecutive polls with unchanged output before marking as "quiet". +# At the default 5s interval, 6 polls ≈ 30s of unchanged output. +QUIET_THRESHOLD = 6 + def _check_agent_status(agent: agent_state.TrackedAgent) -> None: """Check and update a single agent's status in-place.""" @@ -23,10 +27,27 @@ def _check_agent_status(agent: agent_state.TrackedAgent) -> None: agent.status = "running" +def _check_quiescence(agent: agent_state.TrackedAgent) -> None: + """Track output changes and mark agent as quiet if output is stable.""" + output = tmux_ops.capture_pane(agent.pane_id) + if output is None: + return + + output_hash = tmux_ops.hash_output(output) + if output_hash == agent.last_output_hash: + agent.consecutive_quiet += 1 + else: + agent.last_output_hash = output_hash + agent.consecutive_quiet = 0 + + if agent.consecutive_quiet >= QUIET_THRESHOLD: + agent.status = "quiet" + + def poll_once(repo_root: Path) -> dict[str, str]: """Perform a single poll of all tracked agents. - Checks pane existence and completion sentinels. + Checks pane existence, completion sentinels, and output quiescence. Updates the state file and returns ``{agent_name: status}``. """ state = agent_state.load_state(repo_root) @@ -34,6 +55,8 @@ def poll_once(repo_root: Path) -> dict[str, str]: for agent in state.agents.values(): _check_agent_status(agent) + if agent.status == "running": + _check_quiescence(agent) state.last_poll_at = now agent_state.save_state(repo_root, state) @@ -54,14 +77,11 @@ def wait_for_agent( Raises ``KeyError`` if the agent is not found. """ state = agent_state.load_state(repo_root) - agent = state.agents.get(name) - if agent is None: + if name not in state.agents: msg = f"Agent '{name}' not found" raise KeyError(msg) start = time.time() - consecutive_quiet = 0 - previous_output_hash = "" while True: elapsed = time.time() - start @@ -69,32 +89,10 @@ def wait_for_agent( msg = f"Timeout after {elapsed:.0f}s" raise TimeoutError(msg) - _check_agent_status(agent) - - if agent.status in ("dead", "done"): - _update_agent_status(repo_root, name, agent.status) - return agent.status, elapsed - - output = tmux_ops.capture_pane(agent.pane_id) - if output is not None: - output_hash = tmux_ops.hash_output(output) - if output_hash == previous_output_hash: - consecutive_quiet += 1 - else: - previous_output_hash = output_hash - consecutive_quiet = 0 + statuses = poll_once(repo_root) + status = statuses.get(name, "dead") - # Require several quiet polls to infer completion for agents without sentinels. - # At the default 5s interval, 6 polls = 30s of unchanged output. - if consecutive_quiet >= 6: # noqa: PLR2004 - return "quiet", elapsed + if status in ("dead", "done", "quiet"): + return status, elapsed time.sleep(interval) - - -def _update_agent_status(repo_root: Path, name: str, status: agent_state.AgentStatus) -> None: - """Update a single agent's status in the state file.""" - state = agent_state.load_state(repo_root) - if name in state.agents: - state.agents[name].status = status - agent_state.save_state(repo_root, state) diff --git a/tests/dev/test_orchestration.py b/tests/dev/test_orchestration.py index c33ed10b..11251f40 100644 --- a/tests/dev/test_orchestration.py +++ b/tests/dev/test_orchestration.py @@ -373,6 +373,56 @@ def test_poll_ignores_done_sentinel_for_non_claude_agents(self, tmp_path: Path) statuses = poller.poll_once(repo) assert statuses["worker"] == "running" + def test_poll_detects_quiescence_for_non_claude_agents(self, tmp_path: Path) -> None: + """poll_once marks agent as quiet after enough polls with unchanged output.""" + with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): + repo = tmp_path / "repo" + wt = tmp_path / "worktree" + agent_state.register_agent(repo, "worker", "%3", wt, "codex") + + with ( + patch("agent_cli.dev.tmux_ops.pane_exists", return_value=True), + patch("agent_cli.dev.tmux_ops.capture_pane", return_value="output"), + patch("agent_cli.dev.tmux_ops.hash_output", return_value="h1"), + ): + # First poll sets the hash, consecutive_quiet stays 0 + statuses = poller.poll_once(repo) + assert statuses["worker"] == "running" + + # Polls 2-7: hash matches, consecutive_quiet increments 1..6 + for _i in range(poller.QUIET_THRESHOLD): + statuses = poller.poll_once(repo) + + assert statuses["worker"] == "quiet" + + def test_poll_resets_quiescence_on_output_change(self, tmp_path: Path) -> None: + """Output change resets the quiescence counter.""" + with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): + repo = tmp_path / "repo" + wt = tmp_path / "worktree" + agent_state.register_agent(repo, "worker", "%3", wt, "codex") + + with ( + patch("agent_cli.dev.tmux_ops.pane_exists", return_value=True), + patch("agent_cli.dev.tmux_ops.capture_pane", return_value="output"), + ): + # Use a side_effect to return different hashes + hashes = ["h1", "h1", "h1", "h2", "h2", "h2", "h2", "h2", "h2", "h2"] + with patch("agent_cli.dev.tmux_ops.hash_output", side_effect=hashes): + # First 3 polls: h1, h1, h1 → consecutive_quiet = 2 (first sets hash) + for _ in range(3): + statuses = poller.poll_once(repo) + assert statuses["worker"] == "running" + + # 4th poll: h2 → reset, consecutive_quiet = 0 + statuses = poller.poll_once(repo) + assert statuses["worker"] == "running" + + # Polls 5-10: h2 repeated → consecutive_quiet = 1..6 + for _ in range(poller.QUIET_THRESHOLD): + statuses = poller.poll_once(repo) + assert statuses["worker"] == "quiet" + def test_wait_ignores_done_sentinel_for_non_claude_agents(self, tmp_path: Path) -> None: """wait_for_agent should use quiescence for non-Claude agents even if DONE exists.""" with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): From 043e85451d0fe7b1d8a4cafe409955ed564da0b4 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 9 Feb 2026 22:49:58 -0800 Subject: [PATCH 17/20] fix(dev): use wall-clock time for quiescence detection Replace poll-count-based quiescence with time-based: track when output last changed and require 10s of unchanged output before marking quiet. Prevents false positives from rapid polling. --- agent_cli/dev/agent_state.py | 4 +- agent_cli/dev/poller.py | 19 +++++----- tests/dev/test_orchestration.py | 67 ++++++++++++++++++++++----------- 3 files changed, 57 insertions(+), 33 deletions(-) diff --git a/agent_cli/dev/agent_state.py b/agent_cli/dev/agent_state.py index 8e7aa1b3..9e24ba13 100644 --- a/agent_cli/dev/agent_state.py +++ b/agent_cli/dev/agent_state.py @@ -27,7 +27,7 @@ class TrackedAgent: started_at: float status: AgentStatus = "running" last_output_hash: str = "" - consecutive_quiet: int = 0 + last_output_change_at: float = 0.0 @dataclass @@ -92,7 +92,7 @@ def load_state(repo_root: Path) -> AgentStateFile: started_at=float(agent_data["started_at"]), status=status, last_output_hash=str(agent_data.get("last_output_hash", "")), - consecutive_quiet=int(agent_data.get("consecutive_quiet", 0)), + last_output_change_at=float(agent_data.get("last_output_change_at", 0.0)), ) except (KeyError, TypeError, ValueError): continue diff --git a/agent_cli/dev/poller.py b/agent_cli/dev/poller.py index a079cdfa..c63aa9ff 100644 --- a/agent_cli/dev/poller.py +++ b/agent_cli/dev/poller.py @@ -7,9 +7,8 @@ from . import agent_state, tmux_ops -# Number of consecutive polls with unchanged output before marking as "quiet". -# At the default 5s interval, 6 polls ≈ 30s of unchanged output. -QUIET_THRESHOLD = 6 +# Seconds of unchanged output before marking an agent as "quiet". +QUIET_SECONDS = 10.0 def _check_agent_status(agent: agent_state.TrackedAgent) -> None: @@ -27,20 +26,20 @@ def _check_agent_status(agent: agent_state.TrackedAgent) -> None: agent.status = "running" -def _check_quiescence(agent: agent_state.TrackedAgent) -> None: +def _check_quiescence(agent: agent_state.TrackedAgent, now: float) -> None: """Track output changes and mark agent as quiet if output is stable.""" output = tmux_ops.capture_pane(agent.pane_id) if output is None: return output_hash = tmux_ops.hash_output(output) - if output_hash == agent.last_output_hash: - agent.consecutive_quiet += 1 - else: + if output_hash != agent.last_output_hash: agent.last_output_hash = output_hash - agent.consecutive_quiet = 0 + agent.last_output_change_at = now + return - if agent.consecutive_quiet >= QUIET_THRESHOLD: + # Hash unchanged — mark quiet if enough wall-clock time has passed + if agent.last_output_change_at > 0 and (now - agent.last_output_change_at) >= QUIET_SECONDS: agent.status = "quiet" @@ -56,7 +55,7 @@ def poll_once(repo_root: Path) -> dict[str, str]: for agent in state.agents.values(): _check_agent_status(agent) if agent.status == "running": - _check_quiescence(agent) + _check_quiescence(agent, now) state.last_poll_at = now agent_state.save_state(repo_root, state) diff --git a/tests/dev/test_orchestration.py b/tests/dev/test_orchestration.py index 11251f40..42c310a6 100644 --- a/tests/dev/test_orchestration.py +++ b/tests/dev/test_orchestration.py @@ -374,7 +374,7 @@ def test_poll_ignores_done_sentinel_for_non_claude_agents(self, tmp_path: Path) assert statuses["worker"] == "running" def test_poll_detects_quiescence_for_non_claude_agents(self, tmp_path: Path) -> None: - """poll_once marks agent as quiet after enough polls with unchanged output.""" + """poll_once marks agent as quiet after enough time with unchanged output.""" with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): repo = tmp_path / "repo" wt = tmp_path / "worktree" @@ -385,18 +385,24 @@ def test_poll_detects_quiescence_for_non_claude_agents(self, tmp_path: Path) -> patch("agent_cli.dev.tmux_ops.capture_pane", return_value="output"), patch("agent_cli.dev.tmux_ops.hash_output", return_value="h1"), ): - # First poll sets the hash, consecutive_quiet stays 0 - statuses = poller.poll_once(repo) + t0 = time.time() + # First poll sets the hash and last_output_change_at + with patch("time.time", return_value=t0): + statuses = poller.poll_once(repo) assert statuses["worker"] == "running" - # Polls 2-7: hash matches, consecutive_quiet increments 1..6 - for _i in range(poller.QUIET_THRESHOLD): + # Second poll 1s later — too soon, still running + with patch("time.time", return_value=t0 + 1): statuses = poller.poll_once(repo) + assert statuses["worker"] == "running" + # Third poll after QUIET_SECONDS — now quiet + with patch("time.time", return_value=t0 + poller.QUIET_SECONDS + 1): + statuses = poller.poll_once(repo) assert statuses["worker"] == "quiet" def test_poll_resets_quiescence_on_output_change(self, tmp_path: Path) -> None: - """Output change resets the quiescence counter.""" + """Output change resets the quiescence timer.""" with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): repo = tmp_path / "repo" wt = tmp_path / "worktree" @@ -406,22 +412,30 @@ def test_poll_resets_quiescence_on_output_change(self, tmp_path: Path) -> None: patch("agent_cli.dev.tmux_ops.pane_exists", return_value=True), patch("agent_cli.dev.tmux_ops.capture_pane", return_value="output"), ): - # Use a side_effect to return different hashes - hashes = ["h1", "h1", "h1", "h2", "h2", "h2", "h2", "h2", "h2", "h2"] - with patch("agent_cli.dev.tmux_ops.hash_output", side_effect=hashes): - # First 3 polls: h1, h1, h1 → consecutive_quiet = 2 (first sets hash) - for _ in range(3): - statuses = poller.poll_once(repo) - assert statuses["worker"] == "running" - - # 4th poll: h2 → reset, consecutive_quiet = 0 + t0 = time.time() + + # First poll: hash h1 + with ( + patch("agent_cli.dev.tmux_ops.hash_output", return_value="h1"), + patch("time.time", return_value=t0), + ): + poller.poll_once(repo) + + # After QUIET_SECONDS, hash changes to h2 — timer resets + with ( + patch("agent_cli.dev.tmux_ops.hash_output", return_value="h2"), + patch("time.time", return_value=t0 + poller.QUIET_SECONDS + 1), + ): statuses = poller.poll_once(repo) - assert statuses["worker"] == "running" + assert statuses["worker"] == "running" - # Polls 5-10: h2 repeated → consecutive_quiet = 1..6 - for _ in range(poller.QUIET_THRESHOLD): - statuses = poller.poll_once(repo) - assert statuses["worker"] == "quiet" + # QUIET_SECONDS after the reset — now quiet + with ( + patch("agent_cli.dev.tmux_ops.hash_output", return_value="h2"), + patch("time.time", return_value=t0 + 2 * poller.QUIET_SECONDS + 2), + ): + statuses = poller.poll_once(repo) + assert statuses["worker"] == "quiet" def test_wait_ignores_done_sentinel_for_non_claude_agents(self, tmp_path: Path) -> None: """wait_for_agent should use quiescence for non-Claude agents even if DONE exists.""" @@ -434,12 +448,23 @@ def test_wait_ignores_done_sentinel_for_non_claude_agents(self, tmp_path: Path) agent_state.register_agent(repo, "worker", "%3", wt, "codex") + call_count = 0 + t0 = 1000.0 + + def advancing_time() -> float: + """Each call advances time beyond QUIET_SECONDS.""" + nonlocal call_count + call_count += 1 + return t0 + call_count * (poller.QUIET_SECONDS + 1) + with ( patch("agent_cli.dev.tmux_ops.pane_exists", return_value=True), patch("agent_cli.dev.tmux_ops.capture_pane", return_value="output"), patch("agent_cli.dev.tmux_ops.hash_output", return_value="h1"), + patch("time.time", side_effect=advancing_time), + patch("time.sleep"), ): - status, _elapsed = poller.wait_for_agent(repo, "worker", timeout=1, interval=0) + status, _elapsed = poller.wait_for_agent(repo, "worker", timeout=0, interval=0) assert status == "quiet" From f0dd57d10f74df564a4db1551a2e2e8d302a3cc0 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 9 Feb 2026 22:56:54 -0800 Subject: [PATCH 18/20] fix(dev): use per-agent sentinel files and track via tmux from any terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use DONE- sentinel files instead of a shared DONE file to avoid false positives when multiple Claude sessions share a worktree. Also decouple tracked launch from terminal type — when track=True, always use tmux_ops directly (is_tmux() already validated at the CLI layer), so agents launched from zellij/kitty are still tracked via tmux. --- agent_cli/dev/agent_state.py | 11 ++++++++-- agent_cli/dev/launch.py | 10 ++++----- agent_cli/dev/poller.py | 2 +- tests/dev/test_orchestration.py | 38 +++++++++++++++++++++++---------- 4 files changed, 41 insertions(+), 20 deletions(-) diff --git a/agent_cli/dev/agent_state.py b/agent_cli/dev/agent_state.py index 9e24ba13..2064b318 100644 --- a/agent_cli/dev/agent_state.py +++ b/agent_cli/dev/agent_state.py @@ -189,10 +189,17 @@ def generate_agent_name( return f"{candidate}-{n}" -def inject_completion_hook(worktree_path: Path, agent_type: str) -> None: +def sentinel_path(worktree_path: Path, agent_name: str) -> Path: + """Return the completion sentinel path for a tracked agent.""" + return worktree_path / ".claude" / f"DONE-{agent_name}" + + +def inject_completion_hook(worktree_path: Path, agent_type: str, agent_name: str) -> None: """Inject a Stop hook into .claude/settings.local.json for completion detection. Uses settings.local.json (not settings.json) to avoid dirtying tracked files. + The sentinel file is unique per tracked agent name to avoid collisions when + multiple Claude sessions share the same worktree. Only applies to Claude Code agents. Merges with existing settings. """ if agent_type != "claude": @@ -220,7 +227,7 @@ def inject_completion_hook(worktree_path: Path, agent_type: str) -> None: hooks["Stop"] = stop_hooks # Check if our hook is already present - sentinel_cmd = "touch .claude/DONE" + sentinel_cmd = f"touch .claude/DONE-{agent_name}" for entry in stop_hooks: for hook in entry.get("hooks", []): cmd = hook.get("command", "") if isinstance(hook, dict) else hook diff --git a/agent_cli/dev/launch.py b/agent_cli/dev/launch.py index e5e7413e..909351a8 100644 --- a/agent_cli/dev/launch.py +++ b/agent_cli/dev/launch.py @@ -269,8 +269,6 @@ def launch_agent( Returns the tracked agent name if tracking was successful, else ``None``. """ - from .terminals.tmux import Tmux # noqa: PLC0415 - terminal = terminals.detect_current_terminal() # Use wrapper script when opening in a terminal tab - all terminals pass commands @@ -291,8 +289,8 @@ def launch_agent( repo_name = repo_root.name if repo_root else path.name tab_name = f"{repo_name}@{branch}" if branch else repo_name - # Use tmux_ops for tracked launch when in tmux - if isinstance(terminal, Tmux) and track: + # Use tmux_ops for tracked launch (tmux server must be reachable) + if track: from . import agent_state, tmux_ops # noqa: PLC0415 root = repo_root or path @@ -303,12 +301,12 @@ def launch_agent( # Remove stale completion sentinel before launching a new tracked Claude run. if agent.name == "claude": - (path / ".claude" / "DONE").unlink(missing_ok=True) + agent_state.sentinel_path(path, name).unlink(missing_ok=True) pane_id = tmux_ops.open_window_with_pane_id(path, full_cmd, tab_name=tab_name) if pane_id: agent_state.register_agent(root, name, pane_id, path, agent.name) - agent_state.inject_completion_hook(path, agent.name) + agent_state.inject_completion_hook(path, agent.name, name) success(f"Started {agent.name} in new tmux tab (tracking as [cyan]{name}[/cyan])") return name warn("Could not open new tmux window") diff --git a/agent_cli/dev/poller.py b/agent_cli/dev/poller.py index c63aa9ff..f848ed27 100644 --- a/agent_cli/dev/poller.py +++ b/agent_cli/dev/poller.py @@ -18,7 +18,7 @@ def _check_agent_status(agent: agent_state.TrackedAgent) -> None: return if agent.agent_type == "claude": - done_path = Path(agent.worktree_path) / ".claude" / "DONE" + done_path = agent_state.sentinel_path(Path(agent.worktree_path), agent.name) if done_path.exists(): agent.status = "done" return diff --git a/tests/dev/test_orchestration.py b/tests/dev/test_orchestration.py index 42c310a6..bef93e82 100644 --- a/tests/dev/test_orchestration.py +++ b/tests/dev/test_orchestration.py @@ -329,7 +329,7 @@ def test_tracked_launch_validates_name_before_opening_tmux_window(self, tmp_path def test_tracked_claude_launch_clears_stale_done_file(self, tmp_path: Path) -> None: """Tracked Claude launch removes stale completion sentinel before spawn.""" - done_path = tmp_path / ".claude" / "DONE" + done_path = tmp_path / ".claude" / "DONE-reviewer" done_path.parent.mkdir(parents=True, exist_ok=True) done_path.write_text("stale\n") @@ -699,8 +699,8 @@ class TestInjectCompletionHook: """Tests for Claude Code hook injection.""" def test_injects_stop_hook(self, tmp_path: Path) -> None: - """Creates .claude/settings.local.json with Stop hook.""" - inject_completion_hook(tmp_path, "claude") + """Creates .claude/settings.local.json with Stop hook unique to agent name.""" + inject_completion_hook(tmp_path, "claude", "my-agent") settings_path = tmp_path / ".claude" / "settings.local.json" assert settings_path.exists() @@ -710,7 +710,7 @@ def test_injects_stop_hook(self, tmp_path: Path) -> None: hooks = settings["hooks"]["Stop"] assert any( any( - isinstance(hook, dict) and hook.get("command") == "touch .claude/DONE" + isinstance(hook, dict) and hook.get("command") == "touch .claude/DONE-my-agent" for hook in h.get("hooks", []) ) for h in hooks @@ -722,7 +722,7 @@ def test_merges_with_existing_settings(self, tmp_path: Path) -> None: settings_path.parent.mkdir(parents=True) settings_path.write_text(json.dumps({"model": "opus", "hooks": {"PreToolUse": []}})) - inject_completion_hook(tmp_path, "claude") + inject_completion_hook(tmp_path, "claude", "reviewer") settings = json.loads(settings_path.read_text()) assert settings["model"] == "opus" @@ -731,13 +731,13 @@ def test_merges_with_existing_settings(self, tmp_path: Path) -> None: def test_skips_non_claude_agents(self, tmp_path: Path) -> None: """Does nothing for non-Claude agents.""" - inject_completion_hook(tmp_path, "aider") + inject_completion_hook(tmp_path, "aider", "worker") assert not (tmp_path / ".claude" / "settings.local.json").exists() def test_idempotent(self, tmp_path: Path) -> None: """Doesn't duplicate hook on repeated calls.""" - inject_completion_hook(tmp_path, "claude") - inject_completion_hook(tmp_path, "claude") + inject_completion_hook(tmp_path, "claude", "reviewer") + inject_completion_hook(tmp_path, "claude", "reviewer") settings = json.loads((tmp_path / ".claude" / "settings.local.json").read_text()) stop_hooks = settings["hooks"]["Stop"] @@ -745,7 +745,7 @@ def test_idempotent(self, tmp_path: Path) -> None: 1 for h in stop_hooks if any( - isinstance(hook, dict) and hook.get("command") == "touch .claude/DONE" + isinstance(hook, dict) and hook.get("command") == "touch .claude/DONE-reviewer" for hook in h.get("hooks", []) ) ) @@ -757,7 +757,7 @@ def test_hooks_value_is_list_not_dict(self, tmp_path: Path) -> None: settings_path.parent.mkdir(parents=True) settings_path.write_text(json.dumps({"hooks": []})) - inject_completion_hook(tmp_path, "claude") + inject_completion_hook(tmp_path, "claude", "worker") settings = json.loads(settings_path.read_text()) assert isinstance(settings["hooks"], dict) @@ -769,8 +769,24 @@ def test_stop_value_is_not_list(self, tmp_path: Path) -> None: settings_path.parent.mkdir(parents=True) settings_path.write_text(json.dumps({"hooks": {"Stop": "invalid"}})) - inject_completion_hook(tmp_path, "claude") + inject_completion_hook(tmp_path, "claude", "worker") settings = json.loads(settings_path.read_text()) assert isinstance(settings["hooks"]["Stop"], list) assert len(settings["hooks"]["Stop"]) == 1 + + def test_different_agents_get_different_sentinels(self, tmp_path: Path) -> None: + """Two agents in the same worktree get distinct sentinel commands.""" + inject_completion_hook(tmp_path, "claude", "agent-a") + inject_completion_hook(tmp_path, "claude", "agent-b") + + settings = json.loads((tmp_path / ".claude" / "settings.local.json").read_text()) + stop_hooks = settings["hooks"]["Stop"] + commands = [ + hook.get("command", "") + for h in stop_hooks + for hook in h.get("hooks", []) + if isinstance(hook, dict) + ] + assert "touch .claude/DONE-agent-a" in commands + assert "touch .claude/DONE-agent-b" in commands From 93012a0facd55c57be5b16f6ea9ef2f65b91cb6c Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 9 Feb 2026 23:03:07 -0800 Subject: [PATCH 19/20] =?UTF-8?q?fix(dev):=20PR=20review=20cleanup=20?= =?UTF-8?q?=E2=80=94=20move=20is=5Ftmux,=20inline=20=5Fstate=5Fdir,=20pres?= =?UTF-8?q?erve=20agent=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move is_tmux() from agent_state.py to tmux_ops.py where it belongs alongside other tmux operations - Inline _state_dir() into _state_file_path() (was a single-use wrapper) - Stop purging terminal agents in register_agent() so completed agents remain visible in dev poll after launching new ones - Add noqa: S607 to open_window_with_pane_id for consistency - Restore deep-nesting config test for _flatten_nested_sections - Revert unrelated pyproject.toml ruff ignore removals (COM812, D203, D213) --- agent_cli/dev/agent_state.py | 30 +----------------------------- agent_cli/dev/cli.py | 4 ++-- agent_cli/dev/orchestration.py | 4 ++-- agent_cli/dev/tmux_ops.py | 17 +++++++++++++++++ pyproject.toml | 3 +++ tests/test_config.py | 26 ++++++++++++++++++++++++++ 6 files changed, 51 insertions(+), 33 deletions(-) diff --git a/agent_cli/dev/agent_state.py b/agent_cli/dev/agent_state.py index 2064b318..4f0f01c7 100644 --- a/agent_cli/dev/agent_state.py +++ b/agent_cli/dev/agent_state.py @@ -51,14 +51,9 @@ def _repo_slug(repo_root: Path) -> str: return f"{slug}_{digest}" -def _state_dir(repo_root: Path) -> Path: - """Return the state directory for a repo.""" - return STATE_BASE / _repo_slug(repo_root) - - def _state_file_path(repo_root: Path) -> Path: """Return the path to the agents.json state file.""" - return _state_dir(repo_root) / "agents.json" + return STATE_BASE / _repo_slug(repo_root) / "agents.json" def load_state(repo_root: Path) -> AgentStateFile: @@ -129,11 +124,6 @@ def register_agent( ) -> TrackedAgent: """Register a new tracked agent in the state file.""" state = load_state(repo_root) - # Keep only active agents; terminal entries should not reserve names forever. - for existing_name in list(state.agents): - if state.agents[existing_name].status in ("done", "dead", "quiet"): - del state.agents[existing_name] - now = time.time() agent = TrackedAgent( name=name, @@ -241,21 +231,3 @@ def inject_completion_hook(worktree_path: Path, agent_type: str, agent_name: str }, ) settings_path.write_text(json.dumps(settings, indent=2) + "\n") - - -def is_tmux() -> bool: - """Check if tmux is available (inside a session or server is reachable).""" - if os.environ.get("TMUX"): - return True - # Not inside tmux, but check if a tmux server is running - import subprocess # noqa: PLC0415 - - try: - subprocess.run( - ["tmux", "list-sessions"], # noqa: S607 - check=True, - capture_output=True, - ) - return True - except (subprocess.CalledProcessError, FileNotFoundError): - return False diff --git a/agent_cli/dev/cli.py b/agent_cli/dev/cli.py index 0f554332..d10e26ab 100644 --- a/agent_cli/dev/cli.py +++ b/agent_cli/dev/cli.py @@ -913,9 +913,9 @@ def start_agent( if tab: # Launch in a new tmux tab with tracking - from . import agent_state as _agent_state # noqa: PLC0415 + from . import tmux_ops # noqa: PLC0415 - if not _agent_state.is_tmux(): + if not tmux_ops.is_tmux(): error("Agent tracking requires tmux. Start a tmux session first.") launch_agent( wt.path, diff --git a/agent_cli/dev/orchestration.py b/agent_cli/dev/orchestration.py index cca3b0e4..2d402bb2 100644 --- a/agent_cli/dev/orchestration.py +++ b/agent_cli/dev/orchestration.py @@ -33,9 +33,9 @@ def _ensure_git_repo() -> Path: def _ensure_tmux() -> None: """Exit with an error if not running inside tmux.""" - from . import agent_state as _agent_state # noqa: PLC0415 + from . import tmux_ops # noqa: PLC0415 - if not _agent_state.is_tmux(): + if not tmux_ops.is_tmux(): error("Agent tracking requires tmux. Start a tmux session first.") diff --git a/agent_cli/dev/tmux_ops.py b/agent_cli/dev/tmux_ops.py index 2bf4422a..96b0bd3e 100644 --- a/agent_cli/dev/tmux_ops.py +++ b/agent_cli/dev/tmux_ops.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import os import subprocess from typing import TYPE_CHECKING @@ -10,6 +11,22 @@ from pathlib import Path +def is_tmux() -> bool: + """Check if tmux is available (inside a session or server is reachable).""" + if os.environ.get("TMUX"): + return True + # Not inside tmux, but check if a tmux server is running + try: + subprocess.run( + ["tmux", "list-sessions"], # noqa: S607 + check=True, + capture_output=True, + ) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + def open_window_with_pane_id( path: Path, command: str | None = None, diff --git a/pyproject.toml b/pyproject.toml index 43668006..1532cece 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -199,6 +199,9 @@ ignore = [ "FBT001", # Boolean-typed positional argument in function definition "FBT002", # Boolean-typed keyword-only argument in function definition "BLE001", # Do not catch blind exception: `Exception` + "COM812", # Missing trailing comma (conflicts with formatter) + "D203", # Incorrect blank line before class (incompatible with D211) + "D213", # Multi-line summary second line (incompatible with D212) ] [tool.ruff.lint.per-file-ignores] diff --git a/tests/test_config.py b/tests/test_config.py index 284cdd9d..60043244 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -181,6 +181,32 @@ def test_config_preserves_scalar_options_when_section_has_nested_subsections( assert config["dev.agent_args"]["claude"] == ["--dangerously-skip-permissions"] +def test_config_deeply_nested_sections_with_scalars(tmp_path: Path) -> None: + """Deeply nested sections (agent_env.claude) are flattened with dot notation.""" + config_content = """ +[dev] +branch_name_mode = "ai" + +[dev.agent_args] +claude = ["--dangerously-skip-permissions"] +codex = ["--dangerously-bypass-approvals-and-sandbox"] + +[dev.agent_env.claude] +CLAUDE_CODE_USE_VERTEX = "1" +ANTHROPIC_MODEL = "claude-opus-4-6" +""" + config_path = tmp_path / "config.toml" + config_path.write_text(config_content) + + config = load_config(str(config_path)) + + assert config["dev"]["branch_name_mode"] == "ai" + assert config["dev.agent_args"]["claude"] == ["--dangerously-skip-permissions"] + assert config["dev.agent_args"]["codex"] == ["--dangerously-bypass-approvals-and-sandbox"] + assert config["dev.agent_env.claude"]["CLAUDE_CODE_USE_VERTEX"] == "1" + assert config["dev.agent_env.claude"]["ANTHROPIC_MODEL"] == "claude-opus-4-6" + + def test_provider_alias_normalization(config_file: Path) -> None: """Ensure deprecated provider names are normalized.""" config = load_config(str(config_file)) From 14602f4862abde08abe05dc710d846115b367c93 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 9 Feb 2026 23:08:48 -0800 Subject: [PATCH 20/20] fix(dev): harden against corrupt state files and fix --tab tmux guard Address Codex review findings: 1. (High) load_state now skips non-dict agent entries instead of crashing with AttributeError on malformed cache files. 2. (Medium) inject_completion_hook skips non-dict entries in the Stop hooks list instead of crashing with AttributeError. 3. (Medium) dev agent --tab now checks TMUX env var (inside a session) instead of is_tmux() (any reachable server), preventing silent untracked launches from non-tmux terminals. 4. (Low) Add docs for dev poll, dev output, dev send, dev wait to docs/commands/dev.md. --- agent_cli/dev/agent_state.py | 6 +- agent_cli/dev/cli.py | 10 +- docs/commands/dev.md | 157 ++++++++++++++++++++++++++++++++ tests/dev/test_orchestration.py | 46 ++++++++++ 4 files changed, 213 insertions(+), 6 deletions(-) diff --git a/agent_cli/dev/agent_state.py b/agent_cli/dev/agent_state.py index 4f0f01c7..7c5c99e1 100644 --- a/agent_cli/dev/agent_state.py +++ b/agent_cli/dev/agent_state.py @@ -75,6 +75,8 @@ def load_state(repo_root: Path) -> AgentStateFile: if not isinstance(raw_agents, dict): raw_agents = {} for name, agent_data in raw_agents.items(): + if not isinstance(agent_data, dict): + continue status = agent_data.get("status", "running") if status not in ("running", "done", "dead", "quiet"): status = "running" @@ -219,9 +221,11 @@ def inject_completion_hook(worktree_path: Path, agent_type: str, agent_name: str # Check if our hook is already present sentinel_cmd = f"touch .claude/DONE-{agent_name}" for entry in stop_hooks: + if not isinstance(entry, dict): + continue for hook in entry.get("hooks", []): cmd = hook.get("command", "") if isinstance(hook, dict) else hook - if sentinel_cmd in cmd: + if isinstance(cmd, str) and sentinel_cmd in cmd: return # Already injected stop_hooks.append( diff --git a/agent_cli/dev/cli.py b/agent_cli/dev/cli.py index d10e26ab..5b7ac6e1 100644 --- a/agent_cli/dev/cli.py +++ b/agent_cli/dev/cli.py @@ -912,11 +912,11 @@ def start_agent( agent_env = get_agent_env(agent) if tab: - # Launch in a new tmux tab with tracking - from . import tmux_ops # noqa: PLC0415 - - if not tmux_ops.is_tmux(): - error("Agent tracking requires tmux. Start a tmux session first.") + # Launch in a new tmux tab with tracking — require being *inside* a tmux + # session (TMUX env var), not just having a reachable tmux server, so the + # new window is visible in the user's current session. + if not os.environ.get("TMUX"): + error("--tab requires running inside a tmux session. Start tmux first.") launch_agent( wt.path, agent, diff --git a/docs/commands/dev.md b/docs/commands/dev.md index d2a13d88..5ca2ae5e 100644 --- a/docs/commands/dev.md +++ b/docs/commands/dev.md @@ -301,6 +301,163 @@ agent-cli dev agent my-feature --prompt "Continue implementing the user settings agent-cli dev agent my-feature -a aider --prompt "Add unit tests for the auth module" ``` +## Agent Orchestration + +These commands require tmux and work with tracked agents (launched via `dev new -a` or `dev agent --tab`). + +### `dev poll` + +Check status of all tracked agents. + +```bash +agent-cli dev poll [OPTIONS] +``` + + + + + + + +### Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--json` | `false` | Output as JSON | + + + + +**Status values:** + +| Status | Meaning | +|--------|---------| +| **running** | Agent pane exists and task is still in progress | +| **done** | Agent wrote a completion sentinel (`.claude/DONE`) | +| **quiet** | Agent output unchanged for several consecutive polls | +| **dead** | tmux pane no longer exists | + +**Examples:** + +```bash +# Show status table +agent-cli dev poll + +# Machine-readable output +agent-cli dev poll --json +``` + +### `dev output` + +Get recent terminal output from a tracked agent. + +```bash +agent-cli dev output NAME [OPTIONS] +``` + + + + + + + +### Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--lines, -n` | `50` | Number of lines to capture | + + + + +**Examples:** + +```bash +# Last 50 lines from agent +agent-cli dev output my-feature + +# Last 200 lines +agent-cli dev output my-feature -n 200 +``` + +### `dev send` + +Send text input to a running agent's terminal. + +```bash +agent-cli dev send NAME MESSAGE [OPTIONS] +``` + + + + + + + +### Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--no-enter` | `false` | Don't press Enter after sending | + + + + +**Examples:** + +```bash +# Send a message to an agent +agent-cli dev send my-feature "Fix the failing tests" + +# Send without pressing Enter +agent-cli dev send my-feature "/exit" --no-enter +``` + +### `dev wait` + +Block until a tracked agent finishes. + +```bash +agent-cli dev wait NAME [OPTIONS] +``` + + + + + + + +### Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--timeout, -t` | `0` | Timeout in seconds (0 = no timeout) | +| `--interval, -i` | `5.0` | Poll interval in seconds | + + + + +**Exit codes:** + +| Code | Meaning | +|------|---------| +| 0 | Agent finished (done or quiet) | +| 1 | Agent died (pane closed unexpectedly) | +| 2 | Timeout reached | + +**Examples:** + +```bash +# Wait indefinitely +agent-cli dev wait my-feature + +# Wait up to 5 minutes +agent-cli dev wait my-feature --timeout 300 + +# Poll every 2 seconds +agent-cli dev wait my-feature -i 2 +``` + ### `dev run` Run a command in a dev environment. diff --git a/tests/dev/test_orchestration.py b/tests/dev/test_orchestration.py index bef93e82..eef08240 100644 --- a/tests/dev/test_orchestration.py +++ b/tests/dev/test_orchestration.py @@ -284,6 +284,35 @@ def test_load_state_agents_is_list(self, tmp_path: Path) -> None: state = agent_state.load_state(tmp_path / "repo") assert state.agents == {} + def test_load_state_skips_non_dict_agent_data(self, tmp_path: Path) -> None: + """Skips agent entries that are not dicts (e.g. from partially corrupt cache).""" + with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): + repo = tmp_path / "repo" + state_path = agent_state._state_file_path(repo) + state_path.parent.mkdir(parents=True, exist_ok=True) + state_path.write_text( + json.dumps( + { + "agents": { + "good": { + "name": "good", + "pane_id": "%1", + "worktree_path": "/tmp/wt", # noqa: S108 + "agent_type": "claude", + "started_at": 100.0, + "status": "running", + }, + "bad_string": "not a dict", + "bad_int": 42, + "bad_list": [1, 2, 3], + }, + }, + ), + ) + state = agent_state.load_state(repo) + assert len(state.agents) == 1 + assert "good" in state.agents + def test_save_state_uses_pid_in_temp_file(self, tmp_path: Path) -> None: """Temp file includes PID to avoid races between concurrent writers.""" with patch.object(agent_state, "STATE_BASE", tmp_path / ".cache"): @@ -790,3 +819,20 @@ def test_different_agents_get_different_sentinels(self, tmp_path: Path) -> None: ] assert "touch .claude/DONE-agent-a" in commands assert "touch .claude/DONE-agent-b" in commands + + def test_stop_hooks_with_non_dict_entries(self, tmp_path: Path) -> None: + """Non-dict entries in Stop list don't crash hook injection.""" + settings_path = tmp_path / ".claude" / "settings.local.json" + settings_path.parent.mkdir(parents=True) + settings_path.write_text( + json.dumps({"hooks": {"Stop": ["stale-string", 42, None]}}), + ) + + inject_completion_hook(tmp_path, "claude", "worker") + + settings = json.loads(settings_path.read_text()) + stop_hooks = settings["hooks"]["Stop"] + # Non-dict entries preserved, our hook appended + assert len(stop_hooks) == 4 + last_hook = stop_hooks[-1] + assert last_hook["hooks"][0]["command"] == "touch .claude/DONE-worker"