← Commit history

v0.9.80: NEVER dismiss progress dialogs (their button is Cancel — sweeper was aborting loads mid-verb; suite finding); runner fixes

John Lauer ·b2d01ad7e5 ·2mo ago ·parent a0cbba5
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: