Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions loom-tools/src/loom_tools/agent_spawn.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,19 @@ def _select_oauth_token(repo_root: pathlib.Path) -> str | None:
DEFAULT_STUCK_THRESHOLD = 300 # 5 minutes
DEFAULT_VERIFY_TIMEOUT = 10 # seconds

# Bypass-permissions modal auto-accept (see issue #3348 / PR #112).
# Claude Code 2.1.x shows a "WARNING: Claude Code running in Bypass Permissions
# mode" modal on every spawn — the acknowledgement is not persisted to
# CLAUDE_CONFIG_DIR — and the default selection ("1. No, exit") would exit the
# session on an unattended Enter. We poll the pane for the modal text, then
# send Down+Enter to select "2. Yes, I accept".
BYPASS_PROMPT_MARKERS = (
"Bypass Permissions mode", # primary marker (matches "WARNING: ... Bypass Permissions mode")
"--dangerously-skip-permissions",
)
DEFAULT_BYPASS_POLL_TIMEOUT = 15 # seconds — total budget for the poll loop
DEFAULT_BYPASS_POLL_INTERVAL = 1 # seconds — sleep between capture-pane attempts

# Patterns in log output that indicate transient API errors
# (agent is waiting for "try again" input, not actually stuck on a logic problem)
API_ERROR_PATTERNS = (
Expand Down Expand Up @@ -364,6 +377,72 @@ def _is_claude_running(shell_pid: str) -> bool:
return False


def _bypass_prompt_visible(session_name: str) -> bool:
"""Return True when the pane contains the bypass-permissions modal text.

The detection is intentionally permissive — any of the markers in
``BYPASS_PROMPT_MARKERS`` count as a hit, so future renames of the warning
string remain auto-accepted until we update the list.
"""
try:
result = _tmux("capture-pane", "-t", session_name, "-p", "-S", "-200")
except (subprocess.TimeoutExpired, FileNotFoundError):
return False
if result.returncode != 0:
return False
pane = result.stdout or ""
return any(marker in pane for marker in BYPASS_PROMPT_MARKERS)


def _auto_accept_bypass_prompt(
session_name: str,
timeout: int = DEFAULT_BYPASS_POLL_TIMEOUT,
poll_interval: int = DEFAULT_BYPASS_POLL_INTERVAL,
sleep_fn=time.sleep,
) -> bool:
"""Poll the tmux pane for the bypass-permissions modal and accept it.

Sends ``Down Enter`` to select "2. Yes, I accept" once the modal is
detected (default selection is "1. No, exit", which would terminate the
spawn on an unattended Enter). Returns True if the modal was detected
and the keystrokes were sent; False if the poll timed out (the modal
never appeared — either Claude Code already proceeded past it or this
build does not show it).

Gated by ``LOOM_AUTO_ACCEPT_BYPASS``. Default is "1" (enabled); set to
"0" to disable on builds where ``--dangerously-skip-permissions`` already
suppresses the modal. See issue #3348.

Args:
session_name: Fully qualified tmux session (e.g. ``loom-builder-1``).
timeout: Total poll budget in seconds.
poll_interval: Seconds between ``capture-pane`` attempts.
sleep_fn: Sleep function (injected for tests). Defaults to
``time.sleep``.
"""
if os.environ.get("LOOM_AUTO_ACCEPT_BYPASS", "1") == "0":
log_info("Bypass auto-accept disabled via LOOM_AUTO_ACCEPT_BYPASS=0")
return False

elapsed = 0
while elapsed < timeout:
if _bypass_prompt_visible(session_name):
log_info(
f"Bypass-permissions modal detected after {elapsed}s — "
"sending Down+Enter to accept"
)
_tmux("send-keys", "-t", session_name, "Down", "Enter")
return True
sleep_fn(poll_interval)
elapsed += poll_interval

log_info(
f"Bypass-permissions modal not detected within {timeout}s "
"(claude may have skipped the prompt or already moved past it)"
)
return False


# ---------------------------------------------------------------------------
# Validation helpers
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -830,6 +909,19 @@ def spawn_agent(
return SpawnResult(status="error", name=name, error="process_not_detected")

log_success("Agent spawned successfully")

# Auto-accept the "Claude Code running in Bypass Permissions mode" modal.
# On Claude Code 2.1.x the acknowledgement is not persisted to the
# CLAUDE_CONFIG_DIR — every fresh process shows the modal warning, with
# default selection "1. No, exit". We poll for the modal text (rather
# than racing a fixed sleep) and send Down+Enter once it appears.
# Disable on builds that already suppress the modal by exporting
# ``LOOM_AUTO_ACCEPT_BYPASS=0``. See issue #3348.
try:
_auto_accept_bypass_prompt(session_name)
except Exception as exc: # pragma: no cover - defensive
log_warning(f"Bypass auto-accept failed: {exc}")

log_info("")
log_info(f"Session: {session_name}")
log_info(f"Attach: tmux -L {TMUX_SOCKET} attach -t {session_name}")
Expand Down
190 changes: 190 additions & 0 deletions loom-tools/tests/test_agent_spawn.py
Original file line number Diff line number Diff line change
Expand Up @@ -1045,3 +1045,193 @@ def test_kill_proceeds_when_capture_fails(
tmux_calls = [str(c) for c in mock_tmux.call_args_list]
assert any("send-keys" in c for c in tmux_calls)
assert any("kill-session" in c for c in tmux_calls)


class TestAutoAcceptBypassPrompt:
"""Tests for the bypass-permissions modal auto-accept logic (issue #3348)."""

@patch("loom_tools.agent_spawn._tmux")
def test_sends_down_enter_when_modal_detected(
self, mock_tmux: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""When the modal text appears in the pane, Down+Enter is sent."""
monkeypatch.delenv("LOOM_AUTO_ACCEPT_BYPASS", raising=False)
from loom_tools.agent_spawn import _auto_accept_bypass_prompt

# capture-pane returns the bypass warning text on the first call.
mock_tmux.return_value = subprocess.CompletedProcess(
args=[],
returncode=0,
stdout=(
"WARNING: Claude Code running in Bypass Permissions mode\n"
"1. No, exit\n"
"2. Yes, I accept\n"
),
)

sleeps: list[float] = []
result = _auto_accept_bypass_prompt(
"loom-builder-1",
timeout=15,
poll_interval=1,
sleep_fn=sleeps.append,
)

assert result is True, "should report success when modal detected"
# Find the send-keys call: ("send-keys", "-t", session, "Down", "Enter")
send_keys_calls = [
c.args
for c in mock_tmux.call_args_list
if len(c.args) >= 5 and c.args[0] == "send-keys"
]
assert len(send_keys_calls) == 1, (
f"expected exactly one send-keys call, got {send_keys_calls}"
)
assert send_keys_calls[0] == (
"send-keys", "-t", "loom-builder-1", "Down", "Enter",
)
# Should have detected on first iteration — no sleeps.
assert sleeps == [], f"expected no sleeps on immediate detection, got {sleeps}"

@patch("loom_tools.agent_spawn._tmux")
def test_polls_until_modal_appears(
self, mock_tmux: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Modal can appear after several poll iterations; keystrokes still sent."""
monkeypatch.delenv("LOOM_AUTO_ACCEPT_BYPASS", raising=False)
from loom_tools.agent_spawn import _auto_accept_bypass_prompt

empty = subprocess.CompletedProcess(args=[], returncode=0, stdout="")
with_prompt = subprocess.CompletedProcess(
args=[],
returncode=0,
stdout="WARNING: ... Bypass Permissions mode ... (1) No, exit",
)
send_ok = subprocess.CompletedProcess(args=[], returncode=0, stdout="")

# First two captures empty, third has the prompt, then send-keys returns ok.
mock_tmux.side_effect = [empty, empty, with_prompt, send_ok]

sleeps: list[float] = []
result = _auto_accept_bypass_prompt(
"loom-shepherd-1",
timeout=10,
poll_interval=1,
sleep_fn=sleeps.append,
)

assert result is True
# Two sleeps (after iters 0 and 1) before detection on iter 2.
assert sleeps == [1, 1], f"expected 2 sleeps, got {sleeps}"
send_keys_calls = [
c.args
for c in mock_tmux.call_args_list
if len(c.args) >= 5 and c.args[0] == "send-keys"
]
assert len(send_keys_calls) == 1
assert send_keys_calls[0][-2:] == ("Down", "Enter")

@patch("loom_tools.agent_spawn._tmux")
def test_honours_timeout_when_modal_never_appears(
self, mock_tmux: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""If the modal never appears, return False after the timeout budget."""
monkeypatch.delenv("LOOM_AUTO_ACCEPT_BYPASS", raising=False)
from loom_tools.agent_spawn import _auto_accept_bypass_prompt

# Every capture returns benign shell output — no marker.
mock_tmux.return_value = subprocess.CompletedProcess(
args=[], returncode=0, stdout="$ claude --dangerously--ready\n",
)

sleeps: list[float] = []
# Use a short timeout so the test runs fast.
result = _auto_accept_bypass_prompt(
"loom-builder-2",
timeout=5,
poll_interval=1,
sleep_fn=sleeps.append,
)

assert result is False, "should return False on poll timeout"
# No send-keys call should have been emitted.
send_keys_calls = [
c.args
for c in mock_tmux.call_args_list
if len(c.args) >= 1 and c.args[0] == "send-keys"
]
assert send_keys_calls == [], (
f"expected no send-keys on timeout, got {send_keys_calls}"
)
# Five sleeps total (one per poll, until elapsed >= timeout).
assert len(sleeps) == 5, f"expected 5 sleeps, got {len(sleeps)}: {sleeps}"

@patch("loom_tools.agent_spawn._tmux")
def test_disabled_by_env_var(
self, mock_tmux: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""LOOM_AUTO_ACCEPT_BYPASS=0 disables the auto-accept entirely."""
monkeypatch.setenv("LOOM_AUTO_ACCEPT_BYPASS", "0")
from loom_tools.agent_spawn import _auto_accept_bypass_prompt

sleeps: list[float] = []
result = _auto_accept_bypass_prompt(
"loom-builder-3",
timeout=15,
poll_interval=1,
sleep_fn=sleeps.append,
)

assert result is False
# tmux must not be invoked at all when disabled.
assert mock_tmux.call_count == 0
assert sleeps == []

@patch("loom_tools.agent_spawn._tmux")
def test_detects_alternate_marker(
self, mock_tmux: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The ``--dangerously-skip-permissions`` marker also triggers detection."""
monkeypatch.delenv("LOOM_AUTO_ACCEPT_BYPASS", raising=False)
from loom_tools.agent_spawn import _auto_accept_bypass_prompt

mock_tmux.return_value = subprocess.CompletedProcess(
args=[],
returncode=0,
stdout="claude --dangerously-skip-permissions (warning) ...",
)

result = _auto_accept_bypass_prompt(
"loom-builder-4",
timeout=5,
poll_interval=1,
sleep_fn=lambda _s: None,
)
assert result is True

@patch("loom_tools.agent_spawn._tmux")
def test_capture_pane_failure_does_not_raise(
self, mock_tmux: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Non-zero capture-pane returncode is treated as 'no modal yet'."""
monkeypatch.delenv("LOOM_AUTO_ACCEPT_BYPASS", raising=False)
from loom_tools.agent_spawn import _auto_accept_bypass_prompt

mock_tmux.return_value = subprocess.CompletedProcess(
args=[], returncode=1, stdout="",
)

result = _auto_accept_bypass_prompt(
"loom-builder-5",
timeout=3,
poll_interval=1,
sleep_fn=lambda _s: None,
)
assert result is False
# No send-keys emitted.
send_keys_calls = [
c
for c in mock_tmux.call_args_list
if c.args and c.args[0] == "send-keys"
]
assert send_keys_calls == []