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.59
3 files changed
+107−27
dashboard/kicad_dashboard.py+105−25@@ -49,7 +49,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import unquote, urlparse -VERSION = "2.2.1"+VERSION = "2.2.2" AI_THREAD = "kicad-dashboard" DEFAULT_PORT = 8931 UI_DIR = Path(__file__).resolve().parent / "ui"@@ -95,32 +95,60 @@ 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}."""+ 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:- with urllib.request.urlopen(- f"{HYDROGEN_CONTROL}/ai-threads/history?tag={tag}", timeout=2) as r:- hist = json.loads(r.read().decode("utf-8") or "{}")+ census = _get("/ai-threads") except Exception as e: return {"staged": False, "reason": f"hydrogen control api unreachable ({e.__class__.__name__})"}- threads = hist.get("threads") or hist.get("items") or hist.get("data") or []- sid = None- for t in threads if isinstance(threads, list) else []:- sid = t.get("sid") or t.get("id")- if sid:- break- if not sid:- return {"staged": False, "reason": f"no AI thread tagged {tag} yet"}+ # 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:- body = json.dumps({"sid": sid, "prompt": phrase, "submit": False}).encode("utf-8")- req = urllib.request.Request(f"{HYDROGEN_CONTROL}/ai-threads/run-prompt", data=body,- method="POST", headers={"Content-Type": "application/json"})- with urllib.request.urlopen(req, timeout=4) as r:- r.read()- return {"staged": True, "sid": sid}+ hist = _get(f"/ai-threads/history?tag={tag}") except Exception as e:- return {"staged": False, "reason": f"run-prompt failed ({e.__class__.__name__})", "sid": sid}+ 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@@ -1198,7 +1226,8 @@ def main(): # console. An already-running dashboard should make relaunch INSTANT, # not fatal, so: if a healthy instance of OURS already owns the port, # print its URL and exit 0.- def _healthy_instance(p_):+ def _running_version(p_):+ """Version of OUR instance on the port, or None if none/not ours.""" import urllib.request # imported per-use in this module try: with urllib.request.urlopen(@@ -1206,15 +1235,66 @@ def main(): d_ = json.loads(r.read().decode("utf-8") or "{}") # Confirm it is OURS - another service on the port is not a # reason to claim success.- return bool(d_.get("ok")) and d_.get("app") == "kicad-dashboard"+ if bool(d_.get("ok")) and d_.get("app") == "kicad-dashboard":+ return str(d_.get("version") or "?") except Exception:- return False+ pass+ return None - if _healthy_instance(port):+ def _healthy_instance(p_):+ return _running_version(p_) is not None++ def _retire_stale(p_, their):+ """VERSION-BLIND REUSE (dock thread, 2026-08-23, twice): after an+ install, `serve` found a healthy OLD instance and happily reused+ it, so /health kept reporting 2.0.0 and new actions answered+ "unknown action" until someone killed it by hand. A healthy+ instance of a DIFFERENT version is not a reason to skip serving;+ it is the thing to replace. Kill only our own serve processes+ (matched by command line), never anything broad."""+ log(f"kicad-dashboard {their} is serving on {p_} but this is {VERSION}; "+ f"retiring the stale instance")+ me = os.getpid()+ victims = []+ for pid in os.listdir("/proc"):+ if not pid.isdigit() or int(pid) == me:+ continue+ try:+ cmd = open(f"/proc/{pid}/cmdline", "rb").read().replace(b"\0", b" ")+ except Exception:+ continue+ if b"kicad_dashboard.py" in cmd and b" serve" in cmd:+ victims.append(int(pid))+ import signal+ for pid in victims:+ try:+ os.kill(pid, signal.SIGTERM)+ except Exception:+ pass+ for _ in range(40): # up to ~4s for the port to free+ if _running_version(p_) is None:+ return True+ time.sleep(0.1)+ for pid in victims:+ try:+ os.kill(pid, signal.SIGKILL)+ except Exception:+ pass+ time.sleep(0.5)+ return _running_version(p_) is None++ their = _running_version(port)+ if their is not None and their != VERSION:+ if not _retire_stale(port, their):+ print(f"kicad-dashboard: a stale {their} instance owns port {port} and "+ f"would not exit; kill it and relaunch.", file=sys.stderr, flush=True)+ raise SystemExit(2)+ their = None+ if their is not None: if "--print-url" in argv: print(direct_url(port), flush=True) else:- log(f"kicad-dashboard already serving on {port}; nothing to do")+ log(f"kicad-dashboard {VERSION} already serving on {port}; nothing to do") return # BIND FIRST. The old order printed the URL and only then tried to bind,
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "kicad-bridge",- "version": "1.0.58",+ "version": "1.0.59", "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.58",+ "version": "1.0.59", "tags": [ "kicad", "pcb",