From dbd53a1398be3e88985e1bf2d744979a497184b6 Mon Sep 17 00:00:00 2001 From: Michael Ensor Date: Tue, 19 May 2026 04:20:25 +0900 Subject: [PATCH 1/2] feat(ghl): add GHL site profile, google_sso flow, PIT macros, manual-fallback protocol - arc_browser/config/site_registry.json: add app.gohighlevel.com entry with google_sso flow, 2FA + captcha detect selectors, per-domain click-speed policy - arc_browser/browser.py: extend auto_login() to branch on auth.flow=google_sso; add _login_google_sso, wait_for_hydration, extract_modal_text, tick_all_checkboxes, click_by_text helpers - arc_browser/server.py: fix browser_snapshot (Patchright dropped page.accessibility - DOM walker replacement); add 7 new MCP tools: ghl_auth_refresh, ghl_verify_session, ghl_switch_view, ghl_switch_subaccount, ghl_create_pit, ghl_list_pits, agentic_browser_send_prompt - arc_browser/utils/prompt.py: agentic_browser_prompt helper posts to Discord #agentic-browser, polls for human reply or /tmp/_resume flag - MANUAL_FALLBACK.md: manual-fallback protocol - agent asks user before falling back to manual click-through, never silently Closes COMM-36, COMM-37, COMM-38, COMM-39, COMM-40, COMM-58, COMM-59, COMM-60 Co-Authored-By: Claude Opus 4.7 (1M context) --- MANUAL_FALLBACK.md | 79 ++++++++ arc_browser/browser.py | 229 ++++++++++++++++++++++- arc_browser/config/site_registry.json | 30 ++++ arc_browser/server.py | 250 +++++++++++++++++++++++++- arc_browser/utils/prompt.py | 109 +++++++++++ 5 files changed, 693 insertions(+), 4 deletions(-) create mode 100644 MANUAL_FALLBACK.md create mode 100644 arc_browser/utils/prompt.py diff --git a/MANUAL_FALLBACK.md b/MANUAL_FALLBACK.md new file mode 100644 index 0000000..d9dfe6d --- /dev/null +++ b/MANUAL_FALLBACK.md @@ -0,0 +1,79 @@ +# arc-browser Manual Fallback Protocol + +When automation fails, arc-browser does NOT silently fall back to manual click-through. The agent always: + +1. Posts a clear message to Discord `#agentic-browser` via `agentic_browser_prompt(...)` +2. Pauses execution +3. Offers the human the option to drive the failed step manually +4. Resumes only on explicit human reply (Discord message or `touch /tmp/_resume`) + +## Decision tree + +``` +Tool fails (selector miss, modal not detected, 2FA challenge, captcha, scope picker confused) + | + v +Post to #agentic-browser: + "automation hit [X] on [URL]. + - reply 'manual' to take over click-by-click (window stays open) + - reply 'retry' to attempt the macro again + - reply 'skip' to abort + - reply 'abort' to close the session" + | + +-- reply 'manual' -> agent surfaces step-by-step guidance, polls + | for completion signal, then resumes downstream macros + +-- reply 'retry' -> re-run the same macro once with relaxed timing + +-- reply 'skip' -> return failure to caller, do not close session + +-- reply 'abort' -> teardown session + return failure +``` + +## Implementation rule + +Manual fallback is a **first-class branch** in every macro tool, not a hidden default. Macros must: + +- Wrap risky steps in try/except +- On failure, call `agentic_browser_prompt(message=..., session=...)` +- Branch on `result['reply']` content +- Document the expected reply vocabulary in the message itself + +## Anti-pattern + +```python +# WRONG - silent fallback +try: + await click_create_button() +except Exception: + pass # hope the user clicks it +``` + +```python +# RIGHT - explicit human pause +try: + if not await click_by_text(page, "Create Integration"): + raise RuntimeError("button not found") +except Exception: + rep = agentic_browser_prompt( + message="Could not find 'Create Integration' button. Click it manually, then reply 'done', or reply 'abort' to cancel.", + session=GHL_SESSION, + timeout=600, + ) + if rep["reply"].lower().strip() == "abort": + return {"ok": False, "error": "user aborted"} + # otherwise assume done, continue +``` + +## Default behavior settings + +Per `~/.cache/arc-browser/config.json` (created on first run): + +```json +{ + "manual_fallback_default": "ask", + "ask_timeout_s": 600, + "auto_fallback_after_failures": 3, + "audit_log_retention_days": 30 +} +``` + +- `manual_fallback_default`: `"ask"` (default, recommended), `"never"`, or `"always"` +- `auto_fallback_after_failures`: after this many consecutive macro failures, escalate to manual prompt automatically diff --git a/arc_browser/browser.py b/arc_browser/browser.py index e1c1646..9314a50 100644 --- a/arc_browser/browser.py +++ b/arc_browser/browser.py @@ -6,6 +6,7 @@ cdp - Connects to user's real running Chrome (most stealth, disruptive) """ import asyncio +import json import os import signal import subprocess @@ -190,7 +191,12 @@ async def navigate_ready(page, url: str, wait_for_selector: str = None, async def auto_login(site_id: str, session: str = None, force: bool = False) -> dict: """Auto-login for a site using the registry recipe + 1Password. - Returns {"status": "logged_in"|"already"|"failed", "reason": str}. + Supports two flow types via `auth.flow`: + - "form" (default) - email/password form submit + - "google_sso" - click "Continue with Google" -> Google account picker + -> 2FA pause via #agentic-browser if challenge appears + + Returns {"status": "logged_in"|"already"|"failed"|"pending_2fa", "reason": str}. Idempotent: returns "already" if the verify_url check passes without needing to re-authenticate (unless force=True). """ @@ -202,6 +208,7 @@ async def auto_login(site_id: str, session: str = None, force: bool = False) -> if not auth: return {"status": "failed", "reason": f"No auth recipe for '{site_id}'"} + flow = auth.get("flow", "form") required = ["credential_item", "login_url", "verify_url"] missing = [k for k in required if k not in auth] if missing: @@ -222,6 +229,10 @@ async def auto_login(site_id: str, session: str = None, force: bool = False) -> except Exception: pass + # Branch on flow type + if flow == "google_sso": + return await _login_google_sso(page, auth, sess) + # Fetch credentials try: creds = get_credentials(auth["credential_item"]) @@ -288,3 +299,219 @@ async def verify_auth(site_id: str, session: str = None) -> dict: return {"authenticated": True, "reason": f"At {page.url}"} except Exception as e: return {"authenticated": False, "reason": f"Probe failed: {e}"} + + +# --------------------------------------------------------------------------- +# Google SSO flow + helpers +# --------------------------------------------------------------------------- + +async def _detect_any_selector(page, selectors: list) -> bool: + """Return True if any selector resolves to a visible element.""" + for sel in selectors or []: + try: + el = await page.query_selector(sel) + if el: + visible = await el.is_visible() + if visible: + return True + except Exception: + continue + return False + + +async def _login_google_sso(page, auth: dict, sess: str) -> dict: + """Drive a Google SSO login. Pauses for 2FA / captcha via #agentic-browser.""" + from .utils.credentials import get_credentials + from .utils.prompt import agentic_browser_prompt + + # Fetch credentials (used for typing email if Google prompt shows) + try: + creds = get_credentials(auth["credential_item"]) + except Exception as e: + return {"status": "failed", "reason": f"Credential lookup failed: {e}"} + + try: + await page.goto(auth["login_url"], wait_until="load", timeout=60000) + await asyncio.sleep(2.5) + except Exception as e: + return {"status": "failed", "reason": f"Initial nav failed: {e}"} + + # Try the Google button + btn_sels = auth.get("google_button_selectors", []) + clicked = False + for sel in btn_sels: + try: + el = await page.query_selector(sel) + if el: + await el.click() + clicked = True + await asyncio.sleep(2) + break + except Exception: + continue + if not clicked: + # Site may already be at Google account picker if cookies partially valid + if "accounts.google.com" not in page.url: + return {"status": "failed", "reason": "Could not find Continue with Google button"} + + # If Google email entry visible, type it + try: + email_input = await page.query_selector("input[type='email']") + if email_input: + await email_input.fill(creds.get("username", "")) + await asyncio.sleep(0.8) + try: + next_btn = await page.query_selector("button:has-text('Next'), #identifierNext") + if next_btn: + await next_btn.click() + except Exception: + await page.keyboard.press("Enter") + await asyncio.sleep(2.5) + except Exception: + pass + + # Password if shown + try: + pw_input = await page.query_selector("input[type='password']") + if pw_input: + await pw_input.fill(creds.get("password", "")) + await asyncio.sleep(0.8) + try: + next_btn = await page.query_selector("button:has-text('Next'), #passwordNext") + if next_btn: + await next_btn.click() + except Exception: + await page.keyboard.press("Enter") + await asyncio.sleep(3) + except Exception: + pass + + # Detect 2FA + captcha + two_fa_sels = (auth.get("two_fa") or {}).get("detect_selectors", []) + captcha_sels = (auth.get("captcha") or {}).get("detect_selectors", []) + if await _detect_any_selector(page, two_fa_sels): + reply = agentic_browser_prompt( + message=f"2FA challenge detected on {auth['login_url']}.\nComplete it in the browser window, then reply 'done' or run: touch /tmp/{sess}_resume", + session=sess, + timeout=600, + ) + if reply["status"] == "timeout": + return {"status": "pending_2fa", "reason": "Human did not resolve 2FA in time"} + await asyncio.sleep(2) + elif await _detect_any_selector(page, captcha_sels): + reply = agentic_browser_prompt( + message=f"reCAPTCHA detected on {auth['login_url']}.\nComplete it in the browser window, then reply 'done' or run: touch /tmp/{sess}_resume", + session=sess, + timeout=600, + ) + if reply["status"] == "timeout": + return {"status": "pending_2fa", "reason": "Captcha unresolved in time"} + await asyncio.sleep(2) + + # Poll for redirect to verify_url + for _ in range(30): + await asyncio.sleep(2) + if auth.get("verify_not_contains", "") not in page.url and "accounts.google.com" not in page.url: + return {"status": "logged_in", "reason": f"Redirected to {page.url}"} + + return {"status": "failed", "reason": f"Did not redirect away from auth (current: {page.url})"} + + +async def wait_for_hydration(page, max_ms: int = 8000, custom_selector: str = None) -> bool: + """Poll for SPA-ready signals: body has visible text, optional selector visible. + + Returns True if hydrated within max_ms, False on timeout. + """ + deadline = asyncio.get_event_loop().time() + max_ms / 1000.0 + while asyncio.get_event_loop().time() < deadline: + try: + ready = await page.evaluate( + """() => { + const hasText = (document.body.innerText || '').trim().length > 50; + const ready = document.readyState === 'complete'; + return ready && hasText; + }""" + ) + if ready: + if custom_selector: + try: + el = await page.query_selector(custom_selector) + if el and await el.is_visible(): + return True + except Exception: + pass + else: + return True + except Exception: + pass + await asyncio.sleep(0.4) + return False + + +async def extract_modal_text(page, modal_selector: str = "[role='dialog'], .modal, [class*='modal'], [class*='Modal']", max_chars: int = 3000) -> str: + """Find any visible modal and return its inner text. Empty string if none.""" + try: + el = await page.query_selector(modal_selector) + if not el: + return "" + visible = await el.is_visible() + if not visible: + return "" + txt = await el.inner_text() + return (txt or "")[:max_chars] + except Exception: + return "" + + +async def tick_all_checkboxes(page, container_selector: str, exclude_labels: list = None, delay_range: tuple = (0.05, 0.2)) -> int: + """Click every unchecked checkbox inside container. Returns count of ticks.""" + import random + exclude_labels = [s.lower() for s in (exclude_labels or [])] + try: + container = await page.query_selector(container_selector) + if not container: + return 0 + checkboxes = await container.query_selector_all("input[type='checkbox'], [role='checkbox']") + except Exception: + return 0 + ticked = 0 + for cb in checkboxes: + try: + checked = await cb.is_checked() if hasattr(cb, "is_checked") else False + if checked: + continue + # Check exclude + label_text = "" + try: + label_text = (await cb.evaluate("el => (el.closest('label')||el.parentElement||el).innerText || ''")).lower() + except Exception: + pass + if any(ex in label_text for ex in exclude_labels): + continue + await cb.click() + ticked += 1 + await asyncio.sleep(random.uniform(*delay_range)) + except Exception: + continue + return ticked + + +async def click_by_text(page, text: str, role: str = "button", timeout: int = 5000) -> bool: + """Click an element matching visible text + role. Resilient to className churn.""" + role_map = {"button": "button, [role='button']", "link": "a, [role='link']", "menuitem": "[role='menuitem']"} + sel = role_map.get(role, role) + js = f"""(() => {{ + const cand = Array.from(document.querySelectorAll({json.dumps(sel)})); + const target = cand.find(el => (el.innerText||'').trim().toLowerCase().includes({json.dumps(text.lower())})); + if (target) {{ target.click(); return true; }} + return false; + }})()""" + try: + for _ in range(int(timeout / 250)): + ok = await page.evaluate(js) + if ok: + return True + await asyncio.sleep(0.25) + except Exception: + return False + return False diff --git a/arc_browser/config/site_registry.json b/arc_browser/config/site_registry.json index b707356..eb12afe 100644 --- a/arc_browser/config/site_registry.json +++ b/arc_browser/config/site_registry.json @@ -45,6 +45,36 @@ }, "notes": "SPA - always use navigate_ready with post-nav settle. Login form selectors work for both email and username login flows." }, + "app.gohighlevel.com": { + "risk": "low", + "mode": "headed", + "actions_per_hour": 120, + "waits": { "default": "load", "post_nav_ms": 2500 }, + "auth": { + "flow": "google_sso", + "login_url": "https://app.gohighlevel.com/", + "credential_item": "Google - digital.access.partners@gmail.com", + "google_button_selectors": [ + "button:has-text('Continue with Google')", + "div[role='button']:has-text('Sign in with Google')", + "[data-provider='google']", + "iframe[src*='accounts.google.com']" + ], + "verify_url": "https://app.gohighlevel.com/agency_dashboard?tab=summary", + "verify_not_contains": "/sign-in", + "two_fa": { + "detect_selectors": ["input[autocomplete='one-time-code']", "input[name='Passcode']", "div:has-text('2-Step Verification')"], + "handoff_channel": "agentic-browser" + }, + "captcha": { + "detect_selectors": ["iframe[title*='reCAPTCHA']", "iframe[src*='recaptcha']", "div.g-recaptcha"], + "handoff_channel": "agentic-browser" + } + }, + "click_speed": { "min_ms": 200, "max_ms": 600 }, + "auth_click_speed": { "min_ms": 1500, "max_ms": 3000 }, + "notes": "GHL SPA with iframe-nested settings. Agency vs sub-account view switch via top-left dropdown. PIT settings at Agency Settings > Private Integrations. Sub-account context affects Settings menu - switch to Agency view before PIT create." + }, "github.com": { "risk": "low", "mode": "headless", diff --git a/arc_browser/server.py b/arc_browser/server.py index 6e4c4cc..87d27b8 100644 --- a/arc_browser/server.py +++ b/arc_browser/server.py @@ -25,7 +25,9 @@ from .browser import ( get_context, current_page, navigate_ready, auto_login, verify_auth, with_retry, + wait_for_hydration, extract_modal_text, tick_all_checkboxes, click_by_text, ) +from .utils.prompt import agentic_browser_prompt from .router import classify, get_recipe from .agent import run_task from .utils.human import human_click, human_type, human_delay @@ -146,10 +148,54 @@ async def browser_navigate(url: str, session: str = "default", @mcp.tool() async def browser_snapshot(session: str = "default") -> str: - """Return the accessibility tree of the current page (structured, low token cost).""" + """Return a structured snapshot of the current page. + + Patchright dropped the `page.accessibility` namespace. This implementation + walks the live DOM in-page and emits a compact a11y-style tree with role, + name, level, and short text snippets - sufficient for LLM-driven planning + at a fraction of full HTML tokens. + """ page = current_page(session) - snap = await page.accessibility.snapshot() - return str(snap) + js = """ +(() => { + const ROLE_MAP = { + A:'link', BUTTON:'button', INPUT:'textbox', TEXTAREA:'textbox', + SELECT:'combobox', NAV:'navigation', MAIN:'main', HEADER:'banner', + FOOTER:'contentinfo', ASIDE:'complementary', FORM:'form', LABEL:'label', + UL:'list', OL:'list', LI:'listitem', H1:'heading', H2:'heading', + H3:'heading', H4:'heading', H5:'heading', H6:'heading', + IMG:'img', SECTION:'region', ARTICLE:'article', TABLE:'table', + TR:'row', TD:'cell', TH:'columnheader', DIALOG:'dialog', + }; + function nameOf(el){ + return (el.getAttribute('aria-label') || el.getAttribute('alt') || + el.getAttribute('title') || el.getAttribute('placeholder') || + (el.tagName==='INPUT' ? (el.value||el.type||'') : '') || + (el.innerText||'').trim().split('\\n')[0].slice(0,120) || ''); + } + function walk(el, depth){ + if (!el || depth>10) return null; + if (el.nodeType !== 1) return null; + if (el.offsetParent===null && el.tagName!=='BODY' && el.tagName!=='DIALOG') return null; + const role = el.getAttribute('role') || ROLE_MAP[el.tagName]; + const name = nameOf(el); + const node = (role || name) ? {role: role||'generic', name: name.slice(0,140), tag: el.tagName.toLowerCase()} : null; + const children = []; + for (const c of el.children){ + const sub = walk(c, depth+1); + if (sub) children.push(sub); + } + if (node){ if (children.length) node.children = children; return node; } + if (children.length===1) return children[0]; + if (children.length>1) return {role:'generic', children}; + return null; + } + const tree = walk(document.body, 0) || {role:'generic'}; + return JSON.stringify({url: location.href, title: document.title, tree}, null, 2); +})() +""" + result = await page.evaluate(js) + return result @mcp.tool() @@ -529,5 +575,203 @@ async def skool_onboard(slug: str, client_id: str = None) -> str: return stdout.decode() +# --------------------------------------------------------------------------- +# GHL (GoHighLevel) dedicated tool surface +# --------------------------------------------------------------------------- + +GHL_SESSION = "ghl" +GHL_DOMAIN = "app.gohighlevel.com" + + +@mcp.tool() +async def ghl_auth_refresh(force: bool = False) -> str: + """Ensure a valid GoHighLevel session exists. + + Uses the google_sso flow in site_registry. Pauses for 2FA via + #agentic-browser if Google challenges. Idempotent. + Returns JSON: {status, reason}. + """ + result = await auto_login(GHL_DOMAIN, session=GHL_SESSION, force=force) + return json.dumps(result, indent=2) + + +@mcp.tool() +async def ghl_verify_session() -> str: + """Check whether the GHL session is authenticated without re-logging in. + Returns JSON: {authenticated, reason}. + """ + result = await verify_auth(GHL_DOMAIN, session=GHL_SESSION) + return json.dumps(result, indent=2) + + +@mcp.tool() +async def ghl_switch_view(view: str = "agency") -> str: + """Switch GHL UI between 'agency' and 'subaccount' context. + + Args: + view: 'agency' or 'subaccount' (with location_id) - currently only 'agency' supported. + Returns JSON: {ok, url}. + """ + ctx = await get_context(session=GHL_SESSION, mode="headed") + page = ctx.pages[0] if ctx.pages else await ctx.new_page() + if view == "agency": + await page.goto("https://app.gohighlevel.com/agency_dashboard?tab=summary", + wait_until="load", timeout=30000) + await wait_for_hydration(page, max_ms=8000) + else: + return json.dumps({"ok": False, "reason": "use ghl_switch_subaccount(location_id) for sub-account view"}) + return json.dumps({"ok": True, "url": page.url}) + + +@mcp.tool() +async def ghl_switch_subaccount(location_id: str) -> str: + """Switch GHL UI into a specific sub-account by location_id. + + Returns JSON: {ok, url}. + """ + ctx = await get_context(session=GHL_SESSION, mode="headed") + page = ctx.pages[0] if ctx.pages else await ctx.new_page() + await page.goto(f"https://app.gohighlevel.com/v2/location/{location_id}/", + wait_until="load", timeout=30000) + await wait_for_hydration(page, max_ms=10000) + return json.dumps({"ok": location_id in page.url, "url": page.url}) + + +@mcp.tool() +async def ghl_create_pit(level: str = "agency", name: str = "stackpack-full", + scopes: str = "all") -> str: + """Create a Private Integration Token in GoHighLevel. + + Args: + level: 'agency' or 'subaccount' (run ghl_switch_subaccount first for subaccount) + name: integration name + scopes: 'all' (tick everything) or comma-separated list + + Drives the 5-step UI flow: + Settings -> Private Integrations -> Create New -> tick scopes -> Submit + -> scrape token from display modal before close. + + Returns JSON: {ok, pit, scopes_ticked, name, level, error?}. + """ + ctx = await get_context(session=GHL_SESSION, mode="headed") + page = ctx.pages[0] if ctx.pages else await ctx.new_page() + + # Navigate to private integrations + settings_url = "https://app.gohighlevel.com/v2/settings/private-integrations" + try: + await page.goto(settings_url, wait_until="load", timeout=30000) + await wait_for_hydration(page, max_ms=10000) + except Exception as e: + return json.dumps({"ok": False, "error": f"nav failed: {e}"}) + + # Click "Create New Integration" - try several common labels + for label in ("Create New Integration", "Create Integration", "Add Integration", "+ Add"): + if await click_by_text(page, label, role="button", timeout=2500): + break + else: + # Fallback: agentic prompt + rep = agentic_browser_prompt( + message=f"ghl_create_pit could not find the 'Create New Integration' button on {settings_url}. Click it manually then reply 'done'.", + session=GHL_SESSION, timeout=600, + ) + if rep["status"] == "timeout": + return json.dumps({"ok": False, "error": "create-button not found, no human reply"}) + + await asyncio.sleep(2) + + # Fill name + try: + name_input = await page.query_selector("input[name='name'], input[placeholder*='Name'], input[placeholder*='name']") + if name_input: + await name_input.fill(name) + await asyncio.sleep(0.5) + except Exception: + pass + + # Tick scopes + ticked = 0 + if scopes == "all": + # Find scope picker container - try common patterns + for container_sel in ( + "[class*='scopes']", "[class*='Scopes']", + "[data-testid*='scope']", "form", "[role='dialog']" + ): + ticked = await tick_all_checkboxes(page, container_sel, delay_range=(0.04, 0.12)) + if ticked > 5: + break + else: + scope_list = [s.strip() for s in scopes.split(",")] + for s in scope_list: + cb = await page.query_selector(f"input[type='checkbox'][value='{s}']") + if cb: + checked = await cb.is_checked() + if not checked: + await cb.click() + ticked += 1 + await asyncio.sleep(0.1) + + await asyncio.sleep(1) + + # Submit + submitted = False + for label in ("Create Integration", "Create", "Submit", "Save"): + if await click_by_text(page, label, role="button", timeout=2500): + submitted = True + break + if not submitted: + return json.dumps({"ok": False, "error": "submit button not found", "scopes_ticked": ticked}) + + # Scrape token from modal - wait up to 8s + for _ in range(16): + await asyncio.sleep(0.5) + modal_text = await extract_modal_text(page) + if modal_text: + import re + m = re.search(r"pit-[0-9a-f-]{32,}", modal_text) + if m: + return json.dumps({ + "ok": True, "pit": m.group(0), + "scopes_ticked": ticked, "name": name, "level": level, + }, indent=2) + + return json.dumps({"ok": False, "error": "token modal not detected", "scopes_ticked": ticked}, indent=2) + + +@mcp.tool() +async def ghl_list_pits(level: str = "agency") -> str: + """List existing Private Integration Tokens visible in current view. + + Reads names + created dates from the Private Integrations settings page. + Returns JSON array. + """ + ctx = await get_context(session=GHL_SESSION, mode="headed") + page = ctx.pages[0] if ctx.pages else await ctx.new_page() + settings_url = "https://app.gohighlevel.com/v2/settings/private-integrations" + await page.goto(settings_url, wait_until="load", timeout=30000) + await wait_for_hydration(page, max_ms=8000) + rows = await page.evaluate(""" +() => { + const out=[]; + document.querySelectorAll('tr, [role=row], [class*=integration-row]').forEach(r => { + const t=(r.innerText||'').trim(); + if (t && t.length>3) out.push(t.slice(0,200)); + }); + return out; +} +""") + return json.dumps({"level": level, "rows": rows or []}, indent=2) + + +@mcp.tool() +async def agentic_browser_send_prompt(message: str, session: str = "default", + timeout: int = 600) -> str: + """Post a message to #agentic-browser and wait for human reply. + + Used for 2FA pauses, captcha handoffs, manual-fallback offers. + Returns JSON: {status, reply, elapsed_s}. + """ + result = agentic_browser_prompt(message=message, session=session, timeout=timeout) + return json.dumps(result, indent=2) + if __name__ == "__main__": mcp.run() diff --git a/arc_browser/utils/prompt.py b/arc_browser/utils/prompt.py new file mode 100644 index 0000000..2552ff1 --- /dev/null +++ b/arc_browser/utils/prompt.py @@ -0,0 +1,109 @@ +""" +agentic_browser_prompt: post a pause prompt to Discord #agentic-browser and wait +for the human to respond. Used by 2FA pauses, captcha handoffs, and +manual-fallback offers. + +Two unblock paths: + 1. Human posts any reply in #agentic-browser (poll Discord) + 2. Human creates /tmp/_resume file + +Returns reply text (first non-empty Discord reply after the prompt, or +"resume" if file flag used). +""" +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path +from typing import Optional + +DISCORD_AGENT_DIR = "/Users/home/ai/agents/comms/discord_agent" +DEFAULT_CHANNEL = "agentic-browser" +DEFAULT_TIMEOUT = 900 # 15 min + + +def _discord_send(message: str, channel: str = DEFAULT_CHANNEL) -> tuple[bool, Optional[str]]: + """Send a message to the agentic-browser channel via discord_agent. Returns (ok, posted_message_id).""" + if DISCORD_AGENT_DIR not in sys.path: + sys.path.insert(0, DISCORD_AGENT_DIR) + try: + from discord_api import DiscordClient # type: ignore + except Exception as e: + return False, f"discord_api import failed: {e}" + try: + c = DiscordClient("arc") + ch_id = c.resolve_channel(channel) + ok = c.send_message(ch_id, message) + return bool(ok), None + except Exception as e: + return False, f"send_message failed: {e}" + + +def _discord_poll_replies(after_ts: float, channel: str = DEFAULT_CHANNEL, limit: int = 20) -> list[dict]: + """Return messages newer than after_ts in the channel.""" + if DISCORD_AGENT_DIR not in sys.path: + sys.path.insert(0, DISCORD_AGENT_DIR) + try: + from discord_api import DiscordClient # type: ignore + except Exception: + return [] + try: + c = DiscordClient("arc") + ch_id = c.resolve_channel(channel) + msgs = c.get_messages(ch_id, limit=limit) or [] + except Exception: + return [] + out = [] + for m in msgs: + # Discord timestamp is ISO; convert + ts_str = m.get("timestamp", "") + try: + from datetime import datetime + ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00")).timestamp() + except Exception: + continue + if ts > after_ts and not m.get("author", {}).get("bot"): + out.append({"ts": ts, "author": m["author"].get("username"), "content": m.get("content", "")}) + return sorted(out, key=lambda x: x["ts"]) + + +def agentic_browser_prompt( + message: str, + session: str = "default", + channel: str = DEFAULT_CHANNEL, + timeout: int = DEFAULT_TIMEOUT, + poll_interval: int = 10, +) -> dict: + """Post `message` to #agentic-browser. Wait for human reply. + + Returns {"status": "replied"|"file_resume"|"timeout", "reply": str, "elapsed_s": int}. + """ + start = time.time() + body = f"```\n{message}\n```\n_Session: `{session}` - reply in this channel or create `/tmp/{session}_resume` to unblock._" + ok, err = _discord_send(body, channel) + if not ok: + print(f"[agentic_browser_prompt] discord post failed: {err}", file=sys.stderr) + + resume_file = Path(f"/tmp/{session}_resume") + deadline = start + timeout + while time.time() < deadline: + # File flag wins + if resume_file.exists(): + try: + content = resume_file.read_text().strip() + except Exception: + content = "" + try: + resume_file.unlink() + except Exception: + pass + return {"status": "file_resume", "reply": content or "resume", "elapsed_s": int(time.time() - start)} + # Poll Discord + replies = _discord_poll_replies(start, channel=channel, limit=10) + if replies: + r = replies[-1] + return {"status": "replied", "reply": r["content"], "elapsed_s": int(time.time() - start), "author": r["author"]} + time.sleep(poll_interval) + + return {"status": "timeout", "reply": "", "elapsed_s": int(time.time() - start)} From 3f28df1384c6d496792a86546e011990aee59143 Mon Sep 17 00:00:00 2001 From: Michael Ensor Date: Thu, 21 May 2026 01:16:28 +0900 Subject: [PATCH 2/2] Embed no-fabricated-estimates house rule Adds PR template checklist + CI workflow caller (reusable workflow at arc-web/claude-skills). Adds AGENTS.md / instruction-file reference to canonical rule where clean. Rule: agents do not invent durations ('30min', 'quick task'). Real deadlines from humans, human-set Plane time fields, calendar dates allowed. See https://github.com/arc-web/claude-skills/blob/main/house-rules/no-fabricated-estimates.md Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/PULL_REQUEST_TEMPLATE.md | 7 +++++++ .github/workflows/check-fabricated-estimates.yml | 6 ++++++ AGENTS.md | 16 ++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/check-fabricated-estimates.yml create mode 100644 AGENTS.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..6952b3f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ +## Summary + +- + +## Checklist + +- [ ] No fabricated effort estimates in this change. See [house-rules/no-fabricated-estimates.md](https://github.com/arc-web/claude-skills/blob/main/house-rules/no-fabricated-estimates.md). diff --git a/.github/workflows/check-fabricated-estimates.yml b/.github/workflows/check-fabricated-estimates.yml new file mode 100644 index 0000000..6e1d0b6 --- /dev/null +++ b/.github/workflows/check-fabricated-estimates.yml @@ -0,0 +1,6 @@ +name: check-fabricated-estimates +on: + pull_request: +jobs: + check: + uses: arc-web/claude-skills/.github/workflows/check-fabricated-estimates.yml@main diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..49629ed --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,16 @@ +# arc-browser - Agent Instructions + +Repo: /Users/home/ai/tools/browser/arc-browser + + +--- + +## House rules (non-negotiable, version-controlled) + +### No fabricated effort estimates + +Agents must not invent durations ("30 minutes", "half an hour", "quick task"). Real deadlines from humans/stakeholders ("by Friday", "before campaign launch") and human-set Plane time fields are fine. + +Canonical rule: + +Enforced via pre-commit hook (global + per-repo) and CI workflow `check-fabricated-estimates`. Hard fail on violation. PR template carries the reminder.