← Commit history

v0.9.155 EMERGENCY: ownership ledger - the bridge only parks/bounces/badges windows from process trees it spawned; user-launched KiCad is untouchable. Sentinel now honors taskbar/Start clicks as user intent (it was bouncing John's own launches)

John Lauer ·ea54cba88b ·1mo ago ·parent 21b4032
12 files changed +294−27
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.154+0.9.155
adom-bridge-kicad-manifest.json+5−5
@@ -1,15 +1,15 @@ {   "manifest_version": 1,   "name": "kicad",-  "version": "0.9.154",-  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.154/adom-bridge-kicad-v0.9.154.zip",-  "sha256": "d0e13a16b607b1060970b76abb4fb7b56afe854bcae5477f7c8d3794b42ac052",-  "size": 434420,+  "version": "0.9.155",+  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.155/adom-bridge-kicad-v0.9.155.zip",+  "sha256": "abd0989f6d447c3b3dac9b8f444442880cf5a8a4c1b4f4970e25b3e11f98b003",+  "size": 436974,   "verbPrefixes": [     "kicad_"   ],   "healthEndpoint": "/status",-  "released_at": "2026-08-17T20:52:21Z",+  "released_at": "2026-08-17T21:13:28Z",   "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png",   "languages": [     "Python",
adom-bridge-kicad-v0.9.155.zipadded
⋯ 1 unchanged line ⋯
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "kicad",   "displayName": "KiCad EDA",-  "version": "0.9.154",+  "version": "0.9.155",   "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/launch.py+14−4
@@ -51,7 +51,12 @@ def handle_launch(kicad_info: dict, args: dict) -> dict:     if not IS_WINDOWS:         try:             cmd = ["open", "-a", "KiCad"] + ([project] if project else [])-            subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)+            _p = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)+            try:+                from handlers.win_focus import register_owned_pid+                register_owned_pid(_p.pid)+            except Exception:+                pass             return {"success": True, "launched": True,                     "output": "KiCad launch requested via `open -a KiCad`."}         except Exception as e:@@ -73,9 +78,14 @@ def handle_launch(kicad_info: dict, args: dict) -> dict:     except Exception:         pass     try:-        subprocess.Popen([exe] + ([project] if project else []),-                         stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,-                         startupinfo=_si)+        _p = subprocess.Popen([exe] + ([project] if project else []),+                              stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,+                              startupinfo=_si)+        try:+            from handlers.win_focus import register_owned_pid+            register_owned_pid(_p.pid)+        except Exception:+            pass     except Exception as e:         return {"success": False, "error": f"Failed to launch KiCad: {e}"} 
handlers/open_files.py+7−1
@@ -43,11 +43,17 @@ def _open_file(kicad_info: dict, args: dict, exe_key: str, file_type_label: str)         }      try:-        subprocess.Popen(+        _p = subprocess.Popen(             [exe, str(path)],             stdout=subprocess.DEVNULL,             stderr=subprocess.DEVNULL,         )++        try:+            from handlers.win_focus import register_owned_pid+            register_owned_pid(_p.pid)+        except Exception:+            pass         # v1.7.12+: hint nudging the AI toward kicad_lint_board /         # kicad_lint_schematic for unfamiliar files. The lint verbs are         # ~1-3s via kicad-cli and surface DRC/ERC violations + file-format
handlers/open_footprint_editor.py+14−2
@@ -418,10 +418,16 @@ def _mac_open_footprint_editor(kicad_info: dict, args: dict) -> dict:             "_hint": "Reinstall KiCad — the Footprint Editor app bundle is missing.",         }     try:-        subprocess.Popen(+        _p = subprocess.Popen(             ["open", "-a", str(fp_app)],             stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,         )++        try:+            from handlers.win_focus import register_owned_pid+            register_owned_pid(_p.pid)+        except Exception:+            pass     except Exception as e:         return {"success": False, "error": f"Failed to launch Footprint Editor: {e}"}     fp_name = args.get("footprintName", "") or args.get("footprint", "")@@ -589,12 +595,18 @@ def handle_open_footprint_editor(kicad_info: dict, args: dict) -> dict:                     _si = win_focus.background_startupinfo()  # open the manager behind the user                 except Exception:                     _si = None-                subprocess.Popen(+                _p = subprocess.Popen(                     [kicad_exe],                     startupinfo=_si,                     stdout=subprocess.DEVNULL,                     stderr=subprocess.DEVNULL,                 )++                try:+                    from handlers.win_focus import register_owned_pid+                    register_owned_pid(_p.pid)+                except Exception:+                    pass             except Exception as e:                 return {                     "success": False,
handlers/open_symbol_editor.py+14−2
@@ -341,10 +341,16 @@ def _mac_open_symbol_editor(kicad_info: dict, args: dict) -> dict:         }      try:-        subprocess.Popen(+        _p = subprocess.Popen(             ["open", "-a", str(sym_app)],             stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,         )++        try:+            from handlers.win_focus import register_owned_pid+            register_owned_pid(_p.pid)+        except Exception:+            pass     except Exception as e:         return {"success": False, "error": f"Failed to launch Symbol Editor: {e}"} @@ -498,12 +504,18 @@ def handle_open_symbol_editor(kicad_info: dict, args: dict) -> dict:                     _si = win_focus.background_startupinfo()  # open the manager behind the user                 except Exception:                     _si = None-                subprocess.Popen(+                _p = subprocess.Popen(                     [kicad_exe],                     startupinfo=_si,                     stdout=subprocess.DEVNULL,                     stderr=subprocess.DEVNULL,                 )++                try:+                    from handlers.win_focus import register_owned_pid+                    register_owned_pid(_p.pid)+                except Exception:+                    pass             except Exception as e:                 return {                     "success": False,
handlers/place_footprint.py+7−1
@@ -105,7 +105,7 @@ def handle_place_footprint(kicad_info: dict, args: dict) -> dict:                     _si = win_focus.background_startupinfo()  # SW_SHOWNOACTIVATE — open behind the user                 except Exception:                     _si = None-                subprocess.Popen(+                _p = subprocess.Popen(                     [pcbnew_exe, str(out_path)],                     creationflags=flags,                     startupinfo=_si,@@ -114,6 +114,12 @@ def handle_place_footprint(kicad_info: dict, args: dict) -> dict:                     stderr=subprocess.DEVNULL,                     close_fds=True,                 )++                try:+                    from handlers.win_focus import register_owned_pid+                    register_owned_pid(_p.pid)+                except Exception:+                    pass                 opened = True             except OSError as e:                 return {"success": True, "boardPath": str(out_path), "footprintName": fp_name,
handlers/win_focus.py+218−2
@@ -324,6 +324,7 @@ def get_focus_debug() -> dict:         "parkAttempts": _park_attempts,         "parkLastError": _park_last_error,         "sweepTicks": _sweep_ticks,+        "ownedPids": sorted(_owned_pids),     }  @@ -371,6 +372,182 @@ _park_baseline: set = set() _bridge_windows: set = set()  # windows the BRIDGE created (persists across verbs) _park_sweep_started = False +# ---- Ownership ledger (John, 2026-08-17: "what happens if the user opens their+# own kicad? i don't want us ruining that") -------------------------------------+# The bridge may only park/bounce/badge windows it OWNS. Ownership is decided by+# PROCESS: every kicad-family pid the bridge spawns is registered here (children+# count via parent-chain walk — the PM spawns eeschema.exe/pcbnew.exe). The set+# persists to %USERPROFILE%/.adom/kicad-bridge-owned.json so a bridge restart+# does not orphan (or worse, mis-adopt) windows. A KiCad the USER launched is in+# no spawned pid tree -> _is_owned_window() is False -> every focus/park/badge+# path leaves it completely alone. There is deliberately NO "adopt what exists"+# seeding: unknown windows default to user-owned.+_owned_pids: set = set()+_KICAD_EXES = ("kicad", "eeschema", "pcbnew", "gerbview", "pl_editor",+               "pcb_calculator", "bitmap2component")+++def _ledger_path():+    import os as _os+    from pathlib import Path as _P+    base = _os.environ.get("USERPROFILE") or str(_P.home())+    d = _P(base) / ".adom"+    try:+        d.mkdir(parents=True, exist_ok=True)+    except Exception:+        pass+    return d / "kicad-bridge-owned.json"+++def _pid_of_hwnd(hwnd) -> int:+    pid = ctypes.wintypes.DWORD()+    _user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))+    return int(pid.value)+++def _pid_exe_base(pid) -> str:+    try:+        h = _kernel32.OpenProcess(0x1000, False, int(pid))+        if not h:+            return ""+        try:+            buf = ctypes.create_unicode_buffer(512)+            size = ctypes.wintypes.DWORD(512)+            if _kernel32.QueryFullProcessImageNameW(h, 0, buf, ctypes.byref(size)):+                import os as _os+                return _os.path.basename(buf.value).lower()+        finally:+            _kernel32.CloseHandle(h)+    except Exception:+        pass+    return ""+++def _pid_is_kicad(pid) -> bool:+    base = _pid_exe_base(pid)+    return any(base.startswith(e) for e in _KICAD_EXES)+++def _save_ledger() -> None:+    try:+        import json as _json+        live = {int(p) for p in _owned_pids if _pid_is_kicad(p)}+        wins = []+        for h in list(_bridge_windows):+            try:+                if _user32.IsWindow(h):+                    wins.append({"hwnd": int(h), "pid": _pid_of_hwnd(h)})+            except Exception:+                pass+        _ledger_path().write_text(_json.dumps({"pids": sorted(live), "windows": wins}))+    except Exception:+        pass+++def load_owned_ledger() -> None:+    """Boot: re-own only what provably survived — a ledger hwnd whose pid still+    matches, and ledger pids still running a kicad-family exe. Everything else+    (including anything the user launched while the bridge was down) stays+    user-owned."""+    try:+        import json as _json+        data = _json.loads(_ledger_path().read_text())+    except Exception:+        return+    for e in data.get("windows", []):+        try:+            h, p = int(e.get("hwnd") or 0), int(e.get("pid") or 0)+            if h and _user32.IsWindow(h) and _pid_of_hwnd(h) == p and is_kicad_window(h):+                _bridge_windows.add(h)+                _owned_pids.add(p)+        except Exception:+            pass+    for p in data.get("pids", []):+        try:+            if _pid_is_kicad(p):+                _owned_pids.add(int(p))+        except Exception:+            pass+++def register_owned_pid(pid) -> None:+    """Spawn sites call this for every kicad-family process the bridge starts."""+    try:+        if pid:+            _owned_pids.add(int(pid))+            _save_ledger()+    except Exception:+        pass+++def _parent_pid_map() -> dict:+    """{pid: ppid} via toolhelp snapshot (cheap, ~ms)."""+    out = {}+    try:+        TH32CS_SNAPPROCESS = 0x02++        class PE32(ctypes.Structure):+            _fields_ = [("dwSize", ctypes.wintypes.DWORD),+                        ("cntUsage", ctypes.wintypes.DWORD),+                        ("th32ProcessID", ctypes.wintypes.DWORD),+                        ("th32DefaultHeapID", ctypes.POINTER(ctypes.c_ulong)),+                        ("th32ModuleID", ctypes.wintypes.DWORD),+                        ("cntThreads", ctypes.wintypes.DWORD),+                        ("th32ParentProcessID", ctypes.wintypes.DWORD),+                        ("pcPriClassBase", ctypes.c_long),+                        ("dwFlags", ctypes.wintypes.DWORD),+                        ("szExeFile", ctypes.c_wchar * 260)]++        snap = _kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)+        if snap == ctypes.wintypes.HANDLE(-1).value:+            return out+        try:+            pe = PE32()+            pe.dwSize = ctypes.sizeof(PE32)+            if _kernel32.Process32FirstW(snap, ctypes.byref(pe)):+                while True:+                    out[int(pe.th32ProcessID)] = int(pe.th32ParentProcessID)+                    if not _kernel32.Process32NextW(snap, ctypes.byref(pe)):+                        break+        finally:+            _kernel32.CloseHandle(snap)+    except Exception:+        pass+    return out+++def _is_owned_window(hwnd) -> bool:+    """Is this window the bridge's (spawned by us, or child of our spawn)?"""+    if hwnd in _bridge_windows:+        return True+    try:+        pid = _pid_of_hwnd(hwnd)+    except Exception:+        return False+    if pid in _owned_pids:+        return True+    try:+        pm = _parent_pid_map()+        p, depth = pid, 0+        while p in pm and depth < 6:+            p = pm[p]+            depth += 1+            if p in _owned_pids:+                _owned_pids.add(pid)  # child of our spawn: ours too+                _save_ledger()+                return True+    except Exception:+        pass+    return False+++def is_bridge_window(hwnd) -> bool:+    """Public ownership probe (kicad_state, taskbar badges)."""+    try:+        return _is_owned_window(hwnd)+    except Exception:+        return False+  def _enum_kicad_hwnds() -> set:     out = set()@@ -413,7 +590,14 @@ def _start_park_sweep() -> None:                     quiet_since = None                     if _t.monotonic() >= _expected_fg_until:                         for h in _enum_kicad_hwnds() - _park_baseline:-                            _bridge_windows.add(h)+                            # Ownership gate: a new window from a process tree we+                            # did NOT spawn is the USER's (e.g. they launched+                            # KiCad mid-verb) — never adopt, park, or badge it.+                            if h not in _bridge_windows and not _is_owned_window(h):+                                continue+                            if h not in _bridge_windows:+                                _bridge_windows.add(h)+                                _save_ledger()                             suppress_activation(h)                             _park_offscreen(h)                             try:@@ -671,6 +855,8 @@ def start_focus_event_hook() -> None:                 return  # sanctioned foreground in progress             if not is_kicad_window(hwnd):                 return+            if not _is_owned_window(hwnd):+                return  # the user's own KiCad: not ours to park or bounce             print(f"[focus-hook] event=0x{event:04x} hwnd={hwnd} inflight={_verb_inflight} guards={_guard_count}", file=sys.stderr)             if event == EVENT_OBJECT_SHOW:                 suppress_activation(hwnd)@@ -755,6 +941,31 @@ def start_focus_sentinel() -> None:         except Exception:             return False +    # 0.9.155 (caught live: John launched his own KiCad and the sentinel bounced+    # him 8 times, including a File Open Warning he never saw): a click on the+    # TASKBAR / Start is the canonical "bring this window up" gesture, but the+    # cursor is over the shell — not the raised window — when the transition+    # lands, so click-on-window missed it and the raise read as a self-raise.+    _SHELL_CLASSES = ("Shell_TrayWnd", "Shell_SecondaryTrayWnd",+                      "Windows.UI.Core.CoreWindow", "XamlExplorerHostIslandWindow",+                      "TopLevelWindowForOverflowXamlIsland", "Progman", "WorkerW")++    def _cursor_over_shell() -> bool:+        try:+            import ctypes as _ct+            pt = _ct.wintypes.POINT()+            _user32.GetCursorPos(_ct.byref(pt))+            under = _user32.WindowFromPoint(pt)+            GA_ROOT = 2+            root = _user32.GetAncestor(under, GA_ROOT) if under else None+            if not root:+                return False+            buf = _ct.create_unicode_buffer(64)+            _user32.GetClassNameW(root, buf, 64)+            return buf.value in _SHELL_CLASSES+        except Exception:+            return False+     def _loop():         # v0.9.115: "recent user input" was the wrong bless test — the user         # typing in ANOTHER app is exactly when a self-raise is most disruptive,@@ -781,12 +992,17 @@ def start_focus_sentinel() -> None:                 if fg != last_fg:                     if fg and is_kicad_window(fg):                         ago = _seconds_since_user_input()-                        clicked_here = (now - last_click_t) <= 1.2 and _cursor_over(fg)+                        clicked_here = (now - last_click_t) <= 1.2 and \+                            (_cursor_over(fg) or _cursor_over_shell())                         alt_tabbed = (now - last_alt_t) <= 2.0                         if now < _expected_fg_until:                             _log("sanctioned-foreground (allowed)", fg, ago)                         elif clicked_here or alt_tabbed:                             _log("user-foregrounded (left alone)", fg, ago)+                        elif not _is_owned_window(fg):+                            # Not our spawn tree: the USER's own KiCad. The+                            # bridge has no authority over it — ever.+                            _log("user-owned window (left alone)", fg, ago)                         elif _guard_count > 0:                             _log("steal-during-guard (guardian handles)", fg, ago)                         else:
handlers/win_taskbar.py+7−6
@@ -176,13 +176,14 @@ def clear_adom_badge(hwnd) -> None:   def _all_kicad_hwnds() -> set:-    """Every live KiCad top-level window: bridge-tracked PLUS enumerated.-    Windows opened before the last bridge restart drop out of _bridge_windows-    (fresh process, empty set) but were still bridge-driven — badging only the-    tracked set left the taskbar button bare after every bridge upgrade."""+    """Only windows the bridge OWNS (tracked live + revived from the persisted+    ownership ledger at boot). 0.9.154 briefly badged EVERY enumerated KiCad+    window to survive restarts — wrong the moment the user opens their own+    KiCad (John, 2026-08-17). The ledger solves restart amnesia the right way;+    user-owned windows are never touched."""     try:-        from handlers.win_focus import _bridge_windows, _enum_kicad_hwnds-        return _enum_kicad_hwnds() | set(_bridge_windows)+        from handlers.win_focus import _bridge_windows+        return set(_bridge_windows)     except Exception:         return set() 
server.py+6−2
@@ -1081,8 +1081,12 @@ def main():     except Exception:         pass -    # 0.9.153: KiCad windows outlive bridge restarts but _bridge_windows does-    # not, so overlay badges vanished after every upgrade — re-stamp survivors.+    # 0.9.155: revive the ownership ledger FIRST (which windows/pids are OURS —+    # never the user's own KiCad), then re-stamp badges on the survivors only.+    try:+        win_focus.load_owned_ledger()+    except Exception:+        pass     try:         from handlers.win_taskbar import badge_existing_on_boot         badge_existing_on_boot()