← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.270\ No newline at end of file+0.9.271\ 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.270",-  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.270/adom-bridge-kicad-v0.9.270.zip",-  "sha256": "3ef33acf4fbd10a4812973c2b2b5bfadb3311930b72f695f1737952956750550",-  "size": 1042012,+  "version": "0.9.271",+  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.271/adom-bridge-kicad-v0.9.271.zip",+  "sha256": "6245ed3354e7b5144684c0398374da7eb761a7c4d438179187ab96583c31d5ef",+  "size": 1045024,   "verbPrefixes": [     "kicad_"   ],   "healthEndpoint": "/status",-  "released_at": "2026-09-01T02:51:43Z",+  "released_at": "2026-09-01T02:57:17Z",   "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.270",+  "version": "0.9.271",   "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+14−23
@@ -245,27 +245,23 @@ def handle_errors(kicad_info: dict, args: dict) -> dict:     Read-only and background: enumerates windows and reads their text. Never     raises, never clicks, never screenshots. Args: includeAll:true to return     every KiCad dialog rather than only ones whose title looks like an error."""-    import ctypes-    from ctypes import wintypes-    user32 = ctypes.windll.user32+    # Window discovery comes from handlers/kicad_windows (AD's desktop_list_windows+    # when reachable, local EnumWindows otherwise). This used to run its own+    # EnumWindows and decide "is this KiCad?" by MATCHING THE TITLE, which is the+    # exact mistake skills/kicad-uia warns about: titles are localised and they+    # lie. Worse, it silently skipped any dialog whose caption does not contain a+    # KiCad word — a bare Win32 "#32770" error box owned by pcbnew.exe never+    # appeared at all. The shared finder decides by OWNING EXECUTABLE.+    from handlers import kicad_windows     wins: list[dict] = []+    rows = kicad_windows.find(fresh=True) -    @ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)-    def cb(hwnd, _lp):-        if not user32.IsWindowVisible(hwnd):-            return True-        buf = ctypes.create_unicode_buffer(512)-        user32.GetWindowTextW(hwnd, buf, 512)-        title = (buf.value or "").strip()+    for row in rows:+        hwnd, title = row["hwnd"], (row.get("title") or "").strip()         if not title:-            return True-        low = title.lower()-        looks_kicad = any(k in low for k in ("kicad", "pcb editor", "schematic editor",-                                             "symbol editor", "footprint editor", "3d viewer"))-        if not looks_kicad:-            return True+            continue         if not args.get("includeAll") and not _ERR_TITLE.search(title):-            return True+            continue         texts = _enum_dialog_text(hwnd)         # the message is the longest static; buttons are short ("OK", "Cancel")         msg = max(texts, key=len) if texts else ""@@ -298,14 +294,9 @@ def handle_errors(kicad_info: dict, args: dict) -> dict:             entry["readTextWith"] = (f'desktop_ui_tree {{"hwnd":{hwnd},"maxDepth":8}} '                                      "— read it IMMEDIATELY; these dialogs are transient")         wins.append(entry)-        return True--    try:-        user32.EnumWindows(cb, 0)-    except Exception as e:  # pylint: disable=broad-except-        return {"success": False, "error": f"window enumeration failed: {type(e).__name__}: {e}"}      return {"success": True, "count": len(wins), "dialogs": wins,+            "windowSource": kicad_windows.source(),             "_hint": ("KiCad has NO log file and in GUI mode its errors go to DIALOGS, not "                       "stderr — so this is where a GUI failure reason lives. Read-only: nothing "                       "is clicked or raised. THREE channels, use all three: (1) this verb; "
handlers/kicad_windows.pyadded+154
@@ -0,0 +1,154 @@+"""ONE place that answers "which KiCad windows exist right now".++Six modules each had their own EnumWindows + filter loop (close_windows.py,+open_symbol_editor.py, open_footprint_editor.py, kicad_ui.py, kicad_log.py,+server.py). They agreed by luck, not by construction, and a fix to one — say,+starting to include owned popups so an error dialog is visible — silently did+not reach the others. This is the single source of truth.++AD CORE FIRST, LOCAL FALLBACK+-----------------------------+The list comes from AD's `desktop_list_windows` when AD is reachable. That is+the 2026-09-01 audit rule (John): the bridge owns KiCad, AD owns Windows. It+also buys something the local path never had — the **z-order** field, which is+the only honest way to tell whether an action stole the user's foreground (see+skills/kicad-uia: response fields lie about this, z-order does not).++When AD is not reachable we enumerate locally, because window discovery is on+the path of nearly every verb and must not become a hard dependency on a+loopback call.++WHY THE CACHE EXISTS (the re-entrancy answer)+---------------------------------------------+AD calls this bridge; a bridge calling back into AD is re-entrant, and a verb+that looks up windows five times would make five loopback round trips (~8ms+each, plus AD's own enumeration) inside a call AD is already blocked on. A+short TTL collapses that to ONE lookup per verb invocation while staying fresh+enough for "what is on screen now": windows do not appear and vanish inside+250ms, and every mutating operation calls invalidate() anyway.++Do NOT raise the TTL to "make things faster". The staleness window is a+correctness budget: `kicad_errors` reading a dialog that closed 900ms ago+reports a failure that is no longer on screen.+"""++from __future__ import annotations++import time++_TTL_S = 0.25+_cache: dict = {"at": 0.0, "rows": None, "source": ""}+++def invalidate() -> None:+    """Call after anything that opens, closes, or retitles a KiCad window."""+    _cache["at"] = 0.0+    _cache["rows"] = None+++def _from_ad() -> list | None:+    """AD's view: hwnd, title, className, rect, kind, and z. None if unreachable."""+    try:+        from handlers import ad_client+        r = ad_client.call("desktop_list_windows", {}, timeout=6) or {}+    except Exception:  # pylint: disable=broad-except+        return None+    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+            return None+    data = (out or {}).get("data") if isinstance(out, dict) else None+    wins = (data or {}).get("windows") if isinstance(data, dict) else None+    if not isinstance(wins, list):+        return None+    rows = []+    for w in wins:+        if not isinstance(w, dict) or not w.get("hwnd"):+            continue+        rect = w.get("rect") or {}+        rows.append({+            "hwnd": int(w["hwnd"]),+            "title": w.get("title") or "",+            "className": w.get("className") or "",+            "rect": {"x": rect.get("left", rect.get("x", 0)),+                     "y": rect.get("top", rect.get("y", 0)),+                     "width": rect.get("width", 0), "height": rect.get("height", 0)},+            "z": w.get("z"),+            "kind": w.get("kind"),+        })+    return rows+++def _from_local() -> list:+    """Local EnumWindows. The fallback, and the only path when AD is old."""+    try:+        from handlers.kicad_ui import _find_all_kicad_windows+        return list(_find_all_kicad_windows() or [])+    except Exception:  # pylint: disable=broad-except+        return []+++def _is_kicad(row: dict) -> bool:+    """Owned by a KiCad executable. Uses the ONE predicate everybody already+    shares (win_focus.is_kicad_window, which checks the owning exe) rather than+    matching on window titles — titles are localised and lie ('[no symbol+    loaded]' while a symbol is selected)."""+    try:+        from handlers.win_focus import is_kicad_window+        return bool(is_kicad_window(row["hwnd"]))+    except Exception:  # pylint: disable=broad-except+        t = (row.get("title") or "").lower()+        return any(k in t for k in ("kicad", "pcb editor", "schematic editor",+                                    "symbol editor", "footprint editor", "3d viewer"))+++def find(all_windows: bool = False, fresh: bool = False) -> list:+    """KiCad windows now. all_windows=True returns every top-level window+    (needed when hunting a dialog whose owner is unclear)."""+    now = time.monotonic()+    if fresh or _cache["rows"] is None or (now - _cache["at"]) > _TTL_S:+        rows = _from_ad()+        src = "ad:desktop_list_windows"+        if rows is None:+            rows, src = _from_local(), "local:EnumWindows"+        _cache.update({"at": now, "rows": rows, "source": src})+    rows = _cache["rows"] or []+    return list(rows) if all_windows else [r for r in rows if _is_kicad(r)]+++def source() -> str:+    """Which path produced the cached list — for a verb that wants to say so."""+    return _cache.get("source") or "(not yet queried)"+++def _owner_hwnd(hwnd: int) -> int:+    """GW_OWNER for a window, 0 if it owns itself. AD's window list does not+    carry ownership, and the project manager is identified BY being unowned, so+    this one bit stays local."""+    try:+        import ctypes+        return int(ctypes.windll.user32.GetWindow(int(hwnd), 4) or 0)  # GW_OWNER+    except Exception:  # pylint: disable=broad-except+        return 0+++def find_project_manager() -> int | None:+    """The KiCad project-manager window, or None.++    This exact function was duplicated byte-for-byte in open_symbol_editor.py+    and open_footprint_editor.py. It is the manager when the title says KiCad,+    does NOT name a sub-tool, and the window is unowned (an editor or dialog is+    owned by, or titled after, its tool)."""+    for row in find():+        title = (row.get("title") or "")+        low = title.lower()+        if "kicad" not in low:+            continue+        if any(k in low for k in ("editor", "viewer", "calculator")):+            continue+        if _owner_hwnd(row["hwnd"]) == 0:+            return row["hwnd"]+    return None
handlers/open_footprint_editor.py+8−10
@@ -85,16 +85,14 @@ def _find_windows_by_title(substring: str) -> list:   def _find_kicad_manager() -> int | None:-    """Find the KiCad project manager window handle."""-    windows = _find_windows_by_title("KiCad")-    for hwnd, title, owner in windows:-        lower = title.lower()-        if ("editor" not in lower-                and "viewer" not in lower-                and "calculator" not in lower-                and owner == 0):-            return hwnd-    return None+    """Find the KiCad project manager window handle.++    Delegates to handlers/kicad_windows, which is the ONE window finder (AD's+    desktop_list_windows when reachable, local EnumWindows otherwise). This body+    used to be duplicated byte-for-byte here and in the other editor module, so a+    fix to one never reached the other."""+    from handlers import kicad_windows+    return kicad_windows.find_project_manager()   
handlers/open_symbol_editor.py+8−10
@@ -241,16 +241,14 @@ def _find_windows_by_title(substring: str) -> list:   def _find_kicad_manager() -> int | None:-    """Find the KiCad project manager window handle."""-    windows = _find_windows_by_title("KiCad")-    for hwnd, title, owner in windows:-        lower = title.lower()-        if ("editor" not in lower-                and "viewer" not in lower-                and "calculator" not in lower-                and owner == 0):-            return hwnd-    return None+    """Find the KiCad project manager window handle.++    Delegates to handlers/kicad_windows, which is the ONE window finder (AD's+    desktop_list_windows when reachable, local EnumWindows otherwise). This body+    used to be duplicated byte-for-byte here and in the other editor module, so a+    fix to one never reached the other."""+    from handlers import kicad_windows+    return kicad_windows.find_project_manager()   def _find_symbol_editor() -> int | None: