← Commit history

v1.3.3 (insiders): 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.

John Lauer ·85caf75d52 ·5d ago ·parent 9b9bd55
10 files changed +444−30
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.3.2+1.3.3\ No newline at end of file
CLAUDE.md+24−9
@@ -32,7 +32,7 @@ will seed/stream the runtime from the release zip via `updateManifestUrl` in `br   `ad_callback`. Never `shell_execute` / `pull_file` / `send_files` from the HTTP path. - `handlers/*.py` — readiness, describe, windows (open/screenshot/list), chat (status/read),   send, app (close/resize/record/login/logout/plugin), webhook, bots.-- `bridge.json` + `BRIDGE_VERSION` — KEEP IN LOCKSTEP (currently 1.3.2).+- `bridge.json` + `BRIDGE_VERSION` — KEEP IN LOCKSTEP (tools/ship.sh does it). - `adom-bridge-grok-manifest.json` — streaming manifest ab's `updateManifestUrl` points at;   its `url` is the RELEASE download URL of the zip, with matching `sha256`/`size`. - `SKILL.md` (root) — core USER skill, installed as **`grok`** (not `grok-bridge`).@@ -41,7 +41,7 @@ will seed/stream the runtime from the release zip via `updateManifestUrl` in `br - `package.json` — wiki skills pkg. Description is the skills-not-runtime sentence. Runtime   files and `*.zip` do **not** belong in `files[]`. -## Verbs (v1.3.2)+## Verbs  Existing: `grok_readiness` `grok_prewarm` `grok_describe` `grok_status` `grok_open` `grok_screenshot` `grok_list_windows` `grok_chat_status` `grok_chat_read` `grok_send` `grok_uninstall`.@@ -58,16 +58,31 @@ Install (1.3.0): `grok_install {force?}` background job + `grok_install_status`. `grok_webhook_ensure` scrapes UIA Edit **Webhook URL** / **Webhook key** (no human copy-paste). `grok_webhook_fire` optional `agent` / `payload.agent` (name or id) relays into that teammate. +Agent-box state (1.3.3, wiki #5): `handlers/chat.py` `page_probe` / `probe_computer` read the+Document once (8 s per UIA callback, 15 s budget) and report `computerState` /+`computerReachable` from the "Couldn't Reach Grok Bot's Computer" page. chat_read / chat_status+answer at once with `no_transcript` or `computer_unreachable`; focusedThread is never a caption+button. readiness, webhook_fire (warning) and computer_screenshot (refuses) use it.+ 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)+## The publish loop: insiders first, public on a human's words (wiki #2)++`tools/ship.sh <ver> "<changelog>"` does all of it: lockstep bump (`BRIDGE_VERSION`,+`bridge.json`, `package.json`) → zip the runtime at **zip root** → `pkg publish --org adom`+(same version, so the top version row has a tarball) → release upload on adom/grok-bridge →+**insiders** manifest at `adom/insiders` `grok-bridge/version.json` + a release-log entry in+`grok-bridge/README.md` ("Not promoted") → push source + skills to this page. The public+manifest `adom-bridge-grok-manifest.json` is NOT touched.++`tools/promote.sh <ver> "<the human's exact words>" --by "<name>"` is the only way the public+manifest moves. It refuses without the words and records them in the manifest. Never promote+on your own judgment. -Edit runtime → bump `BRIDGE_VERSION` + `bridge.json` together → zip-`adom-bridge-grok-v<ver>.zip` from the runtime files at **zip root** (no top dir) →-`adom-wiki release upload` the zip → point the manifest at that release URL with the-zip's real sha256/size → `adom-wiki repo push` the manifest + skills →-`adom-bridge bridge_install {"manifestUrl":".../files/adom-bridge-grok-manifest.json","force":true}`.-Wiki = canonical. **Do not publish from this clone until asked.**+Desktops on ab >= 2.0.99 follow `insidersManifestUrl` when the login can read adom/insiders. A+desktop whose installed bridge.json predates the field needs one bootstrap:+`adom-bridge --target <box> bridge_install '{"manifestUrl":"https://wiki.adom.inc/api/v1/pages/insiders/files/grok-bridge/version.json","force":true}'`.+Other threads ship this bridge too: check `adom-wiki repo log adom/grok-bridge` right before shipping.  ## Hard rules 
bridge.json+3−2
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "grok",   "displayName": "Grok Bot",-  "version": "1.3.2",+  "version": "1.3.3",   "description": "Drive Grok Bot (standalone Electron app, Grok Bot.exe) from Adom Bridge: open/close/resize/screenshot/record the app, read thinking vs idle and the visible transcript of the focused thread, queue messages, bind the Grok Bridge webhook and create bots/channels. No public Grok HTTP API \u2014 the runtime talks back to ab over desktop_* callbacks. Cursor is last-resort fallback only if Grok Bot.exe is missing.",   "homepage": "https://wiki.adom.inc/adom/grok-bridge",   "author": "Adom Inc.",@@ -71,6 +71,7 @@   ],   "docs": "https://wiki.adom.inc/adom/grok-bridge",   "updateManifestUrl": "https://wiki.adom.inc/api/v1/pages/grok-bridge/files/adom-bridge-grok-manifest.json",+  "insidersManifestUrl": "https://wiki.adom.inc/api/v1/pages/insiders/files/grok-bridge/version.json",   "detect": {     "hostApp": "Grok Bot",     "appPathsExe": "Grok Bot.exe",@@ -95,7 +96,7 @@         "/opt/cursor/cursor"       ]     },-    "installVerb": "grok_open",+    "installVerb": "grok_install",     "hostAppOptionalVerbs": [       "readiness",       "describe",
dev-skills/grok-publish/SKILL.md+1−1
@@ -23,7 +23,7 @@ Wiki: https://wiki.adom.inc/adom/grok-bridge. Changelog / `-m` must be ≥10 cha - Skills library / README / hero: bump **`package.json` version** (pkg). Leave `BRIDGE_VERSION` + `bridge.json` version alone if the Python runtime did not change. - New/changed `grok_*` verbs: bump **all three** (`BRIDGE_VERSION`, `bridge.json`, `package.json`) in lockstep, rebuild the zip, upload release, rewrite manifest, then pkg publish. -Current runtime: **1.3.2**. Skills-only bumps `package.json` only; verb changes bump zip + manifest + package.json together.+Runtime ships go through `tools/ship.sh <ver> "<changelog>"`: INSIDERS tier first (`adom/insiders` `grok-bridge/version.json`, release log in `grok-bridge/README.md`), skills pkg at the same version, public manifest untouched. `tools/promote.sh <ver> "<human's words>" --by "<name>"` is the only path to the public manifest. Skills-only changes can still bump `package.json` alone.  ## Skills pkg allowlist 
handlers/chat.py+164−14
@@ -7,6 +7,7 @@ Uses grok_hwnd / _cb / _callback_ok from handlers.windows after the hwnd unwrap from __future__ import annotations  import re+import time  from grok_detect import HOST_APP, detect_host from handlers.windows import (@@ -37,6 +38,33 @@ SIDEBAR_NOISE = {  _WORKING_SUFFIXES = (" is working", " is thinking") +# Title-bar caption buttons. With no thread list on the page (blank window after a+# background launch, or the reconnect page) a Heading query can land on these, and+# focusedThread once read "Minimize" (wiki #5). Never report one as a thread.+CAPTION_BUTTONS = {+    "minimize", "maximize", "restore", "restore down", "close", "system",+    "system menu", "application",+}++# The page Grok Bot shows when the agent box (Grok Bot's computer) is down:+# "Reconnecting to your computer... Couldn't Reach Grok Bot's Computer ...+# Recover Grok Bot's Computer / Retry". Matched case-insensitively, both apostrophes.+_UNREACHABLE_MARKERS = (+    "couldn't reach", "couldn\u2019t reach",+    "recover grok bot's computer", "recover grok bot\u2019s computer",+)+_RECONNECT_MARKERS = ("reconnecting to your computer",)++# Per-callback UIA budget. A blank window used to run grok_chat_read into ab's 60 s+# verb timeout; every chat callback now gives up after this, and the Document read+# has an overall budget too.+UI_TIMEOUT = 8+DOC_READ_BUDGET = 15.0+++def _ui(command: str, args: dict) -> dict:+    return _cb(command, args, timeout=UI_TIMEOUT)+ # Never return webhook keys / bearer tokens in transcript. _SECRET_RE = re.compile(     r"(?:crsr_[A-Za-z0-9_-]+)"@@ -174,10 +202,10 @@ def _find_working(hwnd: int) -> tuple[bool, str]:         {"hwnd": hwnd, "contains": "working"},     )     for args in queries:-        blob = _cb("desktop_find_controls", args)+        blob = _ui("desktop_find_controls", args)         ctrls = _as_controls(blob)         if not ctrls:-            ctrls = _as_controls(_cb("desktop_find_control", args))+            ctrls = _as_controls(_ui("desktop_find_control", args))         for c in ctrls:             n = _ctrl_name(c) or _ctrl_value(c)             if _looks_working(n):@@ -185,7 +213,13 @@ def _find_working(hwnd: int) -> tuple[bool, str]:     return False, ""  -def _read_agent_name(hwnd: int, working_name: str = "") -> str:+def _is_thread_name(n: str) -> bool:+    nl = (n or "").strip().lower()+    return bool(nl) and nl not in SIDEBAR_NOISE and nl not in CAPTION_BUTTONS+++def _read_agent_name(hwnd: int, working_name: str = "") -> str | None:+    """Focused thread name, or None when the page shows no thread (never a caption button)."""     stripped = _strip_working(working_name)     if stripped:         return stripped@@ -196,25 +230,25 @@ def _read_agent_name(hwnd: int, working_name: str = "") -> str:         {"hwnd": hwnd, "name": VIEW_DETAILS},     )     for args in queries:-        blob = _cb("desktop_find_control", args)+        blob = _ui("desktop_find_control", args)         for c in _as_controls(blob):             n = (_ctrl_name(c) or _ctrl_value(c)).strip()-            if n and n.lower() not in SIDEBAR_NOISE:+            if _is_thread_name(n):                 return n-        blob2 = _cb("desktop_find_controls", args)+        blob2 = _ui("desktop_find_controls", args)         for c in _as_controls(blob2):             n = (_ctrl_name(c) or _ctrl_value(c)).strip()-            if n and n.lower() not in SIDEBAR_NOISE:+            if _is_thread_name(n):                 return n-    return HOST_APP+    return None   def _prompt_value(hwnd: int) -> str:-    blob = _cb("desktop_ui_get", {"hwnd": hwnd, "name": PROMPT_NAME, "role": "Edit"})+    blob = _ui("desktop_ui_get", {"hwnd": hwnd, "name": PROMPT_NAME, "role": "Edit"})     text = _extract_text(blob)     if text:         return text.strip()-    blob = _cb("desktop_find_control", {"hwnd": hwnd, "name": PROMPT_NAME, "role": "Edit"})+    blob = _ui("desktop_find_control", {"hwnd": hwnd, "name": PROMPT_NAME, "role": "Edit"})     for c in _as_controls(blob):         v = _ctrl_value(c)         if v:@@ -244,12 +278,15 @@ def _strip_prompt_garbage(text: str, prompt_val: str) -> str: def _read_document_text(hwnd: int) -> tuple[str, dict | None]:     """desktop_ui_text {hwnd, role:Document, range:all} — proven Document 'Grok Bot'."""     last = None+    deadline = time.monotonic() + DOC_READ_BUDGET     for args in (         {"hwnd": hwnd, "role": "Document", "range": "all"},         {"hwnd": hwnd, "name": DOCUMENT_NAME, "role": "Document", "range": "all"},         {"hwnd": hwnd, "name": DOCUMENT_NAME, "range": "all"},     ):-        last = _cb("desktop_ui_text", args)+        if time.monotonic() > deadline:+            break+        last = _ui("desktop_ui_text", args)         if not _callback_ok(last):             continue         text = _extract_text(last)@@ -258,6 +295,97 @@ def _read_document_text(hwnd: int) -> tuple[str, dict | None]:     return "", last  +def computer_state_from_text(text: str) -> tuple[str, str | None]:+    """(state, bannerLine) from the page text. unreachable | reconnecting | no_banner | unknown.++    no_banner is NOT proof the box is up: the banner only shows on some pages.+    """+    if not (text or "").strip():+        return "unknown", None+    for line in text.splitlines():+        ll = line.strip().lower()+        if any(m in ll for m in _UNREACHABLE_MARKERS):+            return "unreachable", line.strip()[:200]+    for line in text.splitlines():+        ll = line.strip().lower()+        if any(m in ll for m in _RECONNECT_MARKERS):+            return "reconnecting", line.strip()[:200]+    return "no_banner", None+++def computer_fields(state: str, banner: str | None) -> dict:+    return {+        "computerState": state,+        "computerReachable": False if state in ("unreachable", "reconnecting") else None,+        "computerBanner": banner,+    }+++def page_probe(hwnd: int) -> dict:+    """One bounded Document read: is there a page yet, and is Grok Bot's computer down?"""+    raw, last = _read_document_text(hwnd)+    state, banner = computer_state_from_text(raw)+    return {"hasDocument": bool((raw or "").strip()), "text": raw or "", "last": last,+            "state": state, "banner": banner}+++def probe_computer() -> dict:+    """computer_* fields for readiness / webhook verbs. Read-only, bounded, never raises."""+    try:+        hwnd, _win = grok_hwnd(allow_cursor=False)+        if not hwnd:+            out = computer_fields("unknown", None)+            out["computerStateReason"] = "no Grok Bot window to read"+            return out+        pr = page_probe(hwnd)+        out = computer_fields(pr["state"], pr["banner"])+        if not pr["hasDocument"]:+            out["computerStateReason"] = "Grok Bot window has no page yet (blank or loading)"+        return out+    except Exception as e:  # never let a probe break the verb it decorates+        out = computer_fields("unknown", None)+        out["computerStateReason"] = f"probe failed: {e.__class__.__name__}"+        return out+++UNREACHABLE_HINT = (+    "Grok Bot shows \"Couldn't Reach Grok Bot's Computer\": the agent box is down, so webhook "+    "events are accepted (HTTP 200) but no reply can come back. Ask the user to press "+    "Recover Grok Bot's Computer / Retry in Grok Bot, then re-check grok_chat_status."+)+NO_PAGE_REASON = "no thread list on the page (window blank or still loading)"+++def _no_page(hwnd: int, win, probe: dict, verb: str) -> dict:+    """Fast answer when the window has no Document, or shows the unreachable page."""+    state = probe["state"]+    down = state in ("unreachable", "reconnecting")+    out = {+        "success": False,+        "errorCode": "computer_unreachable" if down else "no_transcript",+        "thinking": False,+        "idle": True,+        "agentName": None,+        "agent": None,+        "focusedThread": None,+        "focusedThreadReason": ("Grok Bot is showing the reconnect page, not a thread" if down+                                else NO_PAGE_REASON),+        "text": "",+        "length": 0,+        "visibleText": _redact_secrets(probe["text"])[:1000],+        "hwnd": hwnd,+        "title": _title_of(win),+        "windowEtiquette": "background",+        "_hint": (UNREACHABLE_HINT if down else+                  "The Grok Bot window has no page yet (only caption buttons in UIA). After a "+                  "background grok_open the first paint can take minutes; retry in 30 s or "+                  "grok_screenshot to see it. Answered fast instead of waiting out the verb timeout."),+        "_next": ("grok_chat_status {} after the user presses Retry" if down+                  else f"wait 30 s, then grok_{verb} {{}}"),+    }+    out.update(computer_fields(state, probe["banner"]))+    return out+  def _select_sidebar_thread(hwnd: int, name: str) -> dict:     """Best-effort click a sidebar row by UIA name. Does not steal OS focus beyond Invoke."""@@ -268,7 +396,7 @@ def _select_sidebar_thread(hwnd: int, name: str) -> dict:         {"hwnd": hwnd, "name": name, "role": "Button"},         {"hwnd": hwnd, "nameContains": name},     ):-        last = _cb("desktop_ui_click", args)+        last = _ui("desktop_ui_click", args)         if _callback_ok(last) and not (isinstance(last, dict) and last.get("error")):             return last     return last if isinstance(last, dict) else {"success": False}@@ -283,10 +411,18 @@ def handle_chat_status(_args: dict) -> dict:     if not hwnd:         return _window_missing("chat_status") +    probe = page_probe(hwnd)+    if not probe["hasDocument"] or probe["state"] in ("unreachable", "reconnecting"):+        out = _no_page(hwnd, win, probe, "chat_status")+        # status is an observation, not a failed read: the window exists+        out["success"] = True+        out.pop("text", None); out.pop("length", None)+        return out+     thinking, working_name = _find_working(hwnd)     agent = _read_agent_name(hwnd, working_name)     title = _title_of(win) or HOST_APP-    return {+    out = {         "success": True,         "thinking": bool(thinking),         "idle": (not thinking),@@ -302,6 +438,10 @@ def handle_chat_status(_args: dict) -> dict:             + " Read-only UIA — focus not stolen. grok_chat_read for the visible transcript."         ),     }+    if agent is None:+        out["focusedThreadReason"] = "no conversation heading on the page"+    out.update(computer_fields(probe["state"], probe["banner"]))+    return out   def handle_chat_read(args: dict) -> dict:@@ -325,6 +465,10 @@ def handle_chat_read(args: dict) -> dict:         miss["agent"] = None         return miss +    probe = page_probe(hwnd)+    if not probe["hasDocument"] or probe["state"] in ("unreachable", "reconnecting"):+        return _no_page(hwnd, win, probe, "chat_read")+     wanted = str(args.get("thread") or args.get("agent") or "").strip()     select_attempt = None     if wanted:@@ -332,7 +476,10 @@ def handle_chat_read(args: dict) -> dict:      thinking, working_name = _find_working(hwnd)     agent = _read_agent_name(hwnd, working_name)-    raw, last = _read_document_text(hwnd)+    if select_attempt is None:+        raw, last = probe["text"], probe["last"]    # nothing clicked: the probe read is current+    else:+        raw, last = _read_document_text(hwnd)      # Cold start: focused thread is often not grok-bridge. If nothing requested and     # transcript is empty/tiny, try the grok-bridge sidebar row once.@@ -435,6 +582,9 @@ def handle_chat_read(args: dict) -> dict:         ),     }     out.update(limit_meta)+    out.update(computer_fields(*computer_state_from_text(raw)))+    if focused is None:+        out["focusedThreadReason"] = "no conversation heading on the page"     if err and not text.strip():         out["success"] = False         out["errorCode"] = err
handlers/describe.py+4−3
@@ -9,7 +9,7 @@ VERB_CATALOG = {     "readiness": {         "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 / hostAppFallback / errorCode host_app_not_installed.",+        "hint": "Zero side effects, never downloads. Branch on hostAppInstalled / hostAppRunning / hostAppFallback / errorCode host_app_not_installed. When running, also computerState / computerReachable (false = Grok Bot shows Couldn't Reach Grok Bot's Computer).",         "related": ["grok_prewarm", "grok_open", "grok_status"],         "pitfalls": [             "ready=false is NOT a user-download. Offer grok_open.",@@ -73,9 +73,10 @@ VERB_CATALOG = {     "chat_status": {         "summary": "Read-only: is the focused Grok Bot thread thinking or idle? Never steals focus.",         "long": False,-        "hint": "thinking=true when UIA exposes a name like \"<agent> is working\" (proven: \"New Bot is working\"). idle when not thinking.",+        "hint": "thinking=true when UIA exposes a name like \"<agent> is working\" (proven: \"New Bot is working\"). idle when not thinking. Carries computerState / computerReachable. errorCode no_transcript (window has no page yet) or computer_unreachable answer at once.",         "related": ["grok_chat_read", "grok_list_windows", "grok_screenshot"],         "pitfalls": [+            "focusedThread null + focusedThreadReason when the page has no conversation heading; never a caption-button name.",             "Needs a live Grok Bot window — grok_open first.",             "Never steal focus. UIA first, not OCR, never CDP 9222.",             "Do not treat Google Chrome / ai-grok-bot as the host.",@@ -84,7 +85,7 @@ VERB_CATALOG = {     "chat_read": {         "summary": "Read the visible transcript of the focused Grok Bot thread (UIA Document). Returns focusedThread.",         "long": False,-        "hint": "desktop_ui_text {hwnd, role:Document, range:all}. Optional thread/agent clicks that sidebar row then re-reads. Optional limit is CHARACTERS from the END (not messages; values under 500 are coerced). Returns focusedThread + agent. Never returns webhook keys.",+        "hint": "desktop_ui_text {hwnd, role:Document, range:all}. Optional thread/agent clicks that sidebar row then re-reads. Optional limit is CHARACTERS from the END (not messages; values under 500 are coerced). Returns focusedThread + agent + computerState. errorCode no_transcript (blank window, answered at once with visibleText) or computer_unreachable. Never returns webhook keys.",         "related": ["grok_chat_status", "grok_list_windows", "grok_send", "grok_open"],         "pitfalls": [             "After cold launch the focused thread may not be grok-bridge — pass thread/agent or wait after grok_open.",
handlers/readiness.py+7
@@ -60,6 +60,13 @@ def handle_readiness(_args: dict) -> dict:         "windowsHint": info.get("windowsHint"),         "_hint": "Read-only host-app detection (no download, no launch). " + state,     }+    if running:+        # Wiki #5: "Couldn't Reach Grok Bot's Computer" used to be invisible here.+        from handlers.chat import probe_computer+        out.update(probe_computer())+        if out.get("computerReachable") is False:+            out["_hint"] += (" Grok Bot's computer (the agent box) is DOWN: webhook events will get "+                             "HTTP 200 but no reply. The user presses Recover / Retry in Grok Bot.")     if not installed:         out["errorCode"] = "host_app_not_installed"         out["_next"] = "grok_install {} then poll grok_install_status {}, then grok_open {}. Do not send the user to a download page."
handlers/webhook.py+24
@@ -459,6 +459,13 @@ def handle_webhook_fire(args: dict) -> dict:     }     if agent:         out["agent"] = agent+    from handlers.chat import UNREACHABLE_HINT, probe_computer+    comp = probe_computer()+    out.update(comp)+    if comp.get("computerReachable") is False:+        # Wiki #5: HTTP 200 + runUuid even though no reply can ever come back.+        out["warning"] = "computer_unreachable"+        out["_warningHint"] = UNREACHABLE_HINT     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@@ -546,6 +553,16 @@ def handle_computer_screenshot(args: dict) -> dict:         timeout = SHOT_TIMEOUT_DEFAULT     timeout = max(SHOT_TIMEOUT_MIN, min(SHOT_TIMEOUT_MAX, timeout)) +    from handlers.chat import UNREACHABLE_HINT, probe_computer+    comp = probe_computer()+    if comp.get("computerReachable") is False:+        # Wiki #5: do not fire and then wait out the whole timeout for a reply that cannot come.+        out = {"success": False, "found": False, "fired": False, "nonce": nonce,+               "errorCode": "computer_unreachable", "_hint": UNREACHABLE_HINT,+               "_next": "grok_chat_status {} after the user presses Retry, then grok_computer_screenshot {}"}+        out.update(comp)+        return out+     fire = handle_webhook_fire({         "event": "screenshot_computer",         "nonce": nonce,@@ -577,6 +594,8 @@ def handle_computer_screenshot(args: dict) -> dict:             found = True             snippet = _snippet_around(text, nonce)             break+        if last_read.get("errorCode") == "computer_unreachable":+            break    # the box went down mid-wait; say so now instead of at the timeout         remaining = deadline - time.monotonic()         if remaining <= 0:             snippet = _snippet_around(text, nonce)@@ -613,6 +632,11 @@ def handle_computer_screenshot(args: dict) -> dict:                 out["shotId"] = shot_id         return out +    if last_read.get("errorCode") == "computer_unreachable":+        out["errorCode"] = "computer_unreachable"+        out["_hint"] = UNREACHABLE_HINT+        out.update({k: last_read.get(k) for k in ("computerState", "computerReachable", "computerBanner")})+        return out     out["errorCode"] = "nonce_not_in_transcript"     out["_next"] = "retry grok_computer_screenshot {} with a FRESH nonce; FIFO may be busy."     return out
tools/promote.shadded+84
@@ -0,0 +1,84 @@+#!/usr/bin/env bash+# promote.sh — move an INSIDERS build of grok-bridge to the PUBLIC tier, on a human's exact words.+#+#   tools/promote.sh <version> "<the human's exact words>" --by "<name>"+#+# The public manifest (adom-bridge-grok-manifest.json, the updateManifestUrl every non-insider+# desktop follows) moves ONLY through this script, and it REFUSES without an attestation. An AI+# passes the human's words through verbatim or does not promote, never on its own judgment.+# The words travel in the public manifest (promotion_attestation / promoted_by / promoted_at).+# The bytes are already a release on adom/grok-bridge (tools/ship.sh); promote re-points the+# public manifest at that same asset after re-checking its sha256.+set -euo pipefail+VER="${1:?usage: tools/promote.sh <version> \"<attestation words>\" --by <name>}"+ATTEST="${2:-}"+shift 2 || true+BY="${ADOM_PROMOTED_BY:-}"+while [ $# -gt 0 ]; do case "$1" in --by) BY="$2"; shift 2;; *) shift;; esac; done+REPO="$(cd "$(dirname "$0")/.." && pwd)"; cd "$REPO"+PAGE="adom/grok-bridge"+INSIDERS_PAGE="adom/insiders"+MAN="adom-bridge-grok-manifest.json"++echo "== 0. attestation gate =="+A="$(printf '%s' "$ATTEST" | tr -s '[:space:]' ' ' | sed 's/^ //;s/ $//')"+if [ "${#A}" -lt 8 ] || printf '%s' "$A" | grep -qiE '^(ok|yes|promote|ship it|go|approved|test|placeholder|x+)$'; then+  echo "REFUSED: promoting to the PUBLIC tier needs the human's exact words (>= 8 chars, not a placeholder)."+  exit 2+fi+[ -n "$BY" ] || { echo "REFUSED: say who promoted (--by \"<name>\" or ADOM_PROMOTED_BY)."; exit 2; }+echo "   by $BY: \"$A\""++echo "== 1. the insiders record for $VER =="+INS=$(adom-wiki repo show "$INSIDERS_PAGE" grok-bridge/version.json)+python3 - "$VER" "$INS" <<'PY'+import json, sys+ver, raw = sys.argv[1:3]+d = json.loads(raw)+assert d.get('version') == ver, f"insiders manifest is at {d.get('version')}, not {ver}; ship {ver} first"+PY+URL=$(printf '%s' "$INS" | python3 -c "import json,sys;print(json.load(sys.stdin)['url'])")+WANT=$(printf '%s' "$INS" | python3 -c "import json,sys;print(json.load(sys.stdin)['sha256'])")+GOT=$(curl -sL "$URL" | sha256sum | cut -d' ' -f1)+[ "$GOT" = "$WANT" ] || { echo "REMOTE ASSET MISMATCH: $URL serves $GOT, insiders record says $WANT; refusing"; exit 1; }+echo "   $URL matches the insiders record (sha256 $WANT)"++echo "== 2. public manifest =="+python3 - "$INS" "$A" "$BY" "$MAN" <<'PY'+import datetime, json, sys+ins, words, by, path = json.loads(sys.argv[1]), sys.argv[2], sys.argv[3], sys.argv[4]+now = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds').replace('+00:00', 'Z')+m = json.load(open(path))+m.update({'version': ins['version'], 'url': ins['url'], 'sha256': ins['sha256'], 'size': ins['size'],+          'insiders_released_at': ins.get('released_at'), 'released_at': now,+          'promotion_attestation': words, 'promoted_by': by, 'promoted_at': now})+m.pop('tier', None)+for k in ('rollback_attestation', 'rolled_back_by', 'rolled_back_at'):+    m.pop(k, None)+json.dump(m, open(path, 'w'), indent=2); open(path, 'a').write('\n')+print(f"   {path} -> {ins['version']}")+PY+adom-wiki repo push "$PAGE" --files "$MAN" -m "v${VER}: promoted to public by ${BY}: ${A}" >/dev/null++echo "== 3. mark the insiders release-log entry =="+TMP=$(mktemp -d)+mkdir -p "$TMP/grok-bridge"+adom-wiki repo show "$INSIDERS_PAGE" grok-bridge/README.md > "$TMP/grok-bridge/README.md"+python3 - "$TMP/grok-bridge/README.md" "$VER" "$A" "$BY" <<'PY'+import datetime, sys+path, ver, words, by = sys.argv[1:5]+s = open(path).read()+i = s.find(f"- **{ver}** insiders")+if i < 0:+    print(f"   no release-log entry for {ver}"); sys.exit(0)+j = s.find("\n", i)+line = s[i:j].replace(" Not promoted.", "")+line += f" Promoted to public on {datetime.date.today().isoformat()} by {by}: \"{words}\"."+open(path, 'w').write(s[:i] + line + s[j:]); print(f"   {ver} marked promoted")+PY+( cd "$TMP" && adom-wiki repo push "$INSIDERS_PAGE" --files grok-bridge/README.md -m "grok-bridge ${VER}: promoted to public by ${BY}" >/dev/null ) \+  || echo "   (release-log push failed; the public manifest is already promoted)"++echo "== 4. verify =="+LIVE=$(curl -s "https://wiki.adom.inc/api/v1/pages/grok-bridge/files/$MAN" | python3 -c "import json,sys;print(json.load(sys.stdin).get('version'))")+[ "$LIVE" = "$VER" ] && echo "PROMOTED $VER to PUBLIC (by $BY)" || { echo "live public manifest reads $LIVE, not $VER"; exit 1; }
tools/ship.shadded+132
@@ -0,0 +1,132 @@+#!/usr/bin/env bash+# ship.sh — the publish pipeline for grok-bridge. Every ship goes to the INSIDERS tier.+#+#   tools/ship.sh <version> "<changelog sentence>"                 # -> INSIDERS (the default)+#   tools/promote.sh <version> "<the human's words>" --by "<name>"  # -> PUBLIC, attested+#+# Release tiers (wiki issue #2, ab 2.0.99 insidersManifestUrl; same pattern as adom/pup-bridge):+#   - The zip becomes a release on adom/grok-bridge. The INSIDERS manifest is written to the+#     shared private page adom/insiders as grok-bridge/version.json (bridge-manifest shape) with a+#     release-log entry in grok-bridge/README.md. Source text files are pushed to the public page,+#     and the skills pkg is published at the same version so the page's top version row always+#     carries a tarball (release rows and pkg rows share one namespace, adom/wiki#170).+#   - The PUBLIC manifest (adom-bridge-grok-manifest.json, updateManifestUrl) is NEVER touched+#     here. It moves only through tools/promote.sh, on a human's exact words.+#   - Desktops on ab >= 2.0.99 whose login can read adom/insiders follow the insiders manifest;+#     everyone else follows the public manifest.+#+# Env: FORCE_PUSH=1 keeps going when a push reports nothing changed.+set -euo pipefail+VER="${1:?usage: tools/ship.sh <version> \"<changelog>\"}"+LOG="${2:?changelog sentence required (>= 10 chars, >= 2 words)}"+REPO="$(cd "$(dirname "$0")/.." && pwd)"+PAGE="adom/grok-bridge"+INSIDERS_PAGE="adom/insiders"+INSIDERS_DIR="dist/insiders-repo"+INSIDERS_REL="grok-bridge/version.json"+ZIP="adom-bridge-grok-v${VER}.zip"+ARCH="dist/${VER}"+RUNTIME=(BRIDGE_VERSION bridge.json caller_identity.py grok_detect.py server.py handlers/*.py)+cd "$REPO"++echo "== 0. static gates =="+python3 -m py_compile server.py grok_detect.py caller_identity.py handlers/*.py dashboard/grok_dashboard.py+python3 - <<'PY'+import glob, re, sys, yaml+bad = []+for p in ['SKILL.md'] + sorted(glob.glob('skills/*/SKILL.md') + glob.glob('dev-skills/*/SKILL.md')):+    m = re.match(r'^---\n(.*?)\n---\n', open(p).read(), re.S)+    try:+        d = yaml.safe_load(m.group(1)) if m else None+        if not isinstance(d, dict) or not isinstance(d.get('name'), str) or not isinstance(d.get('description'), str):+            bad.append(p)+    except Exception:+        bad.append(p)+print('   python compiles; SKILL.md frontmatter invalid:', bad or 'none')+sys.exit(1 if bad else 0)+PY++echo "== 1. version lockstep =="+printf '%s' "$VER" > BRIDGE_VERSION+python3 - "$VER" "$LOG" <<'PY'+import json, sys+v, log = sys.argv[1:3]+d = json.load(open('bridge.json'))+d['version'] = v+assert d.get('insidersManifestUrl', '').endswith('/pages/insiders/files/grok-bridge/version.json'), \+    'bridge.json insidersManifestUrl must point at adom/insiders grok-bridge/version.json'+json.dump(d, open('bridge.json', 'w'), indent=2); open('bridge.json', 'a').write('\n')+p = json.load(open('package.json'))+p['version'] = v; p['changelog'] = log+json.dump(p, open('package.json', 'w'), indent=2); open('package.json', 'a').write('\n')+print(f"   BRIDGE_VERSION + bridge.json + package.json -> {v}")+PY++echo "== 2. runtime zip (files at zip root) =="+mkdir -p "$ARCH"+rm -f "$ARCH/$ZIP"+python3 - "$ARCH/$ZIP" "${RUNTIME[@]}" <<'PY'+import hashlib, os, sys, zipfile+out, files = sys.argv[1], sys.argv[2:]+with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z:+    for f in files:+        z.write(f, f)+print(f"   {out} files={len(files)} sha256={hashlib.sha256(open(out,'rb').read()).hexdigest()} size={os.path.getsize(out)}")+PY++echo "== 3a. skills pkg first (same version, so the top version row has a tarball) =="+PUBOUT=$(adom-wiki pkg publish --org adom 2>&1) || { echo "PKG PUBLISH FAILED"; echo "$PUBOUT" | tail -15; exit 1; }+echo "$PUBOUT" | tail -2++echo "== 3b. release on $PAGE =="+adom-wiki release create "$PAGE" "$VER" --changelog "$LOG" >/dev/null 2>&1 || true   # exists already = fine+adom-wiki release upload "$PAGE" "$VER" "$ARCH/$ZIP" >/dev/null+URL="https://wiki.adom.inc/download/adom/grok-bridge/${VER}/${ZIP}"+LSHA=$(sha256sum "$ARCH/$ZIP" | cut -d' ' -f1)+RSHA=$(curl -sL "$URL" | sha256sum | cut -d' ' -f1)+[ "$LSHA" = "$RSHA" ] || { echo "SERVED ASSET MISMATCH: $URL sha $RSHA, local $LSHA"; exit 1; }+echo "   $URL serves the exact bytes (sha256 $LSHA)"++echo "== 4. insiders manifest + release log -> $INSIDERS_PAGE:grok-bridge/ =="+mkdir -p "$INSIDERS_DIR/grok-bridge"+adom-wiki repo show "$INSIDERS_PAGE" grok-bridge/README.md > "$INSIDERS_DIR/grok-bridge/README.md" 2>/dev/null \+  || rm -f "$INSIDERS_DIR/grok-bridge/README.md"+python3 - "$VER" "$URL" "$ARCH/$ZIP" "$INSIDERS_DIR" "$ARCH" "$LOG" <<'PY'+import datetime, hashlib, json, os, re, sys+ver, url, zp, ins_dir, arch, log = sys.argv[1:7]+base = json.load(open('adom-bridge-grok-manifest.json'))+for k in ('promotion_attestation', 'promoted_by', 'promoted_at', 'rollback_attestation',+          'rolled_back_by', 'rolled_back_at', 'insiders_released_at'):+    base.pop(k, None)+bj = json.load(open('bridge.json'))+now = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds').replace('+00:00', 'Z')+base.update({'version': ver, 'url': url, 'sha256': hashlib.sha256(open(zp, 'rb').read()).hexdigest(),+             'size': os.path.getsize(zp), 'verbPrefixes': bj.get('verbPrefixes'),+             'statusVerb': bj.get('statusVerb'), 'tier': 'insiders', 'released_at': now})+for p in (os.path.join(ins_dir, 'grok-bridge/version.json'), os.path.join(arch, 'insiders-manifest.json')):+    json.dump(base, open(p, 'w'), indent=2); open(p, 'a').write('\n')+rp = os.path.join(ins_dir, 'grok-bridge/README.md')+s = open(rp).read() if os.path.exists(rp) and os.path.getsize(rp) else \+    "# grok-bridge on the insiders tier\n\nRuntime builds of adom/grok-bridge that insiders get first. " \+    "The public manifest moves only through tools/promote.sh, on a human's words.\n\n## Release log\n\n"+if f"- **{ver}**" not in s:+    entry = f"- **{ver}** insiders, {datetime.date.today().isoformat()}. {log} Not promoted.\n"+    s = s.replace("## Release log\n\n", "## Release log\n\n" + entry, 1)+    open(rp, 'w').write(s)+print(f"   insiders manifest {ver} staged; release log entry written; archived {arch}/insiders-manifest.json")+PY+( cd "$INSIDERS_DIR" && adom-wiki repo push "$INSIDERS_PAGE" --files "$INSIDERS_REL" grok-bridge/README.md \+    -m "grok-bridge ${VER} (insiders): ${LOG}" >/dev/null ) || [ "${FORCE_PUSH:-0}" = "1" ] || { echo "INSIDERS PUSH FAILED"; exit 1; }++echo "== 5. source + skills to $PAGE (public manifest untouched) =="+adom-wiki repo push "$PAGE" --files "${RUNTIME[@]}" package.json SKILL.md CLAUDE.md README.md \+    skills/*/SKILL.md dev-skills/*/SKILL.md tools/ship.sh tools/promote.sh \+    dashboard/grok_dashboard.py dashboard/ui/index.html dashboard/ui/newbie-panel.js \+    -m "v${VER} (insiders): ${LOG}" >/dev/null || [ "${FORCE_PUSH:-0}" = "1" ] || { echo "PAGE PUSH FAILED"; exit 1; }++echo "== 6. verify =="+LIVE_INS=$(adom-wiki repo show "$INSIDERS_PAGE" "$INSIDERS_REL" 2>/dev/null | python3 -c "import json,sys;print(json.load(sys.stdin).get('version'))" 2>/dev/null || echo '?')+ANON_INS=$(curl -s -o /dev/null -w '%{http_code}' "https://wiki.adom.inc/api/v1/pages/insiders/files/grok-bridge/version.json")+PUB=$(curl -s "https://wiki.adom.inc/api/v1/pages/grok-bridge/files/adom-bridge-grok-manifest.json" | python3 -c "import json,sys;print(json.load(sys.stdin).get('version'))")+echo "   insiders manifest (with login): $LIVE_INS   anonymous: HTTP $ANON_INS (want 404)   public manifest: $PUB"+echo "SHIPPED $VER to INSIDERS. Public stays at $PUB until: tools/promote.sh $VER \"<the human's exact words>\" --by \"<name>\""