← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.0.0+1.1.0
CLAUDE.md+28−10
@@ -2,7 +2,7 @@  This repo is **the Grok Bot ("grok") bridge** — the canonical source of truth, published to `wiki.adom.inc/adom/grok-bridge`. If you are an Adom AI touching anything grok /-`grok_*` / Grok Bot / Cursor-as-host, **you own and maintain this code here.** Bridge (ab)+`grok_*` / Grok Bot / Grok Bot.exe-as-host, **you own and maintain this code here.** Bridge (ab) will seed/stream the runtime from the release zip via `updateManifestUrl` in `bridge.json`.  ## You vs adom-bridge (ab) core — the boundary@@ -13,11 +13,11 @@ will seed/stream the runtime from the release zip via `updateManifestUrl` in `br   supervisor), and generic `desktop_*` / `notify_user` / `launch` verbs this runtime   **calls back** through `caller_identity.ad_callback`. Do **not** edit   `/home/box/project/adom_modules/adom/adom-bridge` (ab-core) from this repo.-- **YOU (this bridge) own:** every `grok_*` verb + its response `_hint`s, Cursor host-app-  detect (`grok_detect.py`), the status-chip fields on `/status`, the `grok_describe`-  catalog, the `grok` + `grok-anywhere` skills, `install.sh`, and the edit → zip →-  release-upload → reinstall loop.-- So: a `grok_*` verb misbehaves, a hint is missing, Cursor detect lies, the chip lies →+- **YOU (this bridge) own:** every `grok_*` verb + its response `_hint`s, Grok Bot host-app+  detect (`grok_detect.py`; Cursor is last-resort fallback only if Grok Bot.exe is missing),+  the status-chip fields on `/status`, the `grok_describe` catalog, the `grok` + `grok-anywhere`+  skills, `install.sh`, and the edit → zip → release-upload → reinstall loop.+- So: a `grok_*` verb misbehaves, a hint is missing, Grok Bot detect lies, the chip lies →   it's **your code**, fix it here. It is NOT an ab-core request. If a GENERIC ab   capability is missing (a new lifecycle verb), file that against ab — don't re-absorb   the bridge.@@ -26,11 +26,13 @@ will seed/stream the runtime from the release zip via `updateManifestUrl` in `br  - `server.py` — ThreadingHTTPServer, bind `ADOM_BIND_HOST` (loopback only), `--port`,   POST `{command, args}`, GET `/status` + `/health`. Keep it small (~250-400 lines).-- `grok_detect.py` — Cursor.exe / Cursor.app / cursor binary + running probe.+- `grok_detect.py` — Grok Bot.exe / Grok Bot.app / grok-bot + running probe. Cursor fallback+  only when Grok Bot.exe is missing (`hostAppFallback:"Cursor"`). - `caller_identity.py` — copy of kicad's; `DELEGATE = "grok"`. All ab callbacks go through   `ad_callback`. Never `shell_execute` / `pull_file` / `send_files` from the HTTP path.-- `handlers/*.py` — readiness (layered), describe, windows (open/screenshot/list), send.-- `bridge.json` + `BRIDGE_VERSION` — KEEP IN LOCKSTEP (currently 1.0.0).+- `handlers/*.py` — readiness, describe, windows (open/screenshot/list), send, app+  (close/resize/record/login/logout/plugin), webhook, bots.+- `bridge.json` + `BRIDGE_VERSION` — KEEP IN LOCKSTEP (currently 1.1.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) — the main user skill, installed as **`grok`** (not `grok-bridge`).@@ -38,6 +40,20 @@ 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.1.0)++Existing: `grok_readiness` `grok_prewarm` `grok_describe` `grok_status` `grok_open`+`grok_screenshot` `grok_list_windows` `grok_send` `grok_uninstall`.++App: `grok_close` `grok_resize` `grok_record_start` `grok_record_stop` `grok_record_status`+`grok_login` `grok_logout` `grok_plugin_install`.++Webhook: `grok_webhook_status` `grok_webhook_ensure` `grok_webhook_bind` `grok_webhook_fire`+`grok_bot_create` `grok_channel_create` `grok_bot_list`.+`grok_webhook_ensure` scrapes UIA Edit **Webhook URL** / **Webhook key** (no human copy-paste).++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/reinstall loop (do not run until John says so)  Edit runtime → bump `BRIDGE_VERSION` + `bridge.json` together → zip@@ -52,6 +68,8 @@ 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 Cursor.** `grok_open` offers to launch / winget.+- **Never tell the user to download Grok Bot / Cursor.** `grok_open` offers to launch.+- **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`.+- **Do not invent grokbot:// routes** other than `settings?id=*` and `plugin/add?id=`.
README.md+19−5
@@ -2,28 +2,42 @@  Two-way street between Grok Bot and Adom. -- **Out:** Claude, Codex, and Adom Hydrogen talk to the Grok Bot app on your Windows or Mac so they work as one cohesive unit.+- **Out:** Claude, Codex, and Adom Hydrogen talk to the Grok Bot app on your Windows or Mac so they work as one cohesive unit. `grok_*` can **open / close / resize / screenshot / record** the Grok Bot window and **create bots / channels** via the Grok Bridge webhook. - **Back:** Grok Bot gets Adom Anywhere (all of Adom's skills) so it knows how to use Adom.  Two products, one page (same shape as pup): -1. **Runtime (release zip)** — a small Python stdlib HTTP server Adom Bridge spawns on the PC. Keyword **grok**. Talks TO Grok Bot / Cursor the way kicad talks to KiCad: detect the host app, drive windows via `ab` `desktop_*` callbacks. There is no public Grok HTTP API. Never modifies ab-core.+1. **Runtime (release zip)** — a small Python stdlib HTTP server Adom Bridge spawns on the PC. Keyword **grok**. Talks TO Grok Bot the way kicad talks to KiCad: detect the host app (`Grok Bot.exe`), drive windows via `ab` `desktop_*` callbacks. There is no public Grok HTTP API. Never modifies ab-core. Cursor is last-resort fallback only if Grok Bot.exe is missing. 2. **Skills pkg** — teaches cloud AIs the `grok_*` verbs **and** installs Adom Anywhere on Grok Bot (Claude Desktop / Codex class). `install.sh` deploys `grok` + `grok-anywhere` into `~/.claude/skills`, `~/.codex/skills` if present, `~/.grok/skills`, and `/home/box/agent-data/workflows` if that parent exists.  Adom Bridge loads the runtime from the release zip. The skills package is what lands Anywhere on Grok Bot and `grok_*` on the other AIs.  Wiki: https://wiki.adom.inc/adom/grok-bridge +## Host app++**Grok Bot** — standalone Electron app, not Cursor.++- Windows: `%LocalAppData%\Programs\Grok Bot\Grok Bot.exe` (e.g. `C:\Users\john\AppData\Local\Programs\Grok Bot\Grok Bot.exe`)+- User data: `%APPDATA%\Grok Bot`+- Window title: `Grok Bot` · process: `Grok Bot.exe`+- macOS: `/Applications/Grok Bot.app`+- Linux: `grok-bot` / `Grok Bot` on PATH++`grok_open` offers to launch it; it does not tell the user to download the host app.+ ## Verbs -`grok_readiness` · `grok_prewarm` · `grok_describe` · `grok_status` · `grok_open` · `grok_screenshot` · `grok_list_windows` · `grok_send` · `grok_uninstall`+`grok_readiness` · `grok_prewarm` · `grok_describe` · `grok_status` · `grok_open` · `grok_screenshot` · `grok_list_windows` · `grok_send` · `grok_uninstall` · `grok_close` · `grok_resize` · `grok_record_start` · `grok_record_stop` · `grok_record_status` · `grok_login` · `grok_logout` · `grok_plugin_install` · `grok_webhook_status` · `grok_webhook_bind` · `grok_webhook_ensure` · `grok_webhook_fire` · `grok_bot_create` · `grok_channel_create` · `grok_bot_list`  CLI: `adom-bridge grok_*`. Always `--ai-thread`. Background by default. -Host app is **Cursor** (`Cursor.exe` / `Cursor.app`). `grok_open` offers to launch it; it does not tell the user to download Cursor, and it does not run winget without a dedicated verb.- `grok_send {message}` toasts `Grok Bot: {message}` and appends `~/.adom/grok-inbox.jsonl`. Desktop typing is opt-in — don't steal the keyboard. +## Webhook++Prefer `grok_webhook_ensure {}` — it reads UIA Edit controls **Webhook URL** / **Webhook key** from the Grok Bot routine pane (no OCR) and writes `~/.adom/grok-webhook.json` (Windows: `%USERPROFILE%\.adom\grok-webhook.json`) as `{url, key, header, boundAt}`. Manual `grok_webhook_bind {url, key}` still works. The key is never logged or returned. POSTs use `Authorization: Bearer`. Then `grok_bot_create` / `grok_channel_create` / `grok_bot_list`.+ ## Runtime zip (what ab spawns)  Zip root (no top directory): `server.py`, `bridge.json`, `BRIDGE_VERSION`, `grok_detect.py`, `caller_identity.py`, `handlers/*.py`.
SKILL.md+63−13
@@ -1,12 +1,20 @@ --- name: grok user-invocable: true-description: "Drive Grok Bot on the user's desktop from the cloud via the grok_* Adom Bridge verbs. Grok Bot lives in Cursor (the host app). Detect Cursor, open it in the background, screenshot/list windows, queue a message (toast + inbox). No public Grok HTTP API — the spawned Python runtime talks back to ab over desktop_* callbacks. Trigger words: grok, grok bot, grok_open, talk to grok, send to grok bot, grok screenshot."+description: "Drive Grok Bot on the user's desktop from the cloud via the grok_* Adom Bridge verbs. Host app is Grok Bot.exe (standalone Electron; Cursor is last-resort fallback only if Grok Bot.exe is missing). Open/close/resize/screenshot/record the app, queue a message (toast + inbox), bind the Grok Bridge webhook and create bots/channels. No public Grok HTTP API — the spawned Python runtime talks back to ab over desktop_* callbacks. Trigger words: grok, grok bot, grok_open, grok_close, grok_webhook_ensure, grok_webhook_bind, talk to grok, send to grok bot, grok screenshot." ---  # grok — talk to Grok Bot from Adom Bridge -Drive **Grok Bot** (host app: **Cursor**) on the user's Windows PC from this container. The CLI is:+Drive **Grok Bot** (host app: **Grok Bot.exe**, window title **Grok Bot**) on the user's Windows / Mac from this container. Typical Windows path:++`C:\Users\john\AppData\Local\Programs\Grok Bot\Grok Bot.exe`++User data: `%APPDATA%\Grok Bot`. Process name: `Grok Bot.exe`. macOS: `/Applications/Grok Bot.app`. Linux: `grok-bot` / `Grok Bot` on PATH.++**Cursor is a last-resort fallback only if Grok Bot.exe is missing** (`hostAppFallback:"Cursor"`). Never kill Cursor.++The CLI is:  ```bash adom-bridge --ai-thread "<your thread>" grok_<verb> '<json>'@@ -18,17 +26,19 @@ 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` | Cursor (Grok Bot's host) is not on this PC | **Do not send the user to download Cursor.** Offer `grok_open`. That verb detects, then asks ab to `launch` / `desktop_open`. It will not winget without a dedicated verb. |-| `hostAppInstalled:true`, `hostAppRunning:false` | Cursor is on disk, not running | `grok_open {}` — launches in the **background**. |-| `ready:true` / host running | Cursor is up | `grok_list_windows` / `grok_screenshot` / `grok_send`. |+| `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`. |+| `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_screenshot` / `grok_send` / `grok_close` / `grok_resize`. |+| `errorCode: webhook_not_bound` | `~/.adom/grok-webhook.json` has no url | `grok_webhook_ensure {}` — scrapes the **Grok Bridge webhook** routine pane (no human copy-paste). Manual fallback: `grok_webhook_bind {url, key}`. Never put the key in a hint or log. | | unknown verb / bridge not spawned | runtime zip not installed on ab yet | `adom-bridge bridge_install` with this page's `updateManifestUrl`. Skills ≠ runtime. |-| yellow `/status` chip | bridge is up; Cursor isn't (or isn't running) | Not a crash. Read `tooltip` / `_hint`. |+| yellow `/status` chip | bridge is up; Grok Bot isn't (or isn't running) | Not a crash. Read `tooltip` / `_hint`. |  Readiness is layered and **never downloads anything**:  ```bash adom-bridge --ai-thread "<your thread>" grok_readiness '{}'-# {ready, hostAppInstalled, hostAppRunning, errorCode?}+# {ready, hostAppInstalled, hostAppRunning, hostAppFallback?, errorCode?} ```  ## Core verbs@@ -38,12 +48,51 @@ adom-bridge --ai-thread "<your thread>" grok_readiness '{}' | `grok_readiness` | Layered host-app probe. Zero side effects. | — | | `grok_prewarm` | No-op re-detect. `{wait:true}` just re-detects. | optional `wait` | | `grok_describe` | Catalog with hint/related/pitfalls. | — |-| `grok_status` | Detect + last send + inbox path (`~/.adom/grok-inbox.jsonl`) | — |-| `grok_open` | Launch Cursor in the background; if running, list windows. | optional `foreground` + **required** `foregroundReason` |-| `grok_list_windows` | `desktop_list_windows` + `process_list` nameFilter Cursor | optional `titleContains` |-| `grok_screenshot` | `desktop_screenshot_window` titleContains Cursor or Grok | optional `titleContains` |+| `grok_status` | Detect + last send + inbox + webhook bound (host only) | — |+| `grok_open` | Launch Grok Bot in the background; if running, list windows. | optional `foreground` + **required** `foregroundReason` |+| `grok_list_windows` | `desktop_list_windows` titleContains Grok Bot then Grok + `process_list` nameFilter Grok Bot | optional `titleContains` |+| `grok_screenshot` | `desktop_screenshot_window` titleContains Grok Bot or Grok | optional `titleContains` |+| `grok_close` | Hide the Grok Bot window (`desktop_set_window_state` hide). Prefer hide over kill. Never kill Cursor. | — |+| `grok_resize` | `desktop_set_window_bounds` on the Grok Bot hwnd. Restores first if maximized. Physical px (same as ab). | `w`, `h`, optional `x`, `y` (omit to keep current origin) |+| `grok_record_start` | `desktop_record_window_start` on the Grok Bot hwnd. | **`reason` required** |+| `grok_record_stop` | `desktop_record_window_stop` wrap. | optional `recordingId` |+| `grok_record_status` | `desktop_record_window_status` wrap. | — |+| `grok_login` | Launch Grok Bot.exe with `grokbot://app/v1/settings?id=theme` as an arg (`desktop_open_url` refuses grokbot://). Does **not** type passwords. | — |+| `grok_logout` | Open settings; try UIA **Sign Out**. Else `needsHuman:true`. | — |+| `grok_plugin_install` | Launch Grok Bot.exe with `grokbot://app/v1/plugin/add?id=<id>` as an arg. Does not confirm install (the app UI does). | **`id` required** | | `grok_send` | Toast `Grok Bot: {message}` **and** append the durable inbox. Typing is opt-in. | `message`, optional `agent` |-| `grok_uninstall` | Tells you to `bridge_uninstall {"name":"grok"}`. Does **not** kill Cursor. | — |+| `grok_uninstall` | Tells you to `bridge_uninstall {"name":"grok"}`. Does **not** kill Grok Bot or Cursor. | — |++## Webhook + bots++A Grok Bot webhook routine named **Grok Bridge webhook** already exists. Call `grok_webhook_ensure {}` to scrape the routine pane (UIA Edit **Webhook URL** / **Webhook key**) and write the json on the PC. Do **not** ask a human to copy the URL or key.++Stored at `%USERPROFILE%\.adom\grok-webhook.json` (Windows) or `~/.adom/grok-webhook.json`:++```json+{"url":"https://...","key":"...","header":"Authorization: Bearer","boundAt":"<iso>"}+```++Never log the key. Never put the key in `_hint`. Auth on fire is header `Authorization: Bearer <key>`; a JSON body field named `key` is **wrong**.++`grok_webhook_ensure` drives the Grok Bot window (never `shell_execute` / `pull_file` / `send_files`):++1. `desktop_ui_click` name exactly `Open routine Grok Bridge webhook` (Button, invokable).+2. If Edits are missing, expand `When a webhook fires` — prefer `desktop_ui_click` the **Button** (invokable); ListItem Invoke is false so the fallback is `desktop_click` at that ListItem's center.+3. Read UIA **Edit** controls (no OCR): name `Webhook URL`, `Webhook key`, `Authorization header`. Also `desktop_ui_get` those names.+4. Write the json on the PC. Settings gear is **not** the Routines list. Do not type into Prompt (if you touch it, set text empty and do not submit).++| CLI | Purpose | Key args |+|---|---|---|+| `grok_webhook_status` | Whether the json has a url. Returns `bound`, `urlHost` (hostname only). | — |+| `grok_webhook_ensure` | Scrape the routine pane and bind locally. No human copy-paste. | optional `force` |+| `grok_webhook_bind` | Write the json (local FS on the PC). `url` must be **https**. | **`url`**, **`key`** |+| `grok_webhook_fire` | POST `{event, payload, source:"grok-bridge", hostname}`. | **`event`**, optional `payload` |+| `grok_bot_create` | Fire `event=create_bot`. | **`name`**, optional `description` |+| `grok_channel_create` | Fire `event=create_channel`. | **`name`**, optional `member_ids` |+| `grok_bot_list` | Fire `event=list_bots` (cloud agent lists agents). | — |++If unbound: `errorCode: webhook_not_bound` and `_next` is `grok_webhook_ensure {}`.  `grok_describe` is the completeness hatch; the per-call `_hint` is what you read in practice. @@ -53,7 +102,8 @@ adom-bridge --ai-thread "<your thread>" grok_readiness '{}' - If more than one desktop is connected, pass `--target NAME` (`adom-bridge targets`). - Windows open in the **background**. `openedInBackground` / `windowEtiquette:"background"` are the contract. Foreground without `foregroundReason` is refused (`errorCode: foreground_reason_required`). - `grok_send` does **not** type into the chat widget. It toasts and writes `~/.adom/grok-inbox.jsonl` so the Anywhere character can poll. Desktop typing is opt-in — don't steal the keyboard.-- This HTTP runtime never calls `shell_execute`, `pull_file`, or `send_files`. Pull screenshots with the CLI from the container.+- `grok_login` / `grok_logout` never type passwords. Only UI paths: sidebar account button, **Sign In with Cursor**, **Sign Out**. Only grokbot:// routes: `settings?id=*` and `plugin/add?id=`.+- This HTTP runtime never calls `shell_execute`, `pull_file`, or `send_files`. Pull screenshots / recordings with the CLI from the container.  ## Two products, one page 
adom-bridge-grok-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "grok",-  "version": "1.0.0",-  "url": "https://wiki.adom.inc/download/adom/grok-bridge/1.0.0/adom-bridge-grok-v1.0.0.zip",-  "sha256": "43f40ffad1243242135125a917545d97bbecfceff8e692d5d83718625e587d87",-  "size": 17467,+  "version": "1.1.0",+  "url": "https://wiki.adom.inc/download/adom/grok-bridge/1.1.0/adom-bridge-grok-v1.1.0.zip",+  "sha256": "ff0eea91ce1de059f65bb25e8b5feae86d3a472331beb9b05972a93a94763e6c",+  "size": 32340,   "verbPrefixes": [     "grok_"   ],
bridge.json+40−12
@@ -2,8 +2,8 @@   "manifest_version": 1,   "name": "grok",   "displayName": "Grok Bot",-  "version": "1.0.0",-  "description": "Drive Grok Bot (Cursor host app) from Adom Bridge: detect Cursor, screenshot/list windows, queue messages via toast + inbox. No public Grok HTTP API — the runtime talks back to ab over desktop_* callbacks.",+  "version": "1.1.0",+  "description": "Drive Grok Bot (standalone Electron app, Grok Bot.exe) from Adom Bridge: open/close/resize/screenshot/record the app, 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.",   "license": "MIT",@@ -28,7 +28,22 @@     "grok_screenshot",     "grok_list_windows",     "grok_send",-    "grok_uninstall"+    "grok_uninstall",+    "grok_close",+    "grok_resize",+    "grok_record_start",+    "grok_record_stop",+    "grok_record_status",+    "grok_login",+    "grok_logout",+    "grok_plugin_install",+    "grok_webhook_status",+    "grok_webhook_bind",+    "grok_webhook_ensure",+    "grok_webhook_fire",+    "grok_bot_create",+    "grok_channel_create",+    "grok_bot_list"   ],   "statusVerb": "grok_readiness",   "timeouts": {@@ -43,25 +58,30 @@     "grok-bot",     "cursor",     "adom-bridge",-    "anywhere"+    "anywhere",+    "webhook"   ],   "docs": "https://wiki.adom.inc/adom/grok-bridge",   "updateManifestUrl": "https://wiki.adom.inc/api/v1/pages/grok-bridge/files/adom-bridge-grok-manifest.json",   "detect": {-    "hostApp": "Cursor",-    "appPathsExe": "Cursor.exe",+    "hostApp": "Grok Bot",+    "appPathsExe": "Grok Bot.exe",     "paths": {       "windows": [+        "%LocalAppData%\\Programs\\Grok Bot\\Grok Bot.exe",+        "%ProgramFiles%\\Grok Bot\\Grok Bot.exe",+        "%ProgramFiles(x86)%\\Grok Bot\\Grok Bot.exe",         "%LocalAppData%\\Programs\\cursor\\Cursor.exe",         "%LocalAppData%\\Programs\\Cursor\\Cursor.exe",-        "%ProgramFiles%\\Cursor\\Cursor.exe",-        "%ProgramFiles%\\cursor\\Cursor.exe",-        "%ProgramFiles(x86)%\\Cursor\\Cursor.exe"+        "%ProgramFiles%\\Cursor\\Cursor.exe"       ],       "macos": [+        "/Applications/Grok Bot.app",         "/Applications/Cursor.app"       ],       "linux": [+        "/usr/bin/grok-bot",+        "/usr/local/bin/grok-bot",         "/usr/bin/cursor",         "/usr/local/bin/cursor",         "/opt/cursor/cursor"@@ -72,7 +92,15 @@       "readiness",       "describe",       "status",-      "prewarm"+      "prewarm",+      "webhook_status",+      "webhook_bind",+      "webhook_fire",+      "bot_create",+      "channel_create",+      "bot_list",+      "uninstall",+      "webhook_ensure"     ]   },   "platforms": {@@ -81,11 +109,11 @@     },     "macos": {       "supported": true,-      "notes": "Detects /Applications/Cursor.app. Window verbs go through ab desktop_* callbacks."+      "notes": "Detects /Applications/Grok Bot.app. Window verbs go through ab desktop_* callbacks. Cursor.app is last-resort fallback."     },     "linux": {       "supported": true,-      "notes": "Detects typical cursor binaries. Window verbs go through ab desktop_* callbacks when ab is present."+      "notes": "Detects grok-bot / Grok Bot on PATH. Window verbs go through ab desktop_* callbacks when ab is present. Cursor is last-resort fallback."     }   },   "languages": [
grok_detect.py+232−74
@@ -1,11 +1,13 @@-"""Detect Cursor — the host app Grok Bot lives in — on Windows / macOS / Linux.+"""Detect Grok Bot — standalone Electron host app — on Windows / macOS / Linux.  Grok Bot has no public HTTP API. This bridge talks TO it the way kicad talks to KiCad: detect the host app on disk, then drive windows via ab desktop_* callbacks. -Never tell the user to download Cursor themselves. grok_open offers to launch it-(via ab `launch` / `desktop_open`) or, if missing, offers a winget install — it-does not run winget without a dedicated verb.+Host app is Grok Bot.exe (window title "Grok Bot"). Cursor is a LAST-RESORT+fallback only if Grok Bot.exe is missing — never the default, never killed.++Never tell the user to download the host app themselves. grok_open offers to+launch it (via ab `launch` / `desktop_open`). """  from __future__ import annotations@@ -22,9 +24,14 @@ IS_LINUX = sys.platform.startswith("linux")  CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) -HOST_APP = "Cursor"-APP_PATHS_EXE = "Cursor.exe"-WINDOWS_HINT = "titleContains Cursor or Grok"+HOST_APP = "Grok Bot"+APP_PATHS_EXE = "Grok Bot.exe"+WINDOWS_HINT = 'titleContains "Grok Bot" first, then "Grok"'+PROCESS_FILTER = "Grok Bot"+TITLE_NEEDLES = ["Grok Bot", "Grok"]++FALLBACK_HOST_APP = "Cursor"+FALLBACK_APP_PATHS_EXE = "Cursor.exe"   def _localappdata_roots() -> list[Path]:@@ -42,10 +49,74 @@ def _localappdata_roots() -> list[Path]:     return roots  -def _candidate_exes() -> list[Path]:-    """Typical Cursor install locations. Existence is the installed signal."""+def _programfiles_roots() -> list[Path]:+    roots: list[Path] = []+    for env in ("ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"):+        base = os.environ.get(env)+        if base:+            p = Path(base)+            if p not in roots:+                roots.append(p)+    return roots+++def _existing(paths: list[Path]) -> list[str]:+    seen = set()+    out = []+    for p in paths:+        try:+            if p.exists():+                s = str(p)+                if s not in seen:+                    seen.add(s)+                    out.append(s)+        except OSError:+            continue+    return out+++def _grok_bot_candidate_exes() -> list[Path]:     out: list[Path] = []+    if IS_WINDOWS:+        for root in _localappdata_roots():+            out.extend([+                root / "Programs" / "Grok Bot" / "Grok Bot.exe",+                root / "Programs" / "GrokBot" / "Grok Bot.exe",+                root / "Programs" / "grok-bot" / "Grok Bot.exe",+                root / "Grok Bot" / "Grok Bot.exe",+            ])+        for root in _programfiles_roots():+            out.extend([+                root / "Grok Bot" / "Grok Bot.exe",+                root / "GrokBot" / "Grok Bot.exe",+            ])+    elif IS_MACOS:+        out.extend([+            Path("/Applications/Grok Bot.app/Contents/MacOS/Grok Bot"),+            Path("/Applications/Grok Bot.app"),+            Path.home() / "Applications" / "Grok Bot.app",+        ])+    else:+        which = shutil.which("grok-bot") or shutil.which("grokbot")+        if which:+            out.append(Path(which))+        # `Grok Bot` as a PATH name is unusual; try anyway.+        which_space = shutil.which("Grok Bot")+        if which_space:+            out.append(Path(which_space))+        out.extend([+            Path("/usr/bin/grok-bot"),+            Path("/usr/local/bin/grok-bot"),+            Path("/opt/grok-bot/grok-bot"),+            Path("/opt/Grok Bot/Grok Bot"),+            Path.home() / ".local" / "bin" / "grok-bot",+        ])+    return out+ +def _cursor_candidate_exes() -> list[Path]:+    """Last-resort fallback if Grok Bot.exe is missing."""+    out: list[Path] = []     if IS_WINDOWS:         for root in _localappdata_roots():             out.extend([@@ -54,19 +125,17 @@ def _candidate_exes() -> list[Path]:                 root / "cursor" / "Cursor.exe",                 root / "Cursor" / "Cursor.exe",             ])-        for env in ("ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"):-            base = os.environ.get(env)-            if base:-                out.extend([-                    Path(base) / "cursor" / "Cursor.exe",-                    Path(base) / "Cursor" / "Cursor.exe",-                ])+        for root in _programfiles_roots():+            out.extend([+                root / "cursor" / "Cursor.exe",+                root / "Cursor" / "Cursor.exe",+            ])     elif IS_MACOS:         out.extend([             Path("/Applications/Cursor.app/Contents/MacOS/Cursor"),             Path("/Applications/Cursor.app"),+            Path.home() / "Applications" / "Cursor.app",         ])-        out.append(Path.home() / "Applications" / "Cursor.app")     else:         which = shutil.which("cursor")         if which:@@ -82,42 +151,24 @@ def _candidate_exes() -> list[Path]:     return out  -def _existing_paths() -> list[str]:-    seen = set()-    paths = []-    for p in _candidate_exes():-        try:-            if p.exists():-                s = str(p)-                if s not in seen:-                    seen.add(s)-                    paths.append(s)-        except OSError:-            continue-    return paths+def _tasklist_running(image: str) -> bool:+    try:+        r = subprocess.run(+            ["tasklist", "/FI", f"IMAGENAME eq {image}", "/FO", "CSV", "/NH"],+            capture_output=True, text=True, timeout=8,+            creationflags=CREATE_NO_WINDOW,+        )+        out = (r.stdout or "") + (r.stderr or "")+        if "No tasks" in out or out.strip().startswith("INFO:"):+            return False+        return image.lower() in out.lower()+    except Exception:+        return False  -def _process_running() -> bool:-    """Best-effort: is Cursor.exe / Cursor.app / cursor running? Never raises."""+def _pgrep_running(patterns: tuple[str, ...]) -> bool:     try:-        if IS_WINDOWS:-            r = subprocess.run(-                ["tasklist", "/FI", "IMAGENAME eq Cursor.exe", "/FO", "CSV", "/NH"],-                capture_output=True, text=True, timeout=8,-                creationflags=CREATE_NO_WINDOW,-            )-            out = (r.stdout or "") + (r.stderr or "")-            if "Cursor.exe" in out and "No tasks" not in out and "INFO:" not in out:-                return True-            r2 = subprocess.run(-                ["tasklist", "/FI", "IMAGENAME eq cursor.exe", "/FO", "CSV", "/NH"],-                capture_output=True, text=True, timeout=8,-                creationflags=CREATE_NO_WINDOW,-            )-            out2 = (r2.stdout or "") + (r2.stderr or "")-            return "cursor.exe" in out2.lower() and "No tasks" not in out2 and "INFO:" not in out2-        # macOS: Cursor.app helper; Linux: binary named cursor / Cursor-        for pat in (r"[/]Cursor[.]app[/]", r"[/][Cc]ursor$", r"^cursor$"):+        for pat in patterns:             r = subprocess.run(                 ["pgrep", "-f", pat],                 capture_output=True, text=True, timeout=5,@@ -129,57 +180,164 @@ def _process_running() -> bool:         return False  -def detect_cursor() -> dict:-    """Return {hostAppInstalled, paths, running, windowsHint} plus extras for /status."""-    paths = _existing_paths()-    installed = bool(paths)-    running = _process_running()+def _grok_bot_running() -> bool:+    try:+        if IS_WINDOWS:+            return (+                _tasklist_running("Grok Bot.exe")+                or _tasklist_running("GrokBot.exe")+            )+        return _pgrep_running((+            r"[/]Grok Bot[.]app[/]",+            r"[/]Grok Bot$",+            r"grok-bot",+            r"^Grok Bot$",+        ))+    except Exception:+        return False+++def _cursor_running() -> bool:+    try:+        if IS_WINDOWS:+            return (+                _tasklist_running("Cursor.exe")+                or _tasklist_running("cursor.exe")+            )+        return _pgrep_running((+            r"[/]Cursor[.]app[/]",+            r"[/][Cc]ursor$",+            r"^cursor$",+        ))+    except Exception:+        return False+++def detect_host() -> dict:+    """Return host-app probe. Grok Bot first; Cursor only if Grok Bot.exe is missing."""+    grok_paths = _existing(_grok_bot_candidate_exes())+    grok_installed = bool(grok_paths)+    grok_running = _grok_bot_running()++    if grok_installed or grok_running:+        return {+            "hostApp": HOST_APP,+            "hostAppInstalled": grok_installed or grok_running,+            "hostAppRunning": grok_running,+            "installed": grok_installed or grok_running,+            "running": grok_running,+            "paths": grok_paths,+            "exe": grok_paths[0] if grok_paths else None,+            "appPathsExe": APP_PATHS_EXE,+            "windowsHint": WINDOWS_HINT,+            "processFilter": PROCESS_FILTER,+            "titleNeedles": list(TITLE_NEEDLES),+            "hostAppFallback": None,+            "platform": (+                "windows" if IS_WINDOWS else "macos" if IS_MACOS else "linux"+            ),+        }++    cursor_paths = _existing(_cursor_candidate_exes())+    cursor_running = _cursor_running()+    cursor_installed = bool(cursor_paths)+    fallback_used = cursor_installed or cursor_running     return {         "hostApp": HOST_APP,-        "hostAppInstalled": installed,-        "hostAppRunning": running,-        "installed": installed,          # alias used by some callers-        "running": running,-        "paths": paths,-        "exe": paths[0] if paths else None,+        "hostAppInstalled": fallback_used,+        "hostAppRunning": cursor_running,+        "installed": fallback_used,+        "running": cursor_running,+        "paths": cursor_paths,+        "exe": cursor_paths[0] if cursor_paths else None,         "appPathsExe": APP_PATHS_EXE,-        "windowsHint": WINDOWS_HINT,+        "windowsHint": WINDOWS_HINT + (+            '; Cursor last-resort fallback (Grok Bot.exe missing)'+            if fallback_used else ""+        ),+        "processFilter": "Cursor" if fallback_used else PROCESS_FILTER,+        "titleNeedles": (+            list(TITLE_NEEDLES) + ["Cursor"] if fallback_used else list(TITLE_NEEDLES)+        ),+        "hostAppFallback": "Cursor" if fallback_used else None,+        "fallbackApp": FALLBACK_HOST_APP if fallback_used else None,+        "fallbackExe": FALLBACK_APP_PATHS_EXE if fallback_used else None,         "platform": (             "windows" if IS_WINDOWS else "macos" if IS_MACOS else "linux"         ),     }  +def detect_cursor() -> dict:+    """Back-compat alias — host app is Grok Bot; Cursor is last-resort fallback."""+    return detect_host()+++def window_title_needles(info: dict | None = None) -> list[str]:+    info = info or detect_host()+    needles = info.get("titleNeedles")+    if isinstance(needles, list) and needles:+        return [str(n) for n in needles if n]+    return list(TITLE_NEEDLES)+++def process_name_filter(info: dict | None = None) -> str:+    info = info or detect_host()+    return str(info.get("processFilter") or PROCESS_FILTER)++ def status_chip(info: dict | None = None) -> dict:     """led / summary / tooltip for GET /status and /health."""-    info = info or detect_cursor()+    info = info or detect_host()     installed = bool(info.get("hostAppInstalled"))     running = bool(info.get("running"))-    if running:+    fallback = info.get("hostAppFallback")+    if running and not fallback:         return {             "led": "green",-            "summary": "Cursor ready (Grok Bot host)",+            "summary": "Grok Bot ready",             "tooltip": (-                "Grok Bot lives in Cursor. Host app is running. "-                "Drive it with grok_list_windows / grok_screenshot / grok_send "+                "Grok Bot is running. Drive it with grok_list_windows / "+                "grok_screenshot / grok_send / grok_resize / grok_record_start "                 "(background by default; don't steal the keyboard)."             ),         }-    if installed:+    if running and fallback:+        return {+            "led": "yellow",+            "summary": "Cursor fallback (Grok Bot.exe missing)",+            "tooltip": (+                "Grok Bot.exe was not found; falling back to Cursor as a last resort. "+                "Prefer installing Grok Bot (C:\\Users\\john\\AppData\\Local\\Programs\\"+                "Grok Bot\\Grok Bot.exe). Drive the current host with grok_open / "+                "grok_list_windows — never kill Cursor."+            ),+        }+    if installed and not fallback:         return {             "led": "yellow",-            "summary": "Cursor installed, not running",+            "summary": "Grok Bot installed, not running",             "tooltip": (-                "Cursor is on disk but not running. Call grok_open to launch it "+                "Grok Bot is on disk but not running. Call grok_open to launch it "                 "in the background — it will not steal focus."             ),         }+    if installed and fallback:+        return {+            "led": "yellow",+            "summary": "Cursor fallback installed (Grok Bot.exe missing)",+            "tooltip": (+                "Grok Bot.exe is missing. Cursor is a last-resort fallback host. "+                "Call grok_open to launch it in the background. Prefer Grok Bot."+            ),+        }     return {         "led": "yellow",-        "summary": "Cursor not detected",+        "summary": "Grok Bot not detected",         "tooltip": (-            "Cursor isn't installed — want me to open it if present, or I can try winget? "-            "Call grok_open (it detects, then offers launch). Do not send the user to "-            "download Cursor themselves."+            "Grok Bot isn't installed — want me to open it if present? "+            "Call grok_open (it detects, then offers launch). Cursor is only a "+            "last-resort fallback if Grok Bot.exe is missing. Do not send the "+            "user to download the host app themselves."         ),     }
handlers/app.pyadded+378
@@ -0,0 +1,378 @@+"""App-window verbs: close / resize / record / login / logout / plugin_install.++All driving is caller_identity.ad_callback (desktop_*). Never shell_execute /+pull_file / send_files. Prefer hide over process_kill. Never kill Cursor.+Background by default. Never invent grokbot:// routes other than+settings?id=* and plugin/add?id=.+"""++from __future__ import annotations++from urllib.parse import quote++from grok_detect import HOST_APP, detect_host+from handlers.windows import (+    _cb,+    _callback_ok,+    _hwnd_of,+    grok_hwnd,+    launch_host,+    pick_grok_window,+    window_is_maximized,+    window_is_minimized,+    window_rect,+)++SETTINGS_DEEPLINK = "grokbot://app/v1/settings?id=theme"+PLUGIN_ADD_DEEPLINK = "grokbot://app/v1/plugin/add?id="+++def _window_missing(verb: str) -> dict:+    info = detect_host()+    return {+        "success": False,+        "errorCode": "window_not_found",+        "error": "No Grok Bot window.",+        "hostApp": HOST_APP,+        "hostAppFallback": info.get("hostAppFallback"),+        "_hint": (+            "No Grok Bot hwnd (titleContains Grok Bot, then Grok). "+            "Call grok_open {} to launch Grok Bot in the background, then retry "+            f"{verb}. Never kill Cursor."+            + (+                " Grok Bot.exe was not found; Cursor is last-resort fallback and will not be closed."+                if info.get("hostAppFallback") else ""+            )+        ),+        "_next": f"grok_open {{}} then grok_{verb}",+    }+++def handle_close(_args: dict) -> dict:+    """Hide the Grok Bot window. Prefer hide over process_kill. Never kill Cursor."""+    # Do NOT target a generic Cursor window — that is the user's IDE on fallback.+    hwnd, win = grok_hwnd(allow_cursor=False)+    if not hwnd:+        miss = _window_missing("close")+        miss["closed"] = False+        return miss++    hide = _cb("desktop_set_window_state", {"hwnd": hwnd, "state": "hide"})+    if _callback_ok(hide):+        return {+            "success": True,+            "closed": True,+            "via": "desktop_set_window_state hide",+            "hwnd": hwnd,+            "title": (win or {}).get("title"),+            "killedProcess": False,+            "windowEtiquette": "background",+            "_hint": (+                f"Hid Grok Bot hwnd={hwnd} via desktop_set_window_state state=hide. "+                "Did not kill the process and did not touch Cursor. grok_open to show it again."+            ),+        }++    # Hide failed — restore (if needed) then try a UIA Close without process_kill.+    if window_is_minimized(win) or window_is_maximized(win):+        _cb("desktop_set_window_state", {"hwnd": hwnd, "state": "restore"})+    click = _cb("desktop_ui_click", {"hwnd": hwnd, "name": "Close"})+    if _callback_ok(click):+        return {+            "success": True,+            "closed": True,+            "via": "desktop_ui_click Close",+            "hwnd": hwnd,+            "hideAttempt": hide,+            "killedProcess": False,+            "windowEtiquette": "background",+            "_hint": (+                f"Hide failed; clicked UIA name=Close on Grok Bot hwnd={hwnd} in the background. "+                "Did not process_kill. Never kills Cursor."+            ),+        }++    return {+        "success": False,+        "errorCode": "close_failed",+        "closed": False,+        "hwnd": hwnd,+        "hideAttempt": hide,+        "clickAttempt": click,+        "killedProcess": False,+        "_hint": (+            "Could not hide or UIA-Close the Grok Bot window. Not killing the process "+            "(prefer hide over process_kill; never kill Cursor). "+            "Ask the human to close or hide Grok Bot, or retry grok_close after grok_list_windows."+        ),+        "_next": "grok_list_windows {} then retry grok_close {}",+    }+++def _as_int(val, name: str):+    if val is None or val == "":+        return None+    try:+        return int(val)+    except (TypeError, ValueError):+        raise ValueError(name)+++def handle_resize(args: dict) -> dict:+    """desktop_set_window_bounds on the Grok Bot hwnd. Physical px (same space as ab)."""+    try:+        w = _as_int(args.get("w") if args.get("w") is not None else args.get("width"), "w")+        h = _as_int(args.get("h") if args.get("h") is not None else args.get("height"), "h")+        x = _as_int(args.get("x") if args.get("x") is not None else args.get("left"), "x")+        y = _as_int(args.get("y") if args.get("y") is not None else args.get("top"), "y")+    except ValueError as e:+        return {+            "success": False,+            "errorCode": "invalid_bounds",+            "error": f"Invalid {e} — need integer physical px.",+            "_hint": "grok_resize {w, h, x?, y?} in ab physical pixels. w and h required.",+            "_next": "grok_resize {\"w\":1200,\"h\":800}",+        }+    if w is None or h is None:+        return {+            "success": False,+            "errorCode": "bounds_required",+            "error": "grok_resize needs {w, h}.",+            "_hint": "Pass w and h in ab physical px. x and y default to the window's current origin.",+            "_next": "grok_resize {\"w\":1200,\"h\":800}",+        }++    hwnd, win = grok_hwnd(allow_cursor=True)+    if not hwnd:+        return _window_missing("resize")++    if window_is_maximized(win) or window_is_minimized(win):+        _cb("desktop_set_window_state", {"hwnd": hwnd, "state": "restore"})+        # Re-read origin after restore so omitted x,y stay physical-correct.+        win = pick_grok_window(allow_cursor=True) or win+        hwnd = _hwnd_of(win) or hwnd++    cur = window_rect(win)+    if x is None:+        x = cur.get("x")+    if y is None:+        y = cur.get("y")++    bounds = {"hwnd": hwnd, "w": w, "h": h}+    if x is not None:+        bounds["x"] = x+    if y is not None:+        bounds["y"] = y+    # restore:false so MoveWindow does not raise (pup lesson: bounds can steal focus).+    bounds["restore"] = False++    res = _cb("desktop_set_window_bounds", bounds)+    ok = _callback_ok(res)+    if ok:+        hint = (+            f"desktop_set_window_bounds hwnd={hwnd} w={w} h={h}"+            + (f" x={x}" if x is not None else " (kept current x)")+            + (f" y={y}" if y is not None else " (kept current y)")+            + " in ab physical px. Restored first if maximized. Background — did not steal focus."+        )+    else:+        hint = (+            "desktop_set_window_bounds failed. grok_list_windows to confirm the hwnd, then retry. "+            "w/h/x/y are physical px (same space as ab)."+        )+    out = {+        "success": ok,+        "hwnd": hwnd,+        "bounds": {k: bounds[k] for k in ("x", "y", "w", "h") if k in bounds},+        "callback": res,+        "windowEtiquette": "background",+        "_hint": hint,+    }+    if not ok:+        out["errorCode"] = "resize_failed"+        out["_next"] = "grok_list_windows {} then grok_resize {\"w\":...,\"h\":...}"+    return out+++def handle_record_start(args: dict) -> dict:+    reason = (args.get("reason") or "").strip()+    if not reason:+        return {+            "success": False,+            "errorCode": "reason_required",+            "error": "grok_record_start needs {reason}.",+            "_hint": (+                "Pass reason (forwarded to desktop_record_window_start). "+                "Example: grok_record_start {\"reason\":\"demo of grok-bridge app control\"}."+            ),+            "_next": "grok_record_start {\"reason\":\"...\"}",+        }+    hwnd, win = grok_hwnd(allow_cursor=True)+    if not hwnd:+        return _window_missing("record_start")+    rec_args = {"hwnd": hwnd, "reason": reason}+    res = _cb("desktop_record_window_start", rec_args, timeout=45)+    ok = _callback_ok(res)+    out = dict(res) if isinstance(res, dict) else {"callback": res}+    out["success"] = ok+    out.setdefault("hwnd", hwnd)+    out.setdefault("reason", reason)+    out.setdefault("title", (win or {}).get("title"))+    out.setdefault("windowEtiquette", "background")+    if not ok:+        out.setdefault("errorCode", "record_start_failed")+        out["_next"] = "grok_open {} then grok_record_start {\"reason\":\"...\"}"+    out["_hint"] = (+        f"desktop_record_window_start on Grok Bot hwnd={hwnd} reason={reason!r}. "+        "Background WGC — no picker, no focus steal. Stop with grok_record_stop {}; "+        "poll grok_record_status {}. The mp4 lands on the desktop; pull it with the CLI "+        "(this HTTP callback never calls pull_file)."+        if ok else+        "desktop_record_window_start failed. Is Grok Bot visible to WGC? grok_open, then retry. "+        "reason is required and is passed through."+    )+    return out+++def handle_record_stop(args: dict) -> dict:+    rec_args = {}+    for k in ("recordingId", "id"):+        if args.get(k):+            rec_args["recordingId"] = args[k]+            break+    res = _cb("desktop_record_window_stop", rec_args, timeout=60)+    ok = _callback_ok(res)+    out = dict(res) if isinstance(res, dict) else {"callback": res}+    out["success"] = ok+    if not ok:+        out.setdefault("errorCode", "record_stop_failed")+        out["_next"] = "grok_record_status {} then grok_record_stop {}"+    out["_hint"] = (+        "desktop_record_window_stop. Recording file is on the desktop "+        "(%TEMP%\\adom-bridge-recordings or the path in this payload). "+        "Pull it with the adom-bridge CLI — this HTTP callback never calls pull_file."+        if ok else+        "desktop_record_window_stop failed. grok_record_status to see if a recording is active."+    )+    return out+++def handle_record_status(_args: dict) -> dict:+    res = _cb("desktop_record_window_status", {})+    ok = _callback_ok(res)+    out = dict(res) if isinstance(res, dict) else {"callback": res}+    out["success"] = ok if res else True+    out["_hint"] = (+        "desktop_record_window_status wrap. Start with grok_record_start {reason}; "+        "stop with grok_record_stop {}."+    )+    return out+++def _open_deeplink(url: str) -> dict:+    """desktop_open_url refuses grokbot:// (http/https/mailto/ftp only). Launch the exe with the URL as an arg."""+    return launch_host([url])+++def handle_login(_args: dict) -> dict:+    """Open Grok Bot settings in the background. Do NOT type passwords."""+    res = _open_deeplink(SETTINGS_DEEPLINK)+    ok = _callback_ok(res)+    return {+        "success": ok or True,  # opening settings is best-effort; the human signs in+        "opened": ok,+        "url": SETTINGS_DEEPLINK,+        "background": True,+        "typedPassword": False,+        "callback": res,+        "windowEtiquette": "background",+        "_hint": (+            "Launched Grok Bot.exe with grokbot://app/v1/settings?id=theme as an arg in the BACKGROUND (desktop_open_url refuses grokbot://; "+            "no password typing). On the account card, the human should click "+            "**Sign In with Cursor**. This verb will not type credentials."+        ),+    }+++def handle_logout(_args: dict) -> dict:+    """Open settings; try background UIA Sign Out. Else needsHuman."""+    opened = _open_deeplink(SETTINGS_DEEPLINK)+    hwnd, win = grok_hwnd(allow_cursor=False)+    if not hwnd:+        hwnd, win = grok_hwnd(allow_cursor=True)++    clicked = False+    click_res = None+    found = None+    if hwnd:+        found = _cb("desktop_find_control", {"hwnd": hwnd, "name": "Sign Out"})+        click_res = _cb("desktop_ui_click", {"hwnd": hwnd, "name": "Sign Out"})+        clicked = _callback_ok(click_res) and not click_res.get("error")+        if not clicked:+            # Allowed path: sidebar account button, then Sign Out.+            acct = _cb("desktop_ui_click", {"hwnd": hwnd, "name": "Account"})+            if _callback_ok(acct):+                click_res = _cb("desktop_ui_click", {"hwnd": hwnd, "name": "Sign Out"})+                clicked = _callback_ok(click_res) and not click_res.get("error")++    if clicked:+        return {+            "success": True,+            "loggedOut": True,+            "needsHuman": False,+            "hwnd": hwnd,+            "openedSettings": _callback_ok(opened),+            "windowEtiquette": "background",+            "_hint": (+                "Clicked UIA name containing Sign Out on the Grok Bot window in the background. "+                "Did not type anything. If the session is still signed in, the human should "+                "click Sign Out on the account card."+            ),+        }++    return {+        "success": True,+        "loggedOut": False,+        "needsHuman": True,+        "hwnd": hwnd,+        "openedSettings": _callback_ok(opened),+        "findControl": found,+        "clickAttempt": click_res,+        "windowEtiquette": "background",+        "_hint": (+            "Launched Grok Bot.exe with settings deep link as an arg (desktop_open_url refuses grokbot://). "+            "Sign Out was not background-actionable via UIA. needsHuman:true — please click "+            "**Sign Out** on the account card (sidebar account button → Sign Out). "+            "This verb will not invent other UI paths and will not type."+        ),+        "_next": "Human clicks Sign Out on the Grok Bot account card.",+    }+++def handle_plugin_install(args: dict) -> dict:+    plugin_id = str(args.get("id") or args.get("pluginId") or "").strip()+    if not plugin_id:+        return {+            "success": False,+            "errorCode": "id_required",+            "error": "grok_plugin_install needs {id}.",+            "_hint": "Pass the plugin id. This opens grokbot://app/v1/plugin/add?id=<id> and does not confirm install — the app UI does.",+            "_next": "grok_plugin_install {\"id\":\"...\"}",+        }+    url = PLUGIN_ADD_DEEPLINK + quote(plugin_id, safe="")+    res = _open_deeplink(url)+    ok = _callback_ok(res)+    return {+        "success": ok or True,+        "opened": ok,+        "id": plugin_id,+        "url": url,+        "confirmed": False,+        "background": True,+        "callback": res,+        "windowEtiquette": "background",+        "_hint": (+            f"Launched Grok Bot.exe with {url} as an arg (desktop_open_url refuses grokbot://). This does NOT confirm install — the Grok Bot "+            "app UI does. The human may need to accept the plugin add dialog."+        ),+    }
handlers/bots.pyadded+80
@@ -0,0 +1,80 @@+"""Bot / channel verbs — fire the bound Grok Bridge webhook.++create_bot / create_channel / list_bots. If the webhook is not bound, return+errorCode webhook_not_bound and tell the caller to grok_webhook_bind from the+\"Grok Bridge webhook\" routine panel (URL + sender key).+"""++from __future__ import annotations++from handlers.webhook import handle_webhook_fire, load_webhook, webhook_not_bound_error+++def _need_name(args: dict, verb: str) -> str | dict:+    name = str(args.get("name") or "").strip()+    if not name:+        return {+            "success": False,+            "errorCode": "name_required",+            "error": f"{verb} needs {{name}}.",+            "_hint": f"Call {verb} {{\"name\":\"...\"}}.",+            "_next": f"{verb} {{\"name\":\"...\"}}",+        }+    return name+++def handle_bot_create(args: dict) -> dict:+    if not load_webhook().get("url"):+        return webhook_not_bound_error("grok_bot_create")+    name = _need_name(args, "grok_bot_create")+    if isinstance(name, dict):+        return name+    payload = {"name": name}+    desc = args.get("description")+    if desc is not None and str(desc).strip():+        payload["description"] = str(desc)+    res = handle_webhook_fire({"event": "create_bot", "payload": payload})+    res.setdefault("created", bool(res.get("success")))+    res.setdefault("name", name)+    if res.get("success"):+        res["_hint"] = (+            f"Fired webhook event=create_bot name={name!r} to host {res.get('urlHost')}. "+            + (res.get("_hint") or "")+        )+    return res+++def handle_channel_create(args: dict) -> dict:+    if not load_webhook().get("url"):+        return webhook_not_bound_error("grok_channel_create")+    name = _need_name(args, "grok_channel_create")+    if isinstance(name, dict):+        return name+    payload = {"name": name}+    members = args.get("member_ids")+    if members is None:+        members = args.get("memberIds")+    if members is not None:+        payload["member_ids"] = members+    res = handle_webhook_fire({"event": "create_channel", "payload": payload})+    res.setdefault("created", bool(res.get("success")))+    res.setdefault("name", name)+    if res.get("success"):+        res["_hint"] = (+            f"Fired webhook event=create_channel name={name!r} to host {res.get('urlHost')}. "+            + (res.get("_hint") or "")+        )+    return res+++def handle_bot_list(_args: dict) -> dict:+    if not load_webhook().get("url"):+        return webhook_not_bound_error("grok_bot_list")+    res = handle_webhook_fire({"event": "list_bots", "payload": {}})+    if res.get("success"):+        res["_hint"] = (+            f"Fired webhook event=list_bots to host {res.get('urlHost')} "+            "(cloud agent lists agents). "+            + (res.get("_hint") or "")+        )+    return res
handlers/describe.py+166−21
@@ -7,21 +7,22 @@ PREFIX = "grok_" # Keyed by the INTERNAL (unprefixed) command name. VERB_CATALOG = {     "readiness": {-        "summary": "Read-only: is Cursor (Grok Bot's host app) installed / running?",+        "summary": "Read-only: is Grok Bot.exe installed / running? Cursor is last-resort fallback.",         "long": False,-        "hint": "Zero side effects, never downloads. Branch on hostAppInstalled / hostAppRunning / errorCode host_app_not_installed.",+        "hint": "Zero side effects, never downloads. Branch on hostAppInstalled / hostAppRunning / hostAppFallback / errorCode host_app_not_installed.",         "related": ["grok_prewarm", "grok_open", "grok_status"],         "pitfalls": [             "ready=false is NOT a user-download. Offer grok_open.",-            "Do not treat a yellow /status chip as a crash — the bridge is up, Cursor isn't.",+            "hostAppFallback:Cursor means Grok Bot.exe is missing — do not kill Cursor.",+            "Do not treat a yellow /status chip as a crash — the bridge is up, Grok Bot isn't.",         ],     },     "prewarm": {         "summary": "No-op re-detect. {wait:true} just re-detects; nothing to fetch.",         "long": False,-        "hint": "Cursor is a host app, not a runtime asset. Cheap enough to call anytime.",+        "hint": "Grok Bot is a host app, not a runtime asset. Cheap enough to call anytime.",         "related": ["grok_readiness", "grok_open"],-        "pitfalls": ["wait:true does not install Cursor or steal focus"],+        "pitfalls": ["wait:true does not install Grok Bot or steal focus"],     },     "describe": {         "summary": "This catalog — every grok_* verb with hint/related/pitfalls.",@@ -31,39 +32,39 @@ VERB_CATALOG = {         "pitfalls": ["the catalog is static; live state is grok_readiness / grok_status"],     },     "status": {-        "summary": "Detect + last grok_send + inbox path.",+        "summary": "Detect + last grok_send + inbox path + webhook bound (host only, never the key).",         "long": False,         "hint": "Use this after a send to confirm the durable inbox got the line.",-        "related": ["grok_send", "grok_readiness"],+        "related": ["grok_send", "grok_readiness", "grok_webhook_status"],         "pitfalls": ["lastSend is in-process; a bridge restart clears it (inbox file survives)"],     },     "open": {-        "summary": "Launch Cursor (Grok Bot host) in the BACKGROUND if installed.",+        "summary": "Launch Grok Bot in the BACKGROUND if installed. Cursor only if Grok Bot.exe is missing.",         "long": True,-        "hint": "Detect, then ab launch / desktop_open. If already running, lists windows. Never steals focus. If missing: offers launch / winget — does not winget itself.",-        "related": ["grok_list_windows", "grok_screenshot", "grok_readiness"],+        "hint": "Detect, then ab launch / desktop_open. If already running, lists windows. Never steals focus.",+        "related": ["grok_list_windows", "grok_screenshot", "grok_close", "grok_readiness"],         "pitfalls": [             "Background by default. foreground:true without foregroundReason is refused.",-            "Do not tell the user to download Cursor. Offer grok_open.",+            "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.",         ],     },     "screenshot": {-        "summary": "Screenshot a Cursor / Grok Bot window via ab desktop_screenshot_window.",+        "summary": "Screenshot a Grok Bot window via ab desktop_screenshot_window.",         "long": False,-        "hint": "titleContains Cursor, then Grok. Does not raise the window.",-        "related": ["grok_list_windows", "grok_open"],+        "hint": "titleContains Grok Bot, then Grok. Does not raise the window.",+        "related": ["grok_list_windows", "grok_open", "grok_record_start"],         "pitfalls": [             "Needs a live window — grok_open first if hostAppRunning is false.",             "The PNG lands on the desktop; pull it with the CLI, not from this HTTP callback.",         ],     },     "list_windows": {-        "summary": "List Cursor / Grok Bot windows (desktop_list_windows + process_list).",+        "summary": "List Grok Bot windows (desktop_list_windows + process_list nameFilter Grok Bot).",         "long": False,-        "hint": "nameFilter Cursor. Read-only; does not focus or click.",-        "related": ["grok_screenshot", "grok_open", "grok_send"],-        "pitfalls": ["empty list usually means Cursor isn't running — grok_open, don't panic"],+        "hint": "titleContains Grok Bot first, then Grok. Read-only; does not focus or click.",+        "related": ["grok_screenshot", "grok_open", "grok_close", "grok_resize"],+        "pitfalls": ["empty list usually means Grok Bot isn't running — grok_open, don't panic"],     },     "send": {         "summary": "Queue a message to Grok Bot: toast + durable inbox. Typing is opt-in.",@@ -77,11 +78,153 @@ VERB_CATALOG = {         ],     },     "uninstall": {-        "summary": "Tell the caller to bridge_uninstall {name:grok}. Does not kill Cursor.",+        "summary": "Tell the caller to bridge_uninstall {name:grok}. Does not kill Grok Bot or Cursor.",         "long": False,         "hint": "adom-bridge bridge_uninstall '{\"name\":\"grok\"}'. Skills: ./uninstall.sh.",         "related": ["grok_status"],-        "pitfalls": ["never kills Cursor.exe / Cursor.app — that's the user's host app"],+        "pitfalls": ["never kills Grok Bot.exe or Cursor.exe — those are the user's apps"],+    },+    "close": {+        "summary": "Hide the Grok Bot window (desktop_set_window_state hide). Never process_kill Cursor.",+        "long": False,+        "hint": "Prefer hide over kill. Returns closed:true. grok_open to show it again.",+        "related": ["grok_open", "grok_list_windows", "grok_resize"],+        "pitfalls": [+            "Does not kill Grok Bot.exe or Cursor.exe.",+            "Will not hide a generic Cursor IDE window even on fallback.",+        ],+    },+    "resize": {+        "summary": "desktop_set_window_bounds on the Grok Bot hwnd. Physical px (same space as ab).",+        "long": False,+        "hint": "grok_resize {w, h, x?, y?}. Restores first if maximized. Omitting x,y keeps the current origin.",+        "related": ["grok_list_windows", "grok_screenshot", "grok_open"],+        "pitfalls": [+            "w and h are required. Units are ab physical pixels, not DIPs.",+            "Background — does not steal focus.",+        ],+    },+    "record_start": {+        "summary": "desktop_record_window_start on the Grok Bot hwnd. reason required.",+        "long": True,+        "hint": "grok_record_start {\"reason\":\"...\"}. Background WGC. Stop with grok_record_stop.",+        "related": ["grok_record_stop", "grok_record_status", "grok_screenshot"],+        "pitfalls": [+            "reason is required and is passed through.",+            "The mp4 lands on the desktop; pull it with the CLI, never pull_file from this HTTP callback.",+        ],+    },+    "record_stop": {+        "summary": "desktop_record_window_stop wrap.",+        "long": False,+        "hint": "Optional recordingId. File is on the desktop.",+        "related": ["grok_record_start", "grok_record_status"],+        "pitfalls": ["does not pull the file — use the adom-bridge CLI"],+    },+    "record_status": {+        "summary": "desktop_record_window_status wrap.",+        "long": False,+        "hint": "Poll after grok_record_start.",+        "related": ["grok_record_start", "grok_record_stop"],+        "pitfalls": [],+    },+    "login": {+        "summary": "Open Grok Bot settings (grokbot://app/v1/settings?id=theme) in the BACKGROUND. No passwords.",+        "long": False,+        "hint": "Human clicks Sign In with Cursor on the account card. This verb never types credentials.",+        "related": ["grok_logout", "grok_open"],+        "pitfalls": [+            "Do not type passwords or 2FA.",+            "Only grokbot:// settings?id=* and plugin/add?id= routes exist in v1.1.",+        ],+    },+    "logout": {+        "summary": "Open settings; try background UIA Sign Out. Else needsHuman:true.",+        "long": False,+        "hint": "If Sign Out is not background-actionable, the human clicks Sign Out on the account card.",+        "related": ["grok_login", "grok_open"],+        "pitfalls": [+            "Never invent UI paths beyond sidebar account button / Sign In with Cursor / Sign Out.",+            "needsHuman:true is success, not a crash — surface _hint.",+        ],+    },+    "plugin_install": {+        "summary": "Open grokbot://app/v1/plugin/add?id=<id>. Does not confirm install (the app UI does).",+        "long": False,+        "hint": "id required. Launches Grok Bot.exe with grokbot://app/v1/plugin/add?id=<id> as an arg (desktop_open_url refuses grokbot://).",+        "related": ["grok_open", "grok_login"],+        "pitfalls": ["does not confirm or wait for install", "id is required"],+    },+    "webhook_status": {+        "summary": "Whether ~/.adom/grok-webhook.json has a url. Returns bound + urlHost (never the key).",+        "long": False,+        "hint": "Key is never logged or returned. Prefer grok_webhook_ensure to read the routine pane.",+        "related": ["grok_webhook_ensure", "grok_webhook_bind", "grok_bot_list"],+        "pitfalls": ["never echo the sender key", "bound:false → grok_webhook_ensure first"],+    },+    "webhook_ensure": {+        "summary": "Scrape the Grok Bridge webhook routine pane (UIA Edit Webhook URL / Webhook key) and bind locally. No human copy-paste.",+        "long": True,+        "hint": "grok_webhook_ensure {}. Opens Open routine Grok Bridge webhook, expands When a webhook fires, reads Edit values, writes ~/.adom/grok-webhook.json. Never returns the key. force:true re-scrapes.",+        "related": ["grok_webhook_status", "grok_webhook_fire", "grok_bot_create"],+        "pitfalls": [+            "Needs a live Grok Bot window.",+            "Never echo the sender key.",+            "Do not ask a human to copy the URL/key — this verb is the auto-setup.",+        ],+    },+    "webhook_bind": {+        "summary": "Write ~/.adom/grok-webhook.json {url, key, header, boundAt}. url must be https. key required.",+        "long": False,+        "hint": "Manual bind. Prefer grok_webhook_ensure {} which reads UIA Edits Webhook URL / Webhook key.",+        "related": ["grok_webhook_ensure", "grok_webhook_status", "grok_webhook_fire"],+        "pitfalls": [+            "url must be https.",+            "The key is stored locally and never put in _hint or logs.",+        ],+    },+    "webhook_ensure": {+        "summary": "Read the Grok Bridge webhook routine pane via UIA Edits and write ~/.adom/grok-webhook.json.",+        "long": False,+        "hint": "Clicks \"Open routine Grok Bridge webhook\", expands \"When a webhook fires\" if needed, reads Edit names Webhook URL / Webhook key / Authorization header. No OCR. Never echoes the key.",+        "related": ["grok_webhook_status", "grok_webhook_bind", "grok_bot_list"],+        "pitfalls": [+            "Settings gear is NOT the Routines list.",+            "Do not type into Prompt (clear leftover garbage; do not submit).",+            "ListItem \"When a webhook fires\" is not invokable — prefer the Button, else click its center.",+            "Never log or return the sender key.",+        ],+    },+    "webhook_fire": {+        "summary": "POST {event, payload, source:grok-bridge} to the bound url. Auth is Authorization: Bearer, never a body key.",+        "long": False,+        "hint": "Header Authorization: Bearer <key>. errorCode webhook_not_bound if unbound — grok_webhook_ensure first.",+        "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.",+            "Never log the sender key.",+        ],+    },+    "bot_create": {+        "summary": "Webhook event=create_bot. {name, description?}. Needs grok_webhook_bind first.",+        "long": False,+        "hint": "If unbound: errorCode webhook_not_bound — grok_webhook_ensure {} first.",+        "related": ["grok_webhook_ensure", "grok_bot_list", "grok_channel_create"],+        "pitfalls": ["name required", "does nothing local if webhook_not_bound"],+    },+    "channel_create": {+        "summary": "Webhook event=create_channel. {name, member_ids?}.",+        "long": False,+        "hint": "Same bind requirement as grok_bot_create.",+        "related": ["grok_webhook_ensure", "grok_bot_create"],+        "pitfalls": ["name required", "errorCode webhook_not_bound if unbound"],+    },+    "bot_list": {+        "summary": "Webhook event=list_bots (cloud agent lists agents).",+        "long": False,+        "hint": "If unbound: errorCode webhook_not_bound.",+        "related": ["grok_webhook_ensure", "grok_bot_create"],+        "pitfalls": ["errorCode webhook_not_bound if unbound"],     }, } @@ -109,7 +252,9 @@ def handle_describe(_args: dict) -> dict:         "_hint": (             "Full grok_* catalog — each entry carries hint/related/pitfalls. "             "Every verb ALSO returns its own rich _hint when called; that's what to read in practice. "-            "Host-app probe: grok_readiness. Drive Grok Bot: grok_open / grok_send / grok_screenshot. "+            "Host app is Grok Bot.exe (Cursor last-resort fallback). "+            "Drive the app: grok_open / grok_close / grok_resize / grok_screenshot / grok_record_start. "+            "Bots/channels: grok_webhook_ensure then grok_bot_create / grok_channel_create / grok_bot_list. "             "CLI: adom-bridge grok_* . Always --ai-thread. Background by default."         ),     }
handlers/readiness.py+53−25
@@ -2,76 +2,93 @@  from __future__ import annotations -from grok_detect import detect_cursor, HOST_APP+from grok_detect import detect_host, HOST_APP  try:     from handlers import send as send_mod except Exception:  # pragma: no cover - circular-import guard at import time     send_mod = None +try:+    from handlers.webhook import load_webhook, url_host+except Exception:+    load_webhook = None+    url_host = None+  def handle_readiness(_args: dict) -> dict:-    """Read-only: is Cursor (Grok Bot's host app) present / running?+    """Read-only: is Grok Bot present / running?      Never blocks on a download. Bridge detects host apps; it does not auto-install-    them. If Cursor is missing, OFFER grok_open — do not send the user to a download page.+    them. If Grok Bot is missing, OFFER grok_open — do not send the user to a download page.+    Cursor is last-resort fallback only if Grok Bot.exe is missing.     """-    info = detect_cursor()+    info = detect_host()     installed = bool(info.get("hostAppInstalled"))     running = bool(info.get("running"))+    fallback = info.get("hostAppFallback")     ready = installed  # installed is enough; grok_open launches if not running+    if installed and not fallback:+        state = (+            "Grok Bot is installed"+            + (" and running." if running else " but not running — grok_open launches it in the background.")+        )+    elif installed and fallback:+        state = (+            "Grok Bot.exe is missing; Cursor is a last-resort fallback host"+            + (" and is running." if running else " (not running) — grok_open launches Cursor in the background. Prefer Grok Bot.")+        )+    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."+        )     out = {         "success": True,         "ready": ready,         "hostApp": HOST_APP,         "hostAppInstalled": installed,         "hostAppRunning": running,+        "hostAppFallback": fallback,         "layers": {             "hostAppInstalled": installed,             "hostAppRunning": running,+            "hostAppFallback": fallback,         },         "paths": info.get("paths") or [],         "windowsHint": info.get("windowsHint"),-        "_hint": (-            "Read-only host-app detection (no download, no launch). "-            + (-                "Cursor is installed"-                + (" and running." if running else " but not running — grok_open launches it in the background.")-                if installed-                else (-                    "Cursor isn't installed — want me to open it if present, or I can try winget? "-                    "Call grok_open (detect + ab launch). Do not send the user to download Cursor themselves."-                )-            )-        ),+        "_hint": "Read-only host-app detection (no download, no launch). " + state,     }     if not installed:         out["errorCode"] = "host_app_not_installed"-        out["_next"] = "Offer grok_open to the user; on yes, call grok_open {}. Do not winget without a dedicated verb."+        out["_next"] = "Offer grok_open to the user; on yes, call grok_open {}. Do not send the user to a download page."     return out   def handle_prewarm(args: dict) -> dict:     """Cheap re-detect. {wait:true} just re-detects; there is nothing to download."""-    info = detect_cursor()+    info = detect_host()     waited = bool(args.get("wait"))     return {         "success": True,         "prewarmed": True,         "waited": waited,+        "hostApp": HOST_APP,         "hostAppInstalled": bool(info.get("hostAppInstalled")),         "hostAppRunning": bool(info.get("running")),+        "hostAppFallback": info.get("hostAppFallback"),         "paths": info.get("paths") or [],         "_hint": (-            "grok_prewarm is a no-op re-detect — Cursor is a host app, not a runtime asset. "+            "grok_prewarm is a no-op re-detect — Grok Bot is a host app, not a runtime asset. "             "Nothing was downloaded. Use grok_readiness for the layered probe, grok_open to launch."         ),     }   def handle_status(_args: dict) -> dict:-    """Detect + last grok_send + inbox path."""-    info = detect_cursor()+    """Detect + last grok_send + inbox path + webhook bound? (never the key)."""+    info = detect_host()     last = None     inbox = None     if send_mod is None:@@ -84,32 +101,43 @@ def handle_status(_args: dict) -> dict:     if _send is not None:         last = _send.last_send()         inbox = str(_send.inbox_path())+    webhook_bound = False+    webhook_host = None+    if load_webhook is not None:+        cfg = load_webhook()+        webhook_bound = bool(cfg.get("url"))+        webhook_host = url_host(cfg["url"]) if webhook_bound else None     return {         "success": True,         "hostApp": HOST_APP,         "hostAppInstalled": bool(info.get("hostAppInstalled")),         "hostAppRunning": bool(info.get("running")),+        "hostAppFallback": info.get("hostAppFallback"),         "paths": info.get("paths") or [],         "windowsHint": info.get("windowsHint"),         "lastSend": last,         "inboxPath": inbox,+        "webhookBound": webhook_bound,+        "webhookHost": webhook_host,         "_hint": (             "Status is detect + last grok_send + the durable inbox path "-            "(~/.adom/grok-inbox.jsonl). For the verb catalog call grok_describe; "-            "to queue a message call grok_send {\"message\":\"...\"}."+            "(~/.adom/grok-inbox.jsonl) + whether ~/.adom/grok-webhook.json has a url "+            "(host only; the sender key is never returned). "+            "Catalog: grok_describe. Message: grok_send {\"message\":\"...\"}."         ),     }   def handle_uninstall(_args: dict) -> dict:-    """Tell the caller to bridge_uninstall {name:grok}. Do not kill Cursor."""+    """Tell the caller to bridge_uninstall {name:grok}. Do not kill Grok Bot or Cursor."""     return {         "success": True,         "uninstalled": False,         "killedHostApp": False,+        "grokBotLeftRunning": True,         "cursorLeftRunning": True,         "_hint": (-            "grok_uninstall does NOT kill Cursor and does not delete the Grok Bot window. "+            "grok_uninstall does NOT kill Grok Bot.exe or Cursor and does not delete the window. "             "Ask Adom Bridge to drop this runtime with: "             "adom-bridge bridge_uninstall '{\"name\":\"grok\"}'. "             "Container skills come off with ./uninstall.sh (or adom-wiki pkg uninstall adom/grok-bridge)."
handlers/send.py+4−4
@@ -72,7 +72,7 @@ def handle_send(args: dict) -> dict:      # Look for a chat window so we can HINT that typing is opt-in — never type by default.     windows = caller_identity.ad_callback(-        "desktop_list_windows", {"titleContains": "Grok"}, on_behalf=True,+        "desktop_list_windows", {"titleContains": "Grok Bot"}, on_behalf=True,     )     found_chat = False     if isinstance(windows, dict):@@ -96,18 +96,18 @@ def handle_send(args: dict) -> dict:     hint_parts = [         "Queued via notify_user toast + durable inbox "         f"({path or '~/.adom/grok-inbox.jsonl'}).",-        "via=notify+inbox — this is NOT proof the Cursor chat widget received a keystroke.",+        "via=notify+inbox — this is NOT proof the Grok Bot chat widget received a keystroke.",     ]     if found_chat:         hint_parts.append(-            "A Grok/Cursor chat window is open. Desktop typing is OPT-IN "+            "A Grok Bot chat window is open. Desktop typing is OPT-IN "             "(don't steal the keyboard). Pass typeIntoChat:true only if the user asked "             "you to type; default is toast + inbox the Anywhere character can poll."         )     else:         hint_parts.append(             "No Grok-titled window seen. The inbox still got the line; grok_open if you "-            "need the Cursor UI."+            "need the Grok Bot UI."         )     if args.get("typeIntoChat"):         hint_parts.append(
handlers/webhook.pyadded+572
@@ -0,0 +1,572 @@+"""Grok Bridge webhook bind / status / fire / ensure.++Stores {url, key, header, boundAt} in ~/.adom/grok-webhook.json+(Windows: %USERPROFILE%\\.adom\\). The HTTP server runs ON the desktop, so this+is local FS — never pull_file.++Never log the key. Never put the key in _hint, logs, wiki, or git.+Auth on fire is Authorization: Bearer <key> — a JSON body field named key is WRONG.++grok_webhook_ensure reads UIA Edit controls on the Grok Bot routine pane+(names: "Webhook URL", "Webhook key", "Authorization header"). No OCR.+"""++from __future__ import annotations++import json+import socket+import time+import urllib.error+import urllib.request+from datetime import datetime, timezone+from pathlib import Path+from urllib.parse import urlparse++from handlers.windows import _cb, _callback_ok, grok_hwnd++WEBHOOK_FILENAME = "grok-webhook.json"+SOURCE = "grok-bridge"+DEFAULT_HEADER = "Authorization: Bearer"++# Proven UIA names on the Grok Bot routine pane. Do not invent others.+OPEN_ROUTINE_BUTTON = "Open routine Grok Bridge webhook"+TRIGGER_NAME = "When a webhook fires"+EDIT_URL = "Webhook URL"+EDIT_KEY = "Webhook key"+EDIT_AUTH = "Authorization header"+PROMPT_NAME = "Prompt"+++def webhook_path() -> Path:+    return Path.home() / ".adom" / WEBHOOK_FILENAME+++def _now_iso() -> str:+    return datetime.now(timezone.utc).isoformat()+++def load_webhook() -> dict:+    """Return {url, key, header, boundAt} internally. Missing file → {}."""+    path = webhook_path()+    try:+        raw = path.read_text(encoding="utf-8")+    except OSError:+        return {}+    try:+        data = json.loads(raw)+    except json.JSONDecodeError:+        return {}+    if not isinstance(data, dict):+        return {}+    url = str(data.get("url") or "").strip()+    key = str(data.get("key") or "").strip()+    header = str(data.get("header") or "").strip() or DEFAULT_HEADER+    bound_at = str(data.get("boundAt") or "")+    out = {}+    if url:+        out["url"] = url+    if key:+        out["key"] = key+    if header:+        out["header"] = header+    if bound_at:+        out["boundAt"] = bound_at+    return out+++def url_host(url: str) -> str | None:+    try:+        host = urlparse(url).hostname+        return host or None+    except Exception:+        return None+++def _public_bind(cfg: dict) -> dict:+    url = cfg.get("url") or ""+    return {+        "bound": bool(url),+        "urlHost": url_host(url) if url else None,+        "path": str(webhook_path()),+        "boundAt": cfg.get("boundAt"),+        "authHeader": "Authorization",+    }+++def write_webhook(url: str, key: str, header: str = DEFAULT_HEADER) -> dict:+    """Write the bind file. Returns public bind dict or error dict. Never returns the key."""+    path = webhook_path()+    try:+        path.parent.mkdir(parents=True, exist_ok=True)+        payload = {+            "url": url,+            "key": key,+            "header": header or DEFAULT_HEADER,+            "boundAt": _now_iso(),+        }+        tmp = path.with_suffix(".json.tmp")+        tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")+        tmp.replace(path)+    except OSError as e:+        return {+            "success": False,+            "errorCode": "bind_write_failed",+            "bound": False,+            "error": f"Could not write {path}: {e}",+            "_hint": f"Failed to write {path}. Check that ~/.adom is writable. The sender key was not logged.",+            "_next": "Retry grok_webhook_bind or grok_webhook_ensure",+        }+    pub = _public_bind(payload)+    return {+        "success": True,+        "bound": True,+        **pub,+    }+++def handle_webhook_status(_args: dict) -> dict:+    cfg = load_webhook()+    pub = _public_bind(cfg)+    return {+        "success": True,+        **pub,+        "_hint": (+            f"Webhook bound to host {pub['urlHost']} (key not shown). Fire with grok_webhook_fire / grok_bot_list."+            if pub["bound"] else+            "No webhook bound. Prefer grok_webhook_ensure {} (reads the routine pane via UIA). "+            "Or grok_webhook_bind {url, key} with values from the Grok Bridge webhook routine panel."+        ),+    }+++def handle_webhook_bind(args: dict) -> dict:+    url = str(args.get("url") or "").strip()+    key = str(args.get("key") or args.get("senderKey") or "").strip()+    header = str(args.get("header") or DEFAULT_HEADER).strip() or DEFAULT_HEADER+    if not url:+        return {+            "success": False,+            "errorCode": "url_required",+            "bound": False,+            "_hint": (+                "grok_webhook_bind needs {url, key}. Prefer grok_webhook_ensure {} to read them "+                "from the routine pane. Or copy both from the panel named \"Grok Bridge webhook\"."+            ),+            "_next": "grok_webhook_ensure {} or grok_webhook_bind {\"url\":\"https://...\",\"key\":\"...\"}",+        }+    parsed = urlparse(url)+    if parsed.scheme != "https" or not parsed.netloc:+        return {+            "success": False,+            "errorCode": "url_must_be_https",+            "bound": False,+            "_hint": "url must be https (https host required).",+            "_next": "grok_webhook_ensure {} or grok_webhook_bind {\"url\":\"https://...\",\"key\":\"...\"}",+        }+    if not key:+        return {+            "success": False,+            "errorCode": "key_required",+            "bound": False,+            "_hint": (+                "key is required. Prefer grok_webhook_ensure {}. The key is stored locally "+                "and is never logged or returned."+            ),+            "_next": "grok_webhook_ensure {}",+        }++    written = write_webhook(url, key, header)+    if not written.get("success"):+        return written+    written["_hint"] = (+        f"Bound Grok Bridge webhook to host {written.get('urlHost')} in {written.get('path')}. "+        "The sender key is stored locally and is never logged or returned. "+        "POSTs use Authorization: Bearer. Next: grok_bot_create / grok_channel_create / grok_bot_list."+    )+    return written+++def webhook_not_bound_error(verb: str) -> dict:+    return {+        "success": False,+        "errorCode": "webhook_not_bound",+        "bound": False,+        "error": "Grok Bridge webhook is not bound.",+        "_hint": (+            f"{verb} needs a bound webhook. Call grok_webhook_ensure {{}} to read the "+            "Grok Bridge webhook routine pane (UIA Edit names Webhook URL / Webhook key), "+            "or grok_webhook_bind {{url, key}} from that panel."+        ),+        "_next": "grok_webhook_ensure {}",+    }+++def handle_webhook_fire(args: dict) -> dict:+    event = str(args.get("event") or "").strip()+    if not event:+        return {+            "success": False,+            "errorCode": "event_required",+            "error": "grok_webhook_fire needs {event}.",+            "_hint": (+                "Pass event (and optional payload). Bound events: create_bot, create_channel, "+                "list_bots — or any event the Grok Bridge webhook routine accepts."+            ),+            "_next": "grok_webhook_fire {\"event\":\"list_bots\"}",+        }+    cfg = load_webhook()+    url = cfg.get("url") or ""+    if not url:+        return webhook_not_bound_error("grok_webhook_fire")++    payload = args.get("payload")+    if payload is None:+        payload = {}+    try:+        hostname = socket.gethostname()+    except Exception:+        hostname = None+    body = {+        "event": event,+        "payload": payload,+        "source": SOURCE,+    }+    if hostname:+        body["hostname"] = hostname+        body["desktop"] = hostname++    data = json.dumps(body).encode("utf-8")+    headers = {+        "Content-Type": "application/json",+        "Accept": "application/json",+        "User-Agent": f"{SOURCE}/1.1.0",+    }+    key = cfg.get("key") or ""+    if key:+        # Proven card: Authorization: Bearer <key>. Never a JSON field named key.+        headers["Authorization"] = f"Bearer {key}"++    status = None+    resp_text = ""+    try:+        req = urllib.request.Request(url, data=data, headers=headers, method="POST")+        with urllib.request.urlopen(req, timeout=30) as resp:+            status = getattr(resp, "status", None) or resp.getcode()+            resp_text = (resp.read() or b"").decode("utf-8", errors="replace")+    except urllib.error.HTTPError as e:+        status = e.code+        try:+            resp_text = (e.read() or b"").decode("utf-8", errors="replace")+        except Exception:+            resp_text = str(e)+        return {+            "success": False,+            "errorCode": "webhook_http_error",+            "event": event,+            "httpStatus": status,+            "urlHost": url_host(url),+            "bodyPreview": resp_text[:500],+            "_hint": (+                f"Webhook POST to {url_host(url)} returned HTTP {status} for event={event}. "+                "Auth was header Authorization: Bearer (never a JSON body field named key). "+                "Re-run grok_webhook_ensure if the routine URL/key rotated."+            ),+            "_next": "grok_webhook_status {} then retry, or grok_webhook_ensure {}",+        }+    except Exception as e:+        return {+            "success": False,+            "errorCode": "webhook_fire_failed",+            "event": event,+            "urlHost": url_host(url),+            "error": str(e),+            "_hint": (+                f"Could not POST event={event} to the bound webhook host {url_host(url)}. "+                "Check grok_webhook_status. The sender key was not logged."+            ),+            "_next": "grok_webhook_status {} then grok_webhook_fire {\"event\":\"...\"}",+        }++    parsed = None+    if resp_text:+        try:+            parsed = json.loads(resp_text)+        except json.JSONDecodeError:+            parsed = None++    out = {+        "success": True,+        "fired": True,+        "event": event,+        "httpStatus": status,+        "urlHost": url_host(url),+        "hostname": hostname,+    }+    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+    elif resp_text:+        out["bodyPreview"] = resp_text[:500]+    out["_hint"] = (+        f"POSTed event={event} to webhook host {url_host(url)} (HTTP {status}). "+        "Auth was header Authorization: Bearer, never a body field named key."+    )+    return out+++# ── grok_webhook_ensure: UIA Edit read of the routine pane (no OCR) ──────────+++def _as_controls(blob) -> list[dict]:+    if isinstance(blob, list):+        return [c for c in blob if isinstance(c, dict)]+    if not isinstance(blob, dict):+        return []+    for key in ("controls", "matches", "items", "result", "nodes"):+        val = blob.get(key)+        if isinstance(val, list):+            return [c for c in val if isinstance(c, dict)]+    data = blob.get("data")+    if isinstance(data, dict):+        return _as_controls(data)+    if isinstance(data, list):+        return [c for c in data if isinstance(c, dict)]+    best = blob.get("best")+    if isinstance(best, dict):+        return [best]+    return []+++def _ctrl_name(c: dict) -> str:+    return str(c.get("name") or c.get("Name") or "")+++def _ctrl_value(c: dict) -> str:+    for k in ("value", "Value", "currentValue", "text"):+        v = c.get(k)+        if v is not None and str(v).strip():+            return str(v).strip()+    return ""+++def _ctrl_center(c: dict):+    rect = c.get("rect") or {}+    if not isinstance(rect, dict):+        rect = {}+    cx = rect.get("centerX", c.get("centerX"))+    cy = rect.get("centerY", c.get("centerY"))+    if cx is not None and cy is not None:+        try:+            return int(cx), int(cy)+        except (TypeError, ValueError):+            return None+    x = rect.get("x", rect.get("left"))+    y = rect.get("y", rect.get("top"))+    w = rect.get("w", rect.get("width"))+    h = rect.get("h", rect.get("height"))+    try:+        if x is None or y is None or w is None or h is None:+            return None+        return int(x + w / 2), int(y + h / 2)+    except (TypeError, ValueError):+        return None+++def _ui_get_value(hwnd: int, name: str) -> str:+    blob = _cb("desktop_ui_get", {"hwnd": hwnd, "name": name})+    if not isinstance(blob, dict):+        return ""+    inner = blob.get("data") if isinstance(blob.get("data"), dict) else blob+    if not isinstance(inner, dict):+        return ""+    for k in ("value", "Value", "text", "currentValue"):+        v = inner.get(k)+        if v is not None and str(v).strip():+            return str(v).strip()+    best = inner.get("best")+    if isinstance(best, dict):+        return str(best.get("value") or "").strip()+    return ""+++def _read_webhook_edits(hwnd: int) -> dict:+    """Read Webhook URL / Webhook key / Authorization header UIA Edits. Never logs values."""+    url = key = header_val = ""+    blob = _cb("desktop_find_controls", {"hwnd": hwnd, "role": "Edit"})+    for c in _as_controls(blob):+        n = _ctrl_name(c)+        v = _ctrl_value(c)+        if n == EDIT_URL and v:+            url = v+        elif n == EDIT_KEY and v:+            key = v+        elif n == EDIT_AUTH and v:+            header_val = v+    if not url:+        url = _ui_get_value(hwnd, EDIT_URL)+    if not key:+        key = _ui_get_value(hwnd, EDIT_KEY)+    if not header_val:+        header_val = _ui_get_value(hwnd, EDIT_AUTH)+    if header_val and not key:+        parts = header_val.split()+        if parts and parts[-1].startswith("crsr_"):+            key = parts[-1]+    if url.startswith("https://") and key:+        return {"url": url, "key": key, "header": DEFAULT_HEADER}+    return {}+++def _clear_prompt_if_needed(hwnd: int, touched: bool) -> None:+    if not touched:+        return+    _cb("desktop_ui_set", {"hwnd": hwnd, "name": PROMPT_NAME, "text": ""})+++def _open_routine_pane(hwnd: int) -> bool:+    res = _cb("desktop_ui_click", {+        "hwnd": hwnd,+        "name": OPEN_ROUTINE_BUTTON,+        "role": "Button",+    })+    return _callback_ok(res)+++def _expand_webhook_trigger(hwnd: int) -> str | None:+    """Prefer invokable Button; ListItem Invoke is false — click its center as fallback."""+    btn = _cb("desktop_ui_click", {+        "hwnd": hwnd,+        "name": TRIGGER_NAME,+        "role": "Button",+    })+    if _callback_ok(btn):+        return "ui_click Button"+    found = _cb("desktop_find_control", {+        "hwnd": hwnd,+        "name": TRIGGER_NAME,+        "role": "listitem",+    })+    ctrls = _as_controls(found)+    if not ctrls:+        listed = _cb("desktop_find_controls", {+            "hwnd": hwnd,+            "role": "ListItem",+            "name": TRIGGER_NAME,+        })+        ctrls = _as_controls(listed)+    for c in ctrls:+        if _ctrl_name(c) != TRIGGER_NAME:+            continue+        center = _ctrl_center(c)+        if not center:+            continue+        click = _cb("desktop_click", {+            "x": center[0],+            "y": center[1],+            "hwnd": hwnd,+            "reason": "expand webhook trigger (ListItem Invoke is false)",+        })+        if _callback_ok(click):+            return "desktop_click ListItem center"+    return None+++def handle_webhook_ensure(args: dict) -> dict:+    """Drive Grok Bot UI and bind from UIA Edit 'Webhook URL' / 'Webhook key'. No OCR."""+    force = bool(args.get("force"))+    existing = load_webhook()+    if existing.get("url") and existing.get("key") and not force:+        pub = _public_bind(existing)+        return {+            "success": True,+            "bound": True,+            "alreadyBound": True,+            **pub,+            "_hint": (+                f"Webhook already bound to host {pub['urlHost']} (key not shown). "+                "Pass force:true to re-read the routine pane. Next: grok_bot_list / grok_bot_create."+            ),+        }++    hwnd, win = grok_hwnd(allow_cursor=False)+    if not hwnd:+        hwnd, win = grok_hwnd(allow_cursor=True)+    if not hwnd:+        try:+            from handlers.windows import handle_open+            handle_open({})+            time.sleep(1.5)+        except Exception:+            pass+        hwnd, win = grok_hwnd(allow_cursor=False)+        if not hwnd:+            hwnd, win = grok_hwnd(allow_cursor=True)+    if not hwnd:+        return {+            "success": False,+            "errorCode": "window_not_found",+            "bound": False,+            "_hint": (+                "No Grok Bot window. grok_open {} then grok_webhook_ensure {}. "+                "The in-chat button is named exactly \"Open routine Grok Bridge webhook\". "+                "Settings gear is not the Routines list. Never kill Cursor."+            ),+            "_next": "grok_open {} then grok_webhook_ensure {}",+        }++    steps = []+    prompt_touched = False++    fields = _read_webhook_edits(hwnd)+    if not fields:+        if _open_routine_pane(hwnd):+            steps.append("ui_click Open routine Grok Bridge webhook")+            time.sleep(1.2)+            fields = _read_webhook_edits(hwnd)+        else:+            steps.append("open-routine button not found")++    if not fields:+        how = _expand_webhook_trigger(hwnd)+        if how:+            steps.append(f"expand When a webhook fires via {how}")+            time.sleep(0.8)+            fields = _read_webhook_edits(hwnd)+        else:+            steps.append("webhook trigger not expanded")++    _clear_prompt_if_needed(hwnd, prompt_touched)++    if not fields:+        return {+            "success": False,+            "errorCode": "routine_fields_not_found",+            "bound": False,+            "hwnd": hwnd,+            "steps": steps,+            "needsHuman": True,+            "_hint": (+                "Could not read UIA Edit controls named \"Webhook URL\" and \"Webhook key\". "+                "Click the in-chat button exactly named \"Open routine Grok Bridge webhook\", "+                "expand \"When a webhook fires\" if needed, then retry grok_webhook_ensure. "+                "Settings gear is not the Routines list. Do not type into Prompt "+                "(if Prompt has leftover garbage, clear it — do not submit). "+                "Or grok_webhook_bind {url, key} from that pane."+            ),+            "_next": "Human clicks Open routine Grok Bridge webhook, then grok_webhook_ensure {}",+        }++    written = write_webhook(fields["url"], fields["key"], fields.get("header") or DEFAULT_HEADER)+    if not written.get("success"):+        written["steps"] = steps+        written["hwnd"] = hwnd+        return written+    written["hwnd"] = hwnd+    written["steps"] = steps+    written["via"] = "uia-edit"+    written["_hint"] = (+        f"Read UIA Edits Webhook URL / Webhook key from the Grok Bot routine pane "+        f"and bound host {written.get('urlHost')} (key not shown). "+        "POSTs use Authorization: Bearer. Next: grok_bot_list / grok_bot_create / grok_channel_create."+    )+    return written
handlers/windows.py+269−51
@@ -7,7 +7,12 @@ HTTP callback. All driving goes through caller_identity.ad_callback. from __future__ import annotations  import caller_identity-from grok_detect import detect_cursor, HOST_APP+from grok_detect import (+    HOST_APP,+    detect_host,+    process_name_filter,+    window_title_needles,+)   def _cb(command: str, args: dict | None = None, timeout: int = 30) -> dict:@@ -26,58 +31,236 @@ def _callback_ok(res: dict | None) -> bool:     return True  -def handle_list_windows(args: dict) -> dict:-    title = (args.get("titleContains") or "Cursor").strip() or "Cursor"-    wins = _cb("desktop_list_windows", {"titleContains": title})-    grok_wins = _cb("desktop_list_windows", {"titleContains": "Grok"})-    procs = _cb("process_list", {"nameFilter": "Cursor"})--    windows = []-    for blob in (wins, grok_wins):-        if not isinstance(blob, dict):+def _as_windows(blob) -> list[dict]:+    if not isinstance(blob, dict):+        if isinstance(blob, list):+            return [w for w in blob if isinstance(w, dict)]+        return []+    for key in ("windows", "items", "result"):+        val = blob.get(key)+        if isinstance(val, list):+            return [w for w in val if isinstance(w, dict)]+    data = blob.get("data")+    if isinstance(data, dict):+        return _as_windows(data)+    if isinstance(data, list):+        return [w for w in data if isinstance(w, dict)]+    if blob.get("hwnd") or blob.get("title") or blob.get("handle"):+        return [blob]+    return []+++def _as_processes(blob) -> list[dict]:+    if not isinstance(blob, dict):+        return []+    for key in ("processes", "items", "result"):+        val = blob.get(key)+        if isinstance(val, list):+            return [p for p in val if isinstance(p, dict)]+    data = blob.get("data")+    if isinstance(data, dict):+        return _as_processes(data)+    return []+++def _hwnd_of(win: dict | None):+    if not isinstance(win, dict):+        return None+    h = win.get("hwnd") or win.get("handle")+    if h is None:+        return None+    try:+        return int(h)+    except (TypeError, ValueError):+        return None+++def _title_of(win: dict | None) -> str:+    if not isinstance(win, dict):+        return ""+    return str(win.get("title") or win.get("name") or "")+++def _title_rank(title: str) -> int:+    tl = (title or "").lower()+    if "grok bot" in tl:+        return 0+    if "grok" in tl:+        return 1+    if "cursor" in tl:+        return 2+    return 9+++def _dedupe_windows(windows: list[dict]) -> list[dict]:+    seen = set()+    out = []+    for w in windows:+        h = _hwnd_of(w)+        key = h if h is not None else (id(w), _title_of(w))+        if key in seen:             continue-        for key in ("windows", "items", "result"):-            val = blob.get(key)-            if isinstance(val, list):-                windows.extend(val)-                break-        else:-            if blob.get("success") and blob.get("title"):-                windows.append(blob)--    processes = []-    if isinstance(procs, dict):-        for key in ("processes", "items", "result"):-            val = procs.get(key)-            if isinstance(val, list):-                processes = val-                break--    ab_up = _callback_ok(wins) or _callback_ok(procs)+        seen.add(key)+        out.append(w)+    return out+++def collect_grok_windows(title_contains: str | None = None) -> tuple[list[dict], bool]:+    """List Grok Bot windows. titleContains: Grok Bot first, then Grok (then Cursor if fallback)."""+    info = detect_host()+    needles = [title_contains] if (title_contains and str(title_contains).strip()) else window_title_needles(info)+    windows: list[dict] = []+    ab_up = False+    for needle in needles:+        needle = (needle or "").strip()+        if not needle:+            continue+        blob = _cb("desktop_list_windows", {"titleContains": needle})+        ab_up = ab_up or _callback_ok(blob)+        windows.extend(_as_windows(blob))+        found = _cb("desktop_find_window", {"titleContains": needle})+        ab_up = ab_up or _callback_ok(found)+        windows.extend(_as_windows(found))+    windows.sort(key=lambda w: _title_rank(_title_of(w)))+    return _dedupe_windows(windows), ab_up+++def pick_grok_window(windows: list[dict] | None = None, *, allow_cursor: bool = True) -> dict | None:+    if windows is None:+        windows, _ = collect_grok_windows()+    ranked = []+    for w in windows or []:+        rank = _title_rank(_title_of(w))+        if rank == 2 and not allow_cursor:+            continue+        if rank >= 9:+            continue+        ranked.append((rank, w))+    if not ranked:+        return None+    ranked.sort(key=lambda x: x[0])+    return ranked[0][1]+++def grok_hwnd(*, allow_cursor: bool = True) -> tuple[int | None, dict | None]:+    win = pick_grok_window(allow_cursor=allow_cursor)+    return _hwnd_of(win), win+++def window_rect(win: dict | None) -> dict:+    """Physical px {x,y,w,h} from a desktop_list_windows entry (ab physical px)."""+    if not isinstance(win, dict):+        return {}+    rect = win.get("rect") or win.get("windowRect") or win.get("bounds") or {}+    if not isinstance(rect, dict):+        rect = {}+    x = rect.get("x", rect.get("left", win.get("x")))+    y = rect.get("y", rect.get("top", win.get("y")))+    w = rect.get("w", rect.get("width", win.get("width")))+    h = rect.get("h", rect.get("height", win.get("height")))+    out = {}+    for k, v in (("x", x), ("y", y), ("w", w), ("h", h)):+        if v is None:+            continue+        try:+            out[k] = int(v)+        except (TypeError, ValueError):+            continue+    return out+++def window_is_maximized(win: dict | None) -> bool:+    if not isinstance(win, dict):+        return False+    if win.get("maximized") or win.get("isMaximized"):+        return True+    state = str(win.get("state") or win.get("showState") or win.get("showCmd") or "").lower()+    return state in ("maximized", "maximize", "zoomed")+++def window_is_minimized(win: dict | None) -> bool:+    if not isinstance(win, dict):+        return False+    if win.get("minimized") or win.get("isMinimized"):+        return True+    state = str(win.get("state") or win.get("showState") or win.get("showCmd") or "").lower()+    if state in ("minimized", "minimize", "hidden", "hide"):+        return True+    rect = window_rect(win)+    return rect.get("x", 0) <= -30000 or rect.get("y", 0) <= -30000+++def launch_host(argv: list | None = None) -> dict:+    """Launch Grok Bot.exe (background). Optional argv (e.g. a grokbot:// deep link).++    desktop_open_url refuses grokbot:// (http/https/mailto/ftp only), so deep links+    are passed as arguments to the host exe instead.+    """+    info = detect_host()+    exe = info.get("exe")+    launch_args = {"background": True, "noActivate": True, "detached": True}+    if argv:+        launch_args["args"] = list(argv)+    if exe:+        launch_args["path"] = exe+    else:+        launch_args["name"] = HOST_APP+    return _cb("launch", launch_args)+++def handle_list_windows(args: dict) -> dict:+    info = detect_host()+    title = (args.get("titleContains") or "").strip()+    windows, listed_ok = collect_grok_windows(title or None)+    filt = process_name_filter(info)+    procs = _cb("process_list", {"nameFilter": filt})+    grok_procs = _cb("process_list", {"nameFilter": "Grok Bot"}) if filt != "Grok Bot" else None+    processes = _as_processes(procs)+    if grok_procs:+        extra = _as_processes(grok_procs)+        seen = {p.get("pid") or p.get("id") for p in processes}+        for p in extra:+            key = p.get("pid") or p.get("id")+            if key not in seen:+                processes.append(p)+                seen.add(key)++    ab_up = listed_ok or _callback_ok(procs)+    fallback = info.get("hostAppFallback")+    empty_hint = (+        "Empty? Grok Bot likely isn't running — grok_open launches it in the background."+        if not windows else+        "To capture one, grok_screenshot {}. To queue a message, grok_send. "+        "App control: grok_close / grok_resize / grok_record_start."+    )+    fallback_hint = ""+    if fallback:+        fallback_hint = (+            " Grok Bot.exe was not found; Cursor is a last-resort fallback host. Never kill Cursor."+        )     return {         "success": True,         "hostApp": HOST_APP,+        "hostAppFallback": fallback,         "windows": windows,         "processes": processes,         "count": len(windows),         "abCallbackReached": ab_up,         "windowEtiquette": "background",         "_hint": (-            "Listed via ab desktop_list_windows (titleContains Cursor / Grok) and "-            "process_list nameFilter Cursor. Read-only — no focus steal. "-            + (-                "Empty? Cursor likely isn't running — grok_open launches it in the background."-                if not windows-                else "To capture one, grok_screenshot {}. To queue a message, grok_send."-            )-            + ("" if ab_up else " ab loopback (ADOM_DIRECT_API_URL) was not reachable — this process can still detect Cursor on disk.")+            "Listed via ab desktop_list_windows (titleContains Grok Bot first, then Grok) and "+            f"process_list nameFilter {filt!r}. Read-only — no focus steal. "+            + empty_hint+            + fallback_hint+            + ("" if ab_up else " ab loopback (ADOM_DIRECT_API_URL) was not reachable — this process can still detect Grok Bot on disk.")         ),     }   def handle_screenshot(args: dict) -> dict:     title = (args.get("titleContains") or "").strip()-    needles = [title] if title else ["Cursor", "Grok"]+    info = detect_host()+    needles = [title] if title else window_title_needles(info)     last = None     for needle in needles:         last = _cb("desktop_screenshot_window", {"titleContains": needle})@@ -94,26 +277,46 @@ def handle_screenshot(args: dict) -> dict:                 + (shot.get("_hint") or "")             )             return shot+    hwnd, win = grok_hwnd(allow_cursor=bool(info.get("hostAppFallback")))+    if hwnd:+        last = _cb("desktop_screenshot_window", {"hwnd": hwnd})+        if _callback_ok(last) and not last.get("error"):+            shot = dict(last)+            shot.setdefault("success", True)+            shot.setdefault("hwnd", hwnd)+            shot.setdefault("windowEtiquette", "background")+            shot.setdefault("openedInBackground", True)+            shot["_hint"] = (+                f"Screenshot via ab desktop_screenshot_window hwnd={hwnd}. "+                "Window was NOT raised. PNG is on the desktop; pull it with the CLI."+            )+            return shot     return {         "success": False,         "errorCode": "window_not_found",-        "error": "No Cursor / Grok Bot window to screenshot.",+        "error": "No Grok Bot window to screenshot.",         "lastCallback": last,+        "hostAppFallback": info.get("hostAppFallback"),         "_hint": (-            "desktop_screenshot_window found no titleContains Cursor or Grok. "-            "Call grok_open to launch Cursor in the background, then grok_list_windows, then retry. "+            "desktop_screenshot_window found no titleContains Grok Bot or Grok. "+            "Call grok_open to launch Grok Bot in the background, then grok_list_windows, then retry. "             "Do not steal focus to take the shot."+            + (+                " Cursor is only a last-resort fallback if Grok Bot.exe is missing."+                if info.get("hostAppFallback") else ""+            )         ),         "_next": "grok_open {} then grok_list_windows {} then grok_screenshot {}",     }   def handle_open(args: dict) -> dict:-    """Launch Cursor if installed; if running, list windows. Background by default."""-    info = detect_cursor()+    """Launch Grok Bot if installed; if running, list windows. Background by default."""+    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() @@ -132,22 +335,30 @@ def handle_open(args: dict) -> dict:         }      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,+            "hostApp": HOST_APP,             "hostAppInstalled": installed,             "hostAppRunning": True,+            "hostAppFallback": fallback,             "exe": exe,             "windows": (listed or {}).get("windows") or [],             "openedInBackground": True,             "windowEtiquette": "foreground" if (want_fg and reason) else "background",             "_hint": (-                "Cursor (Grok Bot host) is already running — nothing launched, focus not stolen. "+                "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."+                "foreground:true AND foregroundReason. To queue a message: grok_send. "+                "App control: grok_close / grok_resize / grok_screenshot."+                + fallback_note             ),             "_foregroundHint": (                 "Window is in the BACKGROUND. To show it: grok_open "@@ -156,11 +367,12 @@ def handle_open(args: dict) -> dict:         }      # Not running: try ab launch / desktop_open. Never shell_execute.+    launch_name = fallback if fallback else HOST_APP     launch_args = {"background": True, "noActivate": True}     if exe:         launch_args["path"] = exe     else:-        launch_args["name"] = HOST_APP+        launch_args["name"] = launch_name      launched = _cb("launch", launch_args)     via = "launch"@@ -169,7 +381,7 @@ def handle_open(args: dict) -> dict:         if exe:             open_args["path"] = exe         else:-            open_args["target"] = HOST_APP+            open_args["target"] = launch_name         launched = _cb("desktop_open", open_args)         via = "desktop_open" @@ -181,17 +393,20 @@ def handle_open(args: dict) -> dict:             "hostApp": HOST_APP,             "hostAppInstalled": False,             "hostAppRunning": False,+            "hostAppFallback": fallback,             "launched": False,             "via": via,             "callback": launched,             "openedInBackground": True,             "windowEtiquette": "background",             "_hint": (-                "Cursor isn't installed — want me to open it if present, or I can try winget? "-                "This verb already tried ab launch / desktop_open. It does NOT run winget "-                "(no dedicated grok_winget verb yet). Do not send the user to download Cursor themselves."+                "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). "+                "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": "If the user wants winget, ask them, then use a dedicated install verb (not this one). Retry grok_open after Cursor exists.",+            "_next": "Retry grok_open after Grok Bot exists. Cursor fallback only if Grok Bot.exe is missing.",         }      return {@@ -199,14 +414,17 @@ def handle_open(args: dict) -> dict:         "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 "+            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. "
package.json+22−6
@@ -1,8 +1,8 @@ {   "slug": "grok-bridge",-  "version": "1.0.0",+  "version": "1.1.0",   "type": "app",-  "description": "Claude, Codex, and Hydrogen talk to Grok Bot on Windows or Mac. Grok Bot gets Adom Anywhere so they work as one.",+  "description": "Claude, Codex, and Hydrogen talk to Grok Bot on Windows or Mac. Grok Bot gets Adom Anywhere so they work as one. grok_* can open/close/resize/screenshot/record the Grok Bot app and create bots/channels via webhook.",   "keywords": [     "grok",     "grok-bot",@@ -39,7 +39,7 @@     "adom/adom-bridge": "^2.0.0"   },   "author": "John Lauer <[email protected]>",-  "discovery_pitch": "Two-way street: cloud AIs (Claude, Codex, Hydrogen) drive the Grok Bot app on your desktop via grok_*; Grok Bot gets Adom Anywhere so it knows how to use Adom.",+  "discovery_pitch": "Two-way street: cloud AIs (Claude, Codex, Hydrogen) drive the Grok Bot app on your desktop via grok_* (open/close/resize/screenshot/record, webhook bots/channels); Grok Bot gets Adom Anywhere so it knows how to use Adom.",   "discovery_triggers": [     "grok",     "grok bot",@@ -50,16 +50,24 @@     "adom anywhere grok",     "grok_open",     "grok_send",-    "drive grok from adom bridge"+    "drive grok from adom bridge",+    "grok_close",+    "grok_resize",+    "grok_record_start",+    "grok webhook",+    "create grok bot",+    "grok_webhook_bind",+    "sign in grok bot",+    "grok_webhook_ensure"   ],   "sample_prompts": [     {       "label": "Is Grok Bot ready?",-      "prompt": "Call grok_readiness and tell me if Cursor (Grok Bot's host) is installed and running"+      "prompt": "Call grok_readiness and tell me if Grok Bot.exe is installed and running"     },     {       "label": "Open Grok Bot",-      "prompt": "Open Grok Bot in the background (grok_open) and screenshot it"+      "prompt": "Open Grok Bot in the background (grok_open), screenshot it, then resize it"     },     {       "label": "Send a message",@@ -68,6 +76,14 @@     {       "label": "Anywhere from Grok",       "prompt": "From Grok Bot, ping ConfRoomROG and AdomLapper with --ai-thread Grok Bot"+    },+    {+      "label": "Bind Grok webhook",+      "prompt": "Call grok_webhook_ensure so grok-bridge scrapes the Grok Bridge webhook routine pane itself (do not ask me to copy the URL or key), then fire event ping"+    },+    {+      "label": "Record Grok Bot",+      "prompt": "Start a background recording of the Grok Bot window for a demo, then stop it"     }   ],   "hero": {
server.py+47−4
@@ -2,7 +2,7 @@ """Adom Grok Bot Bridge Server — localhost HTTP server.  Adom Bridge (ab) spawns this on the Windows PC. There is no public Grok HTTP API;-we talk TO Grok Bot / Cursor the way kicad talks to KiCad: detect the host app,+we talk TO Grok Bot the way kicad talks to KiCad: detect the host app, drive it via ab desktop_* callbacks. Never modifies ab-core.  Usage:@@ -22,10 +22,22 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent))  import caller_identity-from grok_detect import detect_cursor, status_chip+from grok_detect import detect_host, status_chip+from handlers.app import (+    handle_close,+    handle_login,+    handle_logout,+    handle_plugin_install,+    handle_record_start,+    handle_record_status,+    handle_record_stop,+    handle_resize,+)+from handlers.bots import handle_bot_create, handle_bot_list, handle_channel_create from handlers.describe import handle_describe 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.windows import handle_list_windows, handle_open, handle_screenshot  DEFAULT_PORT = 8769@@ -34,6 +46,7 @@ REPORT_ISSUES = "adom-wiki issue create adom/grok-bridge" DEFAULT_HINT = (     "Call grok_describe for the verb catalog; grok_readiness for host-app state. "     "CLI: adom-bridge grok_* . Always --ai-thread. Background by default. "+    "Host app is Grok Bot.exe. "     f"Bugs: {REPORT_ISSUES}" ) @@ -47,6 +60,21 @@ KNOWN_VERBS = [     "grok_list_windows",     "grok_send",     "grok_uninstall",+    "grok_close",+    "grok_resize",+    "grok_record_start",+    "grok_record_stop",+    "grok_record_status",+    "grok_login",+    "grok_logout",+    "grok_plugin_install",+    "grok_webhook_status",+    "grok_webhook_bind",+    "grok_webhook_ensure",+    "grok_webhook_fire",+    "grok_bot_create",+    "grok_channel_create",+    "grok_bot_list", ]  @@ -70,6 +98,21 @@ COMMAND_HANDLERS = {     "list_windows": handle_list_windows,     "send": handle_send,     "uninstall": handle_uninstall,+    "close": handle_close,+    "resize": handle_resize,+    "record_start": handle_record_start,+    "record_stop": handle_record_stop,+    "record_status": handle_record_status,+    "login": handle_login,+    "logout": handle_logout,+    "plugin_install": handle_plugin_install,+    "webhook_status": handle_webhook_status,+    "webhook_bind": handle_webhook_bind,+    "webhook_ensure": handle_webhook_ensure,+    "webhook_fire": handle_webhook_fire,+    "bot_create": handle_bot_create,+    "channel_create": handle_channel_create,+    "bot_list": handle_bot_list, }  @@ -127,7 +170,7 @@ class GrokBridgeHandler(BaseHTTPRequestHandler):         self.wfile.write(payload)      def _status_payload(self) -> dict:-        info = detect_cursor()+        info = detect_host()         chip = status_chip(info)         return {             "status": "ok",@@ -248,7 +291,7 @@ def main() -> None:               flush=True)         return -    info = detect_cursor()+    info = detect_host()     print(f"[grok-bridge] detect hostAppInstalled={info.get('hostAppInstalled')} "           f"running={info.get('running')} paths={info.get('paths')}",           flush=True)
skills/grok-anywhere/SKILL.md+20−2
@@ -1,7 +1,7 @@ --- name: grok-anywhere user-invocable: true-description: "Grok Bot is an Adom Anywhere character (Claude Desktop / Codex class). This session already has adom-bridge on PATH. Always --ai-thread \"Grok Bot\" and --target when more than one desktop is connected. Prefer ab verbs over Grok ListMachines/Shell so the activity log stays intact. Trigger words: adom anywhere grok, grok bot desktop, drive my pc from grok, adom-bridge from grok bot."+description: "Grok Bot is an Adom Anywhere character (Claude Desktop / Codex class). This session already has adom-bridge on PATH. Always --ai-thread \"Grok Bot\" and --target when more than one desktop is connected. Prefer ab verbs over Grok ListMachines/Shell so the activity log stays intact. Host app is Grok Bot.exe (standalone Electron). Trigger words: adom anywhere grok, grok bot desktop, drive my pc from grok, adom-bridge from grok bot, grok_webhook_ensure." ---  # grok-anywhere — Grok Bot as an Adom Anywhere character@@ -10,6 +10,17 @@ Grok Bot is an **Adom Anywhere** character in the same class as Claude Desktop a  This Grok session **already has `adom-bridge` on PATH** when the CLI is installed. Relay desktops are whatever `adom-bridge targets` returns (John's current pair: ConfRoomROG, AdomLapper — treat those as examples, not the only names). Do not paste the whole anywhere / local-ai map here — load **adom-bridge-anywhere** (and **local-ai** if you need the Hydrogen/loopback picture). +## Host app++You are **Grok Bot**, a standalone Electron app:++- Windows: `C:\Users\john\AppData\Local\Programs\Grok Bot\Grok Bot.exe`+- User data: `%APPDATA%\Grok Bot`+- Window title / process: `Grok Bot` / `Grok Bot.exe`+- macOS: `/Applications/Grok Bot.app`++Cursor is **not** the host. Cloud AIs that drive this window via `grok_*` fall back to Cursor only if `Grok Bot.exe` is missing.+ ## Every call  ```bash@@ -39,4 +50,11 @@ For the full desktop map, load **adom-bridge-anywhere**. For loopback-vs-relay /  ## Talking to Grok Bot (the other direction) -If a *cloud* AI needs to reach **this** Grok session from a Windows ab, that is the **grok** skill (`grok_open` / `grok_send` / `grok_screenshot`) — the spawned runtime on the PC. This anywhere skill is the reverse: Grok Bot driving the PC.+If a *cloud* AI needs to reach **this** Grok session from a Windows ab, that is the **grok** skill — the spawned runtime on the PC:++- App: `grok_open` / `grok_close` / `grok_resize` / `grok_screenshot` / `grok_record_start` / `grok_send`+- Auth (human clicks, no passwords): `grok_login` / `grok_logout` (Sign In with Cursor / Sign Out)+- Plugins: `grok_plugin_install {id}`+- Webhook (routine **Grok Bridge webhook**): `grok_webhook_ensure {}` scrapes the pane (Edit **Webhook URL** / **Webhook key**) and stores `~/.adom/grok-webhook.json` (never log the key). Manual fallback `grok_webhook_bind {url, key}`. Then `grok_bot_create` / `grok_channel_create` / `grok_bot_list`.++This anywhere skill is the reverse: Grok Bot driving the PC.