← Commit history

v1.3.13 (insiders): Shared-mailbox protection for the Grok webhook: 6 fires a minute per PC and 3 per thread, a shared cooldown after Grok throttles (Retry-After), an activity log of every call with time, thread and result, grok_activity to read it, and a cached readiness probe.

John Lauer ·2d046db87f ·5d ago ·parent 79c14a7
8 files changed +321−12
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.3.12\ No newline at end of file+1.3.13\ No newline at end of file
CLAUDE.md+8
@@ -75,6 +75,14 @@ when hidden; chat verbs read it in place by hwnd (`read_target_hwnd`), and `grok {requireApproval, approvalSummary}` waits on a notify_user Allow/Deny toast and stamps `payload.approval`; caller-supplied approval fields are always stripped. +Activity + governor (1.3.13): `handlers/activity.py`. server.py logs every verb call to+`~/.adom/grok-bridge-activity.jsonl` (ts, caller, verb, ms, result fields; never keys/text; 5 MB+rotation) and stamps text-log lines with time. `grok_webhook_fire` passes `activity.admit()`+(6/min per PC, 3/min per thread, overridable in `~/.adom/grok-bridge-settings.json`) and, after a+Grok 429, a shared cooldown (`~/.adom/grok-webhook-cooldown.json`, Retry-After or 60 s doubling to+300 s). `grok_activity` reads it. `probe_computer` is cached 30 s because Hydrogen polls readiness+thousands of times a day.+ Webhook bind file: `~/.adom/grok-webhook.json` `{url, key, header, boundAt}` (never log the key). `grok_webhook_ensure` reads UIA Edits Webhook URL / Webhook key (no OCR).  ## The publish loop: insiders first, public on a human's words (wiki #2)
bridge.json+5−3
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "grok",   "displayName": "Grok Bot",-  "version": "1.3.12",+  "version": "1.3.13",   "description": "Drive Grok Bot (standalone Electron app, Grok Bot.exe) from Adom Bridge: open/close/resize/screenshot/record the app, read thinking vs idle and the visible transcript of the focused thread, queue messages, bind the Grok Bridge webhook and create bots/channels. No public Grok HTTP API \u2014 the runtime talks back to ab over desktop_* callbacks. Cursor is last-resort fallback only if Grok Bot.exe is missing.",   "homepage": "https://wiki.adom.inc/adom/grok-bridge",   "author": "Adom Inc.",@@ -50,7 +50,8 @@     "grok_bot_list",     "grok_install",     "grok_install_status",-    "grok_settings"+    "grok_settings",+    "grok_activity"   ],   "statusVerb": "grok_readiness",   "timeouts": {@@ -115,7 +116,8 @@       "channel_create",       "bot_list",       "uninstall",-      "webhook_ensure"+      "webhook_ensure",+      "activity"     ]   },   "platforms": {
handlers/activity.pyadded+212
@@ -0,0 +1,212 @@+"""Activity log + webhook governor (1.3.13).++Why: John (2026-10-15) saw Claude Desktop's Grok requests fail with throttling while other AI+threads were hammering the same Grok Bridge webhook, and grok.log could not say who, when or+what came back (no timestamps, no results). Now every verb call is one JSONL line in+~/.adom/grok-bridge-activity.jsonl, and grok_webhook_fire goes through a governor:++  - a per-PC budget (default 6 fires a minute) and a per-thread budget (default 3 a minute),+    so one noisy thread cannot use up the shared mailbox;+  - a cooldown after Grok answers 429 / "rate limit": honour Retry-After (else 60 s, doubling+    to 300 s), refuse locally until it passes, so callers do not extend the throttle.++Never logs the webhook key, payload messages or transcript text. Limits are overridable in+~/.adom/grok-bridge-settings.json: {"webhookPerMinute": 6, "webhookPerThreadPerMinute": 3}.+"""++from __future__ import annotations++import collections+import json+import os+import threading+import time+from datetime import datetime++_DIR = os.path.join(os.path.expanduser("~"), ".adom")+LOG_PATH = os.path.join(_DIR, "grok-bridge-activity.jsonl")+COOLDOWN_PATH = os.path.join(_DIR, "grok-webhook-cooldown.json")+SETTINGS_PATH = os.path.join(_DIR, "grok-bridge-settings.json")+MAX_BYTES = 5 * 1024 * 1024++DEFAULT_PER_MINUTE = 6+DEFAULT_PER_THREAD_PER_MINUTE = 3+COOLDOWN_DEFAULT = 60+COOLDOWN_MAX = 300++_lock = threading.Lock()+_fires: collections.deque = collections.deque()          # (t, caller) of accepted fires++# Result fields worth keeping (never message text, keys or transcripts).+_KEEP = ("errorCode", "event", "httpStatus", "runUuid", "nonce", "agent", "retryAfterSeconds",+         "throttledBy", "warning", "computerState", "launched", "restarted", "mainWindowHidden")+++def now_iso() -> str:+    return datetime.now().astimezone().isoformat(timespec="seconds")+++def _write(entry: dict) -> None:+    try:+        os.makedirs(_DIR, exist_ok=True)+        with _lock:+            if os.path.exists(LOG_PATH) and os.path.getsize(LOG_PATH) > MAX_BYTES:+                os.replace(LOG_PATH, LOG_PATH + ".1")+            with open(LOG_PATH, "a", encoding="utf-8") as f:+                f.write(json.dumps(entry, ensure_ascii=False) + "\n")+    except Exception:+        pass   # logging must never break a verb+++def log_call(verb: str, caller: str, ms: int, result) -> None:+    entry = {"ts": now_iso(), "verb": verb, "caller": caller or "anonymous", "ms": ms}+    if isinstance(result, dict):+        entry["ok"] = result.get("success") is not False+        for k in _KEEP:+            if result.get(k) is not None:+                entry[k] = result.get(k)+    _write(entry)+++def _settings() -> dict:+    try:+        with open(SETTINGS_PATH, encoding="utf-8") as f:+            d = json.load(f)+            return d if isinstance(d, dict) else {}+    except Exception:+        return {}+++def limits() -> tuple[int, int]:+    s = _settings()+    try:+        per_min = max(1, int(s.get("webhookPerMinute") or DEFAULT_PER_MINUTE))+    except (TypeError, ValueError):+        per_min = DEFAULT_PER_MINUTE+    try:+        per_thread = max(1, int(s.get("webhookPerThreadPerMinute") or DEFAULT_PER_THREAD_PER_MINUTE))+    except (TypeError, ValueError):+        per_thread = DEFAULT_PER_THREAD_PER_MINUTE+    return per_min, min(per_thread, per_min)+++def _read_cooldown() -> dict:+    try:+        with open(COOLDOWN_PATH, encoding="utf-8") as f:+            return json.load(f) or {}+    except Exception:+        return {}+++def cooldown_state() -> dict:+    c = _read_cooldown()+    left = int(float(c.get("until") or 0) - time.time())+    return {"active": left > 0, "secondsLeft": max(0, left), "since": c.get("since"),+            "httpStatus": c.get("httpStatus"), "strikes": c.get("strikes", 0)}+++def note_throttled(http_status, retry_after) -> int:+    """Grok throttled us: start/extend the shared cooldown. Returns seconds to wait."""+    c = _read_cooldown()+    strikes = int(c.get("strikes") or 0) + 1 if float(c.get("until") or 0) > time.time() - 600 else 1+    try:+        wait = int(float(retry_after)) if retry_after not in (None, "") else 0+    except (TypeError, ValueError):+        wait = 0+    if wait <= 0:+        wait = min(COOLDOWN_MAX, COOLDOWN_DEFAULT * (2 ** (strikes - 1)))+    wait = min(max(wait, 5), COOLDOWN_MAX)+    try:+        os.makedirs(_DIR, exist_ok=True)+        with open(COOLDOWN_PATH, "w", encoding="utf-8") as f:+            json.dump({"until": time.time() + wait, "since": now_iso(), "httpStatus": http_status,+                       "strikes": strikes}, f)+    except Exception:+        pass+    return wait+++def note_ok() -> None:+    """A fire went through: clear strikes once any cooldown has passed."""+    c = _read_cooldown()+    if c and float(c.get("until") or 0) <= time.time():+        try:+            os.remove(COOLDOWN_PATH)+        except Exception:+            pass+++def admit(caller: str) -> dict | None:+    """None = may fire now (and the fire is counted). Otherwise a refusal dict."""+    cd = cooldown_state()+    if cd["active"]:+        return {"throttledBy": "grok", "retryAfterSeconds": cd["secondsLeft"],+                "why": f"Grok throttled this PC's webhook (HTTP {cd['httpStatus']}) at {cd['since']}; "+                       f"cooling down {cd['secondsLeft']} s more. Not sent, so the throttle is not extended."}+    per_min, per_thread = limits()+    caller = caller or "anonymous"+    now = time.time()+    with _lock:+        while _fires and now - _fires[0][0] > 60:+            _fires.popleft()+        mine = [t for t, c in _fires if c == caller]+        if len(mine) >= per_thread:+            wait = int(60 - (now - mine[0])) + 1+            return {"throttledBy": "thread-budget", "retryAfterSeconds": wait,+                    "why": f"thread {caller!r} already fired {len(mine)} times in the last minute "+                           f"(limit {per_thread} per thread, so other threads can still reach Grok)."}+        if len(_fires) >= per_min:+            wait = int(60 - (now - _fires[0][0])) + 1+            return {"throttledBy": "pc-budget", "retryAfterSeconds": wait,+                    "why": f"{len(_fires)} webhook fires from this PC in the last minute (limit {per_min})."}+        _fires.append((now, caller))+    return None+++def budget_state() -> dict:+    per_min, per_thread = limits()+    now = time.time()+    with _lock:+        recent = [(t, c) for t, c in _fires if now - t <= 60]+    by = collections.Counter(c for _, c in recent)+    return {"perMinute": per_min, "perThreadPerMinute": per_thread, "firesLastMinute": len(recent),+            "byThreadLastMinute": dict(by), "cooldown": cooldown_state()}+++def read_recent(minutes: int = 60, limit: int = 40, verb: str = "", caller: str = "") -> dict:+    cutoff = time.time() - minutes * 60+    rows = []+    for path in (LOG_PATH + ".1", LOG_PATH):+        try:+            with open(path, encoding="utf-8") as f:+                for line in f:+                    try:+                        e = json.loads(line)+                        t = datetime.fromisoformat(e["ts"]).timestamp()+                    except Exception:+                        continue+                    if t < cutoff:+                        continue+                    if verb and e.get("verb") != verb:+                        continue+                    if caller and e.get("caller") != caller:+                        continue+                    rows.append(e)+        except FileNotFoundError:+            continue+    counts = collections.Counter((e.get("caller"), e.get("verb")) for e in rows)+    fires = [e for e in rows if e.get("verb") == "grok_webhook_fire"]+    return {+        "windowMinutes": minutes,+        "calls": len(rows),+        "byCallerVerb": [{"caller": c, "verb": v, "count": n} for (c, v), n in counts.most_common(30)],+        "webhook": {+            "fires": len(fires),+            "sent": sum(1 for e in fires if e.get("httpStatus") == 200),+            "throttledByGrok": sum(1 for e in fires if e.get("httpStatus") == 429+                                   or e.get("throttledBy") == "grok"),+            "refusedByBudget": sum(1 for e in fires if e.get("throttledBy") in ("thread-budget", "pc-budget")),+            "byCaller": dict(collections.Counter(e.get("caller") for e in fires)),+        },+        "recent": rows[-limit:],+    }
handlers/chat.py+18−2
@@ -344,8 +344,24 @@ def page_probe(hwnd: int) -> dict:             "state": state, "banner": banner}  -def probe_computer() -> dict:-    """computer_* fields for readiness / webhook verbs. Read-only, bounded, never raises."""+_PROBE_CACHE: dict = {}+PROBE_TTL = 30.0+++def probe_computer(max_age: float = PROBE_TTL) -> dict:+    """computer_* fields for readiness / webhook verbs. Read-only, bounded, never raises.++    Cached for 30 s: Hydrogen polls grok_readiness thousands of times a day (16k on crr), and+    each uncached probe is several UIA callbacks on the user's PC."""+    hit = _PROBE_CACHE.get("v")+    if hit and time.monotonic() - _PROBE_CACHE.get("t", 0) < max_age:+        return dict(hit, computerStateAgeSeconds=int(time.monotonic() - _PROBE_CACHE["t"]))+    out = _probe_computer_uncached()+    _PROBE_CACHE.update(v=out, t=time.monotonic())+    return dict(out)+++def _probe_computer_uncached() -> dict:     try:         hwnd, _win = grok_hwnd(allow_cursor=False)         if not hwnd:
handlers/describe.py+8−1
@@ -224,10 +224,17 @@ VERB_CATALOG = {             "Never log or return the sender key.",         ],     },+    "activity": {+        "summary": "Who called which grok_* verb on this PC, when, and what came back; webhook fires, throttles and the shared budget.",+        "long": False,+        "hint": "grok_activity {minutes?=60, verb?, caller?, limit?=40}. Reads ~/.adom/grok-bridge-activity.jsonl (one line per call: ts, caller thread, verb, ms, ok, errorCode, httpStatus, event, nonce, runUuid). webhookBudget shows fires in the last minute per thread and any Grok cooldown.",+        "related": ["grok_webhook_fire", "grok_webhook_status"],+        "pitfalls": ["Per PC: each PC running grok-bridge has its own log and budget.", "Never contains webhook keys or message text."],+    },     "webhook_fire": {         "summary": "POST {event, payload, source:grok-bridge, agent?} to the bound url. Auth is Authorization: Bearer, never a body key.",         "long": False,-        "hint": "Optional agent (name or id) as args.agent or payload.agent — grok-bridge relays into that teammate and pastes the reply into its own thread. Header Authorization: Bearer <key>. errorCode webhook_not_bound if unbound — grok_webhook_ensure first. requireApproval:true + approvalSummary raises an Allow/Deny toast on the user's PC, fires only on Allow and stamps payload.approval (caller-supplied approval fields are stripped). The verb budget is 300 s (bridge.json timeouts).",+        "hint": "Optional agent (name or id) as args.agent or payload.agent — grok-bridge relays into that teammate and pastes the reply into its own thread. Header Authorization: Bearer <key>. errorCode webhook_not_bound if unbound — grok_webhook_ensure first. requireApproval:true + approvalSummary raises an Allow/Deny toast on the user's PC, fires only on Allow and stamps payload.approval (caller-supplied approval fields are stripped). The verb budget is 300 s (bridge.json timeouts). Shared mailbox: 6 fires/min per PC, 3/min per thread; errorCode webhook_throttled (throttledBy thread-budget | pc-budget | grok, retryAfterSeconds) means NOT sent: wait, never loop. After a Grok 429 the PC holds all fires for Retry-After.",         "related": ["grok_webhook_ensure", "grok_bot_create", "grok_channel_create", "grok_bot_list"],         "pitfalls": [             "A JSON field named key is WRONG — auth is a header.",
handlers/webhook.py+36−1
@@ -427,6 +427,24 @@ def handle_webhook_fire(args: dict) -> dict:             return approval["refusal"]         payload["approval"] = approval["stamp"] +    # Governor (1.3.13): Grok's webhook is ONE mailbox shared by every AI thread. Refuse+    # locally while Grok's throttle cools down, and cap each thread so others still get through.+    import caller_identity+    from handlers import activity+    refusal = activity.admit(caller_identity.thread_name())+    if refusal:+        return {+            "success": False, "fired": False, "event": event,+            "errorCode": "webhook_throttled",+            "throttledBy": refusal["throttledBy"],+            "retryAfterSeconds": refusal["retryAfterSeconds"],+            "_hint": ("Not sent: " + refusal["why"] + " Every AI thread shares this one Grok mailbox. "+                      f"Wait at least {refusal['retryAfterSeconds']} s; do not loop or retry sooner, and "+                      "do not fire again while waiting for a reply you already asked for (poll "+                      "grok_chat_read for its nonce instead). grok_activity shows who is using it."),+            "_next": "grok_activity {\"minutes\":10}",+        }+     body = {         "event": event,         "payload": payload,@@ -465,7 +483,22 @@ def handle_webhook_fire(args: dict) -> dict:             resp_text = (e.read() or b"").decode("utf-8", errors="replace")         except Exception:             resp_text = str(e)-        if "bot is unavailable" in resp_text.lower():+        low = resp_text.lower()+        if status == 429 or "rate limit" in low or "too many requests" in low or "throttl" in low:+            from handlers import activity+            wait = activity.note_throttled(status, e.headers.get("Retry-After") if e.headers else None)+            return {+                "success": False, "fired": False, "event": event,+                "errorCode": "webhook_throttled", "throttledBy": "grok",+                "httpStatus": status, "retryAfterSeconds": wait,+                "urlHost": url_host(url), "bodyPreview": resp_text[:300],+                "_hint": (f"Grok throttled the webhook (HTTP {status}). This PC now holds every fire for "+                          f"{wait} s (Retry-After when Grok sends one) so the throttle is not extended; all "+                          "AI threads share this mailbox. Do not retry before then. grok_activity shows "+                          "which threads have been firing."),+                "_next": "grok_activity {\"minutes\":10}",+            }+        if "bot is unavailable" in low:             return {                 "success": False,                 "errorCode": "bot_unavailable",@@ -519,6 +552,8 @@ def handle_webhook_fire(args: dict) -> dict:             parsed = None      run_uuid = _extract_run_uuid(parsed, resp_text)+    from handlers import activity+    activity.note_ok()     out = {         "success": True,         "fired": True,
server.py+33−4
@@ -13,6 +13,7 @@ Usage: from __future__ import annotations  import json+import time import os import sys import traceback@@ -41,6 +42,7 @@ from handlers.readiness import handle_prewarm, handle_readiness, handle_status, from handlers.send import handle_send from handlers.webhook import handle_computer_screenshot, handle_run_status, handle_webhook_bind, handle_webhook_ensure, handle_webhook_fire, handle_webhook_status from handlers.chat import handle_chat_read, handle_chat_status+from handlers import activity from handlers.windows import handle_list_windows, handle_open, handle_screenshot  DEFAULT_PORT = 8769@@ -98,6 +100,28 @@ def _read_bridge_version() -> str:  BRIDGE_VERSION = _read_bridge_version() ++def handle_activity(args: dict) -> dict:+    """Who called what, when, and what came back (from ~/.adom/grok-bridge-activity.jsonl)."""+    try:+        minutes = max(1, min(24 * 60, int(args.get("minutes") or 60)))+    except (TypeError, ValueError):+        minutes = 60+    try:+        limit = max(0, min(500, int(args.get("limit") or 40)))+    except (TypeError, ValueError):+        limit = 40+    out = activity.read_recent(minutes, limit, str(args.get("verb") or ""), str(args.get("caller") or ""))+    out.update({"success": True, "logPath": activity.LOG_PATH, "webhookBudget": activity.budget_state()})+    w = out["webhook"]+    out["_hint"] = (+        f"Last {minutes} min on this PC: {out['calls']} calls; webhook fires {w['fires']} "+        f"(sent {w['sent']}, throttled by Grok {w['throttledByGrok']}, refused by the local budget "+        f"{w['refusedByBudget']}). byCallerVerb names the threads. Filter with verb / caller; "+        "limit sets how many recent rows come back."+    )+    return out+ COMMAND_HANDLERS = {     "readiness": handle_readiness,     "prewarm": handle_prewarm,@@ -130,6 +154,7 @@ COMMAND_HANDLERS = {     "bot_create": handle_bot_create,     "channel_create": handle_channel_create,     "bot_list": handle_bot_list,+    "activity": handle_activity, }  @@ -267,23 +292,27 @@ class GrokBridgeHandler(BaseHTTPRequestHandler):         args = request.get("args") if isinstance(request.get("args"), dict) else {}         caller_identity.set_from_request(self.headers, args)         full = command if str(command).startswith("grok_") else f"grok_{_strip_prefix(str(command))}"-        print(f"[verb] {full}{caller_identity.log_suffix()}", flush=True)+        print(f"{activity.now_iso()} [verb] {full}{caller_identity.log_suffix()}", flush=True)+        t0 = time.monotonic()+        result = None         try:             result = dispatch_command(str(command), args)             self.sendJSON(200, result)         except Exception as e:             traceback.print_exc()-            self.sendJSON(500, {+            result = {                 "success": False,                 "errorCode": "internal_error",                 "error": f"Internal error: {e}",                 "_hint": f"Bridge bug, not your call. {REPORT_ISSUES} with the verb and args.",-            })+            }+            self.sendJSON(500, result)         finally:+            activity.log_call(full, caller_identity.thread_name(), int((time.monotonic() - t0) * 1000), result)             caller_identity.clear()      def log_message(self, fmt, *args):-        print(f"[grok-bridge] {args[0]} {args[1]} {args[2]}", flush=True)+        print(f"{activity.now_iso()} [grok-bridge] {args[0]} {args[1]} {args[2]}", flush=True)   def main() -> None: