← Commit history

v0.9.159: every verb response now carries bridgeVersion (it only ever reached /health, so no caller could ask which bridge code is running); kicad-dashboard 2.0 reaches fusion-dashboard parity (SSE live channel, /api/action, ui toast+click drivability, LED _hints, targets picker, dock.json)

John Lauer ·40d50cc8db ·1mo ago ·parent 9821c59
8 files changed +885−222
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.158+0.9.159
adom-bridge-kicad-manifest.json+5−5
@@ -1,15 +1,15 @@ {   "manifest_version": 1,   "name": "kicad",-  "version": "0.9.158",-  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.158/adom-bridge-kicad-v0.9.158.zip",-  "sha256": "645c75781bce119d683adfe6b0c011b7e056b07fb9ce01c77a46593fe344756e",-  "size": 439000,+  "version": "0.9.159",+  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.159/adom-bridge-kicad-v0.9.159.zip",+  "sha256": "a5a88aa11d4f80ec9ddbe0f3e44ed6d84e19302b8948f18e22c3dfb226407b26",+  "size": 439770,   "verbPrefixes": [     "kicad_"   ],   "healthEndpoint": "/status",-  "released_at": "2026-08-18T00:40:14Z",+  "released_at": "2026-08-18T10:24:38Z",   "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png",   "languages": [     "Python",
adom-bridge-kicad-v0.9.159.zipadded
⋯ 1 unchanged line ⋯
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "kicad",   "displayName": "KiCad EDA",-  "version": "0.9.158",+  "version": "0.9.159",   "description": "Reverse bridge for KiCad \u2014 board/schematic introspection, lint via kicad-cli, plugin install, multi-instance probe, in-process DRC.",   "homepage": "https://wiki.adom.inc/adom/adom-bridge",   "author": "Adom Inc.",
dashboard/kicad_dashboard.py+495−214
@@ -1,291 +1,572 @@ #!/usr/bin/env python3-"""kicad-dashboard - the KiCad bridge's Launch Page (fusion-dashboard's sibling).+"""kicad-dashboard - the KiCad bridge's Launch Page. -John (2026-08-17): "did you ever make a dashboard like fusion made for itself?"-Now it does. LED status for every bridge facet, a Settings panel (overlay-badges), copy-paste AI prompts, and a target picker - served in the container,-rendered in a Hydrogen webview / pup / phone browser.+Parity sibling of fusion-dashboard (adom/fusion-bridge dashboard/src/main.rs):+same LED-with-_hint model, same live channel, same AI-drivability contract, same+visual system. Status comes from shelling `adom-bridge`, so the BRIDGE stays the+single source of truth and this server owns no state the bridge could contradict. -  kicad-dashboard serve [--port 8931] [--target <box>] [--print-url]-  kicad-dashboard status [--target <box>]     one-shot LED JSON+Serves:+  GET  /                     the app (dashboard/ui/index.html, read per request+                             so a ralph loop only needs a browser reload)+  GET  /favicon.svg+  GET  /events               SSE live channel: hello / state / toast / click+  GET  /api/targets          every connected box, lightly probed+  GET  /api/status[/<t>[/force]]   the LED payload (also /state)+  GET  /console              the server's own log lines (what the AI reads back)+  GET  /health               liveness + sseClients (live viewer count)+  POST /api/action {action,target,args}+  POST /ui/toast  {message,kind}    narrate OVER the running app+  POST /ui/click  {target}          the PAGE clicks its own control++CLI:+  kicad-dashboard serve [--port 8931] [--target BOX] [--print-url]+  kicad-dashboard status [--target BOX]+  kicad-dashboard ui toast "msg" [--kind ok|err|info]+  kicad-dashboard ui click <setup|launch|close|demo|part-tour|settings|refresh>+  kicad-dashboard ui state | ui console+  kicad-dashboard open [--pup | --webview] [--target BOX]   kicad-dashboard health++PROXY NOTE (paid for live): adom.cloud STRIPS query strings on sub-paths, so the+target rides the PATH (/api/status/<target>/force), never ?target=. The SSE route+also needs the padding + no-transform headers or the proxy buffers every byte. """ import json+import os+import queue+import re+import shutil import subprocess import sys+import tempfile import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer-from urllib.parse import urlparse, parse_qs+from pathlib import Path+from urllib.parse import unquote, urlparse -PORT = 8931+VERSION = "2.0.0" AI_THREAD = "kicad-dashboard"-_cache = {"t": 0.0, "target": None, "data": None}-_cache_lock = threading.Lock()-DEFAULT_TARGET = None  # first connected+DEFAULT_PORT = 8931+UI_DIR = Path(__file__).resolve().parent / "ui"++FAVICON = ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" '+           'stroke="#00b8b1" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="3"/>'+           '<path d="M8 12h8M12 8v8"/></svg>')  -def ab(target, verb, args, timeout=30):+# ---------------------------------------------------------------- bridge calls+def ab(target, verb, args=None, timeout=140):+    """One bridge verb. Returns the parsed payload dict (never raises)."""     cmd = ["adom-bridge", "--ai-thread", AI_THREAD]-    if target:+    if target and target != "-":         cmd += ["--target", target]-    cmd += [verb, json.dumps(args)]+    cmd += [verb, json.dumps(args or {})]     try:         p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)-        return json.loads(p.stdout.strip() or p.stderr.strip())+        d = json.loads(p.stdout or "{}")+        # ab wraps some payloads in output-as-JSON-string; unwrap when it is one+        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     except Exception as e:-        return {"error": str(e)[:120]}+        return {"error": str(e)}   def get_targets():     try:-        p = subprocess.run(["adom-bridge", "targets"], capture_output=True, text=True, timeout=20)-        d = json.loads(p.stdout.strip())-        return [t.get("name") for t in d.get("targets") or []]+        p = subprocess.run(["adom-bridge", "targets"], capture_output=True, text=True, timeout=30)+        d = json.loads(p.stdout or "{}")+        return [t.get("name") for t in (d.get("targets") or []) if t.get("name")]     except Exception:         return []  +# ------------------------------------------------------------------- SSE hub+class Hub:+    def __init__(self):+        self._clients = []+        self._lock = threading.Lock()++    def add(self):+        q = queue.Queue(maxsize=64)+        with self._lock:+            self._clients.append(q)+        return q++    def drop(self, q):+        with self._lock:+            if q in self._clients:+                self._clients.remove(q)++    def count(self):+        with self._lock:+            return len(self._clients)++    def broadcast(self, event, data):+        msg = f"event: {event}\ndata: {json.dumps(data)}\n\n"+        with self._lock:+            for q in list(self._clients):+                try:+                    q.put_nowait(msg)+                except queue.Full:+                    pass+++HUB = Hub()+CONSOLE = []+_console_lock = threading.Lock()+++def log(line):+    with _console_lock:+        CONSOLE.append(f"{time.strftime('%H:%M:%S')} {line}")+        del CONSOLE[:-300]+    print(line, file=sys.stderr, flush=True)+++# ------------------------------------------------------------------ LED model+def _suite_led():+    """Last daily-suite verdict, plus container-cron liveness (a dead cron means+    the suite silently stops running - it died unnoticed for 4 days once)."""+    try:+        try:+            hb = time.time() - os.path.getmtime("/tmp/cron-heartbeat")+            if hb > 600:+                return {"label": "Daily suite", "state": "err",+                        "detail": f"container cron DEAD ({int(hb // 60)} min since heartbeat)",+                        "_hint": "The container's cron stopped, so the 6:30am verb suite will not run. "+                                 "Restart cron in the container and re-check."}+        except OSError:+            pass+        rp = "/home/adom/.kicad-verbtest-last-report.json"+        if not os.path.exists(rp):+            rp = "/tmp/kicad-verbtest/report.json"+        age_h = (time.time() - os.path.getmtime(rp)) / 3600+        t = (json.load(open(rp)).get("totals")) or {}+        det = f"{t.get('pass', '?')} pass / {t.get('fail', '?')} fail ({int(age_h)}h ago)"+        if age_h > 48:+            return {"label": "Daily suite", "state": "warn", "detail": f"stale: {det}",+                    "_hint": "No suite run in over 48h. Check the cron entry and /tmp/kicad-verbtest/cron.log."}+        if t.get("fail"):+            return {"label": "Daily suite", "state": "warn", "detail": det,+                    "_hint": "Failures are triaged on the wiki issues; the suite files a ticket only for NEW ones."}+        return {"label": "Daily suite", "state": "ok", "detail": det,+                "_hint": "Every verb passed on the last nightly run."}+    except Exception:+        return {"label": "Daily suite", "state": "off", "detail": "no suite report yet",+                "_hint": "The nightly suite writes ~/.kicad-verbtest-last-report.json; "+                         "it runs at 6:30am when the box is idle."}++ def build_status(target):-    """The LED model. Each facet: {state: ok|warn|err|off, detail}."""+    """The LED payload. Each facet: {label, state: ok|warn|err|off, detail, _hint}."""     targets = get_targets()+    if not target or target == "-":+        target = targets[0] if targets else ""     leds = {}     box_ok = bool(target and target in targets)-    leds["relay"] = {"state": "ok" if targets else "err",-                     "detail": f"{len(targets)} box(es) connected" if targets else "no boxes on the relay"}-    leds["box"] = {"state": "ok" if box_ok else "err",-                   "detail": target if box_ok else f"'{target}' not connected"}+    leds["relay"] = {"label": "Relay", "state": "ok" if targets else "err",+                     "detail": f"{len(targets)} box(es) connected" if targets else "no boxes on the relay",+                     "_hint": "Boxes connect through adom-bridge. `adom-bridge targets` lists them; "+                              "if empty, the desktop app is closed or offline."}+    leds["box"] = {"label": "Box", "state": "ok" if box_ok else "err",+                   "detail": target if box_ok else f"'{target or 'none'}' not connected",+                   "_hint": "Pick a connected box with the pills above; the bridge runs there, not in this container."}     if not box_ok:-        for k in ("bridge", "kicad", "plugin", "update", "focus", "presence"):-            leds[k] = {"state": "off", "detail": "box offline"}-        return {"target": target, "targets": targets, "leds": leds,-                "settings": {}, "generatedAt": time.strftime("%H:%M:%S")}+        for k, lbl in (("bridge", "Bridge"), ("kicad", "KiCad"), ("update", "Updates"),+                       ("plugin", "Plugin"), ("focus", "Focus contract"), ("presence", "User presence")):+            leds[k] = {"label": lbl, "state": "off", "detail": "box offline",+                       "_hint": "Unknowable while the box is off the relay - dormant, not broken."}+        leds["suite"] = _suite_led()+        return {"target": target, "targets": targets, "leds": leds, "settings": {},+                "windows": [], "dashboardVersion": VERSION,+                "generatedAt": time.strftime("%H:%M:%S"),+                "_hint": "Every LED carries its own _hint naming the verb that fixes it. "+                         "POST /api/action {action,args} to act; subscribe to /events for live pushes."} -    r = ab(target, "kicad_readiness", {}, timeout=140)+    r = ab(target, "kicad_readiness", {})     installed = bool(r.get("installed", r.get("defaultVersion")))-    ver = r.get("defaultVersion") or "?"-    leds["bridge"] = {"state": "ok" if not r.get("error") else "err",-                      "detail": "answering" if not r.get("error") else str(r.get("error"))[:80]}-    leds["kicad"] = {"state": "ok" if installed else "err",-                     "detail": f"KiCad {ver}" if installed else "not installed (kicad_upgrade installs it)"}-    nudge = r.get("kicadUpdate")-    leds["update"] = ({"state": "warn", "detail": f"{nudge['installed']} -> {nudge['latest']} available"}-                      if nudge else {"state": "ok", "detail": "up to date (checked <24h)"})--    bs = ab(target, "kicad_bridge_status", {}, timeout=140)-    summ = bs.get("summary") or {}-    alive = summ.get("alive", 0) if isinstance(summ, dict) else 0-    leds["plugin"] = ({"state": "ok", "detail": f"{alive} live instance(s)"} if alive-                      else {"state": "warn", "detail": "no live plugin (binds ~10s after a KiCad launch)"})--    st = ab(target, "kicad_state", {"includeImages": False}, timeout=140)-    fd = st.get("focusDebug") or {}-    ev = st.get("focusEvents") or []-    steals = [e for e in ev if "bounced" in (e.get("action") or "")]-    leds["focus"] = {"state": "ok" if not steals else "warn",+    cu = ab(target, "kicad_check_for_updates", {})+    ver = cu.get("currentVersion") or r.get("defaultVersion") or "?"+    leds["bridge"] = {"label": "Bridge", "state": "ok" if not r.get("error") else "err",+                      "detail": "answering" if not r.get("error") else str(r.get("error"))[:70],+                      "_hint": "The kicad_* verb surface is served by the bridge on the box. If red: "+                               "bridge_kill {name:'kicad',confirm:true} respawns it from cache."}+    leds["kicad"] = {"label": "KiCad", "state": "ok" if installed else "err",+                     "detail": f"KiCad {ver}" if installed else "not installed",+                     "_hint": "KiCad is installed and driveable." if installed+                              else "kicad_upgrade {} installs it silently per-user (no UAC prompt)."}+    if cu.get("upgradeAvailable"):+        leds["update"] = {"label": "Updates", "state": "warn",+                          "detail": f"{cu.get('currentVersion')} -> {cu.get('latestVersion')} available",+                          "_hint": "kicad_close then kicad_upgrade {} installs the official release silently. "+                                   "Verify with a gerber export version stamp."}+    else:+        leds["update"] = {"label": "Updates", "state": "ok",+                          "detail": f"up to date (latest {cu.get('latestVersion') or '?'})",+                          "_hint": "The bridge checks upstream every 24h and nudges the AI when a release lands."}++    bs = ab(target, "kicad_bridge_status", {})+    alive = bs.get("alive") or 0+    leds["plugin"] = ({"label": "Plugin", "state": "ok", "detail": f"{alive} live instance(s)",+                       "_hint": "The in-process plugin is bound, so background part navigation is available."}+                      if alive else+                      {"label": "Plugin", "state": "warn", "detail": "no live plugin",+                       "_hint": "Normal while KiCad is closed - it binds ~10s after a launch. "+                                "kicad_install_plugin forces a fresh deploy if it never binds."})++    st = ab(target, "kicad_state", {"includeImages": False})+    ks = st.get("kicadState") or st+    fd = ks.get("focusDebug") or {}+    steals = [e for e in (ks.get("focusEvents") or [])[-20:] if "bounced" in str(e.get("action"))]+    leds["focus"] = {"label": "Focus contract", "state": "ok" if not steals else "warn",                      "detail": (f"quiet - {fd.get('parkedCount', 0)} parked, guards {fd.get('guardCount', 0)}"-                                if not steals else f"{len(steals)} self-raise(s) bounced recently")}-    idle = st.get("userIdleSeconds")+                                if not steals else f"{len(steals)} self-raise(s) bounced recently"),+                     "_hint": "Bridge-owned KiCad windows always open in the background; self-raises are "+                              "bounced and logged. Your own KiCad launches are never touched."}+    idle = ks.get("userIdleSeconds")     if idle is None:-        leds["presence"] = {"state": "off", "detail": "unknown"}+        leds["presence"] = {"label": "User presence", "state": "off", "detail": "unknown",+                            "_hint": "Presence needs a live bridge; it gates window automation."}     elif idle < 600:-        leds["presence"] = {"state": "warn",-                            "detail": f"USER ACTIVE ({int(idle)}s idle) - window automation deferred"}+        leds["presence"] = {"label": "User presence", "state": "warn",+                            "detail": f"USER ACTIVE ({int(idle)}s idle)",+                            "_hint": "Window-spawning automation defers while a human is at the box. "+                                     "Headless kicad-cli work (exports, DRC/ERC) is exempt."}     else:-        leds["presence"] = {"state": "ok", "detail": f"idle {int(idle // 60)} min - automation clear"}+        leds["presence"] = {"label": "User presence", "state": "ok",+                            "detail": f"idle {int(idle // 60)} min",+                            "_hint": "Idle long enough that the daily suite and window automation may run."}+    leds["suite"] = _suite_led()      settings = (ab(target, "kicad_get_settings", {}, timeout=60) or {}).get("settings") or {}     return {"target": target, "targets": targets, "leds": leds, "settings": settings,-            "windows": [w.get("title") for w in st.get("windows") or []],-            "generatedAt": time.strftime("%H:%M:%S")}+            "windows": [w.get("title") for w in (ks.get("windows") or [])],+            "bridgeVersion": ks.get("bridgeVersion") or r.get("bridgeVersion"),+            "dashboardVersion": VERSION, "generatedAt": time.strftime("%H:%M:%S"),+            "_hint": "Every LED carries its own _hint naming the verb that fixes it. "+                     "POST /api/action {action,args} to act; GET /api/status to re-read; "+                     "subscribe to /events (SSE) for live pushes; POST /ui/toast to narrate on the app."}+ +_cache = {"t": 0.0, "target": None, "data": None}+_cache_lock = threading.Lock() -def cached_status(target):++def cached_status(target, force=False):     with _cache_lock:-        if _cache["data"] and _cache["target"] == target and time.time() - _cache["t"] < 8:+        if (not force and _cache["data"] and _cache["target"] == target+                and time.time() - _cache["t"] < 8):             return _cache["data"]     data = build_status(target)     with _cache_lock:         _cache.update(t=time.time(), target=target, data=data)+    HUB.broadcast("state", data)     return data  -PROMPTS = [-    ("Run the guided demo",-     "Run the KiCad bridge demo on my machine: kicad_demo {\"all\": true, \"background\": true}, "-     "poll kicad_demo {\"progress\": true} every 3s, show me each stepShot as it arrives, and "-     "speak result.narrate when done. Everything stays in the background."),-    ("Tour a wiki part end to end",-     "Take the wiki part page I give you (it ships .kicad_sym/.kicad_mod/.step), install it with "-     "kicad_install_symbol/kicad_install_footprint {model:{filePath}} via ab send_files, run "-     "kicad_make_part_project {partName}, then show all 8 surfaces (kicad_show_*) and export "-     "gerbers - all background, with screenshots."),-    ("Update KiCad to the latest",-     "Check kicad_check_for_updates; if an update is available, kicad_close then kicad_upgrade {} "-     "(official installer, silent). Verify with a gerber export version stamp."),-]--PAGE = """<!doctype html><html><head><meta charset="utf-8">-<title>KiCad - Launch Page</title>-<meta name="viewport" content="width=device-width, initial-scale=1">-<style>-:root{--bg:#12141a;--card:#1a1e27;--line:#2a3040;--txt:#e5e8ef;--dim:#8b93a7;---teal:#2dd4bf;--ok:#34d399;--warn:#fbbf24;--err:#f87171;--off:#4b5563}-*{box-sizing:border-box;margin:0;padding:0}-body{background:var(--bg);color:var(--txt);font:15px/1.5 'Segoe UI',system-ui,sans-serif;padding:24px}-h1{font-size:22px;margin-bottom:2px} h1 b{color:var(--teal)}-.sub{color:var(--dim);font-size:13px;margin-bottom:18px}-.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:12px;margin-bottom:20px}-.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px}-.led{display:flex;align-items:center;gap:10px}-.dot{width:12px;height:12px;border-radius:50%;flex:none;box-shadow:0 0 8px currentColor}-.ok{background:var(--ok);color:var(--ok)} .warn{background:var(--warn);color:var(--warn)}-.err{background:var(--err);color:var(--err)} .off{background:var(--off);color:var(--off);box-shadow:none}-.led .name{font-weight:600;font-size:14px} .led .detail{color:var(--dim);font-size:12.5px}-h2{font-size:15px;margin:22px 0 10px;color:var(--teal)}-select,button{background:var(--card);color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:7px 12px;font-size:14px;cursor:pointer}-.toggle{display:flex;align-items:center;gap:10px;padding:10px 0}-.prompt{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px;margin-bottom:10px}-.prompt .t{font-weight:600;margin-bottom:6px}-.prompt pre{white-space:pre-wrap;color:var(--dim);font:12.5px/1.5 Consolas,monospace;margin-bottom:8px}-.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}-.win{color:var(--dim);font-size:12.5px}-#toast{position:fixed;bottom:20px;right:20px;background:var(--card);border:1px solid var(--teal);border-radius:10px;padding:10px 16px;display:none}-</style></head><body>-<h1><b>KiCad</b> - Launch Page</h1>-<div class="sub">Your real KiCad, driven by AI entirely in the background · <span id="gen"></span></div>-<div class="row" style="margin-bottom:14px">-  <label>Box: <select id="target"></select></label>-  <button onclick="refresh(true)">Refresh</button>-</div>-<div class="grid" id="leds"></div>-<div class="card"><h2 style="margin-top:0">Settings</h2>-  <div class="toggle">-    <input type="checkbox" id="overlay" onchange="setSetting()">-    <div><b>Adom overlay badges</b><div class="win">Stamp the Adom favicon on every bridge-driven KiCad taskbar button, so you can see which windows Adom is controlling.</div></div>-  </div>-</div>-<h2>Open KiCad windows</h2><div class="win" id="wins">-</div>-<h2>Copy-paste prompts for your AI</h2><div id="prompts"></div>-<div id="toast"></div>-<script>-const NAMES={relay:'Relay',box:'Box',bridge:'Bridge',kicad:'KiCad',plugin:'Plugin',update:'Updates',focus:'Focus contract',presence:'User presence'};-let cur=null;-async function refresh(force){-  const t=document.getElementById('target').value||'';-  const r=await fetch('api/status/'+encodeURIComponent(t||'-')+(force?'/force':''));-  const d=await r.json(); cur=d;-  document.getElementById('gen').textContent='updated '+d.generatedAt;-  const sel=document.getElementById('target');-  if(sel.options.length===0&&d.targets){d.targets.forEach(x=>{const o=document.createElement('option');o.value=o.textContent=x;if(x===d.target)o.selected=true;sel.appendChild(o);});}-  const g=document.getElementById('leds');g.innerHTML='';-  Object.entries(d.leds||{}).forEach(([k,v])=>{-    g.insertAdjacentHTML('beforeend',`<div class="card led"><div class="dot ${v.state}"></div><div><div class="name">${NAMES[k]||k}</div><div class="detail">${v.detail}</div></div></div>`);-  });-  document.getElementById('overlay').checked=!!(d.settings||{}).overlayBadges;-  document.getElementById('wins').textContent=(d.windows&&d.windows.length)?d.windows.join('  ·  '):'none';-}-async function setSetting(){-  const t=document.getElementById('target').value||'';-  const v=document.getElementById('overlay').checked;-  await fetch('api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({target:t,overlayBadges:v})});-  toast('Overlay badges '+(v?'ON':'OFF'));-}-function toast(m){const e=document.getElementById('toast');e.textContent=m;e.style.display='block';setTimeout(()=>e.style.display='none',2500);}-async function loadPrompts(){-  const r=await fetch('api/prompts');const ps=await r.json();-  const el=document.getElementById('prompts');-  ps.forEach(([t,p])=>{el.insertAdjacentHTML('beforeend',`<div class="prompt"><div class="t">${t}</div><pre>${p}</pre><button onclick="navigator.clipboard.writeText(this.previousElementSibling.textContent).then(()=>toast('Copied'))">Copy</button></div>`);});-}-loadPrompts();refresh();setInterval(()=>refresh(),12000);-</script></body></html>"""+def probe_targets():+    rows = []+    for name in get_targets():+        st = ab(name, "kicad_state", {"includeImages": False}, timeout=60)+        ks = st.get("kicadState") or st+        wins = ks.get("windows") or []+        rows.append({"name": name, "kicadRunning": bool(wins),+                     "windows": len(wins), "kicadVersion": ks.get("kicadVersion")})+    return {"targets": rows,+            "_hint": "Pick where KiCad should run. The bridge only drives instances IT launched, "+                     "so a KiCad the user opened on any box is never parked, bounced, or badged."}+++# -------------------------------------------------------------------- actions+def part_tour(target, args):+    """Clone a wiki part page, push its CAD files to the box, install symbol ++    footprint + 3D model, then generate a single-component project."""+    page = (args or {}).get("page") or "aravk/in-s42ata"+    steps = []+    tmp = tempfile.mkdtemp(prefix="kdash-part-")+    try:+        p = subprocess.run(["adom-wiki", "repo", "clone", page, "--dir", tmp],+                           capture_output=True, text=True, timeout=180)+        if p.returncode != 0:+            return {"success": False, "error": f"clone failed: {p.stderr[:200]}",+                    "_hint": "Give an owner/slug page reference that exists, e.g. aravk/in-s42ata."}+        found = {".kicad_sym": None, ".kicad_mod": None, ".step": None}+        for root, _dirs, names in os.walk(tmp):+            for n in names:+                for ext in found:+                    if n.lower().endswith(ext) and not found[ext]:+                        found[ext] = os.path.join(root, n)+        if not found[".kicad_sym"] and not found[".kicad_mod"]:+            return {"success": False, "error": "page ships no .kicad_sym or .kicad_mod",+                    "_hint": "This tour needs a part page carrying KiCad CAD files."}+        steps.append("cloned " + page)++        send = [f for f in found.values() if f]+        sent = ab(target, "send_files", {"filePaths": send}, timeout=180)+        dest = sent.get("destinationPaths") or []+        if not dest:+            return {"success": False, "steps": steps,+                    "error": f"send_files failed: {str(sent.get('error'))[:150]}"}+        by_ext = {os.path.splitext(d)[1].lower(): d for d in dest}+        steps.append(f"sent {len(dest)} file(s) to the box")++        part = None+        if by_ext.get(".kicad_sym"):+            r = ab(target, "kicad_install_symbol", {"filePath": by_ext[".kicad_sym"]}, timeout=180)+            part = r.get("symbolName") or r.get("partName")+            steps.append("installed symbol" + (f" ({part})" if part else ""))+        if by_ext.get(".kicad_mod"):+            fargs = {"filePath": by_ext[".kicad_mod"]}+            if by_ext.get(".step"):+                fargs["model"] = {"filePath": by_ext[".step"]}+            ab(target, "kicad_install_footprint", fargs, timeout=180)+            steps.append("installed footprint" + (" + 3D model" if by_ext.get(".step") else ""))+        if not part:+            part = os.path.basename(page)+        proj = ab(target, "kicad_make_part_project", {"partName": part}, timeout=180)+        if proj.get("success") is False:+            return {"success": False, "steps": steps,+                    "error": f"project generation failed: {str(proj.get('error'))[:150]}"}+        steps.append("generated project " + str(proj.get("projectDir") or part))+        return {"success": True, "steps": steps, "part": part, "project": proj.get("project"),+                "_hint": "Open it with kicad_open_project, or show any of the eight surfaces "+                         "with the kicad_show_* verbs - all in the background."}+    finally:+        shutil.rmtree(tmp, ignore_errors=True)+++def do_action(target, name, args):+    args = args or {}+    if name == "launch":+        return ab(target, "kicad_launch", {})+    if name == "close":+        return ab(target, "kicad_close", {"force": True})+    if name == "demo":+        return ab(target, "kicad_demo", {"all": True, "background": True})+    if name == "demo_progress":+        return ab(target, "kicad_demo", {"progress": True}, timeout=60)+    if name == "upgrade":+        return ab(target, "kicad_upgrade", {}, timeout=600)+    if name == "install_plugin":+        return ab(target, "kicad_install_plugin", {})+    if name == "set_overlay_badges":+        return ab(target, "kicad_set_settings",+                  {"overlayBadges": bool(args.get("overlayBadges", True))}, timeout=60)+    if name == "part_tour":+        return part_tour(target, args)+    return {"error": f"unknown action {name}",+            "_hint": "Actions: launch, close, demo, demo_progress, upgrade, install_plugin, "+                     "set_overlay_badges, part_tour."}  +# --------------------------------------------------------------------- server class Handler(BaseHTTPRequestHandler):+    protocol_version = "HTTP/1.1"+    server_version = "kicad-dashboard/" + VERSION+     def log_message(self, *a):         pass -    def _json(self, obj, code=200):-        body = json.dumps(obj).encode()+    def _send(self, code, ctype, body, extra=None):+        if isinstance(body, str):+            body = body.encode("utf-8")         self.send_response(code)-        self.send_header("Content-Type", "application/json")+        self.send_header("Content-Type", ctype)         self.send_header("Content-Length", str(len(body)))+        self.send_header("Access-Control-Allow-Origin", "*")+        for k, v in (extra or {}).items():+            self.send_header(k, v)         self.end_headers()         self.wfile.write(body) +    def _json(self, obj, code=200):+        self._send(code, "application/json", json.dumps(obj))++    def _read_body(self):+        try:+            n = int(self.headers.get("Content-Length") or 0)+            return json.loads(self.rfile.read(n) or b"{}") if n else {}+        except Exception:+            return {}+     def do_GET(self):-        u = urlparse(self.path)-        if not u.path.startswith("/api") and u.path != "/health":-            body = PAGE.encode()-            self.send_response(200)-            self.send_header("Content-Type", "text/html; charset=utf-8")-            self.send_header("Content-Length", str(len(body)))-            self.end_headers()-            self.wfile.write(body)-        elif u.path == "/health":-            self._json({"ok": True, "app": "kicad-dashboard"})-        elif u.path == "/api/prompts":-            self._json(PROMPTS)-        elif u.path.startswith("/api/status"):-            # params ride the PATH (/api/status/<target>[/force]) — the-            # adom.cloud proxy 404s querystring requests on subpaths.-            parts = [p for p in u.path.split("/") if p][2:]-            q = parse_qs(u.query)-            _p0 = (parts[0] if parts else "")-            if _p0 == "-":-                _p0 = ""-            target = (_p0 or (q.get("target") or [""])[0]-                      or DEFAULT_TARGET or (get_targets() or [None])[0])-            if "force" in parts or q.get("force"):-                with _cache_lock:-                    _cache["t"] = 0-            self._json(cached_status(target))-        else:-            self._json({"error": "not found"}, 404)+        path = urlparse(self.path).path.rstrip("/") or "/"+        if path in ("/favicon.svg", "/favicon.ico"):+            return self._send(200, "image/svg+xml", FAVICON)+        if path == "/health":+            return self._json({"ok": True, "app": "kicad-dashboard", "version": VERSION,+                               "sseClients": HUB.count()})+        if path == "/console":+            with _console_lock:+                return self._json({"lines": list(CONSOLE)})+        if path == "/events":+            return self._sse()+        if path == "/api/targets":+            return self._json(probe_targets())+        m = re.match(r"^/(?:api/status|state)(?:/([^/]+))?(/force)?$", path)+        if m:+            target = unquote(m.group(1) or "-")+            force = bool(m.group(2))+            log(f"status target={target}{' force' if force else ''}")+            return self._json(cached_status(target, force))+        try:  # any other GET renders the app (proxy-friendly: sub-paths still work)+            return self._send(200, "text/html; charset=utf-8",+                              (UI_DIR / "index.html").read_text(encoding="utf-8"))+        except Exception as e:+            return self._json({"error": f"ui missing: {e}"}, 500)      def do_POST(self):-        u = urlparse(self.path)-        n = int(self.headers.get("Content-Length") or 0)+        path = urlparse(self.path).path.rstrip("/") or "/"+        body = self._read_body()+        if path == "/api/action":+            name = body.get("action") or ""+            target = body.get("target") or (get_targets() or ["-"])[0]+            log(f"action {name} target={target}")+            return self._json(do_action(target, name, body.get("args")))+        if path == "/ui/toast":+            msg, kind = body.get("message") or "", body.get("kind") or "info"+            log(f"toast [{kind}] {msg}")+            HUB.broadcast("toast", {"message": msg, "kind": kind})+            return self._json({"ok": True, "delivered": HUB.count(),+                               "_hint": "delivered = live SSE viewers that showed it. "+                                        "0 means nobody has the page open - open it first."})+        if path == "/ui/click":+            t = body.get("target") or ""+            log(f"ui click {t}")+            HUB.broadcast("click", {"target": t})+            return self._json({"ok": True, "delivered": HUB.count(),+                               "_hint": "Targets: setup, launch, close, demo, part-tour, settings, refresh. "+                                        "The PAGE performs the click, so what the AI drives is what the user gets."})+        return self._json({"error": "not found",+                           "_hint": "POST /api/action {action,args}, /ui/toast {message,kind}, /ui/click {target}"}, 404)++    def _sse(self):+        """Headers immediately, then one flush PER EVENT. The 2KB comment padding+        plus no-transform / X-Accel-Buffering defeat proxy buffering (the adom.cloud+        proxy otherwise holds every byte until the stream ends)."""+        self.send_response(200)+        self.send_header("Content-Type", "text/event-stream")+        self.send_header("Cache-Control", "no-cache, no-transform")+        self.send_header("X-Accel-Buffering", "no")+        self.send_header("Access-Control-Allow-Origin", "*")+        self.send_header("Connection", "keep-alive")+        self.end_headers()+        q = HUB.add()+        log(f"sse client connected ({HUB.count()} live)")         try:-            args = json.loads(self.rfile.read(n) or b"{}")+            self.wfile.write((": " + "p" * 2048 + "\n\n").encode())+            self.wfile.write(f"event: hello\ndata: {json.dumps({'version': VERSION})}\n\n".encode())+            self.wfile.flush()+            while True:+                try:+                    msg = q.get(timeout=15)+                except queue.Empty:+                    msg = ": ping\n\n"+                self.wfile.write(msg.encode())+                self.wfile.flush()         except Exception:-            args = {}-        if u.path == "/api/settings":-            target = args.pop("target", None) or DEFAULT_TARGET or (get_targets() or [None])[0]-            r = ab(target, "kicad_set_settings", args, timeout=60)-            with _cache_lock:-                _cache["t"] = 0-            self._json({"ok": bool(r.get("success")), "result": {k: r.get(k) for k in ("settings", "changed", "error")}})-        else:-            self._json({"error": "not found"}, 404)+            pass+        finally:+            HUB.drop(q)+            log(f"sse client disconnected ({HUB.count()} live)")+++# ------------------------------------------------------------------------ CLI+def proxy_url(port):+    tpl = os.environ.get("VSCODE_PROXY_URI") or ""+    if "{{port}}" in tpl:+        return tpl.replace("{{port}}", str(port))+    return f"http://127.0.0.1:{port}/"+++def local_call(port, path, payload=None):+    import urllib.request+    url = f"http://127.0.0.1:{port}{path}"+    data = json.dumps(payload).encode() if payload is not None else None+    req = urllib.request.Request(url, data=data, method="POST" if data else "GET",+                                 headers={"Content-Type": "application/json"})+    try:+        with urllib.request.urlopen(req, timeout=20) as r:+            return json.loads(r.read().decode())+    except Exception as e:+        return {"error": str(e),+                "_hint": f"Is the dashboard serving? `kicad-dashboard serve --port {port}` (health: /health)."}   def main():-    global DEFAULT_TARGET, PORT-    argv = sys.argv[1:]-    cmd = argv[0] if argv else "serve"-    if "--target" in argv:-        DEFAULT_TARGET = argv[argv.index("--target") + 1]-    if "--port" in argv:-        PORT = int(argv[argv.index("--port") + 1])-    if cmd == "health":-        import urllib.request+    argv = sys.argv[1:] or ["serve"]+    cmd = argv[0]++    def flag(name, default=None):+        return argv[argv.index(name) + 1] if name in argv and argv.index(name) + 1 < len(argv) else default++    port = int(flag("--port", DEFAULT_PORT))+    target = flag("--target", "")++    if cmd == "serve":+        if "--print-url" in argv:+            print(proxy_url(port), flush=True)+        srv = ThreadingHTTPServer(("0.0.0.0", port), Handler)+        srv.daemon_threads = True+        log(f"kicad-dashboard {VERSION} serving on {port} (target={target or 'auto'})")         try:-            with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/health", timeout=3) as r:-                print(r.read().decode())-                return-        except Exception as e:-            print(json.dumps({"ok": False, "error": str(e)}))-            sys.exit(1)+            srv.serve_forever()+        except KeyboardInterrupt:+            pass+        return+     if cmd == "status":-        print(json.dumps(build_status(DEFAULT_TARGET or (get_targets() or [None])[0]), indent=2))+        print(json.dumps(build_status(target or "-"), indent=2))         return-    # serve-    srv = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)-    if "--print-url" in argv:-        print(f"http://127.0.0.1:{PORT}/")-    print(f"kicad-dashboard serving on :{PORT}", file=sys.stderr)-    srv.serve_forever()++    if cmd == "health":+        print(json.dumps(local_call(port, "/health")))+        return++    if cmd == "ui":+        sub = argv[1] if len(argv) > 1 else ""+        if sub == "toast":+            print(json.dumps(local_call(port, "/ui/toast",+                                        {"message": argv[2] if len(argv) > 2 else "",+                                         "kind": flag("--kind", "info")})))+        elif sub == "click":+            print(json.dumps(local_call(port, "/ui/click",+                                        {"target": argv[2] if len(argv) > 2 else ""})))+        elif sub == "state":+            print(json.dumps(local_call(port, "/state"), indent=2))+        elif sub == "console":+            print(json.dumps(local_call(port, "/console"), indent=2))+        else:+            print(json.dumps({"error": "ui <toast|click|state|console>"}))+        return++    if cmd == "open":+        url = proxy_url(port)+        box = target or (get_targets() or [""])[0]+        if "--webview" in argv:+            print(json.dumps({"url": url, "surface": "webview",+                              "_hint": "Open this URL in a Hydrogen webview panel (the `webview` skill); "+                                       "panel placement stays the AI's decision."}))+            return+        r = ab(box, "pup_open_window", {"sessionId": "kicad-dashboard", "url": url}, timeout=120)+        ab(box, "pup_alert_window", {"sessionId": "kicad-dashboard"}, timeout=60)+        print(json.dumps({"url": url, "surface": "pup", "target": box, "opened": r.get("success"),+                          "_hint": "Opened in a BACKGROUND pup window and flashed its taskbar button. "+                                   "Never raise it yourself - let the user click it."}))+        return++    print(json.dumps({"error": f"unknown command {cmd}",+                      "_hint": "serve | status | ui <toast|click|state|console> | open | health"}))   if __name__ == "__main__":
dashboard/ui/index.htmladded+352
@@ -0,0 +1,352 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>KiCad Dashboard</title>+<link rel="icon" type="image/svg+xml" href="favicon.svg">+<style>+:root{+  --bg:#0d1117;--surface:#161b22;--elevated:#1c2128;--overlay:#21262d;--border:#30363d;+  --text:#e6edf3;--text-body:#c9d1d9;--text-2:#8b949e;+  --accent:#00b8b1;--accent-hover:#00d4cb;--green:#3fb950;--red:#f85149;--yellow:#d29922;--blue:#64abff;+}+*{box-sizing:border-box;margin:0;padding:0}+@font-face{font-family:'Familjen Grotesk';src:url('https://adom.inc/fonts/FamiljenGrotesk-SemiBold.woff2') format('woff2');font-weight:600}+@font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/Satoshi-Regular.woff2') format('woff2');font-weight:400}+@font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/Satoshi-Medium.woff2') format('woff2');font-weight:500}+body{background:var(--bg);color:var(--text-body);font:15px/1.5 'Satoshi',sans-serif;min-height:100vh}+header{height:44px;display:flex;align-items:center;gap:12px;padding:0 16px;background:var(--surface);border-bottom:1px solid var(--border)}+.brand{display:flex;align-items:center;gap:8px;font-family:'Familjen Grotesk',sans-serif;font-weight:600;color:var(--accent);white-space:nowrap;cursor:default}+.brand svg{width:16px;height:16px;stroke:var(--text)}+.vsep{width:1px;height:20px;background:var(--border)}+.subject{font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}+.meta{font:12px 'JetBrains Mono',monospace;color:var(--text-2)}+.actions{margin-left:auto;display:flex;gap:8px}+.iconbtn{width:32px;height:32px;display:flex;align-items:center;justify-content:center;background:none;border:1px solid transparent;border-radius:8px;color:var(--text-body);cursor:pointer;transition:background .15s ease,border-color .15s ease,transform .06s ease}+.iconbtn:hover{background:var(--elevated);border-color:var(--border)}+.iconbtn:active{transform:scale(.9);background:var(--overlay)}+main{max-width:1080px;margin:0 auto;padding:20px 16px 60px}+h2{font-family:'Familjen Grotesk',sans-serif;font-size:17px;color:var(--text);margin:26px 0 12px}+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:12px}+.card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px}+.led-row{display:flex;align-items:center;gap:10px}+.led{width:12px;height:12px;border-radius:999px;background:var(--text-2);flex:none;transition:background .15s ease}+.led.on{background:var(--green);box-shadow:0 0 8px rgba(63,185,80,.5)}+.led.off{background:var(--red)}+.led.warn{background:var(--yellow)}+.led.dim{background:var(--text-2)}+.led-label{font-weight:500;color:var(--text)}+.led-detail{font-size:12.5px;color:var(--text);margin-top:6px}+.led-sub{font-size:12.5px;color:var(--text-2);margin-top:6px}+.btn{display:inline-flex;align-items:center;gap:8px;min-height:36px;padding:0 14px;border-radius:8px;border:1px solid var(--border);background:var(--elevated);color:var(--text);font:500 14px 'Satoshi',sans-serif;cursor:pointer;transition:background .15s ease,border-color .15s ease,transform .06s ease}+.btn:hover{background:var(--overlay);border-color:#4b5563}+.btn:active{transform:translateY(1px) scale(.98);background:var(--surface)}+.btn.primary{background:var(--accent);border-color:var(--accent);color:#05221f}+.btn.primary:hover{background:var(--accent-hover)}+.btn.primary:active{background:#009d97;border-color:#009d97;transform:translateY(1px) scale(.98)}+.btn:disabled{opacity:.5;cursor:not-allowed}+.btn:focus-visible,.iconbtn:focus-visible,.tpill:focus-visible,.toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px}+.btnrow{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px}+.prompt{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:8px;transition:border-color .15s ease}+.prompt:hover{border-color:#4b5563}+input:focus-visible{outline:none;border-color:var(--accent) !important}+.prompt code{font:13px 'JetBrains Mono',monospace;color:var(--text-body);flex:1;white-space:normal}+.copy{flex:none}+.bar{height:8px;background:var(--elevated);border:1px solid var(--border);border-radius:999px;overflow:hidden;margin-top:10px;display:none}+.bar>div{height:100%;width:0;background:var(--accent);transition:width .5s ease}+.barlabel{font-size:12.5px;color:var(--text-2);margin-top:6px}+.shots{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;margin-top:12px}+.shot{background:var(--elevated);border:1px solid var(--border);border-radius:8px;overflow:hidden}+.shot img{width:100%;display:block}+.shot .cap{font-size:11.5px;color:var(--text-2);padding:6px 8px}+#toasts{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);display:flex;flex-direction:column-reverse;gap:8px;z-index:99999}+.toast{background:var(--overlay);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:8px;padding:10px 14px;color:var(--text);font-size:14px;box-shadow:0 8px 24px rgba(0,0,0,.4)}+.toast.ok{border-left-color:var(--green)}.toast.err{border-left-color:var(--red)}+dialog{margin:auto;background:var(--overlay);color:var(--text-body);border:1px solid var(--border);border-radius:12px;padding:20px;max-width:520px;width:92vw}+dialog::backdrop{background:rgba(0,0,0,.55)}+.setrow{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 0;border-bottom:1px solid var(--border)}+.toggle{width:42px;height:24px;border-radius:999px;background:var(--elevated);border:1px solid var(--border);position:relative;cursor:pointer;flex:none;transition:border-color .15s ease}+.toggle:hover{border-color:var(--accent)}+.toggle:active::after{width:22px}+.toggle.is-on:active::after{left:17px}+.toggle::after{content:'';position:absolute;top:2px;left:2px;width:18px;height:18px;border-radius:999px;background:var(--text-2);transition:all .15s ease}+.toggle.is-on{background:var(--accent);border-color:var(--accent)}+.toggle.is-on::after{left:21px;background:#05221f}+@media (max-width:720px){.meta{display:none}.grid{grid-template-columns:1fr 1fr}}+@media (max-width:480px){.grid{grid-template-columns:1fr}.brand span{display:none}.subject{display:none}.vsep{display:none}header{gap:8px}}+#tt{position:fixed;z-index:99999;background:var(--overlay);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:12.5px;color:var(--text-body);max-width:260px;pointer-events:none;opacity:0;transform:translateY(3px);transition:opacity .18s ease,transform .18s ease}+#tt.show{opacity:1;transform:none}+#appinfo{position:fixed;z-index:99998;background:var(--overlay);border:1px solid var(--border);border-radius:10px;padding:12px 14px;font-size:13px;color:var(--text-body);box-shadow:0 8px 24px rgba(0,0,0,.4);display:none;pointer-events:auto;min-width:220px}+#appinfo.show{display:block}+#appinfo a{color:var(--accent)}+#targets{display:flex;gap:8px;margin:0 0 14px;flex-wrap:wrap}+.tpill{display:inline-flex;align-items:center;gap:8px;padding:7px 14px;border-radius:999px;border:1px solid var(--border);background:var(--surface);color:var(--text-body);cursor:pointer;font:500 13.5px 'Satoshi',sans-serif;transition:background .15s ease,border-color .15s ease,color .15s ease,transform .06s ease}+.tpill:hover{border-color:#4b5563;background:var(--elevated);color:var(--text)}+.tpill:active{transform:translateY(1px) scale(.97)}+.tpill.active{border-color:var(--accent);color:var(--text);background:var(--elevated)}+.tdot{width:8px;height:8px;border-radius:999px;background:var(--text-2)}+.tdot.on{background:var(--green)}+.note{font-size:12.5px;color:var(--text-2);margin:-6px 0 12px}+.wins{font:12.5px 'JetBrains Mono',monospace;color:var(--text-2)}+</style>+</head>+<body>+<header>+  <div class="brand">+    <svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M8 12h8M12 8v8"/></svg>+    KiCad Dashboard <span style="color:var(--text-2)">&middot; bridge launch page</span>+  </div>+  <div class="vsep"></div>+  <div class="subject" id="subject">connecting...</div>+  <div class="meta" id="meta"></div>+  <div class="actions">+    <button class="iconbtn" id="btnSettings" data-tip="Settings: bridge defaults like the Adom overlay badge on KiCad taskbar buttons (sticky per box)" aria-label="Settings"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1 1.51 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.26.6.86 1 1.51 1H21a2 2 0 1 1 0 4h-.09c-.65 0-1.25.4-1.51 1z"/></svg></button>+    <button class="iconbtn" id="btnRefresh" data-tip="Re-read every facet's status from the bridge right now (also auto-refreshes every 15s)" aria-label="Refresh"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6"/></svg></button>+  </div>+</header>+<main>+  <h2>Status</h2>+  <div id="targets"></div>+  <div class="note" id="boxnote" style="display:none">Many boxes, one bridge: pick where KiCad should run. The bridge only ever drives the KiCad instances IT launched, so your own KiCad is never touched.</div>+  <div class="grid" id="ledGrid"></div>+  <div class="btnrow">+    <button class="btn primary" id="btnSetup">Setup</button>+    <button class="btn" id="btnLaunch">Launch KiCad</button>+    <button class="btn" id="btnClose">Close KiCad</button>+  </div>+  <div class="wins" id="wins" style="margin-top:12px"></div>++  <h2>Demos</h2>+  <div class="card">+    <div class="led-label">Guided demo</div>+    <div class="led-sub">Six beats on a generated sample project: schematic, 2D board, 3D board, symbol, footprint, 3D chip. Runs entirely in the background, and each finished beat streams back a screenshot.</div>+    <div class="btnrow"><button class="btn primary" id="btnDemo">Run the demo</button></div>+    <div class="bar" id="demoBar"><div></div></div>+    <div class="barlabel" id="demoLabel"></div>+    <div class="shots" id="demoShots"></div>+  </div>+  <div class="card" style="margin-top:12px">+    <div class="led-label">Tour a wiki part end to end</div>+    <div class="led-sub">Pulls a part page from wiki.adom.inc, installs its symbol, footprint and 3D model into KiCad, then generates a single-component project you can open.</div>+    <div class="btnrow">+      <input id="partPage" value="aravk/in-s42ata" data-tip="Any wiki part page that ships .kicad_sym / .kicad_mod / .step files" style="flex:1;min-width:180px;background:var(--elevated);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:8px 10px;font:13px 'JetBrains Mono',monospace">+      <button class="btn" id="btnPartTour">Tour this part</button>+    </div>+    <div class="barlabel" id="partLabel"></div>+  </div>++  <h2>Copy-paste prompts for your AI</h2>+  <div id="prompts"></div>+</main>++<dialog id="dlgSettings">+  <h2 style="margin:0 0 8px">Settings</h2>+  <div class="setrow">+    <div>+      <div class="led-label">Adom overlay badges</div>+      <div class="led-sub">Stamp the Adom favicon on every bridge-driven KiCad taskbar button, so you can see at a glance which windows Adom is controlling. Never applied to KiCad windows you launched yourself.</div>+    </div>+    <div class="toggle" id="tglOverlay" role="switch" aria-checked="true" tabindex="0"></div>+  </div>+  <div class="btnrow" style="justify-content:flex-end"><button class="btn" id="btnCloseSettings">Done</button></div>+</dialog>++<dialog id="dlgSetup">+  <h2 style="margin:0 0 8px">Setup</h2>+  <div id="setupBody" class="led-sub" style="font-size:14px"></div>+  <div class="btnrow" style="justify-content:flex-end">+    <button class="btn primary" id="btnSetupNext">Run the next step</button>+    <button class="btn" id="btnCloseSetup">Close</button>+  </div>+</dialog>++<div id="toasts"></div>+<div id="tt" role="tooltip"></div>+<div id="appinfo"></div>+<script>+const $=s=>document.querySelector(s);+const consoleBuf=[];+["log","warn","error"].forEach(k=>{const o=console[k].bind(console);console[k]=(...a)=>{consoleBuf.push(k+": "+a.join(" "));if(consoleBuf.length>300)consoleBuf.shift();o(...a);};});+window.onerror=(m)=>{consoleBuf.push("onerror: "+m);};+function toast(msg,kind){const t=document.createElement('div');t.className='toast '+(kind||'');t.textContent=msg;$('#toasts').appendChild(t);setTimeout(()=>t.remove(),kind==='err'?9000:4000);}+async function api(path,opts){const r=await fetch(path,opts);return r.json();}+async function act(action,args){return api('api/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,target:curTarget||undefined,args:args||{}})});}++let lastStatus=null;+let curTarget=localStorage.getItem('kdTarget')||'';+let targetsCache=[];+// The adom.cloud proxy STRIPS query strings on sub-paths, so target rides the PATH.+function statusUrl(force){return 'api/status/'+encodeURIComponent(curTarget||'-')+(force?'/force':'');}+async function loadTargets(){+  try{+    const d=await api('api/targets');+    targetsCache=d.targets||[];+    const el=$('#targets');el.innerHTML='';+    $('#boxnote').style.display=targetsCache.length>1?'':'none';+    if(!curTarget&&targetsCache.length){curTarget=(targetsCache.find(t=>t.kicadRunning)||targetsCache[0]).name;localStorage.setItem('kdTarget',curTarget);}+    for(const t of targetsCache){+      const b=document.createElement('button');b.className='tpill'+(t.name===curTarget?' active':'');+      b.setAttribute('data-tip',(t.kicadRunning?'KiCad is running here':'KiCad is closed here')+(t.kicadVersion?(' - KiCad '+t.kicadVersion):''));+      b.innerHTML='<span class="tdot'+(t.kicadRunning?' on':'')+'"></span>'+t.name;+      b.onclick=()=>{curTarget=t.name;localStorage.setItem('kdTarget',curTarget);loadTargets();refresh();toast('switched to '+t.name)};+      el.appendChild(b);+    }+  }catch(e){}+}+function renderLeds(st){+  const g=$('#ledGrid');g.innerHTML='';+  const leds=st.leds||{};+  const ORDER=['relay','box','bridge','kicad','update','plugin','focus','presence','suite'];+  const keys=ORDER.filter(k=>leds[k]).concat(Object.keys(leds).filter(k=>!ORDER.includes(k)));+  for(const key of keys){+    const l=leds[key];+    const cls=l.state==='ok'?'on':(l.state==='warn'?'warn':(l.state==='err'?'off':'dim'));+    const el=document.createElement('div');el.className='card';+    el.innerHTML=`<div class="led-row"><span class="led ${cls}"></span><span class="led-label">${l.label||key}</span></div>`++                 `<div class="led-detail">${l.detail||''}</div><div class="led-sub">${l._hint||''}</div>`;+    g.appendChild(el);+  }+  $('#subject').textContent=st.target?('target: '+st.target):'no bridge target';+  $('#meta').textContent='bridge '+(st.bridgeVersion||'?')+' | dashboard '+(st.dashboardVersion||'');+  const w=st.windows||[];+  $('#wins').textContent=w.length?('open KiCad windows: '+w.join(' · ')):'no KiCad windows open';+}+async function refresh(force){try{lastStatus=await api(statusUrl(force));renderLeds(lastStatus);}catch(e){toast('status failed: '+e,'err');}}+$('#btnRefresh').onclick=()=>{toast('refreshing status');refresh(true);};+$('#btnLaunch').onclick=async()=>{toast('launching KiCad in the background');await act('launch');setTimeout(()=>refresh(true),4000);};+$('#btnClose').onclick=async()=>{toast('closing the bridge\u2019s KiCad windows');await act('close');setTimeout(()=>refresh(true),4000);};++// Guided demo with a REAL progress bar + the stepShot stream (one screenshot per finished beat).+$('#btnDemo').onclick=async()=>{+  const b=$('#btnDemo');b.disabled=true;+  toast('starting the guided demo (background)');+  $('#demoShots').innerHTML='';+  const s=await act('demo');const d=s.data||s;+  if(d.success===false){toast('demo failed to start: '+(d.error||'unknown'),'err');b.disabled=false;return;}+  $('#demoBar').style.display='block';+  const poll=setInterval(async()=>{+    const p=await act('demo_progress');const q=p.data||p;+    $('#demoBar>div').style.width=(q.percent||0)+'%';+    $('#demoLabel').textContent=(q.percent||0)+'% '+(q.stepLabel||'');+    if(q.stepShot&&q.stepShot.image){+      const c=document.createElement('div');c.className='shot';+      c.innerHTML='<img src="data:image/png;base64,'+q.stepShot.image+'" alt="'+(q.stepShot.title||'')+'"><div class="cap">'+(q.stepShot.title||q.stepShot.step||'')+'</div>';+      $('#demoShots').appendChild(c);+    }+    if(q.done){clearInterval(poll);b.disabled=false;toast(q.error?('demo failed: '+q.error):'demo complete','' + (q.error?'err':'ok'));refresh(true);}+  },3000);+};+$('#btnPartTour').onclick=async()=>{+  const page=$('#partPage').value.trim();+  const b=$('#btnPartTour');b.disabled=true;+  toast('touring '+page+' (clone, install, generate project)');+  $('#partLabel').textContent='working...';+  const r=await act('part_tour',{page});const d=r.data||r;+  b.disabled=false;+  if(d.success===false){toast('part tour failed: '+(d.error||'unknown'),'err');$('#partLabel').textContent=d.error||'failed';}+  else{toast('part installed and project generated','ok');$('#partLabel').textContent=(d.steps||[]).join(' · ');refresh(true);}+};++// Settings+$('#btnSettings').onclick=()=>{const s=(lastStatus&&lastStatus.settings)||{};+  const t=$('#tglOverlay');const on=s.overlayBadges!==false;t.classList.toggle('is-on',on);t.setAttribute('aria-checked',String(on));+  $('#dlgSettings').showModal();};+$('#btnCloseSettings').onclick=()=>$('#dlgSettings').close();+$('#tglOverlay').onclick=async(e)=>{+  const t=e.currentTarget;const now=!t.classList.contains('is-on');+  t.classList.toggle('is-on',now);t.setAttribute('aria-checked',String(now));+  await act('set_overlay_badges',{overlayBadges:now});+  toast('overlay badges '+(now?'on':'off')+' (sticky)','ok');+};++// Setup: walk the not-green facets in order, one action per click.+const SETUP_ORDER=[+  {led:'kicad',action:'upgrade',label:'Install KiCad (silent, per-user, no UAC)'},+  {led:'update',action:'upgrade',label:'Update KiCad to the latest release'},+  {led:'plugin',action:'launch',label:'Launch KiCad so the reverse-bridge plugin binds'},+];+function setupPending(){if(!lastStatus)return[];return SETUP_ORDER.filter(s=>{const l=lastStatus.leds[s.led];return l&&l.state!=='ok'&&l.state!=='off';});}+function renderSetup(){+  const p=setupPending();+  $('#setupBody').innerHTML=p.length?('Remaining steps:<br>'+p.map((s,i)=>`${i+1}. ${s.label}`).join('<br>')):'Everything is configured. You are ready to go.';+  $('#btnSetupNext').style.display=p.length?'':'none';+}+$('#btnSetup').onclick=async()=>{await refresh(true);renderSetup();$('#dlgSetup').showModal();};+$('#btnCloseSetup').onclick=()=>$('#dlgSetup').close();+$('#btnSetupNext').onclick=async()=>{+  const p=setupPending();if(!p.length)return;+  toast('running: '+p[0].label);+  await act(p[0].action);+  setTimeout(async()=>{await refresh(true);renderSetup();},5000);+};++// Copy-paste AI prompts (teach the user to drive the bridge through their AI).+const PROMPTS=[+  'run the kicad demo and show me each step screenshot as it arrives',+  'tour the wiki part aravk/in-s42ata in kicad: symbol, footprint, 3d chip, project, schematic, 2d board, 3d board',+  'is kicad up to date on all my boxes? update whatever is behind',+  'show me the symbol and footprint for the part I just installed',+  'make a single-component project for this part and show me the 3d board',+  'export gerbers for the open board and tell me the KiCad version stamp',+  'are any kicad windows in my foreground? prove the bridge stayed in the background',+];+$('#prompts').innerHTML=PROMPTS.map(p=>`<div class="prompt"><code>${p}</code><button class="btn copy" onclick="navigator.clipboard.writeText('${p.replace(/'/g,"\\'")}');toast('copied','ok')">Copy</button></div>`).join('');++// Live channel (SSE): state pushes, AI toasts, CLI click directives.+const CLICK_TARGETS={setup:'#btnSetup',launch:'#btnLaunch',close:'#btnClose',demo:'#btnDemo','part-tour':'#btnPartTour',settings:'#btnSettings',refresh:'#btnRefresh'};+function connectSSE(){+  const es=new EventSource('events');+  es.addEventListener('hello',()=>{console.log('sse connected');});+  es.addEventListener('state',e=>{try{const st=JSON.parse(e.data);if(!curTarget||st.target===curTarget){lastStatus=st;renderLeds(st);}}catch(_){}});+  es.addEventListener('toast',e=>{try{const t=JSON.parse(e.data);toast(t.message,t.kind==='info'?'':t.kind);}catch(_){}});+  es.addEventListener('click',e=>{try{+    const {target}=JSON.parse(e.data);const sel=CLICK_TARGETS[target];+    if(sel&&$(sel)){$(sel).click();}else{toast('unknown click target: '+target,'err');}+  }catch(_){}});+  es.onerror=()=>{es.close();setTimeout(connectSSE,3000);};+}+connectSSE();++// Tooltips: body-appended, fixed, clamped to the viewport on BOTH axes.+const tt=$('#tt');let ttTimer=null;+document.addEventListener('mouseover',e=>{+  const el=e.target.closest('[data-tip]');+  if(!el){clearTimeout(ttTimer);tt.classList.remove('show');return;}+  clearTimeout(ttTimer);+  ttTimer=setTimeout(()=>{+    tt.textContent=el.getAttribute('data-tip');+    tt.style.left='0px';tt.style.top='0px';tt.classList.add('show');+    const r=el.getBoundingClientRect(),M=8,w=tt.offsetWidth,h=tt.offsetHeight;+    let x=r.left+r.width/2-w/2, y=r.bottom+10;+    if(y+h>innerHeight-M) y=r.top-h-10;+    x=Math.max(M,Math.min(x,innerWidth-w-M));+    y=Math.max(M,Math.min(y,innerHeight-h-M));+    tt.style.left=x+'px';tt.style.top=y+'px';+  },600);+});+document.addEventListener('mouseout',e=>{if(e.target.closest('[data-tip]')){clearTimeout(ttTimer);tt.classList.remove('show');}});++// Brand hover popover: app identity lives here, not in the header row.+const ai=$('#appinfo');const brand=document.querySelector('.brand');+let aiShow=null,aiHide=null;+function fillAppInfo(){+  const v=lastStatus?lastStatus.dashboardVersion:'';const bv=lastStatus?(lastStatus.bridgeVersion||'?'):'?';+  ai.innerHTML='<b style="color:var(--text)">KiCad Dashboard</b><br>dashboard v'+(v||'?')+' \u00b7 bridge v'+bv++    '<br>engine: kicad-dashboard (Python) + adom-bridge<br>author: adom<br>'++    '<a href="https://wiki.adom.inc/adom/kicad-bridge" target="_blank" rel="noopener">wiki.adom.inc/adom/kicad-bridge</a>';+}+brand.addEventListener('mouseenter',()=>{clearTimeout(aiHide);aiShow=setTimeout(()=>{+  fillAppInfo();ai.classList.add('show');+  const r=brand.getBoundingClientRect(),M=8;+  let x=Math.max(M,Math.min(r.left,innerWidth-ai.offsetWidth-M));+  ai.style.left=x+'px';ai.style.top=Math.min(r.bottom+8,innerHeight-ai.offsetHeight-M)+'px';+},600);});+brand.addEventListener('mouseleave',()=>{clearTimeout(aiShow);aiHide=setTimeout(()=>ai.classList.remove('show'),220);});+ai.addEventListener('mouseenter',()=>clearTimeout(aiHide));+ai.addEventListener('mouseleave',()=>{aiHide=setTimeout(()=>ai.classList.remove('show'),220);});++loadTargets().then(()=>refresh());setInterval(()=>refresh(),15000);setInterval(loadTargets,60000);+</script>+</body>+</html>
dock.jsonadded+26
@@ -0,0 +1,26 @@+{+  "dock_manifest": "1.1.0",+  "webview": { "title": "KiCad Dashboard", "favicon": "docs/img/dock-kicad.svg" },+  "launch": {+    "mode": "container_command",+    "console": "show",+    "steps": [+      { "label": "Installing the KiCad bridge",+        "run": "adom-wiki pkg install adom/kicad-bridge",+        "skip_if": "command -v kicad-dashboard",+        "timeout_seconds": 180 },+      { "label": "Starting the KiCad Dashboard",+        "run": "kicad-dashboard serve --print-url",+        "await": "url",+        "timeout_seconds": 60 }+    ],+    "on_ready": "navigate_placeholder",+    "ai": {+      "prompt": "The user just opened the KiCad Dashboard from the dock. It shows LED status for every KiCad bridge facet. If any LED is not green, offer to fix it (the /api/status payload carries a _hint per LED naming the exact verb). Everything the bridge does to KiCad happens in the background; never steal their foreground, and never touch a KiCad instance the user launched themselves.",+      "thread": "existing",+      "thread_fallback": "new",+      "submit": "staged",+      "typed": false+    }+  }+}
server.py+5−1
@@ -791,9 +791,13 @@ def dispatch_command(command: str, args: dict) -> dict:                 _wfg.end_gui_verb()         except Exception:             pass-    # Stamp the version actually used so callers can see which install ran+    # Stamp the version actually used so callers can see which install ran, and+    # OUR OWN version — 0.9.159: BRIDGE_VERSION only ever reached /health, so no+    # caller (the dashboard included) could ask which bridge code is running, and+    # "is my fix deployed?" needed a filesystem read on the box.     if isinstance(result, dict):         result.setdefault("kicadVersionUsed", selected_info.get("version"))+        result.setdefault("bridgeVersion", BRIDGE_VERSION)     # CONSTANT DIALOG COVERAGE: after EVERY GUI-affecting verb, sweep for KiCad     # dialogs (KiCad throws modal error boxes constantly — "Cannot enumerate     # ...Templates (Access denied)", save prompts, format warnings — that