app
Grok Bot
Public Made by Adomby adom
Grok Bridge lets Adom talk to Grok Bot. Adom's AI environment is for electrical engineers: any provider, any desktop power tool, one place.
← 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.
8 files changed
+321−12
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.3.12+1.3.13
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: