app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
v0.9.79: KiCad-aware focus guardian (never fights the user, demotes thief below, 45s guard for async self-raises), launch added to focus set; background doctrine skill; verb suite enforces no-foreground-steal
8 files changed
+359−18
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.78+0.9.79
adom-bridge-kicad-manifest.json+5−5@@ -1,15 +1,15 @@ { "manifest_version": 1, "name": "kicad",- "version": "0.9.78",- "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.78/adom-bridge-kicad-v0.9.78.zip",- "sha256": "fe8093e95d901169e249f1df77bef73a80e911749a58c6b28daa5e0f31357f12",- "size": 374309,+ "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, "verbPrefixes": [ "kicad_" ], "healthEndpoint": "/status",- "released_at": "2026-08-14T09:25:04Z",+ "released_at": "2026-08-14T11:30:37Z", "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png", "languages": [ "Python",
adom-bridge-kicad-v0.9.79.zipadded⋯ 1 unchanged line ⋯
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.78",+ "version": "0.9.79", "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/win_focus.pyadded+191@@ -0,0 +1,191 @@+"""win_focus.py — keep KiCad windows in the BACKGROUND so the bridge never+steals the user's focus / interrupts their work.++The rule (enforced centrally by the dispatcher): capture whatever window the USER+has in the foreground BEFORE a window-spawning/interacting verb runs, then RESTORE+it AFTER. KiCad still opens and renders (visible behind), but the user's active+window stays active — their typing, their focus, undisturbed.++Why a helper: a plain SetForegroundWindow from a background process is blocked by+Windows' foreground-lock unless the caller owns the foreground. We use the+documented AttachThreadInput technique (attach our thread to both the current-fg+and target threads) so the restore reliably works cross-process.+"""++import sys++IS_WINDOWS = sys.platform == "win32"++if IS_WINDOWS:+ import ctypes+ import ctypes.wintypes+ _user32 = ctypes.windll.user32+ _kernel32 = ctypes.windll.kernel32+ SW_SHOWNOACTIVATE = 4 # show a window WITHOUT activating it (no focus theft)+++def capture_foreground():+ """Return the user's current foreground window handle (or None)."""+ if not IS_WINDOWS:+ return None+ try:+ hwnd = _user32.GetForegroundWindow()+ return hwnd or None+ except Exception:+ return None+++def _set_foreground_robust(hwnd) -> bool:+ """SetForegroundWindow that actually works from a background process, via the+ AttachThreadInput trick. Returns True on best-effort success."""+ try:+ fg = _user32.GetForegroundWindow()+ if fg == hwnd:+ return True+ cur = _kernel32.GetCurrentThreadId()+ tid_target = _user32.GetWindowThreadProcessId(hwnd, None)+ tid_fg = _user32.GetWindowThreadProcessId(fg, None) if fg else 0+ attached = []+ for tid in (tid_fg, tid_target):+ if tid and tid != cur and _user32.AttachThreadInput(cur, tid, True):+ attached.append(tid)+ _user32.BringWindowToTop(hwnd)+ _user32.SetForegroundWindow(hwnd)+ for tid in attached:+ _user32.AttachThreadInput(cur, tid, False)+ return True+ except Exception:+ return False+++def restore_foreground(hwnd) -> bool:+ """Put the user's window (captured earlier) back in front. No-op if it's gone+ or already foreground. This is what makes a KiCad open 'background' from the+ user's point of view — their window returns to the top the instant we're done."""+ if not IS_WINDOWS or not hwnd:+ return False+ try:+ if not _user32.IsWindow(hwnd) or not _user32.IsWindowVisible(hwnd):+ return False+ if _user32.GetForegroundWindow() == hwnd:+ return True+ return _set_foreground_robust(hwnd)+ except Exception:+ return False+++def show_without_activating(hwnd) -> None:+ """Make a window visible but do NOT activate it — the gentlest way to surface a+ KiCad window in the background (use instead of ShowWindow(SW_RESTORE)+SetForeground)."""+ if not IS_WINDOWS or not hwnd:+ return+ try:+ _user32.ShowWindow(hwnd, SW_SHOWNOACTIVATE)+ except Exception:+ pass+++def background_startupinfo():+ """A subprocess STARTUPINFO that asks a freshly-launched GUI (KiCad) to show its+ window WITHOUT activating it (SW_SHOWNOACTIVATE) — so a new pcbnew/eeschema opens+ behind the user's work instead of grabbing foreground. Best-effort: an app can+ override nCmdShow, which is why the dispatcher ALSO runs a persistent restore."""+ if not IS_WINDOWS:+ return None+ try:+ import subprocess+ si = subprocess.STARTUPINFO()+ si.dwFlags |= subprocess.STARTF_USESHOWWINDOW+ si.wShowWindow = SW_SHOWNOACTIVATE # 4+ return si+ except Exception:+ return None+++_KICAD_EXES = {"kicad.exe", "eeschema.exe", "pcbnew.exe", "kicad-cli.exe",+ "pl_editor.exe", "bitmap2component.exe", "pcb_calculator.exe"}+++def _hwnd_exe_name(hwnd) -> str:+ """Executable basename (lowercase) owning a window, '' on failure."""+ try:+ import ctypes+ import ctypes.wintypes+ pid = ctypes.wintypes.DWORD()+ _user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000+ h = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid.value)+ if not h:+ return ""+ try:+ buf = ctypes.create_unicode_buffer(512)+ size = ctypes.wintypes.DWORD(512)+ if ctypes.windll.kernel32.QueryFullProcessImageNameW(h, 0, buf, ctypes.byref(size)):+ return buf.value.replace("\\", "/").rsplit("/", 1)[-1].lower()+ finally:+ ctypes.windll.kernel32.CloseHandle(h)+ except Exception:+ pass+ return ""+++def is_kicad_window(hwnd) -> bool:+ return _hwnd_exe_name(hwnd) in _KICAD_EXES+++def _demote_below(kicad_hwnd, user_hwnd) -> None:+ """Slide a KiCad window directly UNDER the user's window without activating+ either — the window stays visible in the taskbar and fully screenshotable,+ but never covers what the user is doing."""+ try:+ SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE = 0x0010, 0x0002, 0x0001+ _user32.SetWindowPos(kicad_hwnd, user_hwnd, 0, 0, 0, 0,+ SWP_NOACTIVATE | SWP_NOMOVE | SWP_NOSIZE)+ except Exception:+ pass+++def restore_foreground_persistent(hwnd, seconds: float = 6.0, interval: float = 0.25) -> None:+ """Focus guardian: for `seconds` after a window-spawning verb, make sure NO+ KiCad window holds the user's foreground. KiCad activates windows+ ASYNCHRONOUSLY (a 3D viewer self-raises when its render completes — sometimes+ 30s+ after the verb returned), so a one-shot restore misses.++ v0.9.79 rules (John, 2026-08-14 — "ALWAYS open in the background"):+ - Only act when the foreground thief IS a KiCad window. The old loop+ restored a FIXED hwnd against any change, which fought the USER when they+ deliberately switched apps mid-guard.+ - Follow the user: whenever a non-KiCad window legitimately holds the+ foreground, adopt it as the new window-to-protect.+ - Don't just steal focus back — also DEMOTE the offending KiCad window to+ directly below the user's window, so it doesn't sit painted over their+ work even unfocused. Screenshots/PrintWindow keep working on it there.+ Non-blocking: returns immediately; the response isn't delayed."""+ if not IS_WINDOWS or not hwnd:+ return+ import threading+ import time as _t++ def _loop():+ protect = hwnd+ deadline = _t.monotonic() + seconds+ while _t.monotonic() < deadline:+ try:+ fg = _user32.GetForegroundWindow()+ if fg and fg != protect:+ if is_kicad_window(fg):+ # KiCad stole the foreground: user back on top, thief tucked under.+ if _user32.IsWindow(protect):+ _set_foreground_robust(protect)+ _demote_below(fg, protect)+ else:+ # The USER moved to another window — protect that instead.+ protect = fg+ except Exception:+ pass+ _t.sleep(interval)++ try:+ threading.Thread(target=_loop, daemon=True).start()+ except Exception:+ pass
server.py+7−3@@ -673,8 +673,8 @@ def dispatch_command(command: str, args: dict) -> dict: # every window it spawns lands ON TOP of the user (verified # live on AdomLapper 2026-08-08: PCB Editor at z0). "demo", "export_molecule"}- _SPAWNS_WINDOW_FOCUS = {"open_board", "open_schematic", "open_symbol_editor",- "open_footprint_editor", "open_3d_viewer",+ _SPAWNS_WINDOW_FOCUS = {"launch", "open_board", "open_schematic", "open_symbol_editor",+ "open_footprint_editor", "open_3d_viewer", "install_library", "place_footprint", "install_symbol", "install_footprint", "demo", "export_molecule"} # BACKGROUND-OPEN RULE: capture the USER's foreground window BEFORE any verb@@ -744,7 +744,11 @@ def dispatch_command(command: str, args: dict) -> dict: # seconds) — STARTUPINFO can't touch it and a 6s restore expires # mid-render, so it needs a longer guard. Plain launched editors # only self-activate once, right after launch, so 6s covers them.- _restore_secs = 16.0 if command in ("open_3d_viewer", "place_footprint", "demo") else 6.0+ # v0.9.79: guard MUCH longer — a 3D render's completion self-raise+ # lands 30s+ out on slow boxes, well past the old 16s window. The+ # guardian is KiCad-aware now (never fights the user), so a long+ # guard has no downside.+ _restore_secs = 45.0 if command in ("open_3d_viewer", "place_footprint", "demo", "launch") else 20.0 win_focus.restore_foreground_persistent(_user_fg, seconds=_restore_secs) if isinstance(result, dict): result["_userFocusRestored"] = bool(restored)
skills/kicad-bridge-background/SKILL.mdadded+98@@ -0,0 +1,98 @@+---+name: kicad-bridge-background+user-invocable: false+description: DEVELOPER-only skill (user-invocable:false). THE background-first window contract for the KiCad bridge - every KiCad window ALWAYS opens and stays in the BACKGROUND, never taking the user's foreground, because users are typing in their AI threads while the bridge works. Read BEFORE writing or changing ANY code that launches, raises, shows, clicks, or types into a KiCad window (open_* verbs, launch, demo, place_footprint, install_* editors, dialog handling). Covers the focus-guardian mechanism, the dispatcher wiring, the SW_SHOWNOACTIVATE rules, why screenshots keep working on background windows, and the test-suite enforcement. Trigger words - background window, foreground steal, focus steal, window takeover, no-foreground, open in background, focus guardian, SW_SHOWNOACTIVATE, restore foreground, window discipline, disruptive windows.+---++# kicad-bridge-background++**The contract (John, 2026-08-14, verbatim intent): a user is busy typing in+their AI threads; when the bridge opens KiCad windows they must NEVER take the+foreground. Everything the bridge does to KiCad — opening editors, screenshots,+clicks, key sends, demo beats — happens in the background while the user keeps+working.** This is a hard product requirement for the public release, not a+politeness preference.++## Why this is hard (the three failure modes)++1. **KiCad activates windows asynchronously.** `SW_SHOWNOACTIVATE` on the+ Popen only covers process start; wx frames `Raise()` themselves when they+ finish loading. The killer case: the 3D viewer self-raises when its RENDER+ completes — 30-60s after the verb already returned on a slow box. Any+ fixed-duration guard shorter than that loses.+2. **A naive guard fights the user.** The pre-0.9.79 loop restored a FIXED+ hwnd whenever the foreground changed — so if the user deliberately+ alt-tabbed to another app during the guard window, the bridge yanked them+ back. That is its own foreground disruption.+3. **Every code path that "shows" something is a suspect.** Not just open_*:+ demo beats, place_footprint, install_symbol/footprint with openEditor,+ dialog auto-dismiss that pokes a window, `_force_foreground` calls left in+ old handlers. New code copies old code; old code steals focus.++## The mechanism (v0.9.79)++`handlers/win_focus.py`:++- `background_startupinfo()` — every `Popen` of a KiCad exe MUST pass this+ (`wShowWindow = SW_SHOWNOACTIVATE`). First line of defense.+- `restore_foreground_persistent(user_hwnd, seconds)` — the **focus+ guardian**: a daemon thread that, for the guard window, watches the OS+ foreground. Rules:+ - Thief is a **KiCad window** (by owning-exe check: kicad/eeschema/pcbnew/+ pl_editor/etc.) → give the user back the foreground AND **demote** the+ KiCad window to directly below the user's window (`SetWindowPos` with+ `SWP_NOACTIVATE`), so it isn't painted over their work even unfocused.+ - Foreground is any **non-KiCad** window → the user chose it; **adopt it**+ as the new protected window. The guardian never fights the user.+- `is_kicad_window(hwnd)` / `_demote_below()` — reusable primitives.++`server.py` dispatcher:++- `_SPAWNS_WINDOW_FOCUS` — the set of verbs that get capture-fg ++ one-shot-restore + guardian. **When you add ANY verb that can surface a+ window, add it to this set.** (The `launch` verb was missed on first ship —+ that's how this rule earns its place here.)+- Guard durations: 45s for `open_3d_viewer` / `place_footprint` / `demo` /+ `launch` (slow async self-raises), 20s for the rest.++## Why background does not limit what the bridge can do++- Screenshots: `PrintWindow`-based capture (`kicad_screenshot_all`,+ `desktop_screenshot_window`) renders windows that are BEHIND other windows+ fine. (A fully minimized/hidden window is the case that blanks — demoted-+ below is not that.)+- Input: all clicks/keys are window-targeted (`PostMessage`/UIA to a specific+ hwnd) — they need no focus. `SendInput` paths are the exception and are+ banned precisely because they need focus.+- The plugin RPC (canvas loads, editor inventory) is in-process — completely+ focus-independent.++## Rules when writing bridge code++1. Never activate a KiCad window in a normal verb path. The ONLY sanctioned+ foreground direction is giving focus BACK to the user. Trap: the editor+ handlers contain a helper literally NAMED `_force_foreground` that was+ deliberately neutered in 2026-07 (it only does `SW_SHOWNOACTIVATE`; the name+ was kept to avoid call-site churn). Do NOT "fix" it back to activating, and+ prefer `win_focus.show_without_activating` in new code so the name stops+ spreading.+2. Every new spawn/raise path: `background_startupinfo()` + add the verb to+ `_SPAWNS_WINDOW_FOCUS`.+3. Long async completions (renders, imports) → the 45s guard tier.+4. Want the user to SEE a window? That is not the bridge's call. Return a+ hint; the AI asks the user or uses `notify_user` with `focus` — an+ explicit, user-visible action, never a side effect.++## Enforcement (so a regression cannot ship silently)++The daily verb suite (`kicad-bridge-test` skill) runs `fg_assert` after EVERY+phase-3 GUI verb: it reads the OS z-order and **fails the verb** if any KiCad+window holds z:0 afterward, reporting `FOREGROUND STEAL` with the title. A verb+that works functionally but steals focus is a FAILED verb. Fix the steal, not+the assertion.++History that motivated all this: 2026-07 the demo verb landed windows on top of+John mid-work (missing from the dispatcher focus set); 2026-08-14 the first+full verb-suite run took over his foreground repeatedly while he typed in+another AI thread — the old guard was too short for 3D-render self-raises and+fought deliberate app switches.
skills/kicad-bridge-test/run_verb_tests.py+56−8@@ -69,7 +69,7 @@ def ok(r: dict) -> bool: CHECKS = { "ok": ok,- "verbs>=48": lambda r: ok(r) and len(r.get("verbs") or []) >= 48,+ "verbs>=47": lambda r: ok(r) and len(r.get("verbs") or []) >= 47, "editors_listed": lambda r: ok(r) and "summary" in r, "windows_listed": lambda r: ok(r) and isinstance((r.get("data") or r).get("editors", None), list), "screenshot_files": lambda r: ok(r) and (r.get("screenshots") or r.get("savedTo") or (r.get("data") or {}).get("screenshots")),@@ -86,7 +86,7 @@ def matrix(w: dict) -> dict: OUT = w["out"] M = { # ---- phase 1: read-only / meta (no KiCad needed)- "describe": dict(phase=1, args={}, check="verbs>=48"),+ "describe": dict(phase=1, args={}, check="verbs>=47"), "status": dict(phase=1, args={}), "readiness": dict(phase=1, args={}), "list_versions": dict(phase=1, args={}),@@ -173,6 +173,9 @@ TEST_SYMBOL = '''(symbol "ADOM_VERBTEST" (pin_numbers hide) (in_bom yes) (on_boa (pin passive line (at -7.62 0 0) (length 2.54) (name "A" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27))))) (pin passive line (at 7.62 0 180) (length 2.54) (name "B" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))))''' +SYMBOL_LIB_WRAP = ('(kicad_symbol_lib (version 20231120) (generator "verbtest")\n'+ + TEST_SYMBOL + '\n)\n')+ TEST_FOOTPRINT = '''(footprint "ADOM_VERBTEST" (version 20240108) (generator "verbtest") (layer "F.Cu") (attr smd)@@ -250,6 +253,31 @@ def main(): ab(a.target, "send_files", {"filePaths": [files[0]], "dest": base + "/copy"}, timeout=120) w_copy_src = base + "/copy/rp2040-breakout.kicad_pcb" + fg_violations = []++ def fg_assert(verb):+ """After every GUI verb: the OS foreground window must NOT be KiCad.+ This is the enforcement of the ALWAYS-background contract — a verb that+ passes functionally but steals the foreground FAILS the run."""+ if a.skip_gui:+ return+ time.sleep(1.0)+ r = ab(a.target, "desktop_list_windows", {}, timeout=45)+ try:+ inner = json.loads(r["output"]) if isinstance(r.get("output"), str) else r+ ws = (inner.get("data") or inner).get("windows") or []+ top = next((x for x in ws if x.get("z") == 0), None)+ title = (top or {}).get("title", "")+ if any(k in title for k in ("KiCad", "PCB Editor", "Schematic Editor",+ "Symbol Editor", "Footprint Editor", "3D Viewer")):+ fg_violations.append({"after_verb": verb, "foreground": title})+ if verb in results and results[verb]["result"] == "PASS":+ results[verb]["result"] = "FAIL"+ results[verb]["reason"] = f"FOREGROUND STEAL: '{title}' on top after verb"+ print(f" !! FOREGROUND STEAL after kicad_{verb}: {title[:60]}")+ except Exception:+ pass+ def run(verb, spec): if only and verb not in only: return@@ -271,19 +299,26 @@ def main(): args = {"hwnd": v3["hwnd"], "x": 280, "y": 100, "relative": False, "reason": "daily verb test: zoom-in toolbar"} elif special == "install_symbol":- args = {"symbolText": TEST_SYMBOL}+ args = {"fileName": "ADOM_VERBTEST.kicad_sym", "fileContent": SYMBOL_LIB_WRAP,+ "symbolName": "ADOM_VERBTEST", "quietInstall": True} elif special == "install_footprint":- args = {"footprintText": TEST_FOOTPRINT, "footprintName": "ADOM_VERBTEST"}+ args = {"fileName": "ADOM_VERBTEST.kicad_mod", "fileContent": TEST_FOOTPRINT,+ "footprintName": "ADOM_VERBTEST", "quietInstall": True} elif special == "install_library":- args = {"symbols": [TEST_SYMBOL]}+ args = {"libraryPath": w["symlib"], "libraryName": "AdomVerbTest",+ "description": "daily verb test re-registration of the Adom lib"} elif special == "place_footprint":- args = {"filePath": w["board_copy"], "footprint": "Adom:ADOM_VERBTEST",- "x": 10, "y": 10}+ import base64 as _b64+ args = {"fileName": "ADOM_VERBTEST.kicad_mod",+ "fileContent": _b64.b64encode(TEST_FOOTPRINT.encode()).decode(),+ "footprintName": "ADOM_VERBTEST", "x": 30, "y": 30, "open": False} if verb == "format_upgrade": args = {"filePath": w_copy_src} if verb == "demo" and a.full: args = {"all": True} t = spec.get("timeout", 150)+ 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) check = CHECKS.get(spec.get("check", "ok"), ok) passed = False@@ -296,13 +331,23 @@ def main(): "response_head": json.dumps(r)[:400]} print(f" kicad_{verb:24} {'PASS' if passed else 'FAIL'}") + PHASE3_ORDER = ["launch", "open_board", "open_schematic", "open_symbol_editor",+ "open_footprint_editor", "open_3d_viewer", "open_editors",+ "window_info", "screenshot_all", "dismiss_dialogs", "send_key",+ "click", "bridge_call"] for phase in (1, 2, 3, 4, 5, 6): entries = [(v, s) for v, s in M.items() if s.get("phase") == phase] if not entries: continue+ if phase == 3:+ entries.sort(key=lambda kv: PHASE3_ORDER.index(kv[0]) if kv[0] in PHASE3_ORDER else 99)+ else:+ entries.sort() print(f"[{phase}] {len(entries)} verbs")- for verb, spec in sorted(entries):+ for verb, spec in entries: run(verb, spec)+ if phase == 3:+ fg_assert(verb) if phase == 3 and not a.skip_gui: time.sleep(2) @@ -319,6 +364,7 @@ def main(): "elapsed_s": round(time.time() - t0, 1), "totals": {"pass": len(passes), "fail": len(fails), "skip": len(skips)}, "coverage_gap": missing, "failures": fails, "results": results,+ "foreground_violations": fg_violations, } (outdir / "report.json").write_text(json.dumps(report, indent=2)) md = [f"# KiCad bridge verb test — {a.target} — {report['started'][:16]}",@@ -329,6 +375,8 @@ def main(): + (f" ({r.get('reason')})" if r.get("reason") else "")) if missing: md.append(f"\n**COVERAGE GAP** (live verbs with no test/skip): {missing}")+ if fg_violations:+ md.append(f"\n**FOREGROUND VIOLATIONS** (KiCad window on top after a verb): {fg_violations}") (outdir / "report.md").write_text("\n".join(md)) print(f"\n{len(passes)} pass / {len(fails)} fail / {len(skips)} skip" f" -> {outdir}/report.md")