Adom Desktop - 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.
name: kicad-bridge-dev user-invocable: false description: DEVELOPER-only skill (user-invocable:false — installed for maintainers, never offered to everyday users). Building/debugging the Adom Desktop KiCad bridge: architecture, the 4 control surfaces, how to add a verb, and every fresh-machine gotcha this bridge has hit. Trigger words — kicad bridge dev, edit kicad bridge, add kicad verb, kicad bridge architecture, debug kicad bridge, kicad control surfaces, kicad fresh machine.
kicad-bridge-dev
Developer notes for the adom-desktop-kicad-bridge. Source-only (belongs in the repo, never shipped to a user install). Read adom-desktop-bridge-sdk first for the AD-core-vs-author boundary.
What it is
A reverse bridge AD spawns on the user's laptop (spawn.kind: python, entrypoint: server.py, port: 0). It adds the kicad_* verb namespace. AD provisions Python (Runtime contract v1.9.63+) and passes ADOM_BIND_HOST — bind that, never 0.0.0.0.
The 4 control surfaces (how a verb reaches KiCad)
- kicad-cli (
subprocess→kicad-cli.exe) — headless:pcb drc,sch erc,pcb/sch export(gerber/pdf/svg/step/bom),pcb/sch upgrade. No GUI. Handlers:run_drc,run_erc,lint_*,export.py,format_upgrade,bom.py,netlist.py. - KiCad IPC API (kipy) — KiCad 9+. Board/schematic introspection + automation (e.g.
place_footprintconfirms viakipy get_footprints()). Expands with KiCad 11. - Embedded-Python reverse bridge (
plugin_payload/adom_bridge.py) —usercustomize.pyauto-injects at KiCad's USER_SITE → runs an HTTP server INSIDE each KiCad process, writes a%TEMP%discovery file; container-sidehandlers/bridge_client.pyPOSTs JSON-RPC to/rpc, dispatched onto KiCad's wx UI thread viawx.CallAfter. Methods:ping,list_frames,get_menu_ids,wm_command. Verbs:bridge_status,bridge_call. - Win32 / UIA / SendKeys (
handlers/kicad_ui.py, ctypes/user32) — the last resort:WM_COMMAND(open editors),SendInput/keybd_event(alt+3,ctrl+s),PrintWindow+EnumWindows(screenshots),GetWindowRect. For opening the 3D viewer, screenshots, clicks, keystrokes, window management.
🚫 NON-NEGOTIABLE: input is WINDOW-targeted, never FOCUS-targeted
Opening windows in the background (below) is only half of it. How you send input matters just as much, because the global input APIs force you to foreground:
| API | Goes to | Verdict |
|---|---|---|
SendInput, keybd_event, mouse_event, SendKeys |
whatever owns FOCUS | ❌ forces SetForegroundWindow; a stray keystroke lands in the user's app |
SetCursorPos |
the user's PHYSICAL mouse | ❌ never — moving someone's pointer mid-work is the rudest thing we do |
PostMessage(hwnd, WM_KEYDOWN/WM_CHAR/WM_LBUTTONDOWN…) |
ONE window's queue | ✅ default — no focus, works while KiCad is buried |
SendMessage(hwnd, WM_SETTEXT, …) |
ONE control | ✅ set a text box without focus |
UIA ValuePattern.SetValue() / InvokePattern.Invoke() |
the a11y tree | ✅ set text / click controls with no focus |
plugin WM_COMMAND (bridge_client) |
KiCad's own command table | ✅ best for menu actions — deterministic AND background |
What went wrong (2026-07-20, John): navigate_symbol.ps1 did an Alt-key
foreground trick → SetForegroundWindow → SetCursorPos + mouse_event →
SendKeys typing, just to put a name in the search box. He was working across
five projects and the bridge repeatedly seized his screen and typed over him:
"when you take over my screen to type into the kicad window it's crazy rude!!!"
It now uses UIA ValuePattern.SetValue (WM_SETTEXT fallback) + posted Enter.
kicad_send_key/kicad_click post to the target hwnd by default.
Rules for any new verb:
- Reach for the plugin
WM_COMMANDpathway first; then UIA; thenPostMessage. - Never call
SetForegroundWindow,SetCursorPos,SendKeysorSendInputon a default path. Gate them behind an explicit caller opt-in (allowForeground/allowFocusSteal/allowCursorClick), default OFF. - When something genuinely can't work in the background, don't just do it —
return
errorCode: "foreground_required"with a hint telling the AI to warn the user ("I need your screen for ~5 seconds") vianotify_userand retry with the opt-in._foreground_warning()inhandlers/kicad_ui.pybuilds it. - Known limit: posted messages don't set global modifier state, so chords (Ctrl+Shift+E) are unreliable via PostMessage — use the plugin pathway.
🧪 And NEVER test on John's working machine
Test on a VM (ADOMGPU / a Hyper-V box), not AdomLapper. Even background windows
plus library installs on his primary box are disruptive, and a foregrounding bug
lands directly on top of whatever he's doing. If a VM isn't available, ASK before
driving his laptop.
🪟 NON-NEGOTIABLE: KiCad windows ALWAYS open in the BACKGROUND
The bridge must NEVER steal the user's focus or interrupt their work. A user may be typing in another app when the AI opens a board — the KiCad window must appear behind their active window, not yank their keyboard/foreground. This is a product rule, not a nicety. (Requested by John, 2026-07.)
How it's enforced (central, in dispatch_command): for EVERY GUI verb (everything
except the headless _NO_GUI kicad-cli set — opens, clicks, key sends, dialog sweeps, all
of it) the dispatcher:
win_focus.capture_foreground()— records the USER's active window before the handler runs.- runs the handler.
win_focus.restore_foreground(user_hwnd)after the handler + dialog sweep — hands focus back. Uses AttachThreadInput so the cross-process SetForegroundWindow actually sticks (a plain call from a background process is blocked by the foreground lock). The response carries_userFocusRestored: true/false.win_focus.restore_foreground_persistent(user_hwnd, seconds=…)— a daemon thread that keeps re-restoring the user for a few seconds. This is the part that actually works — a one-shot restore (step 3 alone) is NOT enough, because KiCad activates its window asynchronously, a beat after the handler returns. Two distinct cases, two durations (see_SPAWNS_WINDOWblock):- Launched editors (
open_board/open_schematic/open_symbol_editor→ a fresh pcbnew/eeschema viasubprocess.Popen): passwin_focus.background_startupinfo()asstartupinfo=so the process shows its window withSW_SHOWNOACTIVATE(opens behind), and a 6s persistent restore for the one late self-activation. Verified: window lands at z-index 1, right behind the user's z-0 window. - In-process child windows (the 3D viewer — pcbnew creates and re-raises it repeatedly
while it renders the board, over several seconds):
STARTUPINFOcan't touch it (no new process) and a 6s restore expires mid-render, so it needs a longer 16s restore (open_3d_viewer/place_footprint). Don't assume one restore duration fits all — match it to how long the window keeps re-raising.
- Launched editors (
Verifying background behavior (do NOT trust GetForegroundWindow via run_script): measuring
foreground from an AD run_script is unreliable — the measuring PowerShell/conhost briefly becomes
the foreground (you read '' or itself), and on some machines run_script just times out at 75s. Use
desktop_list_windows (AD-core, no bridge, returns top-level windows in EnumWindows / top-of-Z-order
first) and compare the z-index of the KiCad window vs the user's window. If the user's window has a
lower index than every KiCad window, it opened in the background. This is the reliable, non-perturbing test.
Mid-operation matters too, not just the end state. Restoring focus after still lets a
window FLASH forward mid-verb, which the user sees and hates. So the handlers themselves must
not foreground: _force_foreground in open_symbol/footprint_editor was gutted to
ShowWindow(SW_SHOWNOACTIVATE) (visible, not activated). The two fallbacks that genuinely
can't avoid it are OPT-IN, off by default:
- the launcher-icon click (moves the user's mouse via
SetCursorPos) → needs{"allowCursorClick": true}. - the Alt+3 keystroke for the 3D viewer (needs pcbnew foregrounded) → needs
{"allowFocusSteal": true}. Default behavior is the background plugin path (WM_COMMAND via the embedded reverse bridge — no focus, no cursor). If it can't (plugin not loaded), the verb fails gracefully pointing at "open a board first so the plugin loads," rather than hijacking the desktop.
Background fallback = UIA, not the cursor. Between the plugin path and the opt-in disruptive
fallbacks sits Tier 1.5: handlers/uia.py — it shells to PowerShell's System.Windows.Automation
and Invokes the "Footprint Editor" / "3D Viewer" control by accessible name. UIA Invoke is
programmatic: no foreground, no focus steal, no cursor move. KiCad (wxWidgets) exposes an invokable
UIA tree, so this is the reliable background way to open editors when the WM_COMMAND plugin path
isn't loaded. uia.uia_invoke(hwnd, contains=...) / uia.uia_set_value(...). This is the bridge's OWN
subprocess (like kicad-cli) — NOT AD's gated run_script.
Rules when you touch UI code:
- A new window-raising verb → add it to
_SPAWNS_WINDOW_FOCUSinserver.py. That's the whole opt-in; the dispatcher does the rest. - Prefer
win_focus.show_without_activating(hwnd)(SW_SHOWNOACTIVATE) overShowWindow(SW_RESTORE)+SetForegroundWindow+BringWindowToTopwhen a window just needs to be visible. The legacy_force_foregroundin open_symbol/footprint_editor is used for SendInput-nav that genuinely needs transient focus — the dispatcher restore covers its cost, but don't add NEW gratuitous foregrounding. - Deliver input by
PostMessage/SendMessageto an exact hwnd where you can (no focus needed) instead ofSendInput(global, needs foreground).kicad_click/kicad_send_keystill foreground+SendInput for the hard cases; that's fine as long as the dispatcher restore runs after. - Recording/demo doctrine agrees: NEVER foreground a window to fix a blank frame. Background capture (WGC) + background windows is the standard.
Adding a verb (3 places, keep in sync)
- Write
handle_<verb>in a handler; return a dict withsuccess+ a rich_hint(mandatory — the AI reads verb OUTPUT, not this skill). - Register in
server.pyCOMMAND_HANDLERS. - Add
kicad_<verb>tobridge.jsonverbs[]AND an entry (summary/hint/related/pitfalls) to_VERB_CATALOGinserver.py(thekicad_describecatalog). A test checks these three stay aligned.
Dispatcher special cases (dispatch_command): readiness, describe, check_for_updates return BEFORE the plugin auto-install + the KiCad-not-installed pre-check (they must work with KiCad absent). upgrade also bypasses that pre-check (it INSTALLS KiCad — do not gate the installer behind "KiCad not installed") and refreshes the detection cache on success.
⚖️ Every file you add to this repo may ship to every AD user
The release zip is auto-bundled into Adom Desktop, so repo weight is user-facing weight. Before adding a file, know which artifact it belongs to:
| Adding… | Goes in | Ships in the zip? |
|---|---|---|
handler / runtime .py |
repo root / handlers/ |
yes |
a data file the code READS (e.g. templates/blank-board.kicad_pcb) |
templates/ |
yes — and it must be in the zip or the verb breaks at runtime |
a .ps1 a handler shells out to |
repo root | yes |
| docs, tutorials, demo scripts, screenshots, hero art, fonts, sample projects, videos | docs/, demo/, tour-pack*/ |
NO |
| a skill (user/dev/publish) | skills/<name>/SKILL.md |
NO — skills ride the container pkg; duplicating them in the zip is the #1 source of bloat |
If a new verb needs a data file, put it in templates/ and confirm it survives the zip's exclusion
filter — the publish skill's §2 KEEP list must name it. (kicad_export_molecule is the worked
example: pure code + the existing kicad-cli, no new assets.)
Audit 2026-07-19 found 649 KB of never-referenced weight (219 KB of hero fonts, five
duplicated skills/ trees, demo docs in the zip; 496 KB of PNGs in the pkg). Don't re-grow it —
full KEEP/EXCLUDE policy lives in kicad-bridge-publish §2/§4.
🎬 The demo verb (kicad_demo) — match the ecosystem contract
HD's installer demos BOTH bridges, so kicad_demo deliberately returns the SAME
shape as fusion_demo. Do not invent fields: an AI driving both should not have to
special-case them.
| Field | Meaning |
|---|---|
stage |
which beat you're on |
done |
keep calling until true |
narrate |
speak this — written for a NEWCOMER, not an engineer |
screenshots |
[{label,path}], captured by the verb; labels schematic/board_2d/board_3d match Fusion's |
steps |
what it did (logging) |
_hint |
the exact next action |
Rules borrowed from fusion-demo (read that skill — it's the canonical one):
- A demo is not "launch the app." It finishes the blockers and shows real work. No KiCad? OFFER to install it and then do it, in the same call.
- Never end a demo on an error screen. A beat that won't open narrates the skip
and moves on;
successstays true anddata.failedStepscarries the detail. - Fixed tour order — symbol → footprint → 3D part → schematic → 2D board → 3D board. It tells the story of how a board is actually made.
- One beat per call is the default: the AI narrates, shows the screenshot, then
calls
data.nextCall. A bulk run also blows AD's per-request budget. - Land it in the user's words ("export the Gerbers", "run DRC"), never verb names.
- KiCad's honest pitch vs Fusion's: free, open source, no trial clock and no feature tier — say it, it's a real differentiator.
Cold-start gotcha: Symbol Editor is hosted in eeschema and Footprint Editor in pcbnew, so the demo warms BOTH documents before beat 1 — otherwise the editor beats fail with "could not open via the background (plugin) path".
Fresh-machine gotchas (all hard-won, all fixed — don't regress them)
- Install scope is mandatory. KiCad 10's NsisMultiUser installer: bare
/S=rc=666660(invalid params). Pass/allusers /S(elevated) or/currentuser /S(per-user,%LOCALAPPDATA%\Programs\KiCad, no UAC). Seehandlers/upgrade.py. - TLS on the portable Python. AD's provisioned Python has no CA bundle →
CERTIFICATE_VERIFY_FAILED. We bundlecerts/cacert.pem(certifi) and load it in_ssl_context(). Any new https fetch must use that context. - Truncated downloads read as EOF and pass MZ/size checks, then NSIS fails at install.
upgrade.pyHEADsContent-Lengthand refuses/deletes a size-mismatched file. - Config doesn't exist pre-first-launch.
%APPDATA%/kicad/<ver>/(lib tables) is created on KiCad's first GUI run.kicad_detect.ensure_win_user_config()bootstraps it (seeds tables from the install template) soinstall_library/_footprintwork on a never-launched KiCad. - Detection cache is built once at startup. After
kicad_upgradeinstalls KiCad,dispatch_commandre-runsdetect_all_kicad_versions()so subsequent verbs see it without a bridge restart. Detect paths include%LocalAppData%\Programs\KiCad(per-user installs). - The bridge's env is STRIPPED — don't trust
%LOCALAPPDATA%. AD provisions the bridge's Python with a minimal environment whereLOCALAPPDATAcan be UNSET. A/currentuser(no-UAC)kicad_upgradeinstalls to%LOCALAPPDATA%\Programs\KiCad, but if detection keys offos.environ["LOCALAPPDATA"]it comes back EMPTY and the just-installed KiCad is invisible (every verb → "not installed") even though AD's own glob-basedbridge_readinessfinds it.kicad_detect.WIN_KICAD_BASEStherefore derives the LocalAppData root fromLOCALAPPDATAandUSERPROFILE\AppData\LocalandPath.home(). Root-caused on ADOMBASELINE 2026-07 (v0.9.41). Rule: never read a user-profile path from a single env var in bridge code. - Blocking dialogs stall everything.
handlers/close_windows.pyscans EVERY window owned by a running KiCad process (by PID —_win_scan_kicad_dialogs, reliable; title/owner matching is NOT), auto-expires benign ones (OpenGL notice), screenshots them, and returns hints.window_infoself-heals by default;kicad_dismiss_dialogs {all|forceSoftwareCanvas}. - GPU-less hosts. No OpenGL → the notice pops and pcbnew/3D may not render. On expiring that notice we persist
graphics.canvas_type=2(Cairo) tokicad_common.json. NOTE: a truly minimal VM (Hyper-V, no GPU) may still not render the heavy editors even in Cairo — that's the hardware. Validate GUI/screenshot work on a real-GPU machine.
Diagnostics you can call
kicad_diagnostics {}— reach for this FIRST, and never AD's approval-gatedrun_script/shell. The bridge runs Python on the desktop with full FS/subprocess access, so it can answer every "where did KiCad install / what env do I see / what's running" question itself. Returns:environment(the env the BRIDGE sees — a nullLOCALAPPDATAhere is the tell for a per-user install going undetected),kicadSearch.bases(every path the detector scans + the version dirs /kicad.exeit finds),processes(running KiCad exes),kicadInfo(+ config/user dir existence). PassprobePaths:[...]to stat/list any path andreadFile:"..."to peek a small config/output file. All read-only, zero shell approval. (Added v0.9.44 precisely so debugging never needsrun_script— which pops a 1-hour shell-approval gate.)kicad_upgrade {"diagnoseOnly":true}— token elevation type + UACEnableLUA+ cached-installer/truncation facts (no install).kicad_dismiss_dialogs {"debug":true}— raw process-based dialog scan (PIDs + every KiCad window + class + isDialog).
Testing on a fresh AD / VM
refresh_bridges {name:kicad} pulls the new cache; the RUNNING instance keeps its old code until it dies — bridge_kill {name:kicad} forces a fresh spawn that loads the new version and re-detects. The first verb after a kill returns a bridge_starting envelope — retry. Confirm the running version via bridge_log_read ("starting v0.9.xx").
Smoke test that a repackaged zip is COMPLETE (run after any change to the zip's file set — a missing data file only shows up at runtime, not at build):
- Static: every local import in the zip resolves inside the zip, and each data file the code reads
(
certs/cacert.pem,templates/blank-board.kicad_pcb,*.ps1) is present. - Live, on a real target:
kicad_describe→ expected verb count (44 @ v0.9.62 — a drop means a handler failed to import), then call one verb that touches a data file. - "KiCad is not installed" from a verb is the correct guard, not a packaging failure — don't
chase it as a regression. Check
kicad_readiness.detectedto tell the two apart.
Hard-won field notes (winvm full-battery, 2026-07)
Headless / GPU-less VM 3D rendering — deploy Mesa llvmpipe. KiCad's 3D viewer needs
OpenGL ≥ 1.5; a bare Azure/cloud Windows VM only has the GDI generic GL 1.1, so the 3D
viewer shows a black canvas + "Your OpenGL version is not supported." Fix: drop the Mesa
software-GL DLLs next to pcbnew.exe (its dir wins the DLL search order). Get
mesa3d-<ver>-release-msvc.7z from pal1000/mesa-dist-win, extract the x64 DLLs and copy
opengl32.dll + libgallium_wgl.dll (the ~60 MB llvmpipe renderer) + dxil.dll +
libGLESv2.dll into KiCad's bin. KiCad 10 via winget installs per-user
(%LOCALAPPDATA%/Programs/KiCad/10.0/bin, writable without admin), not Program Files. After
that, full board 3D renders live and stably (software-rendered).
Menu-command ids DRIFT across KiCad builds — resolve them live. The PCB "3D Viewer" id was
20563 on one build, 20568 on KiCad 10 as shipped to winvm. Never hardcode: use
bridge_client.resolve_menu_id_via_bridge(exe, contains="3D Viewer", frame_index=…)
(get_menu_ids + text match) with the constant only as a last-ditch fallback.
The Footprint Editor is a FRAME inside the pcbnew process (KiCad 10), so the reverse-bridge
plugin can drive its 3D viewer with NO focus steal: resolve_frame_index_via_bridge("pcbnew", "Footprint Editor") → wm_command{frame_index, menu_id}. wm_command honors frame_index even
though get_menu_ids currently only reports frame 0. This replaced the old Alt+3 keystroke
path (needed foreground + raised UnboundLocalError on a shadowed VK_MENU).
Legacy (module …) footprints (KiCad ≤5 / EAGLE-imported / most chip-fetcher parts) are
rejected by a v9 board parser. place_footprint now normalizes (module→(footprint + quotes
the top-level (layer X); that splices into a preview board and KiCad loads it. (pcbnew's GUI is
stricter than kicad-cli lint — a footprint can lint clean yet the GUI aborts with "Open
canceled by user"; when that happens, screenshot the #32770 error dialog to see why.)
kicad_click is RELATIVE by default — x/y are fractions 0.0–1.0 of the window, NOT
pixels. Passing pixels with the default clicks far off-screen (can hit another window's close
box and crash KiCad). v0.9.59 guards this, but always pass fractions or {"relative": false}.
Capture without a round-trip: AD-core desktop_screenshot_window {hwnd} returns a
container-local path in localFullPath (already pulled) — Read it directly, no pull_file
needed. It's also background-safe (PrintWindow), unlike kicad_screenshot_all which can time
out capturing every window.
---
name: kicad-bridge-dev
user-invocable: false
description: DEVELOPER-only skill (user-invocable:false — installed for maintainers, never offered to everyday users). Building/debugging the Adom Desktop KiCad bridge: architecture, the 4 control surfaces, how to add a verb, and every fresh-machine gotcha this bridge has hit. Trigger words — kicad bridge dev, edit kicad bridge, add kicad verb, kicad bridge architecture, debug kicad bridge, kicad control surfaces, kicad fresh machine.
---
# kicad-bridge-dev
Developer notes for the `adom-desktop-kicad-bridge`. Source-only (belongs in the repo, never shipped to a user install). Read `adom-desktop-bridge-sdk` first for the AD-core-vs-author boundary.
## What it is
A reverse bridge AD spawns on the user's laptop (`spawn.kind: python`, `entrypoint: server.py`, `port: 0`). It adds the `kicad_*` verb namespace. AD provisions Python (Runtime contract v1.9.63+) and passes `ADOM_BIND_HOST` — bind that, never `0.0.0.0`.
## The 4 control surfaces (how a verb reaches KiCad)
1. **kicad-cli** (`subprocess` → `kicad-cli.exe`) — headless: `pcb drc`, `sch erc`, `pcb/sch export` (gerber/pdf/svg/step/bom), `pcb/sch upgrade`. No GUI. Handlers: `run_drc`, `run_erc`, `lint_*`, `export.py`, `format_upgrade`, `bom.py`, `netlist.py`.
2. **KiCad IPC API (kipy)** — KiCad 9+. Board/schematic introspection + automation (e.g. `place_footprint` confirms via `kipy get_footprints()`). Expands with KiCad 11.
3. **Embedded-Python reverse bridge** (`plugin_payload/adom_bridge.py`) — `usercustomize.py` auto-injects at KiCad's USER_SITE → runs an HTTP server INSIDE each KiCad process, writes a `%TEMP%` discovery file; container-side `handlers/bridge_client.py` POSTs JSON-RPC to `/rpc`, dispatched onto KiCad's wx UI thread via `wx.CallAfter`. Methods: `ping`, `list_frames`, `get_menu_ids`, `wm_command`. Verbs: `bridge_status`, `bridge_call`.
4. **Win32 / UIA / SendKeys** (`handlers/kicad_ui.py`, ctypes/user32) — the last resort: `WM_COMMAND` (open editors), `SendInput`/`keybd_event` (`alt+3`, `ctrl+s`), `PrintWindow`+`EnumWindows` (screenshots), `GetWindowRect`. For opening the 3D viewer, screenshots, clicks, keystrokes, window management.
## 🚫 NON-NEGOTIABLE: input is WINDOW-targeted, never FOCUS-targeted
Opening windows in the background (below) is only half of it. **How you send input
matters just as much**, because the global input APIs force you to foreground:
| API | Goes to | Verdict |
|---|---|---|
| `SendInput`, `keybd_event`, `mouse_event`, `SendKeys` | whatever owns FOCUS | ❌ forces `SetForegroundWindow`; a stray keystroke lands in the user's app |
| `SetCursorPos` | the user's PHYSICAL mouse | ❌ never — moving someone's pointer mid-work is the rudest thing we do |
| `PostMessage(hwnd, WM_KEYDOWN/WM_CHAR/WM_LBUTTONDOWN…)` | ONE window's queue | ✅ default — no focus, works while KiCad is buried |
| `SendMessage(hwnd, WM_SETTEXT, …)` | ONE control | ✅ set a text box without focus |
| UIA `ValuePattern.SetValue()` / `InvokePattern.Invoke()` | the a11y tree | ✅ set text / click controls with no focus |
| plugin `WM_COMMAND` (`bridge_client`) | KiCad's own command table | ✅ best for menu actions — deterministic AND background |
**What went wrong (2026-07-20, John):** `navigate_symbol.ps1` did an Alt-key
foreground trick → `SetForegroundWindow` → `SetCursorPos` + `mouse_event` →
`SendKeys` typing, just to put a name in the search box. He was working across
five projects and the bridge repeatedly seized his screen and typed over him:
*"when you take over my screen to type into the kicad window it's crazy rude!!!"*
It now uses UIA `ValuePattern.SetValue` (WM_SETTEXT fallback) + posted Enter.
`kicad_send_key`/`kicad_click` post to the target hwnd by default.
**Rules for any new verb:**
1. Reach for the plugin `WM_COMMAND` pathway first; then UIA; then `PostMessage`.
2. Never call `SetForegroundWindow`, `SetCursorPos`, `SendKeys` or `SendInput` on
a default path. Gate them behind an explicit caller opt-in
(`allowForeground` / `allowFocusSteal` / `allowCursorClick`), default OFF.
3. When something genuinely can't work in the background, **don't just do it** —
return `errorCode: "foreground_required"` with a hint telling the AI to warn
the user ("I need your screen for ~5 seconds") via `notify_user` and retry
with the opt-in. `_foreground_warning()` in `handlers/kicad_ui.py` builds it.
4. Known limit: posted messages don't set global modifier state, so **chords
(Ctrl+Shift+E) are unreliable via PostMessage** — use the plugin pathway.
## 🧪 And NEVER test on John's working machine
Test on a VM (ADOMGPU / a Hyper-V box), not `AdomLapper`. Even background windows
plus library installs on his primary box are disruptive, and a foregrounding bug
lands directly on top of whatever he's doing. If a VM isn't available, ASK before
driving his laptop.
## 🪟 NON-NEGOTIABLE: KiCad windows ALWAYS open in the BACKGROUND
**The bridge must NEVER steal the user's focus or interrupt their work.** A user may
be typing in another app when the AI opens a board — the KiCad window must appear
*behind* their active window, not yank their keyboard/foreground. This is a product
rule, not a nicety. (Requested by John, 2026-07.)
**How it's enforced (central, in `dispatch_command`):** for **EVERY GUI verb** (everything
except the headless `_NO_GUI` kicad-cli set — opens, clicks, key sends, dialog sweeps, all
of it) the dispatcher:
1. `win_focus.capture_foreground()` — records the USER's active window **before** the handler runs.
2. runs the handler.
3. `win_focus.restore_foreground(user_hwnd)` **after** the handler + dialog sweep — hands
focus back. Uses **AttachThreadInput** so the cross-process SetForegroundWindow actually
sticks (a plain call from a background process is blocked by the foreground lock).
The response carries `_userFocusRestored: true/false`.
4. `win_focus.restore_foreground_persistent(user_hwnd, seconds=…)` — a daemon thread that keeps
re-restoring the user for a few seconds. **This is the part that actually works** — a one-shot
restore (step 3 alone) is NOT enough, because KiCad activates its window *asynchronously, a beat
after the handler returns.* Two distinct cases, two durations (see `_SPAWNS_WINDOW` block):
- **Launched editors** (`open_board`/`open_schematic`/`open_symbol_editor` → a fresh
pcbnew/eeschema via `subprocess.Popen`): pass `win_focus.background_startupinfo()` as
`startupinfo=` so the process shows its window with `SW_SHOWNOACTIVATE` (opens behind), **and**
a **6s** persistent restore for the one late self-activation. Verified: window lands at z-index 1,
right behind the user's z-0 window.
- **In-process child windows** (the **3D viewer** — pcbnew *creates and re-raises* it repeatedly
while it renders the board, over several seconds): `STARTUPINFO` can't touch it (no new process)
and a 6s restore expires mid-render, so it needs a longer **16s** restore
(`open_3d_viewer`/`place_footprint`). Don't assume one restore duration fits all — match it to how
long the window keeps re-raising.
**Verifying background behavior (do NOT trust `GetForegroundWindow` via `run_script`):** measuring
foreground *from* an AD `run_script` is unreliable — the measuring PowerShell/conhost briefly *becomes*
the foreground (you read `''` or itself), and on some machines `run_script` just times out at 75s. Use
**`desktop_list_windows`** (AD-core, no bridge, returns top-level windows in **EnumWindows / top-of-Z-order
first**) and compare the z-index of the KiCad window vs the user's window. If the user's window has a
lower index than every KiCad window, it opened in the background. This is the reliable, non-perturbing test.
**Mid-operation matters too, not just the end state.** Restoring focus *after* still lets a
window FLASH forward mid-verb, which the user sees and hates. So the handlers themselves must
not foreground: `_force_foreground` in open_symbol/footprint_editor was gutted to
`ShowWindow(SW_SHOWNOACTIVATE)` (visible, not activated). The two fallbacks that genuinely
can't avoid it are **OPT-IN, off by default**:
- the launcher-icon **click** (moves the user's mouse via `SetCursorPos`) → needs `{"allowCursorClick": true}`.
- the **Alt+3** keystroke for the 3D viewer (needs pcbnew foregrounded) → needs `{"allowFocusSteal": true}`.
Default behavior is the **background plugin path** (WM_COMMAND via the embedded reverse bridge —
no focus, no cursor). If it can't (plugin not loaded), the verb fails gracefully pointing at
"open a board first so the plugin loads," rather than hijacking the desktop.
**Background fallback = UIA, not the cursor.** Between the plugin path and the opt-in disruptive
fallbacks sits **Tier 1.5: `handlers/uia.py`** — it shells to PowerShell's `System.Windows.Automation`
and **Invokes** the "Footprint Editor" / "3D Viewer" control by accessible name. UIA Invoke is
programmatic: no foreground, no focus steal, no cursor move. KiCad (wxWidgets) exposes an invokable
UIA tree, so this is the reliable background way to open editors when the WM_COMMAND plugin path
isn't loaded. `uia.uia_invoke(hwnd, contains=...)` / `uia.uia_set_value(...)`. This is the bridge's OWN
subprocess (like kicad-cli) — NOT AD's gated `run_script`.
**Rules when you touch UI code:**
- A new window-raising verb → add it to **`_SPAWNS_WINDOW_FOCUS`** in `server.py`. That's the
whole opt-in; the dispatcher does the rest.
- Prefer **`win_focus.show_without_activating(hwnd)`** (SW_SHOWNOACTIVATE) over
`ShowWindow(SW_RESTORE)+SetForegroundWindow+BringWindowToTop` when a window just needs to be
visible. The legacy `_force_foreground` in open_symbol/footprint_editor is used for
SendInput-nav that genuinely needs transient focus — the dispatcher restore covers its cost,
but don't add NEW gratuitous foregrounding.
- Deliver input by **`PostMessage`/`SendMessage` to an exact hwnd** where you can (no focus
needed) instead of `SendInput` (global, needs foreground). `kicad_click`/`kicad_send_key`
still foreground+SendInput for the hard cases; that's fine as long as the dispatcher restore
runs after.
- Recording/demo doctrine agrees: **NEVER foreground a window to fix a blank frame.** Background
capture (WGC) + background windows is the standard.
## Adding a verb (3 places, keep in sync)
1. Write `handle_<verb>` in a handler; return a dict with `success` + a rich **`_hint`** (mandatory — the AI reads verb OUTPUT, not this skill).
2. Register in `server.py` `COMMAND_HANDLERS`.
3. Add `kicad_<verb>` to `bridge.json` `verbs[]` AND an entry (summary/hint/related/pitfalls) to `_VERB_CATALOG` in `server.py` (the `kicad_describe` catalog). A test checks these three stay aligned.
**Dispatcher special cases** (`dispatch_command`): `readiness`, `describe`, `check_for_updates` return BEFORE the plugin auto-install + the KiCad-not-installed pre-check (they must work with KiCad absent). `upgrade` also bypasses that pre-check (it INSTALLS KiCad — do not gate the installer behind "KiCad not installed") and refreshes the detection cache on success.
### ⚖️ Every file you add to this repo may ship to every AD user
The release zip is **auto-bundled into Adom Desktop**, so repo weight is user-facing weight. Before
adding a file, know which artifact it belongs to:
| Adding… | Goes in | Ships in the zip? |
|---|---|---|
| handler / runtime `.py` | repo root / `handlers/` | **yes** |
| a data file the code READS (e.g. `templates/blank-board.kicad_pcb`) | `templates/` | **yes** — and it must be in the zip or the verb breaks at runtime |
| a `.ps1` a handler shells out to | repo root | **yes** |
| docs, tutorials, demo scripts, screenshots, hero art, fonts, sample projects, videos | `docs/`, `demo/`, `tour-pack*/` | **NO** |
| a skill (user/dev/publish) | `skills/<name>/SKILL.md` | **NO** — skills ride the container **pkg**; duplicating them in the zip is the #1 source of bloat |
If a new verb needs a data file, put it in `templates/` and confirm it survives the zip's exclusion
filter — the publish skill's §2 KEEP list must name it. (`kicad_export_molecule` is the worked
example: pure code + the existing `kicad-cli`, no new assets.)
Audit 2026-07-19 found **649 KB** of never-referenced weight (219 KB of hero *fonts*, five
duplicated `skills/` trees, demo docs in the zip; 496 KB of PNGs in the pkg). Don't re-grow it —
full KEEP/EXCLUDE policy lives in `kicad-bridge-publish` §2/§4.
## 🎬 The demo verb (`kicad_demo`) — match the ecosystem contract
HD's installer demos BOTH bridges, so `kicad_demo` deliberately returns the SAME
shape as `fusion_demo`. Do not invent fields: an AI driving both should not have to
special-case them.
| Field | Meaning |
|---|---|
| `stage` | which beat you're on |
| `done` | keep calling until `true` |
| `narrate` | speak this — written for a NEWCOMER, not an engineer |
| `screenshots` | `[{label,path}]`, **captured by the verb**; labels `schematic`/`board_2d`/`board_3d` match Fusion's |
| `steps` | what it did (logging) |
| `_hint` | the exact next action |
Rules borrowed from `fusion-demo` (read that skill — it's the canonical one):
- **A demo is not "launch the app."** It finishes the blockers and shows real work.
No KiCad? OFFER to install it and then do it, in the same call.
- **Never end a demo on an error screen.** A beat that won't open narrates the skip
and moves on; `success` stays true and `data.failedSteps` carries the detail.
- **Fixed tour order** — symbol → footprint → 3D part → schematic → 2D board → 3D board.
It tells the story of how a board is actually made.
- **One beat per call** is the default: the AI narrates, shows the screenshot, then
calls `data.nextCall`. A bulk run also blows AD's per-request budget.
- **Land it in the user's words** ("export the Gerbers", "run DRC"), never verb names.
- KiCad's honest pitch vs Fusion's: free, open source, **no trial clock and no
feature tier** — say it, it's a real differentiator.
Cold-start gotcha: Symbol Editor is hosted in **eeschema** and Footprint Editor in
**pcbnew**, so the demo warms BOTH documents before beat 1 — otherwise the editor
beats fail with "could not open via the background (plugin) path".
## Fresh-machine gotchas (all hard-won, all fixed — don't regress them)
- **Install scope is mandatory.** KiCad 10's NsisMultiUser installer: bare `/S` = `rc=666660` (invalid params). Pass `/allusers /S` (elevated) or `/currentuser /S` (per-user, `%LOCALAPPDATA%\Programs\KiCad`, **no UAC**). See `handlers/upgrade.py`.
- **TLS on the portable Python.** AD's provisioned Python has no CA bundle → `CERTIFICATE_VERIFY_FAILED`. We bundle `certs/cacert.pem` (certifi) and load it in `_ssl_context()`. Any new https fetch must use that context.
- **Truncated downloads** read as EOF and pass MZ/size checks, then NSIS fails at install. `upgrade.py` HEADs `Content-Length` and refuses/deletes a size-mismatched file.
- **Config doesn't exist pre-first-launch.** `%APPDATA%/kicad/<ver>/` (lib tables) is created on KiCad's first GUI run. `kicad_detect.ensure_win_user_config()` bootstraps it (seeds tables from the install template) so `install_library`/`_footprint` work on a never-launched KiCad.
- **Detection cache is built once at startup.** After `kicad_upgrade` installs KiCad, `dispatch_command` re-runs `detect_all_kicad_versions()` so subsequent verbs see it without a bridge restart. Detect paths include `%LocalAppData%\Programs\KiCad` (per-user installs).
- **The bridge's env is STRIPPED — don't trust `%LOCALAPPDATA%`.** AD provisions the bridge's Python with a minimal environment where `LOCALAPPDATA` can be UNSET. A `/currentuser` (no-UAC) `kicad_upgrade` installs to `%LOCALAPPDATA%\Programs\KiCad`, but if detection keys off `os.environ["LOCALAPPDATA"]` it comes back EMPTY and the just-installed KiCad is invisible (every verb → "not installed") even though AD's own glob-based `bridge_readiness` finds it. `kicad_detect.WIN_KICAD_BASES` therefore derives the LocalAppData root from `LOCALAPPDATA` **and** `USERPROFILE\AppData\Local` **and** `Path.home()`. Root-caused on ADOMBASELINE 2026-07 (v0.9.41). Rule: never read a user-profile path from a single env var in bridge code.
- **Blocking dialogs stall everything.** `handlers/close_windows.py` scans EVERY window owned by a running KiCad process (by PID — `_win_scan_kicad_dialogs`, reliable; title/owner matching is NOT), auto-expires benign ones (OpenGL notice), screenshots them, and returns hints. `window_info` self-heals by default; `kicad_dismiss_dialogs {all|forceSoftwareCanvas}`.
- **GPU-less hosts.** No OpenGL → the notice pops and pcbnew/3D may not render. On expiring that notice we persist `graphics.canvas_type=2` (Cairo) to `kicad_common.json`. NOTE: a truly minimal VM (Hyper-V, no GPU) may still not render the heavy editors even in Cairo — that's the hardware. Validate GUI/screenshot work on a real-GPU machine.
## Diagnostics you can call
- **`kicad_diagnostics {}` — reach for this FIRST, and never AD's approval-gated `run_script`/shell.** The bridge runs Python on the desktop with full FS/subprocess access, so it can answer every "where did KiCad install / what env do I see / what's running" question itself. Returns: `environment` (the env the BRIDGE sees — a null `LOCALAPPDATA` here is the tell for a per-user install going undetected), `kicadSearch.bases` (every path the detector scans + the version dirs / `kicad.exe` it finds), `processes` (running KiCad exes), `kicadInfo` (+ config/user dir existence). Pass `probePaths:[...]` to stat/list any path and `readFile:"..."` to peek a small config/output file. All read-only, zero shell approval. (Added v0.9.44 precisely so debugging never needs `run_script` — which pops a 1-hour shell-approval gate.)
- `kicad_upgrade {"diagnoseOnly":true}` — token elevation type + UAC `EnableLUA` + cached-installer/truncation facts (no install).
- `kicad_dismiss_dialogs {"debug":true}` — raw process-based dialog scan (PIDs + every KiCad window + class + isDialog).
## Testing on a fresh AD / VM
`refresh_bridges {name:kicad}` pulls the new cache; the RUNNING instance keeps its old code until it dies — `bridge_kill {name:kicad}` forces a fresh spawn that loads the new version and re-detects. The first verb after a kill returns a `bridge_starting` envelope — retry. Confirm the running version via `bridge_log_read` ("starting v0.9.xx").
**Smoke test that a repackaged zip is COMPLETE** (run after any change to the zip's file set — a
missing data file only shows up at runtime, not at build):
1. Static: every local import in the zip resolves inside the zip, and each data file the code reads
(`certs/cacert.pem`, `templates/blank-board.kicad_pcb`, `*.ps1`) is present.
2. Live, on a real target: `kicad_describe` → expected **verb count** (44 @ v0.9.62 — a drop means a
handler failed to import), then call one verb that touches a data file.
3. "KiCad is not installed" from a verb is the correct **guard**, not a packaging failure — don't
chase it as a regression. Check `kicad_readiness.detected` to tell the two apart.
## Hard-won field notes (winvm full-battery, 2026-07)
**Headless / GPU-less VM 3D rendering — deploy Mesa llvmpipe.** KiCad's 3D viewer needs
OpenGL ≥ 1.5; a bare Azure/cloud Windows VM only has the GDI generic GL 1.1, so the 3D
viewer shows a black canvas + *"Your OpenGL version is not supported."* Fix: drop the Mesa
software-GL DLLs next to `pcbnew.exe` (its dir wins the DLL search order). Get
`mesa3d-<ver>-release-msvc.7z` from `pal1000/mesa-dist-win`, extract the **x64** DLLs and copy
`opengl32.dll` + `libgallium_wgl.dll` (the ~60 MB llvmpipe renderer) + `dxil.dll` +
`libGLESv2.dll` into KiCad's `bin`. KiCad 10 via winget installs **per-user**
(`%LOCALAPPDATA%/Programs/KiCad/10.0/bin`, writable without admin), not Program Files. After
that, full board 3D renders live and stably (software-rendered).
**Menu-command ids DRIFT across KiCad builds — resolve them live.** The PCB "3D Viewer" id was
20563 on one build, **20568** on KiCad 10 as shipped to winvm. Never hardcode: use
`bridge_client.resolve_menu_id_via_bridge(exe, contains="3D Viewer", frame_index=…)`
(get_menu_ids + text match) with the constant only as a last-ditch fallback.
**The Footprint Editor is a FRAME inside the pcbnew process** (KiCad 10), so the reverse-bridge
plugin can drive its 3D viewer with NO focus steal: `resolve_frame_index_via_bridge("pcbnew",
"Footprint Editor")` → `wm_command{frame_index, menu_id}`. `wm_command` honors frame_index even
though `get_menu_ids` currently only reports frame 0. This replaced the old Alt+3 keystroke
path (needed foreground + raised `UnboundLocalError` on a shadowed `VK_MENU`).
**Legacy `(module …)` footprints** (KiCad ≤5 / EAGLE-imported / most chip-fetcher parts) are
rejected by a v9 board parser. `place_footprint` now normalizes `(module`→`(footprint` + quotes
the top-level `(layer X)`; that splices into a preview board and KiCad loads it. (pcbnew's GUI is
stricter than `kicad-cli lint` — a footprint can lint clean yet the GUI aborts with "Open
canceled by user"; when that happens, screenshot the `#32770` error dialog to see why.)
**`kicad_click` is RELATIVE by default** — `x`/`y` are fractions 0.0–1.0 of the window, NOT
pixels. Passing pixels with the default clicks far off-screen (can hit another window's close
box and crash KiCad). v0.9.59 guards this, but always pass fractions or `{"relative": false}`.
**Capture without a round-trip:** AD-core `desktop_screenshot_window {hwnd}` returns a
container-local path in `localFullPath` (already pulled) — Read it directly, no `pull_file`
needed. It's also background-safe (PrintWindow), unlike `kicad_screenshot_all` which can time
out capturing every window.