← Commit history
dashboard/demo/audio/README.md+7−12
@@ -1,19 +1,14 @@ # Demo narration audio -This folder is supposed to hold one pre-built `adom-tts say` mp3 per-step in `../steps.json` (`welcome.mp3`, `say-it.mp3`, …).+One `adom-tts say` mp3 per step in `../steps.json`, voice `en-US-AndrewNeural`.+Filenames match step ids (`welcome.mp3`, `say-it.mp3`, …). Audio plays by+default in the dash-demo player; mute is a user toggle (`gbDemoMuted`). -`adom-tts` was not on PATH when this dashboard shipped, so the mp3s-are omitted. The step player still runs: captions, highlights, and-the demo.mp4 play on the `min_ms` floor. Mute stays on by default-only when there is no audio file for a step; if you later drop mp3s-here, audio plays by default per the dash-demo contract.--To generate later (from the grok-bridge repo root):+Regenerate from the grok-bridge repo root (do not `--play` — shared player):  ```-adom-tts say "Opening the Grok Bot dashboard — this page is the control panel." -o dashboard/demo/audio/welcome.mp3+adom-tts say --voice en-US-AndrewNeural --out dashboard/demo/audio/welcome.mp3 \+  "In Adom Hydrogen, click Grok Bot on the dock." ``` -Use each step's `caption` as the say text so narration matches the-on-screen caption.+Use each step's `caption` as the say text so narration matches the on-screen caption.
dashboard/demo/steps.json+7−7
@@ -2,7 +2,7 @@   {     "id": "welcome",     "action": "highlight:#hero",-    "caption": "Opening the Grok Bot dashboard — this page is the control panel.",+    "caption": "In Adom Hydrogen, click Grok Bot on the dock.",     "audio": "demo/audio/welcome.mp3",     "min_ms": 4000   },@@ -16,42 +16,42 @@   {     "id": "status",     "action": "highlight:#ledRow",-    "caption": "Reading five status lights: host, webhook, computer, KiCad, and adom/core.",+    "caption": "Reading status lights: Adom Bridge, host, webhook, computer, KiCad, and adom/core.",     "audio": "demo/audio/status.mp3",     "min_ms": 4000   },   {     "id": "demo-clip",     "action": "play-video:#demoVideo",-    "caption": "Playing grok-from-ah: wiki, Fusion, KiCad, Gmail, then the Arduino Uno board.",+    "caption": "Playing both directions: Adom Hydrogen \u2192 Grok Bot, then Grok Bot \u2192 Adom Hydrogen.",     "audio": "demo/audio/demo-clip.mp3",     "min_ms": 5000   },   {     "id": "webhook",     "action": "highlight:#card-webhook",-    "caption": "Binding the grok webhook by scraping the Grok Bridge routine pane. No keys on this page.",+    "caption": "Grok Bot \u2192 Adom Hydrogen: a webhook fire, then grok_chat_read. No keys on this page.",     "audio": "demo/audio/webhook.mp3",     "min_ms": 4500   },   {     "id": "snapshot",     "action": "highlight:#card-snapshots",-    "caption": "Screenshotting Grok Bot's computer — the agent box, not the Grok Bot.exe window.",+    "caption": "Adom Hydrogen \u2192 Grok Bot: screenshotting Grok Bot's computer \u2014 the agent box, not the host window.",     "audio": "demo/audio/snapshot.mp3",     "min_ms": 4000   },   {     "id": "kicad",     "action": "highlight:#card-kicad",-    "caption": "Opening KiCad on grok's computer so the EE never has to use their laptop.",+    "caption": "Adom Hydrogen asks Grok Bot to open KiCad so the EE never has to use their laptop.",     "audio": "demo/audio/kicad.mp3",     "min_ms": 4500   },   {     "id": "core",     "action": "highlight:#card-core",-    "caption": "Installing adom/core on grok's computer so the Adom ecosystem is on the box.",+    "caption": "Installing adom/core on Grok Bot's computer so the Adom ecosystem is on the box.",     "audio": "demo/audio/core.mp3",     "min_ms": 4000   }
dashboard/grok_dashboard.py+478−26
@@ -21,6 +21,7 @@ PATH and `adom/core`.  CLI:   grok-dashboard serve [--port 8791] [--print-url]+  grok-dashboard ensure-desktop   grok-dashboard status   grok-dashboard health """@@ -40,11 +41,18 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import urlparse -VERSION = "1.0.0"+VERSION = "1.0.3" APP = "grok-dashboard" AI_THREAD = "Grok Bot" DEFAULT_PORT = 8791 STATE_PATH = Path.home() / ".adom" / "grok-dashboard.json"+RELAY_PUBLIC_URL_PATH = Path.home() / ".adom" / "relay-public-url"+CLOUDFLARED_LOG_PATH = Path("/tmp/cloudflared-ab.log")+RELAY_HEALTH_URL = "http://127.0.0.1:8766/health"+KNOWN_DESKTOPS = ("AdomLapper", "ConfRoomROG")+GROK_LAPTOP_ID = "a101b94c-a17b-4818-aa2c-503f3e4e2f8c"+WIN_AB_EXE = r"%LOCALAPPDATA%\Adom Bridge\adom-bridge.exe"+WIN_AB_CLI = r"%LOCALAPPDATA%\Adom Bridge\adom-bridge-cli.exe" HERE = Path(__file__).resolve().parent UI_DIR = HERE / "ui" ROOT = HERE.parent@@ -99,7 +107,7 @@ def _ver_tuple(v):  _EXACT_ROUTES = ("/health", "/console", "/events", "/api/targets",                  "/api/action", "/ui/toast", "/ui/click", "/api/status",-                 "/api/say-send")+                 "/api/say-send", "/api/claim-webview") _SUFFIX_ROUTES = ("/newbie-panel.js", "/favicon.svg", "/favicon.png",                   "/favicon.ico", "/icon.svg", "/demo.mp4",                   "/updater/status", "/api/viewport",@@ -241,6 +249,404 @@ def get_targets():         return []  ++def pick_target(targets):+    names = [str(t) for t in (targets or []) if t]+    lower = {n.lower(): n for n in names}+    for want in KNOWN_DESKTOPS:+        if want.lower() in lower:+            return lower[want.lower()]+    return names[0] if names else ""+++def _load_state():+    try:+        d = json.loads(STATE_PATH.read_text(encoding="utf-8"))+        return d if isinstance(d, dict) else {}+    except Exception:+        return {}+++def _write_state_merge(updates):+    """Merge keys into grok-dashboard.json. Never store tokens/keys."""+    st = _load_state()+    for k, v in (updates or {}).items():+        lk = str(k).lower()+        if any(s in lk for s in ("token", "secret", "password", "apikey", "api-key", "auth_token", "authtoken")):+            continue+        st[k] = v+    STATE_PATH.parent.mkdir(parents=True, exist_ok=True)+    STATE_PATH.write_text(json.dumps(st, indent=2) + "\n", encoding="utf-8")+    return st++++def _host_resolves(url):+    from urllib.parse import urlparse+    import socket+    host = urlparse(url).hostname+    if not host:+        return False+    try:+        socket.getaddrinfo(host, 443)+        return True+    except Exception:+        return False+++def _is_loopback_url(url):+    u = (url or "").strip().lower()+    if not u:+        return True+    return any(x in u for x in ("127.0.0.1", "localhost", "[::1]", "0.0.0.0"))+++def _is_public_relay_url(url):+    u = (url or "").strip()+    if not u or _is_loopback_url(u):+        return False+    return u.startswith(("wss://", "ws://", "https://", "http://"))+++def public_relay_url(setup=None):+    setup = setup or {}+    try:+        file_url = RELAY_PUBLIC_URL_PATH.read_text(encoding="utf-8").strip()+    except Exception:+        file_url = ""+    if _is_public_relay_url(file_url):+        return file_url+    su = str(setup.get("server_url") or "").strip()+    if _is_public_relay_url(su):+        return su+    return file_url or su or ""+++_TRYCLOUDFLARE_HTTPS = re.compile(+    r"https://([A-Za-z0-9-]+\.trycloudflare\.com)", re.I+)+++def _to_wss(url):+    u = (url or "").strip().rstrip("/")+    if u.startswith("https://"):+        return "wss://" + u[len("https://"):]+    if u.startswith("http://"):+        return "ws://" + u[len("http://"):]+    return u+++def live_trycloudflare_from_log(log_path=None):+    """Return wss://host from a live cloudflared log, or "".++    Last printed trycloudflare hostname that currently resolves wins.+    Does not copy tokens.+    """+    path = Path(log_path) if log_path else CLOUDFLARED_LOG_PATH+    try:+        blob = path.read_text(encoding="utf-8", errors="replace")+    except Exception:+        return ""+    hosts = [h.lower() for h in _TRYCLOUDFLARE_HTTPS.findall(blob)]+    for host in reversed(hosts):+        wss = "wss://" + host+        if _host_resolves(wss):+            return wss+    return ""+++def heal_public_relay_url(setup=None):+    """If relay-public-url does not resolve, refresh from cloudflared log.++    Returns (url, how). url is "" when the public tunnel is down — callers+    must not push a dead hostname to the laptop. how is resolves |+    refreshed_from_log | down. Writes the healed line back to+    RELAY_PUBLIC_URL_PATH. Does not copy tokens.+    """+    pub = _to_wss(public_relay_url(setup))+    if pub and _host_resolves(pub):+        return pub, "resolves"+    live = live_trycloudflare_from_log()+    if live:+        try:+            RELAY_PUBLIC_URL_PATH.parent.mkdir(parents=True, exist_ok=True)+            RELAY_PUBLIC_URL_PATH.write_text(live + "\n", encoding="utf-8")+        except Exception:+            pass+        return live, "refreshed_from_log"+    return "", "down"+++def _relay_health():+    import urllib.request+    try:+        with urllib.request.urlopen(RELAY_HEALTH_URL, timeout=1.5) as r:+            return json.loads(r.read().decode("utf-8") or "{}")+    except Exception:+        return None+++def ensure_relay():+    h = _relay_health()+    if isinstance(h, dict) and h.get("status") == "ok":+        return True, "relay already answering"+    try:+        subprocess.Popen(+            ["adom-bridge", "serve", "--supervise"],+            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,+            start_new_session=True,+        )+    except FileNotFoundError:+        return False, "adom-bridge not on PATH"+    except Exception as e:+        return False, ("could not start relay: " + str(e))[:120]+    for _ in range(20):+        time.sleep(0.25)+        h = _relay_health()+        if isinstance(h, dict) and h.get("status") == "ok":+            return True, "started adom-bridge serve --supervise"+    return bool(_relay_health()), "started serve --supervise; health not yet ok"+++def _parse_ab_json(stdout):+    try:+        d = json.loads(stdout or "{}")+    except Exception:+        return {"error": "unreadable json", "raw": (stdout or "")[:200]}+    out = d.get("output")+    if isinstance(out, str) and out.strip().startswith("{"):+        try:+            inner = json.loads(out)+            merged = dict(d)+            merged.update(inner.get("data") if isinstance(inner.get("data"), dict) else inner)+            return merged+        except Exception:+            pass+    return d+++def _ab_local(verb, args=None, timeout=25):+    cmd = ["adom-bridge", "--ai-thread", AI_THREAD, verb,+           json.dumps(args if args is not None else {})]+    try:+        p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)+        return _parse_ab_json(p.stdout)+    except subprocess.TimeoutExpired:+        return {"error": f"no answer within {timeout}s", "timedOut": True}+    except FileNotFoundError:+        return {"error": "adom-bridge not on PATH", "errorCode": "no_bridge"}+    except Exception as e:+        return {"error": str(e).split("\n")[0][:120]}+++def _run_register_container(setup, url):+    """Run register_container with the public URL. Do not dump paste-JSON on ok:false."""+    cfg = setup.get("server_config") if isinstance(setup.get("server_config"), dict) else {}+    name = cfg.get("name") or "grok-bot"+    if not url:+        return {"ok": False, "error": "no public relay URL"}+    d = _ab_local("register_container", {"name": name, "url": url}, timeout=20)+    ok = bool(d.get("ok") or d.get("success")) and not d.get("error")+    err = d.get("error") or d.get("message") or d.get("why") or ""+    if not ok:+        return {+            "ok": False,+            "error": (str(err)[:200] or+                      "register_container returned ok:false (not an Adom cloud container)"),+        }+    return {"ok": True, "name": d.get("name") or name}+++def persist_ensure(info):+    safe = {+        "desktop_connected": bool(info.get("desktop_connected")),+        "last_ensure": info.get("last_ensure") or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),+        "error": info.get("error") or "",+        "hint": info.get("hint") or "",+        "targets": list(info.get("targets") or []),+        "public_url": info.get("public_url") or "",+    }+    _write_state_merge(safe)+    return safe+++_ENSURE_BG_LOCK = threading.Lock()+_ENSURE_BG_STARTED = False+++def ensure_desktop(wait_s=28):+    """First-run auto-setup: laptop Adom Bridge on this relay. Never paste JSON."""+    steps = []+    now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())+    result = {+        "ok": False,+        "desktop_connected": False,+        "last_ensure": now,+        "error": "",+        "hint": "",+        "targets": [],+        "public_url": "",+        "steps": steps,+    }++    up, msg = ensure_relay()+    steps.append("relay: " + msg)+    result["relay_up"] = up+    if not up:+        result["error"] = msg+        result["hint"] = ("adom-bridge serve --supervise did not come up. "+                          "Run grok-dashboard ensure-desktop again.")+        persist_ensure(result)+        return result++    def _snapshot():+        names = get_targets()+        result["targets"] = names+        result["desktop_connected"] = bool(names)+        result["ok"] = bool(names)+        return names++    names = _snapshot()+    if names:+        result["hint"] = "Adom Bridge already connected: " + ", ".join(names[:3])+        persist_ensure(result)+        return result++    setup = _ab_local("setup_desktop", {}, timeout=20)+    pub, heal_how = heal_public_relay_url(setup)+    result["public_url"] = pub+    result["tunnel_heal"] = heal_how+    steps.append("setup_desktop: server_url=" + str(setup.get("server_url") or "")[:80])+    if heal_how == "refreshed_from_log":+        steps.append("healed public_url from " + str(CLOUDFLARED_LOG_PATH) + ": " + pub)+        result["tunnel_resolves"] = True+    elif pub:+        steps.append("public_url: " + pub)+        result["tunnel_resolves"] = True+    else:+        steps.append(+            "public tunnel is down; not pushing dead name from "+            + str(RELAY_PUBLIC_URL_PATH)+            + " (no live URL in " + str(CLOUDFLARED_LOG_PATH) + ")"+        )+        result["tunnel_resolves"] = False+        result["hint"] = (+            "public tunnel is down. "+            + str(RELAY_PUBLIC_URL_PATH)+            + " host does not resolve and "+            + str(CLOUDFLARED_LOG_PATH)+            + " has no live trycloudflare URL."+        )+    if setup.get("error") and not setup.get("register_command") and not setup.get("server_url"):+        steps.append("setup_desktop error: " + str(setup.get("error"))[:120])++    if pub:+        reg = _run_register_container(setup, pub)+        if reg.get("ok"):+            steps.append("register_container: ok")+            result["register_ok"] = True+        else:+            steps.append("register_container: ok:false (not an Adom cloud box; not dumping JSON)")+            result["register_ok"] = False+            result["register_error"] = str(reg.get("error") or "")[:200]+    else:+        steps.append("register_container: skipped (no resolvable public URL)")+        result["register_ok"] = False++    names = _snapshot()+    if names:+        result["hint"] = "Adom Bridge connected after register_container: " + ", ".join(names[:3])+        persist_ensure(result)+        return result++    if pub:+        result["laptop_push"] = {+            "label": "AdomLapper",+            "machineId": GROK_LAPTOP_ID,+            "also": "ConfRoomROG",+            "exe": WIN_AB_EXE,+            "cli": WIN_AB_CLI,+            "url": pub,+            "how": (+                "Grok ListMachines, then Shell on AdomLapper. If Adom Bridge is installed, "+                "launch adom-bridge.exe without stealing focus if it is not running, then "+                'adom-bridge-cli.exe --ai-thread "Grok Bot" server_add with the public URL. '+                "Do not paste JSON into the GUI. Do not write a guessed config path."+            ),+        }+        steps.append("laptop_push: recipe for AdomLapper (this Linux box cannot Shell the laptop)")+    else:+        result["laptop_push"] = {+            "label": "AdomLapper",+            "machineId": GROK_LAPTOP_ID,+            "also": "ConfRoomROG",+            "exe": WIN_AB_EXE,+            "cli": WIN_AB_CLI,+            "url": "",+            "how": (+                "public tunnel is down; do not push a dead hostname. See "+                + str(RELAY_PUBLIC_URL_PATH)+                + " and " + str(CLOUDFLARED_LOG_PATH)+                + ". Restart cloudflared, then grok-dashboard ensure-desktop."+            ),+        }+        steps.append(+            "laptop_push: withheld dead URL from "+            + str(RELAY_PUBLIC_URL_PATH)+        )++    deadline = time.time() + max(5, int(wait_s))+    while time.time() < deadline:+        names = _snapshot()+        if names:+            break+        time.sleep(2)++    if names:+        result["error"] = ""+        result["hint"] = "Adom Bridge connected: " + ", ".join(names[:3])+    else:+        if result.get("tunnel_resolves") is False:+            result["error"] = "public relay URL does not resolve; laptop Adom Bridge cannot dial this box"+            result["hint"] = (+                "public tunnel is down. "+                + str(RELAY_PUBLIC_URL_PATH)+                + " host does not resolve and "+                + str(CLOUDFLARED_LOG_PATH)+                + " has no live trycloudflare URL. Restart the quick tunnel, then "+                "grok-dashboard ensure-desktop. Do not paste JSON."+            )+        else:+            result["error"] = result.get("register_error") or "no desktop on this relay"+            result["hint"] = (+                "Adom Bridge on the laptop is not connected. Run grok-dashboard ensure-desktop "+                "or adom-bridge setup_desktop. From Grok Bot, ListMachines and Shell AdomLapper "+                "to launch Adom Bridge with the public relay URL. Do not paste JSON."+            )+    persist_ensure(result)+    return result+++def start_ensure_background():+    global _ENSURE_BG_STARTED+    with _ENSURE_BG_LOCK:+        if _ENSURE_BG_STARTED:+            return+        _ENSURE_BG_STARTED = True++    def _run():+        try:+            log("ensure-desktop: starting (background, once)")+            r = ensure_desktop(wait_s=28)+            names = ",".join(r.get("targets") or [])[:80]+            log("ensure-desktop: connected=" + str(bool(r.get("desktop_connected")))+                + " targets=" + names)+        except Exception as e:+            log("ensure-desktop failed: " + str(e).split("\n")[0][:120])++    threading.Thread(target=_run, name="ensure-desktop", daemon=True).start()++ class Hub:     def __init__(self):         self._clients = []@@ -313,7 +719,7 @@ def _looks_like_agent_box():     ))  -_CRITICAL = ("host", "webhook", "computer")+_CRITICAL = ("bridge", "host", "webhook", "computer") _ADVISORY = ("kicad", "core")  @@ -337,22 +743,52 @@ def rollup(leds):  def build_status():     targets = get_targets()-    target = targets[0] if targets else ""+    target = pick_target(targets)+    st = _load_state()     leds = [] -    ready = ab("grok_readiness", {}, timeout=20, target=target or None)-    no_bridge = ready.get("errorCode") == "no_bridge" or "not on PATH" in str(ready.get("error") or "")-    timed = bool(ready.get("timedOut") or ready.get("error"))-    if no_bridge or (timed and not ready.get("hostAppInstalled") and not ready.get("success")):-        # Not probed — warn, not red. A missing desktop is not a failed check.+    if targets:         leds.append(_led(-            "host", "Grok Bot.exe", "warn", "no desktop",-            "Could not reach grok_readiness (no adom-bridge or no desktop). "-            "Call grok_readiness {} from an AI thread once a box is connected."))+            "bridge", "Adom Bridge", "ok", (target or targets[0])[:24],+            "Adom Bridge on the laptop is connected to this Grok Bot box.",+            "targets: " + ", ".join(targets[:3])))+    else:+        err = st.get("error") or "no desktop"         leds.append(_led(-            "webhook", "Webhook", "warn", "not probed",-            "Could not read grok_webhook_status. Call grok_webhook_ensure {} "-            "once Grok Bot is reachable. Never paste the key."))+            "bridge", "Adom Bridge", "warn", "no desktop",+            "Adom Bridge on the laptop is not connected. Run grok-dashboard ensure-desktop "+            "(it calls adom-bridge setup_desktop and reconnects the laptop). "+            "Do not paste JSON. Do not ask the user to copy connection JSON.",+            str(err)[:200]))++    ready = {}+    no_bridge = False+    timed = False+    if targets:+        ready = ab("grok_readiness", {}, timeout=20, target=target or None)+        no_bridge = ready.get("errorCode") == "no_bridge" or "not on PATH" in str(ready.get("error") or "")+        timed = bool(ready.get("timedOut") or ready.get("error"))+    if not targets or no_bridge or (timed and not ready.get("hostAppInstalled") and not ready.get("success")):+        # Not probed — warn, not red. A missing desktop is not a failed check.+        if not targets:+            leds.append(_led(+                "host", "Grok Bot.exe", "warn", "not probed",+                "No laptop Adom Bridge yet, so grok_readiness was not called. "+                "Run grok-dashboard ensure-desktop / adom-bridge setup_desktop first. "+                "Never paste JSON."))+            leds.append(_led(+                "webhook", "Webhook", "warn", "not probed",+                "No laptop Adom Bridge yet. After grok-dashboard ensure-desktop connects, "+                "call grok_webhook_ensure {}. Never paste the key."))+        else:+            leds.append(_led(+                "host", "Grok Bot.exe", "warn", "no desktop",+                "Could not reach grok_readiness. Run grok-dashboard ensure-desktop / "+                "adom-bridge setup_desktop. Do not paste JSON."))+            leds.append(_led(+                "webhook", "Webhook", "warn", "not probed",+                "Could not read grok_webhook_status. Call grok_webhook_ensure {} "+                "once Grok Bot is reachable. Never paste the key."))     else:         installed = bool(ready.get("hostAppInstalled"))         running = bool(ready.get("hostAppRunning"))@@ -439,8 +875,9 @@ def build_status():         "generatedAt": time.strftime("%H:%M:%S"),         "criticalLeds": rolled["criticalLeds"],         "advisoryLeds": rolled["advisoryLeds"],-        "_hint": "Each LED's hint names the verb that fixes it (grok_readiness, "-                 "grok_webhook_ensure, adom-wiki pkg install adom/core). Never paste webhook keys.",+        "_hint": "Each LED's hint names the verb that fixes it (grok-dashboard ensure-desktop, "+                 "adom-bridge setup_desktop, grok_readiness, grok_webhook_ensure, "+                 "adom-wiki pkg install adom/core). Never paste webhook keys. Never paste JSON.",     }  @@ -617,6 +1054,14 @@ class Handler(BaseHTTPRequestHandler):                 return self._json({"ok": False, "why": "phrase required"}, 400)             log(f"say-send: {phrase[:60]}")             return self._json(say_send(phrase))+        if path.endswith("/api/claim-webview"):+            name = str((body or {}).get("name") or os.environ.get("ADOM_DOCK_WEBVIEW") or "").strip()+            if not name:+                return self._json({"ok": False, "why": "ADOM_DOCK_WEBVIEW unset (hand-run)"})+            port = DEFAULT_PORT+            threading.Thread(target=claim_dock_webview, args=(port, name), daemon=True).start()+            log(f"claim-webview {name}")+            return self._json({"ok": True, "name": name, "url": direct_url(port)})         if path == "/ui/click":             t = body.get("target") or ""             log(f"ui click {t}")@@ -657,17 +1102,16 @@ def direct_url(port):   def write_state(port):-    STATE_PATH.parent.mkdir(parents=True, exist_ok=True)-    STATE_PATH.write_text(json.dumps({+    _write_state_merge({         "app": APP, "pid": os.getpid(), "port": int(port),         "version": VERSION, "url": direct_url(port),-    }, indent=2) + "\n", encoding="utf-8")+    })  -def claim_dock_webview(port):+def claim_dock_webview(port, name=None):     """open-or-refresh ONLY when ADOM_DOCK_WEBVIEW is set (user machine).     Never call adom-cli hydrogen from a cloud container — that drives hc."""-    name = os.environ.get("ADOM_DOCK_WEBVIEW") or ""+    name = name or os.environ.get("ADOM_DOCK_WEBVIEW") or ""     if not name:         return     try:@@ -797,13 +1241,16 @@ def main():                 import urllib.request                 with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=1.5) as r:                     h = json.loads(r.read().decode("utf-8") or "{}")-                STATE_PATH.parent.mkdir(parents=True, exist_ok=True)-                STATE_PATH.write_text(json.dumps({+                _write_state_merge({                     "app": APP, "pid": h.get("pid"), "port": int(port),                     "version": VERSION, "url": direct_url(port),-                }, indent=2) + "\n", encoding="utf-8")+                })             except Exception:                 pass+            # Reuse launch: re-claim the interstitial tab the dock already opened.+            name = os.environ.get("ADOM_DOCK_WEBVIEW") or ""+            if name:+                local_call(port, "/api/claim-webview", {"name": name})             if "--print-url" in argv:                 print(direct_url(port), flush=True)             else:@@ -842,12 +1289,17 @@ def main():                 print(url, flush=True)                 claim_dock_webview(port)         log(f"grok-dashboard {VERSION} serving on {port}")+        start_ensure_background()         try:             srv.serve_forever()         except KeyboardInterrupt:             pass         return +    if cmd == "ensure-desktop":+        print(json.dumps(ensure_desktop(wait_s=28), indent=2))+        return+     if cmd == "status":         print(json.dumps(build_status(), indent=2))         return@@ -857,7 +1309,7 @@ def main():         return      print(json.dumps({"error": f"unknown command {cmd}",-                      "_hint": "serve [--print-url] [--port 8791] | status | health"}))+                      "_hint": "serve [--print-url] [--port 8791] | ensure-desktop | status | health"}))   if __name__ == "__main__":
dashboard/ui/demo.mp4
⋯ 1 unchanged line ⋯
dashboard/ui/index.html+14−3
@@ -180,9 +180,20 @@ dialog::backdrop{background:rgba(0,0,0,.25)}    <div class="leds" id="ledRow" aria-label="Status"></div> ++  <section class="card" id="card-bridge" style="margin-top:16px">+    <h3>Adom Bridge on your laptop</h3>+    <p>Grok Bot auto-connects to Adom Bridge on first use — you should never copy JSON or click through a Connections panel. If the Adom Bridge LED is not green, send this to the AI.</p>+    <code>Run grok-dashboard ensure-desktop (it calls adom-bridge setup_desktop and reconnects Adom Bridge on the laptop using the public relay URL). Do not ask me to paste JSON. Do not steal focus. Never paste webhook keys. Never kill Cursor.</code>+    <div class="btnrow">+      <button class="btn" data-copy="Run grok-dashboard ensure-desktop so Grok Bot auto-connects to Adom Bridge on my laptop. Call adom-bridge setup_desktop if needed. Prefer the public relay URL in ~/.adom/relay-public-url. Do not ask me to paste JSON. Do not steal focus. Never paste webhook keys. Never kill Cursor.">Copy</button>+      <button class="btn send" data-send="Run grok-dashboard ensure-desktop so Grok Bot auto-connects to Adom Bridge on my laptop. Call adom-bridge setup_desktop if needed. Prefer the public relay URL in ~/.adom/relay-public-url. Do not ask me to paste JSON. Do not steal focus. Never paste webhook keys. Never kill Cursor.">Send to AI</button>+    </div>+  </section>+   <section class="card" id="card-demo" style="margin-top:20px">     <h3>Demo video</h3>-    <p>A first-cut grok-from-ah reel: wiki → Fusion → KiCad → Gmail → Arduino Uno → symbol / lbr / footprint. Play the 30s demo from the panel above to walk the dashboard; this clip is the picture of that loop.</p>+    <p>Two directions in about 30 seconds. Adom Hydrogen → Grok Bot: wiki, Fusion, KiCad, Gmail, Arduino Uno. Grok Bot → Adom Hydrogen: webhook answers land in an Adom Hydrogen thread. Play the 30s demo from the panel above to walk the dashboard; this clip is the picture of that loop.</p>     <video id="demoVideo" controls playsinline preload="metadata" poster="">       <source src="demo.mp4" type="video/mp4">     </video>@@ -225,8 +236,8 @@ adom-bridge bridge_install '{"manifestUrl":"https://wiki.adom.inc/api/v1/pages/g     </section>      <section class="card" id="card-loop">-      <h3>Get responses in an ah Claude thread</h3>-      <p>There is no public Grok HTTP API and no ah SendMessage / ListAgents API this dashboard can call. The working loop is fire, then poll.</p>+      <h3>Two directions: Adom Hydrogen → Grok Bot vs Grok Bot → Adom Hydrogen</h3>+      <p>Adom Hydrogen → Grok Bot sends work (open Fusion, screenshot, launch apps). Grok Bot → Adom Hydrogen talks back into an Adom Hydrogen thread. There is no SendMessage / ListAgents API this dashboard can call. The working loop is fire grok_webhook_fire, then poll grok_chat_read.</p>       <code>grok_webhook_fire {"event":"ping","payload":{"nonce":"GB-DEMO-1"}} # then poll grok_chat_read for the nonce (~60–120s FIFO)</code>       <div class="btnrow">
install.sh+3
@@ -64,4 +64,7 @@ case ":$PATH:" in   *) echo "[grok-bridge] NOTE: $BINDIR is not on your PATH; the dock passes it explicitly." ;; esac echo "[grok-bridge] dashboard installed -> $BINDIR/grok-dashboard"+# Best-effort first-run: connect Adom Bridge on the laptop. Do not fail the install+# if the laptop is asleep or Grok local-exec is not approved.+PATH="$BINDIR:$PATH" grok-dashboard ensure-desktop || echo "[grok-bridge] ensure-desktop did not connect a laptop this time (ok if it is asleep)." echo "Done. This package installed SKILLS + grok-dashboard; Adom Bridge loads the grok runtime from the release zip."
package.json+2−2
@@ -1,8 +1,8 @@ {   "slug": "grok-bridge",-  "version": "1.2.3",+  "version": "1.2.4",   "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, \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.",+  "description": "Grok Bridge lets Adom talk to Grok Bot. Adom's AI environment is for electrical engineers: any provider (Claude, Codex, Grok, Gemini, …), any desktop power tool, through Adom Bridge. grok_* open/close/resize/screenshot/record the app, read the thread, and create bots/channels via webhook.",   "keywords": [     "grok",     "grok-bot",
page.json+1−1
@@ -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.2.3",+  "version": "1.2.4",   "hero": {     "type": "image",     "path": "screenshots/hero.png"