app
Grok Bot
Public Made by Adomby adom
Grok Bridge lets Adom talk to Grok Bot. Adom's AI environment is for electrical engineers: any provider, any desktop power tool, one place.
← Commit history
Grok Bot.exe host plus app-control verbs and grok_webhook_ensure so the runtime scrapes the webhook pane itself.
17 files changed
+1998−232
BRIDGE_VERSION+1−1CLAUDE.md+28−10README.md+19−5SKILL.md+63−13adom-bridge-grok-manifest.json+4−4bridge.json+40−12grok_detect.py+232−74handlers/app.py+378handlers/bots.py+80handlers/describe.py+166−21handlers/readiness.py+53−25handlers/send.py+4−4handlers/webhook.py+572handlers/windows.py+269−51package.json+22−6server.py+47−4skills/grok-anywhere/SKILL.md+20−2BRIDGE_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.