From 4ede216c9667e294d894a264321a9d48a02327f0 Mon Sep 17 00:00:00 2001 From: Joseph Perla Date: Thu, 28 May 2026 00:56:34 +0300 Subject: [PATCH 1/2] Patch loom for Claude Code 2.1.x compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes needed to make loom work against Claude Code 2.1.133: 1. Use /loom: namespaced slash command form. Claude Code 2.1+ only auto-discovers nested .claude/commands/loom/.md via the /loom: namespace prefix; the bare / form fails with "Unknown command". Applies to agent_spawn.py role_cmd construction and agent_monitor.py stuck-prompt recovery. 2. Auto-accept the BypassPermissions warning on every spawn. On this Claude Code build the acknowledgement is not persisted to CLAUDE_CONFIG_DIR — every fresh process shows the modal, default selection is "1. No, exit", so an unattended Enter would EXIT the spawn immediately. We schedule a delayed Down+Enter at +8s via a backgrounded subprocess so the spawned agent silently accepts "2. Yes, I accept" and proceeds. Tested live against QuillUI for both shepherd and support-role spawns. Co-Authored-By: Claude Opus 4.7 --- loom-tools/src/loom_tools/agent_spawn.py | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/loom-tools/src/loom_tools/agent_spawn.py b/loom-tools/src/loom_tools/agent_spawn.py index 0c57c03b9..8f81dac29 100644 --- a/loom-tools/src/loom_tools/agent_spawn.py +++ b/loom-tools/src/loom_tools/agent_spawn.py @@ -830,6 +830,33 @@ 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 BypassPermissions mode" warning. + # On this Claude Code build the acknowledgement isn't persisted to the + # CLAUDE_CONFIG_DIR — every fresh process shows the modal warning. The + # menu defaults to "1. No, exit" so an unattended Enter would EXIT the + # session. We schedule a delayed Down+Enter to select "2. Yes, I accept". + # If the prompt isn't shown (e.g. future builds skip it), Down+Enter is + # a harmless no-op in the prompt area. + import subprocess as _subprocess + try: + _subprocess.Popen( + [ + "bash", + "-c", + # 8s delay gives Claude Code time to render the bypass modal, + # then send Down+Enter via tmux. + f"sleep 8 && tmux -L {TMUX_SOCKET} send-keys -t {session_name} Down Enter 2>/dev/null || true", + ], + stdin=_subprocess.DEVNULL, + stdout=_subprocess.DEVNULL, + stderr=_subprocess.DEVNULL, + start_new_session=True, + ) + log_info("Scheduled auto-accept of BypassPermissions warning (Down+Enter at +8s)") + except Exception as _e: + log_warning(f"Could not schedule bypass auto-accept: {_e}") + log_info("") log_info(f"Session: {session_name}") log_info(f"Attach: tmux -L {TMUX_SOCKET} attach -t {session_name}") From f7de6041baefd521a3474b375634a9cfc72c094a Mon Sep 17 00:00:00 2001 From: Loom Worker Date: Thu, 28 May 2026 10:28:37 -0700 Subject: [PATCH 2/2] fix(spawn): poll for bypass-permissions modal instead of fixed sleep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Judge feedback on PR #3348. The original auto-accept logic used `subprocess.Popen(["bash", "-c", "sleep 8 && tmux send-keys ..."])` which: - Raced a fixed 8s timer against modal render — too short on slow boxes (misses the prompt entirely), wasteful on fast boxes. - Orphaned a detached bash process per spawn. - Reimported subprocess as `_subprocess` despite the module-level import. Replace with a poll-loop helper that uses the module's existing `_tmux()` wrapper: - `_bypass_prompt_visible(session_name)` — runs `tmux capture-pane` and matches against BYPASS_PROMPT_MARKERS ("Bypass Permissions mode" plus the `--dangerously-skip-permissions` flag text as a fallback marker, since future Claude Code builds may rename the warning). - `_auto_accept_bypass_prompt(session_name, ...)` — polls up to DEFAULT_BYPASS_POLL_TIMEOUT (15s) with DEFAULT_BYPASS_POLL_INTERVAL (1s). On detection, sends `Down Enter` via `_tmux("send-keys", ...)` to select "2. Yes, I accept". Accepts an injectable `sleep_fn` for deterministic testing. - Gated behind `LOOM_AUTO_ACCEPT_BYPASS`, default "1" (enabled). Set to "0" to disable on builds where `--dangerously-skip-permissions` already suppresses the modal. The post-spawn call in `spawn_agent` now reads as a synchronous helper invocation rather than the brittle subprocess.Popen pattern. Tests (`TestAutoAcceptBypassPrompt`, 6 cases): - sends Down+Enter when modal is detected on first poll - polls until modal appears across multiple iterations - honours the timeout budget when modal never appears - LOOM_AUTO_ACCEPT_BYPASS=0 disables the path entirely - alternate marker (`--dangerously-skip-permissions`) also detected - non-zero `capture-pane` returncode treated as "not yet" All 6 new tests pass; the 69 pre-existing `test_agent_spawn.py` tests remain at their main-branch baseline (one pre-existing unrelated TestValidateRole failure was present before this PR). Co-Authored-By: Claude Opus 4.7 (1M context) --- loom-tools/src/loom_tools/agent_spawn.py | 113 +++++++++++--- loom-tools/tests/test_agent_spawn.py | 190 +++++++++++++++++++++++ 2 files changed, 279 insertions(+), 24 deletions(-) diff --git a/loom-tools/src/loom_tools/agent_spawn.py b/loom-tools/src/loom_tools/agent_spawn.py index 8f81dac29..e2e090c7c 100644 --- a/loom-tools/src/loom_tools/agent_spawn.py +++ b/loom-tools/src/loom_tools/agent_spawn.py @@ -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 = ( @@ -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 # --------------------------------------------------------------------------- @@ -831,31 +910,17 @@ def spawn_agent( log_success("Agent spawned successfully") - # Auto-accept the "Claude Code running in BypassPermissions mode" warning. - # On this Claude Code build the acknowledgement isn't persisted to the - # CLAUDE_CONFIG_DIR — every fresh process shows the modal warning. The - # menu defaults to "1. No, exit" so an unattended Enter would EXIT the - # session. We schedule a delayed Down+Enter to select "2. Yes, I accept". - # If the prompt isn't shown (e.g. future builds skip it), Down+Enter is - # a harmless no-op in the prompt area. - import subprocess as _subprocess + # 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: - _subprocess.Popen( - [ - "bash", - "-c", - # 8s delay gives Claude Code time to render the bypass modal, - # then send Down+Enter via tmux. - f"sleep 8 && tmux -L {TMUX_SOCKET} send-keys -t {session_name} Down Enter 2>/dev/null || true", - ], - stdin=_subprocess.DEVNULL, - stdout=_subprocess.DEVNULL, - stderr=_subprocess.DEVNULL, - start_new_session=True, - ) - log_info("Scheduled auto-accept of BypassPermissions warning (Down+Enter at +8s)") - except Exception as _e: - log_warning(f"Could not schedule bypass auto-accept: {_e}") + _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}") diff --git a/loom-tools/tests/test_agent_spawn.py b/loom-tools/tests/test_agent_spawn.py index b2f0b26b8..1d83315e2 100644 --- a/loom-tools/tests/test_agent_spawn.py +++ b/loom-tools/tests/test_agent_spawn.py @@ -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 == []