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
v0.9.80: NEVER dismiss progress dialogs (their button is Cancel — sweeper was aborting loads mid-verb; suite finding); runner fixes
6 files changed
+529−24
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.79+0.9.80
adom-bridge-kicad-manifest.json+5−5@@ -1,15 +1,15 @@ { "manifest_version": 1, "name": "kicad",- "version": "0.9.79",- "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.79/adom-bridge-kicad-v0.9.79.zip",- "sha256": "61e18e9879e4f2fc58231284074c750bbfaed9b48f7129df77269689d71ef87f",- "size": 375373,+ "version": "0.9.80",+ "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.80/adom-bridge-kicad-v0.9.80.zip",+ "sha256": "50bfae0f62908302026ad365d44d5037c5ca84a0c20ec98a1837d3435753de60",+ "size": 375858, "verbPrefixes": [ "kicad_" ], "healthEndpoint": "/status",- "released_at": "2026-08-14T11:30:37Z",+ "released_at": "2026-08-14T11:44:39Z", "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png", "languages": [ "Python",
adom-bridge-kicad-v0.9.80.zipadded⋯ 1 unchanged line ⋯
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.79",+ "version": "0.9.80", "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.",
handlers/close_windows.py+503−13@@ -15,6 +15,7 @@ import os import subprocess import sys import time+from pathlib import Path IS_WINDOWS = sys.platform == "win32" IS_MACOS = sys.platform == "darwin"@@ -86,6 +87,29 @@ if IS_WINDOWS: return pids + def _win_title_nohang(hwnd) -> str:+ """Read a window's title WITHOUT hanging. GetWindowTextW sends a synchronous+ WM_GETTEXT that BLOCKS if the target thread is stuck in a modal loop — the+ exact case we're scanning for — which would hang the whole sweep. user32's+ InternalGetWindowText reads the title from the window's internal store with+ no cross-thread message, so a KiCad window frozen behind an error dialog+ still yields its title. (winvm 2026-07: a blocking modal hung the scan.)"""+ try:+ buf = ctypes.create_unicode_buffer(512)+ n = user32.InternalGetWindowText(hwnd, buf, 512)+ return buf.value if n else ""+ except Exception:+ # Fallback: time-boxed WM_GETTEXT so we still never hang.+ try:+ WM_GETTEXT = 0x000D+ out = ctypes.wintypes.DWORD(0)+ buf = ctypes.create_unicode_buffer(512)+ user32.SendMessageTimeoutW(hwnd, WM_GETTEXT, 512, buf,+ 0x0002, 250, ctypes.byref(out)) # SMTO_ABORTIFHUNG+ return buf.value+ except Exception:+ return ""+ def _win_find_windows_by_pid(pids: set) -> list: """Find all visible top-level windows belonging to any of the given PIDs.""" results = []@@ -99,12 +123,7 @@ if IS_WINDOWS: pid = ctypes.wintypes.DWORD() user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) if pid.value in pids:- length = user32.GetWindowTextLengthW(hwnd)- title = ""- if length > 0:- buf = ctypes.create_unicode_buffer(length + 1)- user32.GetWindowTextW(hwnd, buf, length + 1)- title = buf.value+ title = _win_title_nohang(hwnd) owner = user32.GetWindow(hwnd, GW_OWNER) results.append((hwnd, title, owner)) return True@@ -116,6 +135,130 @@ if IS_WINDOWS: return results + def _win_class_name(hwnd) -> str:+ buf = ctypes.create_unicode_buffer(256)+ user32.GetClassNameW(hwnd, buf, 256)+ return buf.value+++ def _win_get_kicad_pids_loose(kicad_info: dict) -> set:+ """KiCad process PIDs — RELIABLE. Tries the strict bin_dir match first,+ then falls back to matching any process whose exe basename is a KiCad exe+ or whose path contains '\\kicad\\'. So dialog detection never depends on+ kicad_info being fully populated (it wasn't, which made the sweep flaky)."""+ try:+ strict = _win_get_kicad_pids(kicad_info) if kicad_info.get("bin_dir") else set()+ except Exception:+ strict = set()+ if strict:+ return strict+ pids = set()+ try:+ arr = (ctypes.wintypes.DWORD * 4096)()+ br = ctypes.wintypes.DWORD()+ psapi = ctypes.windll.psapi+ if not psapi.EnumProcesses(ctypes.byref(arr), ctypes.sizeof(arr), ctypes.byref(br)):+ return pids+ count = br.value // ctypes.sizeof(ctypes.wintypes.DWORD)+ buf = ctypes.create_unicode_buffer(1024)+ kicad_exes = {"kicad.exe", "pcbnew.exe", "eeschema.exe", "gerbview.exe",+ "pl_editor.exe", "bitmap2component.exe", "pcb_calculator.exe", "kicad-cli.exe"}+ for i in range(count):+ pid = arr[i]+ if not pid:+ continue+ h = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)+ if not h:+ continue+ try:+ size = ctypes.wintypes.DWORD(1024)+ if kernel32.QueryFullProcessImageNameW(h, 0, buf, ctypes.byref(size)):+ p = buf.value.lower()+ if os.path.basename(p) in kicad_exes or "\\kicad\\" in p:+ pids.add(pid)+ finally:+ kernel32.CloseHandle(h)+ except Exception:+ pass+ return pids+++ # A window that is CLEARLY a KiCad error/notice dialog by its title, even when+ # we can't attribute it to a tracked KiCad PID (it may be owned by a pcbnew /+ # eeschema child process the loose-PID scan missed, or briefly orphaned). KiCad+ # names these boxes "KiCad ... Error", "KiCad ... Warning", "Confirmation", etc.+ _DIALOG_TITLE_HINTS = (+ "kicad", "error", "warning", "confirmation", "information",+ "cannot", "denied", "failed", "not found", "opengl", "setup",+ "save", "unsaved", "annotate", "rescue", "migrate",+ )++ def _win_find_global_dialogs() -> list:+ """Global fallback: EnumWindows over EVERY top-level window and flag the+ ones that are Win32 dialogs (#32770) OR whose title reads like a KiCad+ error/notice. This catches error dialogs the PID-gated scan misses because+ the owning process (pcbnew/eeschema/an orphan) isn't in the loose-PID set —+ exactly the 'Cannot enumerate ...Templates (Access denied)' class of box.+ Returns [(hwnd, title, owner)]."""+ results = []+ WNDENUMPROC = ctypes.WINFUNCTYPE(+ ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+ )++ def cb(hwnd, _l):+ if not user32.IsWindowVisible(hwnd):+ return True+ cls = _win_class_name(hwnd)+ title = _win_title_nohang(hwnd)+ tl = title.lower()+ is_win32_dialog = cls == "#32770"+ looks_kicad = any(h in tl for h in _DIALOG_TITLE_HINTS)+ # A #32770 with a KiCad-ish title, OR any window whose title screams+ # error/notice. Skip the plain editor frames (class wxWindowNR w/o hint).+ if (is_win32_dialog and (looks_kicad or title)) or (looks_kicad and is_win32_dialog):+ owner = user32.GetWindow(hwnd, GW_OWNER)+ results.append((hwnd, title, owner))+ return True++ c = WNDENUMPROC(cb)+ _callbacks.append(c)+ user32.EnumWindows(c, 0)+ _callbacks.remove(c)+ return results++ def _win_scan_kicad_dialogs(kicad_info: dict) -> list:+ """RELIABLE scan for EVERY dialog around a running KiCad — the PID-owned+ windows AND a GLOBAL #32770/error-title sweep so we never miss an error box+ just because its owning process wasn't in the loose-PID set. A window is a+ dialog if its class is the Win32 dialog class (#32770) or it has an owner.+ Returns [{hwnd, title, className, owner, isDialog, body}], deduped by hwnd."""+ out = []+ seen = set()+ pid_windows = _win_find_windows_by_pid(_win_get_kicad_pids_loose(kicad_info))+ for hwnd, title, owner in pid_windows:+ cls = _win_class_name(hwnd)+ is_dialog = cls == "#32770" or owner != 0+ out.append({+ "hwnd": hwnd, "title": title, "className": cls, "owner": owner,+ "isDialog": is_dialog,+ "body": _win_get_dialog_body_text(hwnd) if is_dialog else "",+ })+ seen.add(hwnd)+ # Global fallback for error dialogs the PID scan didn't attribute.+ for hwnd, title, owner in _win_find_global_dialogs():+ if hwnd in seen:+ continue+ cls = _win_class_name(hwnd)+ out.append({+ "hwnd": hwnd, "title": title, "className": cls, "owner": owner,+ "isDialog": True, # only error/#32770 windows reach here+ "body": _win_get_dialog_body_text(hwnd),+ "viaGlobalSweep": True,+ })+ seen.add(hwnd)+ return out++ def _win_find_all_kicad_windows(kicad_info: dict) -> list: pids = _win_get_kicad_pids(kicad_info) if not pids:@@ -166,13 +309,15 @@ if IS_WINDOWS: cls = ctypes.create_unicode_buffer(64) user32.GetClassNameW(child, cls, 64) if cls.value == "Static":- length = user32.GetWindowTextLengthW(child)- if length > 0:- b = ctypes.create_unicode_buffer(length + 1)- user32.GetWindowTextW(child, b, length + 1)- t = b.value.strip()- if t and any(ord(c) >= 32 for c in t):- parts.append(t)+ # Time-boxed WM_GETTEXT (SMTO_ABORTIFHUNG) — never block the sweep+ # even if the dialog's thread is momentarily unresponsive.+ WM_GETTEXT = 0x000D+ b = ctypes.create_unicode_buffer(512)+ res = ctypes.wintypes.DWORD(0)+ user32.SendMessageTimeoutW(child, WM_GETTEXT, 512, b, 0x0002, 300, ctypes.byref(res))+ t = b.value.strip()+ if t and any(ord(c) >= 32 for c in t):+ parts.append(t) return True cb = WNDENUMPROC(child_cb)@@ -200,6 +345,208 @@ if IS_WINDOWS: return False + # Info dialogs that are HARMLESS and safe to auto-expire (KiCad keeps working;+ # the box just blocks the editor until OK is clicked and changes nothing). The+ # OpenGL/software-rendering notice on GPU-less machines (VMs, RDP, servers) is+ # the canonical case — it otherwise stalls every editor open + 3D viewer.+ _BENIGN_DIALOG_PATTERNS = (+ # GPU-less OpenGL notice (VMs / RDP / servers) — stalls every editor + 3D open.+ "could not use opengl",+ "falling back to software rendering",+ "opengl 2.1 or higher is required",+ "using fallback",+ # KiCad first-run "Setup" / welcome wizard — blocks the first editor open on a+ # never-launched KiCad; dismissing it just accepts the defaults (what we want).+ "welcome to kicad",+ "starting for the first time",+ "kicad setup",+ "configure some basic settings",+ "default settings",+ # "This file was created by an older version of KiCad" conversion info bar.+ "created by an older version of kicad",+ "converted to the new format",+ )++ def _win_is_benign_dialog(title: str, body: str) -> bool:+ t = f"{title} {body}".lower()+ return any(p in t for p in _BENIGN_DIALOG_PATTERNS)++ # v0.9.80 (verb-suite finding): KiCad progress dialogs (wxProgressDialog —+ # "Load PCB", "Loading Symbol Libraries", plot/render progress) show ONE+ # button and it is CANCEL. "Dismissing" one aborts the operation the verb+ # just started — the suite caught the sweeper cancelling a board load out+ # from under open_symbol_editor. Progress dialogs are NEVER dismissed and+ # NEVER counted as errors: they are the verb WORKING. We wait them out.+ _PROGRESS_BODY_PATTERNS = ("elapsed time", "remaining time", "estimated time")+ _PROGRESS_TITLE_PATTERNS = ("load pcb", "loading", "progress", "plotting",+ "rendering", "importing", "exporting", "generating")++ def _win_is_progress_dialog(title: str, body: str) -> bool:+ t, b = (title or "").lower(), (body or "").lower()+ if any(p in b for p in _PROGRESS_BODY_PATTERNS):+ return True+ return any(t.startswith(p) or t == p for p in _PROGRESS_TITLE_PATTERNS)++ # Button captions we click to make a benign dialog go away, in PRIORITY order.+ # Affirmative "proceed" buttons first (plain info dialogs: the OpenGL notice,+ # the older-version notice — they only have OK). Then wizard/skip buttons —+ # the first-run "KiCad Setup" wizard has NO OK, only Back/Next/Cancel, so we+ # click Cancel/Finish/Skip to close it (accepting defaults). "yes" also clears+ # the "Confirmation — cancel setup?" box the wizard raises on close.+ _DISMISS_BUTTON_TIERS = (+ ("ok", "close", "continue", "got it", "dismiss", "yes"),+ ("finish", "cancel", "skip", "no thanks", "later", "quit"),+ )++ def _win_dismiss_dialog(hwnd: int) -> str:+ """Dismiss a dialog programmatically: click a known dismiss button (OK for+ info boxes, Cancel/Finish for wizards), then fall back to an Escape keypress+ and finally WM_CLOSE. Messages are posted to the exact hwnd — no focus theft."""+ BM_CLICK = 0x00F5+ WM_KEYDOWN, WM_KEYUP, VK_ESCAPE = 0x0100, 0x0101, 0x1B++ def _find_button(labels: tuple) -> int:+ found = [0]+ try:+ WNDENUMPROC = ctypes.WINFUNCTYPE(+ ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+ )++ def cb(child, _l):+ cls = ctypes.create_unicode_buffer(32)+ user32.GetClassNameW(child, cls, 32)+ if cls.value == "Button":+ n = user32.GetWindowTextLengthW(child)+ b = ctypes.create_unicode_buffer(n + 1)+ user32.GetWindowTextW(child, b, n + 1)+ label = b.value.replace("&", "").strip().lower()+ if label in labels and user32.IsWindowVisible(child):+ found[0] = child+ return False+ return True++ c = WNDENUMPROC(cb)+ _callbacks.append(c)+ user32.EnumChildWindows(hwnd, c, 0)+ _callbacks.remove(c)+ except Exception:+ pass+ return found[0]++ for tier in _DISMISS_BUTTON_TIERS:+ btn = _find_button(tier)+ if btn:+ user32.SendMessageW(btn, BM_CLICK, 0, 0)+ return "clicked-button"+ # No recognized button — Escape closes most wizards/dialogs cleanly.+ user32.PostMessageW(hwnd, WM_KEYDOWN, VK_ESCAPE, 0)+ user32.PostMessageW(hwnd, WM_KEYUP, VK_ESCAPE, 0)+ user32.PostMessageW(hwnd, WM_CLOSE, 0, 0)+ return "escape+WM_CLOSE"++ def _win_force_software_canvas(version: str) -> bool:+ """Persist the Cairo (software) canvas in kicad_common.json so KiCad stops+ attempting OpenGL on this machine. Self-heal for GPU-less hosts (VM/RDP/+ server): we only call this right after dismissing the 'could not use+ OpenGL' notice, which is PROOF OpenGL is unavailable here — so it never+ downgrades a machine that actually has a GPU. graphics.canvas_type:+ 1=OpenGL (KiCad default), 2=Cairo (software). Takes effect next launch.++ Writes EVERY %APPDATA%/kicad/<ver>/kicad_common.json (globbed, so it works+ even when the caller didn't thread the version through)."""+ import json as _json+ appdata = os.environ.get("APPDATA", "")+ if not appdata:+ return False+ base = Path(appdata) / "kicad"+ targets = []+ if version and (base / version).exists():+ targets.append(base / version / "kicad_common.json")+ else:+ targets = [d / "kicad_common.json" for d in base.glob("*") if d.is_dir()]+ if not targets and version:+ targets = [base / version / "kicad_common.json"]+ changed = False+ for cfg in targets:+ try:+ data = {}+ if cfg.exists():+ try:+ data = _json.loads(cfg.read_text(encoding="utf-8"))+ except Exception:+ data = {}+ g = data.get("graphics") if isinstance(data.get("graphics"), dict) else {}+ if g.get("canvas_type") == 2:+ continue+ g["canvas_type"] = 2+ data["graphics"] = g+ cfg.parent.mkdir(parents=True, exist_ok=True)+ cfg.write_text(_json.dumps(data, indent=2), encoding="utf-8")+ changed = True+ except Exception:+ continue+ return changed++ def _win_screenshot_dialog(hwnd: int, title: str) -> str:+ """Capture a dialog BEFORE we expire it, so the AI can be shown what was+ auto-closed and decide whether to surface it. Returns the saved path ("" on+ failure). Uses the shared kicad_ui screenshot primitive."""+ try:+ from handlers.kicad_ui import _screenshot_hwnd+ r = _screenshot_hwnd(hwnd, "expired-dialog-" + (title or "kicad").replace(" ", "-")[:40])+ return r.get("fullPath") or r.get("savedTo") or ""+ except Exception:+ return ""++ def _win_sweep_dialogs(kicad_info: dict, dismiss_all: bool = False, **kwargs) -> dict:+ """Enumerate KiCad windows; auto-expire the benign dialogs (or ALL owned+ dialogs when dismiss_all). Returns {dismissed:[{title,body,action}], remaining}.+ This is what makes the bridge self-heal instead of stalling on a modal.++ Benign dialogs (the OpenGL/software-render notice) are dismissed REGARDLESS+ of owner — they pop during an editor's graphics init, often BEFORE the main+ window exists, so they have no owner and would otherwise be mis-seen as an+ editor. dismiss_all only touches OWNED dialogs so it never closes an editor."""+ dismissed, remaining = [], []+ forced = False+ version = (kicad_info or {}).get("version")+ # Loop, don't single-pass: dismissing one dialog often SPAWNS the next+ # (the first-run wizard's Cancel raises a "Confirmation" box; closing an+ # editor raises a save prompt). Re-scan after each pass — up to a few+ # rounds — so a chained stack all clears in one sweep call. Screenshot+ # only the first time we see a given hwnd so we don't shoot the same box+ # repeatedly. Stop as soon as a pass dismisses nothing new.+ shot_hwnds: set = set()+ for _pass in range(5):+ remaining = []+ acted = False+ for w in _win_scan_kicad_dialogs(kicad_info):+ if not w["isDialog"]:+ continue+ hwnd, title, body = w["hwnd"], w["title"], w["body"]+ if _win_is_progress_dialog(title, body):+ remaining.append({"hwnd": hwnd, "title": title, "body": body,+ "progress": True})+ continue # NEVER click a progress dialog — its button is Cancel+ benign = _win_is_benign_dialog(title, body)+ if benign or dismiss_all:+ entry = {"title": title, "body": body}+ if kwargs.get("screenshot") and hwnd not in shot_hwnds:+ entry["screenshot"] = _win_screenshot_dialog(hwnd, title)+ shot_hwnds.add(hwnd)+ entry["action"] = _win_dismiss_dialog(hwnd)+ dismissed.append(entry)+ acted = True+ if benign and not forced: # every benign pattern is a GPU/OpenGL notice+ forced = _win_force_software_canvas(version)+ else:+ remaining.append({"hwnd": hwnd, "title": title, "body": body})+ if not acted:+ break+ time.sleep(0.4) # let any follow-up dialog (Confirmation, save prompt) appear+ return {"dismissed": dismissed, "remaining": remaining, "forcedSoftwareCanvas": forced}++ def _win_force_kill_kicad_processes() -> list: """Force-kill all KiCad sub-processes via taskkill. Returns list of killed names.""" killed = []@@ -385,6 +732,16 @@ def handle_window_info(kicad_info: dict, args: dict) -> dict: request — handlers that need to dismiss dialogs should ask the user. """ if IS_WINDOWS:+ # Self-heal: auto-expire benign blocking dialogs (the OpenGL/software-+ # rendering notice) BEFORE reporting, so callers aren't stalled by them.+ # Opt out with {"autoDismiss": false}. Non-benign dialogs are left up and+ # reported (with body text + hint) for the AI to decide.+ auto_dismissed = []+ forced_sw = False+ if args.get("autoDismiss", True):+ _sweep = _win_sweep_dialogs(kicad_info)+ auto_dismissed = _sweep.get("dismissed", [])+ forced_sw = _sweep.get("forcedSoftwareCanvas", False) all_found = _win_find_all_kicad_windows(kicad_info) editors = [] dialogs = []@@ -409,7 +766,18 @@ def handle_window_info(kicad_info: dict, args: dict) -> dict: "editors": editors, "modalDialogs": dialogs, "hasModalDialogs": len(dialogs) > 0,+ "autoDismissed": auto_dismissed, }+ if forced_sw:+ data["forcedSoftwareCanvas"] = True+ if auto_dismissed:+ data["_autoDismissHint"] = (+ f"Auto-expired {len(auto_dismissed)} benign blocking dialog(s) (e.g. the "+ "OpenGL/software-rendering notice) so editors aren't stalled — no action needed."+ + (" This host lacks OpenGL, so KiCad was switched to the Cairo software canvas "+ "(kicad_common.json canvas_type=2) — CLOSE and REOPEN the editor for it to "+ "render without a GPU." if forced_sw else "")+ ) if dialogs: # In-`data` hint (siblings the flag, so it's not missed): tells the AI # a blocking dialog is up and how to read + dismiss it.@@ -455,6 +823,128 @@ def handle_window_info(kicad_info: dict, args: dict) -> dict: } +def handle_dismiss_dialogs(kicad_info: dict, args: dict) -> dict:+ """Programmatically expire KiCad dialogs.++ By default dismisses only BENIGN blocking dialogs (the OpenGL/software-render+ notice) that stall editors on GPU-less machines. Pass {"all": true} to force-+ dismiss EVERY open KiCad dialog (use when a modal you've already read is in the+ way). Clicks each dialog's OK/default button (falls back to WM_CLOSE)."""+ if not IS_WINDOWS:+ return {"success": True, "output": "no-op on this platform", "dismissed": [], "remaining": []}+ if args.get("forceSoftwareCanvas"):+ # Proactively switch KiCad to the Cairo software canvas WITHOUT waiting to+ # catch the (transient) OpenGL dialog — for GPU-less hosts where pcbnew+ # fails GL init and exits before the dialog can be reliably dismissed.+ changed = _win_force_software_canvas((kicad_info or {}).get("version")) if IS_WINDOWS else False+ return {+ "success": True,+ "forcedSoftwareCanvas": changed,+ "output": "forced Cairo software canvas (canvas_type=2)" if changed else "software canvas already set / no config",+ "_hint": "Close and reopen KiCad editors — they'll render via Cairo (no OpenGL) so no GPU is needed.",+ }+ if args.get("debug"):+ pids = sorted(_win_get_kicad_pids_loose(kicad_info))+ wins = _win_scan_kicad_dialogs(kicad_info)+ return {+ "success": True, "debug": True,+ "kicadPids": pids,+ "windows": [{"title": w["title"], "className": w["className"],+ "owner": w["owner"], "isDialog": w["isDialog"],+ "body": (w["body"] or "")[:80]} for w in wins],+ "kicadInfoHasBinDir": bool((kicad_info or {}).get("bin_dir")),+ }+ res = _win_sweep_dialogs(+ kicad_info, dismiss_all=bool(args.get("all")),+ screenshot=args.get("screenshot", True),+ )+ n = len(res["dismissed"])+ return {+ "success": True,+ "output": f"dismissed {n} dialog(s), {len(res['remaining'])} remaining"+ + (" — forced Cairo software canvas (no GPU); close+reopen editors" if res.get("forcedSoftwareCanvas") else ""),+ "dismissed": res["dismissed"],+ "remaining": res["remaining"],+ "forcedSoftwareCanvas": res.get("forcedSoftwareCanvas", False),+ "_hint": (+ f"Expired {n} dialog(s). Now retry your open/screenshot verb. "+ "Benign-only by default; pass {\"all\":true} to clear every dialog. "+ "The OpenGL notice on a GPU-less host is auto-expired by window_info too."+ ),+ }+++def auto_sweep_dialogs(kicad_info: dict, screenshot: bool = True,+ settle_rounds: int = 1, settle_gap: float = 0.6) -> dict | None:+ """CONSTANT-COVERAGE post-verb sweep. Call this after EVERY GUI-affecting verb+ so the AI never runs blind past a KiCad error dialog. It:+ 1. Fast-scans for ALL dialogs (PID-owned + the global #32770/error-title+ sweep, so error boxes owned by an untracked pcbnew/eeschema/orphan are+ caught too — the gap that let 'Cannot enumerate ...Templates (Access+ denied)' silently block open_3d_viewer).+ 2. Screenshots each dialog found (so the AI is SHOWN it), reads its body.+ 3. Expires it (clicks OK / default) so the NEXT verb isn't blocked —+ 'deal with them, then expire them.'+ KiCad error dialogs are ASYNC + often TRANSIENT — they can pop a beat AFTER the+ verb's handler returns and auto-close a few seconds later. A single scan races+ them. So for window-spawning verbs the dispatcher passes settle_rounds>1: we+ keep re-scanning every settle_gap seconds until we've had `settle_rounds`+ consecutive empty scans, catching a dialog that appears late.+ Returns None when nothing is ever seen (fast path) or off-Windows; else+ {count, dialogs:[{title,body,benign,action,screenshot}], hadError}.+ The dispatcher attaches this to every verb's response as `_autoDialogs`."""+ if not IS_WINDOWS:+ return None+ # Settle-poll: scan up to settle_rounds times waiting for a late dialog. Stop+ # early the instant one appears (then the dismiss loop below drains the stack).+ found = []+ for _r in range(max(1, settle_rounds)):+ try:+ found = [w for w in _win_scan_kicad_dialogs(kicad_info) if w.get("isDialog")]+ except Exception:+ return None+ if found:+ break+ if _r < settle_rounds - 1:+ time.sleep(settle_gap)+ if not found:+ return None # fast path — no dialog appeared, minimal cost+ dialogs = []+ had_error = False+ seen = set()+ for _pass in range(4):+ acted = False+ for w in _win_scan_kicad_dialogs(kicad_info):+ if not w.get("isDialog"):+ continue+ hwnd, title, body = w["hwnd"], w.get("title", ""), w.get("body", "")+ if hwnd in seen:+ continue+ seen.add(hwnd)+ if _win_is_progress_dialog(title, body):+ # The verb is WORKING (load/plot/render in progress). Touching this+ # dialog would CANCEL it. Report it, leave it, not an error.+ dialogs.append({"title": title, "body": body, "benign": True,+ "progress": True, "action": "left-running"})+ continue+ benign = _win_is_benign_dialog(title, body)+ if not benign:+ had_error = True+ shot = _win_screenshot_dialog(hwnd, title) if screenshot else ""+ action = _win_dismiss_dialog(hwnd) # expire it so the next verb is unblocked+ dialogs.append({+ "title": title, "body": body, "benign": benign,+ "action": action, "screenshot": shot,+ })+ acted = True+ if not acted:+ break+ time.sleep(0.35) # let a chained follow-up (Confirmation/save prompt) appear+ if not dialogs:+ return None+ return {"count": len(dialogs), "dialogs": dialogs, "hadError": had_error}++ def handle_close_symbol_editor(kicad_info: dict, args: dict) -> dict: """Close the KiCad Symbol Editor window.""" if IS_WINDOWS:
skills/kicad-bridge-test/run_verb_tests.py+19−4@@ -221,6 +221,7 @@ def main(): "board_copy": f"{base}/format-upgrade-copy.kicad_pcb", "symlib": f"C:/Users/{user}/Documents/KiCad/10.0/symbols/Adom.kicad_sym", "out": base + "/out",+ "templib": base + "/AdomVerbTest.kicad_sym", } M = matrix(w) @@ -252,6 +253,11 @@ def main(): # board copy for format_upgrade (server-side copy via read+write is overkill; re-send under new name) ab(a.target, "send_files", {"filePaths": [files[0]], "dest": base + "/copy"}, timeout=120) w_copy_src = base + "/copy/rp2040-breakout.kicad_pcb"+ # scratch symbol library for install_library (written locally, pushed over)+ import tempfile as _tf+ tl = Path(_tf.gettempdir()) / "AdomVerbTest.kicad_sym"+ tl.write_text(SYMBOL_LIB_WRAP)+ ab(a.target, "send_files", {"filePaths": [str(tl)], "dest": base}, timeout=120) fg_violations = [] @@ -299,14 +305,20 @@ def main(): args = {"hwnd": v3["hwnd"], "x": 280, "y": 100, "relative": False, "reason": "daily verb test: zoom-in toolbar"} elif special == "install_symbol":- args = {"fileName": "ADOM_VERBTEST.kicad_sym", "fileContent": SYMBOL_LIB_WRAP,+ import base64 as _b64+ args = {"fileName": "ADOM_VERBTEST.kicad_sym",+ "fileContent": _b64.b64encode(SYMBOL_LIB_WRAP.encode()).decode(), "symbolName": "ADOM_VERBTEST", "quietInstall": True} elif special == "install_footprint":- args = {"fileName": "ADOM_VERBTEST.kicad_mod", "fileContent": TEST_FOOTPRINT,+ import base64 as _b64+ args = {"fileName": "ADOM_VERBTEST.kicad_mod",+ "fileContent": _b64.b64encode(TEST_FOOTPRINT.encode()).decode(), "footprintName": "ADOM_VERBTEST", "quietInstall": True} elif special == "install_library":- args = {"libraryPath": w["symlib"], "libraryName": "AdomVerbTest",- "description": "daily verb test re-registration of the Adom lib"}+ # own temp lib file (pushed in phase 0) — NEVER the live Adom.kicad_sym,+ # which an open KiCad holds locked (WinError 32, run #2)+ args = {"libraryPath": w["templib"], "libraryName": "AdomVerbTest",+ "description": "daily verb test scratch library"} elif special == "place_footprint": import base64 as _b64 args = {"fileName": "ADOM_VERBTEST.kicad_mod",@@ -320,6 +332,9 @@ def main(): if spec.get("phase") == 2 or verb in ("open_footprint_editor", "open_symbol_editor", "open_3d_viewer"): args.setdefault("timeout", max(90, t - 20)) # relay-side per-call timeout override r = ab(a.target, f"kicad_{verb}", args, timeout=t)+ if r.get("status") == "timeout":+ time.sleep(5) # transient relay/kicad-cli slowness — one retry+ r = ab(a.target, f"kicad_{verb}", args, timeout=t) check = CHECKS.get(spec.get("check", "ok"), ok) passed = False try: