← Commit history
handlers/settings.py+35−1
@@ -19,6 +19,13 @@ from pathlib import Path  _DEFAULTS = {     "overlayBadges": True,+    # How the taskbar shows "Adom is driving KiCad" (John, 2026-08-22, adopted+    # from the fusion bridge): "progress" paints a progress bar on the KiCad+    # button from the measured verb timings and suppresses the OS orange flash;+    # "flash" leaves Windows' attention flash alone and paints no bar; "both";+    # "none". Default progress: a flash nags the user to click it away, while a+    # bar ends by itself exactly when Adom stops driving.+    "activityIndicator": "progress", }  @@ -50,12 +57,39 @@ def handle_get_settings(kicad_info: dict, args: dict) -> dict:     }  +_CHOICES = {"activityIndicator": ("progress", "flash", "both", "none")}+++def _coerce(key, raw):+    """Type the value by its DEFAULT's type. The first cut bool()'d everything,+    so activityIndicator:"flash" persisted as True and the indicator silently+    ran its fallback branch (caught 2026-08-23 rebuilding the settings modal)."""+    if key in _CHOICES:+        v = str(raw).strip().lower()+        if v not in _CHOICES[key]:+            raise ValueError(f"{key} must be one of {', '.join(_CHOICES[key])}")+        return v+    if isinstance(_DEFAULTS[key], bool):+        if isinstance(raw, str):+            return raw.strip().lower() in ("1", "true", "on", "yes")+        return bool(raw)+    return raw++ def handle_set_settings(kicad_info: dict, args: dict) -> dict:     s = get_settings()     changed = {}+    if args.get("restoreDefaults"):+        for k, v in _DEFAULTS.items():+            if s.get(k) != v:+                changed[k] = v+            s[k] = v     for k in _DEFAULTS:         if k in args:-            v = bool(args[k])+            try:+                v = _coerce(k, args[k])+            except ValueError as e:+                return {"success": False, "error": str(e)}             if s.get(k) != v:                 changed[k] = v             s[k] = v
handlers/show.py+48−4
@@ -76,6 +76,14 @@ def handle_show_symbol(kicad_info: dict, args: dict) -> dict:         "symbol editor", a.get("symbolName") or "",         a.get("libraryName") or a.get("library") or "")     if already:+        # #49 ask 2: the reuse fast path returned no resolution fields, so the+        # most common success on a warm editor was also the least inspectable.+        try:+            from handlers.open_symbol_editor import _resolved_fields+            _res = _resolved_fields(already.get("title") or "",+                                    a.get("libraryName") or a.get("library") or "")+        except Exception:+            _res = {}         _ev = _capture_evidence(already, "symbol-editor-reused")         _sh = _build_shot(already, "symbol-editor-reused",                           a.get("captureMaxWidth", _SHOT_DEFAULT_MAX)) if a.get("capture") else None@@ -239,7 +247,24 @@ def _build_shot(win, label: str, max_width: int) -> dict | None:         mw = max(_SHOT_MIN_MAX, min(_SHOT_MAX_MAX, mw))          from handlers.kicad_ui import _screenshot_hwnd-        shot = _screenshot_hwnd(hwnd, label=f"shot-{label}", max_dim=mw)+        # Wiki #52 (John): 4 of 30 symbol shots came back 100%-uniform white -+        # the editor answers "ready" before its GL canvas first paints, and the+        # blanks cluster on FAST parts precisely because less time passes+        # before the capture. The evidence path has polled for paint since+        # 0.9.202; this path - the one web control actually consumes - captured+        # once and shipped whatever it got. Own the settle here: retry while+        # the canvas region is unrendered, up to 10s, and REPORT the verdict on+        # the shot either way so a blank can never pose as proof.+        shot = None+        waited = 0+        for waited in range(11):+            shot = _screenshot_hwnd(hwnd, label=f"shot-{label}", max_dim=mw)+            c = (shot or {}).get("canvas") or {}+            if not shot or not shot.get("success"):+                return None+            if not c.get("checked") or c.get("rendered"):+                break+            time.sleep(1.0)         path = (shot or {}).get("fullPath") or (shot or {}).get("savedTo")         if not path:             return None@@ -251,7 +276,8 @@ def _build_shot(win, label: str, max_width: int) -> dict | None:         # what we actually wrote rather than assuming PNG.         ext = str(path).lower().rsplit(".", 1)[-1]         mime = {"png": "image/png", "bmp": "image/bmp", "webp": "image/webp"}.get(ext, "image/png")-        return {+        _c = (shot or {}).get("canvas") or {}+        out = {             "title": title,             "image": b64,             "thumbnailDataUrl": f"data:{mime};base64,{b64}",@@ -262,6 +288,14 @@ def _build_shot(win, label: str, max_width: int) -> dict | None:                       "auto-pulls. Request it with capture:true; captureMaxWidth defaults to "                       f"{_SHOT_DEFAULT_MAX} and clamps to {_SHOT_MIN_MAX}-{_SHOT_MAX_MAX}."),         }+        if _c.get("checked"):+            out["canvasRendered"] = bool(_c.get("rendered"))+            out["canvasWaitedSec"] = waited+            if not _c.get("rendered"):+                out["_canvasHint"] = ("The canvas region is still UNRENDERED after 10s of "+                                      "polling - treat this frame as proof of the window, "+                                      "not the part, and recapture rather than render it.")+        return out     except Exception:         return None @@ -516,10 +550,20 @@ def handle_show_library(kicad_info: dict, args: dict) -> dict:         return {"success": False, "error": "libraryName is required",                 "_hint": "kicad_show_library {\"libraryName\":\"Device\"} — add kind:\"footprint\" for the footprint side."}     kind = (a.get("kind") or "symbol").lower()+    # #49 follow-up: symbolName/footprintName were DROPPED here, and the+    # library-browse response then said "No symbol was requested" - which was+    # untrue when the caller had passed one. Silently discarding an argument and+    # then denying it was given is worse than ignoring it.+    want_sym = a.get("symbolName") or ""+    want_fp = a.get("footprintName") or ""     if kind in ("footprint", "fp"):-        r = handle_open_footprint_editor(kicad_info, {"library": lib, "timeout": a.get("timeout")})+        r = handle_open_footprint_editor(kicad_info, {+            "library": lib, "timeout": a.get("timeout"),+            **({"footprintName": want_fp} if want_fp else {})})     else:-        r = handle_open_symbol_editor(kicad_info, {"libraryName": lib, "timeout": a.get("timeout")})+        r = handle_open_symbol_editor(kicad_info, {+            "libraryName": lib, "timeout": a.get("timeout"),+            **({"symbolName": want_sym} if want_sym else {})})     if isinstance(r, dict):         r.setdefault("surface", "library")     return r
handlers/win_focus.py+110
@@ -12,7 +12,12 @@ documented AttachThreadInput technique (attach our thread to both the current-fg and target threads) so the restore reliably works cross-process. """ +import ctypes+import ctypes.wintypes+import json+import os import sys+import time  IS_WINDOWS = sys.platform == "win32" @@ -1528,3 +1533,108 @@ def announce_foreground(action: str, seconds: int = 3) -> None:         _t.sleep(1.2)   # give the toast a beat to render BEFORE the grab     except Exception:         pass+++# ── PLACEMENT MEMORY (wiki #48, Colby/Caleb, 2026-08-20) ────────────────────+# A bridge-initiated restart used to lose where the user had put their windows.+# Caleb drags KiCad to his external monitor, the bridge restarts it for the+# footprint-cache remedy, and Windows places the fresh process on the PRIMARY+# display: "it keeps moving and annoying." Restoring placement is not focus+# steal, so it belongs inside the background-first contract, not outside it.+_PLACEMENT_FILE = os.path.join(+    os.environ.get("USERPROFILE") or os.path.expanduser("~"),+    ".adom", "kicad-bridge-placement.json")+++def capture_placement() -> dict:+    """Where every KiCad window is RIGHT NOW, keyed by window kind.++    Keyed by kind ("symbol editor", "pcb editor", ...) rather than hwnd,+    because the whole point is to survive a restart, and hwnds do not."""+    out = {}+    try:+        for h in _enum_kicad_hwnds():+            try:+                if h in _parked:+                    continue          # never memorise the park position+                _b = ctypes.create_unicode_buffer(160)+                _user32.GetWindowTextW(h, _b, 160)+                title = (_b.value or "").lower()+                kind = next((k for k in ("symbol editor", "footprint editor", "pcb editor",+                                         "schematic editor", "3d viewer", "kicad")+                             if k in title), None)+                if not kind:+                    continue+                r = ctypes.wintypes.RECT()+                _user32.GetWindowRect(h, ctypes.byref(r))+                if r.right - r.left < 200 or r.bottom - r.top < 150:+                    continue          # a minimised/parked stub is not a placement+                out[kind] = {"x": r.left, "y": r.top,+                             "w": r.right - r.left, "h": r.bottom - r.top,+                             "maximized": bool(_user32.IsZoomed(h))}+            except Exception:+                continue+    except Exception:+        pass+    if out:+        try:+            os.makedirs(os.path.dirname(_PLACEMENT_FILE), exist_ok=True)+            prev = {}+            try:+                with open(_PLACEMENT_FILE) as f:+                    prev = json.load(f)+            except Exception:+                prev = {}+            prev.update(out)+            with open(_PLACEMENT_FILE, "w") as f:+                json.dump(prev, f, indent=2)+        except Exception:+            pass+    return out+++def restore_placement(saved: dict = None, waits: int = 40) -> dict:+    """Put fresh KiCad windows back where the user had them.++    Uses the on-disk memory when no live capture was taken, so even a COLD+    spawn lands on the monitor they last used rather than the primary."""+    if saved is None:+        try:+            with open(_PLACEMENT_FILE) as f:+                saved = json.load(f)+        except Exception:+            saved = {}+    if not saved:+        return {"restored": 0, "reason": "no remembered placement"}++    SWP_NOACTIVATE, SWP_NOZORDER = 0x0010, 0x0004+    SW_SHOWNOACTIVATE, SW_SHOWMAXIMIZED = 4, 3+    done, seen = 0, set()+    for _ in range(waits):+        for h in _enum_kicad_hwnds():+            try:+                if h in seen:+                    continue+                _b = ctypes.create_unicode_buffer(160)+                _user32.GetWindowTextW(h, _b, 160)+                title = (_b.value or "").lower()+                kind = next((k for k in saved if k in title), None)+                if not kind:+                    continue+                p = saved[kind]+                # NEVER leave it minimised: a parked window blanks PrintWindow,+                # which silently breaks the evidence/shot pipeline (#48 ask 4).+                _user32.ShowWindow(h, SW_SHOWMAXIMIZED if p.get("maximized")+                                   else SW_SHOWNOACTIVATE)+                if not p.get("maximized"):+                    _user32.SetWindowPos(h, 0, int(p["x"]), int(p["y"]),+                                         int(p["w"]), int(p["h"]),+                                         SWP_NOACTIVATE | SWP_NOZORDER)+                seen.add(h)+                done += 1+            except Exception:+                continue+        if done >= len(saved):+            break+        time.sleep(0.5)+    return {"restored": done, "kinds": sorted(saved.keys())}
handlers/win_taskbar.py+435−68
@@ -10,8 +10,11 @@ handlers/taskbar_overlay.py in fusion): an in-bridge ITaskbarList3::SetOverlayIc is IN-PROCESS-ONLY. The COM call returns S_OK cross-process and the shell silently ignores it (verified live here 2026-08-17: badges never rendered on kicad.exe's buttons). The working path is AD-core's `desktop_taskbar` verb: hand it the badge-PNG as base64 (resources/adom-overlay-32.png — fusion's exact asset, mark anchored-bottom-right of a transparent 32px canvas so Windows renders it at the right size)+PNG as base64 (resources/adom-overlay-32.png — mark anchored TOP-RIGHT of a+transparent 32px canvas, matching how pup places its favicon badge; John,+2026-08-21: "put the favicon in the upper right corner instead of the lower+right". Windows renders the overlay from that canvas, so the anchor in the+asset is what decides the corner) and AD paints the overlay natively, cross-process.      set:   desktop_taskbar {hwnd, overlay: {icon: <b64 png>, tooltip}}@@ -28,7 +31,6 @@ iconPath is a real .ico extracted from the exe itself — a fresh identity gets fresh icon lookup. kicad.exe's cache entry is healthy; its windows stay native. """ import os-import subprocess import sys import threading from pathlib import Path@@ -36,18 +38,27 @@ from pathlib import Path IS_WINDOWS = sys.platform == "win32"  _BADGE_PATH = str(Path(__file__).resolve().parent.parent / "resources" / "adom-overlay-32.png")-_ICON_DIR = os.path.join(os.environ.get("USERPROFILE") or str(Path.home()), ".adom", "kicad-icons") -# exe basename (lower) -> (appId, displayName). Only the exes whose shell icon-# cache is known-poisoned; kicad.exe renders fine natively.-_IDENTITY_EXES = {-    "eeschema.exe": ("Adom.KiCad.Eeschema", "KiCad Schematic Editor"),-    "pcbnew.exe": ("Adom.KiCad.Pcbnew", "KiCad PCB Editor"),-}+# ⛔ THIS BRIDGE STAMPS NO AUMIDs. EVER. (John, 2026-08-23: "i just want+# default kicad behavior.")+#+# History, so nobody rebuilds this: weeks ago an AI stamped per-window AUMIDs+# on eeschema/pcbnew windows and at least one stamp ran before icon extraction+# succeeded - Windows caches the icon PER AUMID STRING, so those buttons became+# a white generic box that survived KiCad reinstalls. The first "fix" (.v2+# suffix, 0.9.240-242) replaced the poison with Adom-flavoured identity, which+# was still an override of default KiCad behaviour. The real fix is REMOVAL:+# `_identity_repair()` below clears the per-window AppUserModelID property on+# every eeschema/pcbnew window and deletes our Adom.KiCad.* registry+# registrations, so buttons fall back to the exes' own native icons.+#+# The overlay badge and the progress bar DO NOT need an AUMID - they ride+# ITaskbarList3 (SetOverlayIcon/SetProgressValue) against the hwnd's button+# directly. Activity indication keeps working on fully native buttons.+_ONCE_STAMPED_EXES = {"eeschema.exe", "pcbnew.exe"}+_AUMID_PREFIX = "Adom.KiCad."  _badged: set = set()-_identified: set = set()-_icons_extracted = False _B64 = None _LOCK = threading.Lock() @@ -84,68 +95,209 @@ def _hwnd_exe_base(hwnd) -> str:         return ""  -def _ensure_exe_icons() -> None:-    """Extract eeschema/pcbnew .ico files from the installed exes (once). Runs a-    tiny PowerShell because a stdlib-only HICON→.ico writer isn't worth owning."""-    global _icons_extracted-    if _icons_extracted or not IS_WINDOWS:-        return-    if all(os.path.isfile(os.path.join(_ICON_DIR, e.replace(".exe", ".ico")))-           for e in _IDENTITY_EXES):-        _icons_extracted = True-        return+def _clear_window_aumid(hwnd) -> bool:+    """Blank the per-window PKEY_AppUserModel_ID (VT_EMPTY) so the button+    falls back to the exe's own identity. ctypes COM because the bridge has+    no pywin32; AD's desktop_set_window_identity has no clear path (an empty+    appId no-ops, verified live)."""+    import ctypes+    from ctypes import wintypes++    class GUID(ctypes.Structure):+        _fields_ = [("d1", ctypes.c_uint32), ("d2", ctypes.c_uint16),+                    ("d3", ctypes.c_uint16), ("d4", ctypes.c_ubyte * 8)]++    class PROPERTYKEY(ctypes.Structure):+        _fields_ = [("fmtid", GUID), ("pid", ctypes.c_uint32)]++    class PROPVARIANT(ctypes.Structure):+        _fields_ = [("vt", ctypes.c_uint16), ("r1", ctypes.c_uint16),+                    ("r2", ctypes.c_uint16), ("r3", ctypes.c_uint16),+                    ("data", ctypes.c_byte * 16)]++    try:+        iid_ips = GUID(0x886D8EEB, 0x8CF2, 0x4446, (ctypes.c_ubyte * 8)(+            0x8D, 0x02, 0xCD, 0xBA, 0x1D, 0xBD, 0xCF, 0x99))+        pkey = PROPERTYKEY(GUID(0x9F4C2855, 0x9F79, 0x4B39, (ctypes.c_ubyte * 8)(+            0xA8, 0xD0, 0xE1, 0xD4, 0x2D, 0xE1, 0xD5, 0xF3)), 5)+        ps = ctypes.c_void_p()+        hr = ctypes.windll.shell32.SHGetPropertyStoreForWindow(+            wintypes.HWND(hwnd), ctypes.byref(iid_ips), ctypes.byref(ps))+        if hr != 0 or not ps.value:+            return False+        # IPropertyStore vtable: QI(0) AddRef(1) Release(2) GetCount(3)+        # GetAt(4) GetValue(5) SetValue(6) Commit(7)+        vtbl = ctypes.cast(ps.value,+                           ctypes.POINTER(ctypes.POINTER(ctypes.c_void_p))).contents+        set_value = ctypes.WINFUNCTYPE(+            ctypes.c_long, ctypes.c_void_p, ctypes.POINTER(PROPERTYKEY),+            ctypes.POINTER(PROPVARIANT))(vtbl[6])+        commit = ctypes.WINFUNCTYPE(ctypes.c_long, ctypes.c_void_p)(vtbl[7])+        release = ctypes.WINFUNCTYPE(ctypes.c_long, ctypes.c_void_p)(vtbl[2])+        empty = PROPVARIANT()               # vt == VT_EMPTY+        ok = set_value(ps, ctypes.byref(pkey), ctypes.byref(empty)) == 0+        ok = (commit(ps) == 0) and ok+        release(ps)+        return ok+    except Exception:+        return False+++def _delete_adom_aumid_registrations() -> int:+    """Delete every HKCU AppUserModelId registration we ever created+    (Adom.KiCad.*): icon paths, display names, the poisoned strings, all of+    it. Best-effort; returns how many keys went."""+    n = 0+    try:+        import winreg+        root = r"Software\Classes\AppUserModelId"+        with winreg.OpenKey(winreg.HKEY_CURRENT_USER, root) as k:+            subs, i = [], 0+            while True:+                try:+                    subs.append(winreg.EnumKey(k, i))+                    i += 1+                except OSError:+                    break+        for s in subs:+            if s.startswith(_AUMID_PREFIX):+                try:+                    winreg.DeleteKey(winreg.HKEY_CURRENT_USER, root + "\\" + s)+                    n += 1+                except OSError:+                    pass+    except Exception:+        pass+    return n+++def _rebuild_button(hwnd) -> None:+    """Win11 bakes the button's tile at creation; a cleared AUMID only shows+    after the button is recreated. Brief hide + re-show (no activation) forces+    that - except on the foreground window, which we never blink."""     try:+        import ctypes+        u = ctypes.windll.user32+        if u.GetForegroundWindow() == hwnd:+            return+        if u.IsIconic(hwnd):+            u.ShowWindow(hwnd, 0)   # SW_HIDE+            u.ShowWindow(hwnd, 7)   # SW_SHOWMINNOACTIVE+        else:+            u.ShowWindow(hwnd, 0)+            u.ShowWindow(hwnd, 8)   # SW_SHOWNA+    except Exception:+        pass+++def _identity_repair() -> None:+    """One-shot per boot: remove every taskbar-identity override this bridge+    (or any earlier AI experiment) ever applied, restoring default KiCad+    taskbar behaviour. Idempotent - a clean box is a fast no-op."""+    cleared = _delete_adom_aumid_registrations()+    fixed = 0+    for h in _enum_all_kicad_hwnds():+        if _hwnd_exe_base(h) not in _ONCE_STAMPED_EXES:+            continue+        if _clear_window_aumid(h):+            _rebuild_button(h)+            fixed += 1+    if cleared or fixed:+        print("[taskbar] identity repair: %d registry key(s) removed, "+              "%d window(s) restored to native identity" % (cleared, fixed))+    _repair_kicad_shortcut_aumids()+++def _repair_kicad_shortcut_aumids() -> None:+    """Fill the AppUserModelID field KiCad's OWN installer forgot (solved live+    on John's taskbar, 2026-08-23).++    Standalone editor processes present AUMIDs like Kicad.Kicad.pcbnew.10.0+    (built at runtime; the pattern is confirmed by reading the taskbar buttons'+    AutomationIds). The installer writes that AUMID onto the MAIN shortcut only+    and leaves it blank on the standalone editor shortcuts, so the shell has no+    shortcut to resolve the editor group's icon from - on some machines it+    falls back to the window's icon (ConfRoomROG), on others it paints the+    generic white page (AdomLapper; why the fallback differs is unsolved).+    Writing KiCad's own AUMID onto KiCad's own user-level shortcut makes the+    icon resolution deterministic - the exact mechanism that keeps the main+    KiCad button correct. NO Adom identity involved.++    The mapping takes effect when explorer next (re)builds its Start Menu+    AUMID index (logon / explorer restart); we never restart explorer+    ourselves. Idempotent: shortcuts already carrying an AUMID are skipped."""+    try:+        import subprocess         from kicad_detect import detect_kicad         info = detect_kicad() or {}-        bin_dir = os.path.dirname(info.get("kicad_cli_exe") or info.get("kicadCli") or "")-        if not bin_dir:+        ver = str(info.get("version") or info.get("kicadVersion") or "")+        ver = ".".join(ver.split(".")[:2]) if ver else ""+        if not ver:             return         ps = r'''-Add-Type -AssemblyName System.Drawing-$sig = '[DllImport("shell32.dll", CharSet=CharSet.Unicode)] public static extern uint ExtractIconExW(string f, int i, IntPtr[] big, IntPtr[] small, uint n);'-Add-Type -MemberDefinition $sig -Name IX -Namespace W-New-Item -ItemType Directory -Force -Path "%OUT%" | Out-Null-foreach ($exe in @("eeschema","pcbnew")) {-  $big = New-Object IntPtr[] 1; $small = New-Object IntPtr[] 1-  [W.IX]::ExtractIconExW("%BIN%\$exe.exe", 0, $big, $small, 1) | Out-Null-  if ($big[0] -ne [IntPtr]::Zero) {-    $icon = [System.Drawing.Icon]::FromHandle($big[0])-    $fs = [System.IO.File]::Create((Join-Path "%OUT%" ($exe + ".ico")))-    $icon.Save($fs); $fs.Close()+$cs = @"+using System;+using System.Runtime.InteropServices;+using System.Runtime.InteropServices.ComTypes;+public class LnkAumid {+  [StructLayout(LayoutKind.Sequential, Pack=4)] public struct PROPERTYKEY { public Guid fmtid; public uint pid; }+  [StructLayout(LayoutKind.Explicit)] public struct PropVariant {+    [FieldOffset(0)] public ushort vt; [FieldOffset(8)] public IntPtr p;+  }+  [ComImport, Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]+  public interface IPropertyStore {+    int GetCount(out uint c); int GetAt(uint i, out PROPERTYKEY k);+    int GetValue(ref PROPERTYKEY k, out PropVariant v);+    int SetValue(ref PROPERTYKEY k, ref PropVariant v); int Commit();   }+  [ComImport, Guid("00021401-0000-0000-C000-000000000046")] class CShellLink {}+  public static bool Set(string path, string aumid) {+    var link = (object)new CShellLink();+    var pf = (IPersistFile)link;+    pf.Load(path, 2);+    var ps = (IPropertyStore)link;+    var k = new PROPERTYKEY { fmtid = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), pid = 5 };+    PropVariant cur; ps.GetValue(ref k, out cur);+    if (cur.vt == 31 && Marshal.PtrToStringUni(cur.p) != "") return false;+    var v = new PropVariant { vt = 31, p = Marshal.StringToCoTaskMemUni(aumid) };+    bool ok = ps.SetValue(ref k, ref v) == 0 && ps.Commit() == 0;+    pf.Save(path, true);+    Marshal.FreeCoTaskMem(v.p);+    return ok;+  }+}+"@+Add-Type -TypeDefinition $cs+$v = "%VER%"+$map = @{+  ("PCB Editor " + $v + " (standalone).lnk") = ("Kicad.Kicad.pcbnew." + $v)+  ("Schematic Editor " + $v + " (standalone).lnk") = ("Kicad.Kicad.eeschema." + $v)+  ("Gerber Viewer " + $v + ".lnk") = ("Kicad.Kicad.gerbview." + $v)+}+$n = 0+$root = "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\KiCad " + $v+foreach ($name in $map.Keys) {+  $p = Join-Path $root $name+  if (Test-Path $p) { try { if ([LnkAumid]::Set($p, $map[$name])) { $n++ } } catch {} } }-'''.replace("%OUT%", _ICON_DIR).replace("%BIN%", bin_dir)+Write-Output $n+'''.replace("%VER%", ver)         cnw = getattr(subprocess, "CREATE_NO_WINDOW", 0)-        subprocess.run(["powershell", "-NoProfile", "-Command", ps],-                       capture_output=True, timeout=30, creationflags=cnw)-        _icons_extracted = True+        r = subprocess.run(["powershell", "-NoProfile", "-Command", ps],+                           capture_output=True, timeout=60, creationflags=cnw)+        n = (r.stdout or b"").decode(errors="replace").strip()+        if n and n != "0":+            print("[taskbar] wrote KiCad's own AUMID onto %s standalone "+                  "shortcut(s) (installer gap); effective at next explorer "+                  "restart/logon" % n)     except Exception:         pass  -def _ensure_identity(hwnd) -> None:-    """Bypass the poisoned per-exe shell icon cache: stamp eeschema/pcbnew-    windows with a per-exe AUMID + real iconPath (idempotent per hwnd)."""-    if hwnd in _identified:-        return-    exe = _hwnd_exe_base(hwnd)-    ident = _IDENTITY_EXES.get(exe)-    if not ident:-        _identified.add(hwnd)  # kicad.exe & friends: nothing to fix-        return-    _ensure_exe_icons()-    app_id, display = ident-    ico = os.path.join(_ICON_DIR, exe.replace(".exe", ".ico"))-    args = {"hwnd": int(hwnd), "appId": app_id, "displayName": display}-    if os.path.isfile(ico):-        args["iconPath"] = ico-    if _ad("desktop_set_window_identity", args):-        _identified.add(hwnd)-- def apply_adom_badge(hwnd) -> bool:-    """Repair the button's base icon (identity) then stamp the Adom overlay-    (idempotent per hwnd). Best-effort: never raises, never blocks a verb."""+    """Stamp the Adom overlay on the window's taskbar button (idempotent per+    hwnd). ITaskbarList3 by hwnd - touches no AUMID, no base icon. Best-effort:+    never raises, never blocks a verb."""     try:         from handlers.settings import get_settings         if not get_settings().get("overlayBadges", True):@@ -155,7 +307,6 @@ def apply_adom_badge(hwnd) -> bool:     if hwnd in _badged:         return True     with _LOCK:-        _ensure_identity(hwnd)         icon = _badge_b64()         if not icon:             return False@@ -215,13 +366,229 @@ def badge_existing_on_boot() -> None:         return     import time -    def _later():-        time.sleep(5)+    # INVERTED (2026-08-22, twice in one day and each move follows from the+    # badge's one-sentence meaning). First: boot re-BADGING was retired - a+    # freshly booted bridge is driving nothing, so stamping at boot lied.+    # Then the live idle-clear proof caught the mirror-image gap: a badge+    # stamped by the PREVIOUS bridge process survived the restart, because the+    # fresh process had no _badged memory and an unarmed watchdog - overlays+    # outlived a deploy by design. Same sentence, same conclusion: at boot,+    # Adom is driving nothing, so any overlay found on a KiCad window is stale.+    # CLEAR them, off the boot path.+    def _boot_clear():+        import time as _t+        _t.sleep(6)+        try:+            for h in _enum_all_kicad_hwnds():+                _ad("desktop_taskbar", {"hwnd": int(h), "overlay": {"badge": "none"}})+                _paint_progress(h, None)     # a stale bar is the same lie+        except Exception:+            pass+    threading.Thread(target=_boot_clear, daemon=True,+                     name="kicad-boot-badge-clear").start()++    def _repair():+        import time as _t+        _t.sleep(8)              # off the boot path         try:-            from handlers.settings import get_settings-            if get_settings().get("overlayBadges", True):-                rebadge_all(True)+            _identity_repair()         except Exception:             pass+    threading.Thread(target=_repair, daemon=True,+                     name="kicad-identity-repair").start()+    return+++# ── ACTIVITY-DRIVEN overlay (John, 2026-08-22) ──────────────────────────────+# THE HIGH-LEVEL GOAL of the Adom overlay: tell the user "Adom is driving+# KiCad for you, right now". Which means it must track ACTIVITY, not ownership:+#+#   * John drove his own KiCad via web control and saw NO badge, because the+#     old code stamped only windows the bridge had itself spawned. But "who+#     launched it" is not what the badge claims - "who is driving it" is, and+#     a verb navigating the user's own KiCad is Adom driving it.+#   * The badge must also COME OFF when the driving stops, or it decays into+#     wallpaper. Five minutes with no KiCad verb -> cleared -> "it's yours".+#+# The 0.9.154 lesson still holds in its correct scope: never badge a user's+# KiCad that Adom is NOT touching (boot-time re-badging of enumerated windows+# was wrong). Activity semantics satisfy both: badge while verbs run against+# KiCad, whoever launched it; clear on idle.++_IDLE_CLEAR_SEC = 300           # "adom is no longer driving kicad, it's yours"+_last_drive = 0.0+_watchdog_on = False+# Verbs currently IN FLIGHT. The first ticker keyed "driving" off the+# note_driving timestamp alone, which is stamped once at dispatch START - so a+# 23s verb had its bar cleared at ~4s, mid-work (caught on a live screenshot:+# the direct paint showed, the ticker's did not). A counter cannot lie about+# in-flight the way a timestamp does.+_inflight = 0+_inflight_lock = threading.Lock()+++def _enum_all_kicad_hwnds() -> set:+    """EVERY live KiCad window, not just bridge-owned - during activity the+    badge belongs on whatever Adom is actually driving."""+    try:+        from handlers.win_focus import _enum_kicad_hwnds+        return _enum_kicad_hwnds()+    except Exception:+        return set()+++def note_driving() -> None:+    """A KiCad verb is running: stamp the overlay everywhere and reset the+    idle clock. Called from the dispatch chokepoint; best-effort, never+    blocks a verb."""+    global _last_drive, _watchdog_on, _inflight+    import time as _t+    _last_drive = _t.time()+    with _inflight_lock:+        _inflight += 1+    try:+        from handlers.settings import get_settings+        if not get_settings().get("overlayBadges", True):+            return+    except Exception:+        pass+    try:+        for h in _enum_all_kicad_hwnds():+            apply_adom_badge(h)+    except Exception:+        pass+    if not _watchdog_on:+        _watchdog_on = True+        threading.Thread(target=_idle_watchdog, daemon=True,+                         name="kicad-badge-idle").start()+    global _prog_ticker_on+    if not _prog_ticker_on:+        _prog_ticker_on = True+        print("[taskbar] progress ticker starting")+        threading.Thread(target=_progress_ticker, daemon=True,+                         name="kicad-taskbar-progress").start()+++def note_done() -> None:+    """The verb finished (success, failure, or raise): its driving is over.+    Paired with note_driving at the dispatch chokepoint's finally."""+    global _inflight, _last_drive+    import time as _t+    with _inflight_lock:+        _inflight = max(0, _inflight - 1)+    _last_drive = _t.time()      # idle clock starts when the LAST verb ends -    threading.Thread(target=_later, daemon=True, name="kicad-boot-badge").start()++def _idle_watchdog() -> None:+    """Clear the overlay after _IDLE_CLEAR_SEC with no KiCad verb activity.+    Checks every 30s; a new verb simply re-stamps, so flapping costs nothing."""+    import time as _t+    while True:+        _t.sleep(30)+        try:+            if _last_drive and (_t.time() - _last_drive) >= _IDLE_CLEAR_SEC:+                # Sweep every enumerated window, not just _badged: process+                # restarts (deploys, the footprint-cache remedy) orphan badges+                # this set has never heard of.+                for h in (_enum_all_kicad_hwnds() | set(_badged)):+                    clear_adom_badge(h) if h in _badged else _ad(+                        "desktop_taskbar", {"hwnd": int(h), "overlay": {"badge": "none"}})+        except Exception:+            pass+++# ── Taskbar PROGRESS instead of the orange flash (John, 2026-08-22) ────────+# The orange flash was never ours: this bridge calls FlashWindowEx nowhere.+# Windows itself flashes the button when a backgrounded KiCad's self-raise is+# denied by the focus sentinel - a side effect of the background-first+# contract. And a flash NAGS: it sits there until the user clicks it away, so+# it converts "Adom is working" into "you must do something". A progress bar+# says the same thing and then removes itself, which is the difference between+# informing and demanding. Adopted from the fusion bridge, toggle+# `activityIndicator` (progress|flash|both|none, default progress).++_FLASHW_STOP = 0+++def _flash_stop(hwnd) -> None:+    """Cancel an active OS attention-flash on a window (FLASHW_STOP)."""+    try:+        import ctypes+        class FLASHWINFO(ctypes.Structure):+            _fields_ = [("cbSize", ctypes.c_uint), ("hwnd", ctypes.c_void_p),+                        ("dwFlags", ctypes.c_uint), ("uCount", ctypes.c_uint),+                        ("dwTimeout", ctypes.c_uint)]+        fi = FLASHWINFO(ctypes.sizeof(FLASHWINFO), int(hwnd), _FLASHW_STOP, 0, 0)+        ctypes.windll.user32.FlashWindowEx(ctypes.byref(fi))+    except Exception:+        pass+++def _paint_progress(hwnd, percent) -> None:+    """Paint (or clear) the taskbar progress bar on one KiCad button."""+    if percent is None:+        _ad("desktop_taskbar", {"hwnd": int(hwnd), "progress": {"state": "none"}})+    elif percent < 0:+        _ad("desktop_taskbar", {"hwnd": int(hwnd),+                                "progress": {"state": "indeterminate"}})+    else:+        _ad("desktop_taskbar", {"hwnd": int(hwnd), "progress": {+            "state": "normal", "value": max(1, min(100, int(percent)))}})+++_prog_ticker_on = False+++def _progress_ticker() -> None:+    """While verbs run, mirror the bridge's MEASURED live progress frames onto+    every KiCad taskbar button; clear the bars the moment activity stops.++    2s cadence with a cached hwnd set - deliberately NOT per-paint enumeration+    at 0.7s, which the fusion code review measured at ~780 subprocess spawns+    per long verb."""+    import time as _t+    global _prog_ticker_on+    idle_polls = 0+    hwnds = set()+    try:+        while True:+            _t.sleep(2)+            mode = "progress"+            try:+                from handlers.settings import get_settings+                mode = get_settings().get("activityIndicator", "progress")+            except Exception:+                pass+            with _inflight_lock:+                _busy = _inflight > 0+            driving = _busy or (_last_drive and (_t.time() - _last_drive) < 3.5)+            if driving:+                idle_polls = 0+                hwnds = _enum_all_kicad_hwnds() or hwnds+                pct = None+                try:+                    from handlers import progress as _pg+                    frames = _pg.live(mine_only=False)+                    if frames:+                        pct = min((f.get("percent") for f in frames+                                   if f.get("percent") is not None), default=-1)+                        pct = -1 if pct is None else pct+                except Exception:+                    pct = -1+                for h in hwnds:+                    if mode in ("progress", "both"):+                        _paint_progress(h, pct if pct is not None else -1)+                    if mode in ("progress", "none"):+                        _flash_stop(h)   # the OS flash contradicts the bar+            else:+                idle_polls += 1+                if hwnds:+                    for h in hwnds:+                        _paint_progress(h, None)   # bars vanish WITH the work+                    hwnds = set()+                if idle_polls > 5:+                    _prog_ticker_on = False+                    return               # nothing to do; next verb restarts us+    except Exception as e:+        print(f"[taskbar] ticker DIED: {type(e).__name__}: {e}")+        _prog_ticker_on = False
plugin_payload/adom_bridge.py+67
@@ -2903,6 +2903,67 @@ class _ModuleFpTable:             raise _ProbeUnavailable("pcbnew has no FootprintLoad on this build")         return fn(library, footprint) ++def _ui_probe_fp_io() -> dict:+    """What footprint FILE-IO capability does this KiCad's Python expose?++    Wiki #51 (John: "try FootprintLibCreate and friends"). The module-level+    probe (#44) found no FootprintEnumerate/FootprintLoad on `pcbnew` itself -+    but KiCad has historically exposed them as METHODS of an IO/plugin class+    (PCB_IO in 6/7, PCB_IO_KICAD_SEXPR in 8+), which the earlier inventory's+    name filter would not have surfaced. Report what exists, so the next step+    is informed rather than another guess."""+    import pcbnew+    out = {}+    out["moduleAttrs"] = sorted(n for n in dir(pcbnew)+                                if "io" in n.lower() and not n.startswith("_"))[:25]+    for cls in ("PCB_IO_KICAD_SEXPR", "PCB_IO", "IO_MGR", "PCB_IO_MGR", "PLUGIN"):+        c = getattr(pcbnew, cls, None)+        if c is None:+            out[cls] = "absent"+            continue+        try:+            methods = [m for m in dir(c) if "ootprint" in m]+            out[cls] = methods or "(class exists, no footprint methods)"+        except Exception as e:+            out[cls] = f"introspect failed: {type(e).__name__}"+    return out+++def _ui_save_footprint_via_api(src_pretty: str, src_name: str,+                               dst_pretty: str, dst_name: str) -> dict:+    """THE EXPERIMENT: write a footprint into a library THROUGH KiCad's own IO,+    from inside the running process - then the caller checks whether the+    Footprint Editor can see it WITHOUT a restart. If yes, installs stop+    needing the ~150s restart entirely; if no, the cache ignores even KiCad's+    own writes and the avenue is closed with evidence."""+    import pcbnew+    io = None+    for cls in ("PCB_IO_KICAD_SEXPR", "PCB_IO"):+        c = getattr(pcbnew, cls, None)+        if c is not None:+            try:+                io = c()+                break+            except Exception:+                continue+    if io is None:+        return {"ok": False, "error": "no PCB_IO class constructible in this build"}+    try:+        fp = io.FootprintLoad(src_pretty, src_name)+        if fp is None:+            return {"ok": False, "error": f"FootprintLoad returned None for {src_name}"}+    except Exception as e:+        return {"ok": False, "error": f"load: {type(e).__name__}: {e}"}+    try:+        if dst_name and dst_name != src_name:+            fp.SetFPID(pcbnew.LIB_ID("", dst_name))+        io.FootprintSave(dst_pretty, fp)+        return {"ok": True, "saved": dst_name or src_name, "into": dst_pretty,+                "via": type(io).__name__}+    except Exception as e:+        return {"ok": False, "error": f"save: {type(e).__name__}: {e}"}+ def _ui_probe_footprint(library: str, footprint: str) -> dict:     """Ask KICAD ITSELF whether it can see and load this footprint. @@ -3057,6 +3118,12 @@ def _dispatch(method: str, params: dict) -> Any:         return {"payloadVersion": __version__, "marker": "reload-probe-1",                 "pid": _state["pid"], "exeName": _state["exe_name"],                 "uptimeMs": int(time.time() * 1000) - _STARTED_AT_MS}+    if method == "probe_fp_io":+        return _call_on_ui_thread(_ui_probe_fp_io)+    if method == "save_footprint_via_api":+        return _call_on_ui_thread(lambda: _ui_save_footprint_via_api(+            str(params.get("srcPretty") or ""), str(params.get("srcName") or ""),+            str(params.get("dstPretty") or ""), str(params.get("dstName") or "")))     if method == "probe_footprint":         return _call_on_ui_thread(lambda: _ui_probe_footprint(             str(params.get("library") or ""), str(params.get("footprint") or "")))
resources/adom-overlay-32.png
⋯ 1 unchanged line ⋯
resources/demo-audio/board.mp3added
⋯ 1 unchanged line ⋯
resources/demo-audio/board3d.mp3added
⋯ 1 unchanged line ⋯
resources/demo-audio/footprint.mp3added
⋯ 1 unchanged line ⋯
resources/demo-audio/prepare.mp3added
⋯ 1 unchanged line ⋯
resources/demo-audio/schematic.mp3added
⋯ 1 unchanged line ⋯
resources/demo-audio/symbol.mp3added
⋯ 1 unchanged line ⋯
server.py+87
@@ -606,6 +606,65 @@ def _detect_running_kicad_instances() -> list[dict]:   ++def _handle_rescan_libraries(kicad_info: dict, args: dict) -> dict:+    """kicad_rescan_libraries - make newly installed parts visible, ONCE.++    Wiki #51 (John): per-part cache restarts cost ~150s each and dominated a+    17-part run at 75% of total time. The batch fix: install N parts, call this+    once, then show them all - one rescan instead of N.++    Today the rescan IS a KiCad process restart, because KiCad 10 caches+    footprint libraries process-wide and its Python API exposes no table+    reload (measured on #44/#47: a frame bounce does not clear it, a process+    restart does; pcbnew has no FootprintEnumerate/FootprintLoad to drive).+    The verb owns that cost explicitly so no later show-verb pays it by+    surprise. If a future KiCad exposes an in-process reload, this verb keeps+    its name and simply gets fast."""+    # First resort (#51 discovery): reload every open editor's library tree in+    # place via its Refresh menu action - seconds, no restart, nothing closed.+    if not args.get("force"):+        try:+            from handlers.open_footprint_editor import _tree_refresh, _find_footprint_editor+            from handlers.open_symbol_editor import _find_symbol_editor+            refreshed = []+            for h in (_find_footprint_editor(), _find_symbol_editor()):+                if h and _tree_refresh(h):+                    refreshed.append(int(h))+            if refreshed:+                return {"success": True, "method": "tree-refresh",+                        "refreshedEditors": refreshed,+                        "_hint": ("Library trees reloaded in place - no restart. Parts "+                                  "installed before this call are now visible. If a show "+                                  "verb still reports library_cache_stale afterwards, call "+                                  "again with force:true for the full restart.")}+        except Exception:+            pass+    from handlers.open_footprint_editor import _restart_kicad_for_cache+    r = _restart_kicad_for_cache(kicad_info)+    if not r.get("restarted"):+        return {"success": False, "errorCode": "rescan_refused",+                "reason": r.get("reason"), "userWindows": r.get("userWindows"),+                "_hint": ("The bridge will not restart a KiCad a human launched. Ask the "+                          "user to close it, then call again.")}+    # This verb's whole job is the rescan, so unlike the mid-show path it DOES+    # wait for the fresh instance - the caller asked for exactly this cost.+    from handlers.kicad_ui import _find_all_kicad_windows+    import time as _t+    for _ in range(80):+        _t.sleep(0.75)+        try:+            if _find_all_kicad_windows():+                return {"success": True, "restarted": True, "ready": True,+                        "_hint": ("Library caches rebuilt. Every part installed before this "+                                  "call is now visible; show verbs will not trigger another "+                                  "restart until the next install-while-open.")}+        except Exception:+            pass+    return {"success": False, "restarted": True, "ready": False,+            "errorCode": "kicad_restarting", "statusVerb": "kicad_status",+            "error": "KiCad is restarting but did not answer within 60s; poll statusVerb."}+ def _handle_verb_times(kicad_info: dict, args: dict) -> dict:     """kicad_verb_times - measured run times per verb ON THIS BOX. @@ -764,6 +823,7 @@ COMMAND_HANDLERS = {     "list_footprints": handle_list_footprints,     "progress": _handle_progress,     "verb_times": _handle_verb_times,+    "rescan_libraries": _handle_rescan_libraries,     # v1.7.12+: headless lint pre-checks via kicad-cli     "run_erc": handle_run_erc,     "lint_board": handle_lint_board,@@ -950,6 +1010,11 @@ def dispatch_command(command: str, args: dict) -> dict:     #  _NO_GUI: headless kicad-cli verbs (no window) — skip the dialog sweep.     #  _SPAWNS_WINDOW: verbs that may surface a dialog — get the ~3s settle-poll.     #  _SPAWNS_WINDOW_FOCUS: verbs that RAISE a KiCad window — restore user focus.+    # Read-only verbs: they observe KiCad, they do not drive it. They still get+    # the GUI-verb dialog sweep, but never the activity indicator.+    _PASSIVE_VERBS = {"status", "bridge_status", "state", "readiness", "window_info",+                      "open_editors", "screenshot_all", "plugin_diagnose", "describe",+                      "get_settings", "diagnostics", "check_for_updates"}     _NO_GUI = {"run_drc", "run_erc", "lint_board", "lint_schematic", "lint_library",                "format_upgrade", "export_gerber", "export_gerbers", "export_pdf",                "export_svg", "export_step", "export_bom_csv", "adom_library_status",@@ -1016,6 +1081,20 @@ def dispatch_command(command: str, args: dict) -> dict:         if command not in _NO_GUI:             from handlers import win_focus as _wfg             _wfg.begin_gui_verb()+            # The Adom overlay's whole meaning is "Adom is driving KiCad right+            # now" (John, 2026-08-22) - stamped on ACTIVITY at the chokepoint,+            # cleared by the idle watchdog. But OBSERVATION IS NOT DRIVING:+            # status/state/readiness are polled every 15s by the dashboard, and+            # counting those as activity meant the badge could never clear on+            # any box running it - permanent wallpaper through a side door.+            # (Caught live: a kicad_status call re-stamped freshly cleared+            # badges.) Only verbs that ACT on KiCad mark driving.+            if command not in _PASSIVE_VERBS:+                try:+                    from handlers.win_taskbar import note_driving+                    note_driving()+                except Exception:+                    pass             # 0.9.156 (John's catch): a window CLOSED while parked off-screen             # makes KiCad persist the off-screen geometry, so the user's next             # normal launch opens invisibly. Restore true positions before any@@ -1050,6 +1129,14 @@ def dispatch_command(command: str, args: dict) -> dict:             _pgT.log_run(command, time.time() - _prog_started, ok=bool(_ok))         except Exception:             pass+        # The taskbar activity indicator pairs with note_driving: the bar must+        # track the verb exactly, so mark done in the same always-runs finally.+        try:+            if command not in _NO_GUI and command not in _PASSIVE_VERBS:+                from handlers.win_taskbar import note_done+                note_done()+        except Exception:+            pass         # Retire the live progress frame HERE, in the one block that runs even         # when the handler raises. Anywhere later is unreachable on the error         # path, and a leaked frame shows a poller a bar that never completes for