← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.269\ No newline at end of file+0.9.270\ No newline at end of file
adom-bridge-kicad-manifest.json+5−5
@@ -1,15 +1,15 @@ {   "manifest_version": 1,   "name": "kicad",-  "version": "0.9.269",-  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.269/adom-bridge-kicad-v0.9.269.zip",-  "sha256": "06d48e276cf7722ccf7d8598a8f318ef66964fd19489adf7ca2a8f35a781f469",-  "size": 1039639,+  "version": "0.9.270",+  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.270/adom-bridge-kicad-v0.9.270.zip",+  "sha256": "3ef33acf4fbd10a4812973c2b2b5bfadb3311930b72f695f1737952956750550",+  "size": 1042012,   "verbPrefixes": [     "kicad_"   ],   "healthEndpoint": "/status",-  "released_at": "2026-09-01T02:43:45Z",+  "released_at": "2026-09-01T02:51:43Z",   "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png",   "languages": [     "Python",
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "kicad",   "displayName": "KiCad EDA",-  "version": "0.9.269",+  "version": "0.9.270",   "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/kicad_log.py+64−6
@@ -198,6 +198,47 @@ def _enum_dialog_text(hwnd: int) -> list[str]:     return out  ++def _uia_text(hwnd: int) -> str:+    """Longest Text node in a window's UIA tree, via ab's desktop_ui_tree.++    Best-effort by design: if ab is old, unreachable, or the window died in the+    microseconds since enumeration, return "" and let the caller report the+    dialog without text rather than failing the whole verb."""+    try:+        from handlers import ad_client+        r = ad_client.call("desktop_ui_tree", {"hwnd": int(hwnd), "maxDepth": 8,+                                               "maxNodes": 80}, timeout=8) or {}+    except Exception:  # pylint: disable=broad-except+        return ""+    out = r.get("output") if isinstance(r, dict) else None+    if isinstance(out, str):+        try:+            import json as _j+            out = _j.loads(out)+        except Exception:  # pylint: disable=broad-except+            out = None+    root = ((out or r) or {}).get("root") if isinstance((out or r), dict) else None+    if not root:+        return ""+    texts: list[str] = []++    def walk(n):+        if not isinstance(n, dict):+            return+        if n.get("role") == "Text":+            nm = (n.get("name") or "").strip()+            if nm:+                texts.append(nm)+        for c in n.get("children") or []:+            walk(c)++    walk(root)+    # Skip the icon/instruction placeholders ("MainInstructionIcon") and take the+    # longest real sentence — that is the message every time in KiCad's dialogs.+    texts = [t for t in texts if not t.endswith("Icon")]+    return max(texts, key=len) if texts else ""+ def handle_errors(kicad_info: dict, args: dict) -> dict:     """Every KiCad error/warning dialog currently on screen, with its text. @@ -228,17 +269,34 @@ def handle_errors(kicad_info: dict, args: dict) -> dict:         texts = _enum_dialog_text(hwnd)         # the message is the longest static; buttons are short ("OK", "Cancel")         msg = max(texts, key=len) if texts else ""+        via = "win32"+        if not msg:+            # A Windows TASKDIALOG (KiCad 10 uses these): its text is drawn by+            # DirectUI with no per-string child controls, so GetWindowText finds+            # nothing. UI Automation reads it fine.+            #+            # HARVEST IT NOW, IN THIS CALL. The first cut returned the hwnd and+            # told the caller to run desktop_ui_tree themselves; that is a RACE+            # and it lost — the dialog closed between discovery and the+            # follow-up read, so the reason for the failure was gone. A verb+            # that finds an error must return the error's TEXT, not a promise+            # that the text is obtainable.+            #+            # ab owns UIA (desktop_ui_tree, read-only, no foreground), so we+            # call it rather than reimplementing IUIAutomation in here. That is+            # the same rule win_taskbar.py learned: the bridge owns KiCad, ab+            # owns Windows.+            ui = _uia_text(hwnd)+            if ui:+                msg, via = ui, "uia"         entry = {"hwnd": hwnd, "title": title, "message": msg,+                 "messageVia": via if msg else None,                  "allText": texts[:12],                  "level": "error" if _ERR_TITLE.search(title) else "info"}         if not msg:-            # A TaskDialog. Say so, and hand the caller the exact call that DOES-            # read it, rather than reporting an error with no text and leaving-            # them to screenshot it.-            entry["messageUnavailable"] = "taskdialog_directui"+            entry["messageUnavailable"] = "taskdialog_directui_and_uia_unavailable"             entry["readTextWith"] = (f'desktop_ui_tree {{"hwnd":{hwnd},"maxDepth":8}} '-                                     "— the message is the Text node; win32 cannot read "-                                     "DirectUI, UIA can (read-only, no foreground)")+                                     "— read it IMMEDIATELY; these dialogs are transient")         wins.append(entry)         return True 
handlers/kicad_ui.py+103−1
@@ -483,8 +483,110 @@ def _canvas_uniformity(pixels: bytes, width: int, height: int, rect=None) -> dic     except Exception:         return {"checked": False} +def _ab_capture(hwnd: int, label: str = "") -> dict | None:+    """Capture via AD core's desktop_screenshot_window. None if unavailable.++    WHY AB AND NOT OUR OWN PrintWindow (2026-09-01 audit, John):+    the bridge should own what only it can do — KiCad detection, KiCad's file+    formats, its plugin IPC — and call AD for anything about WINDOWS ITSELF.+    win_taskbar.py learned this the expensive way (an in-process+    ITaskbarList3::SetOverlayIcon returns S_OK cross-process and the shell+    silently ignores it; AD's desktop_taskbar works), and the rule was never+    generalised. This is the generalisation.++    What AD's capture gives us that a local PrintWindow does not:+      * OWNED POPUPS captured with the parent — an error dialog arrives WITH the+        editor that raised it, instead of needing a second enumeration pass;+      * coordMap, so a caller can click what it SEES ({space:"image"}) with no+        DPI or scaling arithmetic;+      * the universal auto-pull, so a cloud caller gets localFullPath and can+        Read the file with no pull_file hop;+      * ONE capture implementation to maintain instead of two that drift.++    What it CANNOT do is judge whether KiCad's OpenGL canvas actually rendered:+    that needs raw pixels and KiCad-specific knowledge of where the canvas is.+    So the split is: AD takes the picture, we keep the diagnosis (_canvas_probe).+    """+    try:+        from handlers import ad_client+        r = ad_client.call("desktop_screenshot_window",+                           {"hwnd": int(hwnd),+                            "reason": f"Capture the KiCad window {label or hwnd} as evidence for the calling AI"},+                           timeout=30) or {}+    except Exception:  # pylint: disable=broad-except+        return None+    if not isinstance(r, dict) or not (r.get("success") or r.get("ok")):+        return None+    full = r.get("fullPath") or r.get("fullPathHost") or r.get("savedTo")+    if not full:+        return None+    out = {"success": True, "savedTo": str(full).replace("\\", "/"),+           "fullPath": str(full).replace("\\", "/"),+           "capturedBy": "ad:desktop_screenshot_window"}+    for k in ("localFullPath", "safePath", "coordMap", "shotId",+              "fullWidth", "fullHeight", "ownedPopupCount", "screenshots"):+        if r.get(k) is not None:+            out[k] = r[k]+    if r.get("safeBytes") or r.get("fullBytes"):+        out["sizeKB"] = round((r.get("safeBytes") or r.get("fullBytes")) / 1024, 1)+    return out+++def _canvas_probe(hwnd: int) -> dict | None:+    """Uniformity verdict for the editor canvas WITHOUT writing a file.++    Kept local on purpose: this is the one part of a KiCad screenshot that AD+    cannot do for us, because it needs the raw BGRA buffer and knowledge of+    where KiCad puts its GL canvas. Cheap: one GDI grab, sampled, discarded."""+    try:+        rect = _get_window_rect(hwnd)+        width, height = rect["width"], rect["height"]+        if width <= 0 or height <= 0:+            return None+        hdc_screen = user32.GetDC(hwnd)+        hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)+        hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, width, height)+        old_bmp = gdi32.SelectObject(hdc_mem, hbmp)+        user32.PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT)+        class _BIH(ctypes.Structure):+            _fields_ = [("biSize", ctypes.wintypes.DWORD), ("biWidth", ctypes.wintypes.LONG),+                        ("biHeight", ctypes.wintypes.LONG), ("biPlanes", ctypes.wintypes.WORD),+                        ("biBitCount", ctypes.wintypes.WORD), ("biCompression", ctypes.wintypes.DWORD),+                        ("biSizeImage", ctypes.wintypes.DWORD), ("biXPelsPerMeter", ctypes.wintypes.LONG),+                        ("biYPelsPerMeter", ctypes.wintypes.LONG), ("biClrUsed", ctypes.wintypes.DWORD),+                        ("biClrImportant", ctypes.wintypes.DWORD)]+        bmi = _BIH()+        bmi.biSize = ctypes.sizeof(_BIH)+        bmi.biWidth, bmi.biHeight, bmi.biPlanes = width, height, 1+        bmi.biBitCount, bmi.biCompression = 32, BI_RGB+        pixel_buf = ctypes.create_string_buffer(width * height * 4)+        gdi32.GetDIBits(hdc_mem, hbmp, 0, height, pixel_buf, ctypes.byref(bmi), DIB_RGB_COLORS)+        buf = pixel_buf.raw+        gdi32.SelectObject(hdc_mem, old_bmp)+        gdi32.DeleteObject(hbmp)+        gdi32.DeleteDC(hdc_mem)+        user32.ReleaseDC(hwnd, hdc_screen)+        if buf is None:+            return None+        return _canvas_uniformity(buf, width, height, _gl_canvas_rect(hwnd))+    except Exception:  # pylint: disable=broad-except+        return None++ def _screenshot_hwnd(hwnd: int, label: str = "", max_dim: int = 1568) -> dict:-    """Capture a window by HWND using PrintWindow. Returns {success, savedTo, sizeKB}."""+    """Capture a window by HWND. AD core first, local PrintWindow as fallback.++    Returns {success, savedTo, fullPath, sizeKB, canvas}. `canvas` is always+    ours (see _canvas_probe); the IMAGE comes from AD when AD is reachable."""+    _ab = _ab_capture(hwnd, label)+    if _ab:+        _ab["canvas"] = _canvas_probe(hwnd)+        return _ab+    # FALLBACK: an older AD, a closed direct-API port, or a capture AD refused.+    # Never let the evidence channel die just because the preferred path is+    # unavailable — the screenshot loop is the floor under everything else+    # (John, 2026-09-01: "keep the screenshot-and-read-the-pixels loop just in+    # case"), so it must work even when AD does not answer.     # A park-minimized window (focus machinery) can't PrintWindow reliably —     # restore it OFF-SCREEN without activation just for the capture.     try: