← Commit history

v1.3.15 (insiders): Second route and wake for Grok's agent computer: grok_chat_send types into the grok-bridge chat through UIA when the webhook is refused, grok_wake keeps the paused agent computer running for N minutes, and Grok's rate-limit refusal reports its limit and count.

John Lauer ·b70293600a ·5d ago ·parent 119be16
7 files changed +184−11
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.3.14\ No newline at end of file+1.3.15\ No newline at end of file
CLAUDE.md+7
@@ -83,6 +83,13 @@ Grok 429, a shared cooldown (`~/.adom/grok-webhook-cooldown.json`, Retry-After o 300 s). `grok_activity` reads it. `probe_computer` is cached 30 s because Hydrogen polls readiness thousands of times a day. +Chat route + wake (1.3.15, wiki #8-#10): `handlers/compose.py`. `grok_chat_send` sets the Prompt+Edit (desktop_ui_set text) and invokes the "Send message" button, refuses on a user draft, always+prefixes `[from AI thread "..." via grok-bridge, not typed by John]`, shares activity.admit's budget.+`grok_wake {minutes<=30, reason}` asks the agent to `sleep`, because the agent computer suspends+between runs and Grok Bot has no keep-awake setting. A Grok rate-limit 400 parses+`rateLimitPerMinute` / `currentCount` and suggests chat_send.+ 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+6−3
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "grok",   "displayName": "Grok Bot",-  "version": "1.3.14",+  "version": "1.3.15",   "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.",@@ -51,7 +51,9 @@     "grok_install",     "grok_install_status",     "grok_settings",-    "grok_activity"+    "grok_activity",+    "grok_chat_send",+    "grok_wake"   ],   "statusVerb": "grok_readiness",   "timeouts": {@@ -61,7 +63,8 @@       "computer_screenshot": 330,       "open": 120,       "webhook_ensure": 120,-      "install": 120+      "install": 120,+      "wake": 120     }   },   "dependencies": {
handlers/compose.pyadded+136
@@ -0,0 +1,136 @@+"""grok_chat_send + grok_wake (1.3.15, wiki #8 / #9 / #10).++grok_chat_send: a second route into the grok-bridge agent when the webhook is refused (#8).+Sets the grok-bridge chat's Prompt box through UIA ValuePattern and invokes its "Send message"+button: programmatic, no keyboard, no focus steal (measured on AdomLapper 2026-10-15). The+message lands in the user's own chat, so it shows as the user. To keep the agent's approval+rule honest, every message carries a fixed prefix naming the AI thread; it cannot be turned off.+It refuses when the Prompt box already holds text (the user's draft is never overwritten).++grok_wake: Grok's agent computer pauses a few minutes after an agent run ends and resumes when+the next run starts (#9). There is no Grok Bot setting to keep it up (Settings > General /+Computer / Usage checked 2026-10-15). An agent run that is still working keeps it running, so+grok_wake starts a run that only sleeps for N minutes. It costs Grok usage like any agent run.+"""++from __future__ import annotations++import secrets+import time++import caller_identity+from handlers import activity+from handlers.chat import _select_sidebar_thread+from handlers.windows import _callback_ok, flatten_callback, grok_hwnd++PROMPT = {"name": "Prompt", "role": "Edit"}+SEND = {"name": "Send message", "role": "Button"}+MIN_REASON = 10+WAKE_MAX_MIN = 30+++def _ui(cmd: str, args: dict, timeout: int = 10) -> dict:+    from handlers.windows import _cb+    return _cb(cmd, args, timeout=timeout)+++def _prompt_text(hwnd: int) -> str | None:+    res = _ui("desktop_find_control", dict(PROMPT, hwnd=hwnd))+    m = flatten_callback(res)+    best = (m.get("best") or m.get("control")) if isinstance(m, dict) else None+    if not isinstance(best, dict):+        return None+    got = _ui("desktop_ui_get", dict(PROMPT, hwnd=hwnd))+    g = flatten_callback(got)+    if isinstance(g, dict):+        for k in ("value", "text", "Value"):+            if isinstance(g.get(k), str):+                return g[k]+    return ""+++def chat_send(message: str, *, reason: str, thread: str = "grok-bridge", count_budget: bool = True) -> dict:+    who = caller_identity.thread_name() or "an unnamed AI thread"+    message = (message or "").strip()+    if not message:+        return {"success": False, "errorCode": "message_required", "_hint": "Pass message."}+    if len((reason or "").strip()) < MIN_REASON:+        return {"success": False, "errorCode": "reason_required",+                "_hint": f"grok_chat_send types into the user's own Grok Bot chat; pass reason (>= {MIN_REASON} chars)."}+    if count_budget:+        refusal = activity.admit(who)+        if refusal and refusal["throttledBy"] != "grok":   # Grok's webhook throttle does not apply to chat+            return {"success": False, "sent": False, "errorCode": "webhook_throttled",+                    "throttledBy": refusal["throttledBy"], "retryAfterSeconds": refusal["retryAfterSeconds"],+                    "_hint": "Not sent: " + refusal["why"] + " The chat and the webhook share one budget."}+    hwnd, _win = grok_hwnd(allow_cursor=False)+    if not hwnd:+        return {"success": False, "errorCode": "window_not_found",+                "_hint": "Grok Bot's main window is not visible. grok_open {} first (a hidden window cannot be typed into)."}+    _select_sidebar_thread(hwnd, thread)+    current = _prompt_text(hwnd)+    if current is None:+        return {"success": False, "errorCode": "prompt_not_found",+                "_hint": "The Prompt box is not exposed to UIA. If grok_chat_status says launchedWithAccessibility:false, "+                         "grok_open {\"restart\":true,...} first."}+    if current.strip():+        return {"success": False, "errorCode": "prompt_busy",+                "_hint": "The Prompt box already holds text (likely the user's draft). Not overwritten, not sent."}+    text = f'[from AI thread "{who}" via grok-bridge, not typed by John] {message}'+    st = _ui("desktop_ui_set", dict(PROMPT, hwnd=hwnd, text=text))+    if not _callback_ok(st) or (isinstance(st, dict) and st.get("error")):+        return {"success": False, "errorCode": "prompt_set_failed", "callback": st}+    time.sleep(0.6)+    clicked = _ui("desktop_ui_click", dict(SEND, hwnd=hwnd))+    ok = _callback_ok(clicked) and not (isinstance(clicked, dict) and clicked.get("error"))+    after = _prompt_text(hwnd)+    sent = ok and (after is not None and not after.strip())+    if not sent:+        _ui("desktop_ui_set", dict(PROMPT, hwnd=hwnd, text=""))   # never leave our text in the user's box+    return {+        "success": sent, "sent": sent, "via": "chat", "thread": thread, "hwnd": hwnd,+        **({} if sent else {"errorCode": "send_not_confirmed", "clickCallback": clicked}),+        "_hint": ("Typed into the user's grok-bridge chat through UIA (no focus steal) and pressed Send message. "+                  "It shows as the user's message with the 'from AI thread' prefix, so the agent treats it as an "+                  "AI request, not the user's approval. Read the reply with grok_chat_read."+                  if sent else "Send was not confirmed; the Prompt box was cleared again."),+    }+++def handle_chat_send(args: dict) -> dict:+    return chat_send(str(args.get("message") or ""), reason=str(args.get("reason") or ""),+                     thread=str(args.get("thread") or "grok-bridge"))+++def handle_wake(args: dict) -> dict:+    """Start an agent run that only sleeps, so the agent computer stays up for N minutes."""+    from handlers.webhook import handle_webhook_fire+    reason = str(args.get("reason") or "").strip()+    if len(reason) < MIN_REASON:+        return {"success": False, "errorCode": "reason_required",+                "_hint": f"grok_wake starts a Grok agent run (it costs Grok usage); pass reason (>= {MIN_REASON} chars)."}+    try:+        minutes = int(args.get("minutes") or 10)+    except (TypeError, ValueError):+        minutes = 10+    minutes = max(1, min(WAKE_MAX_MIN, minutes))+    nonce = "WAKE-" + secrets.token_hex(4)+    msg = (f"Keep your computer awake for {minutes} minutes for {caller_identity.thread_name() or 'an AI thread'} "+           f"({reason}). Run exactly `sleep {minutes * 60}` in your terminal and nothing else, then reply "+           f"with just: {nonce} awake-done.")+    fire = handle_webhook_fire({"event": "ping", "payload": {"nonce": nonce, "message": msg}})+    via = "webhook"+    if not fire.get("success") and fire.get("errorCode") in ("webhook_throttled", "webhook_http_error", "bot_unavailable"):+        fire = chat_send(f"{nonce}: {msg}", reason="wake the agent computer: " + reason)+        via = "chat"+    out = {+        "success": bool(fire.get("success")), "via": via, "nonce": nonce, "minutes": minutes,+        "awakeUntilApprox": time.strftime("%H:%M", time.localtime(time.time() + minutes * 60 + 60)),+        "result": {k: fire.get(k) for k in ("errorCode", "httpStatus", "runUuid", "throttledBy", "retryAfterSeconds")},+        "_hint": (f"Asked the agent (via {via}) to sleep {minutes} min, which keeps its computer running. It resumes "+                  "within about a minute: check `adom-bridge targets` for grok-bot-vm-155244935. It pauses again a few "+                  f"minutes after the run ends; the reply '{nonce} awake-done' marks the end. Each wake costs Grok usage."),+    }+    if not out["success"]:+        out["errorCode"] = fire.get("errorCode") or "wake_failed"+    return out
handlers/describe.py+14
@@ -224,6 +224,20 @@ VERB_CATALOG = {             "Never log or return the sender key.",         ],     },+    "chat_send": {+        "summary": "Second route to the grok-bridge agent: type a message into the user's grok-bridge chat through UIA (no focus steal) and press Send message.",+        "long": False,+        "hint": "grok_chat_send {message, reason, thread?=grok-bridge}. Use when grok_webhook_fire is refused (webhook_throttled). Every message carries '[from AI thread \"<name>\" via grok-bridge, not typed by John]'. Refuses with prompt_busy if the user has a draft in the box. Shares the webhook's per-thread / per-PC budget. Needs the main window visible.",+        "related": ["grok_webhook_fire", "grok_chat_read", "grok_wake"],+        "pitfalls": ["The message shows in the user's own chat as theirs, with the AI prefix: not a way to fake the user's approval.", "Each message starts an agent run and costs Grok usage."],+    },+    "wake": {+        "summary": "Keep Grok's agent computer running for N minutes (it pauses a few minutes after each agent run).",+        "long": False,+        "hint": "grok_wake {minutes?=10 (max 30), reason}. Starts an agent run that only sleeps, via the webhook (chat fallback when throttled). The box rejoins the mesh within about a minute; check adom-bridge targets. Costs Grok usage per wake.",+        "related": ["grok_webhook_fire", "grok_chat_send"],+        "pitfalls": ["No Grok Bot setting keeps the computer awake (checked 2026-10-15).", "Wake shortly before you need the box, not in a loop."],+    },     "activity": {         "summary": "Who called which grok_* verb on this PC, when, and what came back; webhook fires, throttles and the shared budget.",         "long": False,
handlers/webhook.py+16−6
@@ -486,18 +486,28 @@ def handle_webhook_fire(args: dict) -> dict:         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+            import re as _re             wait = activity.note_throttled(status, e.headers.get("Retry-After") if e.headers else None)-            return {+            m_lim = _re.search(r"limit\D{0,20}?(\d+)\s*per\s*minute", low)+            m_cnt = _re.search(r"current_count\D{0,3}(\d+)", low)+            out = {                 "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}",+                "_hint": (f"Grok refused the webhook for its rate limit (HTTP {status}). Wait or stop; do NOT "+                          f"re-run grok_webhook_ensure (the URL and key are fine). This PC holds every fire for "+                          f"{wait} s so the limit is not pushed further; all AI threads share this mailbox. "+                          "On 2026-10-15 Grok's automation counter sat at its cap for over an hour while no "+                          "grok-bridge PC was firing, so the load can be outside grok-bridge. Urgent and cannot "+                          "wait: grok_chat_send {message, reason} reaches the agent through the chat instead."),+                "_next": "grok_chat_send {\"message\":\"...\",\"reason\":\"...\"} if urgent; else wait retryAfterSeconds",             }+            if m_lim:+                out["rateLimitPerMinute"] = int(m_lim.group(1))+            if m_cnt:+                out["currentCount"] = int(m_cnt.group(1))+            return out         if "bot is unavailable" in low:             return {                 "success": False,
server.py+4−1
@@ -43,6 +43,7 @@ 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.compose import handle_chat_send, handle_wake from handlers.windows import handle_list_windows, handle_open, handle_screenshot  DEFAULT_PORT = 8769@@ -121,7 +122,7 @@ def handle_activity(args: dict) -> dict:     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; "+        f"{w['refusedByBudget']}). byCallerVerb names the threads. Filter with verb / thread; "         "limit sets how many recent rows come back."     )     return out@@ -159,6 +160,8 @@ COMMAND_HANDLERS = {     "channel_create": handle_channel_create,     "bot_list": handle_bot_list,     "activity": handle_activity,+    "chat_send": handle_chat_send,+    "wake": handle_wake, }