app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
Publish 1.0.74
3 files changed
+12−242
dashboard/kicad_dashboard.py+10−240@@ -97,12 +97,6 @@ def say_phrases(): HYDROGEN_CONTROL = "http://127.0.0.1:47084" -# SHIP-NIGHT PARK, server half (dock thread, 2026-08-23, copying nb's model):-# the UI already hides the Try it button, but a parked feature must refuse at-# the ENDPOINT too, or a direct POST (curl, a script, another AI) still-# reaches run-prompt and can wake or crash ah. One line to unpark, with the-# page's TRY_IT_PARKED flag.-TRY_IT_PARKED = True def _hydrogen_control_url():@@ -194,226 +188,6 @@ def say_send(phrase): return {"ok": True, "deliveredTo": str(delivered), "sid": d.get("sid")} -def stage_phrase(phrase):- """'Try it': stage the phrase (unsubmitted) into this app's own AI thread- through the Hydrogen control API. Reachable from WSL2 under mirrored- networking; from a cloud container it is not, and the page then falls back- to copying. Never raises; returns {staged, reason, sid|tid}.-- Lookup recipe measured against the live API (dock thread, 2026-08-23):- CENSUS FIRST (GET /ai-threads -> groups[].threads[] with tags, sid|null,- tid), HISTORY SECOND (GET /ai-threads/history?tag=... -> items[].id; it- EXCLUDES open threads by design, so history-first returns empty exactly- when the user has the thread open). Deliver with sid when present, else- tid. A closed thread is resumed (POST /ai-threads/resume {ai,id}) before- run-prompt {sid:id}."""- import urllib.request- tag = "app:adom/kicad-bridge"-- def _get(path):- with urllib.request.urlopen(f"{HYDROGEN_CONTROL}{path}", timeout=2) as r:- return json.loads(r.read().decode("utf-8") or "{}")-- def _post(path, body):- req = urllib.request.Request(f"{HYDROGEN_CONTROL}{path}",- data=json.dumps(body).encode("utf-8"), method="POST",- headers={"Content-Type": "application/json"})- with urllib.request.urlopen(req, timeout=4) as r:- return json.loads(r.read().decode("utf-8") or "{}")-- try:- census = _get("/ai-threads")- except Exception as e:- return {"staged": False, "reason": f"hydrogen control api unreachable ({e.__class__.__name__})"}- # 1. an OPEN thread tagged for this app- for g in census.get("groups") or []:- for t in g.get("threads") or []:- if tag in (t.get("tags") or []):- target = {"sid": t["sid"]} if t.get("sid") else {"tid": t.get("tid")}- try:- _post("/ai-threads/run-prompt", {**target, "prompt": phrase, "submit": False})- return {"staged": True, "where": "open thread", **target}- except Exception as e:- return {"staged": False, "reason": f"run-prompt failed ({e.__class__.__name__})", **target}- # 2. a CLOSED thread from history: resume it, then stage- try:- hist = _get(f"/ai-threads/history?tag={tag}")- except Exception as e:- return {"staged": False, "reason": f"history lookup failed ({e.__class__.__name__})"}- items = hist.get("items") or []- if items:- it = items[0]- try:- _post("/ai-threads/resume", {"ai": it.get("ai") or "claude", "id": it["id"]})- _post("/ai-threads/run-prompt", {"sid": it["id"], "prompt": phrase, "submit": False})- return {"staged": True, "where": "resumed thread", "sid": it["id"]}- except Exception as e:- return {"staged": False, "reason": f"resume/run-prompt failed ({e.__class__.__name__})",- "sid": it.get("id")}- # 3. neither- return {"staged": False, "reason": f"no AI thread tagged {tag} yet (open or closed)"}---# ---------------------------------------------------------------- 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 and target != "-":- cmd += ["--target", target]- cmd += [verb, json.dumps(args or {})]- try:- p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)- 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 subprocess.TimeoutExpired:- return {"error": f"no answer within {timeout}s", "timedOut": True}- except Exception as e:- # never leak a raw Python exception (a TimeoutExpired repr used to land- # verbatim in the Bridge LED as "Command '['adom-bridge', ...]'")- return {"error": str(e).split("\n")[0][:120]}---def get_targets():- try:- 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-DEV_MODE = bool(os.environ.get("KICAD_DASHBOARD_DEV"))---def _suite_led():- """DEV-ONLY (John, 2026-08-18: "the daily suite should not be in here. no- normal adom user runs that. that's just for me as the main dev"). Shown only- with --dev / KICAD_DASHBOARD_DEV=1: the nightly verb-suite verdict plus- container-cron liveness (the cron 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."}----# ── The dock card's ROLLUP (wiki #43, John) ────────────────────────────────-# Without a top-level `state` the dock takes our WORST led, so a yellow-# "update available" turns the whole card yellow while the bridge answers-# perfectly. That is not what a user should read off a card. We own the-# rollup, so we own the judgement about which facets are load-bearing.-#-# I initially declined this, reasoning that a disagreement between our rollup-# and the dock's derivation would be a signal worth seeing. John's argument is-# better: the card is a USER-facing health light, not a debugging surface, and-# an advisory LED must not be able to raise an alarm.-_CRITICAL_LEDS = ("relay", "box", "bridge", "kicad") # can this thing work at all-_ADVISORY_LEDS = ("update", "focus", "presence", "plugin") # informational, never alarming---def rollup_state(leds: dict) -> dict:- """Green when the bridge can actually do its job; yellow only when a- CRITICAL facet is degraded; red when one is broken."""- def st(k):- return (leds.get(k) or {}).get("state")- crit = [st(k) for k in _CRITICAL_LEDS if st(k)]- if "err" in crit:- worst, why = "err", [k for k in _CRITICAL_LEDS if st(k) == "err"]- elif "warn" in crit:- worst, why = "warn", [k for k in _CRITICAL_LEDS if st(k) == "warn"]- else:- worst, why = "ok", []- degraded = [k for k in _ADVISORY_LEDS if st(k) in ("warn", "err")]- return {- "state": worst,- "reason": (", ".join(why) if why else- ("healthy" + (f" (advisory: {', '.join(degraded)})" if degraded else ""))),- "criticalLeds": list(_CRITICAL_LEDS),- "advisoryLeds": list(_ADVISORY_LEDS),- "_hint": ("Top-level rollup for the dock card LED. Advisory facets (an available "- "update, focus bounces, user presence, a plugin that only degrades "- "background navigation) never turn the card yellow, because the bridge "- "still works. Only relay/box/bridge/kicad can."),- }- def build_status(target): """The LED payload. Each facet: {label, state: ok|warn|err|off, detail, _hint}.""" targets = get_targets()@@ -1089,20 +863,16 @@ def do_action(target, name, args): if name == "say": return {"success": True, "phrases": say_phrases()} if name == "try_phrase":- if TRY_IT_PARKED:- return {"success": False, "staged": False, "parked": True,- "error": "try_phrase is parked for ship night: the ai-threads "- "run-prompt path has an unverified crash fix, and this "- "endpoint must not wake ah's AI backend. Copy the phrase "- "into your AI instead."}- phrase = str(args.get("phrase") or "").strip()- if not phrase:- return {"success": False, "error": "phrase required"}- r = stage_phrase(phrase)- return {"success": True, "phrase": phrase, **r}- if name == "demo_control":- cmd = str(args.get("cmd") or "").strip().lower()- return ab(target, "kicad_demo", {"control": cmd}, timeout=30)+ # RETIRED 2026-08-24. This was the staged-prompt path, parked on ship+ # night and then superseded by Send to AI (say_send, one call, new+ # threads only). Its implementation is DELETED rather than dormant:+ # it contained an /ai-threads/resume call, and resume stays parked by+ # ruling, so leaving it alive-but-flagged put a forbidden call one+ # flag flip from running. Nothing calls this; answer honestly.+ return {"success": False, "retired": True,+ "error": "try_phrase is retired. Use the dashboard's Send to AI "+ "(POST /api/say-send), which opens a NEW thread and submits.",+ "_hint": "Resuming or typing into EXISTING threads remains parked."} if name == "restore_settings": return ab(target, "kicad_set_settings", {"restoreDefaults": True}, timeout=60) if name == "part_tour":
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "kicad-bridge",- "version": "1.0.73",+ "version": "1.0.74", "type": "app", "description": "Skills for your container so your AI knows how to drive the KiCad bridge. The bridge runtime itself is the release zip; Adom Bridge loads that.", "tags": [
page.json+1−1@@ -4,7 +4,7 @@ "slug": "kicad-bridge", "title": "KiCad - the KiCad Bridge", "brief": "Reference implementation of the KiCad bridge — multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.",- "version": "1.0.73",+ "version": "1.0.74", "tags": [ "kicad", "pcb",