← All Pull Requests

Bind 3D evidence to its source editor and expose native pan #5

Closed opened by John Lauer 2026-09-18

Bind 3D viewer discovery to the requested source editor, refuse stale explicit HWND/document fallback, and expose native pan actions through dynamically discovered Move Board menus. This addresses issues #99/#102 and the pan portion of #105. The canvas probe now scans the full sample instead of accepting its first thirteen colors and rejects near-black noise (#100); it remains a heuristic, not geometry qualification.

Validation: cargo test --workspace --lib --bins passes, including new source/owner/ambiguity and black-noise regressions. Full integration suite reaches schematic_edit but eight tests fail because the exported repository fixture lookup expects tour-pack under rust/ rather than the repository root. No shared bridge runtime was replaced. Windows multi-editor live acceptance is requested before release. Reference/region framing and model/zone/silkscreen primitives remain separate work.

Diff Skip to comments (1)

--- a/rust/crates/kicad-bridge/src/verbs_windows.rs+++ b/rust/crates/kicad-bridge/src/verbs_windows.rs@@ -1,3133 +1,3125 @@⋯ 113 unchanged lines ⋯         mechanism: Mechanism::Window, risk: "input", timeout_sec: 120,         input: "{\"action\": \"zoom_in|zoom_out|fit|rotate_x_cw|rotate_x_ccw|rotate_y_cw|rotate_y_ccw|rotate_z_cw|rotate_z_ccw|view_top|view_bottom|view_front|view_back|view_left|view_right|flip|tour|actions\", \"count\"?: 3, \"pauseMs\"?: 700, \"hwnd\"?: 123, \"steps\"?: [[\"rotate_z_cw\", 8], [\"zoom_in\", 3]]}",         example: "kicad_3d_view {\"action\":\"rotate_z_cw\",\"count\":8,\"pauseMs\":700}",-        hint: "Every action is a View-menu command fired on the 3D Viewer window by WM_COMMAND: no focus, no cursor, works with the viewer in the background (a window recording of it still sees every frame). count repeats the action with pauseMs between, so a slow orbit is one call. action:\"tour\" runs the default walkthrough (fit, top, tilt, orbit, zoom in, swing, zoom out, front, bottom, flip, top, fit) or your own steps list. Open the viewer first with kicad_open_3d_viewer. The viewer has no menu command for panning; zoom about the centre and rotate instead.",-        related: &["kicad_open_3d_viewer", "kicad_send_key", "kicad_screenshot_all", "kicad_model_check"],-        pitfalls: &["the viewer must be open (kicad_open_3d_viewer)", "a huge board takes seconds per redraw; keep pauseMs at 500 or more"],-    },-    Verb {-        name: "kicad_open_3d_viewer",-        summary: "Open the 3D board viewer for a .kicad_pcb.",-        mechanism: Mechanism::Window, risk: "process", timeout_sec: 300,-        input: "{\"editor\"?: \"auto\"|\"fp\"|\"pcb\", \"pcbHwnd\"?: 123, \"fpHwnd\"?: 123, \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",-        example: "kicad_open_3d_viewer {\"editor\":\"pcb\"}",-        hint: "Opens the 3D viewer from an open PCB Editor (or Footprint Editor) through its View menu, in the background. Follow with screenshot_all to capture it.",-        related: &["kicad_open_board", "kicad_screenshot_all", "kicad_close_3d_viewer"],-        pitfalls: &["3D models only render if the footprints reference installed 3D model files", "the viewer takes a few seconds to render; wait before screenshotting"],-    },-    Verb {-        name: "kicad_open_editors",-        summary: "List currently open KiCad editor windows.",-        mechanism: Mechanism::Window, risk: "read", timeout_sec: 30,-        input: "{}",-        example: "kicad_open_editors {}",-        hint: "Read-only INVENTORY (despite the name): lists editors inside a running KiCad. It never launches anything; to cold-start KiCad use kicad_launch.",-        related: &["kicad_launch", "kicad_window_info", "kicad_screenshot_all", "kicad_close"],-        pitfalls: &["with KiCad closed it succeeds with 0 editors; that is not an error, kicad_launch is the verb that starts KiCad"],-    },-    Verb {-        name: "kicad_close",-        summary: "Close KiCad (editors first, then the project manager).",-        mechanism: Mechanism::Window, risk: "process", timeout_sec: 130,-        input: "{\"force\"?: bool, \"discardChanges\"?: bool}",-        example: "kicad_close {}",-        hint: "Closes KiCad gracefully (WM_CLOSE). A 'Save changes?' prompt is read and reported as errorCode unsaved_changes; discardChanges:true presses Discard for the caller. force:true is the ONLY path that kills a process. Read the kicad-interaction skill before blind-closing.",-        related: &["kicad_open_editors", "kicad_window_info"],-        pitfalls: &["closing with unsaved work triggers a save dialog; handle it, do not blind-close", "force:true discards silently"],-    },-    Verb {-        name: "kicad_close_symbol_editor",-        summary: "Close the Symbol Editor window.",-        mechanism: Mechanism::Window, risk: "process", timeout_sec: 60,-        input: "{}", example: "kicad_close_symbol_editor {}",-        hint: "Closes the Symbol Editor if open; no-op otherwise.",-        related: &["kicad_open_symbol_editor", "kicad_close"],-        pitfalls: &["unsaved edits may prompt; save first if you made changes"],-    },-    Verb {-        name: "kicad_close_footprint_editor",-        summary: "Close the Footprint Editor window.",-        mechanism: Mechanism::Window, risk: "process", timeout_sec: 60,-        input: "{}", example: "kicad_close_footprint_editor {}",-        hint: "Closes the Footprint Editor if open; no-op otherwise.",-        related: &["kicad_open_footprint_editor", "kicad_close"],-        pitfalls: &["unsaved edits may prompt; save first"],-    },-    Verb {-        name: "kicad_close_3d_viewer",-        summary: "Close the 3D viewer window.",-        mechanism: Mechanism::Window, risk: "process", timeout_sec: 60,-        input: "{}", example: "kicad_close_3d_viewer {}",-        hint: "Closes the 3D viewer if open; no-op otherwise.",-        related: &["kicad_open_3d_viewer", "kicad_close"],-        pitfalls: &[],-    },-    Verb {-        name: "kicad_dismiss_dialogs",-        summary: "Programmatically expire KiCad dialogs (auto-clears the benign OpenGL/software-render notice; {all:true} clears every dialog).",-        mechanism: Mechanism::Window, risk: "input", timeout_sec: 60,-        input: "{\"all\"?: bool, \"screenshot\"?: true, \"forceSoftwareCanvas\"?: bool, \"debug\"?: bool}",-        example: "kicad_dismiss_dialogs {\"all\":true}",-        hint: "Read-only-ish self-heal: clicks each dialog's OK/default button. window_info also auto-expires benign dialogs; use this to force-clear a specific/all dialogs before retrying an open/screenshot.",-        related: &["kicad_window_info", "kicad_screenshot_all", "kicad_open_board"],-        pitfalls: &["benign-only by default; pass {\"all\":true} to also clear real dialogs you have already read", "a NON-benign dialog left up may indicate a real error; read its body first", "progress dialogs (Load PCB, Loading Symbol Libraries) are never dismissed: their one button is Cancel"],-    },-    Verb {-        name: "kicad_window_info",-        summary: "Report geometry/state for open KiCad windows (auto-expires benign OpenGL dialogs).",-        mechanism: Mechanism::Window, risk: "read", timeout_sec: 60,-        input: "{\"autoDismiss\"?: true}",-        example: "kicad_window_info {}",-        hint: "Read-only; returns each KiCad window's position/size/state. Use before click/send_key to target the right window.",-        related: &["kicad_open_editors", "kicad_click", "kicad_send_key"],-        pitfalls: &["coordinates are screen-absolute; re-read after a window moves/resizes"],-    },-    Verb {-        name: "kicad_state",-        summary: "KiCad's full state of the union: every owned window as inline base64 PNG + logical state.",-        mechanism: Mechanism::Window, risk: "read", timeout_sec: 130,-        input: "{\"includeImages\"?: true, \"maxWidth\"?: 1100}",-        example: "kicad_state {\"includeImages\":false}",-        hint: "Read-only, non-gated. shots[] = base64 screenshot of EVERY KiCad window (render all of them; one window is not the app); plus editors inventory, modal dialogs, running flag. Poll before/after an action to confirm it landed. includeImages:false for a cheap logical poll; maxWidth caps image size.",-        related: &["kicad_screenshot_all", "kicad_open_editors", "kicad_window_info"],-        pitfalls: &["shots are inline base64; the response is large, use includeImages:false when you only need the logical state"],-    },-    Verb {-        name: "kicad_screenshot_all",-        summary: "Screenshot every open KiCad window.",-        mechanism: Mechanism::Window, risk: "read", timeout_sec: 130,-        input: "{}",-        example: "kicad_screenshot_all {}",-        hint: "Captures all KiCad windows through ab's desktop_screenshot_window (background, owned popups included, coordMap per image); the go-to way to VERIFY what actually happened after an open/edit. canvas.rendered says whether the GL canvas actually painted.",-        related: &["kicad_open_editors", "kicad_window_info", "kicad_open_board"],-        pitfalls: &["a window still rendering (e.g. 3D viewer) shows blank; wait a beat first", "with ab unreachable no image is written (abUnavailable:true); only the canvas probe answers"],-    },-    Verb {-        name: "kicad_send_key",-        summary: "Send a key to KiCad, or fire a menu item by label; chords and menu labels resolve to menu commands (background)",-        mechanism: Mechanism::Window, risk: "input", timeout_sec: 30,-        input: "{\"key\"?: \"enter\"|\"escape\"|\"a\"|\"ctrl+s\", \"menu\"?: \"Plugin and Content Manager\", \"hwnd\"?: 123}",-        example: "kicad_send_key {\"hwnd\":123456,\"key\":\"escape\"}",-        hint: "menu: a label substring fired as a menu command on the target window without touching focus (e.g. menu:\"Plugin and Content Manager\" on the project manager opens the PCM dialog; the dialog is a child, kicad_screenshot_all captures it). For modifier CHORDS ('ctrl+s', 'alt+3', 'ctrl+shift+p') use Bridge's desktop_press_key; it owns full chord parsing; do not route chords here. Use this verb only for the case Bridge's focus-first model cannot cover: a single key delivered to an EXACT hwnd (e.g. Enter/Escape to a specific dialog from window_info).",-        related: &["kicad_window_info", "kicad_click", "kicad_fix_keyboard"],-        pitfalls: &["not for chords; use desktop_press_key", "needs a target hwnd (from window_info)", "allowForeground (real keystrokes) is not in the native build"],-    },-    Verb {-        name: "kicad_click",-        summary: "Click at coordinates inside a KiCad window.",-        mechanism: Mechanism::Window, risk: "input", timeout_sec: 30,-        input: "{\"hwnd\": 123, \"x\": 0.5, \"y\": 0.5, \"relative\"?: true}",-        example: "kicad_click {\"hwnd\":123456,\"x\":0.5,\"y\":0.5}",-        hint: "Posts the click to the window's own queue (background: the user's mouse never moves); read window_info + screenshot_all first to compute coordinates. relative:true (default) takes fractions 0.0-1.0 of the window.",-        related: &["kicad_window_info", "kicad_screenshot_all", "kicad_send_key"],-        pitfalls: &["coordinates are window-relative and break if the window resized; re-read window_info", "allowForeground (a real cursor click) is not in the native build"],-    },-    Verb {-        name: "kicad_fix_keyboard",-        summary: "Reset stuck modifier keys on a KiCad window.",-        mechanism: Mechanism::Local, risk: "read", timeout_sec: 10,-        input: "{}", example: "kicad_fix_keyboard {}",-        hint: "Clears stuck Ctrl/Shift/Alt state after send_key sequences misbehave. The native build never sends global keystrokes, so there is never anything to fix.",-        related: &["kicad_send_key"],-        pitfalls: &[],-    },-    Verb {-        name: "kicad_place_footprint",-        summary: "Deterministically place a footprint into a board preview.",-        mechanism: Mechanism::Window, risk: "write", timeout_sec: 130,-        input: "{\"fileName\": \"X.kicad_mod\", \"fileContent\": \"<base64>\", \"footprintName\"?: \"X\", \"x\"?: 148, \"y\"?: 105, \"open\"?: true}",-        example: "kicad_place_footprint {\"fileName\":\"BMI423.kicad_mod\",\"fileContent\":\"KGZvb3RwcmludCAu...\"}",-        hint: "Splices a footprint into a fresh preview board via s-expr (no GUI edit) and opens it in pcbnew in the background; good for previews.",-        related: &["kicad_open_board", "kicad_install_footprint"],-        pitfalls: &["operates on the file; close the board in KiCad first or the edit races the GUI"],-    },-    Verb {-        name: "kicad_enable_software_opengl",-        summary: "LAST-RESORT fallback: deploy Mesa llvmpipe software OpenGL into KiCad's bin so it renders on a GPU-less host (Hyper-V/RDP/headless). SLOW (CPU). Never suggest proactively.",-        mechanism: Mechanism::File, risk: "write", timeout_sec: 240,-        input: "{}", example: "kicad_enable_software_opengl {}",-        hint: "Only when KiCad cannot render at all AND there is no GPU. Drops Mesa opengl32/libgallium DLLs + .local redirection into KiCad bin (needs elevation to write Program Files, or use a per-user install). Close+reopen editors after.",-        related: &["kicad_dismiss_dialogs", "kicad_upgrade"],-        pitfalls: &["a real GPU / Hyper-V GPU-P passthrough is far better; this is CPU rendering, slow", "writing KiCad's bin needs an elevated Bridge"],-    },-    Verb {-        name: "kicad_rescan_libraries",-        summary: "Make newly installed parts visible ONCE: reload every open editor's library tree in place (seconds, nothing closed), then verify each listed footprint by loading it in the Footprint Editor. force:true is the process restart, the last resort.",-        mechanism: Mechanism::Window, risk: "write", timeout_sec: 200,-        input: "{\"footprints\"?: [\"Lib:Name\", ...], \"force\"?: bool, \"confirmRestart\"?: bool}",-        example: "kicad_rescan_libraries {\"footprints\":[\"Adom:LQFP-48_STM32G431\"]}",-        hint: "Wiki #51: install N parts, call this once, then show them all. A restart closes KiCad gracefully first (so it saves its own geometry) and puts every window back on the monitor the user had it on (wiki #48; closeHow and placementRestored report both). Pass footprints so the reply can say what KiCad can load now; without the list nothing is verified. A restart (force) refuses while any KiCad runs unless confirmRestart:true, because the native build cannot yet tell a user-launched KiCad from its own (the ownership ledger arrives with phase 4).",-        related: &["kicad_install_footprint", "kicad_show_footprint", "kicad_list_footprints"],-        pitfalls: &["KiCad 10 caches footprint libraries per process; the tree refresh reveals parts installed after the editor started (measured 2026-09-07), a full restart is only for the cases it does not."],-    },-];--/// Verbs that may spawn or raise a KiCad window: they get the 5-round settle sweep and-/// the foreground etiquette (server.py `_SPAWNS_WINDOW`, this group's members).-const SPAWNS_WINDOW: &[&str] = &["kicad_launch", "kicad_open_board", "kicad_open_schematic", "kicad_open_symbol_editor", "kicad_open_footprint_editor", "kicad_open_3d_viewer", "kicad_place_footprint"];--/// Verbs that touch no window at all (no sweep, no focus check).-const NO_GUI: &[&str] = &["kicad_fix_keyboard", "kicad_enable_software_opengl"];--pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {-    if !VERBS.iter().any(|v| v.name == command) {-        return None;-    }-    // Caller identity, so every ab callback made while serving this verb is attributed-    // "<thread> (via kicad)". ab relays it as args.caller; headers are the fallback.-    ab::set_caller_from_args(args);-    let gui = !NO_GUI.contains(&command);-    let fg_before = if gui { native().foreground().ok().filter(|h| *h != 0) } else { None };-    if gui {-        // Phase 4: arm the guard and snapshot the park baseline for the etiquette loop.-        native().etiquette_begin_verb(command, SPAWNS_WINDOW.contains(&command));-    }-    let mut out = match command {-        "kicad_launch" => launch(state, args),-        "kicad_rescan_libraries" => rescan_libraries(state, args),-        "kicad_open_board" => open_file(state, args, WindowKind::PcbEditor),-        "kicad_open_schematic" => open_file(state, args, WindowKind::SchematicEditor),-        "kicad_open_symbol_editor" => open_symbol_editor(state, args),-        "kicad_open_footprint_editor" => open_footprint_editor(state, args),-        "kicad_open_3d_viewer" => open_3d_viewer(args),-        "kicad_3d_view" => view_3d(args),-        "kicad_menu_items" => menu_items(args),-        "kicad_open_editors" => open_editors(),-        "kicad_close" => close_kicad(state, args),-        "kicad_close_symbol_editor" => close_kind(WindowKind::SymbolEditor, "Symbol Editor"),-        "kicad_close_footprint_editor" => close_kind(WindowKind::FootprintEditor, "Footprint Editor"),-        "kicad_close_3d_viewer" => close_kind(WindowKind::Viewer3d, "3D Viewer"),-        "kicad_dismiss_dialogs" => dismiss_dialogs(state, args),-        "kicad_window_info" => window_info(state, args),-        "kicad_state" => kicad_state(state, args),-        "kicad_screenshot_all" => screenshot_all(state),-        "kicad_send_key" => send_key(args),-        "kicad_click" => click(args),-        "kicad_fix_keyboard" => fix_keyboard(),-        "kicad_place_footprint" => place_footprint(state, args),-        "kicad_enable_software_opengl" => enable_software_opengl(state),-        _ => return None,-    };-    if gui && out.get("errorCode") != Some(&json!("not_supported_on_platform")) {-        post_verb(state, command, args, &mut out, fg_before);-    }-    if gui {-        native().etiquette_end_verb(command);-    }-    ab::clear_caller();-    Some(out)-}--/// The server.py dispatch wrapper for a window verb: dialog sweep, foreground etiquette,-/// the one-shot focus check, window labelling.-pub(crate) fn post_verb(state: &mut State, command: &str, args: &Value, out: &mut Value, fg_before: Option<u64>) {-    let spawns = SPAWNS_WINDOW.contains(&command);-    let info = state.kicad_info();-    // CONSTANT DIALOG COVERAGE: KiCad throws modal error boxes constantly; sweep after-    // every GUI verb so the AI never runs blind past one and the next verb is unblocked.-    let rounds = if spawns { 5 } else { 1 };-    if let Some(swept) = auto_sweep_dialogs(&info, true, rounds, Duration::from_millis(600)) {-        let had_error = swept["hadError"].as_bool().unwrap_or(false);-        if had_error {-            let titles: Vec<String> = swept["dialogs"].as_array().map(|a| a.iter().filter(|d| !d["benign"].as_bool().unwrap_or(false)).map(|d| format!("\"{}\"", d["title"].as_str().unwrap_or(""))).collect()).unwrap_or_default();-            out["_dialogHint"] = json!(format!(-                "KiCad popped {} dialog(s) during this verb (non-benign: {}). Each was screenshotted (see _autoDialogs[].screenshot + .body) and auto-expired so you are unblocked. If your verb 'failed'/'did not open', this dialog is almost certainly why: read the body and act on it.",-                swept["count"], titles.join(", ")));-        }-        out["_autoDialogs"] = swept;-    }-    // Foreground etiquette (John, 2026-08-14 and 2026-08-16): the ONE sanctioned way a-    // KiCad window comes to the front is an explicit foreground flag WITH a reason.-    let wants_fg = user_wants_foreground(args);-    let success = out.get("success").and_then(Value::as_bool).unwrap_or(true);-    if wants_fg && spawns {-        std::thread::sleep(Duration::from_secs(1)); // let the window exist-        let reason = arg_str(args, "foregroundReason").or_else(|| arg_str(args, "reason")).unwrap_or("").trim().to_string();-        let mut shown: Option<u64> = None;-        if !reason.is_empty() {-            if let Some(h) = verb_window(command, out) {-                if bring_to_user(h, &reason) {-                    shown = Some(h);-                }-            }-        }-        out["_broughtToUser"] = json!(shown.is_some());-        out["windowEtiquette"] = json!(if shown.is_some() { "foreground" } else { "background" });-        if shown.is_none() {-            out["errorCode"] = json!("foreground_reason_required");-            out["_foregroundRefused"] = json!("foreground requested without foregroundReason - pass foregroundReason:'<why the user needs to see this>' and the bridge will caption it for 3s, then foreground.");-            out["_hint"] = json!("errorCode foreground_reason_required: re-call with foreground:true AND foregroundReason:'<why the user needs to see this>'.");-        }-    } else if spawns && success {-        let o = out.as_object_mut().unwrap();-        o.entry("windowEtiquette").or_insert(json!("background"));-        o.entry("openedInBackground").or_insert(json!(true));-        o.entry("_foregroundHint").or_insert(json!("Window opened in the BACKGROUND - the user's foreground was not touched. To show it to the user: re-call with foreground:true AND foregroundReason:'<why the user needs to see this>'. The bridge captions the reason via an ab toast for 3 seconds, then brings the window forward. Foreground without a reason is refused."));-    }-    // One-shot focus check (the phase 4 guardian's stand-in): did a KiCad window take the-    // foreground from a user window during this verb? Push it back once, and say so.-    let mut events = Vec::new();-    if !(wants_fg && out["_broughtToUser"] == json!(true)) {-        // With the etiquette loop running (Windows) the one-shot push would be a second action-        // on a window the loop already handled once; keep it only where no loop exists.-        // Exception (gate on 2026-09-12, ConfRoomROG and arav-rog): a freshly spawned editor-        // re-raises itself when its board finishes loading WITHOUT a foreground transition-        // (it already was the foreground while loading), so the loop sees nothing to bounce-        // and the user's window stays covered. For spawn verbs the one-shot check runs-        // regardless, twice: now and after a short settle for the late re-raise.-        let loop_running = native().etiquette_debug() != Value::Null;-        if let Some(ev) = if loop_running && !spawns { None } else { one_shot_focus_check(fg_before) } {-            events.push(ev);-        }-        if spawns {-            // Wait for the editor to finish loading (its "Load PCB" / "Loading" progress-            // dialog gone), because that is the moment it raises itself; then check twice.-            for _ in 0..16 {-                let loading = scan_dialogs().iter().any(|d| wm::is_progress_dialog(&d.title, &d.body));-                if !loading {-                    break;-                }-                sleep_ms(500);-            }-            sleep_ms(700);-            if let Some(ev) = one_shot_focus_check(fg_before) {-                events.push(ev);-            }-            sleep_ms(700);-            if let Some(ev) = one_shot_focus_check(fg_before) {-                events.push(ev);-            }-        }-    }-    // kicad_state already carries the loop's ring buffer; never clobber it with the-    // per-verb list (which is empty while the loop runs).-    if out.get("focusEvents").map(|v| v.as_array().map(|a| a.is_empty()).unwrap_or(true)).unwrap_or(true) {-        out["focusEvents"] = json!(events);-    }-    if spawns {-        out["_userFocusRestored"] = json!(false);-    }-    // #12.3: label the KiCad window this verb raised with the owning AI thread, so the-    // user's taskbar/Alt-Tab shows which of their ~20 tabs owns it. Best-effort.-    if spawns && success {-        let thread = ab::thread_name();-        let label = match command {-            "kicad_open_symbol_editor" => Some("Symbol Editor"),-            "kicad_open_footprint_editor" => Some("Footprint Editor"),-            "kicad_open_board" => Some("PCB Editor"),-            "kicad_open_schematic" => Some("Schematic Editor"),-            "kicad_open_3d_viewer" => Some("3D Viewer"),-            _ => None,-        };-        if let (false, Some(word)) = (thread.is_empty(), label) {-            if let Ok((wins, _)) = find_windows(false, true) {-                if let Some(w) = wins.iter().find(|w| w.title.to_lowercase().contains(&word.to_lowercase())) {-                    if ab::desktop_set_window_identity(w.hwnd, &format!("KiCad ({thread})"), &format!("KiCad {word} - driven by {thread}")).is_ok() {-                        out["_windowLabeledFor"] = json!(thread);-                    }-                }-            }-        }-    }-    invalidate();-}--// ── Platform plumbing ─────────────────────────────────────────────────────────--fn is_not_impl(e: &str) -> bool {-    e.contains(": not implemented on ")-}--/// Map a platform Result: "not implemented on <os>" becomes the honest-/// not_supported_on_platform reply; anything else is a platform_error.-fn plat<T>(feature: &str, r: Result<T, String>) -> Result<T, Value> {-    r.map_err(|e| if is_not_impl(&e) { unsupported(feature) } else { fail("platform_error", format!("{feature}: {e}"), "The OS layer refused. kicad_diagnostics shows the platform capabilities; kicad_screenshot_all shows what is on screen.") })-}--fn exe_suffix() -> &'static str {-    if native().cli_file_name().ends_with(".exe") { ".exe" } else { "" }-}--/// `<bin_dir>/<name>[.exe]` for the primary install.-fn kicad_exe(info: &KicadInfo, name: &str) -> Option<PathBuf> {-    let inst = info.primary()?;-    let p = PathBuf::from(&inst.bin_dir).join(format!("{name}{}", exe_suffix()));-    if p.is_file() { Some(p) } else { None }-}--fn utc_stamp() -> String {-    // 2026-09-11T12:34:56Z -> 20260911-123456-    let s = kicad_common::now_utc_iso();-    let date: String = s.chars().take(10).filter(|c| c.is_ascii_digit()).collect();-    let time: String = s.chars().skip(11).take(8).filter(|c| c.is_ascii_digit()).collect();-    format!("{date}-{time}")-}--/// Spawn a KiCad GUI without activating its window, stderr captured to a launch log.-fn spawn_background(exe: &Path, args: &[String], tag: &str, trace_masks: Option<&str>) -> Result<(u32, Option<PathBuf>), Value> {-    let log = bridge_log::log_dir().and_then(|d| bridge_log::new_log_path(&d, &utc_stamp(), tag).ok());-    let env = bridge_log::trace_env(trace_masks);-    let pid = plat("spawn_background", native().spawn_background(exe, args, &env, log.as_deref()))?;-    native().etiquette_register_pid(pid, None);-    invalidate();-    Ok((pid, log))-}--fn sleep_ms(ms: u64) {-    std::thread::sleep(Duration::from_millis(ms));-}--fn user_wants_foreground(args: &Value) -> bool {-    ["foreground", "showToUser", "bringToFront"].iter().any(|k| args.get(*k).and_then(Value::as_bool).unwrap_or(false))-}--fn user_idle_seconds() -> Option<f64> {-    native().seconds_since_input().ok().filter(|s| *s < 1e8).map(|s| (s * 10.0).round() / 10.0)-}--// ── Window finder (kicad_windows.py): ab first for z-order, local fallback ────--#[derive(Clone, Debug, Default)]-pub(crate) struct Win {-    pub(crate) hwnd: u64,-    pub(crate) title: String,-    class_name: String,-    /// x, y, width, height.-    rect: (i32, i32, i32, i32),-    z: Option<i64>,-    owner: u64,-    pid: u32,-    exe: String,-    minimized: bool,-    maximized: bool,-    hung: bool,-}--impl Win {-    fn kind(&self) -> WindowKind {-        wm::classify(&self.title, &self.class_name, self.owner)-    }-    fn is_kicad(&self) -> bool {-        if !self.exe.is_empty() {-            return wm::is_kicad_exe(&self.exe);-        }-        // No exe known (an ab older than 2.1.12 on a platform with no window_info): fail-        // open on the title, like the Python fallback.-        wm::title_looks_kicad(&self.title)-    }-    fn rect_json(&self) -> Value {-        json!({"x": self.rect.0, "y": self.rect.1, "width": self.rect.2, "height": self.rect.3})-    }-    fn from_info(w: &WindowInfo) -> Win {-        Win { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), rect: w.rect, z: None, owner: w.owner, pid: w.pid, exe: w.exe.clone(), minimized: w.minimized, maximized: w.maximized, hung: w.hung }-    }-}--struct Cache {-    at: Option<Instant>,-    rows: Vec<Win>,-    source: &'static str,-}--static CACHE: Mutex<Cache> = Mutex::new(Cache { at: None, rows: Vec::new(), source: "(not yet queried)" });-const CACHE_TTL: Duration = Duration::from_millis(250);--/// Call after anything that opens, closes, or retitles a KiCad window.-pub(crate) fn invalidate() {-    if let Ok(mut c) = CACHE.lock() {-        c.at = None;-        c.rows.clear();-    }-}--fn list_all_windows() -> Result<(Vec<Win>, &'static str), Value> {-    match ab::desktop_list_windows() {-        Ok(l) if !(l.windows.is_empty() && l.degraded) => {-            let mut rows: Vec<Win> = l.windows.iter().map(|w| Win {-                hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), rect: w.rect, z: w.z, owner: w.owner, pid: w.pid,-                exe: w.image.clone(), minimized: w.minimized, maximized: w.maximized, hung: w.hung,-            }).collect();-            // ab before 2.1.12 carries no image/owner (they arrived together): fill both-            // from the OS layer when it can, only for rows that lack the exe.-            for r in rows.iter_mut() {-                if r.exe.is_empty() {-                    if let Ok(Some(i)) = native().window_info(r.hwnd) {-                        r.exe = i.exe;-                        if r.owner == 0 { r.owner = i.owner; }-                        if r.class_name.is_empty() { r.class_name = i.class_name; }-                    }-                }-            }-            Ok((rows, "ad:desktop_list_windows"))-        }-        _ => {-            let rows = plat("window enumeration (list_windows)", native().list_windows())?;-            Ok((rows.iter().filter(|w| w.visible).map(Win::from_info).collect(), "local:EnumWindows"))-        }-    }-}--/// KiCad windows now (all_windows: every top-level window). Cached 250 ms.-fn find_windows(all_windows: bool, fresh: bool) -> Result<(Vec<Win>, &'static str), Value> {-    let mut c = CACHE.lock().map_err(|_| fail("internal", "window cache poisoned", "Retry."))?;-    let stale = fresh || c.at.map(|t| t.elapsed() > CACHE_TTL).unwrap_or(true);-    if stale {-        let (rows, src) = list_all_windows()?;-        c.rows = rows;-        c.source = src;-        c.at = Some(Instant::now());-    }-    let src = c.source;-    if all_windows {-        return Ok((c.rows.clone(), src));-    }-    // A #32770 owned by a KiCad window is KiCad's too (kicad_ui._find_all_kicad_windows).-    let kicad_hwnds: Vec<u64> = c.rows.iter().filter(|r| r.is_kicad()).map(|r| r.hwnd).collect();-    let rows = c.rows.iter().filter(|r| r.is_kicad() || (r.class_name == wm::DIALOG_CLASS && r.owner != 0 && kicad_hwnds.contains(&r.owner))).cloned().collect();-    Ok((rows, src))-}--pub(crate) fn kicad_windows() -> Vec<Win> {-    find_windows(false, false).map(|(w, _)| w).unwrap_or_default()-}--pub(crate) fn windows_of_kind(kind: WindowKind) -> Vec<Win> {-    kicad_windows().into_iter().filter(|w| w.kind() == kind).collect()-}--fn first_of_kind(kind: WindowKind) -> Option<u64> {-    windows_of_kind(kind).first().map(|w| w.hwnd)-}--/// Wiki #48: learn where the user keeps each KiCad window, per window KIND, so a restart can-/// put it back. Called before anything the bridge does that destroys geometry (a restart, a-/// close) and whenever the inventory is in hand anyway. Minimized and parked windows teach-/// nothing, so they are skipped.-pub(crate) fn remember_placement() -> usize {-    let now = utc_stamp();-    let mut mem = window_memory::load();-    let mut learned = 0;-    // Front to back, so that when two windows share a kind (two PCB Editors, say) the one the-    // user was actually looking at is the one whose place is remembered for that kind.-    let mut windows = kicad_windows();-    windows.sort_by_key(|w| w.z.unwrap_or(i64::MAX));-    let mut seen: Vec<WindowKind> = Vec::new();-    for w in windows {-        let kind = w.kind();-        if matches!(kind, WindowKind::Dialog | WindowKind::Other) || seen.contains(&kind) {-            continue;-        }-        seen.push(kind);-        // placement() reports the NORMAL rect plus the maximized flag, which is what has to-        // come back; the enumerated rect is the fallback where that call is unavailable.-        let place = native().placement(w.hwnd).unwrap_or(Placement {-            x: w.rect.0, y: w.rect.1, width: w.rect.2, height: w.rect.3, maximized: w.maximized,-        });-        if window_memory::observe(&mut mem, kind.as_str(), &place, w.minimized, &now) {-            learned += 1;-        }-    }-    if learned > 0 {-        if let Err(e) = window_memory::save(&mem) {-            eprintln!("[placement] could not save the placement memory: {e}");-        }-    }-    learned-}--/// Wiki #48: put each window back where the user had it, without activating it. A fresh-/// KiCad process places every window on the PRIMARY display, which is how a multi-monitor-/// user's PCB Editor kept coming back to the laptop panel. Returns one row per window so the-/// caller's reply can say what moved and what was left alone.-pub(crate) fn restore_placement() -> Vec<Value> {-    let mem = window_memory::load();-    if mem.is_empty() {-        return Vec::new();-    }-    kicad_windows().iter().filter_map(|w| restore_one(&mem, w.kind(), w.hwnd)).collect()-}--/// Wiki #48: one freshly opened window put back on the monitor the user keeps that kind of-/// window on. Called when a verb OPENS an editor, not only when it restarts KiCad, because-/// "the bridge opens or restarts KiCad and it comes back on the laptop screen" is one-/// complaint with two halves. Returns None when nothing is remembered for that kind.-pub(crate) fn restore_placement_one(kind: WindowKind, hwnd: u64) -> Option<Value> {-    restore_one(&window_memory::load(), kind, hwnd)-}--fn restore_one(mem: &window_memory::Memory, kind: WindowKind, hwnd: u64) -> Option<Value> {-    let remembered = mem.get(kind.as_str())?;-    let r = &remembered.place;-    let current = match native().placement(hwnd) {-        Ok(p) => p,-        Err(e) => return Some(json!({"window": kind.as_str(), "hwnd": hwnd, "moved": false, "error": e})),-    };-    let on_monitor = native().on_a_monitor((r.x, r.y, r.width, r.height)).unwrap_or(false);-    Some(match window_memory::decide(r, &current, on_monitor) {-        Restore::Skip(why) => json!({"window": kind.as_str(), "hwnd": hwnd, "moved": false, "why": why}),-        Restore::Move(p) => match native().set_placement(hwnd, &p) {-            Ok(()) => json!({"window": kind.as_str(), "hwnd": hwnd, "moved": true,-                "to": {"x": p.x, "y": p.y, "width": p.width, "height": p.height, "maximized": p.maximized},-                "rememberedAt": remembered.at}),-            Err(e) => json!({"window": kind.as_str(), "hwnd": hwnd, "moved": false, "error": e}),-        },-    })-}--fn find_project_manager() -> Option<u64> {-    kicad_windows().iter().find(|w| wm::is_project_manager(&w.title, w.owner)).map(|w| w.hwnd)-}--/// A window's current title without hanging on a modal-blocked thread.-pub(crate) fn title_of(hwnd: u64) -> String {-    if let Ok(Some(i)) = native().window_info(hwnd) {-        return i.title;-    }-    find_windows(true, true).ok().and_then(|(w, _)| w.into_iter().find(|w| w.hwnd == hwnd)).map(|w| w.title).unwrap_or_default()-}--pub(crate) fn window_alive(hwnd: u64) -> bool {-    match native().window_info(hwnd) {-        Ok(Some(i)) => i.visible,-        Ok(None) => false,-        Err(_) => find_windows(true, true).map(|(w, _)| w.iter().any(|w| w.hwnd == hwnd)).unwrap_or(false),-    }-}--/// The window a verb surfaced, for foreground and labelling: the kind the verb opens,-/// else the topmost KiCad window.-fn verb_window(command: &str, out: &Value) -> Option<u64> {-    if let Some(h) = out.get("hwnd").and_then(Value::as_u64).filter(|h| *h != 0) {-        return Some(h);-    }-    let kind = match command {-        "kicad_open_symbol_editor" => Some(WindowKind::SymbolEditor),-        "kicad_open_footprint_editor" => Some(WindowKind::FootprintEditor),-        "kicad_open_board" | "kicad_place_footprint" => Some(WindowKind::PcbEditor),-        "kicad_open_schematic" => Some(WindowKind::SchematicEditor),-        "kicad_open_3d_viewer" => Some(WindowKind::Viewer3d),-        _ => None,-    };-    let wins = find_windows(false, true).map(|(w, _)| w).unwrap_or_default();-    kind.and_then(|k| wins.iter().find(|w| w.kind() == k).map(|w| w.hwnd)).or_else(|| wins.first().map(|w| w.hwnd))-}--// ── Focus etiquette (win_focus.py, the one-shot part only) ────────────────────--static FOCUS_EVENTS: Mutex<Vec<Value>> = Mutex::new(Vec::new());--fn record_focus_event(ev: Value) {-    if let Ok(mut g) = FOCUS_EVENTS.lock() {-        g.push(ev);-        if g.len() > 200 {-            let n = g.len() - 200;-            g.drain(..n);-        }-    }-}--fn is_kicad_hwnd(hwnd: u64) -> bool {-    if let Ok(Some(i)) = native().window_info(hwnd) {-        return wm::is_kicad_exe(&i.exe);-    }-    kicad_windows().iter().any(|w| w.hwnd == hwnd)-}--/// capture_foreground before, compare after: if a KiCad window now holds the foreground-/// and the user had a non-KiCad window before, push KiCad back once (never activate the-/// user's window: Windows re-activates it naturally) and report the event.-fn one_shot_focus_check(fg_before: Option<u64>) -> Option<Value> {-    let before = fg_before?;-    if is_kicad_hwnd(before) {-        return None;-    }-    // What covers the user's window is the top of the Z order, not only the activated-    // window: a KiCad editor raises itself when its board finishes loading without a-    // foreground transition (gate on 2026-09-12).-    let fg = native().foreground().ok().filter(|h| *h != 0);-    let top = native().z_top().ok().filter(|h| *h != 0);-    let after = match (fg, top) {-        (Some(f), _) if is_kicad_hwnd(f) => f,-        (_, Some(t)) if is_kicad_hwnd(t) => t,-        _ => return None,-    };-    if after == before {-        return None;-    }-    let title = title_of(after);-    let pushed = native().push_to_background(after).is_ok();-    // A Z-order push alone does not hold: the editor still owns the activation and raises-    // itself again on its next paint. Give the user's window its activation back (this-    // restores what the user had before the verb; it is not a foreground of ours).-    let restored = ab::desktop_restore_user_window(before, "restore the user's window after a KiCad window raised itself").is_ok()-        || matches!(native().bring_to_front(before), Ok(true));-    let action = match (pushed, restored) {-        (true, true) => "pushed-to-background, user window restored",-        (true, false) => "pushed-to-background",-        (false, true) => "user window restored",-        (false, false) => "push-failed",-    };-    let ev = json!({"event": "self-raise", "hwnd": after, "title": title, "userWindow": before, "action": action, "at": kicad_common::now_utc_iso()});-    record_focus_event(ev.clone());-    Some(ev)-}--/// Explicit, user-requested foreground (win_focus.bring_to_user): caption the reason-/// first, then restore and activate. Refused without a reason.-fn bring_to_user(hwnd: u64, reason: &str) -> bool {-    if reason.trim().is_empty() {-        return false;-    }-    let msg = format!("KiCad bridge needs your screen for ~3s: {reason}. Background methods were exhausted; this was explicitly allowed by the calling AI (foreground:true).");-    let _ = ab::notify_user("Taking the foreground briefly", &msg, "warning");-    let _ = ab::desktop_caption("kicad-foreground", reason, 3000, "Announce the foreground the calling AI asked for before the KiCad window moves");-    sleep_ms(1200); // let the caption render BEFORE the window moves-    native().etiquette_sanction(hwnd, 20.0); // the loop must not bounce a foreground the AI asked for-    let ok = match native().bring_to_front(hwnd) {-        Ok(b) => b,-        Err(e) if is_not_impl(&e) => ab::desktop_bring_to_front(hwnd, reason).is_ok(),-        Err(_) => false,-    };-    record_focus_event(json!({"event": "user-foregrounded", "hwnd": hwnd, "reason": reason, "ok": ok, "at": kicad_common::now_utc_iso()}));-    ok-}--// ── Dialogs (close_windows.py) ────────────────────────────────────────────────--#[derive(Clone, Debug, Default)]-pub(crate) struct Dialog {-    pub(crate) hwnd: u64,-    pub(crate) title: String,-    class_name: String,-    owner: u64,-    pub(crate) body: String,-    via_global_sweep: bool,-}--/// A dialog's message text from its Static children (never blocks on a hung window).-fn dialog_body_text(hwnd: u64) -> String {-    let Ok(children) = native().child_windows(hwnd) else { return String::new() };-    let texts: Vec<String> = children.iter().filter(|c| c.class_name == "Static").map(|c| c.title.clone()).collect();-    wm::dialog_body(&texts)-}--/// RELIABLE scan for EVERY dialog around a running KiCad: the KiCad-process windows that-/// are dialogs (Win32 dialog class or owned), plus a GLOBAL #32770/error-title sweep so-/// an error box owned by an untracked pcbnew/eeschema/orphan is caught too.-pub(crate) fn scan_dialogs() -> Vec<Dialog> {-    let Ok((all, _)) = find_windows(true, false) else { return Vec::new() };-    let mut out = Vec::new();-    let mut seen = Vec::new();-    for w in all.iter().filter(|w| w.is_kicad()) {-        if !wm::is_dialog(&w.class_name, w.owner) {-            continue;-        }-        out.push(Dialog { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), owner: w.owner, body: dialog_body_text(w.hwnd), via_global_sweep: false });-        seen.push(w.hwnd);-    }-    for w in all.iter() {-        if seen.contains(&w.hwnd) {-            continue;-        }-        if wm::looks_like_global_dialog(&w.title, &w.class_name) {-            out.push(Dialog { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), owner: w.owner, body: dialog_body_text(w.hwnd), via_global_sweep: true });-            seen.push(w.hwnd);-        }-    }-    out-}--fn find_save_prompt() -> Option<Dialog> {-    scan_dialogs().into_iter().find(|d| wm::is_save_prompt(&d.title, &d.body))-}--/// Dismiss a dialog: a known dismiss button (OK for info boxes, Cancel/Finish for-/// wizards) through a time-boxed click, then Escape + WM_CLOSE. All posted to the exact-/// hwnd, no focus theft.-fn dismiss_dialog(hwnd: u64) -> String {-    for tier in wm::DISMISS_BUTTON_TIERS {-        if native().click_button(hwnd, tier).is_ok() {-            invalidate();-            return "clicked-button".into();-        }-    }-    let _ = native().post_key(hwnd, wm::VK_ESCAPE, None);-    let _ = native().post_close(hwnd);-    invalidate();-    "escape+WM_CLOSE".into()-}--/// Capture a dialog BEFORE we expire it, so the AI can be shown what was auto-closed.-fn screenshot_dialog(hwnd: u64, title: &str) -> String {-    ab::desktop_screenshot_window(hwnd, &format!("Capture the KiCad dialog '{title}' before the bridge expires it, as evidence for the calling AI"), None).map(|s| s.full_path).unwrap_or_default()-}--/// Persist the Cairo (software) canvas in every kicad_common.json so KiCad stops-/// attempting OpenGL on this machine. Called only right after dismissing the "could not-/// use OpenGL" notice, which is PROOF OpenGL is unavailable here.-fn force_software_canvas(version: &str) -> bool {-    let Some(root) = native().config_root() else { return false };-    let mut targets: Vec<PathBuf> = Vec::new();-    if !version.is_empty() {-        let d = root.join(kicad_platform::major_minor(version));-        if d.is_dir() {-            targets.push(d.join("kicad_common.json"));-        }-    }-    if targets.is_empty() {-        targets = kicad_common::common_files(&root);-        if targets.is_empty() && !version.is_empty() {-            targets.push(root.join(kicad_platform::major_minor(version)).join("kicad_common.json"));-        }-    }-    let mut changed = false;-    for cfg in targets {-        let mut doc = std::fs::read_to_string(&cfg).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()).unwrap_or(json!({}));-        if !wm::force_software_canvas_doc(&mut doc) {-            continue;-        }-        if let Some(p) = cfg.parent() {-            let _ = std::fs::create_dir_all(p);-        }-        if let Ok(text) = serde_json::to_string_pretty(&doc) {-            if std::fs::write(&cfg, text).is_ok() {-                changed = true;-            }-        }-    }-    changed-}--/// close_windows._win_sweep_dialogs: expire the benign dialogs (or ALL dialogs with-/// dismiss_all), looping because dismissing one often spawns the next.-fn sweep_dialogs(info: &KicadInfo, dismiss_all: bool, screenshot: bool) -> Value {-    let mut dismissed = Vec::new();-    let mut remaining = Vec::new();-    let mut forced = false;-    let mut shot: Vec<u64> = Vec::new();-    let deadline = Instant::now() + Duration::from_secs(25);-    for _pass in 0..5 {-        if Instant::now() > deadline {-            break;-        }-        remaining.clear();-        let mut acted = false;-        for d in scan_dialogs() {-            match wm::sweep_decision(&d.title, &d.body, dismiss_all) {-                DialogAction::LeaveProgress => remaining.push(json!({"hwnd": d.hwnd, "title": d.title, "body": d.body, "progress": true})),-                DialogAction::Dismiss { benign } => {-                    let mut entry = json!({"title": d.title, "body": d.body});-                    if screenshot && !shot.contains(&d.hwnd) {-                        entry["screenshot"] = json!(screenshot_dialog(d.hwnd, &d.title));-                        shot.push(d.hwnd);-                    }-                    entry["action"] = json!(dismiss_dialog(d.hwnd));-                    dismissed.push(entry);-                    acted = true;-                    if benign && !forced {-                        forced = force_software_canvas(&info.version);-                    }-                }-                DialogAction::Leave => remaining.push(json!({"hwnd": d.hwnd, "title": d.title, "body": d.body})),-            }-        }-        if !acted {-            break;-        }-        sleep_ms(400);-    }-    json!({"dismissed": dismissed, "remaining": remaining, "forcedSoftwareCanvas": forced})-}--/// close_windows.auto_sweep_dialogs: the post-verb sweep. Settle-polls for a late-/// dialog, screenshots and reads each one found, expires it (never a progress dialog),-/// and returns None when nothing was ever seen.-fn auto_sweep_dialogs(_info: &KicadInfo, screenshot: bool, settle_rounds: usize, settle_gap: Duration) -> Option<Value> {-    let rounds = settle_rounds.max(1);-    let mut found = Vec::new();-    for r in 0..rounds {-        invalidate();-        found = scan_dialogs();-        if !found.is_empty() {-            break;-        }-        if r + 1 < rounds {-            std::thread::sleep(settle_gap);-        }-    }-    if found.is_empty() {-        return None;-    }-    let mut dialogs = Vec::new();-    let mut had_error = false;-    let mut seen: Vec<u64> = Vec::new();-    let deadline = Instant::now() + Duration::from_secs(25);-    for _pass in 0..4 {-        if Instant::now() > deadline {-            break;-        }-        let mut acted = false;-        invalidate();-        for d in scan_dialogs() {-            if seen.contains(&d.hwnd) {-                continue;-            }-            seen.push(d.hwnd);-            match wm::auto_sweep_decision(&d.title, &d.body) {-                DialogAction::LeaveProgress => dialogs.push(json!({"title": d.title, "body": d.body, "benign": true, "progress": true, "action": "left-running"})),-                DialogAction::Dismiss { benign } => {-                    if !benign {-                        had_error = true;-                    }-                    let shot = if screenshot { screenshot_dialog(d.hwnd, &d.title) } else { String::new() };-                    let action = dismiss_dialog(d.hwnd);-                    dialogs.push(json!({"title": d.title, "body": d.body, "benign": benign, "action": action, "screenshot": shot}));-                    acted = true;-                }-                DialogAction::Leave => {}-            }-        }-        if !acted {-            break;-        }-        sleep_ms(350);-    }-    if dialogs.is_empty() {-        return None;-    }-    Some(json!({"count": dialogs.len(), "dialogs": dialogs, "hadError": had_error}))-}--/// The rows `kicad_errors` reads (bridge_log::DialogRow), for the misc group to consume-/// once kicad_errors moves to a group that runs after this one.-pub fn dialog_rows() -> Vec<bridge_log::DialogRow> {-    scan_dialogs().into_iter().map(|d| {-        let texts: Vec<String> = native().child_windows(d.hwnd).map(|c| c.iter().filter(|c| c.class_name != "Button").map(|c| c.title.clone()).filter(|t| !t.trim().is_empty()).collect()).unwrap_or_default();-        bridge_log::DialogRow { hwnd: d.hwnd, title: d.title, texts, uia_text: None }-    }).collect()-}--// ── Menu walk, UIA, posted keys: the rungs ────────────────────────────────────--/// Resolve a menu item whose label contains `needle` from the window's live menu bar and-/// fire it with WM_COMMAND. Ok(Some((id, label))) when fired, Ok(None) when no such item.-fn menu_invoke_by_text(hwnd: u64, needle: &str) -> Result<Option<(u32, String)>, String> {-    let items = native().menu_tree(hwnd)?;-    let want = needle.to_lowercase();-    // an exact label wins over a longer one that contains it: "Zoom to Selection" must never-    // fire "Zoom to Selection Area" (an interactive tool that parks the editor waiting for a drag)-    let clean = |l: &str| l.replace('&', "").split('\t').next().unwrap_or("").trim().to_lowercase();-    if let Some(it) = items.iter().find(|it| it.id != 0 && it.path.last().map(|l| clean(l) == want).unwrap_or(false)) {-        native().menu_invoke(hwnd, it.id)?;-        invalidate();-        return Ok(Some((it.id, it.path.last().unwrap().replace('&', ""))));-    }-    for it in items {-        let Some(label) = it.path.last() else { continue };-        if clean(label).contains(&want) && it.id != 0 {-            native().menu_invoke(hwnd, it.id)?;-            invalidate();-            return Ok(Some((it.id, label.replace('&', ""))));-        }-    }-    Ok(None)-}--/// win_menu.find_by_accelerator: a chord to the menu command that carries it.-fn menu_invoke_by_accel(hwnd: u64, chord: &str) -> Result<Option<(u32, String)>, String> {-    let items = native().menu_tree(hwnd)?;-    let want = wm::norm_accel(chord);-    for it in items {-        if let Some(a) = &it.accel {-            if wm::norm_accel(a) == want && it.id != 0 {-                native().menu_invoke(hwnd, it.id)?;-                invalidate();-                let label = it.path.last().map(|l| l.replace('&', "")).unwrap_or_default();-                return Ok(Some((it.id, label)));-            }-        }-    }-    Ok(None)-}--/// Poll for a window of `kind` until `deadline`; `repost` fires once at `repost_after`.-fn wait_for_kind(kind: WindowKind, deadline: Instant, repost: Option<(u64, u32)>, repost_after: Duration) -> Option<u64> {-    let t0 = Instant::now();-    let mut reposted = false;-    loop {-        invalidate();-        if let Some(h) = first_of_kind(kind) {-            return Some(h);-        }-        if Instant::now() >= deadline {-            return None;-        }-        if !reposted && t0.elapsed() > repost_after {-            if let Some((host, id)) = repost {-                let _ = native().menu_invoke(host, id);-            }-            reposted = true;-        }-        sleep_ms(500);-    }-}--/// Every descendant (class, text, rect, visible) of a window with its parent handle.-/// The platform's `child_windows` returns the whole subtree with `owner` carrying the-/// PARENT handle (the Windows implementation is built for exactly this join).-#[derive(Clone, Debug)]-struct Child {-    hwnd: u64,-    class_name: String,-    text: String,-    rect: (i32, i32, i32, i32),-    visible: bool,-    parent: u64,-}--fn descendants(root: u64) -> Vec<Child> {-    let Ok(kids) = native().child_windows(root) else { return Vec::new() };-    kids.iter().map(|k| Child { hwnd: k.hwnd, class_name: k.class_name.clone(), text: k.title.clone(), rect: k.rect, visible: k.visible, parent: if k.owner == 0 { root } else { k.owner } }).collect()-}--/// The library tree's filter Edit: class "Edit" under a parent whose text is "searchCtrl".-fn find_search_edit(frame: u64) -> Option<Child> {-    let all = descendants(frame);-    all.iter().find(|c| c.class_name == "Edit" && all.iter().any(|p| p.hwnd == c.parent && p.text == "searchCtrl")).cloned()-}--static TREE_TOGGLED: Mutex<Vec<(u64, Instant)>> = Mutex::new(Vec::new());--/// The tree search control, showing the Library Tree pane through the frame's own View-/// menu when the user has it hidden (toggle at most once per editor per minute).-fn ensure_tree_search(frame: u64, trace: &mut Vec<String>) -> Option<Child> {-    if let Some(e) = find_search_edit(frame).filter(|e| e.visible) {-        return Some(e);-    }-    let may_toggle = TREE_TOGGLED.lock().map(|g| !g.iter().any(|(h, t)| *h == frame && t.elapsed() < Duration::from_secs(60))).unwrap_or(true);-    if may_toggle {-        match menu_invoke_by_text(frame, "library tree") {-            Ok(Some((id, _))) => {-                trace.push(format!("library tree pane was hidden: View menu toggled (id {id})"));-                if let Ok(mut g) = TREE_TOGGLED.lock() {-                    g.retain(|(h, _)| *h != frame);-                    g.push((frame, Instant::now()));-                }-            }-            Ok(None) => trace.push("search Edit not visible and no 'Library Tree' menu item found".into()),-            Err(e) => trace.push(format!("search Edit not visible; menu walk failed: {e}")),-        }-    }-    for _ in 0..16 {-        if let Some(e) = find_search_edit(frame).filter(|e| e.visible) {-            return Some(e);-        }-        sleep_ms(250);-    }-    None-}--/// The rung used to put text in the filter box: "uia" (ValuePattern) else "post" (WM_SETTEXT).-fn set_filter_text(edit: u64, text: &str) -> Result<&'static str, String> {-    if native().uia_set_value(edit, "", text).is_ok() {-        return Ok("uia");-    }-    native().set_text(edit, text).map(|_| "post")-}--fn post_vk(hwnd: u64, vk: u16) {-    let _ = native().post_key(hwnd, vk, None);-}--/// Which editor title check applies during navigation.-#[derive(Clone, Copy)]-enum Verify<'a> {-    Symbol { name: &'a str, library: &'a str },-    Footprint { name: &'a str, library: &'a str },-    Never,-}--fn verify_title(title: &str, v: Verify<'_>) -> bool {-    match v {-        Verify::Symbol { name, library } => wm::title_confirms_symbol(title, name, library),-        Verify::Footprint { name, library } => wm::title_confirms_footprint(title, name, library),-        Verify::Never => false,-    }-}--/// The plugin-free BACKGROUND library-tree load (0.9.83/0.9.89, wiki #71): text into the-/// LIB_TREE filter, Down through the filter box, Enter posted to the tree candidates,-/// Down x1..3 then Enter, a posted double-click on the first row. Zero focus.-/// Returns (confirmed, trace, mechanism).-fn navigate_tree(frame: u64, filter: &str, verify: Verify<'_>) -> (bool, Value, &'static str) {-    let mut steps: Vec<String> = Vec::new();-    let mut trace = json!({"filter": filter, "editorHwnd": frame, "titleBefore": title_of(frame)});-    let mut mechanism = "post";-    let Some(edit) = ensure_tree_search(frame, &mut steps) else {-        steps.push("search Edit under a 'searchCtrl' parent NOT FOUND (library tree pane hidden, or a different child layout)".into());-        trace["steps"] = json!(steps);-        return (false, trace, mechanism);-    };-    trace["editHwnd"] = json!(edit.hwnd);-    match set_filter_text(edit.hwnd, filter) {-        Ok(m) => {-            mechanism = m;-            steps.push(format!("filter text set via {m}"));-        }-        Err(e) => {-            steps.push(format!("could not set the filter text: {e}"));-            trace["steps"] = json!(steps);-            return (false, trace, mechanism);-        }-    }-    sleep_ms(1400); // LIB_TREE search debounce on the UI thread + tree rebuild-    // Down THROUGH the filter box: KiCad forwards nav keys to the tree.-    post_vk(edit.hwnd, wm::VK_DOWN);-    sleep_ms(500);-    steps.push(format!("VK_DOWN posted to edit; title now {:?}", title_of(frame)));-    // Tree candidates: tall siblings under the Libraries pane (wx generic controls share-    // bland class names, so geometry is the discriminator) plus any wxDataView.-    let all = descendants(frame);-    let parent = all.iter().find(|c| c.hwnd == edit.hwnd).map(|c| c.parent).unwrap_or(frame);-    let pane = all.iter().find(|c| c.hwnd == parent).map(|c| c.parent).filter(|p| *p != 0).unwrap_or(parent);-    let cands: Vec<Child> = all.iter().filter(|c| c.hwnd != edit.hwnd && (c.parent == pane || all.iter().any(|p| p.hwnd == c.parent && p.parent == pane)) && (c.rect.3 > 200 || c.class_name.starts_with("wxDataView"))).cloned().collect();-    trace["treeCandidates"] = json!(cands.iter().map(|c| json!({"hwnd": c.hwnd, "class": c.class_name, "rect": [c.rect.0, c.rect.1, c.rect.0 + c.rect.2, c.rect.1 + c.rect.3]})).collect::<Vec<_>>());-    let title_ok = || verify_title(&title_of(frame), verify);-    // Round 1: Enter posted straight to each tree candidate.-    for c in &cands {-        post_vk(c.hwnd, wm::VK_RETURN);-    }-    for _ in 0..6 {-        sleep_ms(700);-        if title_ok() {-            steps.push("VK_RETURN to tree candidates -> title confirms".into());-            trace["steps"] = json!(steps);-            return (true, trace, mechanism);-        }-    }-    steps.push(format!("VK_RETURN to {} tree candidate(s); title still {:?}", cands.len(), title_of(frame)));-    // Round 1b/1c (wiki #71): on a FRESH editor nothing is selected, so select from inside-    // the tree: Down (first lands on the library node, second on the symbol row), then Enter.-    for downs in 1..=3 {-        for c in &cands {-            post_vk(c.hwnd, wm::VK_DOWN);-        }-        sleep_ms(400);-        for c in &cands {-            post_vk(c.hwnd, wm::VK_RETURN);-        }-        for _ in 0..5 {-            sleep_ms(600);-            if title_ok() {-                steps.push(format!("Down x{downs} then VK_RETURN to tree candidates -> title confirms"));-                trace["steps"] = json!(steps);-                return (true, trace, mechanism);-            }-        }-        steps.push(format!("Down x{downs} then VK_RETURN to tree; title still {:?}", title_of(frame)));-    }-    // Round 2: a posted double-click on the first visible row (just under the header).-    for c in &cands {-        let x = (c.rect.2 / 3).max(10);-        let y = 58;-        let _ = native().post_click(c.hwnd, x, y, false);-        let _ = native().post_click(c.hwnd, x, y, true);-    }-    for _ in 0..8 {-        sleep_ms(700);-        if title_ok() {-            steps.push("posted double-click on first row -> title confirms".into());-            trace["steps"] = json!(steps);-            return (true, trace, mechanism);-        }-    }-    steps.push(format!("posted double-click on first row; title still {:?}", title_of(frame)));-    trace["steps"] = json!(steps);-    (false, trace, mechanism)-}--// ── Opening an editor frame: menu walk, then UIA ──────────────────────────────--struct Budget {-    t0: Instant,-    limit: Duration,-}--impl Budget {-    fn new(args: &Value) -> Budget {-        let inline = args.get("waitInline").and_then(Value::as_bool).unwrap_or(false);-        Budget { t0: Instant::now(), limit: if inline { Duration::from_secs(1_000_000) } else { Duration::from_secs(110) } }-    }-    fn out(&self) -> bool {-        self.t0.elapsed() > self.limit-    }-    fn elapsed_sec(&self) -> u64 {-        self.t0.elapsed().as_secs()-    }-}--struct OpenSpec {-    kind: WindowKind,-    /// Menu label fragment ("symbol editor") and the UIA accessible name ("Symbol Editor").-    menu_needle: &'static str,-    uia_name: &'static str,-    /// Frames whose menu bar / UIA tree carries the launcher, in preference order-    /// (a running editor of the same family first, the project manager last).-    hosts: &'static [WindowKind],-    error_code: &'static str,-    starting_code: &'static str,-    label: &'static str,-}--const SYMBOL_SPEC: OpenSpec = OpenSpec { kind: WindowKind::SymbolEditor, menu_needle: "symbol editor", uia_name: "Symbol Editor", hosts: &[WindowKind::SchematicEditor, WindowKind::ProjectManager], error_code: "symbol_editor_did_not_open", starting_code: "symbol_editor_starting", label: "Symbol Editor" };-const FOOTPRINT_SPEC: OpenSpec = OpenSpec { kind: WindowKind::FootprintEditor, menu_needle: "footprint editor", uia_name: "Footprint Editor", hosts: &[WindowKind::PcbEditor, WindowKind::ProjectManager], error_code: "footprint_editor_did_not_open", starting_code: "footprint_editor_starting", label: "Footprint Editor" };--/// The early-return contract when a slow cold start would outlive the transport budget-/// (#54 ask 2, wiki #71): honest about whether anything is actually starting.-fn starting_contract(spec: &OpenSpec, budget: &Budget, phase: &str, pathways: &[String]) -> Value {-    invalidate();-    if first_of_kind(spec.kind).is_none() {-        return json!({-            "success": false, "errorCode": spec.error_code, "retryable": true, "retryAfterMs": 5000,-            "phase": phase, "elapsedSec": budget.elapsed_sec(), "pathwaysTried": pathways,-            "error": format!("No {} window exists. The bridge asked KiCad to open one and none appeared, so this is not a slow cold start - nothing is coming up.", spec.label),-            "_hint": format!("Call kicad_open_{} first and check it returns a window, then re-call this verb. If the editor never opens, KiCad is usually busy with a human (it is single-instance and will not service our menu command while someone is driving it) - kicad_window_info shows what is actually open.", spec.kind.as_str()),-        });-    }-    json!({-        "success": false, "errorCode": spec.starting_code, "starting": true, "retryable": true, "statusVerb": "kicad_state",-        "retryAfterMs": 20000, "phase": phase, "elapsedSec": budget.elapsed_sec(),-        "error": format!("The {} is still coming up (cold start indexes every library, which can outlive the transport budget). This call returned early instead of timing out; KiCad keeps working.", spec.label),-        "_hint": "Not a failure - poll kicad_state (or just wait retryAfterMs) and re-call with the SAME args; a warm editor resolves in seconds. Pass waitInline:true to force the old single-call blocking behaviour.",-    })-}--/// Make sure the project manager exists (spawn kicad.exe in the background if not).-fn ensure_project_manager(info: &KicadInfo, pathways: &mut Vec<String>) -> Result<u64, Value> {-    invalidate();-    if let Some(h) = find_project_manager() {-        return Ok(h);-    }-    let Some(exe) = kicad_exe(info, "kicad") else {-        return Err(fail("kicad_exe_not_found", "KiCad executable not found", "Report to the user and ask them to reinstall KiCad; the installation appears corrupted."));-    };-    spawn_background(&exe, &[], "launch", None).map_err(|mut e| { if e["errorCode"] != json!("not_supported_on_platform") { e["_hint"] = json!("Call kicad_close with {\"force\": true} to clear stuck processes, then retry."); } e })?;-    pathways.push("spawned-project-manager".into());-    let deadline = Instant::now() + Duration::from_secs(10);-    loop {-        sleep_ms(500);-        invalidate();-        if let Some(h) = find_project_manager() {-            sleep_ms(2000);-            return Ok(h);-        }-        if Instant::now() > deadline {-            return Err(fail("project_manager_did_not_open", "KiCad launched but project manager window not found after 10s", "Call screenshot_all to see KiCad's current state, or call close_kicad with {\"force\": true} and retry."));-        }-    }-}--/// Open an editor frame: an existing one wins; else the native menu walk on each host-/// frame (WM_COMMAND, repost once at 10 s); else UIA Invoke. Returns (hwnd, mechanism).-fn open_editor_frame(info: &KicadInfo, spec: &OpenSpec, budget: &Budget, pathways: &mut Vec<String>) -> Result<(u64, &'static str), Value> {-    let out = open_editor_frame_inner(info, spec, budget, pathways);-    // Wiki #48: a frame the bridge just opened is placed by Windows defaults, which means the-    // primary display. An existing window is the user's own and is never moved.-    if let Ok((hwnd, mechanism)) = &out {-        if *mechanism != "existing" {-            if let Some(row) = restore_placement_one(spec.kind, *hwnd) {-                if row["moved"] == json!(true) {-                    pathways.push(format!("placement-restored:{}", spec.kind.as_str()));-                }-            }-        }-    }-    out-}--fn open_editor_frame_inner(info: &KicadInfo, spec: &OpenSpec, budget: &Budget, pathways: &mut Vec<String>) -> Result<(u64, &'static str), Value> {-    invalidate();-    if let Some(h) = first_of_kind(spec.kind) {-        return Ok((h, "existing"));-    }-    // Hosts: a live editor frame of the same family first, then the project manager-    // (spawned in the background when nothing at all is running).-    let mut hosts: Vec<u64> = Vec::new();-    for k in spec.hosts {-        if *k == WindowKind::ProjectManager {-            continue;-        }-        hosts.extend(windows_of_kind(*k).iter().map(|w| w.hwnd));-    }-    if spec.hosts.contains(&WindowKind::ProjectManager) {-        let pm = match find_project_manager() {-            Some(h) => h,-            None if hosts.is_empty() => ensure_project_manager(info, pathways)?,-            None => 0,-        };-        if pm != 0 {-            hosts.push(pm);-        }-    }-    if hosts.is_empty() {-        return Err(fail(spec.error_code, format!("No window can host the {} launcher: neither a project manager nor a related editor is open.", spec.label), "Call kicad_launch first, then retry."));-    }-    // Rung: native menu walk + WM_COMMAND (background, version-proof ids).-    let mut menu_unsupported: Option<Value> = None;-    for &host in &hosts {-        if budget.out() {-            return Err(starting_contract(spec, budget, "waiting-for-editor-window", pathways));-        }-        match menu_invoke_by_text(host, spec.menu_needle) {-            Ok(Some((id, _label))) => {-                pathways.push(format!("menu-walk-id-{id}"));-                pathways.push("wm_command".into());-                let deadline = Instant::now() + Duration::from_secs(30);-                if let Some(h) = wait_for_kind(spec.kind, deadline, Some((host, id)), Duration::from_secs(10)) {-                    return Ok((h, "menu"));-                }-                pathways.push("wm_command-no-window".into());-            }-            Ok(None) => pathways.push(format!("menu-walk-miss:{host}")),-            Err(e) if is_not_impl(&e) => { menu_unsupported = Some(unsupported("native menu walk (menu_tree)")); }-            Err(e) => pathways.push(format!("menu-walk-error:{}", e.chars().take(60).collect::<String>())),-        }-    }-    // Rung: UI Automation Invoke by accessible name (no focus, no cursor).-    let mut uia_ran = false;-    for &host in &hosts {-        if budget.out() {-            return Err(starting_contract(spec, budget, "waiting-for-editor-window", pathways));-        }-        match native().uia_invoke(host, spec.uia_name) {-            Ok(_) => {-                uia_ran = true;-                pathways.push("uia".into());-                let deadline = Instant::now() + Duration::from_secs(30);-                if let Some(h) = wait_for_kind(spec.kind, deadline, None, Duration::from_secs(10)) {-                    return Ok((h, "uia"));-                }-                pathways.push("uia-no-window".into());-            }-            Err(e) if is_not_impl(&e) => {-                if let Some(u) = menu_unsupported.take() {-                    return Err(u);-                }-                pathways.push("uia-unsupported".into());-            }-            Err(e) => {-                uia_ran = true;-                pathways.push(format!("uia-{}", e.chars().take(40).collect::<String>()));-            }-        }-    }-    if let (Some(u), false) = (menu_unsupported, uia_ran) {-        return Err(u);-    }-    let idle = user_idle_seconds();-    let busy = idle.map(|i| i < 120.0).unwrap_or(false);-    Err(json!({-        "success": false, "errorCode": spec.error_code, "pathwaysTried": pathways, "userIdleSeconds": idle,-        "error": format!("KiCad accepted the {} command but no editor window appeared within 30s.{}", spec.label,-            if busy { format!(" A human was active on this machine {}s ago, which is the most likely reason: KiCad is single-instance, and while someone is driving it, it does not service our menu command promptly.", idle.unwrap_or(0.0) as i64) } else { String::new() }),-        "_hint": "Retry - this is usually a busy KiCad rather than a broken one, and it succeeds on a machine nobody is touching (measured: 63s cold, 57s with the project manager already running). If it repeats on an IDLE box, that is a real bug worth reporting. Otherwise check for a modal dialog with kicad_screenshot_all and dismiss it with send_key escape.",-    }))-}--/// Ask an open editor to close so the next open re-indexes the libraries: ab's-/// desktop_close_window first (its bookkeeping), local WM_CLOSE otherwise; the WAIT is-/// measured here. Returns (gone, how).-fn close_and_wait(hwnd: u64, reason: &str, timeout: Duration) -> (bool, &'static str) {-    let how = match ab::desktop_close_window(hwnd, reason) {-        Ok(_) => "ad:desktop_close_window",-        Err(_) => {-            if native().post_close(hwnd).is_err() {-                return (false, "none");-            }-            "local:WM_CLOSE"-        }-    };-    invalidate();-    let deadline = Instant::now() + timeout;-    while Instant::now() < deadline {-        sleep_ms(300);-        invalidate();-        if !window_alive(hwnd) {-            return (true, how);-        }-    }-    (false, how)-}--// ── kicad_launch ──────────────────────────────────────────────────────────────--pub(crate) fn launch(state: &mut State, args: &Value) -> Value {-    let existing = match find_windows(false, true) {-        Ok((w, _)) => w,-        Err(e) => return e,-    };-    if !existing.is_empty() {-        return json!({-            "success": true, "alreadyRunning": true,-            "windows": existing.iter().take(10).map(|w| w.title.clone()).collect::<Vec<_>>(),-            "output": format!("KiCad is already running ({} window(s)); nothing launched.", existing.len()),-            "_hint": "Idempotent no-op: KiCad was already up. Use kicad_open_editors to inventory editors, or the open_* verbs to open a specific one.",-        });-    }-    let project = arg_str(args, "project").unwrap_or("").trim().to_string();-    if !project.is_empty() && !Path::new(&project).exists() {-        return fail("project_not_found", format!("Project not found: {project}"), "Absolute Windows path required; %VAR% expansion is not performed.");-    }-    let info = state.kicad_info();-    let Some(exe) = kicad_exe(&info, "kicad") else {-        return fail("kicad_exe_not_found", "kicad.exe not found", "No KiCad installation detected. kicad_readiness reports install state; kicad_upgrade installs KiCad silently (per-user, no UAC).");-    };-    let argv: Vec<String> = if project.is_empty() { vec![] } else { vec![project.clone()] };-    let (pid, log) = match spawn_background(&exe, &argv, "launch", arg_str(args, "traceMasks")) {-        Ok(x) => x,-        Err(mut e) => {-            if e["errorCode"] != json!("not_supported_on_platform") {-                e["error"] = json!(format!("Failed to launch KiCad: {}", e["error"].as_str().unwrap_or("")));-            }-            return e;-        }-    };-    let wait_s = args.get("waitSeconds").and_then(Value::as_f64).unwrap_or(15.0).max(3.0);-    let deadline = Instant::now() + Duration::from_secs_f64(wait_s);-    let mut windows = Vec::new();-    while Instant::now() < deadline {-        windows = find_windows(false, true).map(|(w, _)| w).unwrap_or_default();-        if !windows.is_empty() {-            break;-        }-        sleep_ms(500);-    }-    let log_path = log.as_ref().map(|p| norm(p));-    if !windows.is_empty() {-        // Wiki #48 ask 2: a cold spawn on a box that has run KiCad before lands on the monitor-        // the user last used, not on whatever Windows defaults to.-        sleep_ms(1200);-        let restored = restore_placement();-        return json!({-            "success": true, "launched": true, "pid": pid, "mechanism": "spawn",-            "windows": windows.iter().take(10).map(|w| w.title.clone()).collect::<Vec<_>>(),-            "output": format!("KiCad launched; {} window(s) up.", windows.len()),-            "logPath": log_path, "placementRestored": restored,-            "_hint": "Launched in the BACKGROUND (no focus steal). First run on a fresh install may show a setup wizard; kicad_window_info self-heals benign dialogs; kicad_screenshot_all shows every window if something looks stuck.",-            "_next": ["kicad_window_info", "kicad_open_editors", "kicad_open_board"],-        });-    }-    json!({-        "success": false, "pid": pid, "mechanism": "spawn", "logPath": log_path,-        "error": format!("kicad.exe started but no KiCad window appeared within {}s", wait_s as i64),-        "_hint": "Cold start on a slow box (or software-OpenGL) can exceed the wait; retry kicad_window_info in a few seconds before treating this as a failure. A modal (crash-recovery / first-run) can also hold the window back: kicad_screenshot_all will show it.",-        "statusVerb": "kicad_window_info",-    })-}--// ── kicad_open_board / kicad_open_schematic ───────────────────────────────────--pub(crate) fn open_file(state: &mut State, args: &Value, kind: WindowKind) -> Value {-    let info = state.kicad_info();-    if !info.installed {-        return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");-    }-    let Some(file_path) = arg_str(args, "filePath") else {-        return fail("missing_arg", "No filePath specified", "Pass filePath: the absolute path of the file on the desktop.");-    };-    let path = PathBuf::from(file_path);-    if !path.exists() {-        return fail("file_not_found", format!("File not found: {file_path}"), "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path.");-    }-    let (exe_name, label, lint_verb) = match kind {-        WindowKind::PcbEditor => ("pcbnew", "PCB board", "kicad_lint_board"),-        _ => ("eeschema", "schematic", "kicad_lint_schematic"),-    };-    let Some(exe) = kicad_exe(&info, exe_name) else {-        return fail("kicad_exe_not_found", format!("KiCad executable not found: {exe_name}_exe"), "Report to the user and ask them to reinstall KiCad; the installation appears corrupted.");-    };-    let stem = path.file_stem().map(|s| s.to_string_lossy().to_lowercase()).unwrap_or_default();-    let name = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();-    // Already open: the existing window, not a fresh reload.-    invalidate();-    if let Some(w) = windows_of_kind(kind).into_iter().find(|w| !stem.is_empty() && w.title.to_lowercase().contains(&stem)) {-        return json!({-            "success": true, "alreadyOpen": true, "hwnd": w.hwnd, "title": w.title, "mechanism": "existing",-            "output": format!("{label} editor already has {name} open"),-            "_hint": "The existing window was reused, not reloaded. kicad_close first if you need a fresh load from disk.",-        });-    }-    let (pid, log) = match spawn_background(&exe, &[norm(&path)], exe_name, None) {-        Ok(x) => x,-        Err(mut e) => {-            if e["errorCode"] != json!("not_supported_on_platform") {-                e["error"] = json!(format!("Failed to launch: {}", e["error"].as_str().unwrap_or("")));-                e["_hint"] = json!("Call close_kicad with {\"force\": true} to clear any stuck processes, then retry.");-            }-            return e;-        }-    };-    // Verify by window title with one deadline (no nested sleeps).-    let wait_s = args.get("waitSeconds").and_then(Value::as_f64).unwrap_or(30.0).clamp(3.0, 110.0);-    let deadline = Instant::now() + Duration::from_secs_f64(wait_s);-    let mut appeared: Option<Win> = None;-    while Instant::now() < deadline {-        invalidate();-        let wins = windows_of_kind(kind);-        appeared = wins.iter().find(|w| w.title.to_lowercase().contains(&stem)).or_else(|| wins.iter().find(|w| w.pid == pid)).cloned();-        if appeared.is_some() {-            break;-        }-        if native().process_alive(pid) == Some(false) {-            break;-        }-        sleep_ms(500);-    }-    let hint = format!(-        "GUI launched. For unfamiliar files (ones you did not generate / have not validated), prefer running `{lint_verb}` FIRST. It uses kicad-cli to give you structured DRC/ERC + file-format info in ~1-3s, much faster + more reliable than recovering from a confused GUI via screenshots. If the GUI shows a modal dialog (file-version upgrade prompt, missing-symbol warning, recovery prompt), use kicad_window_info -> kicad_screenshot_all -> kicad_send_key to handle it; or close + retry after lint."-    );-    let mut out = json!({-        "success": true, "pid": pid, "mechanism": "spawn", "logPath": log.as_ref().map(|p| norm(p)),-        "output": format!("Launched {label} editor with {name}"),-        "_hint": hint,-    });-    match appeared {-        Some(w) => {-            out["hwnd"] = json!(w.hwnd);-            out["title"] = json!(w.title);-            out["windowAppeared"] = json!(true);-            // Wiki #48: this window was spawned by us, so Windows placed it on the primary-            // display; put it back on the monitor the user keeps this editor on.-            if let Some(row) = restore_placement_one(kind, w.hwnd) {-                out["placementRestored"] = row;-            }-        }-        None => {-            let alive = native().process_alive(pid);-            out["windowAppeared"] = json!(false);-            if alive == Some(false) {-                out["success"] = json!(false);-                out["errorCode"] = json!("editor_exited");-                out["error"] = json!(format!("{exe_name} exited before a {label} window appeared"));-                out["_hint"] = json!(format!("The file probably failed to load: the launch log (logPath) and kicad_log_tail carry KiCad's own message; {lint_verb} says what is wrong with the file."));-            } else {-                out["statusVerb"] = json!("kicad_window_info");-                out["_hint"] = json!(format!("{exe_name} is running but no {label} window titled after the file appeared within {}s (a cold start or a large file can take longer). Poll kicad_window_info; a modal dialog (kicad_screenshot_all) can also be holding it. {hint}", wait_s as i64));-            }-        }-    }-    out-}--// ── kicad_open_symbol_editor ──────────────────────────────────────────────────--fn lib_ctx(info: &KicadInfo) -> LibCtx {-    let inst = info.primary();-    let version = inst.map(|i| i.version.clone()).unwrap_or_default();-    let mut ctx = LibCtx {-        version: version.clone(),-        base_dir: inst.map(|i| i.base_dir.clone()).filter(|b| !b.is_empty()),-        config_dir: if version.is_empty() { None } else { native().config_dir(&version) },-        user_dir: if version.is_empty() { None } else { native().user_dir(&version) },-        kicad_cli: inst.map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file()),-        env_overrides: Default::default(),-    };-    ctx.load_env_overrides();-    ctx-}--/// open_symbol_editor._resolve_symbol_alias: exact, case-insensitive, then a UNIQUE-/// prefix match in either direction against the Adom library's real names.-fn resolve_symbol_alias(info: &KicadInfo, requested: &str) -> (Option<String>, String) {-    let ctx = lib_ctx(info);-    let Some((_table, lib_path)) = libraries::adom_paths(&ctx) else { return (None, "library file not found".into()) };-    if !lib_path.is_file() {-        return (None, "library file not found".into());-    }-    let names = libraries::list_symbols(&lib_path);-    if names.iter().any(|n| n == requested) {-        return (Some(requested.to_string()), "exact".into());-    }-    if let Some(n) = names.iter().find(|n| n.eq_ignore_ascii_case(requested)) {-        return (Some(n.clone()), "case-insensitive".into());-    }-    let r = requested.to_lowercase();-    let pref: Vec<&String> = names.iter().filter(|n| { let l = n.to_lowercase(); l.starts_with(&r) || r.starts_with(&l) }).collect();-    match pref.len() {-        1 => (Some(pref[0].clone()), "unique prefix match".into()),-        0 => (None, "no match".into()),-        _ => (None, format!("ambiguous: {:?}", pref.iter().take(5).collect::<Vec<_>>())),-    }-}--fn symbol_on_disk(info: &KicadInfo, name: &str) -> bool {-    let ctx = lib_ctx(info);-    libraries::adom_paths(&ctx).map(|(_, p)| p.is_file() && libraries::list_symbols(&p).iter().any(|n| n.eq_ignore_ascii_case(name))).unwrap_or(false)-}--pub(crate) fn open_symbol_editor(state: &mut State, args: &Value) -> Value {-    let info = state.kicad_info();-    if !info.installed {-        return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");-    }-    let mut symbol_name = arg_str(args, "symbolName").unwrap_or("").to_string();-    let library_name = arg_any(args, &["libraryName", "library"]).unwrap_or("").to_string();-    // Wiki #62: the file is named for the MPN but the symbol inside may not be. Resolve-    // the caller's name against the library's real names BEFORE navigating and say so.-    let mut note = Map::new();-    if !symbol_name.is_empty() && (library_name.is_empty() || library_name.eq_ignore_ascii_case("adom")) {-        let (resolved, how) = resolve_symbol_alias(&info, &symbol_name);-        match resolved {-            Some(r) if r != symbol_name => {-                note.insert("aliasedFrom".into(), json!(symbol_name));-                note.insert("aliasHow".into(), json!(how));-                symbol_name = r;-            }-            None if how.starts_with("ambiguous") => { note.insert("aliasNote".into(), json!(format!("'{symbol_name}' is not in the library and the prefix match is {how}"))); }-            None if how == "no match" => { note.insert("aliasNote".into(), json!(format!("'{symbol_name}' is not a symbol name in the Adom library (no exact, case-insensitive or prefix match)"))); }-            _ => {}-        }-    }-    let mut out = open_symbol_editor_inner(&info, args, &symbol_name, &library_name);-    if !note.is_empty() {-        if let Some(o) = out.as_object_mut() {-            for (k, v) in &note {-                o.insert(k.clone(), v.clone());-            }-            if let Some(from) = note.get("aliasedFrom").and_then(Value::as_str) {-                let prev = o.get("_hint").and_then(Value::as_str).unwrap_or("").to_string();-                o.insert("_hint".into(), json!(format!("Resolved '{from}' to symbol '{symbol_name}' ({}). {prev}", note["aliasHow"].as_str().unwrap_or(""))));-            }-        }-    }-    out-}--fn open_symbol_editor_inner(info: &KicadInfo, args: &Value, symbol_name: &str, library_name: &str) -> Value {-    let spec = &SYMBOL_SPEC;-    let budget = Budget::new(args);-    let rescan = args.get("rescan").and_then(Value::as_bool).unwrap_or(false);-    let mut pathways: Vec<String> = Vec::new();-    invalidate();-    // Already loaded? (#49: the library is a CONSTRAINT, exact library:symbol match only.)-    if !symbol_name.is_empty() && !rescan {-        for w in windows_of_kind(WindowKind::SymbolEditor) {-            if wm::title_confirms_symbol(&w.title, symbol_name, library_name) {-                let _ = native().show_no_activate(w.hwnd);-                let mut v = json!({"success": true, "hwnd": w.hwnd, "mechanism": "existing", "output": format!("Symbol '{symbol_name}' is already loaded in the Symbol Editor.")});-                merge(&mut v, wm::resolved_fields(&w.title, library_name, "Symbol Editor"));-                return v;-            }-        }-    }-    // rescan:true (0.9.192): the remedy for library_cache_stale. Close the editor so the-    // open below indexes a fresh one. Opt-in: it closes a window the user may be using.-    let mut rescanned = false;-    let mut close_via = "";-    if rescan {-        if let Some(h) = first_of_kind(WindowKind::SymbolEditor) {-            let (gone, how) = close_and_wait(h, "Close the Symbol Editor so its next open re-indexes the symbol libraries", Duration::from_secs(12));-            close_via = how;-            if gone {-                rescanned = true;-                pathways.push("rescan-closed-stale-editor".into());-            } else {-                pathways.push("rescan-close-failed".into());-            }-        }-    }-    let (frame, mechanism) = match open_editor_frame(info, spec, &budget, &mut pathways) {-        Ok(x) => x,-        Err(e) => return e,-    };-    if mechanism != "existing" {-        sleep_ms(1500); // let the frame settle-    }-    let _ = native().show_no_activate(frame);-    if symbol_name.is_empty() && library_name.is_empty() {-        return json!({"success": true, "hwnd": frame, "mechanism": mechanism, "pathwaysTried": pathways, "output": if mechanism == "existing" { "Symbol Editor is already open.".to_string() } else { format!("Symbol Editor opened successfully ({mechanism}).") }});-    }-    // Library-only browse mode (0.9.92): filter the tree to the library, no part load.-    if symbol_name.is_empty() {-        let (_, trace, nav_mech) = navigate_tree(frame, library_name, Verify::Never);-        return json!({-            "success": true, "hwnd": frame, "libraryShown": library_name, "mechanism": nav_mech, "openedVia": mechanism, "navigation": trace,-            "output": format!("Symbol Editor open with the '{library_name}' library filtered and selected. No symbol was requested, so none was loaded into the canvas."),-            "via": "win32-library-browse",-            "_hint": "Library browse mode: the tree is filtered to the library. To load a part into the canvas, call again with symbolName too.",-        });-    }-    let filter = if library_name.is_empty() { symbol_name.to_string() } else { format!("{library_name} {symbol_name}") };-    let verify = Verify::Symbol { name: symbol_name, library: library_name };-    let mut attempts = 0;-    let mut last_trace = Value::Null;-    let mut last_mech = "post";-    for attempt in 1..=2 {-        attempts = attempt;-        let (ok, trace, nav_mech) = navigate_tree(frame, &filter, verify);-        last_trace = trace;-        last_mech = nav_mech;-        if ok {-            let title = title_of(frame);-            let mut v = json!({-                "success": true, "hwnd": frame, "mechanism": nav_mech, "openedVia": mechanism, "via": "win32-background", "attempts": attempt,-                "output": format!("Symbol '{symbol_name}' loaded (background {nav_mech} path{}): {title}", if attempt == 2 { ", 2nd attempt" } else { "" }),-                "navigation": last_trace, "pathwaysTried": pathways, "rescanned": rescanned,-                "editorCloseVia": if rescanned { json!(close_via) } else { Value::Null },-            });-            merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));-            return v;-        }-        if budget.out() {-            // Wiki #71: a navigation that ran against an editor that is up is not "starting".-            let title = title_of(frame);-            let mut v = json!({-                "success": false, "errorCode": "navigation_unconfirmed_within_budget", "retryable": true, "retryAfterMs": 3000,-                "elapsedSec": budget.elapsed_sec(), "editorOpened": true, "hwnd": frame, "rescanned": rescanned, "mechanism": nav_mech,-                "navigation": last_trace,-                "error": format!("The Symbol Editor is open and one background navigation ran, but the title did not confirm '{symbol_name}' before this call's time budget ended (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),-                "_hint": "Re-call with the SAME args: the editor is warm now, so the whole call is the navigation alone (seconds). Read navigation.steps and navigation.treeCandidates: zero candidates means Enter reached no window (report it with the trace).",-            });-            merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));-            return v;-        }-    }-    // Two background attempts did not confirm. Is the symbol even in the editor's world?-    invalidate();-    if first_of_kind(WindowKind::SymbolEditor).is_none() {-        return json!({-            "success": false, "editorOpened": false, "symbolLoaded": false, "errorCode": "symbol_editor_not_open",-            "error": format!("No Symbol Editor is open, so '{symbol_name}' could not be loaded (it was open earlier in this call if rescan was requested)."),-            "_hint": "The editor is gone rather than confused: nothing to navigate. Call again without rescan to open a fresh one. If this followed rescan:true, the close succeeded and the reopen did not - that is a bridge bug worth reporting, not a library problem.",-        });-    }-    let title = title_of(frame);-    // Wiki #54, #71 (the 0.9.287 staleVeto, ported): an editor THIS call opened indexed the-    // libraries after the library file was last written, so its cache cannot be stale. Saying-    // library_cache_stale there sends the caller to rescan:true, which only closes and reopens-    // an already fresh editor and burns the budget again. The verdict is navigation_failed.-    let on_disk = symbol_on_disk(info, symbol_name);-    let editor_is_ours = mechanism != "existing";-    let stale = on_disk && !rescan && !editor_is_ours;-    if stale {-        let ctx = lib_ctx(info);-        let paths = libraries::adom_paths(&ctx);-        return json!({-            "success": false, "editorOpened": true, "symbolLoaded": false, "attempts": attempts, "hwnd": frame,-            "errorCode": "library_cache_stale", "mechanism": last_mech, "navigation": last_trace,-            "symbolInLibraryFile": true,-            "symLibTable": paths.as_ref().map(|(t, _)| norm(t)),-            "libraryFile": paths.as_ref().map(|(_, l)| norm(l)),-            "error": format!("'{symbol_name}' is in the Adom library file on disk, but two background navigations did not load it (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),-            "_hint": "The verdict is only 'navigation failed while the symbol is in the Adom file'. Check the sym-lib-table row points at libraryFile (a OneDrive-redirected Documents folder is the classic split), then re-call with rescan:true to close and reopen the editor so it re-indexes; kicad_rescan_libraries force:true restarts KiCad when a reopen is not enough.",-        });-    }-    let mut v = json!({-        "success": false, "editorOpened": true, "symbolLoaded": false, "attempts": attempts, "hwnd": frame,-        "errorCode": "navigation_failed", "mechanism": last_mech, "navigation": last_trace, "pathwaysTried": pathways,-        "error": format!("Symbol Editor open but '{symbol_name}' didn't load in the background after two attempts (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),-        "_hint": "Two background attempts did not confirm, so a third is unlikely to help - check that the symbol really exists in this library. The keystroke fallback (allowFocusSteal) is not in the native build; pass foreground:true with a foregroundReason to bring the editor to the user and let them load it.",-    });-    if on_disk && editor_is_ours {-        v["symbolInLibraryFile"] = json!(true);-        v["staleVeto"] = json!(format!("this call opened the Symbol Editor itself ({mechanism}), so it indexed the libraries after the library file was last written: the index cannot be stale, and rescan:true would close and reopen an already fresh editor"));-        v["_hint"] = json!("The symbol IS in the Adom library file and this call opened the editor itself, so the index is fresh: this is a navigation failure, not a stale cache, and rescan:true cannot help. Read navigation.steps and navigation.treeCandidates (zero candidates means Enter reached no window, worth reporting with the trace), then pass foreground:true with a foregroundReason to hand the editor to the user.");-    }-    merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));-    v-}--fn merge(into: &mut Value, extra: Value) {-    if let (Some(o), Some(e)) = (into.as_object_mut(), extra.as_object()) {-        for (k, v) in e {-            o.insert(k.clone(), v.clone());-        }-    }-}--// ── kicad_open_footprint_editor ───────────────────────────────────────────────--pub(crate) fn open_footprint_editor(state: &mut State, args: &Value) -> Value {-    let info = state.kicad_info();-    if !info.installed {-        return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");-    }-    let spec = &FOOTPRINT_SPEC;-    let budget = Budget::new(args);-    let footprint_name = arg_any(args, &["footprintName", "footprint"]).unwrap_or("").to_string();-    let library_name = arg_any(args, &["libraryName", "library"]).unwrap_or("").to_string();-    let mut pathways: Vec<String> = Vec::new();-    invalidate();-    if !footprint_name.is_empty() {-        for w in windows_of_kind(WindowKind::FootprintEditor) {-            if wm::title_confirms_footprint(&w.title, &footprint_name, &library_name) {-                let _ = native().show_no_activate(w.hwnd);-                let mut v = json!({"success": true, "hwnd": w.hwnd, "mechanism": "existing", "output": format!("Footprint '{footprint_name}' is already loaded in the Footprint Editor.")});-                merge(&mut v, wm::resolved_fields(&w.title, &library_name, "Footprint Editor"));-                return v;-            }-        }-    }-    if args.get("allowCursorClick").and_then(Value::as_bool).unwrap_or(false) {-        pathways.push("icon_click_not_in_native_build".into());-    } else {-        pathways.push("icon_click_skipped_background_mode".into());-    }-    let (frame, mechanism) = match open_editor_frame(&info, spec, &budget, &mut pathways) {-        Ok(x) => x,-        Err(mut e) => {-            if e["errorCode"] == json!(spec.error_code) {-                e["error"] = json!("Could not open the Footprint Editor via the background path.");-                e["_hint"] = json!("The background rungs (the host frame's native menu, then UI Automation) fired without a Footprint Editor appearing. Open a board first (kicad_open_board) so a PCB Editor hosts the launcher, check kicad_screenshot_all for a modal dialog, then retry. The mouse-moving launcher click (allowCursorClick) is not in the native build.");-            }-            return e;-        }-    };-    if mechanism != "existing" {-        sleep_ms(1500);-    }-    let _ = native().show_no_activate(frame);-    if footprint_name.is_empty() && library_name.is_empty() {-        return json!({"success": true, "hwnd": frame, "mechanism": mechanism, "pathwaysTried": pathways, "output": if mechanism == "existing" { "Footprint Editor is already open.".to_string() } else { format!("Footprint Editor opened successfully ({mechanism}).") }});-    }-    if footprint_name.is_empty() {-        let (_, trace, nav_mech) = navigate_tree(frame, &library_name, Verify::Never);-        return json!({-            "success": true, "hwnd": frame, "libraryShown": library_name, "mechanism": nav_mech, "openedVia": mechanism, "navigation": trace,-            "output": format!("Footprint Editor open with the '{library_name}' library filtered and selected. No footprint was requested, so none was loaded into the canvas."),-            "via": "win32-library-browse",-            "_hint": "Library browse mode: the tree is filtered to the library. To load a part, call again with footprintName too.",-        });-    }-    let filter = if library_name.is_empty() { footprint_name.clone() } else { format!("{library_name} {footprint_name}") };-    let verify = Verify::Footprint { name: &footprint_name, library: &library_name };-    let mut last_trace = Value::Null;-    let mut last_mech = "post";-    for attempt in 1..=2 {-        let (ok, trace, nav_mech) = navigate_tree(frame, &filter, verify);-        last_trace = trace;-        last_mech = nav_mech;-        if ok {-            let title = title_of(frame);-            let mut v = json!({-                "success": true, "hwnd": frame, "mechanism": nav_mech, "openedVia": mechanism, "via": "win32-background", "attempts": attempt,-                "output": format!("Footprint '{footprint_name}' loaded (background {nav_mech} path{}): {title}", if attempt == 2 { ", 2nd attempt" } else { "" }),-                "navigation": last_trace, "pathwaysTried": pathways,-            });-            merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor"));-            return v;-        }-        if budget.out() {-            return json!({-                "success": false, "errorCode": "navigation_unconfirmed_within_budget", "retryable": true, "retryAfterMs": 3000,-                "elapsedSec": budget.elapsed_sec(), "editorOpened": true, "hwnd": frame, "mechanism": nav_mech, "navigation": last_trace,-                "error": format!("The Footprint Editor is open and one background navigation ran, but the title did not confirm '{footprint_name}' before this call's time budget ended (title: {})", title_of(frame)),-                "_hint": "Re-call with the SAME args: the editor is warm now, so the whole call is the navigation alone (seconds).",-            });-        }-    }-    let title = title_of(frame);-    let mut v = json!({-        "success": false, "editorOpened": true, "footprintLoaded": false, "attempts": 2, "hwnd": frame,-        "errorCode": "navigation_failed", "mechanism": last_mech, "navigation": last_trace, "pathwaysTried": pathways,-        "error": format!("Footprint Editor open but '{footprint_name}' didn't load in the background after two attempts (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),-        "_hint": "Two background attempts did not confirm - check that the footprint really exists in this library (kicad_list_footprints). If it was installed after the editor opened, KiCad's library index predates it: kicad_close_footprint_editor and re-call, or kicad_rescan_libraries force:true. The keystroke fallback (allowFocusSteal) is not in the native build.",-    });-    merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor"));-    v-}--// ── kicad_open_3d_viewer ──────────────────────────────────────────────────────--fn poll_3d_window(rounds: usize) -> Option<Win> {-    for _ in 0..rounds.max(1) {-        sleep_ms(750);-        invalidate();-        if let Some(w) = windows_of_kind(WindowKind::Viewer3d).into_iter().next() {-            return Some(w);-        }-    }-    None-}--/// The rungs for one source frame: native menu walk, then UIA Invoke. Ok(Some) when a-/// 3D Viewer window appeared.-fn open_3d_from(frame: u64, label: &str, pathways: &mut Vec<String>) -> Result<Option<Value>, Value> {-    let mut unsupported_menu: Option<Value> = None;-    match menu_invoke_by_text(frame, "3d viewer") {-        Ok(Some((id, _))) => {-            pathways.push(format!("win32menu(id={id})"));-            if let Some(w) = poll_3d_window(12) {-                return Ok(Some(json!({"success": true, "pathway": "win32menu", "mechanism": "menu", "hwnd": w.hwnd, "title": w.title, "output": format!("3D Viewer opened from {label} (Win32 menu): {}", w.title), "pathwaysTried": pathways})));-            }-        }-        Ok(None) => pathways.push("win32menu-not-found".into()),-        Err(e) if is_not_impl(&e) => unsupported_menu = Some(unsupported("native menu walk (menu_tree)")),-        Err(e) => pathways.push(format!("win32menu-{}", e.chars().take(40).collect::<String>())),-    }-    match native().uia_invoke(frame, "3D Viewer") {-        Ok(_) => {-            pathways.push("uia".into());-            if let Some(w) = poll_3d_window(12) {-                return Ok(Some(json!({"success": true, "pathway": "uia", "mechanism": "uia", "hwnd": w.hwnd, "title": w.title, "output": format!("3D Viewer opened from {label} via UIA: {}", w.title), "pathwaysTried": pathways})));-            }-        }-        Err(e) if is_not_impl(&e) => {-            // Neither rung exists on this OS: say so instead of "did not open".-            if let Some(u) = unsupported_menu {-                return Err(u);-            }-            pathways.push("uia-unsupported".into());-        }-        Err(e) => pathways.push(format!("uia-{}", e.chars().take(40).collect::<String>())),-    }-    Ok(None)-}---/// `kicad_3d_view`: the 3D Viewer's View-menu commands by name, repeated with pauses, or a tour.--/// `kicad_menu_items`: the native menu bar of a KiCad window, read-only.-pub(crate) fn menu_items(args: &Value) -> Value {-    let hwnd = match arg_any_u64(args, &["hwnd"]).or_else(|| foreground_kicad_hwnd()) {-        Some(h) => h,-        None => return fail("no_window", "No KiCad window found.", "kicad_window_info lists the windows; pass hwnd."),-    };-    let needle = arg_str(args, "contains").map(|s| s.to_lowercase()).unwrap_or_default();-    match native().menu_tree(hwnd) {-        Ok(items) => {-            let list: Vec<Value> = items.iter().filter(|it| needle.is_empty() || it.path.iter().any(|p| p.replace('&', "").to_lowercase().contains(&needle))).map(|it| json!({"path": it.path.iter().map(|p| p.replace('&', "")).collect::<Vec<_>>(), "id": it.id, "accel": it.accel})).collect();-            json!({"success": true, "hwnd": hwnd, "title": title_of(hwnd), "count": list.len(), "items": list})-        }-        Err(e) if is_not_impl(&e) => unsupported("native menu walk (menu_tree)"),-        Err(e) => fail("menu_walk_failed", &e, "Retry once the window is idle."),-    }-}--pub(crate) fn view_3d(args: &Value) -> Value {-    const ACTIONS: &[(&str, &str)] = &[-        ("zoom_in", "Zoom In"), ("zoom_out", "Zoom Out"), ("fit", "Zoom to Fit"),-        ("rotate_x_cw", "Rotate X Clockwise"), ("rotate_x_ccw", "Rotate X Counterclockwise"),-        ("rotate_y_cw", "Rotate Y Clockwise"), ("rotate_y_ccw", "Rotate Y Counterclockwise"),-        ("rotate_z_cw", "Rotate Z Clockwise"), ("rotate_z_ccw", "Rotate Z Counterclockwise"),-        ("view_top", "View Top"), ("view_bottom", "View Bottom"), ("view_front", "View Front"), ("view_back", "View Back"),-        ("view_left", "View Left"), ("view_right", "View Right"), ("flip", "Flip Board"),-    ];-    let action = arg_str(args, "action").unwrap_or("").trim().to_lowercase();-    if action == "actions" || action.is_empty() {-        return json!({"success": true, "actions": ACTIONS.iter().map(|(a, l)| json!({"action": a, "menuItem": l})).collect::<Vec<_>>(), "tour": "fit, view_top, rotate_x_ccw x2, rotate_z_cw x8, zoom_in x3, rotate_z_ccw x4, zoom_out x3, view_front, rotate_z_cw x4, view_bottom, flip, view_top, fit", "_hint": "Pass action (and count / pauseMs), or action:\"tour\" with an optional steps list of [action, count] pairs."});-    }-    invalidate();-    let hwnd = match arg_any_u64(args, &["hwnd"]).or_else(|| first_of_kind(WindowKind::Viewer3d)) {-        Some(h) => h,-        None => return fail("viewer_not_open", "No 3D Viewer window is open.", "kicad_open_3d_viewer {editor:\"pcb\"} opens it from the PCB Editor's View menu; it takes a few seconds to render."),-    };-    let pause = args.get("pauseMs").and_then(Value::as_u64).unwrap_or(700).clamp(0, 10_000);-    let count = args.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 60) as usize;-    let steps: Vec<(String, usize)> = if action == "tour" {-        match args.get("steps").and_then(Value::as_array) {-            Some(a) => a.iter().filter_map(|e| { let a = e.as_array()?; Some((a.first()?.as_str()?.to_lowercase(), a.get(1).and_then(Value::as_u64).unwrap_or(1).clamp(1, 60) as usize)) }).collect(),-            None => vec![("fit", 1), ("view_top", 1), ("rotate_x_ccw", 2), ("rotate_z_cw", 8), ("zoom_in", 3), ("rotate_z_ccw", 4), ("zoom_out", 3), ("view_front", 1), ("rotate_z_cw", 4), ("view_bottom", 1), ("flip", 1), ("view_top", 1), ("fit", 1)].into_iter().map(|(a, n)| (a.to_string(), n)).collect(),-        }-    } else {-        vec![(action.clone(), count)]-    };-    let mut done: Vec<Value> = Vec::new();-    let started = Instant::now();-    for (act, n) in &steps {-        let Some((_, label)) = ACTIONS.iter().find(|(a, _)| *a == act.as_str()) else {-            return fail("bad_action", format!("Unknown action '{act}'."), "kicad_3d_view {action:\"actions\"} lists them.");-        };-        for _ in 0..*n {-            match menu_invoke_by_text(hwnd, label) {-                Ok(Some((id, _))) => { done.push(json!({"action": act, "cmdId": id})); }-                Ok(None) => return fail("menu_item_not_found", format!("The 3D Viewer has no menu item '{label}' (KiCad version?)."), "kicad_send_key {hwnd, menu:...} with a substring finds what this build calls it."),-                Err(e) => return fail("menu_invoke_failed", &e, "Retry once the viewer has finished rendering."),-            }-            std::thread::sleep(Duration::from_millis(pause));-        }-    }-    json!({"success": true, "hwnd": hwnd, "commands": done.len(), "steps": steps.iter().map(|(a, n)| json!([a, n])).collect::<Vec<_>>(), "seconds": (started.elapsed().as_secs_f64() * 10.0).round() / 10.0, "foreground": false,-        "_hint": "Every command was posted to the viewer's own menu in the background. kicad_screenshot_all shows the view; a window recording of the viewer has the motion."})-}--pub(crate) fn open_3d_viewer(args: &Value) -> Value {-    let choice = arg_str(args, "editor").unwrap_or("auto").to_lowercase();-    if !["auto", "fp", "footprint", "pcb", "board"].contains(&choice.as_str()) {-        return fail("bad_arg", format!("Unknown editor '{choice}'. Use 'auto', 'fp', or 'pcb'."), "editor selects which open editor's View menu opens the viewer.");-    }-    invalidate();-    let mut fp = first_of_kind(WindowKind::FootprintEditor);-    let mut pcb = first_of_kind(WindowKind::PcbEditor);-    // Explicit source editors (tour, 2026-09-03): a caller that knows its editor's hwnd-    // passes pcbHwnd / fpHwnd and we never guess among several PCB Editors.-    if let Some(h) = arg_any_u64(args, &["pcbHwnd", "pcb_hwnd"]) {-        pcb = Some(h);-    }-    if let Some(h) = arg_any_u64(args, &["fpHwnd", "fp_hwnd"]) {-        fp = Some(h);-    }-    match choice.as_str() {-        "fp" | "footprint" => {-            if fp.is_none() {-                return fail("footprint_editor_not_open", "Footprint Editor is not open.", "Call open_footprint_editor first or pass editor='pcb'.");-            }-            pcb = None;-        }-        "pcb" | "board" => {-            if pcb.is_none() {-                return fail("pcb_editor_not_open", "PCB Editor is not open.", "Call open_board first or pass editor='fp'.");-            }-            fp = None;-        }-        _ => {-            if pcb.is_some() && fp.is_some() {-                fp = None; // auto: prefer PCB when both are open (tour-like flow)-            }-        }-    }-    let mut pathways: Vec<String> = Vec::new();-    if let Some(f) = fp {-        match open_3d_from(f, "Footprint Editor", &mut pathways) {-            Ok(Some(v)) => return v,-            Err(e) => return e,-            Ok(None) => {}-        }-        if choice == "auto" && pcb.is_none() {-            pcb = first_of_kind(WindowKind::PcbEditor);-        }-        if pcb.is_none() {-            return json!({"success": false, "errorCode": "3d_viewer_did_not_open", "pathwaysTried": pathways, "error": "3D Viewer did not open from Footprint Editor.", "_hint": "Call open_footprint_editor with a footprintName first, OR pass editor='pcb' to target the board's 3D viewer instead."});-        }-    }-    if let Some(p) = pcb {-        match open_3d_from(p, "PCB Editor", &mut pathways) {-            Ok(Some(v)) => return v,-            Err(e) => return e,-            Ok(None) => {}-        }-        return json!({"success": false, "errorCode": "3d_viewer_did_not_open", "pathwaysTried": pathways, "error": "3D Viewer did not open via the background path.", "_hint": "Call open_board {\"filePath\":\"...\"} to load a PCB in pcbnew first, then retry; kicad_screenshot_all shows whether a dialog is holding the editor."});-    }-    fail("no_source_editor", "Neither Footprint Editor nor PCB Editor is open. Open one first with open_footprint_editor or open_board.", "Call open_footprint_editor or open_board {\"filePath\":\"...\"} first, then retry.")-}--fn arg_any_u64(args: &Value, keys: &[&str]) -> Option<u64> {-    keys.iter().find_map(|k| args.get(*k)).and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))).filter(|h| *h != 0)-}--// ── kicad_open_editors ────────────────────────────────────────────────────────--fn open_editors() -> Value {-    let (wins, source) = match find_windows(false, true) {-        Ok(x) => x,-        Err(e) => return e,-    };-    let mut editors = Vec::new();-    let mut by_kind: BTreeMap<String, Vec<Value>> = BTreeMap::new();-    for w in &wins {-        let kind = w.kind();-        if !kind.is_editor() && kind != WindowKind::ProjectManager {-            continue;-        }-        let row = json!({"kind": kind.as_str(), "exeName": w.exe, "pid": w.pid, "frameClass": w.class_name, "frameTitle": w.title, "hwnd": w.hwnd, "isShown": !w.minimized});-        by_kind.entry(kind.as_str().to_string()).or_default().push(row.clone());-        editors.push(row);-    }-    let running = !wins.is_empty();-    let hint = if editors.is_empty() && !running {-        "0 editors and no KiCad window: KiCad is almost certainly NOT RUNNING. This verb only INVENTORIES editors inside a running KiCad (despite its name); it never launches anything. To cold-start KiCad use kicad_launch; kicad_open_board / kicad_open_symbol_editor also cold-launch when opening a specific target."-    } else {-        "Inventory from window enumeration (the native build has no in-process plugin): each KiCad top-level frame is classified by its title. kind is one of project_manager, schematic_editor, pcb_editor, symbol_editor, footprint_editor, 3d_viewer."-    };-    json!({-        "success": true, "editors": editors, "byKind": by_kind, "kicadRunning": running, "windowSource": source,-        "summary": {"totalEditors": editors.len(), "probedPlugins": 0, "failedPlugins": 0, "byKindCounts": by_kind.iter().map(|(k, v)| (k.clone(), json!(v.len()))).collect::<Map<String, Value>>()},-        "_hint": hint,-    })-}--// ── kicad_close* ──────────────────────────────────────────────────────────────--fn close_kind(kind: WindowKind, label: &str) -> Value {-    invalidate();-    let wins = match find_windows(false, true) {-        Ok((w, _)) => w.into_iter().filter(|w| w.kind() == kind).collect::<Vec<_>>(),-        Err(e) => return e,-    };-    if wins.is_empty() {-        return json!({"success": true, "output": format!("{label} is not open.")});-    }-    let mut closed = Vec::new();-    let mut failed = Vec::new();-    let mut hows = Vec::new();-    for w in &wins {-        let (gone, how) = close_and_wait(w.hwnd, &format!("Close the KiCad {label} the calling AI asked to close"), Duration::from_secs(5));-        hows.push(how);-        if gone { closed.push(w.title.clone()) } else { failed.push(w.title.clone()) }-    }-    if !failed.is_empty() {-        let prompt = find_save_prompt();-        let mut v = fail("close_failed", format!("Failed to close: {}", failed.join(", ")), "Call close_kicad with {\"force\": true} to force-kill the window, then retry.");-        if let Some(p) = prompt {-            v["errorCode"] = json!("unsaved_changes");-            v["unsavedChanges"] = json!([{"window": failed[0], "dialog": p.title, "body": p.body}]);-            v["_hint"] = json!("KiCad is asking whether to save changes. Ask the user, then save in KiCad or call kicad_close {\"discardChanges\": true} to press Discard on their behalf.");-        }-        return v;-    }-    json!({-        "success": true, "closeVia": hows,-        "output": if closed.len() == 1 { format!("{label} closed: {}", closed[0]) } else { format!("Closed {} {label} window(s).", closed.len()) },-    })-}--/// kicad_close {force:true}: the ONLY path that kills a process.-fn force_kill_kicad() -> Result<Vec<String>, Value> {-    let procs = plat("process enumeration (processes)", native().processes(wm::KILL_PROCESS_NAMES))?;-    let mut killed = Vec::new();-    for name in wm::KILL_PROCESS_NAMES {-        let mut any = false;-        for p in procs.iter().filter(|p| p.exe.eq_ignore_ascii_case(name) || p.exe.eq_ignore_ascii_case(name.trim_end_matches(".exe"))) {-            if native().kill_process(p.pid).is_ok() {-                any = true;-            }-        }-        if any {-            killed.push(name.to_string());-        }-    }-    invalidate();-    Ok(killed)-}--#[derive(Default)]-struct CloseState {-    discard: bool,-    closed: Vec<String>,-    failed: Vec<String>,-    unsaved: Vec<Value>,-    discard_log: Vec<Value>,-}--/// One window: WM_CLOSE and wait; on a save prompt press Discard (discardChanges) or-/// report unsavedChanges. Some(true) closed, Some(false) failed, None already recorded.-fn close_one(st: &mut CloseState, w: &Win) -> Option<bool> {-    let (gone, _) = close_and_wait(w.hwnd, "Close KiCad as the calling AI asked", Duration::from_secs(8));-    if gone {-        return Some(true);-    }-    let Some(prompt) = find_save_prompt() else { return Some(false) };-    if st.discard {-        let mut attempts = Vec::new();-        let mut pressed = match native().click_button(prompt.hwnd, wm::DISCARD_LABELS) {-            Ok(m) => { attempts.push(json!({"win32Button": true, "method": m})); true }-            Err(e) => { attempts.push(json!({"win32Button": false, "error": e.chars().take(120).collect::<String>()})); false }-        };-        if !pressed {-            for label in wm::DISCARD_LABELS_UIA {-                match native().uia_invoke(prompt.hwnd, label) {-                    Ok(m) => { attempts.push(json!({"label": label, "uia": m})); pressed = true; break; }-                    Err(e) => attempts.push(json!({"label": label, "uia": e.chars().take(120).collect::<String>()})),-                }-            }-        }-        st.discard_log.push(json!({"window": w.title, "dialog": prompt.title, "attempts": attempts}));-        if pressed {-            let deadline = Instant::now() + Duration::from_secs(8);-            while Instant::now() < deadline {-                sleep_ms(300);-                invalidate();-                if !window_alive(w.hwnd) {-                    st.closed.push(format!("{} (changes discarded)", w.title));-                    return None;-                }-            }-        }-    }-    st.unsaved.push(json!({"window": w.title, "dialog": prompt.title, "body": prompt.body}));-    Some(false)-}--fn close_kicad(_state: &mut State, args: &Value) -> Value {-    let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);-    let discard = args.get("discardChanges").and_then(Value::as_bool).unwrap_or(false);-    // Wiki #48: a close is the last moment these windows exist, so learn where they are-    // before the geometry is gone. A kill in particular denies KiCad its own save.-    remember_placement();-    if force {-        return match force_kill_kicad() {-            Ok(k) if !k.is_empty() => json!({"success": true, "output": format!("Force-killed: {}", k.join(", ")), "data": {"forceKilled": k}}),-            Ok(_) => json!({"success": true, "output": "KiCad is not running (nothing to kill)."}),-            Err(e) => e,-        };-    }-    invalidate();-    let wins = match find_windows(false, true) {-        Ok((w, _)) => w,-        Err(e) => return e,-    };-    let mut editors = Vec::new();-    let mut manager: Option<Win> = None;-    for w in wins.into_iter().filter(|w| w.owner == 0) {-        if wm::is_manager_title(&w.title) {-            manager = Some(w);-        } else {-            editors.push(w);-        }-    }-    if editors.is_empty() && manager.is_none() {-        return json!({"success": true, "output": "KiCad is not running."});-    }-    // Issue #84: a dirty board answers WM_CLOSE with "Save changes?". Read it; with-    // discardChanges press Discard and stay graceful; otherwise leave the window open and-    // report unsavedChanges. Nothing is ever killed here.-    let mut st = CloseState::default();-    st.discard = discard;-    for w in &editors {-        match close_one(&mut st, w) {-            Some(true) => st.closed.push(w.title.clone()),-            Some(false) => st.failed.push(w.title.clone()),-            None => {}-        }-    }-    if let (Some(m), true) = (&manager, st.unsaved.is_empty()) {-        match close_one(&mut st, m) {-            Some(true) => st.closed.push(m.title.clone()),-            Some(false) => st.failed.push(m.title.clone()),-            None => {}-        }-    }-    let CloseState { closed, failed, unsaved, discard_log, .. } = st;-    if !unsaved.is_empty() {-        return json!({-            "success": false, "errorCode": "unsaved_changes", "unsavedChanges": unsaved,-            "error": format!("KiCad is asking whether to save changes; nothing was killed: {}", unsaved.iter().map(|u| format!("{}: {}", u["window"].as_str().unwrap_or(""), u["body"].as_str().unwrap_or("").chars().take(80).collect::<String>())).collect::<Vec<_>>().join("; ")),-            "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) },-            "_hint": "The user's unsaved work is still open. Ask them, then either save in KiCad (Ctrl+S via desktop_press_key) and call kicad_close again, or call kicad_close {\"discardChanges\": true} to press Discard on their behalf. force:true kills the process, which discards silently.",-            "discard": discard_log,-        });-    }-    if !failed.is_empty() {-        // No save prompt and no close: the native build never kills here (force:true is-        // the only kill path); say what did not close and how to force it.-        return json!({-            "success": false, "errorCode": "close_failed",-            "error": format!("Failed to close: {}", failed.join(", ")),-            "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) },-            "_hint": "Call close_kicad with {\"force\": true} to force-kill stuck windows (a kill discards unsaved work silently). kicad_screenshot_all shows whether a dialog is holding the window.",-        });-    }-    let mut out = json!({"success": true, "output": format!("KiCad closed ({} window{}).", closed.len(), if closed.len() != 1 { "s" } else { "" }), "data": {}});-    if !discard_log.is_empty() {-        out["discard"] = json!(discard_log);-    }-    out-}--// ── kicad_dismiss_dialogs / kicad_window_info ─────────────────────────────────--fn dismiss_dialogs(state: &mut State, args: &Value) -> Value {-    let info = state.kicad_info();-    if args.get("forceSoftwareCanvas").and_then(Value::as_bool).unwrap_or(false) {-        let changed = force_software_canvas(&info.version);-        return json!({-            "success": true, "forcedSoftwareCanvas": changed,-            "output": if changed { "forced Cairo software canvas (canvas_type=2)" } else { "software canvas already set / no config" },-            "_hint": "Close and reopen KiCad editors; they will render via Cairo (no OpenGL) so no GPU is needed.",-        });-    }-    if let Err(e) = find_windows(true, true) {-        return e;-    }-    if args.get("debug").and_then(Value::as_bool).unwrap_or(false) {-        let wins = scan_dialogs();-        let pids: Vec<u32> = native().processes(wm::KICAD_EXES).map(|p| p.iter().map(|p| p.pid).collect()).unwrap_or_default();-        return json!({-            "success": true, "debug": true, "kicadPids": pids,-            "windows": wins.iter().map(|w| json!({"title": w.title, "className": w.class_name, "owner": w.owner, "isDialog": true, "body": w.body.chars().take(80).collect::<String>(), "viaGlobalSweep": w.via_global_sweep})).collect::<Vec<_>>(),-            "kicadInfoHasBinDir": info.primary().map(|i| !i.bin_dir.is_empty()).unwrap_or(false),-        });-    }-    let all = args.get("all").and_then(Value::as_bool).unwrap_or(false);-    let screenshot = args.get("screenshot").and_then(Value::as_bool).unwrap_or(true);-    let res = sweep_dialogs(&info, all, screenshot);-    let n = res["dismissed"].as_array().map(|a| a.len()).unwrap_or(0);-    let remaining = res["remaining"].as_array().map(|a| a.len()).unwrap_or(0);-    let forced = res["forcedSoftwareCanvas"].as_bool().unwrap_or(false);-    json!({-        "success": true,-        "output": format!("dismissed {n} dialog(s), {remaining} remaining{}", if forced { "; forced Cairo software canvas (no GPU); close+reopen editors" } else { "" }),-        "dismissed": res["dismissed"], "remaining": res["remaining"], "forcedSoftwareCanvas": forced,-        "_hint": format!("Expired {n} dialog(s). Now retry your open/screenshot verb. Benign-only by default; pass {{\"all\":true}} to clear every dialog. The OpenGL notice on a GPU-less host is auto-expired by window_info too."),-    })-}--fn window_info(state: &mut State, args: &Value) -> Value {-    let info = state.kicad_info();-    if let Err(e) = find_windows(true, true) {-        return e;-    }-    let mut auto_dismissed = json!([]);-    let mut forced_sw = false;-    if args.get("autoDismiss").and_then(Value::as_bool).unwrap_or(true) {-        let s = sweep_dialogs(&info, false, false);-        auto_dismissed = s["dismissed"].clone();-        forced_sw = s["forcedSoftwareCanvas"].as_bool().unwrap_or(false);-    }-    invalidate();-    let (wins, source) = match find_windows(false, true) {-        Ok(x) => x,-        Err(e) => return e,-    };-    let mut editors = Vec::new();-    let mut dialogs = Vec::new();-    let mut manager = Value::Null;-    for w in &wins {-        let base = json!({"hwnd": w.hwnd, "title": w.title, "className": w.class_name, "rect": w.rect_json(), "z": w.z, "minimized": w.minimized, "maximized": w.maximized, "hung": w.hung, "kind": w.kind().as_str()});-        if w.owner != 0 || w.class_name == wm::DIALOG_CLASS {-            let mut d = base;-            d["ownerHwnd"] = json!(w.owner);-            d["body"] = json!(dialog_body_text(w.hwnd));-            dialogs.push(d);-        } else if wm::is_manager_title(&w.title) {-            manager = base;-        } else {-            editors.push(base);-        }-    }-    let mut data = json!({-        "projectManager": manager, "editors": editors, "modalDialogs": dialogs, "hasModalDialogs": !dialogs.is_empty(),-        "autoDismissed": auto_dismissed, "windowSource": source,-    });-    if forced_sw {-        data["forcedSoftwareCanvas"] = json!(true);-    }-    let n_auto = data["autoDismissed"].as_array().map(|a| a.len()).unwrap_or(0);-    if n_auto > 0 {-        data["_autoDismissHint"] = json!(format!("Auto-expired {n_auto} benign blocking dialog(s) (e.g. the OpenGL/software-rendering notice) so editors are not stalled; no action needed.{}", if forced_sw { " This host lacks OpenGL, so KiCad was switched to the Cairo software canvas (kicad_common.json canvas_type=2): CLOSE and REOPEN the editor for it to render without a GPU." } else { "" }));-    }-    if !dialogs.is_empty() {-        data["_dialogHint"] = json!("hasModalDialogs is true: KiCad has a BLOCKING modal dialog. Read each modalDialogs[].body for the message text; if body is empty, screenshot modalDialogs[].hwnd via desktop_screenshot_window. Dismiss with kicad_send_key {hwnd, key:'return'} (OK/default) or 'escape' (cancel).");-    }-    json!({"success": true, "output": format!("{} editor(s), {} dialog(s)", editors.len(), dialogs.len()), "data": data})-}--// ── Screenshots (ab) and the canvas probe (local) ─────────────────────────────--/// Uniformity verdict for the editor canvas: one local capture, sampled, discarded.-fn canvas_probe(hwnd: u64) -> Value {-    let Ok(cap) = native().capture_window(hwnd) else { return Value::Null };-    let rect = native().gl_canvas_rect(hwnd).ok().flatten().map(|(x, y, w, h)| (x, y, x + w, y + h));-    wm::canvas_uniformity(&cap.rgb, cap.width, cap.height, rect)-}--/// Capture a window by hwnd: ab takes the picture (owned popups, coordMap, auto-pull);-/// the canvas verdict is always ours.-pub(crate) fn screenshot_hwnd(hwnd: u64, label: &str, resize_max: Option<u32>) -> Value {-    let mut v = match ab::desktop_screenshot_window(hwnd, &format!("Capture the KiCad window {label} as evidence for the calling AI"), resize_max) {-        Ok(shot) => shot.to_json(),-        Err(e) => json!({"success": false, "error": e.message(), "abUnavailable": e.is_unavailable(), "_hint": "The native build takes every screenshot through ab's desktop_screenshot_window; with ab unreachable no image is written. Only the canvas probe (canvas.rendered) answers locally."}),-    };-    v["canvas"] = canvas_probe(hwnd);-    v-}--fn screenshot_all(state: &mut State) -> Value {-    let info = window_info(state, &json!({}));-    if info["success"] != json!(true) {-        return info;-    }-    let data = &info["data"];-    let mut windows: Vec<(u64, String, &str)> = Vec::new();-    for ed in data["editors"].as_array().into_iter().flatten() {-        windows.push((ed["hwnd"].as_u64().unwrap_or(0), ed["title"].as_str().unwrap_or("").into(), "editor"));-    }-    if let Some(h) = data["projectManager"]["hwnd"].as_u64() {-        windows.push((h, data["projectManager"]["title"].as_str().unwrap_or("").into(), "main"));-    }-    for d in data["modalDialogs"].as_array().into_iter().flatten() {-        windows.push((d["hwnd"].as_u64().unwrap_or(0), d["title"].as_str().unwrap_or("").into(), "dialog"));-    }-    if windows.is_empty() {-        return fail("no_windows", "No KiCad windows found.", "kicad_launch or an open_* verb first; kicad_window_info lists what is up.");-    }-    let mut shots = Vec::new();-    let mut ab_unavailable = false;-    for (hwnd, title, kind) in &windows {-        let label = wm::safe_label(title, kind);-        let r = screenshot_hwnd(*hwnd, &label, None);-        let mut entry = json!({"type": kind, "title": title, "hwnd": hwnd, "canvas": r["canvas"]});-        if r["success"] == json!(true) {-            for k in ["savedTo", "fullPath", "safePath", "localFullPath", "sizeKB", "capturedBy", "shotId", "coordMap", "ownedPopupCount", "popups", "screenshots"] {-                if let Some(v) = r.get(k) {-                    entry[k] = v.clone();-                }-            }-        } else {-            entry["error"] = json!(r["error"].as_str().unwrap_or("screenshot failed"));-            if r["abUnavailable"] == json!(true) {-                ab_unavailable = true;-            }-        }-        shots.push(entry);-    }-    let captured = shots.iter().filter(|s| s.get("fullPath").is_some()).count();-    let mut out = json!({-        "success": captured > 0 || !ab_unavailable,-        "output": format!("Captured {captured} of {} KiCad window(s)", shots.len()),-        "data": {"screenshots": shots},-        "_hint": "Each entry is a background PrintWindow capture by ab with its owned popups (popups[]) and a coordMap: click what you see with desktop_click {space:\"image\", shotId, x, y}. canvas.rendered false means the GL canvas has not painted yet (or there is no usable GPU).",-    });-    if ab_unavailable {-        out["abUnavailable"] = json!(true);-        out["errorCode"] = json!("ab_unavailable");-        out["_hint"] = json!("ab's direct API is unreachable, so no image was written (the native build has no local PNG path). ADOM_DIRECT_API_URL missing from the spawn env is the usual cause; ab's own desktop_screenshot_window from the CLI still works.");-    }-    out-}--// ── kicad_state ───────────────────────────────────────────────────────────────--pub(crate) fn b64_encode(bytes: &[u8]) -> String {-    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";-    let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4);-    for chunk in bytes.chunks(3) {-        let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];-        let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;-        out.push(T[(n >> 18) as usize & 63] as char);-        out.push(T[(n >> 12) as usize & 63] as char);-        out.push(if chunk.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });-        out.push(if chunk.len() > 2 { T[n as usize & 63] as char } else { '=' });-    }-    out-}--fn kicad_state(state: &mut State, args: &Value) -> Value {-    let info = state.kicad_info();-    let (wins, source) = match find_windows(false, true) {-        Ok(x) => x,-        Err(e) => return e,-    };-    let include_images = args.get("includeImages").and_then(Value::as_bool).unwrap_or(true);-    let max_width = args.get("maxWidth").and_then(Value::as_u64).unwrap_or(1100) as u32;-    let mut shots = Vec::new();-    let mut ab_unavailable = false;-    if include_images {-        for w in &wins {-            let mut entry = json!({"title": w.title, "hwnd": w.hwnd, "className": w.class_name});-            let r = screenshot_hwnd(w.hwnd, "state", Some(max_width));-            entry["canvas"] = r["canvas"].clone();-            if r["success"] == json!(true) {-                let path = r["safePath"].as_str().filter(|p| !p.is_empty()).or_else(|| r["fullPath"].as_str()).unwrap_or("");-                match std::fs::read(path) {-                    Ok(bytes) if path.to_lowercase().ends_with(".png") => {-                        entry["image"] = json!(b64_encode(&bytes));-                        entry["path"] = json!(path);-                        if let Some(c) = r.get("coordMap") {-                            if let (Some(w), Some(h)) = (c["image"]["w"].as_u64(), c["image"]["h"].as_u64()) {-                                entry["width"] = json!(w);-                                entry["height"] = json!(h);-                            }-                        }-                        if let Some(s) = r.get("shotId") { entry["shotId"] = s.clone(); }-                        if let Some(p) = r.get("popups") { entry["popups"] = p.clone(); }-                    }-                    Ok(_) => { entry["error"] = json!("capture is not a png"); entry["path"] = json!(path); }-                    Err(e) => { entry["error"] = json!(format!("could not read {path}: {e}")); }-                }-            } else {-                entry["error"] = json!(r["error"].as_str().unwrap_or("capture failed"));-                if r["abUnavailable"] == json!(true) {-                    ab_unavailable = true;-                }-            }-            shots.push(entry);-        }-    }-    let editors = open_editors();-    let modal: Vec<Value> = scan_dialogs().into_iter().map(|d| json!({"hwnd": d.hwnd, "title": d.title, "body": d.body})).collect();-    let loop_events = native().etiquette_events();-    let mut events: Vec<Value> = FOCUS_EVENTS.lock().map(|g| g.iter().cloned().collect()).unwrap_or_default();-    events.extend(loop_events);-    let events: Vec<Value> = events.into_iter().rev().take(40).rev().collect();-    let focus_debug = match native().etiquette_debug() {-        Value::Null => json!({"guardian": "no etiquette loop on this OS; one-shot check per verb only", "oneShotCheck": true}),-        v => v,-    };-    let mut out = json!({-        "success": true, "running": !wins.is_empty(), "focusEvents": events,-        // PRESENCE GATE (John, 2026-08-17): automation that spawns windows checks this-        // first and defers while the user is active (< 600 = touched in the last 10 min).-        "userIdleSeconds": user_idle_seconds(),-        "focusDebug": focus_debug,-        "activeVersion": info.version,-        "windows": wins.iter().map(|w| json!({"hwnd": w.hwnd, "title": w.title, "className": w.class_name, "rect": w.rect_json(), "z": w.z, "kind": w.kind().as_str()})).collect::<Vec<_>>(),-        "windowSource": source,-        "shots": shots,-        "editors": {"kicadRunning": editors["kicadRunning"], "list": editors["editors"], "summary": editors["summary"]},-        "modalDialogs": modal,-        "_hint": "focusEvents is the focus log: a self-raise (pushed-to-background) entry is a KiCad window that took the foreground and was pushed back once; user-foregrounded means the caller asked with a reason. The full state of the union: every KiCad-owned window is in shots[] as inline base64 PNG (render them all; one window is NOT the app). modalDialogs non-empty means something is blocking; editors.list is the window inventory by kind. Poll before/after an action to confirm it landed. includeImages:false for a cheap logical poll.",-    });-    if ab_unavailable {-        out["abUnavailable"] = json!(true);-    }-    out-}--// ── kicad_send_key / kicad_click / kicad_fix_keyboard ─────────────────────────--fn foreground_kicad_hwnd() -> Option<u64> {-    if let Ok(fg) = native().foreground() {-        if fg != 0 && is_kicad_hwnd(fg) {-            return Some(fg);-        }-    }-    kicad_windows().first().map(|w| w.hwnd)-}--fn send_key(args: &Value) -> Value {-    // `menu`: a menu-item label (substring, case-insensitive) fired as WM_COMMAND on the-    // target window's menu bar, fully background. The way the PCM dialog is opened.-    if let Some(needle) = arg_str(args, "menu").map(str::trim).filter(|s| !s.is_empty()) {-        let target = match arg_any_u64(args, &["hwnd"]) {-            Some(h) => h,-            None => match foreground_kicad_hwnd() {-                Some(h) => h,-                None => return fail("no_window", "No KiCad window found to send the menu command to.", "kicad_window_info lists the windows; pass hwnd."),-            },-        };-        let title = title_of(target);-        return match menu_invoke_by_text(target, needle) {-            Ok(Some((id, label))) => json!({-                "success": true, "mechanism": "menu",-                "output": format!("Menu item '{label}' fired on '{title}' (background)"),-                "data": {"method": "menu-command", "cmdId": id, "menuItem": label, "hwnd": target, "title": title, "foreground": false},-                "_hint": "Resolved from the window's menu bar by label and fired with WM_COMMAND; no focus was touched. A dialog it opens is a child of this window: kicad_screenshot_all captures it.",-            }),-            Ok(None) => fail("menu_item_not_found", &format!("No menu item on '{title}' contains '{needle}'."), "kicad_window_info lists windows; the label match is a case-insensitive substring of the item text without '&'."),-            Err(e) if is_not_impl(&e) => unsupported("native menu walk (menu_tree)"),-            Err(e) => fail("menu_invoke_failed", &e, "Retry once the window is idle; pass hwnd explicitly."),-        };-    }-    let Some(key) = arg_str(args, "key").map(str::trim) else {-        return fail("missing_arg", "Missing required arg: key", "Pass key: a named key (enter/escape/tab/space/f1-f12), a single character, or a chord like 'ctrl+s'.");-    };-    let target = match arg_any_u64(args, &["hwnd"]) {-        Some(h) => h,-        None => match foreground_kicad_hwnd() {-            Some(h) => h,-            None => return fail("no_window", "No KiCad window found to send key to.", "kicad_window_info lists the windows; pass hwnd."),-        },-    };-    let title = title_of(target);-    // NEVER steal the user's screen: show without activating and post to THIS hwnd's queue.-    let _ = native().show_no_activate(target);-    sleep_ms(50);-    let allow_fg = args.get("allowForeground").and_then(Value::as_bool).unwrap_or(false);-    if let Some((_mods, _base)) = wm::parse_modifier_combo(key) {-        // A CHORD usually IS a menu accelerator (v0.9.106): resolve it from the window's-        // menu bar and fire WM_COMMAND, fully background.-        match menu_invoke_by_accel(target, key) {-            Ok(Some((id, label))) => {-                return json!({-                    "success": true, "mechanism": "menu",-                    "output": format!("Chord '{key}' delivered as menu command '{label}' (background)"),-                    "data": {"method": "menu-command", "cmdId": id, "menuItem": label, "hwnd": target, "title": title, "foreground": false},-                    "_hint": "Resolved via the window's menu-bar accelerator table and fired with WM_COMMAND; no focus was touched. This is the preferred path for every chord that maps to a menu item.",-                });-            }-            Err(e) if is_not_impl(&e) => return unsupported("native menu walk (menu_tree)"),-            _ => {}-        }-        if allow_fg {-            return unsupported("kicad_send_key allowForeground (real keystrokes via SendInput)");-        }-        return json!({-            "success": false, "errorCode": "foreground_required",-            "error": format!("send the key chord '{key}' to KiCad needs the foreground, which would take over the user's screen: no menu item carries this accelerator, so only real (focused) keystrokes can deliver it"),-            "_hint": "Use ab's desktop_press_key (it owns focus-first chord delivery and asks the user), or find the menu item and call it by name. The native bridge never sends global keystrokes.",-        });-    }-    if allow_fg {-        return unsupported("kicad_send_key allowForeground (real keystrokes via SendInput)");-    }-    let (vk, ch) = match wm::vk_for_key(key) {-        Some(vk) => (vk, None),-        None => {-            let mut chars = key.chars();-            match (chars.next(), chars.next()) {-                (Some(c), None) => (wm::vk_for_char(c).unwrap_or(0), Some(c)),-                _ => return fail("unknown_key", format!("Unknown key: '{key}'. Use a named key (enter/escape/tab/space/f1-f12), a single character, or a modifier combo like 'alt+3', 'ctrl+s', 'ctrl+shift+s' (modifiers: ctrl/control, alt/menu, shift, win/super)."), "Chords resolve through the menu bar; anything else needs desktop_press_key."),-            }-        }-    };-    match native().post_key(target, vk, ch) {-        Ok(()) => json!({-            "success": true, "mechanism": "post",-            "output": format!("Posted '{key}' to '{title}' (background)"),-            "data": {"hwnd": target, "title": title, "method": "postmessage", "foreground": false, "vk": vk},-            "_hint": "Delivered without touching the user's focus. If KiCad ignored it, the control may need real input: prefer a menu command by name; only as a last resort ask the user and use desktop_press_key.",-        }),-        Err(e) if is_not_impl(&e) => unsupported("posted keys (post_key)"),-        Err(e) => fail("post_failed", format!("Could not post '{key}' to the window: {e}"), "kicad_window_info shows whether the hwnd is still valid."),-    }-}--fn click(args: &Value) -> Value {-    let Some(hwnd) = arg_any_u64(args, &["hwnd"]) else {-        return fail("missing_arg", "Missing required arg: hwnd", "kicad_window_info lists the windows; pass hwnd.");-    };-    let (Some(x), Some(y)) = (args.get("x").and_then(Value::as_f64), args.get("y").and_then(Value::as_f64)) else {-        return fail("missing_arg", "Missing required args: x, y", "Pass x and y as fractions of the window (relative:true, default) or pixels (relative:false).");-    };-    let relative = args.get("relative").and_then(Value::as_bool).unwrap_or(true);-    if relative && !(0.0..=1.0).contains(&x) || relative && !(0.0..=1.0).contains(&y) {-        return json!({-            "success": false, "errorCode": "bad_arg",-            "error": format!("relative=True expects x/y as fractions 0.0-1.0 of the window, but got x={x}, y={y}. Pass fractions, or set relative=false to use pixels."),-            "_hint": "e.g. center = {\"x\":0.5,\"y\":0.5}; toolbar button ~ {\"x\":0.35,\"y\":0.09}. For exact pixel coords add \"relative\": false.",-        });-    }-    if args.get("allowForeground").and_then(Value::as_bool).unwrap_or(false) {-        return unsupported("kicad_click allowForeground (a real cursor click via SendInput)");-    }-    let info = match native().window_info(hwnd) {-        Ok(Some(i)) => i,-        Ok(None) => return fail("bad_hwnd", format!("HWND {hwnd} is not a valid window."), "kicad_window_info lists the current handles."),-        Err(e) => return plat::<()>("window lookup (window_info)", Err(e)).err().unwrap(),-    };-    let (w, h) = (info.rect.2, info.rect.3);-    let _ = native().show_no_activate(hwnd);-    sleep_ms(50);-    let (cx, cy) = if relative { ((x * w as f64) as i32, (y * h as f64) as i32) } else { (x as i32, y as i32) };-    match native().post_click(hwnd, cx, cy, false) {-        Ok(()) => json!({-            "success": true, "mechanism": "post",-            "output": format!("Posted click at ({cx},{cy}) in '{}' (background)", info.title),-            "data": {"hwnd": hwnd, "title": info.title, "client": [cx, cy], "screen": [info.rect.0 + cx, info.rect.1 + cy], "method": "postmessage", "foreground": false, "relative": relative},-            "_hint": "Clicked without moving the user's mouse or taking focus. If the canvas ignored it, that control may need real input: ab's desktop_click {space:\"image\", shotId, x, y} on a kicad_screenshot_all coordMap asks the user first.",-        }),-        Err(e) if is_not_impl(&e) => unsupported("posted clicks (post_click)"),-        Err(e) => fail("post_failed", format!("Could not post the click to the window: {e}"), "kicad_window_info shows whether the hwnd is still valid."),-    }-}--fn fix_keyboard() -> Value {-    json!({-        "success": true, "stuckKeys": [], "released": false,-        "output": "Nothing to fix: the native bridge never sends global keystrokes (no SendInput/keybd_event path), so it can never leave a modifier key held. Keys and clicks are posted to one window's queue, which touches no global keyboard state.",-        "_hint": "If keys ARE stuck on this desktop, another tool held them: ab's desktop_press_key path or a release of each modifier through it clears the OS state.",-    })-}--// ── kicad_place_footprint ─────────────────────────────────────────────────────--const BLANK_BOARD: &str = include_str!("../../../../templates/blank-board.kicad_pcb");--fn pseudo_uuid() -> String {-    // Not cryptographic: a KiCad item id only has to be unique within the board.-    let mut x: u64 = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0x9E3779B97F4A7C15) ^ ((std::process::id() as u64) << 32);-    let mut bytes = [0u8; 16];-    for b in bytes.iter_mut() {-        x ^= x << 13;-        x ^= x >> 7;-        x ^= x << 17;-        *b = (x & 0xff) as u8;-    }-    bytes[6] = (bytes[6] & 0x0f) | 0x40;-    bytes[8] = (bytes[8] & 0x3f) | 0x80;-    let h: String = bytes.iter().map(|b| format!("{b:02x}")).collect();-    format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32])-}--/// Quote an unquoted top-level `(layer X)` token: `(layer F.Cu)` -> `(layer "F.Cu")`.-fn quote_first_layer(text: &str) -> String {-    let Some(i) = text.find("(layer ") else { return text.to_string() };-    let rest = &text[i + 7..];-    let trimmed = rest.trim_start();-    if trimmed.starts_with('"') {-        return text.to_string();-    }-    let end = trimmed.find(')').unwrap_or(0);-    let name = trimmed[..end].trim();-    if name.is_empty() || !name.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) {-        return text.to_string();-    }-    format!("{}(layer \"{}\"){}", &text[..i], name, &trimmed[end + 1..])-}--/// Inject `(uuid ...)` and `(at x y)` right after the footprint's first quoted `(layer "...")`.-fn place_in_board(fp_text: &str, uid: &str, x: f64, y: f64) -> Option<String> {-    let i = fp_text.find("(layer \"")?;-    let close = fp_text[i..].find(')')? + i + 1;-    Some(format!("{}\n\t\t(uuid \"{uid}\")\n\t\t(at {x} {y}){}", &fp_text[..close], &fp_text[close..]))-}--fn place_footprint(state: &mut State, args: &Value) -> Value {-    let info = state.kicad_info();-    if !info.installed {-        return fail("not_installed", "KiCad not installed", "Install KiCad from https://www.kicad.org/download/ then retry.");-    }-    let file_name = arg_str(args, "fileName").unwrap_or("");-    let Some(content) = arg_str(args, "fileContent") else {-        return fail("missing_arg", "No fileContent (base64 .kicad_mod) specified", "Pass fileContent: the base64 of the .kicad_mod text.");-    };-    let mut fp_text = match kicad_core::install::b64_decode(content) {-        Ok(b) => String::from_utf8_lossy(&b).trim().to_string(),-        Err(e) => return fail("bad_content", format!("could not decode fileContent: {e}"), "fileContent must be standard base64."),-    };-    // Legacy `(module ...)` footprints (KiCad <= 5 / EAGLE imports): rename the wrapper-    // and quote the top-level layer token the splice relies on.-    if fp_text.starts_with("(module") {-        fp_text = format!("(footprint{}", &fp_text["(module".len()..]);-        fp_text = quote_first_layer(&fp_text);-    }-    if !fp_text.starts_with("(footprint") {-        return fail("bad_content", "fileContent is not a .kicad_mod (must start with '(footprint' or '(module')", "Send the raw .kicad_mod text, base64-encoded.");-    }-    let fp_name = arg_str(args, "footprintName").map(str::to_string)-        .or_else(|| kicad_core::install::footprint_name_in(&fp_text))-        .or_else(|| Path::new(file_name).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()))-        .unwrap_or_else(|| "footprint".into());-    let x = args.get("x").and_then(Value::as_f64).unwrap_or(148.0);-    let y = args.get("y").and_then(Value::as_f64).unwrap_or(105.0);-    let uid = pseudo_uuid();-    let Some(fp_board) = place_in_board(&fp_text, &uid, x, y) else {-        return fail("bad_content", "footprint has no (layer \"...\") token to anchor the placement", "A .kicad_mod carries (layer \"F.Cu\") near the top; this one does not parse.");-    };-    let skel = BLANK_BOARD.trim_end();-    let idx = skel.rfind(')').unwrap_or(skel.len());-    let combined = format!("{}\n{}\n)\n", skel[..idx].trim_end(), fp_board);-    let user_dir = info.primary().and_then(|i| native().user_dir(&i.version)).or_else(|| std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")).map(PathBuf::from)).unwrap_or_else(|| PathBuf::from("."));-    let _ = std::fs::create_dir_all(&user_dir);-    let out_path = user_dir.join(format!("adom-place-{fp_name}.kicad_pcb"));-    if let Err(e) = std::fs::write(&out_path, combined) {-        return fail("write_failed", format!("could not write board: {e}"), "The preview board goes to Documents/KiCad/<version>; check it is writable.");-    }-    let board_path = norm(&out_path);-    let open_it = args.get("open").and_then(Value::as_bool).unwrap_or(true);-    let mut opened = false;-    let mut extra = Map::new();-    if open_it {-        match kicad_exe(&info, "pcbnew") {-            Some(exe) => match spawn_background(&exe, &[board_path.clone()], "pcbnew", None) {-                Ok((pid, _)) => {-                    opened = true;-                    extra.insert("pid".into(), json!(pid));-                    // Verify the frame by title with one deadline; the sweep runs after.-                    let stem = format!("adom-place-{fp_name}").to_lowercase();-                    let deadline = Instant::now() + Duration::from_secs(20);-                    while Instant::now() < deadline {-                        invalidate();-                        if let Some(w) = windows_of_kind(WindowKind::PcbEditor).into_iter().find(|w| w.title.to_lowercase().contains(&stem) || w.pid == pid) {-                            extra.insert("hwnd".into(), json!(w.hwnd));-                            extra.insert("title".into(), json!(w.title));-                            break;-                        }-                        sleep_ms(500);-                    }-                }-                Err(e) => {-                    if e["errorCode"] == json!("not_supported_on_platform") {-                        return e;-                    }-                    return json!({"success": true, "boardPath": board_path, "footprintName": fp_name, "placedAt": [x, y], "opened": false, "output": format!("Board written but pcbnew launch failed: {}", e["error"].as_str().unwrap_or("")), "_hint": format!("Open it manually: {board_path}")});-                }-            },-            None => {}-        }-    }-    let mut v = json!({-        "success": true, "boardPath": board_path, "footprintName": fp_name, "placedAt": [x, y], "opened": opened, "mechanism": if opened { "spawn" } else { "file" },-        "output": format!("Placed footprint '{fp_name}' at ({x}, {y}) mm on a preview board{}", if opened { " and opened it in pcbnew" } else { "" }),-    });-    merge(&mut v, Value::Object(extra));-    v-}--// ── kicad_enable_software_opengl ──────────────────────────────────────────────--const MESA_URL: &str = "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.60/mesa-llvmpipe-x64.zip";-const MESA_DLLS: &[&str] = &["opengl32.dll", "libgallium_wgl.dll", "dxil.dll"];-const MESA_EXES: &[&str] = &["kicad.exe", "pcbnew.exe", "eeschema.exe", "gerbview.exe", "pl_editor.exe", "bitmap2component.exe", "pcb_calculator.exe"];--fn enable_software_opengl(state: &mut State) -> Value {-    if native().capabilities().os != "windows" {-        return json!({"success": false, "error": "Windows-only", "errorCode": "unsupported_platform", "_hint": "Mesa llvmpipe DotLocal redirection only applies to KiCad's Windows build."});-    }-    let info = state.kicad_info();-    let mut bins: Vec<PathBuf> = Vec::new();-    for i in &info.installs {-        let mut b = PathBuf::from(if i.bin_dir.is_empty() { &i.base_dir } else { &i.bin_dir });-        if b.file_name().map(|n| n.to_string_lossy().to_lowercase() != "bin").unwrap_or(true) {-            b = b.join("bin");-        }-        if b.is_dir() && !bins.contains(&b) {-            bins.push(b);-        }-    }-    if bins.is_empty() {-        return json!({"success": false, "error": "No KiCad bin dir found; is KiCad installed?", "errorCode": "host_app_not_installed", "errorCodeLegacy": "kicad_not_installed", "hostApp": "KiCad", "_hint": "Run kicad_list_versions. Install KiCad first via kicad_upgrade."});-    }-    eprintln!("[kicad-bridge] enable_software_opengl: downloading Mesa llvmpipe...");-    let data = match pcm::http_fetch(MESA_URL, Duration::from_secs(180)) {-        Ok(d) => d,-        Err(e) => return json!({"success": false, "error": format!("Mesa download/unzip failed: {e}"), "errorCode": "download_failed"}),-    };-    let zip = match pcm::ZipFile::parse(&data) {-        Ok(z) => z,-        Err(e) => return json!({"success": false, "error": format!("Mesa download/unzip failed: {e}"), "errorCode": "download_failed"}),-    };-    let mut enabled = Vec::new();-    let mut errors = Vec::new();-    for bd in &bins {-        let mut ok = true;-        for dll in MESA_DLLS {-            let Some(entry) = zip.entries.iter().find(|e| e.name.rsplit('/').next() == Some(dll)) else {-                errors.push(format!("{}: {dll} missing from the Mesa zip", norm(bd)));-                ok = false;-                break;-            };-            let bytes = match zip.read(entry) {-                Ok(b) => b,-                Err(e) => { errors.push(format!("{}: {dll}: {e}", norm(bd))); ok = false; break; }-            };-            if let Err(e) = std::fs::write(bd.join(dll), bytes) {-                errors.push(if e.kind() == std::io::ErrorKind::PermissionDenied { format!("{}: permission denied (writing KiCad's bin needs elevation)", norm(bd)) } else { format!("{}: {e}", norm(bd)) });-                ok = false;-                break;-            }-        }-        if !ok {-            continue;-        }-        for exe in MESA_EXES {-            if bd.join(exe).exists() {-                let _ = std::fs::write(bd.join(format!("{exe}.local")), "");-            }-        }-        enabled.push(norm(bd));-        eprintln!("[kicad-bridge] enable_software_opengl: deployed to {}", norm(bd));-    }-    let ok = !enabled.is_empty();-    json!({-        "success": ok, "enabledIn": enabled, "errors": errors, "renderer": "Mesa llvmpipe (CPU software OpenGL)",-        "_hint": if ok {-            "LAST-RESORT software-OpenGL fallback deployed: KiCad will render via Mesa llvmpipe on the CPU (SLOW). This is ONLY for a GPU-less host (Hyper-V/RDP/headless) where KiCad cannot render at all; do NOT enable it on a normal machine. CLOSE and REOPEN KiCad editors to load it. To undo, delete opengl32.dll/libgallium_wgl.dll/dxil.dll and the *.local files from KiCad's bin."-        } else {-            "Could not deploy; see errors[]. Writing KiCad's bin (Program Files) needs an elevated Bridge; a per-user KiCad install (kicad_upgrade scope=user) avoids that."-        },-    })-}--#[cfg(test)]-mod tests {-    use super::*;--    #[test]-    fn footprint_splice_matches_python() {-        let fp = "(footprint \"X\"\n\t(version 20240108)\n\t(layer \"F.Cu\")\n\t(pad \"1\" smd rect)\n)";-        let placed = place_in_board(fp, "u-u-i-d", 148.0, 105.0).unwrap();-        assert!(placed.contains("(layer \"F.Cu\")\n\t\t(uuid \"u-u-i-d\")\n\t\t(at 148 105)"));-        let legacy = "(module X (layer F.Cu) (pad 1 smd rect))";-        let modern = format!("(footprint{}", &legacy["(module".len()..]);-        let q = quote_first_layer(&modern);-        assert!(q.starts_with("(footprint X (layer \"F.Cu\")"));-        assert_eq!(quote_first_layer("(footprint X (layer \"B.Cu\"))"), "(footprint X (layer \"B.Cu\"))");-        assert!(place_in_board("(footprint X)", "u", 1.0, 2.0).is_none());-        let u = pseudo_uuid();-        assert_eq!(u.len(), 36);-        assert_eq!(&u[14..15], "4");-    }--    #[test]-    fn base64_round_trip() {-        assert_eq!(b64_encode(b""), "");-        assert_eq!(b64_encode(b"f"), "Zg==");-        assert_eq!(b64_encode(b"fo"), "Zm8=");-        assert_eq!(b64_encode(b"foo"), "Zm9v");-        assert_eq!(b64_encode(b"foobar"), "Zm9vYmFy");-        let back = kicad_core::install::b64_decode(&b64_encode(b"\x89PNG\r\n\x1a\n")).unwrap();-        assert_eq!(back, b"\x89PNG\r\n\x1a\n");-    }--    #[test]-    fn stamp_and_foreground_args() {-        let s = utc_stamp();-        assert_eq!(s.len(), 15);-        assert_eq!(&s[8..9], "-");-        assert!(user_wants_foreground(&json!({"foreground": true})));-        assert!(user_wants_foreground(&json!({"showToUser": true})));-        assert!(user_wants_foreground(&json!({"bringToFront": true})));-        assert!(!user_wants_foreground(&json!({"foreground": false, "reason": "x"})));-        assert_eq!(arg_any_u64(&json!({"pcbHwnd": "123"}), &["pcbHwnd", "pcb_hwnd"]), Some(123));-        assert_eq!(arg_any_u64(&json!({"pcb_hwnd": 0}), &["pcbHwnd", "pcb_hwnd"]), None);-    }--    #[test]-    fn dispatch_claims_only_its_verbs_and_is_honest_off_windows() {-        let mut state = State::new();-        assert!(dispatch(&mut state, "kicad_errors", &json!({})).is_none());-        assert!(dispatch(&mut state, "kicad_run_drc", &json!({})).is_none());-        let fk = dispatch(&mut state, "kicad_fix_keyboard", &json!({})).unwrap();-        assert_eq!(fk["success"], json!(true));-        assert_eq!(fk["stuckKeys"], json!([]));-        // Without window control (this host) the window verbs answer not_supported_on_platform-        // or an ab-unavailable error, never a hang and never a fake success.-        if !native().capabilities().window_control {-            std::env::remove_var("ADOM_DIRECT_API_URL");-            let r = dispatch(&mut state, "kicad_window_info", &json!({})).unwrap();-            assert_eq!(r["success"], json!(false));-            let code = r["errorCode"].as_str().unwrap_or("");-            assert!(code == "not_supported_on_platform" || code == "ab_unavailable", "{r}");-            let r = dispatch(&mut state, "kicad_open_editors", &json!({})).unwrap();-            assert_eq!(r["success"], json!(false));-            let r = dispatch(&mut state, "kicad_send_key", &json!({"key": "escape", "hwnd": 5})).unwrap();-            assert_eq!(r["success"], json!(false));-        }-        let r = dispatch(&mut state, "kicad_click", &json!({"hwnd": 5, "x": 300, "y": 2})).unwrap();-        assert_eq!(r["errorCode"], json!("bad_arg"));-        let r = dispatch(&mut state, "kicad_send_key", &json!({})).unwrap();-        assert_eq!(r["errorCode"], json!("missing_arg"));-        let r = dispatch(&mut state, "kicad_open_3d_viewer", &json!({"editor": "sideways"})).unwrap();-        assert_eq!(r["errorCode"], json!("bad_arg"));-    }--    #[test]-    fn catalog_covers_the_python_window_group() {-        let names: Vec<&str> = VERBS.iter().map(|v| v.name).collect();-        for n in ["kicad_launch", "kicad_open_board", "kicad_open_schematic", "kicad_open_symbol_editor", "kicad_open_footprint_editor", "kicad_open_3d_viewer", "kicad_open_editors", "kicad_close", "kicad_close_symbol_editor", "kicad_close_footprint_editor", "kicad_close_3d_viewer", "kicad_dismiss_dialogs", "kicad_window_info", "kicad_state", "kicad_screenshot_all", "kicad_send_key", "kicad_click", "kicad_fix_keyboard", "kicad_place_footprint", "kicad_enable_software_opengl"] {-            assert!(names.contains(&n), "{n} missing from the catalog");-        }-        assert!(!names.contains(&"kicad_errors"));-        for v in VERBS {-            assert!(!v.summary.contains('\u{2014}') && !v.hint.contains('\u{2014}'), "{} carries an em dash", v.name);-        }-    }-}---/// kicad_rescan_libraries: tree refresh first (#51), restart only on force plus confirmation.-fn rescan_libraries(state: &mut State, args: &Value) -> Value {-    let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);-    if !force {-        let mut refreshed: Vec<u64> = Vec::new();-        for kind in [WindowKind::FootprintEditor, WindowKind::SymbolEditor] {-            if let Some(h) = first_of_kind(kind) {-                if let Ok(Some(_)) = menu_invoke_by_text(h, "refresh") {-                    refreshed.push(h);-                }-            }-        }-        if !refreshed.is_empty() {-            // The tree reloads asynchronously; the Python waited 3 s before probing.-            std::thread::sleep(std::time::Duration::from_secs(3));-            invalidate();-            let mut wanted: Vec<(String, String)> = Vec::new();-            for spec in args.get("footprints").and_then(Value::as_array).cloned().unwrap_or_default() {-                match spec {-                    Value::String(s) if s.contains(':') => {-                        let (lib, name) = s.split_once(':').unwrap();-                        wanted.push((lib.to_string(), name.to_string()));-                    }-                    Value::Object(o) => {-                        if let Some(name) = o.get("name").and_then(Value::as_str) {-                            wanted.push((o.get("library").and_then(Value::as_str).unwrap_or("Adom").to_string(), name.to_string()));-                        }-                    }-                    _ => {}-                }-            }-            let mut probed: Vec<Value> = Vec::new();-            if !wanted.is_empty() {-                let fp = first_of_kind(WindowKind::FootprintEditor);-                for (lib, name) in &wanted {-                    let (ok, trace, _mech) = match fp {-                        Some(h) => navigate_tree(h, &format!("{lib} {name}"), Verify::Footprint { name, library: lib }),-                        None => (false, json!({}), "none"),-                    };-                    probed.push(json!({-                        "library": lib, "name": name, "visible": ok,-                        "error": if ok { Value::Null } else if fp.is_none() { json!("no Footprint Editor open") } else { json!("editor tree did not load it") },-                        "navigation": trace,-                    }));-                }-            }-            let verified = !wanted.is_empty() && probed.iter().all(|p| p["visible"].as_bool().unwrap_or(false));-            let mut out = json!({"success": true, "method": "tree-refresh", "refreshedEditors": refreshed, "verified": verified, "probed": probed});-            out["_hint"] = json!(if !wanted.is_empty() && !verified {-                format!("Tree refreshed but KiCad still cannot load: {}. Check the name with kicad_list_footprints; if it is on disk, call again with force:true and confirmRestart:true for the process restart (about a minute).",-                    probed.iter().filter(|p| !p["visible"].as_bool().unwrap_or(false)).map(|p| format!("{}:{}", p["library"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", "))-            } else if !wanted.is_empty() {-                "Verified in the editor: every listed footprint loaded after the refresh, no restart. The last one is now shown in the Footprint Editor.".to_string()-            } else {-                "Refresh invoked on the open editor tree(s). Pass footprints:[\"Lib:Name\"] to have each loaded in the editor as proof; without that list nothing is verified. force:true is the process restart, the last resort.".to_string()-            });-            post_verb(state, "kicad_rescan_libraries", args, &mut out, None);-            return out;-        }-    }-    // Restart path: the native build has no ownership ledger yet (phase 4), so a running-    // KiCad is treated as the user's unless the caller confirms.-    let running = native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).unwrap_or_default();-    let confirm = args.get("confirmRestart").and_then(Value::as_bool).unwrap_or(false);-    if !running.is_empty() && !confirm {-        return json!({"success": false, "errorCode": "rescan_refused",-            "reason": "KiCad is running and the native build cannot yet tell a user-launched KiCad from its own",-            "runningPids": running.iter().map(|p| p.pid).collect::<Vec<_>>(),-            "_hint": "Ask the user whether KiCad may be restarted, then call again with force:true and confirmRestart:true. Without force the verb refreshes the open editors' trees in place, which is enough for footprints installed after the editor started."});-    }-    // Wiki #48: a fresh process places every window on the primary display, so the user's-    // monitor choice is captured here, while the windows still exist, and restored below.-    let remembered = remember_placement();-    let mut close_how = "nothing was running";-    if !running.is_empty() {-        // Wiki #48 ask 3: graceful first, so KiCad writes its own geometry and its own files;-        // the kill is the fallback for a window that will not go.-        let mut closed = dispatch(state, "kicad_close", &json!({})).unwrap_or_else(|| json!({}));-        close_how = "graceful";-        if !closed["success"].as_bool().unwrap_or(false) {-            if closed["errorCode"] == json!("unsaved_changes") {-                return json!({"success": false, "errorCode": "restart_close_failed", "close": closed,-                    "_hint": "KiCad is asking whether to save the user's work, so nothing was closed or killed. Ask them, then save (or call kicad_close {\"discardChanges\": true}) before restarting."});-            }-            closed = dispatch(state, "kicad_close", &json!({"force": true})).unwrap_or_else(|| json!({}));-            close_how = "force-kill after the graceful close failed";-            if !closed["success"].as_bool().unwrap_or(false) {-                return json!({"success": false, "errorCode": "restart_close_failed", "close": closed, "closeHow": close_how});-            }-        }-        std::thread::sleep(std::time::Duration::from_secs(4));-    }-    let launched = launch(state, &json!({}));-    if !launched["success"].as_bool().unwrap_or(false) {-        return json!({"success": false, "errorCode": "restart_launch_failed", "launch": launched});-    }-    for _ in 0..80 {-        std::thread::sleep(std::time::Duration::from_millis(750));-        invalidate();-        if !kicad_windows().is_empty() {-            sleep_ms(1200); // let the fresh windows settle before moving them-            let restored = restore_placement();-            let moved = restored.iter().filter(|r| r["moved"] == json!(true)).count();-            return json!({"success": true, "restarted": true, "ready": true, "method": "process-restart",-                "placementRemembered": remembered, "placementRestored": restored, "closeHow": close_how,-                "_hint": format!("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. Placement: {moved} window(s) put back where the user had them (wiki #48), without activating anything.")});-        }-    }-    json!({"success": false, "restarted": true, "ready": false, "errorCode": "restart_no_window",-        "_hint": "KiCad was restarted but no window appeared within 60 s; kicad_window_info shows what is up."})-}+        hint: "Every action is a View-menu command fired on the 3D Viewer window by WM_COMMAND: no focus, no cursor, works with the viewer in the background (a window recording of it still sees every frame). count repeats the action with pauseMs between, so a slow orbit is one call. action:\"tour\" runs the default walkthrough (fit, top, tilt, orbit, zoom in, swing, zoom out, front, bottom, flip, top, fit) or your own steps list. Open the viewer first with kicad_open_3d_viewer. pan_left, pan_right, pan_up and pan_down discover Move Board menu commands at runtime. Reference framing is not yet available.",+        related: &["kicad_open_3d_viewer", "kicad_send_key", "kicad_screenshot_all", "kicad_model_check"],+        pitfalls: &["the viewer must be open (kicad_open_3d_viewer)", "a huge board takes seconds per redraw; keep pauseMs at 500 or more"],+    },+    Verb {+        name: "kicad_open_3d_viewer",+        summary: "Open the 3D board viewer for a .kicad_pcb.",+        mechanism: Mechanism::Window, risk: "process", timeout_sec: 300,+        input: "{\"editor\"?: \"auto\"|\"fp\"|\"pcb\", \"pcbHwnd\"?: 123, \"fpHwnd\"?: 123, \"foreground\"?: bool, \"foregroundReason\"?: \"...\"}",+        example: "kicad_open_3d_viewer {\"editor\":\"pcb\"}",+        hint: "Opens the 3D viewer from an open PCB Editor (or Footprint Editor) through its View menu, in the background. Follow with screenshot_all to capture it.",+        related: &["kicad_open_board", "kicad_screenshot_all", "kicad_close_3d_viewer"],+        pitfalls: &["3D models only render if the footprints reference installed 3D model files", "the viewer takes a few seconds to render; wait before screenshotting"],+    },+    Verb {+        name: "kicad_open_editors",+        summary: "List currently open KiCad editor windows.",+        mechanism: Mechanism::Window, risk: "read", timeout_sec: 30,+        input: "{}",+        example: "kicad_open_editors {}",+        hint: "Read-only INVENTORY (despite the name): lists editors inside a running KiCad. It never launches anything; to cold-start KiCad use kicad_launch.",+        related: &["kicad_launch", "kicad_window_info", "kicad_screenshot_all", "kicad_close"],+        pitfalls: &["with KiCad closed it succeeds with 0 editors; that is not an error, kicad_launch is the verb that starts KiCad"],+    },+    Verb {+        name: "kicad_close",+        summary: "Close KiCad (editors first, then the project manager).",+        mechanism: Mechanism::Window, risk: "process", timeout_sec: 130,+        input: "{\"force\"?: bool, \"discardChanges\"?: bool}",+        example: "kicad_close {}",+        hint: "Closes KiCad gracefully (WM_CLOSE). A 'Save changes?' prompt is read and reported as errorCode unsaved_changes; discardChanges:true presses Discard for the caller. force:true is the ONLY path that kills a process. Read the kicad-interaction skill before blind-closing.",+        related: &["kicad_open_editors", "kicad_window_info"],+        pitfalls: &["closing with unsaved work triggers a save dialog; handle it, do not blind-close", "force:true discards silently"],+    },+    Verb {+        name: "kicad_close_symbol_editor",+        summary: "Close the Symbol Editor window.",+        mechanism: Mechanism::Window, risk: "process", timeout_sec: 60,+        input: "{}", example: "kicad_close_symbol_editor {}",+        hint: "Closes the Symbol Editor if open; no-op otherwise.",+        related: &["kicad_open_symbol_editor", "kicad_close"],+        pitfalls: &["unsaved edits may prompt; save first if you made changes"],+    },+    Verb {+        name: "kicad_close_footprint_editor",+        summary: "Close the Footprint Editor window.",+        mechanism: Mechanism::Window, risk: "process", timeout_sec: 60,+        input: "{}", example: "kicad_close_footprint_editor {}",+        hint: "Closes the Footprint Editor if open; no-op otherwise.",+        related: &["kicad_open_footprint_editor", "kicad_close"],+        pitfalls: &["unsaved edits may prompt; save first"],+    },+    Verb {+        name: "kicad_close_3d_viewer",+        summary: "Close the 3D viewer window.",+        mechanism: Mechanism::Window, risk: "process", timeout_sec: 60,+        input: "{}", example: "kicad_close_3d_viewer {}",+        hint: "Closes the 3D viewer if open; no-op otherwise.",+        related: &["kicad_open_3d_viewer", "kicad_close"],+        pitfalls: &[],+    },+    Verb {+        name: "kicad_dismiss_dialogs",+        summary: "Programmatically expire KiCad dialogs (auto-clears the benign OpenGL/software-render notice; {all:true} clears every dialog).",+        mechanism: Mechanism::Window, risk: "input", timeout_sec: 60,+        input: "{\"all\"?: bool, \"screenshot\"?: true, \"forceSoftwareCanvas\"?: bool, \"debug\"?: bool}",+        example: "kicad_dismiss_dialogs {\"all\":true}",+        hint: "Read-only-ish self-heal: clicks each dialog's OK/default button. window_info also auto-expires benign dialogs; use this to force-clear a specific/all dialogs before retrying an open/screenshot.",+        related: &["kicad_window_info", "kicad_screenshot_all", "kicad_open_board"],+        pitfalls: &["benign-only by default; pass {\"all\":true} to also clear real dialogs you have already read", "a NON-benign dialog left up may indicate a real error; read its body first", "progress dialogs (Load PCB, Loading Symbol Libraries) are never dismissed: their one button is Cancel"],+    },+    Verb {+        name: "kicad_window_info",+        summary: "Report geometry/state for open KiCad windows (auto-expires benign OpenGL dialogs).",+        mechanism: Mechanism::Window, risk: "read", timeout_sec: 60,+        input: "{\"autoDismiss\"?: true}",+        example: "kicad_window_info {}",+        hint: "Read-only; returns each KiCad window's position/size/state. Use before click/send_key to target the right window.",+        related: &["kicad_open_editors", "kicad_click", "kicad_send_key"],+        pitfalls: &["coordinates are screen-absolute; re-read after a window moves/resizes"],+    },+    Verb {+        name: "kicad_state",+        summary: "KiCad's full state of the union: every owned window as inline base64 PNG + logical state.",+        mechanism: Mechanism::Window, risk: "read", timeout_sec: 130,+        input: "{\"includeImages\"?: true, \"maxWidth\"?: 1100}",+        example: "kicad_state {\"includeImages\":false}",+        hint: "Read-only, non-gated. shots[] = base64 screenshot of EVERY KiCad window (render all of them; one window is not the app); plus editors inventory, modal dialogs, running flag. Poll before/after an action to confirm it landed. includeImages:false for a cheap logical poll; maxWidth caps image size.",+        related: &["kicad_screenshot_all", "kicad_open_editors", "kicad_window_info"],+        pitfalls: &["shots are inline base64; the response is large, use includeImages:false when you only need the logical state"],+    },+    Verb {+        name: "kicad_screenshot_all",+        summary: "Screenshot every open KiCad window.",+        mechanism: Mechanism::Window, risk: "read", timeout_sec: 130,+        input: "{}",+        example: "kicad_screenshot_all {}",+        hint: "Captures all KiCad windows through ab's desktop_screenshot_window (background, owned popups included, coordMap per image); the go-to way to VERIFY what actually happened after an open/edit. canvas.rendered says whether the GL canvas actually painted.",+        related: &["kicad_open_editors", "kicad_window_info", "kicad_open_board"],+        pitfalls: &["a window still rendering (e.g. 3D viewer) shows blank; wait a beat first", "with ab unreachable no image is written (abUnavailable:true); only the canvas probe answers"],+    },+    Verb {+        name: "kicad_send_key",+        summary: "Send a key to KiCad, or fire a menu item by label; chords and menu labels resolve to menu commands (background)",+        mechanism: Mechanism::Window, risk: "input", timeout_sec: 30,+        input: "{\"key\"?: \"enter\"|\"escape\"|\"a\"|\"ctrl+s\", \"menu\"?: \"Plugin and Content Manager\", \"hwnd\"?: 123}",+        example: "kicad_send_key {\"hwnd\":123456,\"key\":\"escape\"}",+        hint: "menu: a label substring fired as a menu command on the target window without touching focus (e.g. menu:\"Plugin and Content Manager\" on the project manager opens the PCM dialog; the dialog is a child, kicad_screenshot_all captures it). For modifier CHORDS ('ctrl+s', 'alt+3', 'ctrl+shift+p') use Bridge's desktop_press_key; it owns full chord parsing; do not route chords here. Use this verb only for the case Bridge's focus-first model cannot cover: a single key delivered to an EXACT hwnd (e.g. Enter/Escape to a specific dialog from window_info).",+        related: &["kicad_window_info", "kicad_click", "kicad_fix_keyboard"],+        pitfalls: &["not for chords; use desktop_press_key", "needs a target hwnd (from window_info)", "allowForeground (real keystrokes) is not in the native build"],+    },+    Verb {+        name: "kicad_click",+        summary: "Click at coordinates inside a KiCad window.",+        mechanism: Mechanism::Window, risk: "input", timeout_sec: 30,+        input: "{\"hwnd\": 123, \"x\": 0.5, \"y\": 0.5, \"relative\"?: true}",+        example: "kicad_click {\"hwnd\":123456,\"x\":0.5,\"y\":0.5}",+        hint: "Posts the click to the window's own queue (background: the user's mouse never moves); read window_info + screenshot_all first to compute coordinates. relative:true (default) takes fractions 0.0-1.0 of the window.",+        related: &["kicad_window_info", "kicad_screenshot_all", "kicad_send_key"],+        pitfalls: &["coordinates are window-relative and break if the window resized; re-read window_info", "allowForeground (a real cursor click) is not in the native build"],+    },+    Verb {+        name: "kicad_fix_keyboard",+        summary: "Reset stuck modifier keys on a KiCad window.",+        mechanism: Mechanism::Local, risk: "read", timeout_sec: 10,+        input: "{}", example: "kicad_fix_keyboard {}",+        hint: "Clears stuck Ctrl/Shift/Alt state after send_key sequences misbehave. The native build never sends global keystrokes, so there is never anything to fix.",+        related: &["kicad_send_key"],+        pitfalls: &[],+    },+    Verb {+        name: "kicad_place_footprint",+        summary: "Deterministically place a footprint into a board preview.",+        mechanism: Mechanism::Window, risk: "write", timeout_sec: 130,+        input: "{\"fileName\": \"X.kicad_mod\", \"fileContent\": \"<base64>\", \"footprintName\"?: \"X\", \"x\"?: 148, \"y\"?: 105, \"open\"?: true}",+        example: "kicad_place_footprint {\"fileName\":\"BMI423.kicad_mod\",\"fileContent\":\"KGZvb3RwcmludCAu...\"}",+        hint: "Splices a footprint into a fresh preview board via s-expr (no GUI edit) and opens it in pcbnew in the background; good for previews.",+        related: &["kicad_open_board", "kicad_install_footprint"],+        pitfalls: &["operates on the file; close the board in KiCad first or the edit races the GUI"],+    },+    Verb {+        name: "kicad_enable_software_opengl",+        summary: "LAST-RESORT fallback: deploy Mesa llvmpipe software OpenGL into KiCad's bin so it renders on a GPU-less host (Hyper-V/RDP/headless). SLOW (CPU). Never suggest proactively.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 240,+        input: "{}", example: "kicad_enable_software_opengl {}",+        hint: "Only when KiCad cannot render at all AND there is no GPU. Drops Mesa opengl32/libgallium DLLs + .local redirection into KiCad bin (needs elevation to write Program Files, or use a per-user install). Close+reopen editors after.",+        related: &["kicad_dismiss_dialogs", "kicad_upgrade"],+        pitfalls: &["a real GPU / Hyper-V GPU-P passthrough is far better; this is CPU rendering, slow", "writing KiCad's bin needs an elevated Bridge"],+    },+    Verb {+        name: "kicad_rescan_libraries",+        summary: "Make newly installed parts visible ONCE: reload every open editor's library tree in place (seconds, nothing closed), then verify each listed footprint by loading it in the Footprint Editor. force:true is the process restart, the last resort.",+        mechanism: Mechanism::Window, risk: "write", timeout_sec: 200,+        input: "{\"footprints\"?: [\"Lib:Name\", ...], \"force\"?: bool, \"confirmRestart\"?: bool}",+        example: "kicad_rescan_libraries {\"footprints\":[\"Adom:LQFP-48_STM32G431\"]}",+        hint: "Wiki #51: install N parts, call this once, then show them all. A restart closes KiCad gracefully first (so it saves its own geometry) and puts every window back on the monitor the user had it on (wiki #48; closeHow and placementRestored report both). Pass footprints so the reply can say what KiCad can load now; without the list nothing is verified. A restart (force) refuses while any KiCad runs unless confirmRestart:true, because the native build cannot yet tell a user-launched KiCad from its own (the ownership ledger arrives with phase 4).",+        related: &["kicad_install_footprint", "kicad_show_footprint", "kicad_list_footprints"],+        pitfalls: &["KiCad 10 caches footprint libraries per process; the tree refresh reveals parts installed after the editor started (measured 2026-09-07), a full restart is only for the cases it does not."],+    },+];++/// Verbs that may spawn or raise a KiCad window: they get the 5-round settle sweep and+/// the foreground etiquette (server.py `_SPAWNS_WINDOW`, this group's members).+const SPAWNS_WINDOW: &[&str] = &["kicad_launch", "kicad_open_board", "kicad_open_schematic", "kicad_open_symbol_editor", "kicad_open_footprint_editor", "kicad_open_3d_viewer", "kicad_place_footprint"];++/// Verbs that touch no window at all (no sweep, no focus check).+const NO_GUI: &[&str] = &["kicad_fix_keyboard", "kicad_enable_software_opengl"];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    if !VERBS.iter().any(|v| v.name == command) {+        return None;+    }+    // Caller identity, so every ab callback made while serving this verb is attributed+    // "<thread> (via kicad)". ab relays it as args.caller; headers are the fallback.+    ab::set_caller_from_args(args);+    let gui = !NO_GUI.contains(&command);+    let fg_before = if gui { native().foreground().ok().filter(|h| *h != 0) } else { None };+    if gui {+        // Phase 4: arm the guard and snapshot the park baseline for the etiquette loop.+        native().etiquette_begin_verb(command, SPAWNS_WINDOW.contains(&command));+    }+    let mut out = match command {+        "kicad_launch" => launch(state, args),+        "kicad_rescan_libraries" => rescan_libraries(state, args),+        "kicad_open_board" => open_file(state, args, WindowKind::PcbEditor),+        "kicad_open_schematic" => open_file(state, args, WindowKind::SchematicEditor),+        "kicad_open_symbol_editor" => open_symbol_editor(state, args),+        "kicad_open_footprint_editor" => open_footprint_editor(state, args),+        "kicad_open_3d_viewer" => open_3d_viewer(args),+        "kicad_3d_view" => view_3d(args),+        "kicad_menu_items" => menu_items(args),+        "kicad_open_editors" => open_editors(),+        "kicad_close" => close_kicad(state, args),+        "kicad_close_symbol_editor" => close_kind(WindowKind::SymbolEditor, "Symbol Editor"),+        "kicad_close_footprint_editor" => close_kind(WindowKind::FootprintEditor, "Footprint Editor"),+        "kicad_close_3d_viewer" => close_kind(WindowKind::Viewer3d, "3D Viewer"),+        "kicad_dismiss_dialogs" => dismiss_dialogs(state, args),+        "kicad_window_info" => window_info(state, args),+        "kicad_state" => kicad_state(state, args),+        "kicad_screenshot_all" => screenshot_all(state),+        "kicad_send_key" => send_key(args),+        "kicad_click" => click(args),+        "kicad_fix_keyboard" => fix_keyboard(),+        "kicad_place_footprint" => place_footprint(state, args),+        "kicad_enable_software_opengl" => enable_software_opengl(state),+        _ => return None,+    };+    if gui && out.get("errorCode") != Some(&json!("not_supported_on_platform")) {+        post_verb(state, command, args, &mut out, fg_before);+    }+    if gui {+        native().etiquette_end_verb(command);+    }+    ab::clear_caller();+    Some(out)+}++/// The server.py dispatch wrapper for a window verb: dialog sweep, foreground etiquette,+/// the one-shot focus check, window labelling.+pub(crate) fn post_verb(state: &mut State, command: &str, args: &Value, out: &mut Value, fg_before: Option<u64>) {+    let spawns = SPAWNS_WINDOW.contains(&command);+    let info = state.kicad_info();+    // CONSTANT DIALOG COVERAGE: KiCad throws modal error boxes constantly; sweep after+    // every GUI verb so the AI never runs blind past one and the next verb is unblocked.+    let rounds = if spawns { 5 } else { 1 };+    if let Some(swept) = auto_sweep_dialogs(&info, true, rounds, Duration::from_millis(600)) {+        let had_error = swept["hadError"].as_bool().unwrap_or(false);+        if had_error {+            let titles: Vec<String> = swept["dialogs"].as_array().map(|a| a.iter().filter(|d| !d["benign"].as_bool().unwrap_or(false)).map(|d| format!("\"{}\"", d["title"].as_str().unwrap_or(""))).collect()).unwrap_or_default();+            out["_dialogHint"] = json!(format!(+                "KiCad popped {} dialog(s) during this verb (non-benign: {}). Each was screenshotted (see _autoDialogs[].screenshot + .body) and auto-expired so you are unblocked. If your verb 'failed'/'did not open', this dialog is almost certainly why: read the body and act on it.",+                swept["count"], titles.join(", ")));+        }+        out["_autoDialogs"] = swept;+    }+    // Foreground etiquette (John, 2026-08-14 and 2026-08-16): the ONE sanctioned way a+    // KiCad window comes to the front is an explicit foreground flag WITH a reason.+    let wants_fg = user_wants_foreground(args);+    let success = out.get("success").and_then(Value::as_bool).unwrap_or(true);+    if wants_fg && spawns {+        std::thread::sleep(Duration::from_secs(1)); // let the window exist+        let reason = arg_str(args, "foregroundReason").or_else(|| arg_str(args, "reason")).unwrap_or("").trim().to_string();+        let mut shown: Option<u64> = None;+        if !reason.is_empty() {+            if let Some(h) = verb_window(command, out) {+                if bring_to_user(h, &reason) {+                    shown = Some(h);+                }+            }+        }+        out["_broughtToUser"] = json!(shown.is_some());+        out["windowEtiquette"] = json!(if shown.is_some() { "foreground" } else { "background" });+        if shown.is_none() {+            out["errorCode"] = json!("foreground_reason_required");+            out["_foregroundRefused"] = json!("foreground requested without foregroundReason - pass foregroundReason:'<why the user needs to see this>' and the bridge will caption it for 3s, then foreground.");+            out["_hint"] = json!("errorCode foreground_reason_required: re-call with foreground:true AND foregroundReason:'<why the user needs to see this>'.");+        }+    } else if spawns && success {+        let o = out.as_object_mut().unwrap();+        o.entry("windowEtiquette").or_insert(json!("background"));+        o.entry("openedInBackground").or_insert(json!(true));+        o.entry("_foregroundHint").or_insert(json!("Window opened in the BACKGROUND - the user's foreground was not touched. To show it to the user: re-call with foreground:true AND foregroundReason:'<why the user needs to see this>'. The bridge captions the reason via an ab toast for 3 seconds, then brings the window forward. Foreground without a reason is refused."));+    }+    // One-shot focus check (the phase 4 guardian's stand-in): did a KiCad window take the+    // foreground from a user window during this verb? Push it back once, and say so.+    let mut events = Vec::new();+    if !(wants_fg && out["_broughtToUser"] == json!(true)) {+        // With the etiquette loop running (Windows) the one-shot push would be a second action+        // on a window the loop already handled once; keep it only where no loop exists.+        // Exception (gate on 2026-09-12, ConfRoomROG and arav-rog): a freshly spawned editor+        // re-raises itself when its board finishes loading WITHOUT a foreground transition+        // (it already was the foreground while loading), so the loop sees nothing to bounce+        // and the user's window stays covered. For spawn verbs the one-shot check runs+        // regardless, twice: now and after a short settle for the late re-raise.+        let loop_running = native().etiquette_debug() != Value::Null;+        if let Some(ev) = if loop_running && !spawns { None } else { one_shot_focus_check(fg_before) } {+            events.push(ev);+        }+        if spawns {+            // Wait for the editor to finish loading (its "Load PCB" / "Loading" progress+            // dialog gone), because that is the moment it raises itself; then check twice.+            for _ in 0..16 {+                let loading = scan_dialogs().iter().any(|d| wm::is_progress_dialog(&d.title, &d.body));+                if !loading {+                    break;+                }+                sleep_ms(500);+            }+            sleep_ms(700);+            if let Some(ev) = one_shot_focus_check(fg_before) {+                events.push(ev);+            }+            sleep_ms(700);+            if let Some(ev) = one_shot_focus_check(fg_before) {+                events.push(ev);+            }+        }+    }+    // kicad_state already carries the loop's ring buffer; never clobber it with the+    // per-verb list (which is empty while the loop runs).+    if out.get("focusEvents").map(|v| v.as_array().map(|a| a.is_empty()).unwrap_or(true)).unwrap_or(true) {+        out["focusEvents"] = json!(events);+    }+    if spawns {+        out["_userFocusRestored"] = json!(false);+    }+    // #12.3: label the KiCad window this verb raised with the owning AI thread, so the+    // user's taskbar/Alt-Tab shows which of their ~20 tabs owns it. Best-effort.+    if spawns && success {+        let thread = ab::thread_name();+        let label = match command {+            "kicad_open_symbol_editor" => Some("Symbol Editor"),+            "kicad_open_footprint_editor" => Some("Footprint Editor"),+            "kicad_open_board" => Some("PCB Editor"),+            "kicad_open_schematic" => Some("Schematic Editor"),+            "kicad_open_3d_viewer" => Some("3D Viewer"),+            _ => None,+        };+        if let (false, Some(word)) = (thread.is_empty(), label) {+            if let Ok((wins, _)) = find_windows(false, true) {+                if let Some(w) = wins.iter().find(|w| w.title.to_lowercase().contains(&word.to_lowercase())) {+                    if ab::desktop_set_window_identity(w.hwnd, &format!("KiCad ({thread})"), &format!("KiCad {word} - driven by {thread}")).is_ok() {+                        out["_windowLabeledFor"] = json!(thread);+                    }+                }+            }+        }+    }+    invalidate();+}++// ── Platform plumbing ─────────────────────────────────────────────────────────++fn is_not_impl(e: &str) -> bool {+    e.contains(": not implemented on ")+}++/// Map a platform Result: "not implemented on <os>" becomes the honest+/// not_supported_on_platform reply; anything else is a platform_error.+fn plat<T>(feature: &str, r: Result<T, String>) -> Result<T, Value> {+    r.map_err(|e| if is_not_impl(&e) { unsupported(feature) } else { fail("platform_error", format!("{feature}: {e}"), "The OS layer refused. kicad_diagnostics shows the platform capabilities; kicad_screenshot_all shows what is on screen.") })+}++fn exe_suffix() -> &'static str {+    if native().cli_file_name().ends_with(".exe") { ".exe" } else { "" }+}++/// `<bin_dir>/<name>[.exe]` for the primary install.+fn kicad_exe(info: &KicadInfo, name: &str) -> Option<PathBuf> {+    let inst = info.primary()?;+    let p = PathBuf::from(&inst.bin_dir).join(format!("{name}{}", exe_suffix()));+    if p.is_file() { Some(p) } else { None }+}++fn utc_stamp() -> String {+    // 2026-09-11T12:34:56Z -> 20260911-123456+    let s = kicad_common::now_utc_iso();+    let date: String = s.chars().take(10).filter(|c| c.is_ascii_digit()).collect();+    let time: String = s.chars().skip(11).take(8).filter(|c| c.is_ascii_digit()).collect();+    format!("{date}-{time}")+}++/// Spawn a KiCad GUI without activating its window, stderr captured to a launch log.+fn spawn_background(exe: &Path, args: &[String], tag: &str, trace_masks: Option<&str>) -> Result<(u32, Option<PathBuf>), Value> {+    let log = bridge_log::log_dir().and_then(|d| bridge_log::new_log_path(&d, &utc_stamp(), tag).ok());+    let env = bridge_log::trace_env(trace_masks);+    let pid = plat("spawn_background", native().spawn_background(exe, args, &env, log.as_deref()))?;+    native().etiquette_register_pid(pid, None);+    invalidate();+    Ok((pid, log))+}++fn sleep_ms(ms: u64) {+    std::thread::sleep(Duration::from_millis(ms));+}++fn user_wants_foreground(args: &Value) -> bool {+    ["foreground", "showToUser", "bringToFront"].iter().any(|k| args.get(*k).and_then(Value::as_bool).unwrap_or(false))+}++fn user_idle_seconds() -> Option<f64> {+    native().seconds_since_input().ok().filter(|s| *s < 1e8).map(|s| (s * 10.0).round() / 10.0)+}++// ── Window finder (kicad_windows.py): ab first for z-order, local fallback ────++#[derive(Clone, Debug, Default)]+pub(crate) struct Win {+    pub(crate) hwnd: u64,+    pub(crate) title: String,+    class_name: String,+    /// x, y, width, height.+    rect: (i32, i32, i32, i32),+    z: Option<i64>,+    owner: u64,+    pid: u32,+    exe: String,+    minimized: bool,+    maximized: bool,+    hung: bool,+}++impl Win {+    fn kind(&self) -> WindowKind {+        wm::classify(&self.title, &self.class_name, self.owner)+    }+    fn is_kicad(&self) -> bool {+        if !self.exe.is_empty() {+            return wm::is_kicad_exe(&self.exe);+        }+        // No exe known (an ab older than 2.1.12 on a platform with no window_info): fail+        // open on the title, like the Python fallback.+        wm::title_looks_kicad(&self.title)+    }+    fn rect_json(&self) -> Value {+        json!({"x": self.rect.0, "y": self.rect.1, "width": self.rect.2, "height": self.rect.3})+    }+    fn from_info(w: &WindowInfo) -> Win {+        Win { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), rect: w.rect, z: None, owner: w.owner, pid: w.pid, exe: w.exe.clone(), minimized: w.minimized, maximized: w.maximized, hung: w.hung }+    }+}++struct Cache {+    at: Option<Instant>,+    rows: Vec<Win>,+    source: &'static str,+}++static CACHE: Mutex<Cache> = Mutex::new(Cache { at: None, rows: Vec::new(), source: "(not yet queried)" });+const CACHE_TTL: Duration = Duration::from_millis(250);++/// Call after anything that opens, closes, or retitles a KiCad window.+pub(crate) fn invalidate() {+    if let Ok(mut c) = CACHE.lock() {+        c.at = None;+        c.rows.clear();+    }+}++fn list_all_windows() -> Result<(Vec<Win>, &'static str), Value> {+    match ab::desktop_list_windows() {+        Ok(l) if !(l.windows.is_empty() && l.degraded) => {+            let mut rows: Vec<Win> = l.windows.iter().map(|w| Win {+                hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), rect: w.rect, z: w.z, owner: w.owner, pid: w.pid,+                exe: w.image.clone(), minimized: w.minimized, maximized: w.maximized, hung: w.hung,+            }).collect();+            // ab before 2.1.12 carries no image/owner (they arrived together): fill both+            // from the OS layer when it can, only for rows that lack the exe.+            for r in rows.iter_mut() {+                if r.exe.is_empty() {+                    if let Ok(Some(i)) = native().window_info(r.hwnd) {+                        r.exe = i.exe;+                        if r.owner == 0 { r.owner = i.owner; }+                        if r.class_name.is_empty() { r.class_name = i.class_name; }+                    }+                }+            }+            Ok((rows, "ad:desktop_list_windows"))+        }+        _ => {+            let rows = plat("window enumeration (list_windows)", native().list_windows())?;+            Ok((rows.iter().filter(|w| w.visible).map(Win::from_info).collect(), "local:EnumWindows"))+        }+    }+}++/// KiCad windows now (all_windows: every top-level window). Cached 250 ms.+fn find_windows(all_windows: bool, fresh: bool) -> Result<(Vec<Win>, &'static str), Value> {+    let mut c = CACHE.lock().map_err(|_| fail("internal", "window cache poisoned", "Retry."))?;+    let stale = fresh || c.at.map(|t| t.elapsed() > CACHE_TTL).unwrap_or(true);+    if stale {+        let (rows, src) = list_all_windows()?;+        c.rows = rows;+        c.source = src;+        c.at = Some(Instant::now());+    }+    let src = c.source;+    if all_windows {+        return Ok((c.rows.clone(), src));+    }+    // A #32770 owned by a KiCad window is KiCad's too (kicad_ui._find_all_kicad_windows).+    let kicad_hwnds: Vec<u64> = c.rows.iter().filter(|r| r.is_kicad()).map(|r| r.hwnd).collect();+    let rows = c.rows.iter().filter(|r| r.is_kicad() || (r.class_name == wm::DIALOG_CLASS && r.owner != 0 && kicad_hwnds.contains(&r.owner))).cloned().collect();+    Ok((rows, src))+}++pub(crate) fn kicad_windows() -> Vec<Win> {+    find_windows(false, false).map(|(w, _)| w).unwrap_or_default()+}++pub(crate) fn windows_of_kind(kind: WindowKind) -> Vec<Win> {+    kicad_windows().into_iter().filter(|w| w.kind() == kind).collect()+}++fn first_of_kind(kind: WindowKind) -> Option<u64> {+    windows_of_kind(kind).first().map(|w| w.hwnd)+}++/// Wiki #48: learn where the user keeps each KiCad window, per window KIND, so a restart can+/// put it back. Called before anything the bridge does that destroys geometry (a restart, a+/// close) and whenever the inventory is in hand anyway. Minimized and parked windows teach+/// nothing, so they are skipped.+pub(crate) fn remember_placement() -> usize {+    let now = utc_stamp();+    let mut mem = window_memory::load();+    let mut learned = 0;+    for w in kicad_windows() {+        let kind = w.kind();+        if matches!(kind, WindowKind::Dialog | WindowKind::Other) {+            continue;+        }+        // placement() reports the NORMAL rect plus the maximized flag, which is what has to+        // come back; the enumerated rect is the fallback where that call is unavailable.+        let place = native().placement(w.hwnd).unwrap_or(Placement {+            x: w.rect.0, y: w.rect.1, width: w.rect.2, height: w.rect.3, maximized: w.maximized,+        });+        if window_memory::observe(&mut mem, kind.as_str(), &place, w.minimized, &now) {+            learned += 1;+        }+    }+    if learned > 0 {+        if let Err(e) = window_memory::save(&mem) {+            eprintln!("[placement] could not save the placement memory: {e}");+        }+    }+    learned+}++/// Wiki #48: put each window back where the user had it, without activating it. A fresh+/// KiCad process places every window on the PRIMARY display, which is how a multi-monitor+/// user's PCB Editor kept coming back to the laptop panel. Returns one row per window so the+/// caller's reply can say what moved and what was left alone.+pub(crate) fn restore_placement() -> Vec<Value> {+    let mem = window_memory::load();+    if mem.is_empty() {+        return Vec::new();+    }+    let mut out = Vec::new();+    for w in kicad_windows() {+        let kind = w.kind();+        let Some(remembered) = mem.get(kind.as_str()) else { continue };+        let r = &remembered.place;+        let current = match native().placement(w.hwnd) {+            Ok(p) => p,+            Err(e) => {+                out.push(json!({"window": kind.as_str(), "hwnd": w.hwnd, "moved": false, "error": e}));+                continue;+            }+        };+        let on_monitor = native().on_a_monitor((r.x, r.y, r.width, r.height)).unwrap_or(false);+        match window_memory::decide(r, &current, on_monitor) {+            Restore::Skip(why) => out.push(json!({"window": kind.as_str(), "hwnd": w.hwnd, "moved": false, "why": why})),+            Restore::Move(p) => match native().set_placement(w.hwnd, &p) {+                Ok(()) => out.push(json!({"window": kind.as_str(), "hwnd": w.hwnd, "moved": true,+                    "to": {"x": p.x, "y": p.y, "width": p.width, "height": p.height, "maximized": p.maximized},+                    "rememberedAt": remembered.at})),+                Err(e) => out.push(json!({"window": kind.as_str(), "hwnd": w.hwnd, "moved": false, "error": e})),+            },+        }+    }+    out+}++fn find_project_manager() -> Option<u64> {+    kicad_windows().iter().find(|w| wm::is_project_manager(&w.title, w.owner)).map(|w| w.hwnd)+}++/// A window's current title without hanging on a modal-blocked thread.+pub(crate) fn title_of(hwnd: u64) -> String {+    if let Ok(Some(i)) = native().window_info(hwnd) {+        return i.title;+    }+    find_windows(true, true).ok().and_then(|(w, _)| w.into_iter().find(|w| w.hwnd == hwnd)).map(|w| w.title).unwrap_or_default()+}++pub(crate) fn window_alive(hwnd: u64) -> bool {+    match native().window_info(hwnd) {+        Ok(Some(i)) => i.visible,+        Ok(None) => false,+        Err(_) => find_windows(true, true).map(|(w, _)| w.iter().any(|w| w.hwnd == hwnd)).unwrap_or(false),+    }+}++/// The window a verb surfaced, for foreground and labelling: the kind the verb opens,+/// else the topmost KiCad window.+fn verb_window(command: &str, out: &Value) -> Option<u64> {+    if let Some(h) = out.get("hwnd").and_then(Value::as_u64).filter(|h| *h != 0) {+        return Some(h);+    }+    let kind = match command {+        "kicad_open_symbol_editor" => Some(WindowKind::SymbolEditor),+        "kicad_open_footprint_editor" => Some(WindowKind::FootprintEditor),+        "kicad_open_board" | "kicad_place_footprint" => Some(WindowKind::PcbEditor),+        "kicad_open_schematic" => Some(WindowKind::SchematicEditor),+        "kicad_open_3d_viewer" => Some(WindowKind::Viewer3d),+        _ => None,+    };+    let wins = find_windows(false, true).map(|(w, _)| w).unwrap_or_default();+    kind.and_then(|k| wins.iter().find(|w| w.kind() == k).map(|w| w.hwnd)).or_else(|| wins.first().map(|w| w.hwnd))+}++// ── Focus etiquette (win_focus.py, the one-shot part only) ────────────────────++static FOCUS_EVENTS: Mutex<Vec<Value>> = Mutex::new(Vec::new());++fn record_focus_event(ev: Value) {+    if let Ok(mut g) = FOCUS_EVENTS.lock() {+        g.push(ev);+        if g.len() > 200 {+            let n = g.len() - 200;+            g.drain(..n);+        }+    }+}++fn is_kicad_hwnd(hwnd: u64) -> bool {+    if let Ok(Some(i)) = native().window_info(hwnd) {+        return wm::is_kicad_exe(&i.exe);+    }+    kicad_windows().iter().any(|w| w.hwnd == hwnd)+}++/// capture_foreground before, compare after: if a KiCad window now holds the foreground+/// and the user had a non-KiCad window before, push KiCad back once (never activate the+/// user's window: Windows re-activates it naturally) and report the event.+fn one_shot_focus_check(fg_before: Option<u64>) -> Option<Value> {+    let before = fg_before?;+    if is_kicad_hwnd(before) {+        return None;+    }+    // What covers the user's window is the top of the Z order, not only the activated+    // window: a KiCad editor raises itself when its board finishes loading without a+    // foreground transition (gate on 2026-09-12).+    let fg = native().foreground().ok().filter(|h| *h != 0);+    let top = native().z_top().ok().filter(|h| *h != 0);+    let after = match (fg, top) {+        (Some(f), _) if is_kicad_hwnd(f) => f,+        (_, Some(t)) if is_kicad_hwnd(t) => t,+        _ => return None,+    };+    if after == before {+        return None;+    }+    let title = title_of(after);+    let pushed = native().push_to_background(after).is_ok();+    // A Z-order push alone does not hold: the editor still owns the activation and raises+    // itself again on its next paint. Give the user's window its activation back (this+    // restores what the user had before the verb; it is not a foreground of ours).+    let restored = ab::desktop_restore_user_window(before, "restore the user's window after a KiCad window raised itself").is_ok()+        || matches!(native().bring_to_front(before), Ok(true));+    let action = match (pushed, restored) {+        (true, true) => "pushed-to-background, user window restored",+        (true, false) => "pushed-to-background",+        (false, true) => "user window restored",+        (false, false) => "push-failed",+    };+    let ev = json!({"event": "self-raise", "hwnd": after, "title": title, "userWindow": before, "action": action, "at": kicad_common::now_utc_iso()});+    record_focus_event(ev.clone());+    Some(ev)+}++/// Explicit, user-requested foreground (win_focus.bring_to_user): caption the reason+/// first, then restore and activate. Refused without a reason.+fn bring_to_user(hwnd: u64, reason: &str) -> bool {+    if reason.trim().is_empty() {+        return false;+    }+    let msg = format!("KiCad bridge needs your screen for ~3s: {reason}. Background methods were exhausted; this was explicitly allowed by the calling AI (foreground:true).");+    let _ = ab::notify_user("Taking the foreground briefly", &msg, "warning");+    let _ = ab::desktop_caption("kicad-foreground", reason, 3000, "Announce the foreground the calling AI asked for before the KiCad window moves");+    sleep_ms(1200); // let the caption render BEFORE the window moves+    native().etiquette_sanction(hwnd, 20.0); // the loop must not bounce a foreground the AI asked for+    let ok = match native().bring_to_front(hwnd) {+        Ok(b) => b,+        Err(e) if is_not_impl(&e) => ab::desktop_bring_to_front(hwnd, reason).is_ok(),+        Err(_) => false,+    };+    record_focus_event(json!({"event": "user-foregrounded", "hwnd": hwnd, "reason": reason, "ok": ok, "at": kicad_common::now_utc_iso()}));+    ok+}++// ── Dialogs (close_windows.py) ────────────────────────────────────────────────++#[derive(Clone, Debug, Default)]+pub(crate) struct Dialog {+    pub(crate) hwnd: u64,+    pub(crate) title: String,+    class_name: String,+    owner: u64,+    pub(crate) body: String,+    via_global_sweep: bool,+}++/// A dialog's message text from its Static children (never blocks on a hung window).+fn dialog_body_text(hwnd: u64) -> String {+    let Ok(children) = native().child_windows(hwnd) else { return String::new() };+    let texts: Vec<String> = children.iter().filter(|c| c.class_name == "Static").map(|c| c.title.clone()).collect();+    wm::dialog_body(&texts)+}++/// RELIABLE scan for EVERY dialog around a running KiCad: the KiCad-process windows that+/// are dialogs (Win32 dialog class or owned), plus a GLOBAL #32770/error-title sweep so+/// an error box owned by an untracked pcbnew/eeschema/orphan is caught too.+pub(crate) fn scan_dialogs() -> Vec<Dialog> {+    let Ok((all, _)) = find_windows(true, false) else { return Vec::new() };+    let mut out = Vec::new();+    let mut seen = Vec::new();+    for w in all.iter().filter(|w| w.is_kicad()) {+        if !wm::is_dialog(&w.class_name, w.owner) {+            continue;+        }+        out.push(Dialog { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), owner: w.owner, body: dialog_body_text(w.hwnd), via_global_sweep: false });+        seen.push(w.hwnd);+    }+    for w in all.iter() {+        if seen.contains(&w.hwnd) {+            continue;+        }+        if wm::looks_like_global_dialog(&w.title, &w.class_name) {+            out.push(Dialog { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), owner: w.owner, body: dialog_body_text(w.hwnd), via_global_sweep: true });+            seen.push(w.hwnd);+        }+    }+    out+}++fn find_save_prompt() -> Option<Dialog> {+    scan_dialogs().into_iter().find(|d| wm::is_save_prompt(&d.title, &d.body))+}++/// Dismiss a dialog: a known dismiss button (OK for info boxes, Cancel/Finish for+/// wizards) through a time-boxed click, then Escape + WM_CLOSE. All posted to the exact+/// hwnd, no focus theft.+fn dismiss_dialog(hwnd: u64) -> String {+    for tier in wm::DISMISS_BUTTON_TIERS {+        if native().click_button(hwnd, tier).is_ok() {+            invalidate();+            return "clicked-button".into();+        }+    }+    let _ = native().post_key(hwnd, wm::VK_ESCAPE, None);+    let _ = native().post_close(hwnd);+    invalidate();+    "escape+WM_CLOSE".into()+}++/// Capture a dialog BEFORE we expire it, so the AI can be shown what was auto-closed.+fn screenshot_dialog(hwnd: u64, title: &str) -> String {+    ab::desktop_screenshot_window(hwnd, &format!("Capture the KiCad dialog '{title}' before the bridge expires it, as evidence for the calling AI"), None).map(|s| s.full_path).unwrap_or_default()+}++/// Persist the Cairo (software) canvas in every kicad_common.json so KiCad stops+/// attempting OpenGL on this machine. Called only right after dismissing the "could not+/// use OpenGL" notice, which is PROOF OpenGL is unavailable here.+fn force_software_canvas(version: &str) -> bool {+    let Some(root) = native().config_root() else { return false };+    let mut targets: Vec<PathBuf> = Vec::new();+    if !version.is_empty() {+        let d = root.join(kicad_platform::major_minor(version));+        if d.is_dir() {+            targets.push(d.join("kicad_common.json"));+        }+    }+    if targets.is_empty() {+        targets = kicad_common::common_files(&root);+        if targets.is_empty() && !version.is_empty() {+            targets.push(root.join(kicad_platform::major_minor(version)).join("kicad_common.json"));+        }+    }+    let mut changed = false;+    for cfg in targets {+        let mut doc = std::fs::read_to_string(&cfg).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()).unwrap_or(json!({}));+        if !wm::force_software_canvas_doc(&mut doc) {+            continue;+        }+        if let Some(p) = cfg.parent() {+            let _ = std::fs::create_dir_all(p);+        }+        if let Ok(text) = serde_json::to_string_pretty(&doc) {+            if std::fs::write(&cfg, text).is_ok() {+                changed = true;+            }+        }+    }+    changed+}++/// close_windows._win_sweep_dialogs: expire the benign dialogs (or ALL dialogs with+/// dismiss_all), looping because dismissing one often spawns the next.+fn sweep_dialogs(info: &KicadInfo, dismiss_all: bool, screenshot: bool) -> Value {+    let mut dismissed = Vec::new();+    let mut remaining = Vec::new();+    let mut forced = false;+    let mut shot: Vec<u64> = Vec::new();+    let deadline = Instant::now() + Duration::from_secs(25);+    for _pass in 0..5 {+        if Instant::now() > deadline {+            break;+        }+        remaining.clear();+        let mut acted = false;+        for d in scan_dialogs() {+            match wm::sweep_decision(&d.title, &d.body, dismiss_all) {+                DialogAction::LeaveProgress => remaining.push(json!({"hwnd": d.hwnd, "title": d.title, "body": d.body, "progress": true})),+                DialogAction::Dismiss { benign } => {+                    let mut entry = json!({"title": d.title, "body": d.body});+                    if screenshot && !shot.contains(&d.hwnd) {+                        entry["screenshot"] = json!(screenshot_dialog(d.hwnd, &d.title));+                        shot.push(d.hwnd);+                    }+                    entry["action"] = json!(dismiss_dialog(d.hwnd));+                    dismissed.push(entry);+                    acted = true;+                    if benign && !forced {+                        forced = force_software_canvas(&info.version);+                    }+                }+                DialogAction::Leave => remaining.push(json!({"hwnd": d.hwnd, "title": d.title, "body": d.body})),+            }+        }+        if !acted {+            break;+        }+        sleep_ms(400);+    }+    json!({"dismissed": dismissed, "remaining": remaining, "forcedSoftwareCanvas": forced})+}++/// close_windows.auto_sweep_dialogs: the post-verb sweep. Settle-polls for a late+/// dialog, screenshots and reads each one found, expires it (never a progress dialog),+/// and returns None when nothing was ever seen.+fn auto_sweep_dialogs(_info: &KicadInfo, screenshot: bool, settle_rounds: usize, settle_gap: Duration) -> Option<Value> {+    let rounds = settle_rounds.max(1);+    let mut found = Vec::new();+    for r in 0..rounds {+        invalidate();+        found = scan_dialogs();+        if !found.is_empty() {+            break;+        }+        if r + 1 < rounds {+            std::thread::sleep(settle_gap);+        }+    }+    if found.is_empty() {+        return None;+    }+    let mut dialogs = Vec::new();+    let mut had_error = false;+    let mut seen: Vec<u64> = Vec::new();+    let deadline = Instant::now() + Duration::from_secs(25);+    for _pass in 0..4 {+        if Instant::now() > deadline {+            break;+        }+        let mut acted = false;+        invalidate();+        for d in scan_dialogs() {+            if seen.contains(&d.hwnd) {+                continue;+            }+            seen.push(d.hwnd);+            match wm::auto_sweep_decision(&d.title, &d.body) {+                DialogAction::LeaveProgress => dialogs.push(json!({"title": d.title, "body": d.body, "benign": true, "progress": true, "action": "left-running"})),+                DialogAction::Dismiss { benign } => {+                    if !benign {+                        had_error = true;+                    }+                    let shot = if screenshot { screenshot_dialog(d.hwnd, &d.title) } else { String::new() };+                    let action = dismiss_dialog(d.hwnd);+                    dialogs.push(json!({"title": d.title, "body": d.body, "benign": benign, "action": action, "screenshot": shot}));+                    acted = true;+                }+                DialogAction::Leave => {}+            }+        }+        if !acted {+            break;+        }+        sleep_ms(350);+    }+    if dialogs.is_empty() {+        return None;+    }+    Some(json!({"count": dialogs.len(), "dialogs": dialogs, "hadError": had_error}))+}++/// The rows `kicad_errors` reads (bridge_log::DialogRow), for the misc group to consume+/// once kicad_errors moves to a group that runs after this one.+pub fn dialog_rows() -> Vec<bridge_log::DialogRow> {+    scan_dialogs().into_iter().map(|d| {+        let texts: Vec<String> = native().child_windows(d.hwnd).map(|c| c.iter().filter(|c| c.class_name != "Button").map(|c| c.title.clone()).filter(|t| !t.trim().is_empty()).collect()).unwrap_or_default();+        bridge_log::DialogRow { hwnd: d.hwnd, title: d.title, texts, uia_text: None }+    }).collect()+}++// ── Menu walk, UIA, posted keys: the rungs ────────────────────────────────────++/// Resolve a menu item whose label contains `needle` from the window's live menu bar and+/// fire it with WM_COMMAND. Ok(Some((id, label))) when fired, Ok(None) when no such item.+fn menu_invoke_by_text(hwnd: u64, needle: &str) -> Result<Option<(u32, String)>, String> {+    let items = native().menu_tree(hwnd)?;+    let want = needle.to_lowercase();+    // an exact label wins over a longer one that contains it: "Zoom to Selection" must never+    // fire "Zoom to Selection Area" (an interactive tool that parks the editor waiting for a drag)+    let clean = |l: &str| l.replace('&', "").split('\t').next().unwrap_or("").trim().to_lowercase();+    if let Some(it) = items.iter().find(|it| it.id != 0 && it.path.last().map(|l| clean(l) == want).unwrap_or(false)) {+        native().menu_invoke(hwnd, it.id)?;+        invalidate();+        return Ok(Some((it.id, it.path.last().unwrap().replace('&', ""))));+    }+    for it in items {+        let Some(label) = it.path.last() else { continue };+        if clean(label).contains(&want) && it.id != 0 {+            native().menu_invoke(hwnd, it.id)?;+            invalidate();+            return Ok(Some((it.id, label.replace('&', ""))));+        }+    }+    Ok(None)+}++/// win_menu.find_by_accelerator: a chord to the menu command that carries it.+fn menu_invoke_by_accel(hwnd: u64, chord: &str) -> Result<Option<(u32, String)>, String> {+    let items = native().menu_tree(hwnd)?;+    let want = wm::norm_accel(chord);+    for it in items {+        if let Some(a) = &it.accel {+            if wm::norm_accel(a) == want && it.id != 0 {+                native().menu_invoke(hwnd, it.id)?;+                invalidate();+                let label = it.path.last().map(|l| l.replace('&', "")).unwrap_or_default();+                return Ok(Some((it.id, label)));+            }+        }+    }+    Ok(None)+}++/// Poll for a window of `kind` until `deadline`; `repost` fires once at `repost_after`.+fn wait_for_kind(kind: WindowKind, deadline: Instant, repost: Option<(u64, u32)>, repost_after: Duration) -> Option<u64> {+    let t0 = Instant::now();+    let mut reposted = false;+    loop {+        invalidate();+        if let Some(h) = first_of_kind(kind) {+            return Some(h);+        }+        if Instant::now() >= deadline {+            return None;+        }+        if !reposted && t0.elapsed() > repost_after {+            if let Some((host, id)) = repost {+                let _ = native().menu_invoke(host, id);+            }+            reposted = true;+        }+        sleep_ms(500);+    }+}++/// Every descendant (class, text, rect, visible) of a window with its parent handle.+/// The platform's `child_windows` returns the whole subtree with `owner` carrying the+/// PARENT handle (the Windows implementation is built for exactly this join).+#[derive(Clone, Debug)]+struct Child {+    hwnd: u64,+    class_name: String,+    text: String,+    rect: (i32, i32, i32, i32),+    visible: bool,+    parent: u64,+}++fn descendants(root: u64) -> Vec<Child> {+    let Ok(kids) = native().child_windows(root) else { return Vec::new() };+    kids.iter().map(|k| Child { hwnd: k.hwnd, class_name: k.class_name.clone(), text: k.title.clone(), rect: k.rect, visible: k.visible, parent: if k.owner == 0 { root } else { k.owner } }).collect()+}++/// The library tree's filter Edit: class "Edit" under a parent whose text is "searchCtrl".+fn find_search_edit(frame: u64) -> Option<Child> {+    let all = descendants(frame);+    all.iter().find(|c| c.class_name == "Edit" && all.iter().any(|p| p.hwnd == c.parent && p.text == "searchCtrl")).cloned()+}++static TREE_TOGGLED: Mutex<Vec<(u64, Instant)>> = Mutex::new(Vec::new());++/// The tree search control, showing the Library Tree pane through the frame's own View+/// menu when the user has it hidden (toggle at most once per editor per minute).+fn ensure_tree_search(frame: u64, trace: &mut Vec<String>) -> Option<Child> {+    if let Some(e) = find_search_edit(frame).filter(|e| e.visible) {+        return Some(e);+    }+    let may_toggle = TREE_TOGGLED.lock().map(|g| !g.iter().any(|(h, t)| *h == frame && t.elapsed() < Duration::from_secs(60))).unwrap_or(true);+    if may_toggle {+        match menu_invoke_by_text(frame, "library tree") {+            Ok(Some((id, _))) => {+                trace.push(format!("library tree pane was hidden: View menu toggled (id {id})"));+                if let Ok(mut g) = TREE_TOGGLED.lock() {+                    g.retain(|(h, _)| *h != frame);+                    g.push((frame, Instant::now()));+                }+            }+            Ok(None) => trace.push("search Edit not visible and no 'Library Tree' menu item found".into()),+            Err(e) => trace.push(format!("search Edit not visible; menu walk failed: {e}")),+        }+    }+    for _ in 0..16 {+        if let Some(e) = find_search_edit(frame).filter(|e| e.visible) {+            return Some(e);+        }+        sleep_ms(250);+    }+    None+}++/// The rung used to put text in the filter box: "uia" (ValuePattern) else "post" (WM_SETTEXT).+fn set_filter_text(edit: u64, text: &str) -> Result<&'static str, String> {+    if native().uia_set_value(edit, "", text).is_ok() {+        return Ok("uia");+    }+    native().set_text(edit, text).map(|_| "post")+}++fn post_vk(hwnd: u64, vk: u16) {+    let _ = native().post_key(hwnd, vk, None);+}++/// Which editor title check applies during navigation.+#[derive(Clone, Copy)]+enum Verify<'a> {+    Symbol { name: &'a str, library: &'a str },+    Footprint { name: &'a str, library: &'a str },+    Never,+}++fn verify_title(title: &str, v: Verify<'_>) -> bool {+    match v {+        Verify::Symbol { name, library } => wm::title_confirms_symbol(title, name, library),+        Verify::Footprint { name, library } => wm::title_confirms_footprint(title, name, library),+        Verify::Never => false,+    }+}++/// The plugin-free BACKGROUND library-tree load (0.9.83/0.9.89, wiki #71): text into the+/// LIB_TREE filter, Down through the filter box, Enter posted to the tree candidates,+/// Down x1..3 then Enter, a posted double-click on the first row. Zero focus.+/// Returns (confirmed, trace, mechanism).+fn navigate_tree(frame: u64, filter: &str, verify: Verify<'_>) -> (bool, Value, &'static str) {+    let mut steps: Vec<String> = Vec::new();+    let mut trace = json!({"filter": filter, "editorHwnd": frame, "titleBefore": title_of(frame)});+    let mut mechanism = "post";+    let Some(edit) = ensure_tree_search(frame, &mut steps) else {+        steps.push("search Edit under a 'searchCtrl' parent NOT FOUND (library tree pane hidden, or a different child layout)".into());+        trace["steps"] = json!(steps);+        return (false, trace, mechanism);+    };+    trace["editHwnd"] = json!(edit.hwnd);+    match set_filter_text(edit.hwnd, filter) {+        Ok(m) => {+            mechanism = m;+            steps.push(format!("filter text set via {m}"));+        }+        Err(e) => {+            steps.push(format!("could not set the filter text: {e}"));+            trace["steps"] = json!(steps);+            return (false, trace, mechanism);+        }+    }+    sleep_ms(1400); // LIB_TREE search debounce on the UI thread + tree rebuild+    // Down THROUGH the filter box: KiCad forwards nav keys to the tree.+    post_vk(edit.hwnd, wm::VK_DOWN);+    sleep_ms(500);+    steps.push(format!("VK_DOWN posted to edit; title now {:?}", title_of(frame)));+    // Tree candidates: tall siblings under the Libraries pane (wx generic controls share+    // bland class names, so geometry is the discriminator) plus any wxDataView.+    let all = descendants(frame);+    let parent = all.iter().find(|c| c.hwnd == edit.hwnd).map(|c| c.parent).unwrap_or(frame);+    let pane = all.iter().find(|c| c.hwnd == parent).map(|c| c.parent).filter(|p| *p != 0).unwrap_or(parent);+    let cands: Vec<Child> = all.iter().filter(|c| c.hwnd != edit.hwnd && (c.parent == pane || all.iter().any(|p| p.hwnd == c.parent && p.parent == pane)) && (c.rect.3 > 200 || c.class_name.starts_with("wxDataView"))).cloned().collect();+    trace["treeCandidates"] = json!(cands.iter().map(|c| json!({"hwnd": c.hwnd, "class": c.class_name, "rect": [c.rect.0, c.rect.1, c.rect.0 + c.rect.2, c.rect.1 + c.rect.3]})).collect::<Vec<_>>());+    let title_ok = || verify_title(&title_of(frame), verify);+    // Round 1: Enter posted straight to each tree candidate.+    for c in &cands {+        post_vk(c.hwnd, wm::VK_RETURN);+    }+    for _ in 0..6 {+        sleep_ms(700);+        if title_ok() {+            steps.push("VK_RETURN to tree candidates -> title confirms".into());+            trace["steps"] = json!(steps);+            return (true, trace, mechanism);+        }+    }+    steps.push(format!("VK_RETURN to {} tree candidate(s); title still {:?}", cands.len(), title_of(frame)));+    // Round 1b/1c (wiki #71): on a FRESH editor nothing is selected, so select from inside+    // the tree: Down (first lands on the library node, second on the symbol row), then Enter.+    for downs in 1..=3 {+        for c in &cands {+            post_vk(c.hwnd, wm::VK_DOWN);+        }+        sleep_ms(400);+        for c in &cands {+            post_vk(c.hwnd, wm::VK_RETURN);+        }+        for _ in 0..5 {+            sleep_ms(600);+            if title_ok() {+                steps.push(format!("Down x{downs} then VK_RETURN to tree candidates -> title confirms"));+                trace["steps"] = json!(steps);+                return (true, trace, mechanism);+            }+        }+        steps.push(format!("Down x{downs} then VK_RETURN to tree; title still {:?}", title_of(frame)));+    }+    // Round 2: a posted double-click on the first visible row (just under the header).+    for c in &cands {+        let x = (c.rect.2 / 3).max(10);+        let y = 58;+        let _ = native().post_click(c.hwnd, x, y, false);+        let _ = native().post_click(c.hwnd, x, y, true);+    }+    for _ in 0..8 {+        sleep_ms(700);+        if title_ok() {+            steps.push("posted double-click on first row -> title confirms".into());+            trace["steps"] = json!(steps);+            return (true, trace, mechanism);+        }+    }+    steps.push(format!("posted double-click on first row; title still {:?}", title_of(frame)));+    trace["steps"] = json!(steps);+    (false, trace, mechanism)+}++// ── Opening an editor frame: menu walk, then UIA ──────────────────────────────++struct Budget {+    t0: Instant,+    limit: Duration,+}++impl Budget {+    fn new(args: &Value) -> Budget {+        let inline = args.get("waitInline").and_then(Value::as_bool).unwrap_or(false);+        Budget { t0: Instant::now(), limit: if inline { Duration::from_secs(1_000_000) } else { Duration::from_secs(110) } }+    }+    fn out(&self) -> bool {+        self.t0.elapsed() > self.limit+    }+    fn elapsed_sec(&self) -> u64 {+        self.t0.elapsed().as_secs()+    }+}++struct OpenSpec {+    kind: WindowKind,+    /// Menu label fragment ("symbol editor") and the UIA accessible name ("Symbol Editor").+    menu_needle: &'static str,+    uia_name: &'static str,+    /// Frames whose menu bar / UIA tree carries the launcher, in preference order+    /// (a running editor of the same family first, the project manager last).+    hosts: &'static [WindowKind],+    error_code: &'static str,+    starting_code: &'static str,+    label: &'static str,+}++const SYMBOL_SPEC: OpenSpec = OpenSpec { kind: WindowKind::SymbolEditor, menu_needle: "symbol editor", uia_name: "Symbol Editor", hosts: &[WindowKind::SchematicEditor, WindowKind::ProjectManager], error_code: "symbol_editor_did_not_open", starting_code: "symbol_editor_starting", label: "Symbol Editor" };+const FOOTPRINT_SPEC: OpenSpec = OpenSpec { kind: WindowKind::FootprintEditor, menu_needle: "footprint editor", uia_name: "Footprint Editor", hosts: &[WindowKind::PcbEditor, WindowKind::ProjectManager], error_code: "footprint_editor_did_not_open", starting_code: "footprint_editor_starting", label: "Footprint Editor" };++/// The early-return contract when a slow cold start would outlive the transport budget+/// (#54 ask 2, wiki #71): honest about whether anything is actually starting.+fn starting_contract(spec: &OpenSpec, budget: &Budget, phase: &str, pathways: &[String]) -> Value {+    invalidate();+    if first_of_kind(spec.kind).is_none() {+        return json!({+            "success": false, "errorCode": spec.error_code, "retryable": true, "retryAfterMs": 5000,+            "phase": phase, "elapsedSec": budget.elapsed_sec(), "pathwaysTried": pathways,+            "error": format!("No {} window exists. The bridge asked KiCad to open one and none appeared, so this is not a slow cold start - nothing is coming up.", spec.label),+            "_hint": format!("Call kicad_open_{} first and check it returns a window, then re-call this verb. If the editor never opens, KiCad is usually busy with a human (it is single-instance and will not service our menu command while someone is driving it) - kicad_window_info shows what is actually open.", spec.kind.as_str()),+        });+    }+    json!({+        "success": false, "errorCode": spec.starting_code, "starting": true, "retryable": true, "statusVerb": "kicad_state",+        "retryAfterMs": 20000, "phase": phase, "elapsedSec": budget.elapsed_sec(),+        "error": format!("The {} is still coming up (cold start indexes every library, which can outlive the transport budget). This call returned early instead of timing out; KiCad keeps working.", spec.label),+        "_hint": "Not a failure - poll kicad_state (or just wait retryAfterMs) and re-call with the SAME args; a warm editor resolves in seconds. Pass waitInline:true to force the old single-call blocking behaviour.",+    })+}++/// Make sure the project manager exists (spawn kicad.exe in the background if not).+fn ensure_project_manager(info: &KicadInfo, pathways: &mut Vec<String>) -> Result<u64, Value> {+    invalidate();+    if let Some(h) = find_project_manager() {+        return Ok(h);+    }+    let Some(exe) = kicad_exe(info, "kicad") else {+        return Err(fail("kicad_exe_not_found", "KiCad executable not found", "Report to the user and ask them to reinstall KiCad; the installation appears corrupted."));+    };+    spawn_background(&exe, &[], "launch", None).map_err(|mut e| { if e["errorCode"] != json!("not_supported_on_platform") { e["_hint"] = json!("Call kicad_close with {\"force\": true} to clear stuck processes, then retry."); } e })?;+    pathways.push("spawned-project-manager".into());+    let deadline = Instant::now() + Duration::from_secs(10);+    loop {+        sleep_ms(500);+        invalidate();+        if let Some(h) = find_project_manager() {+            sleep_ms(2000);+            return Ok(h);+        }+        if Instant::now() > deadline {+            return Err(fail("project_manager_did_not_open", "KiCad launched but project manager window not found after 10s", "Call screenshot_all to see KiCad's current state, or call close_kicad with {\"force\": true} and retry."));+        }+    }+}++/// Open an editor frame: an existing one wins; else the native menu walk on each host+/// frame (WM_COMMAND, repost once at 10 s); else UIA Invoke. Returns (hwnd, mechanism).+fn open_editor_frame(info: &KicadInfo, spec: &OpenSpec, budget: &Budget, pathways: &mut Vec<String>) -> Result<(u64, &'static str), Value> {+    invalidate();+    if let Some(h) = first_of_kind(spec.kind) {+        return Ok((h, "existing"));+    }+    // Hosts: a live editor frame of the same family first, then the project manager+    // (spawned in the background when nothing at all is running).+    let mut hosts: Vec<u64> = Vec::new();+    for k in spec.hosts {+        if *k == WindowKind::ProjectManager {+            continue;+        }+        hosts.extend(windows_of_kind(*k).iter().map(|w| w.hwnd));+    }+    if spec.hosts.contains(&WindowKind::ProjectManager) {+        let pm = match find_project_manager() {+            Some(h) => h,+            None if hosts.is_empty() => ensure_project_manager(info, pathways)?,+            None => 0,+        };+        if pm != 0 {+            hosts.push(pm);+        }+    }+    if hosts.is_empty() {+        return Err(fail(spec.error_code, format!("No window can host the {} launcher: neither a project manager nor a related editor is open.", spec.label), "Call kicad_launch first, then retry."));+    }+    // Rung: native menu walk + WM_COMMAND (background, version-proof ids).+    let mut menu_unsupported: Option<Value> = None;+    for &host in &hosts {+        if budget.out() {+            return Err(starting_contract(spec, budget, "waiting-for-editor-window", pathways));+        }+        match menu_invoke_by_text(host, spec.menu_needle) {+            Ok(Some((id, _label))) => {+                pathways.push(format!("menu-walk-id-{id}"));+                pathways.push("wm_command".into());+                let deadline = Instant::now() + Duration::from_secs(30);+                if let Some(h) = wait_for_kind(spec.kind, deadline, Some((host, id)), Duration::from_secs(10)) {+                    return Ok((h, "menu"));+                }+                pathways.push("wm_command-no-window".into());+            }+            Ok(None) => pathways.push(format!("menu-walk-miss:{host}")),+            Err(e) if is_not_impl(&e) => { menu_unsupported = Some(unsupported("native menu walk (menu_tree)")); }+            Err(e) => pathways.push(format!("menu-walk-error:{}", e.chars().take(60).collect::<String>())),+        }+    }+    // Rung: UI Automation Invoke by accessible name (no focus, no cursor).+    let mut uia_ran = false;+    for &host in &hosts {+        if budget.out() {+            return Err(starting_contract(spec, budget, "waiting-for-editor-window", pathways));+        }+        match native().uia_invoke(host, spec.uia_name) {+            Ok(_) => {+                uia_ran = true;+                pathways.push("uia".into());+                let deadline = Instant::now() + Duration::from_secs(30);+                if let Some(h) = wait_for_kind(spec.kind, deadline, None, Duration::from_secs(10)) {+                    return Ok((h, "uia"));+                }+                pathways.push("uia-no-window".into());+            }+            Err(e) if is_not_impl(&e) => {+                if let Some(u) = menu_unsupported.take() {+                    return Err(u);+                }+                pathways.push("uia-unsupported".into());+            }+            Err(e) => {+                uia_ran = true;+                pathways.push(format!("uia-{}", e.chars().take(40).collect::<String>()));+            }+        }+    }+    if let (Some(u), false) = (menu_unsupported, uia_ran) {+        return Err(u);+    }+    let idle = user_idle_seconds();+    let busy = idle.map(|i| i < 120.0).unwrap_or(false);+    Err(json!({+        "success": false, "errorCode": spec.error_code, "pathwaysTried": pathways, "userIdleSeconds": idle,+        "error": format!("KiCad accepted the {} command but no editor window appeared within 30s.{}", spec.label,+            if busy { format!(" A human was active on this machine {}s ago, which is the most likely reason: KiCad is single-instance, and while someone is driving it, it does not service our menu command promptly.", idle.unwrap_or(0.0) as i64) } else { String::new() }),+        "_hint": "Retry - this is usually a busy KiCad rather than a broken one, and it succeeds on a machine nobody is touching (measured: 63s cold, 57s with the project manager already running). If it repeats on an IDLE box, that is a real bug worth reporting. Otherwise check for a modal dialog with kicad_screenshot_all and dismiss it with send_key escape.",+    }))+}++/// Ask an open editor to close so the next open re-indexes the libraries: ab's+/// desktop_close_window first (its bookkeeping), local WM_CLOSE otherwise; the WAIT is+/// measured here. Returns (gone, how).+fn close_and_wait(hwnd: u64, reason: &str, timeout: Duration) -> (bool, &'static str) {+    let how = match ab::desktop_close_window(hwnd, reason) {+        Ok(_) => "ad:desktop_close_window",+        Err(_) => {+            if native().post_close(hwnd).is_err() {+                return (false, "none");+            }+            "local:WM_CLOSE"+        }+    };+    invalidate();+    let deadline = Instant::now() + timeout;+    while Instant::now() < deadline {+        sleep_ms(300);+        invalidate();+        if !window_alive(hwnd) {+            return (true, how);+        }+    }+    (false, how)+}++// ── kicad_launch ──────────────────────────────────────────────────────────────++pub(crate) fn launch(state: &mut State, args: &Value) -> Value {+    let existing = match find_windows(false, true) {+        Ok((w, _)) => w,+        Err(e) => return e,+    };+    if !existing.is_empty() {+        return json!({+            "success": true, "alreadyRunning": true,+            "windows": existing.iter().take(10).map(|w| w.title.clone()).collect::<Vec<_>>(),+            "output": format!("KiCad is already running ({} window(s)); nothing launched.", existing.len()),+            "_hint": "Idempotent no-op: KiCad was already up. Use kicad_open_editors to inventory editors, or the open_* verbs to open a specific one.",+        });+    }+    let project = arg_str(args, "project").unwrap_or("").trim().to_string();+    if !project.is_empty() && !Path::new(&project).exists() {+        return fail("project_not_found", format!("Project not found: {project}"), "Absolute Windows path required; %VAR% expansion is not performed.");+    }+    let info = state.kicad_info();+    let Some(exe) = kicad_exe(&info, "kicad") else {+        return fail("kicad_exe_not_found", "kicad.exe not found", "No KiCad installation detected. kicad_readiness reports install state; kicad_upgrade installs KiCad silently (per-user, no UAC).");+    };+    let argv: Vec<String> = if project.is_empty() { vec![] } else { vec![project.clone()] };+    let (pid, log) = match spawn_background(&exe, &argv, "launch", arg_str(args, "traceMasks")) {+        Ok(x) => x,+        Err(mut e) => {+            if e["errorCode"] != json!("not_supported_on_platform") {+                e["error"] = json!(format!("Failed to launch KiCad: {}", e["error"].as_str().unwrap_or("")));+            }+            return e;+        }+    };+    let wait_s = args.get("waitSeconds").and_then(Value::as_f64).unwrap_or(15.0).max(3.0);+    let deadline = Instant::now() + Duration::from_secs_f64(wait_s);+    let mut windows = Vec::new();+    while Instant::now() < deadline {+        windows = find_windows(false, true).map(|(w, _)| w).unwrap_or_default();+        if !windows.is_empty() {+            break;+        }+        sleep_ms(500);+    }+    let log_path = log.as_ref().map(|p| norm(p));+    if !windows.is_empty() {+        // Wiki #48 ask 2: a cold spawn on a box that has run KiCad before lands on the monitor+        // the user last used, not on whatever Windows defaults to.+        sleep_ms(1200);+        let restored = restore_placement();+        return json!({+            "success": true, "launched": true, "pid": pid, "mechanism": "spawn",+            "windows": windows.iter().take(10).map(|w| w.title.clone()).collect::<Vec<_>>(),+            "output": format!("KiCad launched; {} window(s) up.", windows.len()),+            "logPath": log_path, "placementRestored": restored,+            "_hint": "Launched in the BACKGROUND (no focus steal). First run on a fresh install may show a setup wizard; kicad_window_info self-heals benign dialogs; kicad_screenshot_all shows every window if something looks stuck.",+            "_next": ["kicad_window_info", "kicad_open_editors", "kicad_open_board"],+        });+    }+    json!({+        "success": false, "pid": pid, "mechanism": "spawn", "logPath": log_path,+        "error": format!("kicad.exe started but no KiCad window appeared within {}s", wait_s as i64),+        "_hint": "Cold start on a slow box (or software-OpenGL) can exceed the wait; retry kicad_window_info in a few seconds before treating this as a failure. A modal (crash-recovery / first-run) can also hold the window back: kicad_screenshot_all will show it.",+        "statusVerb": "kicad_window_info",+    })+}++// ── kicad_open_board / kicad_open_schematic ───────────────────────────────────++pub(crate) fn open_file(state: &mut State, args: &Value, kind: WindowKind) -> Value {+    let info = state.kicad_info();+    if !info.installed {+        return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");+    }+    let Some(file_path) = arg_str(args, "filePath") else {+        return fail("missing_arg", "No filePath specified", "Pass filePath: the absolute path of the file on the desktop.");+    };+    let path = PathBuf::from(file_path);+    if !path.exists() {+        return fail("file_not_found", format!("File not found: {file_path}"), "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path.");+    }+    let (exe_name, label, lint_verb) = match kind {+        WindowKind::PcbEditor => ("pcbnew", "PCB board", "kicad_lint_board"),+        _ => ("eeschema", "schematic", "kicad_lint_schematic"),+    };+    let Some(exe) = kicad_exe(&info, exe_name) else {+        return fail("kicad_exe_not_found", format!("KiCad executable not found: {exe_name}_exe"), "Report to the user and ask them to reinstall KiCad; the installation appears corrupted.");+    };+    let stem = path.file_stem().map(|s| s.to_string_lossy().to_lowercase()).unwrap_or_default();+    let name = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+    // Already open: the existing window, not a fresh reload.+    invalidate();+    if let Some(w) = windows_of_kind(kind).into_iter().find(|w| !stem.is_empty() && w.title.to_lowercase().contains(&stem)) {+        return json!({+            "success": true, "alreadyOpen": true, "hwnd": w.hwnd, "title": w.title, "mechanism": "existing",+            "output": format!("{label} editor already has {name} open"),+            "_hint": "The existing window was reused, not reloaded. kicad_close first if you need a fresh load from disk.",+        });+    }+    let (pid, log) = match spawn_background(&exe, &[norm(&path)], exe_name, None) {+        Ok(x) => x,+        Err(mut e) => {+            if e["errorCode"] != json!("not_supported_on_platform") {+                e["error"] = json!(format!("Failed to launch: {}", e["error"].as_str().unwrap_or("")));+                e["_hint"] = json!("Call close_kicad with {\"force\": true} to clear any stuck processes, then retry.");+            }+            return e;+        }+    };+    // Verify by window title with one deadline (no nested sleeps).+    let wait_s = args.get("waitSeconds").and_then(Value::as_f64).unwrap_or(30.0).clamp(3.0, 110.0);+    let deadline = Instant::now() + Duration::from_secs_f64(wait_s);+    let mut appeared: Option<Win> = None;+    while Instant::now() < deadline {+        invalidate();+        let wins = windows_of_kind(kind);+        appeared = wins.iter().find(|w| w.title.to_lowercase().contains(&stem)).or_else(|| wins.iter().find(|w| w.pid == pid)).cloned();+        if appeared.is_some() {+            break;+        }+        if native().process_alive(pid) == Some(false) {+            break;+        }+        sleep_ms(500);+    }+    let hint = format!(+        "GUI launched. For unfamiliar files (ones you did not generate / have not validated), prefer running `{lint_verb}` FIRST. It uses kicad-cli to give you structured DRC/ERC + file-format info in ~1-3s, much faster + more reliable than recovering from a confused GUI via screenshots. If the GUI shows a modal dialog (file-version upgrade prompt, missing-symbol warning, recovery prompt), use kicad_window_info -> kicad_screenshot_all -> kicad_send_key to handle it; or close + retry after lint."+    );+    let mut out = json!({+        "success": true, "pid": pid, "mechanism": "spawn", "logPath": log.as_ref().map(|p| norm(p)),+        "output": format!("Launched {label} editor with {name}"),+        "_hint": hint,+    });+    match appeared {+        Some(w) => {+            out["hwnd"] = json!(w.hwnd);+            out["title"] = json!(w.title);+            out["windowAppeared"] = json!(true);+        }+        None => {+            let alive = native().process_alive(pid);+            out["windowAppeared"] = json!(false);+            if alive == Some(false) {+                out["success"] = json!(false);+                out["errorCode"] = json!("editor_exited");+                out["error"] = json!(format!("{exe_name} exited before a {label} window appeared"));+                out["_hint"] = json!(format!("The file probably failed to load: the launch log (logPath) and kicad_log_tail carry KiCad's own message; {lint_verb} says what is wrong with the file."));+            } else {+                out["statusVerb"] = json!("kicad_window_info");+                out["_hint"] = json!(format!("{exe_name} is running but no {label} window titled after the file appeared within {}s (a cold start or a large file can take longer). Poll kicad_window_info; a modal dialog (kicad_screenshot_all) can also be holding it. {hint}", wait_s as i64));+            }+        }+    }+    out+}++// ── kicad_open_symbol_editor ──────────────────────────────────────────────────++fn lib_ctx(info: &KicadInfo) -> LibCtx {+    let inst = info.primary();+    let version = inst.map(|i| i.version.clone()).unwrap_or_default();+    let mut ctx = LibCtx {+        version: version.clone(),+        base_dir: inst.map(|i| i.base_dir.clone()).filter(|b| !b.is_empty()),+        config_dir: if version.is_empty() { None } else { native().config_dir(&version) },+        user_dir: if version.is_empty() { None } else { native().user_dir(&version) },+        kicad_cli: inst.map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file()),+        env_overrides: Default::default(),+    };+    ctx.load_env_overrides();+    ctx+}++/// open_symbol_editor._resolve_symbol_alias: exact, case-insensitive, then a UNIQUE+/// prefix match in either direction against the Adom library's real names.+fn resolve_symbol_alias(info: &KicadInfo, requested: &str) -> (Option<String>, String) {+    let ctx = lib_ctx(info);+    let Some((_table, lib_path)) = libraries::adom_paths(&ctx) else { return (None, "library file not found".into()) };+    if !lib_path.is_file() {+        return (None, "library file not found".into());+    }+    let names = libraries::list_symbols(&lib_path);+    if names.iter().any(|n| n == requested) {+        return (Some(requested.to_string()), "exact".into());+    }+    if let Some(n) = names.iter().find(|n| n.eq_ignore_ascii_case(requested)) {+        return (Some(n.clone()), "case-insensitive".into());+    }+    let r = requested.to_lowercase();+    let pref: Vec<&String> = names.iter().filter(|n| { let l = n.to_lowercase(); l.starts_with(&r) || r.starts_with(&l) }).collect();+    match pref.len() {+        1 => (Some(pref[0].clone()), "unique prefix match".into()),+        0 => (None, "no match".into()),+        _ => (None, format!("ambiguous: {:?}", pref.iter().take(5).collect::<Vec<_>>())),+    }+}++fn symbol_on_disk(info: &KicadInfo, name: &str) -> bool {+    let ctx = lib_ctx(info);+    libraries::adom_paths(&ctx).map(|(_, p)| p.is_file() && libraries::list_symbols(&p).iter().any(|n| n.eq_ignore_ascii_case(name))).unwrap_or(false)+}++pub(crate) fn open_symbol_editor(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    if !info.installed {+        return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");+    }+    let mut symbol_name = arg_str(args, "symbolName").unwrap_or("").to_string();+    let library_name = arg_any(args, &["libraryName", "library"]).unwrap_or("").to_string();+    // Wiki #62: the file is named for the MPN but the symbol inside may not be. Resolve+    // the caller's name against the library's real names BEFORE navigating and say so.+    let mut note = Map::new();+    if !symbol_name.is_empty() && (library_name.is_empty() || library_name.eq_ignore_ascii_case("adom")) {+        let (resolved, how) = resolve_symbol_alias(&info, &symbol_name);+        match resolved {+            Some(r) if r != symbol_name => {+                note.insert("aliasedFrom".into(), json!(symbol_name));+                note.insert("aliasHow".into(), json!(how));+                symbol_name = r;+            }+            None if how.starts_with("ambiguous") => { note.insert("aliasNote".into(), json!(format!("'{symbol_name}' is not in the library and the prefix match is {how}"))); }+            None if how == "no match" => { note.insert("aliasNote".into(), json!(format!("'{symbol_name}' is not a symbol name in the Adom library (no exact, case-insensitive or prefix match)"))); }+            _ => {}+        }+    }+    let mut out = open_symbol_editor_inner(&info, args, &symbol_name, &library_name);+    if !note.is_empty() {+        if let Some(o) = out.as_object_mut() {+            for (k, v) in &note {+                o.insert(k.clone(), v.clone());+            }+            if let Some(from) = note.get("aliasedFrom").and_then(Value::as_str) {+                let prev = o.get("_hint").and_then(Value::as_str).unwrap_or("").to_string();+                o.insert("_hint".into(), json!(format!("Resolved '{from}' to symbol '{symbol_name}' ({}). {prev}", note["aliasHow"].as_str().unwrap_or(""))));+            }+        }+    }+    out+}++fn open_symbol_editor_inner(info: &KicadInfo, args: &Value, symbol_name: &str, library_name: &str) -> Value {+    let spec = &SYMBOL_SPEC;+    let budget = Budget::new(args);+    let rescan = args.get("rescan").and_then(Value::as_bool).unwrap_or(false);+    let mut pathways: Vec<String> = Vec::new();+    invalidate();+    // Already loaded? (#49: the library is a CONSTRAINT, exact library:symbol match only.)+    if !symbol_name.is_empty() && !rescan {+        for w in windows_of_kind(WindowKind::SymbolEditor) {+            if wm::title_confirms_symbol(&w.title, symbol_name, library_name) {+                let _ = native().show_no_activate(w.hwnd);+                let mut v = json!({"success": true, "hwnd": w.hwnd, "mechanism": "existing", "output": format!("Symbol '{symbol_name}' is already loaded in the Symbol Editor.")});+                merge(&mut v, wm::resolved_fields(&w.title, library_name, "Symbol Editor"));+                return v;+            }+        }+    }+    // rescan:true (0.9.192): the remedy for library_cache_stale. Close the editor so the+    // open below indexes a fresh one. Opt-in: it closes a window the user may be using.+    let mut rescanned = false;+    let mut close_via = "";+    if rescan {+        if let Some(h) = first_of_kind(WindowKind::SymbolEditor) {+            let (gone, how) = close_and_wait(h, "Close the Symbol Editor so its next open re-indexes the symbol libraries", Duration::from_secs(12));+            close_via = how;+            if gone {+                rescanned = true;+                pathways.push("rescan-closed-stale-editor".into());+            } else {+                pathways.push("rescan-close-failed".into());+            }+        }+    }+    let (frame, mechanism) = match open_editor_frame(info, spec, &budget, &mut pathways) {+        Ok(x) => x,+        Err(e) => return e,+    };+    if mechanism != "existing" {+        sleep_ms(1500); // let the frame settle+    }+    let _ = native().show_no_activate(frame);+    if symbol_name.is_empty() && library_name.is_empty() {+        return json!({"success": true, "hwnd": frame, "mechanism": mechanism, "pathwaysTried": pathways, "output": if mechanism == "existing" { "Symbol Editor is already open.".to_string() } else { format!("Symbol Editor opened successfully ({mechanism}).") }});+    }+    // Library-only browse mode (0.9.92): filter the tree to the library, no part load.+    if symbol_name.is_empty() {+        let (_, trace, nav_mech) = navigate_tree(frame, library_name, Verify::Never);+        return json!({+            "success": true, "hwnd": frame, "libraryShown": library_name, "mechanism": nav_mech, "openedVia": mechanism, "navigation": trace,+            "output": format!("Symbol Editor open with the '{library_name}' library filtered and selected. No symbol was requested, so none was loaded into the canvas."),+            "via": "win32-library-browse",+            "_hint": "Library browse mode: the tree is filtered to the library. To load a part into the canvas, call again with symbolName too.",+        });+    }+    let filter = if library_name.is_empty() { symbol_name.to_string() } else { format!("{library_name} {symbol_name}") };+    let verify = Verify::Symbol { name: symbol_name, library: library_name };+    let mut attempts = 0;+    let mut last_trace = Value::Null;+    let mut last_mech = "post";+    for attempt in 1..=2 {+        attempts = attempt;+        let (ok, trace, nav_mech) = navigate_tree(frame, &filter, verify);+        last_trace = trace;+        last_mech = nav_mech;+        if ok {+            let title = title_of(frame);+            let mut v = json!({+                "success": true, "hwnd": frame, "mechanism": nav_mech, "openedVia": mechanism, "via": "win32-background", "attempts": attempt,+                "output": format!("Symbol '{symbol_name}' loaded (background {nav_mech} path{}): {title}", if attempt == 2 { ", 2nd attempt" } else { "" }),+                "navigation": last_trace, "pathwaysTried": pathways, "rescanned": rescanned,+                "editorCloseVia": if rescanned { json!(close_via) } else { Value::Null },+            });+            merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));+            return v;+        }+        if budget.out() {+            // Wiki #71: a navigation that ran against an editor that is up is not "starting".+            let title = title_of(frame);+            let mut v = json!({+                "success": false, "errorCode": "navigation_unconfirmed_within_budget", "retryable": true, "retryAfterMs": 3000,+                "elapsedSec": budget.elapsed_sec(), "editorOpened": true, "hwnd": frame, "rescanned": rescanned, "mechanism": nav_mech,+                "navigation": last_trace,+                "error": format!("The Symbol Editor is open and one background navigation ran, but the title did not confirm '{symbol_name}' before this call's time budget ended (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),+                "_hint": "Re-call with the SAME args: the editor is warm now, so the whole call is the navigation alone (seconds). Read navigation.steps and navigation.treeCandidates: zero candidates means Enter reached no window (report it with the trace).",+            });+            merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));+            return v;+        }+    }+    // Two background attempts did not confirm. Is the symbol even in the editor's world?+    invalidate();+    if first_of_kind(WindowKind::SymbolEditor).is_none() {+        return json!({+            "success": false, "editorOpened": false, "symbolLoaded": false, "errorCode": "symbol_editor_not_open",+            "error": format!("No Symbol Editor is open, so '{symbol_name}' could not be loaded (it was open earlier in this call if rescan was requested)."),+            "_hint": "The editor is gone rather than confused: nothing to navigate. Call again without rescan to open a fresh one. If this followed rescan:true, the close succeeded and the reopen did not - that is a bridge bug worth reporting, not a library problem.",+        });+    }+    let title = title_of(frame);+    // Wiki #54, #71 (the 0.9.287 staleVeto, ported): an editor THIS call opened indexed the+    // libraries after the library file was last written, so its cache cannot be stale. Saying+    // library_cache_stale there sends the caller to rescan:true, which only closes and reopens+    // an already fresh editor and burns the budget again. The verdict is navigation_failed.+    let on_disk = symbol_on_disk(info, symbol_name);+    let editor_is_ours = mechanism != "existing";+    let stale = on_disk && !rescan && !editor_is_ours;+    if stale {+        let ctx = lib_ctx(info);+        let paths = libraries::adom_paths(&ctx);+        return json!({+            "success": false, "editorOpened": true, "symbolLoaded": false, "attempts": attempts, "hwnd": frame,+            "errorCode": "library_cache_stale", "mechanism": last_mech, "navigation": last_trace,+            "symbolInLibraryFile": true,+            "symLibTable": paths.as_ref().map(|(t, _)| norm(t)),+            "libraryFile": paths.as_ref().map(|(_, l)| norm(l)),+            "error": format!("'{symbol_name}' is in the Adom library file on disk, but two background navigations did not load it (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),+            "_hint": "The verdict is only 'navigation failed while the symbol is in the Adom file'. Check the sym-lib-table row points at libraryFile (a OneDrive-redirected Documents folder is the classic split), then re-call with rescan:true to close and reopen the editor so it re-indexes; kicad_rescan_libraries force:true restarts KiCad when a reopen is not enough.",+        });+    }+    let mut v = json!({+        "success": false, "editorOpened": true, "symbolLoaded": false, "attempts": attempts, "hwnd": frame,+        "errorCode": "navigation_failed", "mechanism": last_mech, "navigation": last_trace, "pathwaysTried": pathways,+        "error": format!("Symbol Editor open but '{symbol_name}' didn't load in the background after two attempts (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),+        "_hint": "Two background attempts did not confirm, so a third is unlikely to help - check that the symbol really exists in this library. The keystroke fallback (allowFocusSteal) is not in the native build; pass foreground:true with a foregroundReason to bring the editor to the user and let them load it.",+    });+    if on_disk && editor_is_ours {+        v["symbolInLibraryFile"] = json!(true);+        v["staleVeto"] = json!(format!("this call opened the Symbol Editor itself ({mechanism}), so it indexed the libraries after the library file was last written: the index cannot be stale, and rescan:true would close and reopen an already fresh editor"));+        v["_hint"] = json!("The symbol IS in the Adom library file and this call opened the editor itself, so the index is fresh: this is a navigation failure, not a stale cache, and rescan:true cannot help. Read navigation.steps and navigation.treeCandidates (zero candidates means Enter reached no window, worth reporting with the trace), then pass foreground:true with a foregroundReason to hand the editor to the user.");+    }+    merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));+    v+}++fn merge(into: &mut Value, extra: Value) {+    if let (Some(o), Some(e)) = (into.as_object_mut(), extra.as_object()) {+        for (k, v) in e {+            o.insert(k.clone(), v.clone());+        }+    }+}++// ── kicad_open_footprint_editor ───────────────────────────────────────────────++pub(crate) fn open_footprint_editor(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    if !info.installed {+        return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");+    }+    let spec = &FOOTPRINT_SPEC;+    let budget = Budget::new(args);+    let footprint_name = arg_any(args, &["footprintName", "footprint"]).unwrap_or("").to_string();+    let library_name = arg_any(args, &["libraryName", "library"]).unwrap_or("").to_string();+    let mut pathways: Vec<String> = Vec::new();+    invalidate();+    if !footprint_name.is_empty() {+        for w in windows_of_kind(WindowKind::FootprintEditor) {+            if wm::title_confirms_footprint(&w.title, &footprint_name, &library_name) {+                let _ = native().show_no_activate(w.hwnd);+                let mut v = json!({"success": true, "hwnd": w.hwnd, "mechanism": "existing", "output": format!("Footprint '{footprint_name}' is already loaded in the Footprint Editor.")});+                merge(&mut v, wm::resolved_fields(&w.title, &library_name, "Footprint Editor"));+                return v;+            }+        }+    }+    if args.get("allowCursorClick").and_then(Value::as_bool).unwrap_or(false) {+        pathways.push("icon_click_not_in_native_build".into());+    } else {+        pathways.push("icon_click_skipped_background_mode".into());+    }+    let (frame, mechanism) = match open_editor_frame(&info, spec, &budget, &mut pathways) {+        Ok(x) => x,+        Err(mut e) => {+            if e["errorCode"] == json!(spec.error_code) {+                e["error"] = json!("Could not open the Footprint Editor via the background path.");+                e["_hint"] = json!("The background rungs (the host frame's native menu, then UI Automation) fired without a Footprint Editor appearing. Open a board first (kicad_open_board) so a PCB Editor hosts the launcher, check kicad_screenshot_all for a modal dialog, then retry. The mouse-moving launcher click (allowCursorClick) is not in the native build.");+            }+            return e;+        }+    };+    if mechanism != "existing" {+        sleep_ms(1500);+    }+    let _ = native().show_no_activate(frame);+    if footprint_name.is_empty() && library_name.is_empty() {+        return json!({"success": true, "hwnd": frame, "mechanism": mechanism, "pathwaysTried": pathways, "output": if mechanism == "existing" { "Footprint Editor is already open.".to_string() } else { format!("Footprint Editor opened successfully ({mechanism}).") }});+    }+    if footprint_name.is_empty() {+        let (_, trace, nav_mech) = navigate_tree(frame, &library_name, Verify::Never);+        return json!({+            "success": true, "hwnd": frame, "libraryShown": library_name, "mechanism": nav_mech, "openedVia": mechanism, "navigation": trace,+            "output": format!("Footprint Editor open with the '{library_name}' library filtered and selected. No footprint was requested, so none was loaded into the canvas."),+            "via": "win32-library-browse",+            "_hint": "Library browse mode: the tree is filtered to the library. To load a part, call again with footprintName too.",+        });+    }+    let filter = if library_name.is_empty() { footprint_name.clone() } else { format!("{library_name} {footprint_name}") };+    let verify = Verify::Footprint { name: &footprint_name, library: &library_name };+    let mut last_trace = Value::Null;+    let mut last_mech = "post";+    for attempt in 1..=2 {+        let (ok, trace, nav_mech) = navigate_tree(frame, &filter, verify);+        last_trace = trace;+        last_mech = nav_mech;+        if ok {+            let title = title_of(frame);+            let mut v = json!({+                "success": true, "hwnd": frame, "mechanism": nav_mech, "openedVia": mechanism, "via": "win32-background", "attempts": attempt,+                "output": format!("Footprint '{footprint_name}' loaded (background {nav_mech} path{}): {title}", if attempt == 2 { ", 2nd attempt" } else { "" }),+                "navigation": last_trace, "pathwaysTried": pathways,+            });+            merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor"));+            return v;+        }+        if budget.out() {+            return json!({+                "success": false, "errorCode": "navigation_unconfirmed_within_budget", "retryable": true, "retryAfterMs": 3000,+                "elapsedSec": budget.elapsed_sec(), "editorOpened": true, "hwnd": frame, "mechanism": nav_mech, "navigation": last_trace,+                "error": format!("The Footprint Editor is open and one background navigation ran, but the title did not confirm '{footprint_name}' before this call's time budget ended (title: {})", title_of(frame)),+                "_hint": "Re-call with the SAME args: the editor is warm now, so the whole call is the navigation alone (seconds).",+            });+        }+    }+    let title = title_of(frame);+    let mut v = json!({+        "success": false, "editorOpened": true, "footprintLoaded": false, "attempts": 2, "hwnd": frame,+        "errorCode": "navigation_failed", "mechanism": last_mech, "navigation": last_trace, "pathwaysTried": pathways,+        "error": format!("Footprint Editor open but '{footprint_name}' didn't load in the background after two attempts (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),+        "_hint": "Two background attempts did not confirm - check that the footprint really exists in this library (kicad_list_footprints). If it was installed after the editor opened, KiCad's library index predates it: kicad_close_footprint_editor and re-call, or kicad_rescan_libraries force:true. The keystroke fallback (allowFocusSteal) is not in the native build.",+    });+    merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor"));+    v+}++// ── kicad_open_3d_viewer ──────────────────────────────────────────────────────++fn viewer_for_frame(frame: u64, source_pid: u32, windows: Vec<Win>) -> Option<Win> {+    let exact: Vec<_> = windows.iter().filter(|w| w.owner == frame).cloned().collect();+    if exact.len() == 1 { return exact.into_iter().next(); }+    let same_process: Vec<_> = windows.into_iter().filter(|w| source_pid != 0 && w.pid == source_pid).collect();+    if same_process.len() == 1 { same_process.into_iter().next() } else { None }+}+fn poll_3d_window(frame: u64, source_pid: u32, rounds: usize) -> Option<Win> {+    for _ in 0..rounds.max(1) {+        sleep_ms(750);+        invalidate();+        if let Some(w) = viewer_for_frame(frame, source_pid, windows_of_kind(WindowKind::Viewer3d)) {+            return Some(w);+        }+    }+    None+}++/// The rungs for one source frame: native menu walk, then UIA Invoke. Ok(Some) when a+/// 3D Viewer window appeared.+fn open_3d_from(frame: u64, label: &str, pathways: &mut Vec<String>) -> Result<Option<Value>, Value> {+    let source_pid = match kicad_windows().into_iter().find(|w| w.hwnd == frame) {+        Some(w) => w.pid,+        None => return Err(fail("source_editor_missing", "The requested source editor is no longer open.", "Call kicad_open_editors and pass the current pcbHwnd or fpHwnd.")),+    };+    let mut unsupported_menu: Option<Value> = None;+    match menu_invoke_by_text(frame, "3d viewer") {+        Ok(Some((id, _))) => {+            pathways.push(format!("win32menu(id={id})"));+            if let Some(w) = poll_3d_window(frame, source_pid, 12) {+                return Ok(Some(json!({"success": true, "pathway": "win32menu", "mechanism": "menu", "hwnd": w.hwnd, "sourceHwnd": frame, "sourcePid": source_pid, "viewerPid": w.pid, "title": w.title, "output": format!("3D Viewer opened from {label} (Win32 menu): {}", w.title), "pathwaysTried": pathways})));+            }+        }+        Ok(None) => pathways.push("win32menu-not-found".into()),+        Err(e) if is_not_impl(&e) => unsupported_menu = Some(unsupported("native menu walk (menu_tree)")),+        Err(e) => pathways.push(format!("win32menu-{}", e.chars().take(40).collect::<String>())),+    }+    match native().uia_invoke(frame, "3D Viewer") {+        Ok(_) => {+            pathways.push("uia".into());+            if let Some(w) = poll_3d_window(frame, source_pid, 12) {+                return Ok(Some(json!({"success": true, "pathway": "uia", "mechanism": "uia", "hwnd": w.hwnd, "sourceHwnd": frame, "sourcePid": source_pid, "viewerPid": w.pid, "title": w.title, "output": format!("3D Viewer opened from {label} via UIA: {}", w.title), "pathwaysTried": pathways})));+            }+        }+        Err(e) if is_not_impl(&e) => {+            // Neither rung exists on this OS: say so instead of "did not open".+            if let Some(u) = unsupported_menu {+                return Err(u);+            }+            pathways.push("uia-unsupported".into());+        }+        Err(e) => pathways.push(format!("uia-{}", e.chars().take(40).collect::<String>())),+    }+    Ok(None)+}+++/// `kicad_3d_view`: the 3D Viewer's View-menu commands by name, repeated with pauses, or a tour.++/// `kicad_menu_items`: the native menu bar of a KiCad window, read-only.+pub(crate) fn menu_items(args: &Value) -> Value {+    let hwnd = match arg_any_u64(args, &["hwnd"]).or_else(|| foreground_kicad_hwnd()) {+        Some(h) => h,+        None => return fail("no_window", "No KiCad window found.", "kicad_window_info lists the windows; pass hwnd."),+    };+    let needle = arg_str(args, "contains").map(|s| s.to_lowercase()).unwrap_or_default();+    match native().menu_tree(hwnd) {+        Ok(items) => {+            let list: Vec<Value> = items.iter().filter(|it| needle.is_empty() || it.path.iter().any(|p| p.replace('&', "").to_lowercase().contains(&needle))).map(|it| json!({"path": it.path.iter().map(|p| p.replace('&', "")).collect::<Vec<_>>(), "id": it.id, "accel": it.accel})).collect();+            json!({"success": true, "hwnd": hwnd, "title": title_of(hwnd), "count": list.len(), "items": list})+        }+        Err(e) if is_not_impl(&e) => unsupported("native menu walk (menu_tree)"),+        Err(e) => fail("menu_walk_failed", &e, "Retry once the window is idle."),+    }+}++pub(crate) fn view_3d(args: &Value) -> Value {+    const ACTIONS: &[(&str, &str)] = &[+        ("pan_left", "Move Board Left"), ("pan_right", "Move Board Right"),+        ("pan_up", "Move Board Up"), ("pan_down", "Move Board Down"),+        ("zoom_in", "Zoom In"), ("zoom_out", "Zoom Out"), ("fit", "Zoom to Fit"),+        ("rotate_x_cw", "Rotate X Clockwise"), ("rotate_x_ccw", "Rotate X Counterclockwise"),+        ("rotate_y_cw", "Rotate Y Clockwise"), ("rotate_y_ccw", "Rotate Y Counterclockwise"),+        ("rotate_z_cw", "Rotate Z Clockwise"), ("rotate_z_ccw", "Rotate Z Counterclockwise"),+        ("view_top", "View Top"), ("view_bottom", "View Bottom"), ("view_front", "View Front"), ("view_back", "View Back"),+        ("view_left", "View Left"), ("view_right", "View Right"), ("flip", "Flip Board"),+    ];+    let action = arg_str(args, "action").unwrap_or("").trim().to_lowercase();+    if action == "actions" || action.is_empty() {+        return json!({"success": true, "actions": ACTIONS.iter().map(|(a, l)| json!({"action": a, "menuItem": l})).collect::<Vec<_>>(), "tour": "fit, view_top, rotate_x_ccw x2, rotate_z_cw x8, zoom_in x3, rotate_z_ccw x4, zoom_out x3, view_front, rotate_z_cw x4, view_bottom, flip, view_top, fit", "_hint": "Pass action (and count / pauseMs), or action:\"tour\" with an optional steps list of [action, count] pairs."});+    }+    invalidate();+    let hwnd = match arg_any_u64(args, &["hwnd"]).or_else(|| first_of_kind(WindowKind::Viewer3d)) {+        Some(h) => h,+        None => return fail("viewer_not_open", "No 3D Viewer window is open.", "kicad_open_3d_viewer {editor:\"pcb\"} opens it from the PCB Editor's View menu; it takes a few seconds to render."),+    };+    let pause = args.get("pauseMs").and_then(Value::as_u64).unwrap_or(700).clamp(0, 10_000);+    let count = args.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 60) as usize;+    let steps: Vec<(String, usize)> = if action == "tour" {+        match args.get("steps").and_then(Value::as_array) {+            Some(a) => a.iter().filter_map(|e| { let a = e.as_array()?; Some((a.first()?.as_str()?.to_lowercase(), a.get(1).and_then(Value::as_u64).unwrap_or(1).clamp(1, 60) as usize)) }).collect(),+            None => vec![("fit", 1), ("view_top", 1), ("rotate_x_ccw", 2), ("rotate_z_cw", 8), ("zoom_in", 3), ("rotate_z_ccw", 4), ("zoom_out", 3), ("view_front", 1), ("rotate_z_cw", 4), ("view_bottom", 1), ("flip", 1), ("view_top", 1), ("fit", 1)].into_iter().map(|(a, n)| (a.to_string(), n)).collect(),+        }+    } else {+        vec![(action.clone(), count)]+    };+    let mut done: Vec<Value> = Vec::new();+    let started = Instant::now();+    for (act, n) in &steps {+        let Some((_, label)) = ACTIONS.iter().find(|(a, _)| *a == act.as_str()) else {+            return fail("bad_action", format!("Unknown action '{act}'."), "kicad_3d_view {action:\"actions\"} lists them.");+        };+        for _ in 0..*n {+            match menu_invoke_by_text(hwnd, label) {+                Ok(Some((id, _))) => { done.push(json!({"action": act, "cmdId": id})); }+                Ok(None) => return fail("menu_item_not_found", format!("The 3D Viewer has no menu item '{label}' (KiCad version?)."), "kicad_send_key {hwnd, menu:...} with a substring finds what this build calls it."),+                Err(e) => return fail("menu_invoke_failed", &e, "Retry once the viewer has finished rendering."),+            }+            std::thread::sleep(Duration::from_millis(pause));+        }+    }+    json!({"success": true, "hwnd": hwnd, "commands": done.len(), "steps": steps.iter().map(|(a, n)| json!([a, n])).collect::<Vec<_>>(), "seconds": (started.elapsed().as_secs_f64() * 10.0).round() / 10.0, "foreground": false,+        "_hint": "Every command was posted to the viewer's own menu in the background. kicad_screenshot_all shows the view; a window recording of the viewer has the motion."})+}++pub(crate) fn open_3d_viewer(args: &Value) -> Value {+    let choice = arg_str(args, "editor").unwrap_or("auto").to_lowercase();+    if !["auto", "fp", "footprint", "pcb", "board"].contains(&choice.as_str()) {+        return fail("bad_arg", format!("Unknown editor '{choice}'. Use 'auto', 'fp', or 'pcb'."), "editor selects which open editor's View menu opens the viewer.");+    }+    invalidate();+    let mut fp = first_of_kind(WindowKind::FootprintEditor);+    let mut pcb = first_of_kind(WindowKind::PcbEditor);+    // Explicit source editors (tour, 2026-09-03): a caller that knows its editor's hwnd+    // passes pcbHwnd / fpHwnd and we never guess among several PCB Editors.+    if let Some(h) = arg_any_u64(args, &["pcbHwnd", "pcb_hwnd"]) {+        pcb = Some(h);+    }+    if let Some(h) = arg_any_u64(args, &["fpHwnd", "fp_hwnd"]) {+        fp = Some(h);+    }+    match choice.as_str() {+        "fp" | "footprint" => {+            if fp.is_none() {+                return fail("footprint_editor_not_open", "Footprint Editor is not open.", "Call open_footprint_editor first or pass editor='pcb'.");+            }+            pcb = None;+        }+        "pcb" | "board" => {+            if pcb.is_none() {+                return fail("pcb_editor_not_open", "PCB Editor is not open.", "Call open_board first or pass editor='fp'.");+            }+            fp = None;+        }+        _ => {+            if pcb.is_some() && fp.is_some() {+                fp = None; // auto: prefer PCB when both are open (tour-like flow)+            }+        }+    }+    let mut pathways: Vec<String> = Vec::new();+    if let Some(f) = fp {+        match open_3d_from(f, "Footprint Editor", &mut pathways) {+            Ok(Some(v)) => return v,+            Err(e) => return e,+            Ok(None) => {}+        }+        if choice == "auto" && pcb.is_none() {+            pcb = first_of_kind(WindowKind::PcbEditor);+        }+        if pcb.is_none() {+            return json!({"success": false, "errorCode": "3d_viewer_did_not_open", "pathwaysTried": pathways, "error": "3D Viewer did not open from Footprint Editor.", "_hint": "Call open_footprint_editor with a footprintName first, OR pass editor='pcb' to target the board's 3D viewer instead."});+        }+    }+    if let Some(p) = pcb {+        match open_3d_from(p, "PCB Editor", &mut pathways) {+            Ok(Some(v)) => return v,+            Err(e) => return e,+            Ok(None) => {}+        }+        return json!({"success": false, "errorCode": "3d_viewer_did_not_open", "pathwaysTried": pathways, "error": "3D Viewer did not open via the background path.", "_hint": "Call open_board {\"filePath\":\"...\"} to load a PCB in pcbnew first, then retry; kicad_screenshot_all shows whether a dialog is holding the editor."});+    }+    fail("no_source_editor", "Neither Footprint Editor nor PCB Editor is open. Open one first with open_footprint_editor or open_board.", "Call open_footprint_editor or open_board {\"filePath\":\"...\"} first, then retry.")+}++fn arg_any_u64(args: &Value, keys: &[&str]) -> Option<u64> {+    keys.iter().find_map(|k| args.get(*k)).and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))).filter(|h| *h != 0)+}++// ── kicad_open_editors ────────────────────────────────────────────────────────++fn open_editors() -> Value {+    let (wins, source) = match find_windows(false, true) {+        Ok(x) => x,+        Err(e) => return e,+    };+    let mut editors = Vec::new();+    let mut by_kind: BTreeMap<String, Vec<Value>> = BTreeMap::new();+    for w in &wins {+        let kind = w.kind();+        if !kind.is_editor() && kind != WindowKind::ProjectManager {+            continue;+        }+        let row = json!({"kind": kind.as_str(), "exeName": w.exe, "pid": w.pid, "frameClass": w.class_name, "frameTitle": w.title, "hwnd": w.hwnd, "isShown": !w.minimized});+        by_kind.entry(kind.as_str().to_string()).or_default().push(row.clone());+        editors.push(row);+    }+    let running = !wins.is_empty();+    let hint = if editors.is_empty() && !running {+        "0 editors and no KiCad window: KiCad is almost certainly NOT RUNNING. This verb only INVENTORIES editors inside a running KiCad (despite its name); it never launches anything. To cold-start KiCad use kicad_launch; kicad_open_board / kicad_open_symbol_editor also cold-launch when opening a specific target."+    } else {+        "Inventory from window enumeration (the native build has no in-process plugin): each KiCad top-level frame is classified by its title. kind is one of project_manager, schematic_editor, pcb_editor, symbol_editor, footprint_editor, 3d_viewer."+    };+    json!({+        "success": true, "editors": editors, "byKind": by_kind, "kicadRunning": running, "windowSource": source,+        "summary": {"totalEditors": editors.len(), "probedPlugins": 0, "failedPlugins": 0, "byKindCounts": by_kind.iter().map(|(k, v)| (k.clone(), json!(v.len()))).collect::<Map<String, Value>>()},+        "_hint": hint,+    })+}++// ── kicad_close* ──────────────────────────────────────────────────────────────++fn close_kind(kind: WindowKind, label: &str) -> Value {+    invalidate();+    let wins = match find_windows(false, true) {+        Ok((w, _)) => w.into_iter().filter(|w| w.kind() == kind).collect::<Vec<_>>(),+        Err(e) => return e,+    };+    if wins.is_empty() {+        return json!({"success": true, "output": format!("{label} is not open.")});+    }+    let mut closed = Vec::new();+    let mut failed = Vec::new();+    let mut hows = Vec::new();+    for w in &wins {+        let (gone, how) = close_and_wait(w.hwnd, &format!("Close the KiCad {label} the calling AI asked to close"), Duration::from_secs(5));+        hows.push(how);+        if gone { closed.push(w.title.clone()) } else { failed.push(w.title.clone()) }+    }+    if !failed.is_empty() {+        let prompt = find_save_prompt();+        let mut v = fail("close_failed", format!("Failed to close: {}", failed.join(", ")), "Call close_kicad with {\"force\": true} to force-kill the window, then retry.");+        if let Some(p) = prompt {+            v["errorCode"] = json!("unsaved_changes");+            v["unsavedChanges"] = json!([{"window": failed[0], "dialog": p.title, "body": p.body}]);+            v["_hint"] = json!("KiCad is asking whether to save changes. Ask the user, then save in KiCad or call kicad_close {\"discardChanges\": true} to press Discard on their behalf.");+        }+        return v;+    }+    json!({+        "success": true, "closeVia": hows,+        "output": if closed.len() == 1 { format!("{label} closed: {}", closed[0]) } else { format!("Closed {} {label} window(s).", closed.len()) },+    })+}++/// kicad_close {force:true}: the ONLY path that kills a process.+fn force_kill_kicad() -> Result<Vec<String>, Value> {+    let procs = plat("process enumeration (processes)", native().processes(wm::KILL_PROCESS_NAMES))?;+    let mut killed = Vec::new();+    for name in wm::KILL_PROCESS_NAMES {+        let mut any = false;+        for p in procs.iter().filter(|p| p.exe.eq_ignore_ascii_case(name) || p.exe.eq_ignore_ascii_case(name.trim_end_matches(".exe"))) {+            if native().kill_process(p.pid).is_ok() {+                any = true;+            }+        }+        if any {+            killed.push(name.to_string());+        }+    }+    invalidate();+    Ok(killed)+}++#[derive(Default)]+struct CloseState {+    discard: bool,+    closed: Vec<String>,+    failed: Vec<String>,+    unsaved: Vec<Value>,+    discard_log: Vec<Value>,+}++/// One window: WM_CLOSE and wait; on a save prompt press Discard (discardChanges) or+/// report unsavedChanges. Some(true) closed, Some(false) failed, None already recorded.+fn close_one(st: &mut CloseState, w: &Win) -> Option<bool> {+    let (gone, _) = close_and_wait(w.hwnd, "Close KiCad as the calling AI asked", Duration::from_secs(8));+    if gone {+        return Some(true);+    }+    let Some(prompt) = find_save_prompt() else { return Some(false) };+    if st.discard {+        let mut attempts = Vec::new();+        let mut pressed = match native().click_button(prompt.hwnd, wm::DISCARD_LABELS) {+            Ok(m) => { attempts.push(json!({"win32Button": true, "method": m})); true }+            Err(e) => { attempts.push(json!({"win32Button": false, "error": e.chars().take(120).collect::<String>()})); false }+        };+        if !pressed {+            for label in wm::DISCARD_LABELS_UIA {+                match native().uia_invoke(prompt.hwnd, label) {+                    Ok(m) => { attempts.push(json!({"label": label, "uia": m})); pressed = true; break; }+                    Err(e) => attempts.push(json!({"label": label, "uia": e.chars().take(120).collect::<String>()})),+                }+            }+        }+        st.discard_log.push(json!({"window": w.title, "dialog": prompt.title, "attempts": attempts}));+        if pressed {+            let deadline = Instant::now() + Duration::from_secs(8);+            while Instant::now() < deadline {+                sleep_ms(300);+                invalidate();+                if !window_alive(w.hwnd) {+                    st.closed.push(format!("{} (changes discarded)", w.title));+                    return None;+                }+            }+        }+    }+    st.unsaved.push(json!({"window": w.title, "dialog": prompt.title, "body": prompt.body}));+    Some(false)+}++fn close_kicad(_state: &mut State, args: &Value) -> Value {+    let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);+    let discard = args.get("discardChanges").and_then(Value::as_bool).unwrap_or(false);+    // Wiki #48: a close is the last moment these windows exist, so learn where they are+    // before the geometry is gone. A kill in particular denies KiCad its own save.+    remember_placement();+    if force {+        return match force_kill_kicad() {+            Ok(k) if !k.is_empty() => json!({"success": true, "output": format!("Force-killed: {}", k.join(", ")), "data": {"forceKilled": k}}),+            Ok(_) => json!({"success": true, "output": "KiCad is not running (nothing to kill)."}),+            Err(e) => e,+        };+    }+    invalidate();+    let wins = match find_windows(false, true) {+        Ok((w, _)) => w,+        Err(e) => return e,+    };+    let mut editors = Vec::new();+    let mut manager: Option<Win> = None;+    for w in wins.into_iter().filter(|w| w.owner == 0) {+        if wm::is_manager_title(&w.title) {+            manager = Some(w);+        } else {+            editors.push(w);+        }+    }+    if editors.is_empty() && manager.is_none() {+        return json!({"success": true, "output": "KiCad is not running."});+    }+    // Issue #84: a dirty board answers WM_CLOSE with "Save changes?". Read it; with+    // discardChanges press Discard and stay graceful; otherwise leave the window open and+    // report unsavedChanges. Nothing is ever killed here.+    let mut st = CloseState::default();+    st.discard = discard;+    for w in &editors {+        match close_one(&mut st, w) {+            Some(true) => st.closed.push(w.title.clone()),+            Some(false) => st.failed.push(w.title.clone()),+            None => {}+        }+    }+    if let (Some(m), true) = (&manager, st.unsaved.is_empty()) {+        match close_one(&mut st, m) {+            Some(true) => st.closed.push(m.title.clone()),+            Some(false) => st.failed.push(m.title.clone()),+            None => {}+        }+    }+    let CloseState { closed, failed, unsaved, discard_log, .. } = st;+    if !unsaved.is_empty() {+        return json!({+            "success": false, "errorCode": "unsaved_changes", "unsavedChanges": unsaved,+            "error": format!("KiCad is asking whether to save changes; nothing was killed: {}", unsaved.iter().map(|u| format!("{}: {}", u["window"].as_str().unwrap_or(""), u["body"].as_str().unwrap_or("").chars().take(80).collect::<String>())).collect::<Vec<_>>().join("; ")),+            "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) },+            "_hint": "The user's unsaved work is still open. Ask them, then either save in KiCad (Ctrl+S via desktop_press_key) and call kicad_close again, or call kicad_close {\"discardChanges\": true} to press Discard on their behalf. force:true kills the process, which discards silently.",+            "discard": discard_log,+        });+    }+    if !failed.is_empty() {+        // No save prompt and no close: the native build never kills here (force:true is+        // the only kill path); say what did not close and how to force it.+        return json!({+            "success": false, "errorCode": "close_failed",+            "error": format!("Failed to close: {}", failed.join(", ")),+            "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) },+            "_hint": "Call close_kicad with {\"force\": true} to force-kill stuck windows (a kill discards unsaved work silently). kicad_screenshot_all shows whether a dialog is holding the window.",+        });+    }+    let mut out = json!({"success": true, "output": format!("KiCad closed ({} window{}).", closed.len(), if closed.len() != 1 { "s" } else { "" }), "data": {}});+    if !discard_log.is_empty() {+        out["discard"] = json!(discard_log);+    }+    out+}++// ── kicad_dismiss_dialogs / kicad_window_info ─────────────────────────────────++fn dismiss_dialogs(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    if args.get("forceSoftwareCanvas").and_then(Value::as_bool).unwrap_or(false) {+        let changed = force_software_canvas(&info.version);+        return json!({+            "success": true, "forcedSoftwareCanvas": changed,+            "output": if changed { "forced Cairo software canvas (canvas_type=2)" } else { "software canvas already set / no config" },+            "_hint": "Close and reopen KiCad editors; they will render via Cairo (no OpenGL) so no GPU is needed.",+        });+    }+    if let Err(e) = find_windows(true, true) {+        return e;+    }+    if args.get("debug").and_then(Value::as_bool).unwrap_or(false) {+        let wins = scan_dialogs();+        let pids: Vec<u32> = native().processes(wm::KICAD_EXES).map(|p| p.iter().map(|p| p.pid).collect()).unwrap_or_default();+        return json!({+            "success": true, "debug": true, "kicadPids": pids,+            "windows": wins.iter().map(|w| json!({"title": w.title, "className": w.class_name, "owner": w.owner, "isDialog": true, "body": w.body.chars().take(80).collect::<String>(), "viaGlobalSweep": w.via_global_sweep})).collect::<Vec<_>>(),+            "kicadInfoHasBinDir": info.primary().map(|i| !i.bin_dir.is_empty()).unwrap_or(false),+        });+    }+    let all = args.get("all").and_then(Value::as_bool).unwrap_or(false);+    let screenshot = args.get("screenshot").and_then(Value::as_bool).unwrap_or(true);+    let res = sweep_dialogs(&info, all, screenshot);+    let n = res["dismissed"].as_array().map(|a| a.len()).unwrap_or(0);+    let remaining = res["remaining"].as_array().map(|a| a.len()).unwrap_or(0);+    let forced = res["forcedSoftwareCanvas"].as_bool().unwrap_or(false);+    json!({+        "success": true,+        "output": format!("dismissed {n} dialog(s), {remaining} remaining{}", if forced { "; forced Cairo software canvas (no GPU); close+reopen editors" } else { "" }),+        "dismissed": res["dismissed"], "remaining": res["remaining"], "forcedSoftwareCanvas": forced,+        "_hint": format!("Expired {n} dialog(s). Now retry your open/screenshot verb. Benign-only by default; pass {{\"all\":true}} to clear every dialog. The OpenGL notice on a GPU-less host is auto-expired by window_info too."),+    })+}++fn window_info(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    if let Err(e) = find_windows(true, true) {+        return e;+    }+    let mut auto_dismissed = json!([]);+    let mut forced_sw = false;+    if args.get("autoDismiss").and_then(Value::as_bool).unwrap_or(true) {+        let s = sweep_dialogs(&info, false, false);+        auto_dismissed = s["dismissed"].clone();+        forced_sw = s["forcedSoftwareCanvas"].as_bool().unwrap_or(false);+    }+    invalidate();+    let (wins, source) = match find_windows(false, true) {+        Ok(x) => x,+        Err(e) => return e,+    };+    let mut editors = Vec::new();+    let mut dialogs = Vec::new();+    let mut manager = Value::Null;+    for w in &wins {+        let base = json!({"hwnd": w.hwnd, "title": w.title, "className": w.class_name, "rect": w.rect_json(), "z": w.z, "minimized": w.minimized, "maximized": w.maximized, "hung": w.hung, "kind": w.kind().as_str()});+        if w.owner != 0 || w.class_name == wm::DIALOG_CLASS {+            let mut d = base;+            d["ownerHwnd"] = json!(w.owner);+            d["body"] = json!(dialog_body_text(w.hwnd));+            dialogs.push(d);+        } else if wm::is_manager_title(&w.title) {+            manager = base;+        } else {+            editors.push(base);+        }+    }+    let mut data = json!({+        "projectManager": manager, "editors": editors, "modalDialogs": dialogs, "hasModalDialogs": !dialogs.is_empty(),+        "autoDismissed": auto_dismissed, "windowSource": source,+    });+    if forced_sw {+        data["forcedSoftwareCanvas"] = json!(true);+    }+    let n_auto = data["autoDismissed"].as_array().map(|a| a.len()).unwrap_or(0);+    if n_auto > 0 {+        data["_autoDismissHint"] = json!(format!("Auto-expired {n_auto} benign blocking dialog(s) (e.g. the OpenGL/software-rendering notice) so editors are not stalled; no action needed.{}", if forced_sw { " This host lacks OpenGL, so KiCad was switched to the Cairo software canvas (kicad_common.json canvas_type=2): CLOSE and REOPEN the editor for it to render without a GPU." } else { "" }));+    }+    if !dialogs.is_empty() {+        data["_dialogHint"] = json!("hasModalDialogs is true: KiCad has a BLOCKING modal dialog. Read each modalDialogs[].body for the message text; if body is empty, screenshot modalDialogs[].hwnd via desktop_screenshot_window. Dismiss with kicad_send_key {hwnd, key:'return'} (OK/default) or 'escape' (cancel).");+    }+    json!({"success": true, "output": format!("{} editor(s), {} dialog(s)", editors.len(), dialogs.len()), "data": data})+}++// ── Screenshots (ab) and the canvas probe (local) ─────────────────────────────++/// Uniformity verdict for the editor canvas: one local capture, sampled, discarded.+fn canvas_probe(hwnd: u64) -> Value {+    let Ok(cap) = native().capture_window(hwnd) else { return Value::Null };+    let rect = native().gl_canvas_rect(hwnd).ok().flatten().map(|(x, y, w, h)| (x, y, x + w, y + h));+    wm::canvas_uniformity(&cap.rgb, cap.width, cap.height, rect)+}++/// Capture a window by hwnd: ab takes the picture (owned popups, coordMap, auto-pull);+/// the canvas verdict is always ours.+pub(crate) fn screenshot_hwnd(hwnd: u64, label: &str, resize_max: Option<u32>) -> Value {+    let mut v = match ab::desktop_screenshot_window(hwnd, &format!("Capture the KiCad window {label} as evidence for the calling AI"), resize_max) {+        Ok(shot) => shot.to_json(),+        Err(e) => json!({"success": false, "error": e.message(), "abUnavailable": e.is_unavailable(), "_hint": "The native build takes every screenshot through ab's desktop_screenshot_window; with ab unreachable no image is written. Only the canvas probe (canvas.rendered) answers locally."}),+    };+    v["canvas"] = canvas_probe(hwnd);+    v+}++fn screenshot_all(state: &mut State) -> Value {+    let info = window_info(state, &json!({}));+    if info["success"] != json!(true) {+        return info;+    }+    let data = &info["data"];+    let mut windows: Vec<(u64, String, &str)> = Vec::new();+    for ed in data["editors"].as_array().into_iter().flatten() {+        windows.push((ed["hwnd"].as_u64().unwrap_or(0), ed["title"].as_str().unwrap_or("").into(), "editor"));+    }+    if let Some(h) = data["projectManager"]["hwnd"].as_u64() {+        windows.push((h, data["projectManager"]["title"].as_str().unwrap_or("").into(), "main"));+    }+    for d in data["modalDialogs"].as_array().into_iter().flatten() {+        windows.push((d["hwnd"].as_u64().unwrap_or(0), d["title"].as_str().unwrap_or("").into(), "dialog"));+    }+    if windows.is_empty() {+        return fail("no_windows", "No KiCad windows found.", "kicad_launch or an open_* verb first; kicad_window_info lists what is up.");+    }+    let mut shots = Vec::new();+    let mut ab_unavailable = false;+    for (hwnd, title, kind) in &windows {+        let label = wm::safe_label(title, kind);+        let r = screenshot_hwnd(*hwnd, &label, None);+        let mut entry = json!({"type": kind, "title": title, "hwnd": hwnd, "canvas": r["canvas"]});+        if r["success"] == json!(true) {+            for k in ["savedTo", "fullPath", "safePath", "localFullPath", "sizeKB", "capturedBy", "shotId", "coordMap", "ownedPopupCount", "popups", "screenshots"] {+                if let Some(v) = r.get(k) {+                    entry[k] = v.clone();+                }+            }+        } else {+            entry["error"] = json!(r["error"].as_str().unwrap_or("screenshot failed"));+            if r["abUnavailable"] == json!(true) {+                ab_unavailable = true;+            }+        }+        shots.push(entry);+    }+    let captured = shots.iter().filter(|s| s.get("fullPath").is_some()).count();+    let mut out = json!({+        "success": captured > 0 || !ab_unavailable,+        "output": format!("Captured {captured} of {} KiCad window(s)", shots.len()),+        "data": {"screenshots": shots},+        "_hint": "Each entry is a background PrintWindow capture by ab with its owned popups (popups[]) and a coordMap: click what you see with desktop_click {space:\"image\", shotId, x, y}. canvas.rendered false means the GL canvas has not painted yet (or there is no usable GPU).",+    });+    if ab_unavailable {+        out["abUnavailable"] = json!(true);+        out["errorCode"] = json!("ab_unavailable");+        out["_hint"] = json!("ab's direct API is unreachable, so no image was written (the native build has no local PNG path). ADOM_DIRECT_API_URL missing from the spawn env is the usual cause; ab's own desktop_screenshot_window from the CLI still works.");+    }+    out+}++// ── kicad_state ───────────────────────────────────────────────────────────────++pub(crate) fn b64_encode(bytes: &[u8]) -> String {+    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";+    let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4);+    for chunk in bytes.chunks(3) {+        let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];+        let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;+        out.push(T[(n >> 18) as usize & 63] as char);+        out.push(T[(n >> 12) as usize & 63] as char);+        out.push(if chunk.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });+        out.push(if chunk.len() > 2 { T[n as usize & 63] as char } else { '=' });+    }+    out+}++fn kicad_state(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    let (wins, source) = match find_windows(false, true) {+        Ok(x) => x,+        Err(e) => return e,+    };+    let include_images = args.get("includeImages").and_then(Value::as_bool).unwrap_or(true);+    let max_width = args.get("maxWidth").and_then(Value::as_u64).unwrap_or(1100) as u32;+    let mut shots = Vec::new();+    let mut ab_unavailable = false;+    if include_images {+        for w in &wins {+            let mut entry = json!({"title": w.title, "hwnd": w.hwnd, "className": w.class_name});+            let r = screenshot_hwnd(w.hwnd, "state", Some(max_width));+            entry["canvas"] = r["canvas"].clone();+            if r["success"] == json!(true) {+                let path = r["safePath"].as_str().filter(|p| !p.is_empty()).or_else(|| r["fullPath"].as_str()).unwrap_or("");+                match std::fs::read(path) {+                    Ok(bytes) if path.to_lowercase().ends_with(".png") => {+                        entry["image"] = json!(b64_encode(&bytes));+                        entry["path"] = json!(path);+                        if let Some(c) = r.get("coordMap") {+                            if let (Some(w), Some(h)) = (c["image"]["w"].as_u64(), c["image"]["h"].as_u64()) {+                                entry["width"] = json!(w);+                                entry["height"] = json!(h);+                            }+                        }+                        if let Some(s) = r.get("shotId") { entry["shotId"] = s.clone(); }+                        if let Some(p) = r.get("popups") { entry["popups"] = p.clone(); }+                    }+                    Ok(_) => { entry["error"] = json!("capture is not a png"); entry["path"] = json!(path); }+                    Err(e) => { entry["error"] = json!(format!("could not read {path}: {e}")); }+                }+            } else {+                entry["error"] = json!(r["error"].as_str().unwrap_or("capture failed"));+                if r["abUnavailable"] == json!(true) {+                    ab_unavailable = true;+                }+            }+            shots.push(entry);+        }+    }+    let editors = open_editors();+    let modal: Vec<Value> = scan_dialogs().into_iter().map(|d| json!({"hwnd": d.hwnd, "title": d.title, "body": d.body})).collect();+    let loop_events = native().etiquette_events();+    let mut events: Vec<Value> = FOCUS_EVENTS.lock().map(|g| g.iter().cloned().collect()).unwrap_or_default();+    events.extend(loop_events);+    let events: Vec<Value> = events.into_iter().rev().take(40).rev().collect();+    let focus_debug = match native().etiquette_debug() {+        Value::Null => json!({"guardian": "no etiquette loop on this OS; one-shot check per verb only", "oneShotCheck": true}),+        v => v,+    };+    let mut out = json!({+        "success": true, "running": !wins.is_empty(), "focusEvents": events,+        // PRESENCE GATE (John, 2026-08-17): automation that spawns windows checks this+        // first and defers while the user is active (< 600 = touched in the last 10 min).+        "userIdleSeconds": user_idle_seconds(),+        "focusDebug": focus_debug,+        "activeVersion": info.version,+        "windows": wins.iter().map(|w| json!({"hwnd": w.hwnd, "title": w.title, "className": w.class_name, "rect": w.rect_json(), "z": w.z, "kind": w.kind().as_str()})).collect::<Vec<_>>(),+        "windowSource": source,+        "shots": shots,+        "editors": {"kicadRunning": editors["kicadRunning"], "list": editors["editors"], "summary": editors["summary"]},+        "modalDialogs": modal,+        "_hint": "focusEvents is the focus log: a self-raise (pushed-to-background) entry is a KiCad window that took the foreground and was pushed back once; user-foregrounded means the caller asked with a reason. The full state of the union: every KiCad-owned window is in shots[] as inline base64 PNG (render them all; one window is NOT the app). modalDialogs non-empty means something is blocking; editors.list is the window inventory by kind. Poll before/after an action to confirm it landed. includeImages:false for a cheap logical poll.",+    });+    if ab_unavailable {+        out["abUnavailable"] = json!(true);+    }+    out+}++// ── kicad_send_key / kicad_click / kicad_fix_keyboard ─────────────────────────++fn foreground_kicad_hwnd() -> Option<u64> {+    if let Ok(fg) = native().foreground() {+        if fg != 0 && is_kicad_hwnd(fg) {+            return Some(fg);+        }+    }+    kicad_windows().first().map(|w| w.hwnd)+}++fn send_key(args: &Value) -> Value {+    // `menu`: a menu-item label (substring, case-insensitive) fired as WM_COMMAND on the+    // target window's menu bar, fully background. The way the PCM dialog is opened.+    if let Some(needle) = arg_str(args, "menu").map(str::trim).filter(|s| !s.is_empty()) {+        let target = match arg_any_u64(args, &["hwnd"]) {+            Some(h) => h,+            None => match foreground_kicad_hwnd() {+                Some(h) => h,+                None => return fail("no_window", "No KiCad window found to send the menu command to.", "kicad_window_info lists the windows; pass hwnd."),+            },+        };+        let title = title_of(target);+        return match menu_invoke_by_text(target, needle) {+            Ok(Some((id, label))) => json!({+                "success": true, "mechanism": "menu",+                "output": format!("Menu item '{label}' fired on '{title}' (background)"),+                "data": {"method": "menu-command", "cmdId": id, "menuItem": label, "hwnd": target, "title": title, "foreground": false},+                "_hint": "Resolved from the window's menu bar by label and fired with WM_COMMAND; no focus was touched. A dialog it opens is a child of this window: kicad_screenshot_all captures it.",+            }),+            Ok(None) => fail("menu_item_not_found", &format!("No menu item on '{title}' contains '{needle}'."), "kicad_window_info lists windows; the label match is a case-insensitive substring of the item text without '&'."),+            Err(e) if is_not_impl(&e) => unsupported("native menu walk (menu_tree)"),+            Err(e) => fail("menu_invoke_failed", &e, "Retry once the window is idle; pass hwnd explicitly."),+        };+    }+    let Some(key) = arg_str(args, "key").map(str::trim) else {+        return fail("missing_arg", "Missing required arg: key", "Pass key: a named key (enter/escape/tab/space/f1-f12), a single character, or a chord like 'ctrl+s'.");+    };+    let target = match arg_any_u64(args, &["hwnd"]) {+        Some(h) => h,+        None => match foreground_kicad_hwnd() {+            Some(h) => h,+            None => return fail("no_window", "No KiCad window found to send key to.", "kicad_window_info lists the windows; pass hwnd."),+        },+    };+    let title = title_of(target);+    // NEVER steal the user's screen: show without activating and post to THIS hwnd's queue.+    let _ = native().show_no_activate(target);+    sleep_ms(50);+    let allow_fg = args.get("allowForeground").and_then(Value::as_bool).unwrap_or(false);+    if let Some((_mods, _base)) = wm::parse_modifier_combo(key) {+        // A CHORD usually IS a menu accelerator (v0.9.106): resolve it from the window's+        // menu bar and fire WM_COMMAND, fully background.+        match menu_invoke_by_accel(target, key) {+            Ok(Some((id, label))) => {+                return json!({+                    "success": true, "mechanism": "menu",+                    "output": format!("Chord '{key}' delivered as menu command '{label}' (background)"),+                    "data": {"method": "menu-command", "cmdId": id, "menuItem": label, "hwnd": target, "title": title, "foreground": false},+                    "_hint": "Resolved via the window's menu-bar accelerator table and fired with WM_COMMAND; no focus was touched. This is the preferred path for every chord that maps to a menu item.",+                });+            }+            Err(e) if is_not_impl(&e) => return unsupported("native menu walk (menu_tree)"),+            _ => {}+        }+        if allow_fg {+            return unsupported("kicad_send_key allowForeground (real keystrokes via SendInput)");+        }+        return json!({+            "success": false, "errorCode": "foreground_required",+            "error": format!("send the key chord '{key}' to KiCad needs the foreground, which would take over the user's screen: no menu item carries this accelerator, so only real (focused) keystrokes can deliver it"),+            "_hint": "Use ab's desktop_press_key (it owns focus-first chord delivery and asks the user), or find the menu item and call it by name. The native bridge never sends global keystrokes.",+        });+    }+    if allow_fg {+        return unsupported("kicad_send_key allowForeground (real keystrokes via SendInput)");+    }+    let (vk, ch) = match wm::vk_for_key(key) {+        Some(vk) => (vk, None),+        None => {+            let mut chars = key.chars();+            match (chars.next(), chars.next()) {+                (Some(c), None) => (wm::vk_for_char(c).unwrap_or(0), Some(c)),+                _ => return fail("unknown_key", format!("Unknown key: '{key}'. Use a named key (enter/escape/tab/space/f1-f12), a single character, or a modifier combo like 'alt+3', 'ctrl+s', 'ctrl+shift+s' (modifiers: ctrl/control, alt/menu, shift, win/super)."), "Chords resolve through the menu bar; anything else needs desktop_press_key."),+            }+        }+    };+    match native().post_key(target, vk, ch) {+        Ok(()) => json!({+            "success": true, "mechanism": "post",+            "output": format!("Posted '{key}' to '{title}' (background)"),+            "data": {"hwnd": target, "title": title, "method": "postmessage", "foreground": false, "vk": vk},+            "_hint": "Delivered without touching the user's focus. If KiCad ignored it, the control may need real input: prefer a menu command by name; only as a last resort ask the user and use desktop_press_key.",+        }),+        Err(e) if is_not_impl(&e) => unsupported("posted keys (post_key)"),+        Err(e) => fail("post_failed", format!("Could not post '{key}' to the window: {e}"), "kicad_window_info shows whether the hwnd is still valid."),+    }+}++fn click(args: &Value) -> Value {+    let Some(hwnd) = arg_any_u64(args, &["hwnd"]) else {+        return fail("missing_arg", "Missing required arg: hwnd", "kicad_window_info lists the windows; pass hwnd.");+    };+    let (Some(x), Some(y)) = (args.get("x").and_then(Value::as_f64), args.get("y").and_then(Value::as_f64)) else {+        return fail("missing_arg", "Missing required args: x, y", "Pass x and y as fractions of the window (relative:true, default) or pixels (relative:false).");+    };+    let relative = args.get("relative").and_then(Value::as_bool).unwrap_or(true);+    if relative && !(0.0..=1.0).contains(&x) || relative && !(0.0..=1.0).contains(&y) {+        return json!({+            "success": false, "errorCode": "bad_arg",+            "error": format!("relative=True expects x/y as fractions 0.0-1.0 of the window, but got x={x}, y={y}. Pass fractions, or set relative=false to use pixels."),+            "_hint": "e.g. center = {\"x\":0.5,\"y\":0.5}; toolbar button ~ {\"x\":0.35,\"y\":0.09}. For exact pixel coords add \"relative\": false.",+        });+    }+    if args.get("allowForeground").and_then(Value::as_bool).unwrap_or(false) {+        return unsupported("kicad_click allowForeground (a real cursor click via SendInput)");+    }+    let info = match native().window_info(hwnd) {+        Ok(Some(i)) => i,+        Ok(None) => return fail("bad_hwnd", format!("HWND {hwnd} is not a valid window."), "kicad_window_info lists the current handles."),+        Err(e) => return plat::<()>("window lookup (window_info)", Err(e)).err().unwrap(),+    };+    let (w, h) = (info.rect.2, info.rect.3);+    let _ = native().show_no_activate(hwnd);+    sleep_ms(50);+    let (cx, cy) = if relative { ((x * w as f64) as i32, (y * h as f64) as i32) } else { (x as i32, y as i32) };+    match native().post_click(hwnd, cx, cy, false) {+        Ok(()) => json!({+            "success": true, "mechanism": "post",+            "output": format!("Posted click at ({cx},{cy}) in '{}' (background)", info.title),+            "data": {"hwnd": hwnd, "title": info.title, "client": [cx, cy], "screen": [info.rect.0 + cx, info.rect.1 + cy], "method": "postmessage", "foreground": false, "relative": relative},+            "_hint": "Clicked without moving the user's mouse or taking focus. If the canvas ignored it, that control may need real input: ab's desktop_click {space:\"image\", shotId, x, y} on a kicad_screenshot_all coordMap asks the user first.",+        }),+        Err(e) if is_not_impl(&e) => unsupported("posted clicks (post_click)"),+        Err(e) => fail("post_failed", format!("Could not post the click to the window: {e}"), "kicad_window_info shows whether the hwnd is still valid."),+    }+}++fn fix_keyboard() -> Value {+    json!({+        "success": true, "stuckKeys": [], "released": false,+        "output": "Nothing to fix: the native bridge never sends global keystrokes (no SendInput/keybd_event path), so it can never leave a modifier key held. Keys and clicks are posted to one window's queue, which touches no global keyboard state.",+        "_hint": "If keys ARE stuck on this desktop, another tool held them: ab's desktop_press_key path or a release of each modifier through it clears the OS state.",+    })+}++// ── kicad_place_footprint ─────────────────────────────────────────────────────++const BLANK_BOARD: &str = include_str!("../../../../templates/blank-board.kicad_pcb");++fn pseudo_uuid() -> String {+    // Not cryptographic: a KiCad item id only has to be unique within the board.+    let mut x: u64 = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0x9E3779B97F4A7C15) ^ ((std::process::id() as u64) << 32);+    let mut bytes = [0u8; 16];+    for b in bytes.iter_mut() {+        x ^= x << 13;+        x ^= x >> 7;+        x ^= x << 17;+        *b = (x & 0xff) as u8;+    }+    bytes[6] = (bytes[6] & 0x0f) | 0x40;+    bytes[8] = (bytes[8] & 0x3f) | 0x80;+    let h: String = bytes.iter().map(|b| format!("{b:02x}")).collect();+    format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32])+}++/// Quote an unquoted top-level `(layer X)` token: `(layer F.Cu)` -> `(layer "F.Cu")`.+fn quote_first_layer(text: &str) -> String {+    let Some(i) = text.find("(layer ") else { return text.to_string() };+    let rest = &text[i + 7..];+    let trimmed = rest.trim_start();+    if trimmed.starts_with('"') {+        return text.to_string();+    }+    let end = trimmed.find(')').unwrap_or(0);+    let name = trimmed[..end].trim();+    if name.is_empty() || !name.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) {+        return text.to_string();+    }+    format!("{}(layer \"{}\"){}", &text[..i], name, &trimmed[end + 1..])+}++/// Inject `(uuid ...)` and `(at x y)` right after the footprint's first quoted `(layer "...")`.+fn place_in_board(fp_text: &str, uid: &str, x: f64, y: f64) -> Option<String> {+    let i = fp_text.find("(layer \"")?;+    let close = fp_text[i..].find(')')? + i + 1;+    Some(format!("{}\n\t\t(uuid \"{uid}\")\n\t\t(at {x} {y}){}", &fp_text[..close], &fp_text[close..]))+}++fn place_footprint(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    if !info.installed {+        return fail("not_installed", "KiCad not installed", "Install KiCad from https://www.kicad.org/download/ then retry.");+    }+    let file_name = arg_str(args, "fileName").unwrap_or("");+    let Some(content) = arg_str(args, "fileContent") else {+        return fail("missing_arg", "No fileContent (base64 .kicad_mod) specified", "Pass fileContent: the base64 of the .kicad_mod text.");+    };+    let mut fp_text = match kicad_core::install::b64_decode(content) {+        Ok(b) => String::from_utf8_lossy(&b).trim().to_string(),+        Err(e) => return fail("bad_content", format!("could not decode fileContent: {e}"), "fileContent must be standard base64."),+    };+    // Legacy `(module ...)` footprints (KiCad <= 5 / EAGLE imports): rename the wrapper+    // and quote the top-level layer token the splice relies on.+    if fp_text.starts_with("(module") {+        fp_text = format!("(footprint{}", &fp_text["(module".len()..]);+        fp_text = quote_first_layer(&fp_text);+    }+    if !fp_text.starts_with("(footprint") {+        return fail("bad_content", "fileContent is not a .kicad_mod (must start with '(footprint' or '(module')", "Send the raw .kicad_mod text, base64-encoded.");+    }+    let fp_name = arg_str(args, "footprintName").map(str::to_string)+        .or_else(|| kicad_core::install::footprint_name_in(&fp_text))+        .or_else(|| Path::new(file_name).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()))+        .unwrap_or_else(|| "footprint".into());+    let x = args.get("x").and_then(Value::as_f64).unwrap_or(148.0);+    let y = args.get("y").and_then(Value::as_f64).unwrap_or(105.0);+    let uid = pseudo_uuid();+    let Some(fp_board) = place_in_board(&fp_text, &uid, x, y) else {+        return fail("bad_content", "footprint has no (layer \"...\") token to anchor the placement", "A .kicad_mod carries (layer \"F.Cu\") near the top; this one does not parse.");+    };+    let skel = BLANK_BOARD.trim_end();+    let idx = skel.rfind(')').unwrap_or(skel.len());+    let combined = format!("{}\n{}\n)\n", skel[..idx].trim_end(), fp_board);+    let user_dir = info.primary().and_then(|i| native().user_dir(&i.version)).or_else(|| std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")).map(PathBuf::from)).unwrap_or_else(|| PathBuf::from("."));+    let _ = std::fs::create_dir_all(&user_dir);+    let out_path = user_dir.join(format!("adom-place-{fp_name}.kicad_pcb"));+    if let Err(e) = std::fs::write(&out_path, combined) {+        return fail("write_failed", format!("could not write board: {e}"), "The preview board goes to Documents/KiCad/<version>; check it is writable.");+    }+    let board_path = norm(&out_path);+    let open_it = args.get("open").and_then(Value::as_bool).unwrap_or(true);+    let mut opened = false;+    let mut extra = Map::new();+    if open_it {+        match kicad_exe(&info, "pcbnew") {+            Some(exe) => match spawn_background(&exe, &[board_path.clone()], "pcbnew", None) {+                Ok((pid, _)) => {+                    opened = true;+                    extra.insert("pid".into(), json!(pid));+                    // Verify the frame by title with one deadline; the sweep runs after.+                    let stem = format!("adom-place-{fp_name}").to_lowercase();+                    let deadline = Instant::now() + Duration::from_secs(20);+                    while Instant::now() < deadline {+                        invalidate();+                        if let Some(w) = windows_of_kind(WindowKind::PcbEditor).into_iter().find(|w| w.title.to_lowercase().contains(&stem) || w.pid == pid) {+                            extra.insert("hwnd".into(), json!(w.hwnd));+                            extra.insert("title".into(), json!(w.title));+                            break;+                        }+                        sleep_ms(500);+                    }+                }+                Err(e) => {+                    if e["errorCode"] == json!("not_supported_on_platform") {+                        return e;+                    }+                    return json!({"success": true, "boardPath": board_path, "footprintName": fp_name, "placedAt": [x, y], "opened": false, "output": format!("Board written but pcbnew launch failed: {}", e["error"].as_str().unwrap_or("")), "_hint": format!("Open it manually: {board_path}")});+                }+            },+            None => {}+        }+    }+    let mut v = json!({+        "success": true, "boardPath": board_path, "footprintName": fp_name, "placedAt": [x, y], "opened": opened, "mechanism": if opened { "spawn" } else { "file" },+        "output": format!("Placed footprint '{fp_name}' at ({x}, {y}) mm on a preview board{}", if opened { " and opened it in pcbnew" } else { "" }),+    });+    merge(&mut v, Value::Object(extra));+    v+}++// ── kicad_enable_software_opengl ──────────────────────────────────────────────++const MESA_URL: &str = "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.60/mesa-llvmpipe-x64.zip";+const MESA_DLLS: &[&str] = &["opengl32.dll", "libgallium_wgl.dll", "dxil.dll"];+const MESA_EXES: &[&str] = &["kicad.exe", "pcbnew.exe", "eeschema.exe", "gerbview.exe", "pl_editor.exe", "bitmap2component.exe", "pcb_calculator.exe"];++fn enable_software_opengl(state: &mut State) -> Value {+    if native().capabilities().os != "windows" {+        return json!({"success": false, "error": "Windows-only", "errorCode": "unsupported_platform", "_hint": "Mesa llvmpipe DotLocal redirection only applies to KiCad's Windows build."});+    }+    let info = state.kicad_info();+    let mut bins: Vec<PathBuf> = Vec::new();+    for i in &info.installs {+        let mut b = PathBuf::from(if i.bin_dir.is_empty() { &i.base_dir } else { &i.bin_dir });+        if b.file_name().map(|n| n.to_string_lossy().to_lowercase() != "bin").unwrap_or(true) {+            b = b.join("bin");+        }+        if b.is_dir() && !bins.contains(&b) {+            bins.push(b);+        }+    }+    if bins.is_empty() {+        return json!({"success": false, "error": "No KiCad bin dir found; is KiCad installed?", "errorCode": "host_app_not_installed", "errorCodeLegacy": "kicad_not_installed", "hostApp": "KiCad", "_hint": "Run kicad_list_versions. Install KiCad first via kicad_upgrade."});+    }+    eprintln!("[kicad-bridge] enable_software_opengl: downloading Mesa llvmpipe...");+    let data = match pcm::http_fetch(MESA_URL, Duration::from_secs(180)) {+        Ok(d) => d,+        Err(e) => return json!({"success": false, "error": format!("Mesa download/unzip failed: {e}"), "errorCode": "download_failed"}),+    };+    let zip = match pcm::ZipFile::parse(&data) {+        Ok(z) => z,+        Err(e) => return json!({"success": false, "error": format!("Mesa download/unzip failed: {e}"), "errorCode": "download_failed"}),+    };+    let mut enabled = Vec::new();+    let mut errors = Vec::new();+    for bd in &bins {+        let mut ok = true;+        for dll in MESA_DLLS {+            let Some(entry) = zip.entries.iter().find(|e| e.name.rsplit('/').next() == Some(dll)) else {+                errors.push(format!("{}: {dll} missing from the Mesa zip", norm(bd)));+                ok = false;+                break;+            };+            let bytes = match zip.read(entry) {+                Ok(b) => b,+                Err(e) => { errors.push(format!("{}: {dll}: {e}", norm(bd))); ok = false; break; }+            };+            if let Err(e) = std::fs::write(bd.join(dll), bytes) {+                errors.push(if e.kind() == std::io::ErrorKind::PermissionDenied { format!("{}: permission denied (writing KiCad's bin needs elevation)", norm(bd)) } else { format!("{}: {e}", norm(bd)) });+                ok = false;+                break;+            }+        }+        if !ok {+            continue;+        }+        for exe in MESA_EXES {+            if bd.join(exe).exists() {+                let _ = std::fs::write(bd.join(format!("{exe}.local")), "");+            }+        }+        enabled.push(norm(bd));+        eprintln!("[kicad-bridge] enable_software_opengl: deployed to {}", norm(bd));+    }+    let ok = !enabled.is_empty();+    json!({+        "success": ok, "enabledIn": enabled, "errors": errors, "renderer": "Mesa llvmpipe (CPU software OpenGL)",+        "_hint": if ok {+            "LAST-RESORT software-OpenGL fallback deployed: KiCad will render via Mesa llvmpipe on the CPU (SLOW). This is ONLY for a GPU-less host (Hyper-V/RDP/headless) where KiCad cannot render at all; do NOT enable it on a normal machine. CLOSE and REOPEN KiCad editors to load it. To undo, delete opengl32.dll/libgallium_wgl.dll/dxil.dll and the *.local files from KiCad's bin."+        } else {+            "Could not deploy; see errors[]. Writing KiCad's bin (Program Files) needs an elevated Bridge; a per-user KiCad install (kicad_upgrade scope=user) avoids that."+        },+    })+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn viewer_selection_is_bound_to_source_and_refuses_ambiguity() {+        let win=|hwnd,owner,pid|Win{hwnd,owner,pid,title:"3D Viewer".into(),class_name:"wxWindowNR".into(),rect:(0,0,100,100),z:None,exe:"pcbnew.exe".into(),minimized:false,maximized:false,hung:false};+        let other=win(10,1,100);let requested=win(20,2,200);+        assert_eq!(viewer_for_frame(2,200,vec![other.clone(),requested.clone()]).unwrap().hwnd,20);+        assert!(viewer_for_frame(3,300,vec![other.clone(),requested.clone()]).is_none());+        assert!(viewer_for_frame(3,200,vec![requested.clone(),win(21,4,200)]).is_none());+        assert_eq!(viewer_for_frame(2,200,vec![requested,win(21,4,200)]).unwrap().hwnd,20);+    }+++    #[test]+    fn footprint_splice_matches_python() {+        let fp = "(footprint \"X\"\n\t(version 20240108)\n\t(layer \"F.Cu\")\n\t(pad \"1\" smd rect)\n)";+        let placed = place_in_board(fp, "u-u-i-d", 148.0, 105.0).unwrap();+        assert!(placed.contains("(layer \"F.Cu\")\n\t\t(uuid \"u-u-i-d\")\n\t\t(at 148 105)"));+        let legacy = "(module X (layer F.Cu) (pad 1 smd rect))";+        let modern = format!("(footprint{}", &legacy["(module".len()..]);+        let q = quote_first_layer(&modern);+        assert!(q.starts_with("(footprint X (layer \"F.Cu\")"));+        assert_eq!(quote_first_layer("(footprint X (layer \"B.Cu\"))"), "(footprint X (layer \"B.Cu\"))");+        assert!(place_in_board("(footprint X)", "u", 1.0, 2.0).is_none());+        let u = pseudo_uuid();+        assert_eq!(u.len(), 36);+        assert_eq!(&u[14..15], "4");+    }++    #[test]+    fn base64_round_trip() {+        assert_eq!(b64_encode(b""), "");+        assert_eq!(b64_encode(b"f"), "Zg==");+        assert_eq!(b64_encode(b"fo"), "Zm8=");+        assert_eq!(b64_encode(b"foo"), "Zm9v");+        assert_eq!(b64_encode(b"foobar"), "Zm9vYmFy");+        let back = kicad_core::install::b64_decode(&b64_encode(b"\x89PNG\r\n\x1a\n")).unwrap();+        assert_eq!(back, b"\x89PNG\r\n\x1a\n");+    }++    #[test]+    fn stamp_and_foreground_args() {+        let s = utc_stamp();+        assert_eq!(s.len(), 15);+        assert_eq!(&s[8..9], "-");+        assert!(user_wants_foreground(&json!({"foreground": true})));+        assert!(user_wants_foreground(&json!({"showToUser": true})));+        assert!(user_wants_foreground(&json!({"bringToFront": true})));+        assert!(!user_wants_foreground(&json!({"foreground": false, "reason": "x"})));+        assert_eq!(arg_any_u64(&json!({"pcbHwnd": "123"}), &["pcbHwnd", "pcb_hwnd"]), Some(123));+        assert_eq!(arg_any_u64(&json!({"pcb_hwnd": 0}), &["pcbHwnd", "pcb_hwnd"]), None);+    }++    #[test]+    fn dispatch_claims_only_its_verbs_and_is_honest_off_windows() {+        let mut state = State::new();+        assert!(dispatch(&mut state, "kicad_errors", &json!({})).is_none());+        assert!(dispatch(&mut state, "kicad_run_drc", &json!({})).is_none());+        let fk = dispatch(&mut state, "kicad_fix_keyboard", &json!({})).unwrap();+        assert_eq!(fk["success"], json!(true));+        assert_eq!(fk["stuckKeys"], json!([]));+        // Without window control (this host) the window verbs answer not_supported_on_platform+        // or an ab-unavailable error, never a hang and never a fake success.+        if !native().capabilities().window_control {+            std::env::remove_var("ADOM_DIRECT_API_URL");+            let r = dispatch(&mut state, "kicad_window_info", &json!({})).unwrap();+            assert_eq!(r["success"], json!(false));+            let code = r["errorCode"].as_str().unwrap_or("");+            assert!(code == "not_supported_on_platform" || code == "ab_unavailable", "{r}");+            let r = dispatch(&mut state, "kicad_open_editors", &json!({})).unwrap();+            assert_eq!(r["success"], json!(false));+            let r = dispatch(&mut state, "kicad_send_key", &json!({"key": "escape", "hwnd": 5})).unwrap();+            assert_eq!(r["success"], json!(false));+        }+        let r = dispatch(&mut state, "kicad_click", &json!({"hwnd": 5, "x": 300, "y": 2})).unwrap();+        assert_eq!(r["errorCode"], json!("bad_arg"));+        let r = dispatch(&mut state, "kicad_send_key", &json!({})).unwrap();+        assert_eq!(r["errorCode"], json!("missing_arg"));+        let r = dispatch(&mut state, "kicad_open_3d_viewer", &json!({"editor": "sideways"})).unwrap();+        assert_eq!(r["errorCode"], json!("bad_arg"));+    }++    #[test]+    fn catalog_covers_the_python_window_group() {+        let names: Vec<&str> = VERBS.iter().map(|v| v.name).collect();+        for n in ["kicad_launch", "kicad_open_board", "kicad_open_schematic", "kicad_open_symbol_editor", "kicad_open_footprint_editor", "kicad_open_3d_viewer", "kicad_open_editors", "kicad_close", "kicad_close_symbol_editor", "kicad_close_footprint_editor", "kicad_close_3d_viewer", "kicad_dismiss_dialogs", "kicad_window_info", "kicad_state", "kicad_screenshot_all", "kicad_send_key", "kicad_click", "kicad_fix_keyboard", "kicad_place_footprint", "kicad_enable_software_opengl"] {+            assert!(names.contains(&n), "{n} missing from the catalog");+        }+        assert!(!names.contains(&"kicad_errors"));+        for v in VERBS {+            assert!(!v.summary.contains('\u{2014}') && !v.hint.contains('\u{2014}'), "{} carries an em dash", v.name);+        }+    }+}+++/// kicad_rescan_libraries: tree refresh first (#51), restart only on force plus confirmation.+fn rescan_libraries(state: &mut State, args: &Value) -> Value {+    let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);+    if !force {+        let mut refreshed: Vec<u64> = Vec::new();+        for kind in [WindowKind::FootprintEditor, WindowKind::SymbolEditor] {+            if let Some(h) = first_of_kind(kind) {+                if let Ok(Some(_)) = menu_invoke_by_text(h, "refresh") {+                    refreshed.push(h);+                }+            }+        }+        if !refreshed.is_empty() {+            // The tree reloads asynchronously; the Python waited 3 s before probing.+            std::thread::sleep(std::time::Duration::from_secs(3));+            invalidate();+            let mut wanted: Vec<(String, String)> = Vec::new();+            for spec in args.get("footprints").and_then(Value::as_array).cloned().unwrap_or_default() {+                match spec {+                    Value::String(s) if s.contains(':') => {+                        let (lib, name) = s.split_once(':').unwrap();+                        wanted.push((lib.to_string(), name.to_string()));+                    }+                    Value::Object(o) => {+                        if let Some(name) = o.get("name").and_then(Value::as_str) {+                            wanted.push((o.get("library").and_then(Value::as_str).unwrap_or("Adom").to_string(), name.to_string()));+                        }+                    }+                    _ => {}+                }+            }+            let mut probed: Vec<Value> = Vec::new();+            if !wanted.is_empty() {+                let fp = first_of_kind(WindowKind::FootprintEditor);+                for (lib, name) in &wanted {+                    let (ok, trace, _mech) = match fp {+                        Some(h) => navigate_tree(h, &format!("{lib} {name}"), Verify::Footprint { name, library: lib }),+                        None => (false, json!({}), "none"),+                    };+                    probed.push(json!({+                        "library": lib, "name": name, "visible": ok,+                        "error": if ok { Value::Null } else if fp.is_none() { json!("no Footprint Editor open") } else { json!("editor tree did not load it") },+                        "navigation": trace,+                    }));+                }+            }+            let verified = !wanted.is_empty() && probed.iter().all(|p| p["visible"].as_bool().unwrap_or(false));+            let mut out = json!({"success": true, "method": "tree-refresh", "refreshedEditors": refreshed, "verified": verified, "probed": probed});+            out["_hint"] = json!(if !wanted.is_empty() && !verified {+                format!("Tree refreshed but KiCad still cannot load: {}. Check the name with kicad_list_footprints; if it is on disk, call again with force:true and confirmRestart:true for the process restart (about a minute).",+                    probed.iter().filter(|p| !p["visible"].as_bool().unwrap_or(false)).map(|p| format!("{}:{}", p["library"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", "))+            } else if !wanted.is_empty() {+                "Verified in the editor: every listed footprint loaded after the refresh, no restart. The last one is now shown in the Footprint Editor.".to_string()+            } else {+                "Refresh invoked on the open editor tree(s). Pass footprints:[\"Lib:Name\"] to have each loaded in the editor as proof; without that list nothing is verified. force:true is the process restart, the last resort.".to_string()+            });+            post_verb(state, "kicad_rescan_libraries", args, &mut out, None);+            return out;+        }+    }+    // Restart path: the native build has no ownership ledger yet (phase 4), so a running+    // KiCad is treated as the user's unless the caller confirms.+    let running = native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).unwrap_or_default();+    let confirm = args.get("confirmRestart").and_then(Value::as_bool).unwrap_or(false);+    if !running.is_empty() && !confirm {+        return json!({"success": false, "errorCode": "rescan_refused",+            "reason": "KiCad is running and the native build cannot yet tell a user-launched KiCad from its own",+            "runningPids": running.iter().map(|p| p.pid).collect::<Vec<_>>(),+            "_hint": "Ask the user whether KiCad may be restarted, then call again with force:true and confirmRestart:true. Without force the verb refreshes the open editors' trees in place, which is enough for footprints installed after the editor started."});+    }+    // Wiki #48: a fresh process places every window on the primary display, so the user's+    // monitor choice is captured here, while the windows still exist, and restored below.+    let remembered = remember_placement();+    let mut close_how = "nothing was running";+    if !running.is_empty() {+        // Wiki #48 ask 3: graceful first, so KiCad writes its own geometry and its own files;+        // the kill is the fallback for a window that will not go.+        let mut closed = dispatch(state, "kicad_close", &json!({})).unwrap_or_else(|| json!({}));+        close_how = "graceful";+        if !closed["success"].as_bool().unwrap_or(false) {+            if closed["errorCode"] == json!("unsaved_changes") {+                return json!({"success": false, "errorCode": "restart_close_failed", "close": closed,+                    "_hint": "KiCad is asking whether to save the user's work, so nothing was closed or killed. Ask them, then save (or call kicad_close {\"discardChanges\": true}) before restarting."});+            }+            closed = dispatch(state, "kicad_close", &json!({"force": true})).unwrap_or_else(|| json!({}));+            close_how = "force-kill after the graceful close failed";+            if !closed["success"].as_bool().unwrap_or(false) {+                return json!({"success": false, "errorCode": "restart_close_failed", "close": closed, "closeHow": close_how});+            }+        }+        std::thread::sleep(std::time::Duration::from_secs(4));+    }+    let launched = launch(state, &json!({}));+    if !launched["success"].as_bool().unwrap_or(false) {+        return json!({"success": false, "errorCode": "restart_launch_failed", "launch": launched});+    }+    for _ in 0..80 {+        std::thread::sleep(std::time::Duration::from_millis(750));+        invalidate();+        if !kicad_windows().is_empty() {+            sleep_ms(1200); // let the fresh windows settle before moving them+            let restored = restore_placement();+            let moved = restored.iter().filter(|r| r["moved"] == json!(true)).count();+            return json!({"success": true, "restarted": true, "ready": true, "method": "process-restart",+                "placementRemembered": remembered, "placementRestored": restored, "closeHow": close_how,+                "_hint": format!("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. Placement: {moved} window(s) put back where the user had them (wiki #48), without activating anything.")});+        }+    }+    json!({"success": false, "restarted": true, "ready": false, "errorCode": "restart_no_window",+        "_hint": "KiCad was restarted but no window appeared within 60 s; kicad_window_info shows what is up."})+}+--- a/rust/crates/kicad-bridge/src/verbs_show.rs+++ b/rust/crates/kicad-bridge/src/verbs_show.rs@@ -1,972 +1,976 @@⋯ 252 unchanged lines ⋯         if let Some(w) = wins.iter().find(|w| w.hwnd == h) {             return Some(w.clone());         }+        return None; // Never substitute another window for an explicit HWND.     }     if !needle.is_empty() {         if let Some(w) = wins.iter().find(|w| w.title.to_lowercase().contains(needle)) {             return Some(w.clone());         }+        return None; // A missing requested document is not any open editor.     }     wins.into_iter().next() }⋯ 383 unchanged lines ⋯         // needs a live PCB editor) so we do not race "not open".         step(CMD_3D_BOARD, "waiting for the PCB Editor window", 30);         pcb_hwnd = wait_for_surface(WindowKind::PcbEditor, opened.get("hwnd").and_then(Value::as_u64), &stem_of(f), 40, 750).map(|w| w.hwnd);+        if pcb_hwnd.is_none() { return json!({"success":false,"errorCode":"requested_board_not_open","error":"The requested PCB editor did not appear.","_hint":"Inspect kicad_open_editors and dialogs; resolve any file lock before retrying kicad_show_3d_board with the same filePath."}); }     }     step(CMD_3D_BOARD, "opening the 3D Viewer from the PCB Editor", 40);     let mut a = args.clone();⋯ 317 unchanged lines ⋯         assert_eq!(surface_kind("show_project"), None);     } }+--- a/rust/crates/kicad-core/src/windows_model.rs+++ b/rust/crates/kicad-core/src/windows_model.rs@@ -1,746 +1,752 @@⋯ 532 unchanged lines ⋯     let step_y = ((y1 - y0) / 60).max(1);     let mut colors: std::collections::HashSet<[u8; 3]> = std::collections::HashSet::new();     let mut samples = 0u64;+    let mut lit_samples = 0u64;     let mut y = y0;     while y < y1 {         let mut x = x0;⋯ 1 unchanged line ⋯             let i = ((y * w + x) * 3) as usize;             colors.insert([rgb[i], rgb[i + 1], rgb[i + 2]]);             samples += 1;-            if colors.len() > 12 {-                return json!({"checked": true, "distinctColors": colors.len(), "rendered": true, "samples": samples, "region": region});-            }+            if rgb[i].max(rgb[i+1]).max(rgb[i+2]) > 24 { lit_samples += 1; }             x += step_x;         }         y += step_y;     }-    json!({"checked": true, "distinctColors": colors.len(), "rendered": colors.len() > 3, "samples": samples, "region": region})+    json!({"checked": true, "distinctColors": colors.len(), "rendered": colors.len() > 3 && lit_samples * 100 >= samples * 3, "litSamples":lit_samples, "method":"canvas color heuristic, not geometry qualification", "samples": samples, "region": region}) }  /// A filename-safe label for a screenshot (kicad_ui.handle_screenshot_all).⋯ 166 unchanged lines ⋯     }      #[test]+    fn almost_black_canvas_is_not_accepted_for_sparse_color_noise() {+        let mut rgb=vec![0u8;200*100*3];+        for i in 0..rgb.len() { rgb[i]=(i%23) as u8; }+        assert_eq!(canvas_uniformity(&rgb,200,100,None)["rendered"],json!(false));+    }+    #[test]     fn canvas_probe_flat_versus_drawn() {         let (w, h) = (200u32, 100u32);         let flat = vec![40u8; (w * h * 3) as usize];⋯ 18 unchanged lines ⋯         assert_eq!(safe_label("", "dialog"), "dialog");     } }+

Comments

John Lauer 2026-09-18

Superseded by https://wiki.adom.inc/adom/kicad-bridge/prs/7, rebased onto Bridge 1.0.17 and including the same-process, multiple-editor viewer ambiguity regression. Closing this older file set to avoid merging stale source. Native Windows acceptance remains pending.

Log in to comment.