From 64c56da2522657d48dc6e8fc016b1baf8e6fe137 Mon Sep 17 00:00:00 2001 From: Ezop <61061207+Ezopek@users.noreply.github.com> Date: Sun, 7 Jun 2026 01:12:35 +0200 Subject: [PATCH 1/8] feat: support gemini and custom agent runtimes --- README.md | 6 +- docs/agents-and-monitoring.md | 23 ++++-- .../data/agent-fallback.md | 6 +- .../data/tmux-commands.md | 14 ++-- .../src/story_automator/adapters/tmux.py | 2 +- .../src/story_automator/commands/tmux.py | 12 +++- .../src/story_automator/core/tmux_runtime.py | 71 ++++++++++++++++--- tests/test_agent_config_model.py | 66 ++++++++++++++++- 8 files changed, 169 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 783d1837..6e7bcb7f 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ Current caveat: the official registry sets `automator` to `default_channel: next - This is an orchestrator, not a correctness guarantee. Bad planning artifacts still produce bad implementation runs. - The npm installer writes the skill into every supported dependency skill root that is complete: `.agents/skills`, `.claude/skills`, and/or `.codex/skills`. -- Child sessions can use Claude or Codex depending on agent configuration. +- Child sessions can use Claude, Codex, Gemini, or a custom non-Codex agent command depending on agent configuration. - Retrospectives inherit the configured primary agent by default, and can be overridden explicitly via `agentConfig`. - The automator expects sprint planning to be complete before it starts. - Review completion is gated by verification, not by child-session exit alone. @@ -195,7 +195,7 @@ Host requirements: - `python3` 3.11+ - `tmux` -- Claude Code or Codex runtime access +- Claude Code, Codex, Gemini CLI, or configured custom-agent runtime access - macOS, Linux, or Windows via WSL Target project requirements: @@ -207,7 +207,7 @@ Target project requirements: - `bmad-retrospective` - optional `bmad-qa-generate-e2e-tests` -Claude-only, Codex-only, and mixed projects are all supported. The installer updates each supported root that already contains the required dependency `SKILL.md` files. +Claude-only, Codex-only, Gemini-assisted, custom-agent, and mixed projects are all supported. The installer updates each supported root that already contains the required dependency `SKILL.md` files. Dependency skill internals such as `workflow.md` are optional. If the QA skill is missing, install still succeeds. Run Story Automator with `Skip Automate = true` unless the QA skill is installed. diff --git a/docs/agents-and-monitoring.md b/docs/agents-and-monitoring.md index 5121615d..a3d8b298 100644 --- a/docs/agents-and-monitoring.md +++ b/docs/agents-and-monitoring.md @@ -7,7 +7,7 @@ This doc explains how Story Automator chooses child agents, builds child-session There are two distinct agent layers: - the orchestrator itself, which runs from a supported top-level agent session -- child sessions, which can run Claude or Codex depending on the agent plan +- child sessions, which can run Claude, Codex, Gemini, or configured custom non-Codex agent commands depending on the agent plan Agent selection is driven by: @@ -74,14 +74,27 @@ sequenceDiagram Environment details: - `STORY_AUTOMATOR_CHILD=true` -- `AI_AGENT=` +- `AI_AGENT=` - Codex child sessions use isolated `CODEX_HOME` under `/tmp` +- Gemini child sessions use `gemini -p` by default +- custom non-Codex agents can be configured with `STORY_AUTOMATOR_AGENT__COMMAND`; set `STORY_AUTOMATOR_AGENT__PROCESS` only when the monitor should look for a different process name -## Claude vs Codex +## Agent CLI selection -Python Story Automator does support Codex child sessions. +Python Story Automator supports Claude, Codex, Gemini, and custom non-Codex child sessions. Unknown agent names fail fast unless a custom command is configured, so typos do not silently run under Claude. -That is a major difference from the older Go README guidance. +Built-in commands: + +- `claude` → `claude --dangerously-skip-permissions` +- `codex` → `codex exec` / isolated Codex execution path +- `gemini` → `gemini -p` + +Custom command example: + +```bash +export STORY_AUTOMATOR_AGENT_GLM_COMMAND='glm-cli --prompt' +export STORY_AUTOMATOR_AGENT_GLM_PROCESS='glm-cli' # optional; defaults to agent name +``` Important Codex-specific behavior: diff --git a/skills/bmad-story-automator/data/agent-fallback.md b/skills/bmad-story-automator/data/agent-fallback.md index ae7997b9..fba312d2 100644 --- a/skills/bmad-story-automator/data/agent-fallback.md +++ b/skills/bmad-story-automator/data/agent-fallback.md @@ -1,6 +1,6 @@ # Agent Fallback Strategy (v3.0.0) -**Multi-Agent Support:** The orchestrator can use Claude or Codex as AI coding agents, with automatic fallback on failure. +**Multi-Agent Support:** The orchestrator can use Claude, Codex, Gemini, or configured custom non-Codex commands as AI coding agents, with automatic fallback on failure. ## Configuration @@ -29,8 +29,10 @@ Agent selection is resolved via the deterministic agents file created in preflig |-------|-----|--------------|---------|---------------| | Claude | `claude --dangerously-skip-permissions` | Natural language skill prompt | 60min | ☒/☐ checkboxes | | Codex | `codex exec --full-auto` | Natural language prompt | 90min (1.5x) | Not supported | +| Gemini | `gemini -p` | Natural language skill prompt | 60min | best-effort via generic monitor | +| Custom | `STORY_AUTOMATOR_AGENT__COMMAND` | Natural language skill prompt | 60min | best-effort via configured process pattern | -**CRITICAL:** Both Claude and Codex prompts must name the skill/workflow to execute and include the story ID. +**CRITICAL:** All child-agent prompts must name the skill/workflow to execute and include the story ID. The `story-automator tmux-wrapper build-cmd` function automatically generates the correct prompt format based on `AI_AGENT` environment variable. diff --git a/skills/bmad-story-automator/data/tmux-commands.md b/skills/bmad-story-automator/data/tmux-commands.md index f7faecab..8490fd2c 100644 --- a/skills/bmad-story-automator/data/tmux-commands.md +++ b/skills/bmad-story-automator/data/tmux-commands.md @@ -188,15 +188,15 @@ tmux capture-pane -t "SESSION" -p -S -100 # Raw capture (use sparingly) **Agent Configuration (v1.3.0):** -| Variable | Claude | Codex | -|----------|--------|-------| -| CLI | `claude --dangerously-skip-permissions` | `codex exec --full-auto` | -| Prompt Style | Natural language skill prompt | Natural language skill prompt | -| Timeout Multiplier | 1x (60min) | 1.5x (90min) | -| Todo Tracking | ☒/☐ checkboxes | Not supported | +| Variable | Claude | Codex | Gemini | Custom non-Codex | +|----------|--------|-------|--------|------------------| +| CLI | `claude --dangerously-skip-permissions` | `codex exec --full-auto` | `gemini -p` | `STORY_AUTOMATOR_AGENT__COMMAND` | +| Prompt Style | Natural language skill prompt | Natural language skill prompt | Natural language skill prompt | Natural language skill prompt | +| Timeout Multiplier | 1x (60min) | 1.5x (90min) | 1x (60min) | 1x (60min) | +| Todo Tracking | ☒/☐ checkboxes | Not supported | best-effort generic monitor | best-effort generic monitor | **Environment Variables:** -- `AI_AGENT` = `claude` or `codex` (used by story-automator tmux-wrapper and story-automator monitor-session) +- `AI_AGENT` = `claude`, `codex`, `gemini`, or a custom agent name (used by story-automator tmux-wrapper and story-automator monitor-session) - `AI_COMMAND` = Full CLI (legacy, deprecated) `{projectPath}` = project root diff --git a/skills/bmad-story-automator/src/story_automator/adapters/tmux.py b/skills/bmad-story-automator/src/story_automator/adapters/tmux.py index 5421a015..14cd5115 100644 --- a/skills/bmad-story-automator/src/story_automator/adapters/tmux.py +++ b/skills/bmad-story-automator/src/story_automator/adapters/tmux.py @@ -7,7 +7,7 @@ from ..core.tmux_runtime import ( agent_cli, agent_type, - detect_codex_session, + detect_agent_session, estimate_wait, extract_active_task, generate_session_name, diff --git a/skills/bmad-story-automator/src/story_automator/commands/tmux.py b/skills/bmad-story-automator/src/story_automator/commands/tmux.py index 1d62e10c..d1df0455 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/tmux.py +++ b/skills/bmad-story-automator/src/story_automator/commands/tmux.py @@ -97,7 +97,11 @@ def cmd_tmux_wrapper(args: list[str]) -> int: idx += 2 continue idx += 1 - print(agent_cli(agent_type(), model)) + try: + print(agent_cli(agent_type(), model)) + except ValueError as exc: + print(str(exc), file=__import__("sys").stderr) + return 1 return 0 if action == "skill-prefix": print(skill_prefix(agent_type())) @@ -203,7 +207,11 @@ def _build_cmd(args: list[str]) -> int: if ai_command and not os.environ.get("AI_AGENT"): cli = ai_command elif agent != "codex": - cli = agent_cli(agent, model) + try: + cli = agent_cli(agent, model) + except ValueError as exc: + print(str(exc), file=__import__("sys").stderr) + return 1 else: cli = "codex exec" quoted_prompt = shlex.quote(prompt) diff --git a/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py b/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py index 75dbe1a8..fa75a2f0 100644 --- a/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py +++ b/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py @@ -79,23 +79,70 @@ def generate_session_name(step: str, epic: str, story_id: str, cycle: str = "") def agent_type() -> str: - value = os.environ.get("AI_AGENT", "").strip().lower() - if value in {"claude", "codex"}: + value = normalize_agent_name(os.environ.get("AI_AGENT", "")) + if value: return value return runtime_provider() def agent_cli(agent: str, model: str = "") -> str: + agent = normalize_agent_name(agent) model = (model or "").strip() - if agent == "codex": + custom = custom_agent_command(agent) + if custom: + base = custom + elif agent in {"", "claude"}: + base = "claude --dangerously-skip-permissions" + elif agent == "codex": base = "codex exec" + elif agent == "gemini": + base = "gemini -p" else: - base = "claude --dangerously-skip-permissions" + raise ValueError( + f"unsupported agent {agent!r}; supported agents are claude, codex, gemini, " + f"or set STORY_AUTOMATOR_AGENT_{env_agent_name(agent)}_COMMAND" + ) if model: base = f"{base} --model {shlex.quote(model)}" return base +def custom_agent_command(agent: str) -> str: + if not agent: + return "" + for key in ( + f"STORY_AUTOMATOR_AGENT_{env_agent_name(agent)}_COMMAND", + f"AI_COMMAND_{env_agent_name(agent)}", + ): + value = os.environ.get(key, "").strip() + if value: + return value + return "" + + +def normalize_agent_name(agent: str) -> str: + return (agent or "").strip().lower() + + +def env_agent_name(agent: str) -> str: + clean = re.sub(r"[^A-Za-z0-9]+", "_", agent or "").strip("_") + return clean.upper() + + +def agent_process_pattern(agent: str) -> str: + agent = normalize_agent_name(agent) + if not agent: + return "claude" + custom = os.environ.get(f"STORY_AUTOMATOR_AGENT_{env_agent_name(agent)}_PROCESS", "").strip() + if custom: + return custom + if agent == "codex": + return "codex" + if agent == "gemini": + return "gemini" + return agent + + def skill_prefix(agent: str) -> str: return "none" if agent == "codex" else "bmad-" @@ -348,11 +395,14 @@ def extract_active_task(capture: str) -> str: return active[:80] -def detect_codex_session(session: str, capture: str) -> str: - if tmux_show_environment(session, "AI_AGENT") == "codex": - return "codex" +def detect_agent_session(session: str, capture: str) -> str: + env_agent = normalize_agent_name(tmux_show_environment(session, "AI_AGENT")) + if env_agent: + return env_agent if re.search(r"(?i)OpenAI Codex|codex exec|gpt-[0-9]+-codex|tokens used|codex-cli", capture): return "codex" + if re.search(r"(?i)Gemini|gemini -p|gemini-cli", capture): + return "gemini" return "claude" @@ -861,8 +911,9 @@ def _legacy_claude_session_status( "session_state": "completed", } + agent = detect_agent_session(session, capture) pane_pid = _safe_int(tmux_display(session, "#{pane_pid}")) - claude_running = pane_pid > 0 and run_cmd("pgrep", "-P", str(pane_pid), "-f", "claude")[1] == 0 + agent_running = pane_pid > 0 and run_cmd("pgrep", "-P", str(pane_pid), "-f", agent_process_pattern(agent))[1] == 0 activity_detected = bool( re.search( r"(?i)ctrl\+c to interrupt|Musing|Thinking|Working|Running|Loading|Beaming|Galloping|Razzmatazzing|Creating|⏺|✻|·", @@ -870,8 +921,8 @@ def _legacy_claude_session_status( ) ) - if activity_detected or claude_running: - active_task = extract_active_task(capture) or "Claude working" + if activity_detected or agent_running: + active_task = extract_active_task(capture) or f"{agent.title()} working" wait_estimate = estimate_wait(active_task, todos_done, todos_total) _save_legacy_state( state_path, diff --git a/tests/test_agent_config_model.py b/tests/test_agent_config_model.py index 32f27469..3b7ce594 100644 --- a/tests/test_agent_config_model.py +++ b/tests/test_agent_config_model.py @@ -17,7 +17,12 @@ resolve_agent_for_task, resolve_agents, ) -from story_automator.core.tmux_runtime import agent_cli +from story_automator.core.tmux_runtime import ( + agent_cli, + agent_process_pattern, + detect_agent_session, + env_agent_name, +) from story_automator.commands.orchestrator import cmd_orchestrator_helper from story_automator.commands.orchestrator_epic_agents import ( parse_agent_config, @@ -35,6 +40,35 @@ def test_agent_cli_without_model_unchanged(self) -> None: self.assertEqual(agent_cli("claude"), "claude --dangerously-skip-permissions") self.assertEqual(agent_cli("codex"), "codex exec") + def test_agent_cli_supports_gemini(self) -> None: + self.assertEqual(agent_cli("gemini"), "gemini -p") + self.assertEqual(agent_cli(" Gemini "), "gemini -p") + + def test_agent_cli_rejects_unknown_without_custom_command(self) -> None: + with self.assertRaisesRegex(ValueError, "unsupported agent"): + agent_cli("not-a-real-agent") + + def test_agent_cli_supports_custom_command(self) -> None: + with patch.dict(os.environ, {"STORY_AUTOMATOR_AGENT_MY_AGENT_COMMAND": "my-agent --prompt"}, clear=False): + self.assertEqual(agent_cli("my-agent"), "my-agent --prompt") + + def test_agent_cli_supports_legacy_custom_command_env(self) -> None: + with patch.dict(os.environ, {"AI_COMMAND_MY_AGENT": "legacy-agent --prompt"}, clear=False): + self.assertEqual(agent_cli("my-agent"), "legacy-agent --prompt") + + def test_agent_process_pattern_supports_gemini_and_custom(self) -> None: + self.assertEqual(agent_process_pattern("gemini"), "gemini") + self.assertEqual(agent_process_pattern("my-agent"), "my-agent") + with patch.dict(os.environ, {"STORY_AUTOMATOR_AGENT_MY_AGENT_PROCESS": "my-agent-bin"}, clear=False): + self.assertEqual(agent_process_pattern("my-agent"), "my-agent-bin") + + def test_env_agent_name_normalizes_for_env_vars(self) -> None: + self.assertEqual(env_agent_name("gemini-pro preview"), "GEMINI_PRO_PREVIEW") + + def test_detect_agent_session_supports_gemini_capture(self) -> None: + with patch("story_automator.core.tmux_runtime.tmux_show_environment", return_value=""): + self.assertEqual(detect_agent_session("session", "Gemini CLI ready"), "gemini") + def test_agent_cli_with_model_for_claude(self) -> None: self.assertEqual( agent_cli("claude", "claude-sonnet-4-6"), @@ -580,6 +614,36 @@ def test_build_cmd_injects_model_for_codex(self) -> None: self.assertIn("--model gpt-5.5", rendered) self.assertIn("codex exec -s workspace-write", rendered) + def test_build_cmd_uses_gemini_without_claude_fallback(self) -> None: + stdout = io.StringIO() + with patch.dict(os.environ, {"PROJECT_ROOT": str(self.project_root), "AI_AGENT": "gemini"}, clear=False), redirect_stdout(stdout): + code = _build_cmd(["review", "9.1", "--agent", "gemini"]) + self.assertEqual(code, 0) + rendered = stdout.getvalue() + self.assertIn("gemini -p", rendered) + self.assertNotIn("claude --dangerously-skip-permissions", rendered) + + def test_build_cmd_rejects_unknown_agent_without_claude_fallback(self) -> None: + stdout = io.StringIO() + stderr = io.StringIO() + with patch.dict(os.environ, {"PROJECT_ROOT": str(self.project_root), "AI_AGENT": "claude"}, clear=False), redirect_stdout(stdout), __import__("contextlib").redirect_stderr(stderr): + code = _build_cmd(["review", "9.1", "--agent", "gemini-pro"]) + self.assertEqual(code, 1) + self.assertNotIn("claude --dangerously-skip-permissions", stdout.getvalue()) + self.assertIn("unsupported agent", stderr.getvalue()) + + def test_build_cmd_allows_custom_agent_command(self) -> None: + stdout = io.StringIO() + env = { + "PROJECT_ROOT": str(self.project_root), + "AI_AGENT": "gemini-pro", + "STORY_AUTOMATOR_AGENT_GEMINI_PRO_COMMAND": "gemini -p --model pro", + } + with patch.dict(os.environ, env, clear=False), redirect_stdout(stdout): + code = _build_cmd(["review", "9.1", "--agent", "gemini-pro"]) + self.assertEqual(code, 0) + self.assertIn("gemini -p --model pro", stdout.getvalue()) + def test_build_cmd_without_model_unchanged(self) -> None: stdout = io.StringIO() with patch.dict(os.environ, {"PROJECT_ROOT": str(self.project_root), "AI_AGENT": "claude"}, clear=False), redirect_stdout(stdout): From 60ac970be2746c1edd12c60e73c6782dada4bb77 Mon Sep 17 00:00:00 2001 From: Ezop <61061207+Ezopek@users.noreply.github.com> Date: Sun, 7 Jun 2026 01:24:03 +0200 Subject: [PATCH 2/8] fix: make agent liveness checks descendant-aware --- .../data/tmux-commands.md | 2 +- .../src/story_automator/core/tmux_runtime.py | 58 ++++++++++++++++++- tests/test_tmux_runtime.py | 43 ++++++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/skills/bmad-story-automator/data/tmux-commands.md b/skills/bmad-story-automator/data/tmux-commands.md index 8490fd2c..83d6bb2b 100644 --- a/skills/bmad-story-automator/data/tmux-commands.md +++ b/skills/bmad-story-automator/data/tmux-commands.md @@ -190,7 +190,7 @@ tmux capture-pane -t "SESSION" -p -S -100 # Raw capture (use sparingly) | Variable | Claude | Codex | Gemini | Custom non-Codex | |----------|--------|-------|--------|------------------| -| CLI | `claude --dangerously-skip-permissions` | `codex exec --full-auto` | `gemini -p` | `STORY_AUTOMATOR_AGENT__COMMAND` | +| CLI | `claude --dangerously-skip-permissions` | `codex exec -s workspace-write -c 'approval_policy="never"' -c 'model_reasoning_effort="high"' --disable plugins --disable sqlite --disable shell_snapshot` | `gemini -p` | `STORY_AUTOMATOR_AGENT__COMMAND` | | Prompt Style | Natural language skill prompt | Natural language skill prompt | Natural language skill prompt | Natural language skill prompt | | Timeout Multiplier | 1x (60min) | 1.5x (90min) | 1x (60min) | 1x (60min) | | Todo Tracking | ☒/☐ checkboxes | Not supported | best-effort generic monitor | best-effort generic monitor | diff --git a/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py b/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py index fa75a2f0..4f1b4f69 100644 --- a/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py +++ b/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py @@ -913,7 +913,7 @@ def _legacy_claude_session_status( agent = detect_agent_session(session, capture) pane_pid = _safe_int(tmux_display(session, "#{pane_pid}")) - agent_running = pane_pid > 0 and run_cmd("pgrep", "-P", str(pane_pid), "-f", agent_process_pattern(agent))[1] == 0 + agent_running = _pane_has_agent_descendant(pane_pid, agent_process_pattern(agent)) activity_detected = bool( re.search( r"(?i)ctrl\+c to interrupt|Musing|Thinking|Working|Running|Loading|Beaming|Galloping|Razzmatazzing|Creating|⏺|✻|·", @@ -1303,6 +1303,62 @@ def _find_agent_pid(parent: str, pattern: str, depth: int) -> str: return "" +def _pane_has_agent_descendant(pane_pid: int, pattern: str) -> bool: + """Return True if any process matching ``pattern`` is a descendant of ``pane_pid``. + + ``pgrep -P`` only matches direct children, so it misses agents launched + through a wrapper or shell (the agent then runs as a grandchild). Here we + collect candidate PIDs by command pattern and confirm ancestry by walking + parent PIDs back up to ``pane_pid``. + """ + if pane_pid <= 0: + return False + output, code = run_cmd("pgrep", "-f", pattern) + if code != 0: + return False + for line in output.splitlines(): + candidate = _safe_int(line.strip()) + if candidate <= 0 or candidate == pane_pid: + continue + if _pid_has_ancestor(candidate, pane_pid): + return True + return False + + +def _pid_has_ancestor(pid: int, ancestor: int, max_depth: int = 32) -> bool: + current = pid + seen: set[int] = set() + for _ in range(max_depth): + ppid = _process_ppid(current) + if ppid <= 0 or ppid == current or ppid in seen: + return False + if ppid == ancestor: + return True + seen.add(ppid) + current = ppid + return False + + +def _process_ppid(pid: int) -> int: + """Return the parent PID of ``pid`` (prefer ``/proc`` on Linux, fall back to ``ps``).""" + try: + raw = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8", errors="replace") + except OSError: + raw = "" + if raw: + # Format: "pid (comm) state ppid ...". comm may contain spaces or + # parentheses, so split after the final ')' to read state/ppid safely. + rparen = raw.rfind(")") + if rparen != -1: + fields = raw[rparen + 1:].split() + if len(fields) >= 2: + return _safe_int(fields[1]) + output, code = run_cmd("ps", "-o", "ppid=", "-p", str(pid)) + if code != 0: + return 0 + return _safe_int(output.strip()) + + def _process_cpu(pid: int) -> float: output, code = run_cmd("ps", "-o", "%cpu=", "-p", str(pid)) if code != 0: diff --git a/tests/test_tmux_runtime.py b/tests/test_tmux_runtime.py index 9a4a97f5..96fa65f7 100644 --- a/tests/test_tmux_runtime.py +++ b/tests/test_tmux_runtime.py @@ -13,6 +13,8 @@ _check_prompt_visible, _claude_completion_marker_present, _legacy_heartbeat_check, + _pane_has_agent_descendant, + _pid_has_ancestor, _reconcile_runner_state, _runner_file_content, cleanup_runtime_artifacts, @@ -482,5 +484,46 @@ def test_legacy_heartbeat_treats_fractional_cpu_as_alive(self) -> None: self.assertEqual(prompt, "false") +class PaneDescendantDetectionTests(unittest.TestCase): + def test_detects_grandchild_agent_via_ancestry_walk(self) -> None: + # pane(100) -> shell(200) -> claude(300); pgrep -f matches only the grandchild. + parents = {300: 200, 200: 100} + with ( + mock.patch("story_automator.core.tmux_runtime.run_cmd", return_value=("300\n", 0)), + mock.patch( + "story_automator.core.tmux_runtime._process_ppid", + side_effect=lambda pid: parents.get(pid, 0), + ), + ): + self.assertTrue(_pane_has_agent_descendant(100, "claude")) + + def test_ignores_matching_process_outside_pane_tree(self) -> None: + # An unrelated claude process whose ancestry never reaches the pane pid. + parents = {300: 999, 999: 1} + with ( + mock.patch("story_automator.core.tmux_runtime.run_cmd", return_value=("300\n", 0)), + mock.patch( + "story_automator.core.tmux_runtime._process_ppid", + side_effect=lambda pid: parents.get(pid, 0), + ), + ): + self.assertFalse(_pane_has_agent_descendant(100, "claude")) + + def test_no_match_when_pgrep_finds_nothing(self) -> None: + with mock.patch("story_automator.core.tmux_runtime.run_cmd", return_value=("", 1)): + self.assertFalse(_pane_has_agent_descendant(100, "claude")) + + def test_invalid_pane_pid_short_circuits(self) -> None: + self.assertFalse(_pane_has_agent_descendant(0, "claude")) + + def test_pid_has_ancestor_handles_cycles(self) -> None: + # Defensive: a self/looping parent chain must terminate, not hang. + with mock.patch( + "story_automator.core.tmux_runtime._process_ppid", + side_effect=lambda pid: pid, + ): + self.assertFalse(_pid_has_ancestor(300, 100)) + + if __name__ == "__main__": unittest.main() From b65f8b9d26b93ed587c7a115de9934cf061334f2 Mon Sep 17 00:00:00 2001 From: Ezop <61061207+Ezopek@users.noreply.github.com> Date: Sun, 7 Jun 2026 02:27:58 +0200 Subject: [PATCH 3/8] fix: preserve gemini from agent environment --- docs/agents-and-monitoring.md | 6 +- .../src/story_automator/commands/tmux.py | 4 +- tests/test_agent_config_model.py | 56 ++++++++++++++++++- 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/docs/agents-and-monitoring.md b/docs/agents-and-monitoring.md index a3d8b298..564ecabe 100644 --- a/docs/agents-and-monitoring.md +++ b/docs/agents-and-monitoring.md @@ -81,9 +81,11 @@ Environment details: ## Agent CLI selection -Python Story Automator supports Claude, Codex, Gemini, and custom non-Codex child sessions. Unknown agent names fail fast unless a custom command is configured, so typos do not silently run under Claude. +Python Story Automator supports Claude, Codex, Gemini, and custom non-Codex child sessions at the runtime/dispatch layer. Unknown agent names fail fast unless a custom command is configured, so typos do not silently run under Claude. -Built-in commands: +Runtime support means the wrapper launches the requested CLI and monitors the requested process. The agent still needs a local tool contract capable of the target workflow. For example, BMAD create/dev/auto/review steps require file reads/writes and often shell/test execution; a Gemini CLI setup without those tools may launch correctly but fail inside the workflow. + +Built-in command mapping: - `claude` → `claude --dangerously-skip-permissions` - `codex` → `codex exec` / isolated Codex execution path diff --git a/skills/bmad-story-automator/src/story_automator/commands/tmux.py b/skills/bmad-story-automator/src/story_automator/commands/tmux.py index d1df0455..68176cb8 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/tmux.py +++ b/skills/bmad-story-automator/src/story_automator/commands/tmux.py @@ -482,7 +482,7 @@ def _raw_agent_selection() -> str: inferred = _infer_agent_from_command(os.environ.get("AI_COMMAND", "")) if inferred: return inferred - return value if value in {"claude", "codex", "auto", "runtime"} else "auto" + return value if value in {"claude", "codex", "gemini", "auto", "runtime"} else "auto" def _resolve_agent_selection(agent: str, project_root: str) -> str: @@ -502,4 +502,6 @@ def _infer_agent_from_command(command: str) -> str: return "codex" if "claude" in executable: return "claude" + if "gemini" in executable: + return "gemini" return "" diff --git a/tests/test_agent_config_model.py b/tests/test_agent_config_model.py index 3b7ce594..26395a78 100644 --- a/tests/test_agent_config_model.py +++ b/tests/test_agent_config_model.py @@ -29,7 +29,11 @@ resolve_agent, ) from story_automator.commands.state import cmd_build_state_doc -from story_automator.commands.tmux import _build_cmd +from story_automator.commands.tmux import ( + _build_cmd, + _infer_agent_from_command, + _raw_agent_selection, +) REPO_ROOT = Path(__file__).resolve().parents[1] @@ -587,6 +591,43 @@ def test_build_cmd_missing_model_value_fails(self) -> None: self.assertIn("--model requires a value", err.getvalue()) +class RawAgentSelectionTests(unittest.TestCase): + def test_ai_agent_gemini_is_preserved(self) -> None: + with patch.dict(os.environ, {"AI_AGENT": "gemini"}, clear=False): + self.assertEqual(_raw_agent_selection(), "gemini") + + def test_ai_agent_gemini_is_normalized(self) -> None: + with patch.dict(os.environ, {"AI_AGENT": " GEMINI "}, clear=False): + self.assertEqual(_raw_agent_selection(), "gemini") + + def test_known_agents_are_preserved(self) -> None: + for agent in ("claude", "codex", "gemini", "auto", "runtime"): + with patch.dict(os.environ, {"AI_AGENT": agent}, clear=False): + self.assertEqual(_raw_agent_selection(), agent) + + def test_unknown_agent_falls_back_to_auto(self) -> None: + with patch.dict(os.environ, {"AI_AGENT": "gemini-pro"}, clear=False): + self.assertEqual(_raw_agent_selection(), "auto") + + def test_infer_gemini_from_command_when_ai_agent_empty(self) -> None: + with patch.dict(os.environ, {"AI_AGENT": "", "AI_COMMAND": "gemini -p"}, clear=False): + self.assertEqual(_raw_agent_selection(), "gemini") + + +class InferAgentFromCommandTests(unittest.TestCase): + def test_detects_gemini_executable(self) -> None: + self.assertEqual(_infer_agent_from_command("gemini -p"), "gemini") + self.assertEqual(_infer_agent_from_command("/usr/local/bin/gemini --model pro"), "gemini") + + def test_detects_claude_and_codex(self) -> None: + self.assertEqual(_infer_agent_from_command("claude --dangerously-skip-permissions"), "claude") + self.assertEqual(_infer_agent_from_command("codex exec"), "codex") + + def test_empty_and_unknown_return_empty(self) -> None: + self.assertEqual(_infer_agent_from_command(""), "") + self.assertEqual(_infer_agent_from_command("some-other-tool run"), "") + + class BuildCmdModelFlagTests(unittest.TestCase): def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory() @@ -623,6 +664,19 @@ def test_build_cmd_uses_gemini_without_claude_fallback(self) -> None: self.assertIn("gemini -p", rendered) self.assertNotIn("claude --dangerously-skip-permissions", rendered) + def test_build_cmd_honors_ai_agent_gemini_without_explicit_flag(self) -> None: + """Repro: AI_AGENT=gemini with no `--agent` must still resolve to + gemini (not silently fall back to `auto`/claude). Exercises the + `_raw_agent_selection` allowlist path that `_build_cmd` takes when + no `--agent` flag is supplied.""" + stdout = io.StringIO() + with patch.dict(os.environ, {"PROJECT_ROOT": str(self.project_root), "AI_AGENT": "gemini"}, clear=False), redirect_stdout(stdout): + code = _build_cmd(["review", "9.1"]) + self.assertEqual(code, 0) + rendered = stdout.getvalue() + self.assertIn("gemini -p", rendered) + self.assertNotIn("claude --dangerously-skip-permissions", rendered) + def test_build_cmd_rejects_unknown_agent_without_claude_fallback(self) -> None: stdout = io.StringIO() stderr = io.StringIO() From 99669b68e7ea2a718a926a3aa00ed81cb424077f Mon Sep 17 00:00:00 2001 From: Ezop <61061207+Ezopek@users.noreply.github.com> Date: Sun, 7 Jun 2026 02:39:06 +0200 Subject: [PATCH 4/8] fix: run gemini child sessions with tool approval --- docs/agents-and-monitoring.md | 4 ++-- skills/bmad-story-automator/data/agent-fallback.md | 2 +- skills/bmad-story-automator/data/tmux-commands.md | 2 +- .../src/story_automator/core/tmux_runtime.py | 2 +- tests/test_agent_config_model.py | 8 ++++---- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/agents-and-monitoring.md b/docs/agents-and-monitoring.md index 564ecabe..98950076 100644 --- a/docs/agents-and-monitoring.md +++ b/docs/agents-and-monitoring.md @@ -76,7 +76,7 @@ Environment details: - `STORY_AUTOMATOR_CHILD=true` - `AI_AGENT=` - Codex child sessions use isolated `CODEX_HOME` under `/tmp` -- Gemini child sessions use `gemini -p` by default +- Gemini child sessions use `gemini --approval-mode yolo -p` by default so autonomous workflows can edit files and run tests - custom non-Codex agents can be configured with `STORY_AUTOMATOR_AGENT__COMMAND`; set `STORY_AUTOMATOR_AGENT__PROCESS` only when the monitor should look for a different process name ## Agent CLI selection @@ -89,7 +89,7 @@ Built-in command mapping: - `claude` → `claude --dangerously-skip-permissions` - `codex` → `codex exec` / isolated Codex execution path -- `gemini` → `gemini -p` +- `gemini` → `gemini --approval-mode yolo -p` Custom command example: diff --git a/skills/bmad-story-automator/data/agent-fallback.md b/skills/bmad-story-automator/data/agent-fallback.md index fba312d2..88e2c732 100644 --- a/skills/bmad-story-automator/data/agent-fallback.md +++ b/skills/bmad-story-automator/data/agent-fallback.md @@ -29,7 +29,7 @@ Agent selection is resolved via the deterministic agents file created in preflig |-------|-----|--------------|---------|---------------| | Claude | `claude --dangerously-skip-permissions` | Natural language skill prompt | 60min | ☒/☐ checkboxes | | Codex | `codex exec --full-auto` | Natural language prompt | 90min (1.5x) | Not supported | -| Gemini | `gemini -p` | Natural language skill prompt | 60min | best-effort via generic monitor | +| Gemini | `gemini --approval-mode yolo -p` | Natural language skill prompt | 60min | best-effort via generic monitor | | Custom | `STORY_AUTOMATOR_AGENT__COMMAND` | Natural language skill prompt | 60min | best-effort via configured process pattern | **CRITICAL:** All child-agent prompts must name the skill/workflow to execute and include the story ID. diff --git a/skills/bmad-story-automator/data/tmux-commands.md b/skills/bmad-story-automator/data/tmux-commands.md index 83d6bb2b..71cdae47 100644 --- a/skills/bmad-story-automator/data/tmux-commands.md +++ b/skills/bmad-story-automator/data/tmux-commands.md @@ -190,7 +190,7 @@ tmux capture-pane -t "SESSION" -p -S -100 # Raw capture (use sparingly) | Variable | Claude | Codex | Gemini | Custom non-Codex | |----------|--------|-------|--------|------------------| -| CLI | `claude --dangerously-skip-permissions` | `codex exec -s workspace-write -c 'approval_policy="never"' -c 'model_reasoning_effort="high"' --disable plugins --disable sqlite --disable shell_snapshot` | `gemini -p` | `STORY_AUTOMATOR_AGENT__COMMAND` | +| CLI | `claude --dangerously-skip-permissions` | `codex exec -s workspace-write -c 'approval_policy="never"' -c 'model_reasoning_effort="high"' --disable plugins --disable sqlite --disable shell_snapshot` | `gemini --approval-mode yolo -p` | `STORY_AUTOMATOR_AGENT__COMMAND` | | Prompt Style | Natural language skill prompt | Natural language skill prompt | Natural language skill prompt | Natural language skill prompt | | Timeout Multiplier | 1x (60min) | 1.5x (90min) | 1x (60min) | 1x (60min) | | Todo Tracking | ☒/☐ checkboxes | Not supported | best-effort generic monitor | best-effort generic monitor | diff --git a/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py b/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py index 4f1b4f69..59bddbaf 100644 --- a/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py +++ b/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py @@ -96,7 +96,7 @@ def agent_cli(agent: str, model: str = "") -> str: elif agent == "codex": base = "codex exec" elif agent == "gemini": - base = "gemini -p" + base = "gemini --approval-mode yolo -p" else: raise ValueError( f"unsupported agent {agent!r}; supported agents are claude, codex, gemini, " diff --git a/tests/test_agent_config_model.py b/tests/test_agent_config_model.py index 26395a78..22af5fe7 100644 --- a/tests/test_agent_config_model.py +++ b/tests/test_agent_config_model.py @@ -45,8 +45,8 @@ def test_agent_cli_without_model_unchanged(self) -> None: self.assertEqual(agent_cli("codex"), "codex exec") def test_agent_cli_supports_gemini(self) -> None: - self.assertEqual(agent_cli("gemini"), "gemini -p") - self.assertEqual(agent_cli(" Gemini "), "gemini -p") + self.assertEqual(agent_cli("gemini"), "gemini --approval-mode yolo -p") + self.assertEqual(agent_cli(" Gemini "), "gemini --approval-mode yolo -p") def test_agent_cli_rejects_unknown_without_custom_command(self) -> None: with self.assertRaisesRegex(ValueError, "unsupported agent"): @@ -661,7 +661,7 @@ def test_build_cmd_uses_gemini_without_claude_fallback(self) -> None: code = _build_cmd(["review", "9.1", "--agent", "gemini"]) self.assertEqual(code, 0) rendered = stdout.getvalue() - self.assertIn("gemini -p", rendered) + self.assertIn("gemini --approval-mode yolo -p", rendered) self.assertNotIn("claude --dangerously-skip-permissions", rendered) def test_build_cmd_honors_ai_agent_gemini_without_explicit_flag(self) -> None: @@ -674,7 +674,7 @@ def test_build_cmd_honors_ai_agent_gemini_without_explicit_flag(self) -> None: code = _build_cmd(["review", "9.1"]) self.assertEqual(code, 0) rendered = stdout.getvalue() - self.assertIn("gemini -p", rendered) + self.assertIn("gemini --approval-mode yolo -p", rendered) self.assertNotIn("claude --dangerously-skip-permissions", rendered) def test_build_cmd_rejects_unknown_agent_without_claude_fallback(self) -> None: From c9393554f58b4635cadfc92dd4d26dce6c199cc1 Mon Sep 17 00:00:00 2001 From: Ezop <61061207+Ezopek@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:00:28 +0200 Subject: [PATCH 5/8] fix: harden gemini and custom agent dispatch --- docs/agents-and-monitoring.md | 2 +- .../data/agent-fallback.md | 6 +- .../src/story_automator/commands/tmux.py | 15 +++-- .../src/story_automator/core/tmux_runtime.py | 29 ++++++++- tests/test_agent_config_model.py | 62 ++++++++++++++++++- 5 files changed, 103 insertions(+), 11 deletions(-) diff --git a/docs/agents-and-monitoring.md b/docs/agents-and-monitoring.md index 98950076..5ce02dfd 100644 --- a/docs/agents-and-monitoring.md +++ b/docs/agents-and-monitoring.md @@ -76,7 +76,7 @@ Environment details: - `STORY_AUTOMATOR_CHILD=true` - `AI_AGENT=` - Codex child sessions use isolated `CODEX_HOME` under `/tmp` -- Gemini child sessions use `gemini --approval-mode yolo -p` by default so autonomous workflows can edit files and run tests +- Gemini child sessions use `gemini --approval-mode yolo -p` by default so autonomous workflows can edit files and run tests. ⚠️ `yolo` auto-approves every tool call (file writes, shell, tests) without prompting, so only run it in a trusted workspace you control; do not point a yolo Gemini session at untrusted code or a workspace holding secrets - custom non-Codex agents can be configured with `STORY_AUTOMATOR_AGENT__COMMAND`; set `STORY_AUTOMATOR_AGENT__PROCESS` only when the monitor should look for a different process name ## Agent CLI selection diff --git a/skills/bmad-story-automator/data/agent-fallback.md b/skills/bmad-story-automator/data/agent-fallback.md index 88e2c732..333023ca 100644 --- a/skills/bmad-story-automator/data/agent-fallback.md +++ b/skills/bmad-story-automator/data/agent-fallback.md @@ -28,12 +28,14 @@ Agent selection is resolved via the deterministic agents file created in preflig | Agent | CLI | Prompt Style | Timeout | Todo Tracking | |-------|-----|--------------|---------|---------------| | Claude | `claude --dangerously-skip-permissions` | Natural language skill prompt | 60min | ☒/☐ checkboxes | -| Codex | `codex exec --full-auto` | Natural language prompt | 90min (1.5x) | Not supported | -| Gemini | `gemini --approval-mode yolo -p` | Natural language skill prompt | 60min | best-effort via generic monitor | +| Codex | isolated `codex exec -s workspace-write -c 'approval_policy="never"' -c 'model_reasoning_effort="high"' --disable plugins --disable sqlite --disable shell_snapshot` (under a temp `CODEX_HOME`) | Natural language prompt | 90min (1.5x) | Not supported | +| Gemini | `gemini --approval-mode yolo -p` (with model: `gemini --approval-mode yolo --model -p`) | Natural language skill prompt | 60min | best-effort via generic monitor | | Custom | `STORY_AUTOMATOR_AGENT__COMMAND` | Natural language skill prompt | 60min | best-effort via configured process pattern | **CRITICAL:** All child-agent prompts must name the skill/workflow to execute and include the story ID. +> ⚠️ **Gemini `--approval-mode yolo` safety:** `yolo` auto-approves every tool call (file edits, shell, test runs) with no prompt, so the child session can modify the workspace and run commands fully autonomously. Only use it for Gemini child sessions in a trusted workspace you control (the orchestrator runs in BMAD-managed repos with `GEMINI_CLI_TRUST_WORKSPACE=true`). Do not point a yolo Gemini session at untrusted code or a workspace holding secrets/credentials. + The `story-automator tmux-wrapper build-cmd` function automatically generates the correct prompt format based on `AI_AGENT` environment variable. **See `workflow-commands.md` for complete prompt templates.** diff --git a/skills/bmad-story-automator/src/story_automator/commands/tmux.py b/skills/bmad-story-automator/src/story_automator/commands/tmux.py index 68176cb8..69fd8d3a 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/tmux.py +++ b/skills/bmad-story-automator/src/story_automator/commands/tmux.py @@ -478,11 +478,16 @@ def _flag_value(args: list[str], idx: int, flag: str) -> str: def _raw_agent_selection() -> str: value = os.environ.get("AI_AGENT", "").strip().lower() - if not value: - inferred = _infer_agent_from_command(os.environ.get("AI_COMMAND", "")) - if inferred: - return inferred - return value if value in {"claude", "codex", "gemini", "auto", "runtime"} else "auto" + if value: + # A non-empty AI_AGENT is authoritative: preserve the normalized name so + # agent_cli can use a configured custom command or fail fast on a typo. + # Collapsing unknown values to "auto" would silently break env-configured + # custom agents and violate the fail-fast contract. + return value + inferred = _infer_agent_from_command(os.environ.get("AI_COMMAND", "")) + if inferred: + return inferred + return "auto" def _resolve_agent_selection(agent: str, project_root: str) -> str: diff --git a/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py b/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py index 59bddbaf..6293b2ba 100644 --- a/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py +++ b/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py @@ -96,7 +96,11 @@ def agent_cli(agent: str, model: str = "") -> str: elif agent == "codex": base = "codex exec" elif agent == "gemini": - base = "gemini --approval-mode yolo -p" + # `-p` consumes the next argument as the prompt, so `--model` must come + # before it; otherwise `-p` swallows `--model` and the model id is lost. + if model: + return f"gemini --approval-mode yolo --model {shlex.quote(model)} -p" + return "gemini --approval-mode yolo -p" else: raise ValueError( f"unsupported agent {agent!r}; supported agents are claude, codex, gemini, " @@ -120,6 +124,17 @@ def custom_agent_command(agent: str) -> str: return "" +def _command_basename(command: str) -> str: + value = (command or "").strip() + if not value: + return "" + try: + executable = shlex.split(value)[0] + except (ValueError, IndexError): + return "" + return Path(executable).name + + def normalize_agent_name(agent: str) -> str: return (agent or "").strip().lower() @@ -140,6 +155,12 @@ def agent_process_pattern(agent: str) -> str: return "codex" if agent == "gemini": return "gemini" + # For env-configured custom agents the alias (e.g. "gemini-pro") rarely names + # the real process. Derive the basename from the configured command so the + # monitor watches the actual executable instead of the alias. + command_basename = _command_basename(custom_agent_command(agent)) + if command_basename: + return command_basename return agent @@ -401,7 +422,7 @@ def detect_agent_session(session: str, capture: str) -> str: return env_agent if re.search(r"(?i)OpenAI Codex|codex exec|gpt-[0-9]+-codex|tokens used|codex-cli", capture): return "codex" - if re.search(r"(?i)Gemini|gemini -p|gemini-cli", capture): + if re.search(r"(?i)Gemini CLI|gemini -p|gemini-cli|gemini --approval-mode", capture): return "gemini" return "claude" @@ -464,6 +485,8 @@ def _spawn_runner(session: str, command: str, selected_agent: str, project_root: "CLAUDECODE=", "-e", "BASH_ENV=", + "-e", + "GEMINI_CLI_TRUST_WORKSPACE=true", *PLACEHOLDER_COMMAND, ) if create_code != 0: @@ -558,6 +581,8 @@ def _spawn_legacy(session: str, command: str, selected_agent: str, project_root: f"AI_AGENT={selected_agent}", "-e", "CLAUDECODE=", + "-e", + "GEMINI_CLI_TRUST_WORKSPACE=true", ) if code != 0: return (output, code) diff --git a/tests/test_agent_config_model.py b/tests/test_agent_config_model.py index 22af5fe7..b367f667 100644 --- a/tests/test_agent_config_model.py +++ b/tests/test_agent_config_model.py @@ -48,6 +48,19 @@ def test_agent_cli_supports_gemini(self) -> None: self.assertEqual(agent_cli("gemini"), "gemini --approval-mode yolo -p") self.assertEqual(agent_cli(" Gemini "), "gemini --approval-mode yolo -p") + def test_agent_cli_gemini_with_model_keeps_prompt_flag_last(self) -> None: + # Regression: `-p` consumes the next token as the prompt, so `--model` + # must precede it. Appending `--model` after `-p` makes `-p` swallow it. + self.assertEqual( + agent_cli("gemini", "gemini-2.5-pro"), + "gemini --approval-mode yolo --model gemini-2.5-pro -p", + ) + + def test_agent_cli_gemini_model_is_quoted(self) -> None: + cli = agent_cli("gemini", "gemini pro[exp]") + self.assertIn("--model 'gemini pro[exp]'", cli) + self.assertTrue(cli.endswith(" -p"), cli) + def test_agent_cli_rejects_unknown_without_custom_command(self) -> None: with self.assertRaisesRegex(ValueError, "unsupported agent"): agent_cli("not-a-real-agent") @@ -66,12 +79,49 @@ def test_agent_process_pattern_supports_gemini_and_custom(self) -> None: with patch.dict(os.environ, {"STORY_AUTOMATOR_AGENT_MY_AGENT_PROCESS": "my-agent-bin"}, clear=False): self.assertEqual(agent_process_pattern("my-agent"), "my-agent-bin") + def test_agent_process_pattern_derives_basename_from_custom_command(self) -> None: + # Regression: without an explicit PROCESS override, monitor the real + # executable from the configured command, not the alias name. + with patch.dict( + os.environ, + {"STORY_AUTOMATOR_AGENT_GEMINI_PRO_COMMAND": "/usr/local/bin/gemini -p --model pro"}, + clear=False, + ): + self.assertEqual(agent_process_pattern("gemini-pro"), "gemini") + + def test_agent_process_pattern_explicit_override_beats_command_basename(self) -> None: + with patch.dict( + os.environ, + { + "STORY_AUTOMATOR_AGENT_GEMINI_PRO_COMMAND": "gemini -p", + "STORY_AUTOMATOR_AGENT_GEMINI_PRO_PROCESS": "gemini-wrapper", + }, + clear=False, + ): + self.assertEqual(agent_process_pattern("gemini-pro"), "gemini-wrapper") + def test_env_agent_name_normalizes_for_env_vars(self) -> None: self.assertEqual(env_agent_name("gemini-pro preview"), "GEMINI_PRO_PREVIEW") def test_detect_agent_session_supports_gemini_capture(self) -> None: with patch("story_automator.core.tmux_runtime.tmux_show_environment", return_value=""): self.assertEqual(detect_agent_session("session", "Gemini CLI ready"), "gemini") + for marker in ("gemini -p prompt", "gemini-cli v1", "gemini --approval-mode yolo"): + self.assertEqual(detect_agent_session("session", marker), "gemini") + + def test_detect_agent_session_ignores_bare_gemini_mention(self) -> None: + # Regression: a stray "Gemini" word in output (e.g. discussing the model) + # must not be misread as a Gemini CLI session. Env AI_AGENT stays + # authoritative; capture heuristics need CLI-specific markers. + with patch("story_automator.core.tmux_runtime.tmux_show_environment", return_value=""): + self.assertEqual( + detect_agent_session("session", "Comparing Gemini and Claude output quality"), + "claude", + ) + + def test_detect_agent_session_env_remains_authoritative(self) -> None: + with patch("story_automator.core.tmux_runtime.tmux_show_environment", return_value="gemini-pro"): + self.assertEqual(detect_agent_session("session", "no markers here"), "gemini-pro") def test_agent_cli_with_model_for_claude(self) -> None: self.assertEqual( @@ -605,8 +655,18 @@ def test_known_agents_are_preserved(self) -> None: with patch.dict(os.environ, {"AI_AGENT": agent}, clear=False): self.assertEqual(_raw_agent_selection(), agent) - def test_unknown_agent_falls_back_to_auto(self) -> None: + def test_nonempty_unknown_agent_is_preserved_not_collapsed_to_auto(self) -> None: + # Regression: a non-empty AI_AGENT (e.g. an env-configured custom agent) + # must be preserved verbatim (normalized), not collapsed to "auto". + # Collapsing it broke custom agents and violated fail-fast. with patch.dict(os.environ, {"AI_AGENT": "gemini-pro"}, clear=False): + self.assertEqual(_raw_agent_selection(), "gemini-pro") + with patch.dict(os.environ, {"AI_AGENT": " Gemini-Pro "}, clear=False): + self.assertEqual(_raw_agent_selection(), "gemini-pro") + + def test_empty_agent_without_command_returns_auto(self) -> None: + # Only an empty AI_AGENT with no AI_COMMAND inference should fall back to "auto". + with patch.dict(os.environ, {"AI_AGENT": "", "AI_COMMAND": ""}, clear=False): self.assertEqual(_raw_agent_selection(), "auto") def test_infer_gemini_from_command_when_ai_agent_empty(self) -> None: From 1b331678dfac6e88a39800b9116e6c387fd56eca Mon Sep 17 00:00:00 2001 From: Ezop <61061207+Ezopek@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:49:45 +0200 Subject: [PATCH 6/8] fix: align legacy gemini dispatch and heartbeat --- .../src/story_automator/commands/tmux.py | 5 ++++- .../src/story_automator/core/tmux_runtime.py | 2 +- tests/test_agent_config_model.py | 18 ++++++++++++++++++ tests/test_tmux_runtime.py | 14 ++++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/skills/bmad-story-automator/src/story_automator/commands/tmux.py b/skills/bmad-story-automator/src/story_automator/commands/tmux.py index 69fd8d3a..f30ddfed 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/tmux.py +++ b/skills/bmad-story-automator/src/story_automator/commands/tmux.py @@ -205,7 +205,10 @@ def _build_cmd(args: list[str]) -> int: return 1 ai_command = os.environ.get("AI_COMMAND", "").strip() if ai_command and not os.environ.get("AI_AGENT"): - cli = ai_command + if agent == "gemini": + cli = agent_cli(agent, model) + else: + cli = ai_command elif agent != "codex": try: cli = agent_cli(agent, model) diff --git a/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py b/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py index 6293b2ba..11652f61 100644 --- a/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py +++ b/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py @@ -1010,7 +1010,7 @@ def _legacy_heartbeat_check(session: str, selected_agent: str) -> tuple[str, flo pane_pid = _safe_int(tmux_display(session, "#{pane_pid}")) if pane_pid <= 0: return ("completed" if prompt == "true" else "dead", 0.0, "", prompt) - pattern = "codex" if selected_agent == "codex" else "claude" + pattern = agent_process_pattern(selected_agent) agent_pid = _find_agent_pid(str(pane_pid), pattern, 0) if not agent_pid: return ("completed" if prompt == "true" else "dead", 0.0, "", prompt) diff --git a/tests/test_agent_config_model.py b/tests/test_agent_config_model.py index b367f667..12dd6af2 100644 --- a/tests/test_agent_config_model.py +++ b/tests/test_agent_config_model.py @@ -737,6 +737,24 @@ def test_build_cmd_honors_ai_agent_gemini_without_explicit_flag(self) -> None: self.assertIn("gemini --approval-mode yolo -p", rendered) self.assertNotIn("claude --dangerously-skip-permissions", rendered) + def test_build_cmd_honors_ai_command_only_gemini_without_raw_bypass(self) -> None: + stdout = io.StringIO() + env = {"PROJECT_ROOT": str(self.project_root), "AI_COMMAND": "gemini", "AI_AGENT": ""} + with patch.dict(os.environ, env, clear=False), redirect_stdout(stdout): + code = _build_cmd(["review", "9.1", "--model", "gemini-2.5-pro"]) + self.assertEqual(code, 0) + rendered = stdout.getvalue() + self.assertIn("gemini --approval-mode yolo --model gemini-2.5-pro -p", rendered) + self.assertNotIn("claude --dangerously-skip-permissions", rendered) + + def test_build_cmd_preserves_ai_command_only_custom_command(self) -> None: + stdout = io.StringIO() + env = {"PROJECT_ROOT": str(self.project_root), "AI_COMMAND": "custom-agent --prompt", "AI_AGENT": ""} + with patch.dict(os.environ, env, clear=False), redirect_stdout(stdout): + code = _build_cmd(["review", "9.1"]) + self.assertEqual(code, 0) + self.assertIn("custom-agent --prompt", stdout.getvalue()) + def test_build_cmd_rejects_unknown_agent_without_claude_fallback(self) -> None: stdout = io.StringIO() stderr = io.StringIO() diff --git a/tests/test_tmux_runtime.py b/tests/test_tmux_runtime.py index 96fa65f7..90a1f837 100644 --- a/tests/test_tmux_runtime.py +++ b/tests/test_tmux_runtime.py @@ -483,6 +483,20 @@ def test_legacy_heartbeat_treats_fractional_cpu_as_alive(self) -> None: self.assertEqual(pid, "12") self.assertEqual(prompt, "false") + def test_legacy_heartbeat_uses_selected_agent_process_pattern(self) -> None: + with ( + mock.patch("story_automator.core.tmux_runtime.tmux_has_session", return_value=True), + mock.patch("story_automator.core.tmux_runtime._capture_text", return_value="working"), + mock.patch("story_automator.core.tmux_runtime.tmux_display", return_value="10"), + mock.patch("story_automator.core.tmux_runtime._find_agent_pid", return_value="12") as find_pid, + mock.patch("story_automator.core.tmux_runtime._process_cpu", return_value=0.5), + ): + status, _cpu, pid, _prompt = _legacy_heartbeat_check("sa-test-legacy-gemini", "gemini") + + self.assertEqual(status, "alive") + self.assertEqual(pid, "12") + find_pid.assert_called_once_with("10", "gemini", 0) + class PaneDescendantDetectionTests(unittest.TestCase): def test_detects_grandchild_agent_via_ancestry_walk(self) -> None: From 79128fd81d5123cacbc173db82ec2cb18c8558c9 Mon Sep 17 00:00:00 2001 From: Ezop <61061207+Ezopek@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:59:46 +0200 Subject: [PATCH 7/8] fix: avoid agent inference substring matches --- .../src/story_automator/commands/tmux.py | 7 ++++--- tests/test_agent_config_model.py | 4 ++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/skills/bmad-story-automator/src/story_automator/commands/tmux.py b/skills/bmad-story-automator/src/story_automator/commands/tmux.py index f30ddfed..2a1e49ce 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/tmux.py +++ b/skills/bmad-story-automator/src/story_automator/commands/tmux.py @@ -506,10 +506,11 @@ def _infer_agent_from_command(command: str) -> str: executable = Path(shlex.split(value)[0]).name.lower() except ValueError: return "" - if "codex" in executable: + token = Path(executable).stem.lower() + if token in {"codex", "codex-cli"}: return "codex" - if "claude" in executable: + if token == "claude": return "claude" - if "gemini" in executable: + if token in {"gemini", "gemini-cli"}: return "gemini" return "" diff --git a/tests/test_agent_config_model.py b/tests/test_agent_config_model.py index 12dd6af2..f4d0433e 100644 --- a/tests/test_agent_config_model.py +++ b/tests/test_agent_config_model.py @@ -686,6 +686,10 @@ def test_detects_claude_and_codex(self) -> None: def test_empty_and_unknown_return_empty(self) -> None: self.assertEqual(_infer_agent_from_command(""), "") self.assertEqual(_infer_agent_from_command("some-other-tool run"), "") + self.assertEqual(_infer_agent_from_command("my-gemini-wrapper run"), "") + self.assertEqual(_infer_agent_from_command("codextra run"), "") + self.assertEqual(_infer_agent_from_command("prefix-claude run"), "") + self.assertEqual(_infer_agent_from_command("gemini-wrapper run"), "") class BuildCmdModelFlagTests(unittest.TestCase): From de80f2ae747b2309d40d09c01baa51012502a19f Mon Sep 17 00:00:00 2001 From: Ezop <61061207+Ezopek@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:10:07 +0200 Subject: [PATCH 8/8] fix: address agent selection review comments --- .../src/story_automator/core/tmux_runtime.py | 14 +++++- tests/test_agent_config_model.py | 32 ++++++++++++++ tests/test_tmux_runtime.py | 44 +++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py b/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py index 11652f61..bfe9eac9 100644 --- a/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py +++ b/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py @@ -80,7 +80,10 @@ def generate_session_name(step: str, epic: str, story_id: str, cycle: str = "") def agent_type() -> str: value = normalize_agent_name(os.environ.get("AI_AGENT", "")) - if value: + # `auto`/`runtime` are meta-selectors, not agent names: resolve them through + # the runtime provider so they never flow into agent_cli() (which rejects + # them). Built-ins and configured custom agent names pass through verbatim. + if value and value not in {"auto", "runtime"}: return value return runtime_provider() @@ -107,7 +110,14 @@ def agent_cli(agent: str, model: str = "") -> str: f"or set STORY_AUTOMATOR_AGENT_{env_agent_name(agent)}_COMMAND" ) if model: - base = f"{base} --model {shlex.quote(model)}" + quoted = shlex.quote(model) + if custom and base.endswith(" -p"): + # Custom CLIs that end in the prompt flag (e.g. a Gemini-like + # `... -p`) consume the next token as the prompt, so `--model` must + # precede `-p`; appending it afterwards would let `-p` swallow it. + base = f"{base[: -len(' -p')]} --model {quoted} -p" + else: + base = f"{base} --model {quoted}" return base diff --git a/tests/test_agent_config_model.py b/tests/test_agent_config_model.py index f4d0433e..89a07a06 100644 --- a/tests/test_agent_config_model.py +++ b/tests/test_agent_config_model.py @@ -73,6 +73,38 @@ def test_agent_cli_supports_legacy_custom_command_env(self) -> None: with patch.dict(os.environ, {"AI_COMMAND_MY_AGENT": "legacy-agent --prompt"}, clear=False): self.assertEqual(agent_cli("my-agent"), "legacy-agent --prompt") + def test_agent_cli_custom_command_with_trailing_prompt_flag_keeps_model_before_p(self) -> None: + # Regression: a custom Gemini-like command ending in `-p` consumes the + # next token as the prompt. The model must be injected before `-p`, not + # appended after it (where `-p` would swallow it). + with patch.dict( + os.environ, + {"STORY_AUTOMATOR_AGENT_GEMINI_PRO_COMMAND": "gemini --approval-mode yolo -p"}, + clear=False, + ): + cli = agent_cli("gemini-pro", "gemini-2.5-pro") + self.assertEqual(cli, "gemini --approval-mode yolo --model gemini-2.5-pro -p") + self.assertTrue(cli.endswith(" -p"), cli) + self.assertNotIn("-p --model", cli) + + def test_agent_cli_custom_command_trailing_prompt_flag_quotes_model(self) -> None: + with patch.dict( + os.environ, + {"STORY_AUTOMATOR_AGENT_GEMINI_PRO_COMMAND": "gemini -p"}, + clear=False, + ): + cli = agent_cli("gemini-pro", "gemini pro[exp]") + self.assertEqual(cli, "gemini --model 'gemini pro[exp]' -p") + + def test_agent_cli_custom_command_without_trailing_prompt_flag_appends_model(self) -> None: + with patch.dict( + os.environ, + {"STORY_AUTOMATOR_AGENT_MY_AGENT_COMMAND": "my-agent run"}, + clear=False, + ): + cli = agent_cli("my-agent", "some-model") + self.assertEqual(cli, "my-agent run --model some-model") + def test_agent_process_pattern_supports_gemini_and_custom(self) -> None: self.assertEqual(agent_process_pattern("gemini"), "gemini") self.assertEqual(agent_process_pattern("my-agent"), "my-agent") diff --git a/tests/test_tmux_runtime.py b/tests/test_tmux_runtime.py index 90a1f837..18ceae68 100644 --- a/tests/test_tmux_runtime.py +++ b/tests/test_tmux_runtime.py @@ -10,6 +10,7 @@ from story_automator.core.tmux_runtime import ( PaneSnapshot, + agent_type, _check_prompt_visible, _claude_completion_marker_present, _legacy_heartbeat_check, @@ -498,6 +499,49 @@ def test_legacy_heartbeat_uses_selected_agent_process_pattern(self) -> None: find_pid.assert_called_once_with("10", "gemini", 0) +class AgentTypeResolutionTests(unittest.TestCase): + def test_auto_resolves_through_runtime_provider(self) -> None: + with ( + mock.patch.dict(os.environ, {"AI_AGENT": "auto"}, clear=False), + mock.patch("story_automator.core.tmux_runtime.runtime_provider", return_value="codex"), + ): + self.assertEqual(agent_type(), "codex") + + def test_runtime_resolves_through_runtime_provider(self) -> None: + with ( + mock.patch.dict(os.environ, {"AI_AGENT": " RUNTIME "}, clear=False), + mock.patch("story_automator.core.tmux_runtime.runtime_provider", return_value="claude"), + ): + self.assertEqual(agent_type(), "claude") + + def test_empty_resolves_through_runtime_provider(self) -> None: + with ( + mock.patch.dict(os.environ, {"AI_AGENT": ""}, clear=False), + mock.patch("story_automator.core.tmux_runtime.runtime_provider", return_value="claude"), + ): + self.assertEqual(agent_type(), "claude") + + def test_builtin_agent_passes_through_without_provider(self) -> None: + with ( + mock.patch.dict(os.environ, {"AI_AGENT": " Gemini "}, clear=False), + mock.patch( + "story_automator.core.tmux_runtime.runtime_provider", + side_effect=AssertionError("runtime_provider must not be consulted for explicit agents"), + ), + ): + self.assertEqual(agent_type(), "gemini") + + def test_custom_agent_name_passes_through(self) -> None: + with ( + mock.patch.dict(os.environ, {"AI_AGENT": "gemini-pro"}, clear=False), + mock.patch( + "story_automator.core.tmux_runtime.runtime_provider", + side_effect=AssertionError("runtime_provider must not be consulted for explicit agents"), + ), + ): + self.assertEqual(agent_type(), "gemini-pro") + + class PaneDescendantDetectionTests(unittest.TestCase): def test_detects_grandchild_agent_via_ancestry_walk(self) -> None: # pane(100) -> shell(200) -> claude(300); pgrep -f matches only the grandchild.