← Commit history
dashboard/grok_dashboard.py+79−5
@@ -111,7 +111,8 @@ _EXACT_ROUTES = ("/health", "/console", "/events", "/api/targets", _SUFFIX_ROUTES = ("/newbie-panel.js", "/favicon.svg", "/favicon.png",                   "/favicon.ico", "/icon.svg", "/demo.mp4",                   "/updater/status", "/api/viewport",-                  "/ai-threads/run-prompt", "/api/say-send",+                  "/ai-threads/run-prompt", "/ai-threads/run-prompt/status",+                  "/api/say-send",                   "/demo/steps.json") _REGEX_ROUTES = (r"/assets/(.+)$",                  r"^/demo/audio/(.+)$",@@ -182,8 +183,12 @@ def say_send(phrase):     except Exception:         pass -    body = {"new": True, "prompt": phrase, "submit": True,-            "name": "Grok Bot - say it", "tags": ["dock:grok-bridge"]}+    # Wiki #4 (Hydrogen 1.0.213 AI picker): NO `type`, so the human picks the AI in+    # the shell's picker; the app: tag keys its "remember for this app" checkbox.+    # background:true (1.0.219): the answer is an immediate {job, state:"accepted"}+    # and the picker's 5 to 45 s happen detached; the status route narrates them.+    body = {"new": True, "prompt": phrase, "submit": True, "background": True,+            "name": "Grok Bot - say it", "tags": ["dock:grok-bridge", "app:adom/grok-bridge"]}     req = urllib.request.Request(base + "/ai-threads/run-prompt",                                  data=json.dumps(body).encode("utf-8"), method="POST",                                  headers={"Content-Type": "application/json"})@@ -195,6 +200,12 @@ def say_send(phrase):                                     f"Not retried, so no duplicate tab. The phrase was copied instead."}      d = res.get("data") if isinstance(res.get("data"), dict) else res+    if isinstance(d, dict) and d.get("cancelled"):+        return {"ok": False, "cancelled": True,+                "why": "you dismissed the AI picker; nothing was opened or sent. The phrase is still on your clipboard."}+    job = d.get("job") if isinstance(d, dict) else None+    if job:+        return _poll_run_prompt(_get, job, d)     inject = d.get("inject") if isinstance(d.get("inject"), dict) else {}     delivered = (d.get("delivered_to")                  or inject.get("delivered_in_conversation")@@ -214,6 +225,47 @@ def say_send(phrase):             "tid": d.get("tid"), "sid": d.get("sid")}  ++# Hydrogen 1.0.219 run-prompt job states: accepted, picking, picked, opening,+# prefilled | tab_found, submitted, working, needs_you | done; Codex and Kimi end+# at delivered. `terminal:true` ends the job; `human` is the ready toast line.+_SENT_STATES = ("working", "needs_you", "done", "delivered")+_FAIL_STATES = ("cancelled", "rejected", "open_failed", "open_unconfirmed", "tab_not_found",+                "inject_failed", "submit_failed", "failed", "timeout",+                "submitted_unidentified", "submit_unconfirmed", "still_working")+++def _poll_run_prompt(_get, job, d):+    import urllib.parse+    last = str(d.get("state") or "accepted"); human = str(d.get("human") or "")+    picked = None+    deadline = time.monotonic() + 75.0          # the picker alone may hold 45 s+    while time.monotonic() < deadline:+        terminal = False+        try:+            st = _get("/ai-threads/run-prompt/status?job=" + urllib.parse.quote(str(job)), 4)+            sd = st.get("data") if isinstance(st.get("data"), dict) else st+            last = str(sd.get("state") or last); human = str(sd.get("human") or human)+            jb = sd.get("job") if isinstance(sd.get("job"), dict) else {}+            picked = picked or jb.get("type") or sd.get("type")+            terminal = bool(sd.get("terminal"))+        except Exception:+            pass+        if last == "cancelled":+            # the user pressed Escape in the picker: never retry, never name an AI instead+            return {"ok": False, "cancelled": True, "job": job, "state": last,+                    "why": human or "you dismissed the AI picker; nothing was opened or sent. The phrase is still on your clipboard."}+        if last in _SENT_STATES:+            return {"ok": True, "job": job, "state": last, "ai": picked, "deliveredTo": picked,+                    "why": human or f"sent to {picked or 'your AI'}"}+        if last in _FAIL_STATES or terminal:+            why = ("the prompt is sitting in the new tab and Send was never clicked; press Enter there"+                   if last == "submit_failed" else (human or f"the AI panel reported {last}"))+            return {"ok": False, "job": job, "state": last, "why": why + ". The phrase was also copied."}+        time.sleep(1.0)+    return {"ok": False, "unconfirmed": True, "job": job, "state": last,+            "why": (human or f"the new thread is still {last}") + " after 75 s; check the new AI tab. The phrase was also copied."}+ def ab(verb, args=None, timeout=25, target=None):     cmd = ["adom-bridge", "--ai-thread", AI_THREAD]     if target and target != "-":@@ -828,7 +880,15 @@ def build_status():     # Grok's computer is the agent box (this machine when the dashboard is     # serving from Grok Bot's container). A connected box is extra evidence.     host_name = os.uname().nodename if hasattr(os, "uname") else "this machine"-    if _looks_like_agent_box() or targets:+    if ready.get("computerReachable") is False:+        # Wiki #5: Grok Bot shows "Couldn't Reach Grok Bot's Computer"; a connected+        # laptop is not evidence the box is up.+        leds.append(_led(+            "computer", "Grok's computer", "err", "unreachable",+            "Grok Bot says it cannot reach its computer. Webhook events are accepted but "+            "nothing replies. Press Recover Grok Bot's Computer / Retry in Grok Bot.",+            str(ready.get("computerBanner") or "")[:200]))+    elif _looks_like_agent_box() or targets:         leds.append(_led(             "computer", "Grok's computer", "ok", host_name[:24] or "this box",             "This box is Grok Bot's computer (the agent box), not the Grok Bot.exe window. "@@ -984,6 +1044,11 @@ class Handler(BaseHTTPRequestHandler):                     "webp": "image/webp", "mp4": "video/mp4"}.get(                         f.suffix.lstrip(".").lower(), "application/octet-stream")             return self._file(f, mime)+        if path.endswith("/ai-threads/run-prompt/status"):+            # the module polls this once a second; the ?job= query must travel intact+            q = urlparse(self.path).query or ""+            return _proxy_control(self, "/ai-threads/run-prompt/status" + (("?" + q) if q else ""),+                                  timeout=8)         if path.endswith("/updater/status"):             return _proxy_control(self, "/updater/status", timeout=8)         if path.endswith("/api/viewport"):@@ -1047,7 +1112,16 @@ class Handler(BaseHTTPRequestHandler):             del VIEWPORT_LOG[:-16]             return self._json({"ok": True})         if path.endswith("/ai-threads/run-prompt"):-            return _proxy_control(self, "/ai-threads/run-prompt", body=body or {}, timeout=125)+            # Wiki #4: strip any `type` (absent = Hydrogen's AI picker asks the user),+            # carry the app: tag so a remembered pick applies here, and go background+            # so the picker never sits inside this request. Reply goes back verbatim.+            b = dict(body or {}); b.pop("type", None)+            b.setdefault("background", True)+            tags = list(b.get("tags") or [])+            if "app:adom/grok-bridge" not in tags:+                tags.append("app:adom/grok-bridge")+            b["tags"] = tags+            return _proxy_control(self, "/ai-threads/run-prompt", body=b, timeout=125)         if path.endswith("/api/say-send"):             phrase = str((body or {}).get("phrase") or "").strip()             if not phrase:
dashboard/ui/newbie-panel.js+84−7
@@ -112,6 +112,23 @@   var TWO_COL_MIN = 700, PHRASE_BUDGET = 336;   var MIN_API = [1, 0, 142]; +  /* Hydrogen 1.0.187+ answers a send with a JOB and reports progress on+   * GET /ai-threads/run-prompt/status?job=<uuid>. Measured on the VM: tab visible ~0.1s,+   * session id ~2.5s, sent by 3-5s, a trivial answer done ~8s. One poll a second is the+   * right cadence for that shape, and it is an in-memory read on the other end. */+  var PHASE = {+    opening:               'Opening a new AI chat…',+    prefilled:             'Prompt ready…',+    submitted:             'Sent, waiting for the AI…',+    working:               'The AI is working…',+    needs_you:             'The AI needs you. Open the tab.',+    done:                  'Done.',+    submit_failed:         'The prompt is in the tab. Press Enter there to send it.',+    submitted_unidentified:'Sent, but the AI never checked in.',+    submit_unconfirmed:    'Sent, but delivery was not confirmed.',+    still_working:         'The AI is still working. Open the tab.'+  };+   function css() {     if (document.getElementById('anp-css')) return;     var s = document.createElement('style');@@ -174,6 +191,9 @@     if (!toastHost) { toastHost = el('div', 'anp-toasts'); document.body.appendChild(toastHost); }     var t = el('div', 'anp-toast ' + (kind || 'info'), esc(msg));     toastHost.appendChild(t);+    /* retextable, so a long send can narrate its phases in ONE toast instead of+     * stacking six of them on top of each other. */+    t.setText = function (next) { t.innerHTML = esc(next); return t; };     setTimeout(function () { t.remove(); }, ms || (kind === 'err' ? 7000 : 4000));     return t;   }@@ -224,7 +244,7 @@       })       .catch(function () { return { ok: true, version: null, open: true }; });   }-  function send(base, phrase, appName, slug) {+  function send(base, phrase, appName, slug, onProgress) {     return gate(base).then(function (g) {       if (!g.ok) throw new Error('Adom needs updating to send prompts (found ' + g.version + ', needs 1.0.142). Run: adom update');       var ctl = new AbortController(), timer = setTimeout(function () { ctl.abort(); }, 120000);@@ -241,12 +261,55 @@         .then(function (res) {           clearTimeout(timer);           var j = res.j || {};-          /* THREE states, never two. delivered_to is an ARRAY; a thread that opened but did-           * not confirm delivery is NOT a failure, and retrying it double-sends. */+          if (!(res.status >= 200 && res.status < 300))+            throw new Error((j._hints && j._hints.note) || j.error || ('AI threads returned ' + res.status));+          /* PRE-1.0.187 SHAPE, kept because older Hydrogen builds still answer this way.+           * delivered_to is an ARRAY; a thread that opened but did not confirm delivery is+           * NOT a failure, and retrying it double-sends. */           var dt = j.delivered_to, arr = Array.isArray(dt) ? dt : (dt ? [dt] : []);           if (arr.length || (j.inject && j.inject.delivered_in_conversation)) return { state: 'delivered' };-          if (res.status >= 200 && res.status < 300) return { state: 'opened', hint: j._hints && j._hints.note };-          throw new Error((j._hints && j._hints.note) || j.error || ('AI threads returned ' + res.status));+          /* 1.0.187+ returns in well under a second with a JOB, not a delivery. ok:true means+           * the job STARTED. delivered_to is never filled on this path any more, so without+           * the poll below every send would report "delivery not confirmed" forever. */+          if (!j.job) return { state: 'opened', hint: j._hints && j._hints.note };+          var started = Date.now(), missing = false;+          return new Promise(function (resolve) {+            (function tick() {+              /* the deadline is checked in BOTH paths. Checking it only after a successful+               * poll leaves a dashboard whose server stops answering polling forever. */+              function again() {+                if (Date.now() - started > 120000)+                  return resolve({ state: 'opened', hint: 'no confirmation within 120s' });+                setTimeout(tick, 1000);+              }+              fetch(base + '/ai-threads/run-prompt/status?job=' + encodeURIComponent(j.job))+                .then(function (r) {+                  /* THE STATUS ROUTE IS A THIRD PROXY ROUTE. Dashboards proxy /updater/status+                   * and POST /ai-threads/run-prompt only. If this one was never added, every+                   * poll 404s and the user watches "Opening…" for the full 120s before a+                   * hedge, which is worse than the behaviour this replaced. Fail FAST and say+                   * exactly which route is missing instead of hanging. */+                  if (r.status === 404) { missing = true; return null; }+                  return r.json();+                })+                .then(function (st) {+                  if (missing) return resolve({ state: 'opened', phase: 'no_status_route',+                    hint: 'Your AI chat opened. This app does not proxy GET ' ++                          '/ai-threads/run-prompt/status yet, so progress cannot be shown.' });+                  var state = (st.job && st.job.state) || '';+                  if (typeof onProgress === 'function') { try { onProgress(state, st.job); } catch (e) {} }+                  /* working or later IS sent, for the user-facing toast. */+                  if (state === 'working' || state === 'done' || state === 'needs_you' ||+                      state === 'still_working') return resolve({ state: 'delivered', phase: state });+                  if (state === 'submit_failed' || state === 'submitted_unidentified' ||+                      state === 'submit_unconfirmed')+                    return resolve({ state: 'opened', phase: state,+                                     hint: (st.job && (st.job.error || st.job.note)) || state });+                  again();+                })+                .catch(again);+            })();+          });         })         .catch(function (e) {           clearTimeout(timer);@@ -316,7 +379,10 @@         if (sd.disabled) return;         sd.disabled = true;         var pending = toast('Opening a new AI thread…', 'info', 120000);-        send(base, phrase, appName, slug).then(function (r) {+        send(base, phrase, appName, slug, function (state, job) {+          if (PHASE[state]) pending.setText(PHASE[state]);+          if (typeof cfg.onProgress === 'function') { try { cfg.onProgress(state, job); } catch (e) {} }+        }).then(function (r) {           pending.remove();           if (r.state === 'delivered') toast('Sent. Your AI thread has the prompt.', 'ok');           else toast('Thread opened, delivery not confirmed. Check your AI threads before sending again.', 'info', 8000);@@ -605,10 +671,21 @@               real !== 'ERR' && real !== 404 && real !== fake);         });       })+      .then(function () {+        /* THE STATUS ROUTE, added for Hydrogen 1.0.187+. It is a THIRD proxy route and no+         * dashboard had it before 2.22.0, so it is the row most likely to be red. A GET with+         * a job id that cannot exist must still be ANSWERED (any non-404), because a 404 here+         * means the route was never proxied and progress can never be reported. */+        return fetch(base + '/ai-threads/run-prompt/status?job=__anp_probe__')+          .then(function (r) { return r.status; }, function () { return 'ERR'; })+          .then(function (st) {+            add('progress route wired', 'answers (not 404)', String(st), st !== 'ERR' && st !== 404);+          });+      })       .then(function () { return {ok: ok, rows: rows}; });   }    root.AdomNewbiePanel = { mount: mount, toast: toast, selfCheck: selfCheck, copyText: copyText,-                           checkSend: checkSend, version: '2.21.0',+                           checkSend: checkSend, version: '2.22.0',                            TWO_COL_MIN: TWO_COL_MIN, PHRASE_BUDGET: PHRASE_BUDGET }; })(typeof window !== 'undefined' ? window : this);
package.json+4−3
@@ -1,8 +1,8 @@ {   "slug": "grok-bridge",-  "version": "1.3.2",+  "version": "1.3.3",   "type": "app",-  "description": "Grok Bridge lets Adom talk to Grok Bot. Adom's AI environment is for electrical engineers: any provider (Claude, Codex, Grok, Gemini, …), any desktop power tool, through Adom Bridge. grok_* open/close/resize/screenshot/record the app, read the thread, and create bots/channels via webhook.",+  "description": "Grok Bridge lets Adom talk to Grok Bot. Adom's AI environment is for electrical engineers: any provider (Claude, Codex, Grok, Gemini, \u2026), any desktop power tool, through Adom Bridge. grok_* open/close/resize/screenshot/record the app, read the thread, and create bots/channels via webhook.",   "keywords": [     "grok",     "grok-bot",@@ -143,5 +143,6 @@   ],   "hero": {     "path": "docs/hero.png"-  }+  },+  "changelog": "Grok Bot's computer state: computerReachable on readiness, chat and webhook verbs, fast no_transcript on a blank window, no caption-button thread names; Send to AI uses Hydrogen's AI picker; insiders-first publishing." }
page.json+3−2
@@ -4,7 +4,7 @@   "type": "app",   "title": "Grok Bot",   "brief": "Grok Bridge lets Adom talk to Grok Bot. Adom's AI environment is for electrical engineers: any provider, any desktop power tool, one place.",-  "version": "1.3.2",+  "version": "1.3.3",   "hero": {     "type": "image",     "path": "screenshots/hero.png"@@ -162,5 +162,6 @@   },   "dependencies": {     "adom/adom-bridge": "^2.0.0"-  }+  },+  "changelog": "Grok Bot's computer state: computerReachable on readiness, chat and webhook verbs, fast no_transcript on a blank window, no caption-button thread names; Send to AI uses Hydrogen's AI picker; insiders-first publishing." }\ No newline at end of file
skills/grok-chat/SKILL.md+3
@@ -30,6 +30,9 @@ Never steal focus. Surface `_hint` verbatim. - `grok_send` is not a public Grok HTTP API. The Anywhere character (`grok-anywhere`) can poll the inbox. - If list_windows is empty, fix hwnd unwrap first (**grok-app-control**) — chat verbs need that hwnd. - Empty/tiny transcript after cold start: `errorCode` `focused_thread_empty` / `transcript_unavailable` plus `focusedThread`. Call `grok_open` then wait, or `grok_chat_read {"thread":"grok-bridge"}`. Sidebar click is best-effort UIA.+- Window up but no page yet (blank after a background `grok_open`): `errorCode` `no_transcript`, answered at once with `visibleText`, `focusedThread:null` and `focusedThreadReason`. Retry in 30 s.+- Grok Bot showing "Couldn't Reach Grok Bot's Computer": `errorCode` `computer_unreachable`, `computerReachable:false`. Every status/read also carries `computerState`; see **grok-webhook** "Working ON Grok Bot's computer".+- `focusedThread` is never a title-bar button name; when the page has no conversation heading it is `null` with a reason. - `ok:true` is not enough: after send, the toast exists; the composer may be unchanged. Say that honestly.  Window open/screenshot: **grok-app-control**. Webhook bots/channels: **grok-webhook**.
skills/grok-webhook/SKILL.md+12
@@ -73,6 +73,16 @@ Fallback **only if the verb is unknown** (old runtime): fire `screenshot_compute  Do **not** click the monitor icon in Grok Bot and treat that as the computer shot — that is the same window `grok_screenshot` already covers. +## Working ON Grok Bot's computer (the agent box)++grok-bridge's computer is a Linux box in Grok's cloud, not the user's PC. If you need something run or checked there, this is the only way in today:++- **The channel is webhook out, transcript back.** `grok_webhook_fire {"event":"ping","payload":{"nonce":"<fresh>","message":"run X and paste the output with the nonce"}}`, then read it back with `grok_chat_read {"thread":"grok-bridge","limit":8000}` until the nonce appears. No grok_* verb gives you a shell, ssh, or file transfer to the box. The agent there runs the commands and pastes their output into its thread.+- **A round trip takes about 60 to 120 s** per turn, and longer while grok-bridge is busy. The queue is FIFO and drops nothing. `thinking:true` means busy, not stuck.+- **Telling the box is down.** `grok_readiness`, `grok_chat_status` and `grok_chat_read` carry `computerState` (`unreachable` | `reconnecting` | `no_banner` | `unknown`) and `computerReachable` (`false` when Grok Bot shows "Couldn't Reach Grok Bot's Computer", otherwise `null`, because a missing banner does not prove the box is up). While it is down, `grok_webhook_fire` still gets HTTP 200 but adds `warning:"computer_unreachable"`, and `grok_computer_screenshot` refuses at once with `errorCode:"computer_unreachable"` instead of waiting out its timeout. The fix is the user pressing **Recover Grok Bot's Computer / Retry** in Grok Bot.+- **The disk persists between sessions and is shared by every bot on the account.** Earlier work (for example a checked-out design under `/workspace`) is still there for a new bot. If you brief a bot to design something from scratch, name a fresh folder and tell it not to read earlier designs.+- **A faster channel, when it exists.** If an Adom Bridge has been installed on the box and joined the user's mesh, it shows up in `adom-bridge targets`, and `--target <box>` then gives you real verbs (shell, files) there. Check `targets` first; until it is listed, use the webhook loop above.+ ## Named agent (relay, not a second mailbox)  The **Grok Bridge webhook** routine belongs to grok-bridge. `grok_webhook_fire` has no other destination — every POST still lands in the grok-bridge thread.@@ -99,4 +109,6 @@ Smoke: `grok_webhook_fire {"event":"ping"}`. Verify with `grok_webhook_status` ( ## Errors worth knowing  - `webhook_not_bound`: this PC has no `~/.adom/grok-webhook.json`. Bindings are per PC; a freshly installed PC needs `grok_webhook_ensure`.+- `computer_unreachable`: Grok Bot shows "Couldn't Reach Grok Bot's Computer". Events are accepted but no reply can come back. Ask the user to press Recover / Retry in Grok Bot, then re-check `grok_chat_status`.+- `no_transcript` (from `grok_chat_read` / `grok_chat_status`): the Grok Bot window has no page yet, usually right after a background `grok_open`. The verb answers at once instead of hitting the 60 s timeout; retry in 30 s. `focusedThread` is `null` with a `focusedThreadReason`. - `bot_unavailable` (HTTP 400 "This Bot is unavailable"): the key works but the routine's bot is unavailable, usually because the account's cloud computer is still coming up after a sign-in. Wait, check the sidebar with `grok_screenshot`, then `grok_webhook_ensure`. Retrying at once does not help.