← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.2.2+1.3.0
CLAUDE.md+5−3
@@ -32,7 +32,7 @@ will seed/stream the runtime from the release zip via `updateManifestUrl` in `br   `ad_callback`. Never `shell_execute` / `pull_file` / `send_files` from the HTTP path. - `handlers/*.py` — readiness, describe, windows (open/screenshot/list), chat (status/read),   send, app (close/resize/record/login/logout/plugin), webhook, bots.-- `bridge.json` + `BRIDGE_VERSION` — KEEP IN LOCKSTEP (currently 1.2.2).+- `bridge.json` + `BRIDGE_VERSION` — KEEP IN LOCKSTEP (currently 1.3.0). - `adom-bridge-grok-manifest.json` — streaming manifest ab's `updateManifestUrl` points at;   its `url` is the RELEASE download URL of the zip, with matching `sha256`/`size`. - `SKILL.md` (root) — core USER skill, installed as **`grok`** (not `grok-bridge`).@@ -41,7 +41,7 @@ will seed/stream the runtime from the release zip via `updateManifestUrl` in `br - `package.json` — wiki skills pkg. Description is the skills-not-runtime sentence. Runtime   files and `*.zip` do **not** belong in `files[]`. -## Verbs (v1.2.2)+## Verbs (v1.3.0)  Existing: `grok_readiness` `grok_prewarm` `grok_describe` `grok_status` `grok_open` `grok_screenshot` `grok_list_windows` `grok_chat_status` `grok_chat_read` `grok_send` `grok_uninstall`.@@ -51,6 +51,8 @@ App: `grok_close` `grok_resize` `grok_record_start` `grok_record_stop` `grok_rec  Webhook: `grok_webhook_status` `grok_webhook_ensure` `grok_webhook_bind` `grok_webhook_fire` `grok_bot_create` `grok_channel_create` `grok_bot_list`.++Install (1.3.0): `grok_install {force?}` background job + `grok_install_status`. See `handlers/install.py`. `grok_webhook_ensure` scrapes UIA Edit **Webhook URL** / **Webhook key** (no human copy-paste). `grok_webhook_fire` optional `agent` / `payload.agent` (name or id) relays into that teammate. @@ -70,7 +72,7 @@ Wiki = canonical. **Do not publish from this clone until asked.** - **The `.zip` is a RELEASE asset, never a git file.** `*.zip` is gitignored. - **`sha256`/`size` in the manifest MUST match the served release asset** or `bridge_install` rejects it. - **Never hand-edit the bridges-cache.** Edit here, publish.-- **Never tell the user to download Grok Bot / Cursor.** `grok_open` offers to launch.+- **Never tell the user to download Grok Bot / Cursor.** `grok_install` installs it (public feed `api2.cursor.sh/updates/api/update/<platform>/sand/0.0.1/0/stable`, Authenticode Anysphere / sha256 gated); `grok_open` launches. - **Never kill Cursor.** Prefer hide (`grok_close`) over `process_kill`. - **Background by default.** Don't steal focus or the keyboard. `grok_send` is toast + inbox. - **Repo name ≠ skill name.** Install as `grok`.
SKILL.md+2−1
@@ -24,7 +24,7 @@ There is no public Grok HTTP API. `ab` spawns a small Python server from the **r  | Signal | What it means | What YOU do | |---|---|---|-| `grok_readiness` `hostAppInstalled:false` / `errorCode: host_app_not_installed` | Grok Bot.exe is not on this PC | **Do not send the user to a download page.** Offer `grok_open`. That verb detects, then asks ab to `launch` / `desktop_open`. |+| `grok_readiness` `hostAppInstalled:false` / `errorCode: host_app_not_installed` | Grok Bot is not on this PC | **`grok_install {}`** installs it in the background from Grok Bot's public update feed (Windows x64/arm64, macOS, Linux AppImage; signature/sha256-checked, per-user, no admin). Poll `grok_install_status` (~10 s) until `done`, then `grok_open`. Never send the user to a download page. Sign-in is still theirs (`grok_login`). | | `hostAppFallback:"Cursor"` | Grok Bot.exe missing; Cursor is last-resort host | Prefer Grok Bot. Drive Cursor only as fallback. **Never kill Cursor.** | | `hostAppInstalled:true`, `hostAppRunning:false` | Grok Bot is on disk, not running | `grok_open {}` — launches in the **background**. | | `ready:true` / host running | Grok Bot is up | `grok_list_windows` / `grok_chat_status` / `grok_chat_read` / `grok_screenshot` / `grok_send`. |@@ -42,6 +42,7 @@ adom-bridge --ai-thread "<your thread>" grok_readiness '{}' | CLI | Purpose | Key args | More | |---|---|---|---| | `grok_readiness` | Layered host-app probe. Zero side effects. | — | this skill |+| `grok_install` / `grok_install_status` | Install Grok Bot on this PC (public update feed, background job) | optional `force` | this skill | | `grok_prewarm` | No-op re-detect. `{wait:true}` just re-detects. | optional `wait` | this skill | | `grok_describe` | Catalog with hint/related/pitfalls. | — | this skill | | `grok_open` / `grok_close` / `grok_resize` / `grok_screenshot` / `grok_list_windows` / `grok_status` | Window + host | see **grok-app-control** | grok-app-control |
adom-bridge-grok-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "grok",-  "version": "1.2.4",-  "url": "https://wiki.adom.inc/download/adom/grok-bridge/1.2.4/adom-bridge-grok-v1.2.4.zip",-  "sha256": "aaf9ca1abca68a45611e3d1a0668fed4a130c427993de8c347b7f348ef93bdf9",-  "size": 43720,+  "version": "1.3.0",+  "url": "https://wiki.adom.inc/download/adom/grok-bridge/1.3.0/adom-bridge-grok-v1.3.0.zip",+  "sha256": "4a2d2172d9987987b4737fb75df0a27a8e230f635608703a65e480787e4e8049",+  "size": 48242,   "verbPrefixes": [     "grok_"   ],
bridge.json+8−3
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "grok",   "displayName": "Grok Bot",-  "version": "1.2.2",+  "version": "1.3.0",   "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.",@@ -43,13 +43,18 @@     "grok_webhook_bind",     "grok_webhook_ensure",     "grok_webhook_fire",+    "grok_computer_screenshot",+    "grok_run_status",     "grok_bot_create",     "grok_channel_create",-    "grok_bot_list"+    "grok_bot_list",+    "grok_install",+    "grok_install_status"   ],   "statusVerb": "grok_readiness",   "timeouts": {-    "default": 60+    "default": 60,+    "long": 300   },   "dependencies": {     "python": ">=3.10"
dev-skills/grok-publish/SKILL.md+1−1
@@ -23,7 +23,7 @@ Wiki: https://wiki.adom.inc/adom/grok-bridge. Changelog / `-m` must be ≥10 cha - Skills library / README / hero: bump **`package.json` version** (pkg). Leave `BRIDGE_VERSION` + `bridge.json` version alone if the Python runtime did not change. - New/changed `grok_*` verbs: bump **all three** (`BRIDGE_VERSION`, `bridge.json`, `package.json`) in lockstep, rebuild the zip, upload release, rewrite manifest, then pkg publish. -Current runtime: **1.2.2**. Skills-only bumps `package.json` only; verb changes bump zip + manifest + package.json together.+Current runtime: **1.3.0**. Skills-only bumps `package.json` only; verb changes bump zip + manifest + package.json together.  ## Skills pkg allowlist 
handlers/chat.py+103−11
@@ -258,7 +258,24 @@ def _read_document_text(hwnd: int) -> tuple[str, dict | None]:     return "", last  ++def _select_sidebar_thread(hwnd: int, name: str) -> dict:+    """Best-effort click a sidebar row by UIA name. Does not steal OS focus beyond Invoke."""+    last = None+    for args in (+        {"hwnd": hwnd, "name": name},+        {"hwnd": hwnd, "name": name, "role": "ListItem"},+        {"hwnd": hwnd, "name": name, "role": "Button"},+        {"hwnd": hwnd, "nameContains": name},+    ):+        last = _cb("desktop_ui_click", args)+        if _callback_ok(last) and not (isinstance(last, dict) and last.get("error")):+            return last+    return last if isinstance(last, dict) else {"success": False}++ def handle_chat_status(_args: dict) -> dict:+     """thinking=true when UIA exposes '<agent> is working' / working chip. Never steals focus."""     hwnd, win = grok_hwnd(allow_cursor=False)     if not hwnd:@@ -274,11 +291,13 @@ def handle_chat_status(_args: dict) -> dict:         "thinking": bool(thinking),         "idle": (not thinking),         "agentName": agent,+        "agent": agent,+        "focusedThread": agent,         "hwnd": hwnd,         "title": title,         "windowEtiquette": "background",         "_hint": (-            f"Grok Bot hwnd={hwnd} agent={agent!r} "+            f"Grok Bot hwnd={hwnd} focusedThread={agent!r} "             + ("thinking (UIA name like '<agent> is working')." if thinking else "idle (no working chip).")             + " Read-only UIA — focus not stolen. grok_chat_read for the visible transcript."         ),@@ -286,7 +305,15 @@ def handle_chat_status(_args: dict) -> dict:   def handle_chat_read(args: dict) -> dict:-    """Visible transcript via desktop_ui_text Document range=all. limit truncates from the END."""+    """Visible transcript via desktop_ui_text Document range=all.++    limit is CHARACTERS from the END (not messages). Values under 500 are coerced+    to max(limit*250, 4000). Pass 4000+ to search for a nonce.++    Optional args.thread / args.agent: try to click that sidebar row then re-read.+    Always returns focusedThread (heading / agent name). After cold launch the focused+    thread may not be grok-bridge — callers should pass thread/agent or wait after grok_open.+    """     hwnd, win = grok_hwnd(allow_cursor=False)     if not hwnd:         hwnd, win = grok_hwnd(allow_cursor=True)@@ -294,15 +321,40 @@ def handle_chat_read(args: dict) -> dict:         miss = _window_missing("chat_read")         miss["text"] = ""         miss["length"] = 0+        miss["focusedThread"] = None+        miss["agent"] = None         return miss +    wanted = str(args.get("thread") or args.get("agent") or "").strip()+    select_attempt = None+    if wanted:+        select_attempt = _select_sidebar_thread(hwnd, wanted)+     thinking, working_name = _find_working(hwnd)     agent = _read_agent_name(hwnd, working_name)     raw, last = _read_document_text(hwnd)++    # Cold start: focused thread is often not grok-bridge. If nothing requested and+    # transcript is empty/tiny, try the grok-bridge sidebar row once.+    tiny = len((raw or "").strip()) < 40+    agent_l = (agent or "").strip().lower()+    if not wanted and tiny and "grok-bridge" not in agent_l and "grok bridge" not in agent_l:+        select_attempt = _select_sidebar_thread(hwnd, "grok-bridge")+        thinking, working_name = _find_working(hwnd)+        agent = _read_agent_name(hwnd, working_name)+        raw, last = _read_document_text(hwnd)+        tiny = len((raw or "").strip()) < 40     prompt_val = _prompt_value(hwnd)     text = _strip_prompt_garbage(raw, prompt_val)     text = _redact_secrets(text) +    # limit is CHARACTERS from the END, not messages. Callers who pass 20/40+    # almost certainly meant "last N messages" — coerce so a nonce search+    # is not truncated to the last 20 chars ("replied ").+    LIMIT_HINT = (+        "`limit` is characters from the end, not messages. "+        "Values under 500 are coerced. Pass 4000+ to search for a nonce."+    )     limit = args.get("limit")     try:         limit_n = int(limit) if limit is not None and str(limit).strip() != "" else None@@ -310,15 +362,33 @@ def handle_chat_read(args: dict) -> dict:         limit_n = None     if limit_n is not None and limit_n < 0:         limit_n = None+    limit_meta = {}     if limit_n:-        text = text[-limit_n:]+        limit_meta["limitRequested"] = limit_n+        if limit_n < 500:+            chars = max(limit_n * 250, 4000)+            limit_meta["limitCoerced"] = True+        else:+            chars = limit_n+        limit_meta["limitChars"] = chars+        text = text[-chars:]      ok = bool(text) or _callback_ok(last)+    focused = agent+    if wanted and not ok:+        err = "thread_select_failed"+    elif not text.strip():+        err = "focused_thread_empty"+    else:+        err = None+     if not ok and not text:-        return {+        out = {             "success": False,             "errorCode": "transcript_unavailable",             "agentName": agent,+            "agent": agent,+            "focusedThread": focused,             "thinking": bool(thinking),             "idle": (not thinking),             "text": "",@@ -328,15 +398,24 @@ def handle_chat_read(args: dict) -> dict:             "windowEtiquette": "background",             "_hint": (                 "desktop_ui_text {hwnd, role:Document, range:all} returned no text. "-                "Needs a live Grok Bot window with the chat Document (name \"Grok Bot\"). "-                "Never OCR-first. Never CDP 9222. grok_chat_status {} then retry grok_chat_read."+                "After cold launch the focused thread may not be grok-bridge. "+                "Pass args.thread / args.agent to click that sidebar row, or grok_open then wait. "+                "Never OCR-first. Never CDP 9222. "+                + LIMIT_HINT+                + " Default 4000+ when polling for a nonce."             ),-            "_next": "grok_chat_status {} then grok_chat_read {}",+            "_next": "grok_open {} then wait, or grok_chat_read {\"thread\":\"grok-bridge\"}",         }+        if select_attempt is not None:+            out["selectAttempt"] = select_attempt+        out.update(limit_meta)+        return out -    return {-        "success": True,+    out = {+        "success": True if text.strip() else False,         "agentName": agent,+        "agent": agent,+        "focusedThread": focused,         "thinking": bool(thinking),         "idle": (not thinking),         "text": text,@@ -346,9 +425,22 @@ def handle_chat_read(args: dict) -> dict:         "windowEtiquette": "background",         "_hint": (             f"Read UIA Document range=all on Grok Bot hwnd={hwnd} "-            f"({len(text)} chars, agent={agent!r}, "+            f"({len(text)} chars, focusedThread={focused!r}, "             + ("thinking" if thinking else "idle")             + "). Latest messages are at the end. Webhook keys redacted. "-            "Prompt compose box stripped when distinguishable. Never stole focus."+            "Optional thread/agent clicks a sidebar row then re-reads. "+            "If empty after cold start, grok_open then wait or pass thread:grok-bridge. "+            + LIMIT_HINT+            + (" Default 4000+ when polling for a nonce." if not limit_n else "")         ),     }+    out.update(limit_meta)+    if err and not text.strip():+        out["success"] = False+        out["errorCode"] = err+        out["_next"] = "grok_open {} then wait, or grok_chat_read {\"thread\":\"grok-bridge\"}"+    if wanted:+        out["requestedThread"] = wanted+    if select_attempt is not None:+        out["selectAttempt"] = select_attempt+    return out
handlers/describe.py+55−10
@@ -39,12 +39,13 @@ VERB_CATALOG = {         "pitfalls": ["lastSend is in-process; a bridge restart clears it (inbox file survives)"],     },     "open": {-        "summary": "Launch Grok Bot in the BACKGROUND if installed. Cursor only if Grok Bot.exe is missing.",+        "summary": "Launch Grok Bot in the BACKGROUND if installed. Polls process/window; Start-Process minimized fallback.",         "long": True,-        "hint": "Detect, then ab launch / desktop_open. If already running, lists windows. Never steals focus.",+        "hint": "Returns launched, opened, alreadyOpen, hwnd. Polls process_list/window after launch/desktop_open; Start-Process -WindowStyle Minimized if those do not take. Never steals focus.",         "related": ["grok_list_windows", "grok_screenshot", "grok_close", "grok_readiness"],         "pitfalls": [-            "Background by default. foreground:true without foregroundReason is refused.",+            "success:true requires a live process/window after poll — not just callback ok.",+            "Background by default. foreground:true needs foregroundReason >= 10 chars.",             "Host is Grok Bot.exe, not Cursor. Cursor is last-resort fallback only.",             "cliRequired verbs (shell_execute, pull_file, send_files) are never used here.",         ],@@ -81,13 +82,14 @@ VERB_CATALOG = {         ],     },     "chat_read": {-        "summary": "Read the visible transcript of the focused Grok Bot thread (UIA Document TextPattern).",+        "summary": "Read the visible transcript of the focused Grok Bot thread (UIA Document). Returns focusedThread.",         "long": False,-        "hint": "desktop_ui_text {hwnd, role:Document, range:all}. Optional limit truncates from the END (latest messages). Never returns webhook keys.",-        "related": ["grok_chat_status", "grok_list_windows", "grok_send"],+        "hint": "desktop_ui_text {hwnd, role:Document, range:all}. Optional thread/agent clicks that sidebar row then re-reads. Optional limit is CHARACTERS from the END (not messages; values under 500 are coerced). Returns focusedThread + agent. Never returns webhook keys.",+        "related": ["grok_chat_status", "grok_list_windows", "grok_send", "grok_open"],         "pitfalls": [+            "After cold launch the focused thread may not be grok-bridge — pass thread/agent or wait after grok_open.",             "Visible transcript only, not a full history API.",-            "Optional limit is from the end (latest), not the start.",+            "limit is CHARACTERS from the end, not messages. limit:20 will miss a nonce. Use 4000+.",             "Do not treat Prompt compose-box garbage as a message.",             "Never echo webhook keys. Never OCR-first. Never CDP 9222.",         ],@@ -230,7 +232,17 @@ VERB_CATALOG = {             "A JSON field named key is WRONG — auth is a header.",             "Never log the sender key.",             "There is still one mailbox (the Grok Bridge webhook routine). agent is a relay, not a second webhook.",-            "HTTP 200 is only a runUuid. Poll grok_chat_read for payload.nonce.",+            "HTTP 200 is only a runUuid (now also a top-level runUuid field). Poll grok_chat_read for payload.nonce.",+        ],+    },+    "run_status": {+        "summary": "Echo last grok_webhook_fire runUuid (in-process). No public run HTTP API.",+        "long": False,+        "hint": "grok_run_status {runUuid?}. Returns last fire runUuid/event/httpStatus. Poll grok_chat_read for the answer.",+        "related": ["grok_webhook_fire", "grok_chat_read"],+        "pitfalls": [+            "Clears if the grok-bridge process restarts.",+            "Never logs the webhook key.",         ],     },     "bot_create": {@@ -254,6 +266,36 @@ VERB_CATALOG = {         "related": ["grok_webhook_ensure", "grok_bot_create"],         "pitfalls": ["errorCode webhook_not_bound if unbound"],     },+    "install": {+        "summary": "Install Grok Bot on this PC from its public update feed. Background job, per-user, no admin, no login.",+        "long": True,+        "hint": "grok_install {force?}. Resolves the current installer for this OS/arch from api2.cursor.sh/updates, downloads it (~120 MB), checks Authenticode Anysphere (Windows) or the feed sha256 (Linux), then installs silently per-user. Returns at once; poll grok_install_status.",+        "related": ["grok_install_status", "grok_readiness", "grok_open", "grok_login"],+        "pitfalls": [+            "Already installed returns alreadyInstalled:true and does nothing unless force:true.",+            "Sign-in is still a human step afterwards (grok_login); this never types credentials.",+            "Never send the user to a download page; this verb is the install path.",+        ],+    },+    "install_status": {+        "summary": "Progress of the grok_install job: resolving / downloading (percent) / installing / done / failed.",+        "long": False,+        "hint": "Poll about every 10 s. done -> grok_open {}. failed -> read error; a signature/sha256 rejection is not retryable.",+        "related": ["grok_install", "grok_readiness"],+        "pitfalls": ["Job state is in-process; a bridge restart resets it to idle (grok_readiness still tells the truth)."],+    },+    "computer_screenshot": {+        "summary": "Fire screenshot_computer, wait until the nonce is in grok-bridge, then thumbnail Grok Bot.exe. Screenshots grok-bridge's computer (the agent box), not Grok Bot.exe.",+        "long": True,+        "hint": "grok_computer_screenshot {nonce?, timeoutSeconds?}. Mints a unique BOX-SHOT-XXXX-NNNN nonce if omitted. Waits (default 180s) for that nonce in grok_chat_read. Do not bash-poll. Do not reuse a nonce.",+        "related": ["grok_webhook_fire", "grok_chat_read", "grok_screenshot"],+        "pitfalls": [+            "This is NOT grok_screenshot of Grok Bot.exe.",+            "Do not click the monitor icon — that is the same window grok_screenshot already covers.",+            "Do not poll grok_chat_read yourself — this verb waits.",+            "Never reuse a nonce; omit nonce to mint BOX-SHOT-XXXX-NNNN.",+        ],+    }, }  @@ -272,18 +314,21 @@ def handle_describe(_args: dict) -> dict:             entry["statusVerb"] = PREFIX + "readiness"         verbs.append(entry)     verbs.sort(key=lambda v: v["name"])+    names = [v["name"] for v in verbs]     return {         "success": True,         "prefix": PREFIX,         "verbCount": len(verbs),+        "commands": names,+        "commandNames": names,         "verbs": verbs,         "_hint": (-            "Full grok_* catalog — each entry carries hint/related/pitfalls. "+            "Full grok_* catalog. names are grok_* in commands / commandNames / verbs[].name (JSON — grep those keys, not adom-bridge commands unprefixed internals). "             "Every verb ALSO returns its own rich _hint when called; that's what to read in practice. "             "Host app is Grok Bot.exe (Cursor last-resort fallback). "             "Drive the app: grok_open / grok_close / grok_resize / grok_screenshot / grok_record_start. "             "Chat: grok_chat_status / grok_chat_read (UIA, no focus steal). "-            "Bots/channels: grok_webhook_ensure then grok_bot_create / grok_channel_create / grok_bot_list. "+            "Bots/channels: grok_webhook_ensure then grok_bot_create / grok_channel_create / grok_bot_list. Computer shot: grok_computer_screenshot {} (not grok_screenshot). "             "CLI: adom-bridge grok_* . Always --ai-thread. Background by default."         ),     }
handlers/install.pyadded+261
@@ -0,0 +1,261 @@+"""grok_install / grok_install_status — install Grok Bot on this PC, no admin, no login.++Grok Bot's own update feed is public:++    GET https://api2.cursor.sh/updates/api/update/<platform>/sand/<curVer>/<id>/stable+      -> {"version", "url", "sha256hash"?}   (url on downloads.cursor.com)++platform is win32-x64-user / win32-arm64-user / darwin-arm64 / darwin-x64 / linux-x64.+This is the same request the installed app makes to update itself (see+buildUpdateRequestUrl in its app.asar), so asking it with curVer 0.0.1 returns+the current full installer.++Install targets are per-user and match grok_detect's candidate paths:+  Windows  NSIS Setup.exe /S              -> %LOCALAPPDATA%\\Programs\\Grok Bot+  macOS    zip with Grok Bot.app          -> ~/Applications/Grok Bot.app+  Linux    AppImage (sha256 from feed)    -> ~/.local/bin/grok-bot++The download is ~120 MB, longer than ab's 60 s verb wait, so grok_install runs+as a background job and grok_install_status reports progress. The installer is+never launched unless its signature (Windows Authenticode "Anysphere") or its+feed sha256 checks out. Sign-in stays a human step (grok_login).+"""++from __future__ import annotations++import hashlib+import json+import os+import platform+import shutil+import stat+import subprocess+import sys+import tempfile+import threading+import time+import urllib.request+import zipfile+from pathlib import Path++from grok_detect import CREATE_NO_WINDOW, IS_LINUX, IS_MACOS, IS_WINDOWS, detect_host++FEED = "https://api2.cursor.sh/updates/api/update/{platform}/sand/0.0.1/0/stable"+SIGNERS = ("Anysphere, Inc.", "Anysphere")+UA = "adom-grok-bridge"++_lock = threading.Lock()+_job: dict = {"state": "idle"}+++def _platform_key() -> str:+    arm = platform.machine().lower() in ("arm64", "aarch64")+    if IS_WINDOWS:+        return "win32-arm64-user" if arm else "win32-x64-user"+    if IS_MACOS:+        return "darwin-arm64" if arm else "darwin-x64"+    return "linux-x64"+++def _feed() -> dict:+    req = urllib.request.Request(FEED.format(platform=_platform_key()), headers={"User-Agent": UA})+    with urllib.request.urlopen(req, timeout=30) as r:+        data = json.loads(r.read().decode("utf-8"))+    if not data.get("url"):+        raise RuntimeError(f"update feed returned no url: {data}")+    return {+        "version": data.get("version") or data.get("name"),+        "url": data["url"],+        "sha256": data.get("sha256hash"),+    }+++def _set(**kw) -> None:+    with _lock:+        _job.update(kw)+++def _download(url: str, dest: Path) -> str:+    h = hashlib.sha256()+    req = urllib.request.Request(url, headers={"User-Agent": UA})+    with urllib.request.urlopen(req, timeout=60) as r, open(dest, "wb") as f:+        total = int(r.headers.get("Content-Length") or 0)+        done = 0+        _set(bytesTotal=total)+        while True:+            chunk = r.read(1 << 20)+            if not chunk:+                break+            f.write(chunk)+            h.update(chunk)+            done += len(chunk)+            _set(bytesDone=done)+    return h.hexdigest()+++def _authenticode(path: Path) -> dict:+    ps = (+        "$s = Get-AuthenticodeSignature -LiteralPath $env:GROK_SETUP; "+        "[pscustomobject]@{status=[string]$s.Status; "+        "subject=[string]$s.SignerCertificate.Subject} | ConvertTo-Json -Compress"+    )+    env = dict(os.environ, GROK_SETUP=str(path))+    out = subprocess.run(+        ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", ps],+        capture_output=True, text=True, timeout=60, env=env, creationflags=CREATE_NO_WINDOW,+    )+    try:+        return json.loads(out.stdout.strip() or "{}")+    except json.JSONDecodeError:+        return {"status": "Unknown", "subject": "", "stderr": out.stderr.strip()[:300]}+++def _install_windows(setup: Path) -> None:+    sig = _authenticode(setup)+    _set(signature=sig)+    if sig.get("status") != "Valid" or not any(f"CN={s}" in (sig.get("subject") or "") or+                                                f'CN="{s}"' in (sig.get("subject") or "")+                                                for s in SIGNERS):+        raise RuntimeError(f"installer signature rejected: {sig}")+    _set(state="installing")+    # NSIS per-user installer: /S is silent, no UAC, installs to %LOCALAPPDATA%\Programs.+    p = subprocess.run([str(setup), "/S"], timeout=600, creationflags=CREATE_NO_WINDOW)+    _set(installerExit=p.returncode)+++def _install_macos(zpath: Path) -> None:+    _set(state="installing")+    apps = Path.home() / "Applications"+    apps.mkdir(parents=True, exist_ok=True)+    tmp = Path(tempfile.mkdtemp(prefix="grokbot-"))+    # ditto keeps the code signature and symlinks that zipfile would break.+    subprocess.run(["ditto", "-x", "-k", str(zpath), str(tmp)], check=True, timeout=600)+    app = next(tmp.glob("*.app"), None)+    if app is None:+        raise RuntimeError("no .app inside the macOS zip")+    dest = apps / "Grok Bot.app"+    if dest.exists():+        shutil.rmtree(dest)+    shutil.move(str(app), str(dest))+    subprocess.run(["xattr", "-dr", "com.apple.quarantine", str(dest)], timeout=60)+++def _install_linux(appimage: Path) -> None:+    _set(state="installing")+    dest = Path.home() / ".local" / "bin" / "grok-bot"+    dest.parent.mkdir(parents=True, exist_ok=True)+    shutil.move(str(appimage), str(dest))+    dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)+++def _run(feed: dict) -> None:+    work = Path(tempfile.mkdtemp(prefix="grokbot-dl-"))+    name = feed["url"].rsplit("/", 1)[-1] or "grokbot-setup"+    dest = work / name+    try:+        _set(state="downloading", file=name)+        digest = _download(feed["url"], dest)+        _set(sha256=digest)+        if feed.get("sha256") and digest.lower() != feed["sha256"].lower():+            raise RuntimeError(f"sha256 mismatch: got {digest}, feed says {feed['sha256']}")+        if IS_WINDOWS:+            _install_windows(dest)+        elif IS_MACOS:+            _install_macos(dest)+        else:+            if not feed.get("sha256"):+                raise RuntimeError("linux feed had no sha256; refusing to install an unverified AppImage")+            _install_linux(dest)+        # NSIS returns before every file settles on some boxes; give detection a moment.+        for _ in range(30):+            if detect_host().get("hostAppInstalled") and not detect_host().get("hostAppFallback"):+                break+            time.sleep(1)+        info = detect_host()+        ok = bool(info.get("hostAppInstalled")) and not info.get("hostAppFallback")+        _set(state="done" if ok else "failed", installed=ok, paths=info.get("paths") or [],+             finishedAt=time.time(),+             error="" if ok else "installer finished but Grok Bot was not detected")+    except Exception as e:  # noqa: BLE001 - every failure must land in the job state+        _set(state="failed", error=str(e), finishedAt=time.time())+    finally:+        shutil.rmtree(work, ignore_errors=True)+++def _snapshot() -> dict:+    with _lock:+        j = dict(_job)+    total, done = j.get("bytesTotal") or 0, j.get("bytesDone") or 0+    if total:+        j["percent"] = round(100.0 * done / total, 1)+    return j+++def handle_install(args: dict) -> dict:+    info = detect_host()+    force = bool(args.get("force"))+    if info.get("hostAppInstalled") and not info.get("hostAppFallback") and not force:+        return {+            "success": True,+            "alreadyInstalled": True,+            "paths": info.get("paths") or [],+            "_hint": "Grok Bot is already installed. Next: grok_open {} (background), then grok_login if it is not signed in. Pass force:true to reinstall over it.",+            "_next": "grok_open {}",+        }+    with _lock:+        busy = _job.get("state") in ("resolving", "downloading", "installing")+    if busy:+        out = _snapshot()+        out.update(success=True, alreadyRunning=True,+                   _hint="An install is already running. Poll grok_install_status about every 10 s; do not start another.",+                   _next="grok_install_status {}")+        return out+    try:+        _set(state="resolving", startedAt=time.time(), error="", bytesDone=0, bytesTotal=0,+             platform=_platform_key(), signature=None, installed=False)+        feed = _feed()+    except Exception as e:  # noqa: BLE001+        _set(state="failed", error=f"update feed: {e}")+        return {+            "success": False,+            "errorCode": "feed_unreachable",+            "error": str(e),+            "_hint": "Could not read Grok Bot's update feed (api2.cursor.sh). Check this PC's internet, then retry grok_install once.",+            "_next": "grok_install {}",+        }+    _set(version=feed["version"], url=feed["url"])+    threading.Thread(target=_run, args=(feed,), daemon=True, name="grok-install").start()+    return {+        "success": True,+        "started": True,+        "version": feed["version"],+        "platform": _platform_key(),+        "url": feed["url"],+        "_hint": (+            f"Installing Grok Bot {feed['version']} in the background from its public update feed "+            "(~120 MB, per-user, no admin prompt, no window). The installer only runs after its "+            "signature (Windows: Authenticode Anysphere) or sha256 (Linux) checks out. "+            "Poll grok_install_status about every 10 s until state is done or failed."+        ),+        "_next": "grok_install_status {}",+        "related": ["grok_install_status", "grok_readiness", "grok_open", "grok_login"],+    }+++def handle_install_status(_args: dict) -> dict:+    j = _snapshot()+    state = j.get("state")+    hints = {+        "idle": "No install has run since the bridge started. grok_readiness says whether Grok Bot is present; grok_install installs it.",+        "resolving": "Asking the update feed for the current installer.",+        "downloading": "Downloading. Poll again in ~10 s.",+        "installing": "Running the silent per-user installer. Poll again in ~10 s.",+        "done": "Installed. Next: grok_open {} (background), then grok_login so the user signs in with Cursor.",+        "failed": "Install failed; read error. A signature or sha256 rejection means the download was not Anysphere's: do not retry blindly, report it.",+    }+    j["success"] = state != "failed"+    j["_hint"] = hints.get(state, "Unknown install state.")+    j["_next"] = {"done": "grok_open {}", "failed": "grok_install {}"}.get(state, "grok_install_status {}")+    if state == "failed":+        j["errorCode"] = "install_failed"+    return j
handlers/readiness.py+5−5
@@ -20,7 +20,7 @@ def handle_readiness(_args: dict) -> dict:     """Read-only: is Grok Bot present / running?      Never blocks on a download. Bridge detects host apps; it does not auto-install-    them. If Grok Bot is missing, OFFER grok_open — do not send the user to a download page.+    them. If Grok Bot is missing, grok_install installs it — do not send the user to a download page.     Cursor is last-resort fallback only if Grok Bot.exe is missing.     """     info = detect_host()@@ -40,9 +40,9 @@ def handle_readiness(_args: dict) -> dict:         )     else:         state = (-            "Grok Bot isn't installed — want me to open it if present? "-            "Call grok_open (detect + ab launch). Cursor is last-resort fallback only if "-            "Grok Bot.exe is missing. Do not send the user to download the host app themselves."+            "Grok Bot isn't installed. grok_install {} installs it for you in the background "+            "(public update feed, signature-checked, per-user, no admin prompt); poll "+            "grok_install_status. Do not send the user to download it themselves."         )     out = {         "success": True,@@ -62,7 +62,7 @@ def handle_readiness(_args: dict) -> dict:     }     if not installed:         out["errorCode"] = "host_app_not_installed"-        out["_next"] = "Offer grok_open to the user; on yes, call grok_open {}. Do not send the user to a download page."+        out["_next"] = "grok_install {} then poll grok_install_status {}, then grok_open {}. Do not send the user to a download page."     return out  
handlers/webhook.py+257−2
@@ -14,6 +14,7 @@ grok_webhook_ensure reads UIA Edit controls on the Grok Bot routine pane from __future__ import annotations  import json+import secrets import socket import time import urllib.error@@ -221,7 +222,101 @@ def _normalize_payload_agent(args: dict, payload):     return pl, s  ++_LAST_FIRE: dict = {}+++def _extract_run_uuid(parsed, raw: str | None = None) -> str | None:+    if isinstance(parsed, str):+        s = parsed.strip().strip('"').strip("'")+        if 8 <= len(s) <= 80 and " " not in s and "/" not in s:+            return s+    if isinstance(parsed, list) and parsed:+        got = _extract_run_uuid(parsed[0], raw)+        if got:+            return got+    if not isinstance(parsed, dict):+        if raw:+            for marker in ("runUuid", "run_uuid", "runId"):+                key = f'"{marker}"'+                i = raw.find(key)+                if i < 0:+                    i = raw.find(marker)+                if i < 0:+                    continue+                chunk = raw[i:i + 120]+                for sep in (":", "="):+                    if sep in chunk:+                        rest = chunk.split(sep, 1)[1].strip().strip(",} ").strip('"').strip("'")+                        tok = rest.split()[0].strip(",} ") if rest else ""+                        tok = tok.strip('"').strip("'")+                        if 8 <= len(tok) <= 80:+                            return tok+        return None+    for k in ("runUuid", "run_uuid", "runId", "run_id", "uuid", "id"):+        v = parsed.get(k)+        if v is None or v is False:+            continue+        s = str(v).strip()+        if s and s.lower() not in ("none", "null"):+            # skip obvious non-uuids like event names for generic "id"+            if k == "id" and len(s) < 8:+                continue+            return s+    for nest in ("data", "result", "response", "run"):+        inner = parsed.get(nest)+        if isinstance(inner, dict):+            got = _extract_run_uuid(inner, raw)+            if got:+                return got+    if raw:+        got = _extract_run_uuid(None, raw)+        if got:+            return got+    return None+++def handle_run_status(args: dict) -> dict:+    """Cheap last-fire echo. Optional GET of bound url is not required for runUuid surfacing."""+    want = str(args.get("runUuid") or args.get("run_uuid") or "").strip() or None+    last = dict(_LAST_FIRE) if _LAST_FIRE else {}+    run = want or last.get("runUuid")+    out = {+        "success": True if (run or last) else False,+        "runUuid": run,+        "event": last.get("event"),+        "httpStatus": last.get("httpStatus"),+        "firedAt": last.get("firedAt"),+        "urlHost": last.get("urlHost"),+    }+    if not run and not last:+        out["errorCode"] = "no_run"+        out["_hint"] = (+            "No runUuid yet. Fire with grok_webhook_fire {event} first; HTTP 200 now "+            "surfaces runUuid at the top level (and in response)."+        )+        out["_next"] = "grok_webhook_fire {\"event\":\"ping\"}"+        return out+    if want and last.get("runUuid") and want != last.get("runUuid"):+        out["success"] = True+        out["matchedLastFire"] = False+        out["_hint"] = (+            f"Requested runUuid={want}. Last fire in this process was "+            f"{last.get('runUuid')} event={last.get('event')}. "+            "There is no public run-status HTTP API; poll grok_chat_read for the nonce."+        )+        return out+    out["matchedLastFire"] = bool(last.get("runUuid") and run == last.get("runUuid"))+    out["_hint"] = (+        f"Last grok_webhook_fire in this process: runUuid={run} event={last.get('event')} "+        f"HTTP {last.get('httpStatus')}. Poll grok_chat_read for payload.nonce — "+        "the HTTP 200 is only the run id, not the answer."+    )+    return out++ def handle_webhook_fire(args: dict) -> dict:+     event = str(args.get("event") or "").strip()     if not event:         return {@@ -244,6 +339,17 @@ def handle_webhook_fire(args: dict) -> dict:     if payload is None:         payload = {}     payload, agent = _normalize_payload_agent(args, payload)+    # Top-level args.nonce / args.message merge into payload. payload wins on conflict.+    if not isinstance(payload, dict):+        payload = {}+    else:+        payload = dict(payload)+    for field in ("nonce", "message"):+        if field in payload and str(payload.get(field) or "").strip():+            continue+        raw = args.get(field)+        if raw is not None and str(raw).strip():+            payload[field] = str(raw).strip()     try:         hostname = socket.gethostname()     except Exception:@@ -253,6 +359,9 @@ def handle_webhook_fire(args: dict) -> dict:         "payload": payload,         "source": SOURCE,     }+    nonce = str(payload.get("nonce") or "").strip()+    if nonce:+        body["nonce"] = nonce     if agent:         body["agent"] = agent     if hostname:@@ -263,7 +372,7 @@ def handle_webhook_fire(args: dict) -> dict:     headers = {         "Content-Type": "application/json",         "Accept": "application/json",-        "User-Agent": f"{SOURCE}/1.2.2",+        "User-Agent": f"{SOURCE}/1.2.4",     }     key = cfg.get("key") or ""     if key:@@ -318,6 +427,7 @@ def handle_webhook_fire(args: dict) -> dict:         except json.JSONDecodeError:             parsed = None +    run_uuid = _extract_run_uuid(parsed, resp_text)     out = {         "success": True,         "fired": True,@@ -325,24 +435,169 @@ def handle_webhook_fire(args: dict) -> dict:         "httpStatus": status,         "urlHost": url_host(url),         "hostname": hostname,+        "runUuid": run_uuid,     }     if agent:         out["agent"] = agent     if isinstance(parsed, dict):         safe = {k: v for k, v in parsed.items() if k.lower() not in ("key", "senderkey", "token", "secret")}         out["response"] = safe+        if run_uuid is None:+            run_uuid = _extract_run_uuid(safe)+            out["runUuid"] = run_uuid     elif resp_text:         out["bodyPreview"] = resp_text[:500]+    _LAST_FIRE.clear()+    _LAST_FIRE.update({+        "runUuid": run_uuid,+        "event": event,+        "httpStatus": status,+        "urlHost": url_host(url),+        "firedAt": _now_iso(),+    })     out["_hint"] = (         f"POSTed event={event} to webhook host {url_host(url)} (HTTP {status})"         + (f" for agent={agent!r}." if agent else ".")+        + (f" runUuid={run_uuid}." if run_uuid else " runUuid not present in HTTP body.")         + " Auth was header Authorization: Bearer, never a body field named key. "         "The HTTP response is only a runUuid; the answer lands in the grok-bridge "-        "thread — poll grok_chat_read for a nonce in payload."+        "thread — poll grok_chat_read for a nonce in payload. grok_run_status {runUuid} echoes the last fire."     )     return out  ++SHOT_NONCE_PREFIX = "BOX-SHOT-"+SHOT_POLL_SECONDS = 4+SHOT_TIMEOUT_DEFAULT = 180+SHOT_TIMEOUT_MIN = 30+SHOT_TIMEOUT_MAX = 300+SHOT_READ_LIMIT = 8000+SHOT_SNIPPET = 600+SHOT_MESSAGE = (+    "Screenshot grok-bridge computer. Reply in grok-bridge with this nonce and attach the PNG."+)+SHOT_HINT = (+    "This screenshots grok-bridge's computer (the agent box), NOT Grok Bot.exe. "+    "HTTP 200 has no image. This verb waits and returns when the nonce is in grok-bridge. "+    "Do not bash-poll grok_chat_read. Do not reuse a nonce. "+    "Full PNG is the in-thread attachment; shotId is the Grok Bot.exe thumbnail."+)+++def _mint_shot_nonce() -> str:+    """BOX-SHOT- + 4 uppercase hex + '-' + last 4 of unix time. Never BOX-SHOT-4821."""+    hex4 = secrets.token_hex(2).upper()+    last4 = str(int(time.time()))[-4:]+    return f"{SHOT_NONCE_PREFIX}{hex4}-{last4}"+++def _snippet_around(text: str, needle: str, width: int = SHOT_SNIPPET) -> str:+    if not text:+        return ""+    i = text.find(needle)+    if i < 0:+        return text[-width:]+    half = max(width - len(needle), 0) // 2+    start = max(0, i - half)+    end = min(len(text), start + width)+    if end - start < width:+        start = max(0, end - width)+    return text[start:end]+++def handle_computer_screenshot(args: dict) -> dict:+    """Fire screenshot_computer, wait for nonce in grok-bridge, then thumbnail Grok Bot.exe.++    Runs on the laptop (AdomLapper) via Adom Bridge. Does not screenshot this Linux box.+    Never steals focus. Never types into chat. Never logs webhook keys.+    """+    from handlers.chat import handle_chat_read+    from handlers.windows import flatten_callback, handle_screenshot++    nonce = str((args or {}).get("nonce") or "").strip()+    if not nonce:+        nonce = _mint_shot_nonce()++    raw_timeout = (args or {}).get("timeoutSeconds")+    try:+        timeout = int(raw_timeout) if raw_timeout is not None and str(raw_timeout).strip() != "" else SHOT_TIMEOUT_DEFAULT+    except (TypeError, ValueError):+        timeout = SHOT_TIMEOUT_DEFAULT+    timeout = max(SHOT_TIMEOUT_MIN, min(SHOT_TIMEOUT_MAX, timeout))++    fire = handle_webhook_fire({+        "event": "screenshot_computer",+        "nonce": nonce,+        "message": SHOT_MESSAGE,+        "payload": {+            "nonce": nonce,+            "message": SHOT_MESSAGE,+        },+    })+    if not fire.get("success"):+        if "nonce" not in fire:+            fire = dict(fire)+            fire["nonce"] = nonce+        return fire++    run_uuid = fire.get("runUuid")+    t0 = time.monotonic()+    polls = 0+    last_read: dict = {}+    found = False+    snippet = ""+    deadline = t0 + timeout++    while True:+        polls += 1+        last_read = handle_chat_read({"thread": "grok-bridge", "limit": SHOT_READ_LIMIT})+        text = str(last_read.get("text") or "")+        if nonce in text:+            found = True+            snippet = _snippet_around(text, nonce)+            break+        remaining = deadline - time.monotonic()+        if remaining <= 0:+            snippet = _snippet_around(text, nonce)+            break+        time.sleep(min(SHOT_POLL_SECONDS, remaining))++    wait_ms = int((time.monotonic() - t0) * 1000)+    out = {+        "success": found,+        "found": found,+        "nonce": nonce,+        "runUuid": run_uuid,+        "waitMs": wait_ms,+        "polls": polls,+        "snippet": snippet,+        "thinking": last_read.get("thinking"),+        "idle": last_read.get("idle"),+        "_hint": SHOT_HINT,+    }+    if found:+        try:+            shot = handle_screenshot({})+        except Exception:+            shot = None+        if isinstance(shot, dict) and shot.get("success") is not False:+            merged = flatten_callback(shot)+            blob = merged if isinstance(merged, dict) else shot+            shot_id = (+                blob.get("shotId") or shot.get("shotId")+                or blob.get("screenshotId") or shot.get("screenshotId")+                or blob.get("id") or shot.get("id")+            )+            if shot_id and str(shot_id).lower() not in ("none", "null"):+                out["shotId"] = shot_id+        return out++    out["errorCode"] = "nonce_not_in_transcript"+    out["_next"] = "retry grok_computer_screenshot {} with a FRESH nonce; FIFO may be busy."+    return out++ # ── grok_webhook_ensure: UIA Edit read of the routine pane (no OCR) ──────────  
handlers/windows.py+199−62
@@ -7,6 +7,9 @@ HTTP callback. All driving goes through caller_identity.ad_callback. from __future__ import annotations  import json+import subprocess+import sys+import time  import caller_identity from grok_detect import (@@ -14,6 +17,8 @@ from grok_detect import (     detect_host,     process_name_filter,     window_title_needles,+    _cursor_running,+    _grok_bot_running, )  @@ -342,6 +347,85 @@ def launch_host(argv: list | None = None) -> dict:     return _cb("launch", launch_args)  +CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)+_POLL_SECONDS = 12+_POLL_INTERVAL = 1.2+_FG_REASON_MIN = 10+++def _ps_quote(s: str) -> str:+    return "'" + str(s).replace("'", "''") + "'"+++def _host_alive(info: dict | None = None) -> tuple:+    """Return (hwnd, win, process_up) without stealing focus."""+    info = info or detect_host()+    allow = bool(info.get("hostAppFallback"))+    hwnd, win = grok_hwnd(allow_cursor=allow)+    if hwnd:+        return hwnd, win, True+    filt = process_name_filter(info)+    procs = _as_processes(_cb("process_list", {"nameFilter": filt}))+    if procs:+        return None, None, True+    if filt != "Grok Bot":+        extra = _as_processes(_cb("process_list", {"nameFilter": "Grok Bot"}))+        if extra:+            return None, None, True+    if info.get("hostAppFallback"):+        running = _cursor_running()+    else:+        running = _grok_bot_running()+    return None, None, bool(running)+++def _poll_host(info: dict, timeout: float = _POLL_SECONDS) -> tuple:+    deadline = time.time() + timeout+    last = (None, None, False)+    while time.time() < deadline:+        last = _host_alive(info)+        if last[0] or last[2]:+            return last+        time.sleep(_POLL_INTERVAL)+    return last+++def _start_process_minimized(exe: str, extra_args: list | None = None) -> dict:+    """Windows Start-Process -WindowStyle Minimized. Never shell_execute. Never Cursor unless that is the last-resort exe."""+    if not exe:+        return {"success": False, "error": "no exe path"}+    if sys.platform != "win32":+        return {"success": False, "via": "Start-Process", "error": "Start-Process fallback is Windows-only"}+    cmd = (+        "$p = Start-Process -FilePath " + _ps_quote(exe)+        + " -WindowStyle Minimized -PassThru"+    )+    if extra_args:+        quoted = ",".join(_ps_quote(a) for a in extra_args)+        cmd = (+            "$p = Start-Process -FilePath " + _ps_quote(exe)+            + " -ArgumentList " + quoted+            + " -WindowStyle Minimized -PassThru"+        )+    cmd += "; if ($p) { Write-Output ('pid=' + $p.Id) } else { Write-Output 'no-process' }"+    try:+        r = subprocess.run(+            ["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd],+            capture_output=True, text=True, timeout=20,+            creationflags=CREATE_NO_WINDOW,+        )+        out = ((r.stdout or "") + (r.stderr or "")).strip()+        ok = r.returncode == 0 and "pid=" in out.lower()+        return {+            "success": ok or r.returncode == 0,+            "via": "Start-Process -WindowStyle Minimized",+            "returncode": r.returncode,+            "outputPreview": out[:300],+        }+    except Exception as e:+        return {"success": False, "via": "Start-Process", "error": str(e)}++ def handle_list_windows(args: dict) -> dict:     info = detect_host()     title = (args.get("titleContains") or "").strip()@@ -446,127 +530,180 @@ def handle_screenshot(args: dict) -> dict:   def handle_open(args: dict) -> dict:-    """Launch Grok Bot if installed; if running, list windows. Background by default."""+    """Launch Grok Bot if installed; if running, list windows. Background by default.++    Poll process_list / hwnd after launch. Fall back to Start-Process -WindowStyle+    Minimized when ab launch / desktop_open report success but no process appears.+    Never shell_execute. Never kill Cursor.+    """     info = detect_host()     installed = bool(info.get("hostAppInstalled"))-    running = bool(info.get("running"))     exe = info.get("exe")     fallback = info.get("hostAppFallback")     want_fg = bool(args.get("foreground") or args.get("showToUser"))     reason = (args.get("foregroundReason") or args.get("reason") or "").strip() -    if want_fg and not reason:+    if want_fg and len(reason) < _FG_REASON_MIN:         return {             "success": False,             "errorCode": "foreground_reason_required",+            "hwnd": None,+            "opened": False,+            "alreadyOpen": False,+            "launched": False,             "windowEtiquette": "background",             "openedInBackground": True,             "_hint": (-                "foreground requested without foregroundReason — pass "-                "foreground:true AND foregroundReason:'<why the user needs to see this>'. "+                "foreground requested without a foregroundReason of at least "+                f"{_FG_REASON_MIN} characters — pass foreground:true AND "+                "foregroundReason:'<why the user needs to see this>'. "                 "Default is BACKGROUND so Grok Bot never steals the keyboard."             ),             "_next": "Re-call grok_open {\"foreground\":true,\"foregroundReason\":\"...\"} or omit foreground.",         } -    listed = handle_list_windows(args) if running else None     fallback_note = (         " Grok Bot.exe was not found; Cursor is a LAST-RESORT fallback host. Prefer Grok Bot."         if fallback else ""     ) -    if running:-        return {-            "success": True,-            "alreadyRunning": True,-            "launched": False,+    hwnd0, win0, proc0 = _host_alive(info)+    listed = handle_list_windows(args) if (hwnd0 or proc0) else None++    def _envelope(*, success, launched, already_open, opened, hwnd, win, via, callback, host_running, extra_hint=""):+        title = _title_of(win) if win else None+        etiq = "foreground" if (want_fg and len(reason) >= _FG_REASON_MIN) else "background"+        out = {+            "success": success,+            "alreadyRunning": bool(already_open or (proc0 and not launched)),+            "alreadyOpen": bool(already_open),+            "opened": bool(opened),+            "launched": bool(launched),+            "hwnd": hwnd,+            "title": title,+            "via": via,             "hostApp": HOST_APP,             "hostAppInstalled": installed,-            "hostAppRunning": True,+            "hostAppRunning": bool(host_running),             "hostAppFallback": fallback,             "exe": exe,-            "windows": (listed or {}).get("windows") or [],-            "openedInBackground": True,-            "windowEtiquette": "foreground" if (want_fg and reason) else "background",-            "_hint": (+            "windows": (listed or handle_list_windows(args)).get("windows") or [] if (hwnd or host_running) else [],+            "openedInBackground": etiq == "background",+            "windowEtiquette": etiq,+        }+        if callback is not None:+            out["callback"] = callback+        if not success:+            out["errorCode"] = out.get("errorCode") or "launch_did_not_start"+        out["_hint"] = extra_hint + fallback_note+        out["_foregroundHint"] = (+            "Window is in the BACKGROUND. To show it: grok_open "+            "{\"foreground\":true,\"foregroundReason\":\"<why the user needs to see this>\"}."+            if etiq == "background" else+            "Raised because foregroundReason was provided."+        )+        return out++    if hwnd0 or proc0:+        if want_fg and hwnd0 and len(reason) >= _FG_REASON_MIN:+            _cb("desktop_set_window_state", {"hwnd": hwnd0, "state": "restore"})+        return _envelope(+            success=True,+            launched=False,+            already_open=bool(hwnd0),+            opened=bool(hwnd0),+            hwnd=hwnd0,+            win=win0,+            via="already-open",+            callback=None,+            host_running=True,+            extra_hint=(                 "Grok Bot is already running — nothing launched, focus not stolen. "-                "Windows listed via ab. To show the user the window, re-call with "-                "foreground:true AND foregroundReason. To queue a message: grok_send. "-                "App control: grok_close / grok_resize / grok_screenshot."-                + fallback_note+                "hwnd/opened/alreadyOpen populated so callers can tell launched vs already up. "+                "To queue a message: grok_send. App control: grok_close / grok_resize / grok_screenshot."             ),-            "_foregroundHint": (-                "Window is in the BACKGROUND. To show it: grok_open "-                "{\"foreground\":true,\"foregroundReason\":\"<why>\"}."-            ),-        }+        ) -    # Not running: try ab launch / desktop_open. Never shell_execute.     launch_name = fallback if fallback else HOST_APP-    launch_args = {"background": True, "noActivate": True}+    launch_args = {"background": True, "noActivate": True, "detached": True}     if exe:         launch_args["path"] = exe     else:         launch_args["name"] = launch_name -    launched = _cb("launch", launch_args)+    launched_cb = _cb("launch", launch_args)     via = "launch"-    if not _callback_ok(launched):+    hwnd, win, proc = _poll_host(info, timeout=_POLL_SECONDS)+    if not hwnd and not proc:         open_args = {"background": True}         if exe:             open_args["path"] = exe         else:             open_args["target"] = launch_name-        launched = _cb("desktop_open", open_args)+        launched_cb = _cb("desktop_open", open_args)         via = "desktop_open"+        hwnd, win, proc = _poll_host(info, timeout=_POLL_SECONDS)++    start_fb = None+    if not hwnd and not proc:+        start_fb = _start_process_minimized(exe or launch_name)+        via = start_fb.get("via") or "Start-Process -WindowStyle Minimized"+        launched_cb = start_fb+        hwnd, win, proc = _poll_host(info, timeout=20)++    if want_fg and hwnd and len(reason) >= _FG_REASON_MIN:+        _cb("desktop_set_window_state", {"hwnd": hwnd, "state": "restore"}) -    ok = _callback_ok(launched)-    if not installed and not ok:+    really_up = bool(hwnd or proc)+    if not installed and not really_up:         return {             "success": False,             "errorCode": "host_app_not_installed",+            "hwnd": None,+            "opened": False,+            "alreadyOpen": False,+            "launched": False,             "hostApp": HOST_APP,             "hostAppInstalled": False,             "hostAppRunning": False,             "hostAppFallback": fallback,-            "launched": False,             "via": via,-            "callback": launched,+            "callback": launched_cb,             "openedInBackground": True,             "windowEtiquette": "background",             "_hint": (                 "Grok Bot isn't installed — want me to open it if present? "-                "This verb already tried ab launch / desktop_open for Grok Bot.exe "-                "(typical path C:\\Users\\john\\AppData\\Local\\Programs\\Grok Bot\\Grok Bot.exe). "+                "Tried ab launch / desktop_open then Start-Process minimized "+                "(typical path C:\\\\Users\\\\john\\\\AppData\\\\Local\\\\Programs\\\\Grok Bot\\\\Grok Bot.exe). "                 "Cursor is a last-resort fallback only if Grok Bot.exe is missing. "                 "Do not send the user to download the host app themselves."             ),             "_next": "Retry grok_open after Grok Bot exists. Cursor fallback only if Grok Bot.exe is missing.",         } -    return {-        "success": True,-        "alreadyRunning": False,-        "launched": ok,-        "via": via,-        "hostApp": HOST_APP,-        "hostAppInstalled": installed,-        "hostAppRunning": False,  # we asked it to start; it may not be up yet-        "hostAppFallback": fallback,-        "exe": exe,-        "callback": launched,-        "openedInBackground": not (want_fg and reason),-        "windowEtiquette": "foreground" if (want_fg and reason) else "background",-        "_hint": (-            f"Asked ab `{via}` to start "-            + ("Cursor (last-resort fallback; Grok Bot.exe missing) " if fallback else "Grok Bot ")-            + (f"at {exe} " if exe else "(by name) ")-            + "in the BACKGROUND — keyboard/focus not stolen. "-            "Poll grok_readiness / grok_list_windows; then grok_screenshot or grok_send. "-            + ("" if ok else "ab callback did not confirm success — the host may still come up; retry grok_list_windows.")-        ),-        "_foregroundHint": (-            "Opened in the BACKGROUND. To show the user: re-call grok_open "-            "{\"foreground\":true,\"foregroundReason\":\"<why the user needs to see this>\"}."-        ),-    }+    hint = (+        f"Asked ab then polled process_list/window (via={via}). "+        + ("Process/window appeared. " if really_up else "NO process after launch/desktop_open poll + Start-Process minimized fallback. ")+        + ("Cursor last-resort fallback. " if fallback else "Grok Bot ")+        + (f"at {exe} " if exe else "(by name) ")+        + "in the BACKGROUND. Poll grok_list_windows / grok_chat_read; then grok_screenshot or grok_send."+    )+    if start_fb is not None:+        hint += " Start-Process -WindowStyle Minimized used because launch/desktop_open did not take."++    out = _envelope(+        success=really_up,+        launched=really_up,+        already_open=False,+        opened=bool(hwnd),+        hwnd=hwnd,+        win=win,+        via=via,+        callback=launched_cb,+        host_running=really_up,+        extra_hint=hint,+    )+    if not really_up:+        out["errorCode"] = "launch_did_not_start"+        out["_next"] = "Retry grok_open {}; confirm Grok Bot.exe path via grok_readiness."+    return out
package.json+10−2
@@ -1,6 +1,6 @@ {   "slug": "grok-bridge",-  "version": "1.2.6",+  "version": "1.3.0",   "type": "app",   "description": "Grok Bridge lets Adom talk to Grok Bot. Adom's AI environment is for electrical engineers: any provider (Claude, Codex, Grok, Gemini, …), any desktop power tool, through Adom Bridge. grok_* open/close/resize/screenshot/record the app, read the thread, and create bots/channels via webhook.",   "keywords": [@@ -89,13 +89,21 @@     "screenshot_computer",     "screenshot grok bot computer",     "grok computer screenshot",-    "grok_computer_screenshot"+    "grok_computer_screenshot",+    "install grok bot",+    "grok_install",+    "install grok bot on another pc",+    "download grok bot"   ],   "sample_prompts": [     {       "label": "Is Grok Bot ready?",       "prompt": "Call grok_readiness and tell me if Grok Bot.exe is installed and running"     },+    {+      "label": "Install Grok Bot",+      "prompt": "Install Grok Bot on ConfRoomROG with grok_install, poll grok_install_status until done, then open it in the background"+    },     {       "label": "Open Grok Bot",       "prompt": "Open Grok Bot in the background (grok_open), screenshot it, then resize it"
server.py+23−1
@@ -35,9 +35,10 @@ from handlers.app import ( ) from handlers.bots import handle_bot_create, handle_bot_list, handle_channel_create from handlers.describe import handle_describe+from handlers.install import handle_install, handle_install_status from handlers.readiness import handle_prewarm, handle_readiness, handle_status, handle_uninstall from handlers.send import handle_send-from handlers.webhook import handle_webhook_bind, handle_webhook_ensure, handle_webhook_fire, handle_webhook_status+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.windows import handle_list_windows, handle_open, handle_screenshot @@ -75,6 +76,10 @@ KNOWN_VERBS = [     "grok_webhook_bind",     "grok_webhook_ensure",     "grok_webhook_fire",+    "grok_computer_screenshot",+    "grok_install",+    "grok_install_status",+    "grok_run_status",     "grok_bot_create",     "grok_channel_create",     "grok_bot_list",@@ -115,6 +120,10 @@ COMMAND_HANDLERS = {     "webhook_bind": handle_webhook_bind,     "webhook_ensure": handle_webhook_ensure,     "webhook_fire": handle_webhook_fire,+    "computer_screenshot": handle_computer_screenshot,+    "install": handle_install,+    "install_status": handle_install_status,+    "run_status": handle_run_status,     "bot_create": handle_bot_create,     "channel_create": handle_channel_create,     "bot_list": handle_bot_list,@@ -196,6 +205,19 @@ class GrokBridgeHandler(BaseHTTPRequestHandler):         path = self.path.split("?", 1)[0]         if path in ("/status", "/health"):             self.sendJSON(200, self._status_payload())+        elif path in ("/commands", "/verbs"):+            names = list(KNOWN_VERBS)+            self.sendJSON(200, {+                "success": True,+                "prefix": "grok_",+                "commands": names,+                "verbs": names,+                "commandNames": names,+                "_hint": (+                    "Grok verbs are grok_* (verbPrefixes). adom-bridge commands is JSON — "+                    "grep grok_ against this list or grok_describe.verbs[].name, not unprefixed internals."+                ),+            })         else:             self.sendJSON(404, {                 "success": False,