← All Pull Requests

Fusion bridge tier-1 items 2-4 + read-verb doc guard (inspect_bodies, API-error classify, BOM default) #34

Open opened by Oliver 2026-07-27

Implements the three still-open tier-1 items from the bug report (2 geometry read-back, 3 API-error classification, 4 BOM treatAsUnit default), plus the corroborated follow-up (guard on read verbs). Item 1 (expectDocument) already shipped in 1.8.7 and is reused, not re-implemented.

No version bump and no release in this PRBRIDGE_VERSION / bridge.json / AdomBridge.manifest are left at 1.8.7 / 1.0.4 on purpose. Bump + cut the release on merge (items 2 & 3 touch the add-in, so AdomBridge.manifest should bump too and the add-in needs the fusion_stopinstall_addin.pyfusion_start redeploy dance).

⚠️ Source reconciliation (please read first)

The page-repo server.py (321,361 B) and handlers/fusion_ui.py (26,765 B) are behind the shipped 1.8.7 release — the page server.py is missing your _assert_active_document guard entirely, while the deployed bridges-cache copy (328,488 B) has it. This PR's server.py is the shipped-1.8.7 file (guard included) with my new hunks on top, and handlers/fusion_ui.py is the shipped copy verbatim (no change from me). So the diff will show the whole guard block as "added" — that's your already-shipped code being reconciled into the page, not new work. My actual server.py changes are ~30 lines (import + GUARDED_READ_COMMANDS + the _proxy_to_addin classify hook + broadening the guard trigger + the inspect_bodies registration). Reconciling both files keeps a post-merge publish-from-page consistent with what's live.

Item 2 — fusion_inspect_bodies (new read verb)

addin/AdomBridge/commands/inspect_bodies.py (new), registered in commands/__init__.py, server.py ADDIN_COMMANDS, and describe.py. Per body: bbox in mm, volume (mm³), area (mm²), faceCount, cylindricalFaceCount, appearance, material.

  • Reuses the allOccurrences walk from physical_properties.py and the bbox pattern from silkscreen_capture.py.
  • Units converted cm → mm (bbox ×10, area ×100, volume ×1000) to kill the standing off-by-ten between the API (cm) and the model's mm dims.
  • cylindricalFaceCount = faces whose geometry.surfaceType == CylinderSurfaceType — the cheap "did the hole actually get cut" signal (a symmetric cut reaching nothing fails silently; the count drops).
  • worldSpace:true / occurrence:"<fullPathName>" resolves bodies through occurrence transforms via createForAssemblyContext for real assembly-placement verification.

Item 3 — classify raw Fusion API errors

handlers/dialog_classify.py: added _API_ERROR_RULES + classify_api_error(message) (pure string match, no Win32 dep), mirroring the existing dialog _RULES shape one layer down. Hooked in server.py _proxy_to_addin, right beside the existing stale-add-in enrichment, so every add-in verb gets it uniformly with no add-in redeploy — and only when the add-in hasn't already set a specific errorCode (bridge codes always win). Seed rules → stable errorCode + _hint:

  • part_design_single_component (Part-template vs documents.add(FusionDesignDocumentType))
  • root_rename_unsupported (root takes the doc name; rename on save)
  • stale_api_handle (re-fetch the handle after a close/activate/tab-switch)

Unmatched errors pass through with their original text.

Item 4 — widen assembly_bom treatAsUnit default

addin/AdomBridge/commands/assembly_bom.py: default widened from ["with fasteners","with fastener"] to add bearing, pulley, idler so a purchased subassembly (e.g. a GT2 idler) collapses to one row like Fusion's Manage → BOM instead of exploding into 240 bearing balls (took a test walk 744 → 424 instances, fastener counts unchanged). Added a result _hint and documented the partNumber-can't-auto-detect dead end (Fusion auto-fills it from the component name). describe.py updated.

Item 5 (Part B follow-up) — expectDocument on read verbs

server.py: new GUARDED_READ_COMMANDS = {inspect_bodies, assembly_bom, physical_properties, document_info}; the guard trigger now fires for MUTATING_COMMANDS | GUARDED_READ_COMMANDS. Reuses _assert_active_document unchanged (name check, fails open). Closes the corroborated hole where a read-only fusion_assembly_bom silently returned a different assembly after a tab-switch. Additive — no behaviour change when expectDocument is omitted.

Docs

skills/fusion-driving/SKILL.md: extended the expectDocument section to list the read verbs, and added sections for fusion_inspect_bodies (verify geometry, pixels are weak) and the three new API errorCodes.

Testing done here

All changed Python compiles (py_compile). classify_api_error unit-tested: the three messages classify to their codes; unrelated/empty → None (pass-through). Add-in files can't run outside Fusion here, so inspect_bodies + the BOM change need a live-Fusion smoke test on merge — suggested checks: a 20×10×5 mm box with one Ø3 through-hole → fusion_inspect_bodies bbox reads 20/10/5 (not 2/1/0.5) and cylindricalFaceCount ≥ 1, drops to 0 when the hole is removed; worldSpace:true on an occurrence reflects its transform; BOM on the Wirebending-desk assembly collapses the GT2 idler to ~424 total with fasteners 14/18/20 unchanged; fusion_assembly_bom … expectDocument:"ZZZ_NoSuchDoc"wrong_document.

🤖 Generated with Claude Code

Diff

--- a/server.py+++ b/server.py@@ -1,5941 +1,6092 @@-#!/usr/bin/env python3-"""Adom Fusion 360 Bridge Server — localhost HTTP server for Fusion 360 integration.--Receives commands from the Adom Desktop (Tauri app) and controls-Fusion 360 via Win32 API, os.startfile, and the AdomBridge add-in.--Usage:-    python server.py-    python server.py --port 8773-"""--import json-import os-import threading-import time as _t2-import sys-import traceback-import urllib.request-import urllib.error-import urllib.parse-from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler-from pathlib import Path--# Ensure the plugin root is on the path-sys.path.insert(0, str(Path(__file__).parent))--from fusion_detect import detect_fusion, ensure_fusion_running, wait_for_addin, _is_fusion_running, fusion_update_in_progress, find_licensing_dialog, family_windows, _incomplete_webdeploy_present-from handlers.open_design import handle_open_design-from handlers.close_fusion import handle_close_fusion, handle_fusion_stop, handle_fusion_kill-from handlers.dismiss_recovery import dismiss_recovery_dialog-from handlers.fusion_ui import (-    screenshot_fusion_window,-    screenshot_hwnd,-    click_fusion,-    send_key_to_fusion,-    close_window,-    get_fusion_window_info,-)-from handlers.dialog_classify import classify_blocking_dialogs, classify_launch_dialogs, close_dialog_bg, AUTO_DISMISS_CATEGORIES-from install_addin import install as install_addin-import aps-import describe-import ad_client--DEFAULT_PORT = 8773-ADDIN_PORT = 8774--# Read once at load so /status reports the same version we ship + bump.-try:-    BRIDGE_VERSION = (Path(__file__).parent / "BRIDGE_VERSION").read_text(encoding="utf-8").strip()-except Exception:-    BRIDGE_VERSION = "unknown"--# The add-in version this bridge BUNDLES (the one install_addin syncs into-# Fusion's Roaming AddIns dir). Read from our bundled manifest so it can't drift-# from a hardcode. Compared against the RUNNING add-in's reported version to-# catch a stale add-in (issue #55) - the running copy only re-syncs when Fusion-# is closed on bridge start, so a user who never restarted Fusion keeps an old-# add-in and commands like fusion_aps_open fail silently. We make it LOUD.-try:-    EXPECTED_ADDIN_VERSION = (json.loads(-        (Path(__file__).parent / "addin" / "AdomBridge" / "AdomBridge.manifest").read_text(encoding="utf-8")-    ) or {}).get("version", "unknown")-except Exception:-    EXPECTED_ADDIN_VERSION = "unknown"---def _addin_staleness(reported_version) -> dict:-    """Compare the running add-in's reported version to the bundled one.--    Returns {addinVersion, expectedAddinVersion, addinStale, _staleHint?}. A-    stale add-in is the #55 silent-failure: re-sync it by RESTARTING Fusion-    (fusion_stop then fusion_start) so the bridge's on-start install_addin can-    overwrite the Roaming copy (which is file-locked while Fusion runs)."""-    info = {-        "addinVersion": reported_version or "unknown",-        "expectedAddinVersion": EXPECTED_ADDIN_VERSION,-    }-    stale = (-        reported_version not in (None, "unknown")-        and EXPECTED_ADDIN_VERSION not in (None, "unknown")-        and str(reported_version) != str(EXPECTED_ADDIN_VERSION)-    )-    info["addinStale"] = bool(stale)-    if stale:-        info["_staleHint"] = (-            f"STALE add-in: Fusion is running add-in v{reported_version} but this bridge "-            f"bundles v{EXPECTED_ADDIN_VERSION}. Newer verbs (e.g. fusion_aps_open/open_by_urn) "-            f"can fail silently. Fix: fusion_stop then fusion_start - the bridge re-syncs the "-            f"add-in from its cache on start (only possible with Fusion CLOSED)."-        )-    return info--# Populated at startup-fusion_info = None---def _reclaim_seat_from_peers() -> int:-    """Free the Autodesk seat held by another machine by stopping Fusion on peer ADs.--    John (2026-07-06): "if the user asked you to do something with fusion you should-    just grab the license back. the user can always grab it back on the other machine,-    so there's no harm." So on a seat conflict we do NOT ask - we free the seat at the-    SOURCE via the cross-AD direct API (fully background + reliable, no CEF-dialog-    clicking), then relaunch locally with no conflict. Best-effort; returns the number-    of peers signalled (0 if the direct API/peers are unavailable)."""-    n = 0-    try:-        if not ad_client.available():-            return 0-        for peer in ad_client.peers():-            try:-                if ad_client.call("fusion_stop", {}, target=peer) is not None:-                    n += 1-            except Exception:-                pass-    except Exception:-        pass-    return n---def _resolve_seat_via_uia(dlg: dict) -> bool:-    """Resolve the 'Active Sessions Exceeded' seat dialog by UIA-invoking its native-    'Continue' button (Suspend is pre-selected) - entirely IN THE BACKGROUND.--    THE breakthrough (John 2026-07-06, after a long day of failing): UIA Invoke does-    NOT foreground the window, so this grabs the license without ever disturbing the-    user - unlike SendInput coordinate clicks (need foreground) or kill+relaunch (the-    server keeps the seat). Proven live: `desktop_ui_click {hwnd, name:"Continue"}`-    returned "Clicked in the BACKGROUND - the window did NOT come to the foreground"-    and the dialog cleared. The bridge calls the AD `desktop_ui_click` verb on ITSELF-    via the AD 1.9.84 direct API. Never raises."""-    try:-        hwnd = dlg.get("hwnd")-        if not hwnd or not ad_client.available():-            return False-        res = ad_client.call("desktop_ui_click", {"hwnd": hwnd, "name": "Continue"}, timeout=4)-        return bool(res and (res.get("success") or res.get("status") == "ok"))-    except Exception:-        return False---def _owned_popup_count(main_hwnd) -> int:-    """ownedPopupCount on a window via AD's desktop_screenshot_window - the SAME-    parent/child screenshot signal John kept pointing at (2026-07-06): a modal seat/-    error dialog shows up as an OWNED POPUP of the main Fusion window. This is the-    ground-truth 'is a dialog still up' check used to VERIFY a seat dialog actually-    cleared, instead of trusting a window-size heuristic (which false-'resolved' the-    823x262 'Suspend Remote Session' confirm). Returns the count, or -1 if unavailable."""-    try:-        if not main_hwnd or not ad_client.available():-            return -1-        res = ad_client.call("desktop_screenshot_window", {"hwnd": int(main_hwnd)}, timeout=4)-        if isinstance(res, dict):-            c = res.get("ownedPopupCount")-            if c is None and isinstance(res.get("data"), dict):-                c = res["data"].get("ownedPopupCount")-            if c is not None:-                return int(c)-    except Exception:-        pass-    return -1---def _resolve_seat_dialog(max_clicks: int = 3) -> dict:-    # max_clicks kept LOW (was 8): readiness calls this every tick and it SELF-HEALS across ticks, so-    # a single call must stay responsive - 8 clicks x (UIA click + screenshot-verify, each up to its-    # timeout) could blow readiness's whole budget and make the bridge look hung (John 2026-07-14).-    """Detect + resolve the seat/licensing modal deterministically, IN THE BACKGROUND,-    and VERIFY it truly cleared by SCREENSHOTTING the parent window's ownedPopupCount --    not by re-running a size heuristic (that false-'resolved' bit John hard, 2026-07-06:-    the code clicked once, the size filter then failed to re-detect the short confirm-    variant, and readiness lied that the dialog was gone while it sat there blocking).--    Each pass: find the dialog -> UIA-invoke 'Continue' (background, Suspend pre-selected-    so it grabs the license) -> screenshot the parent's ownedPopupCount. Done only when-    BOTH find_licensing_dialog() sees nothing AND the parent shows 0 owned popups (or the-    screenshot signal is unavailable and the heuristic agrees). Returns-    {sawDialog, clicks, verified}. Never raises."""-    import time as _t-    clicks = 0-    saw = False-    verified = False-    for _ in range(max_clicks):-        try:-            dlg = find_licensing_dialog()-        except Exception:-            dlg = None-        if not dlg:-            verified = True-            break-        saw = True-        parent = dlg.get("owner")-        if _resolve_seat_via_uia(dlg):     # count only clicks that actually landed, so-            clicks += 1                    # seatDialogAutoResolved isn't a lie when ad_client is down-        _t.sleep(1.5)-        # Ground-truth confirm via parent/child screenshot (John's insisted-on check).-        cnt = _owned_popup_count(parent)-        if cnt == 0:-            verified = True-            break-        # cnt == -1 (screenshot unavailable): fall through, the next find_licensing_dialog-        # pass decides. cnt > 0: still up, loop and click again.-    return {"sawDialog": saw, "clicks": clicks, "verified": verified}---def _find_adom_desktop_cli() -> str | None:-    """Locate the adom-desktop CLI/exe so the bridge can drive AD's relay directly (used when the-    in-process ad_client is unavailable, e.g. on a headless VM). Checks the per-user Windows install-    dir, then PATH, then the common Linux/dev locations. Returns a path or None."""-    import shutil-    la = os.environ.get("LOCALAPPDATA", "")-    candidates = [-        os.path.join(la, "Adom Desktop", "adom-desktop.exe") if la else None,-        os.path.join(la, "Programs", "Adom Desktop", "adom-desktop.exe") if la else None,-    ]-    for c in candidates:-        if c and os.path.exists(c):-            return c-    return shutil.which("adom-desktop") or shutil.which("adom-desktop.exe")---def _cli_notify_all(title: str, body: str, level: str) -> dict:-    """Deliver a toast to EVERY connected desktop via `adom-desktop --target all notify_user`. This is-    the reliable cross-AD path when the bridge's in-process ad_client is down (headless VM): the CLI-    joins the relay itself and `--target all` reaches the user's real machine (not just this VM).-    Best-effort + never raises. Returns {delivered, targets, error}. (John 2026-07-14: the notify MUST-    actually leave the box - a returned-but-unsent payload is the bug, not a feature.)"""-    import subprocess as _sp-    exe = _find_adom_desktop_cli()-    if not exe:-        return {"delivered": False, "error": "adom-desktop CLI not found"}-    # STICKY by default: a human-wall alert must NOT vanish in a few seconds (John 2026-07-14 - the-    # first toasts disappeared before he noticed them). scenario:reminder keeps it on screen until the-    # user acts; it REQUIRES >=1 button, so include one. durationLong is a belt-and-suspenders ~25s.-    payload = json.dumps({"title": title, "body": body, "level": level,-                          "scenario": "reminder", "durationLong": True,-                          "buttons": [{"label": "Got it"}]})-    try:-        r = _sp.run([exe, "--target", "all", "notify_user", payload],-                    capture_output=True, text=True, timeout=30,-                    creationflags=getattr(_sp, "CREATE_NO_WINDOW", 0))-        out = (r.stdout or "") + (r.stderr or "")-        # AD's CLI returns {status:'ok', action:'displayed'} per target; treat a zero exit or a-        # 'displayed'/'ok' in the output as delivered. The Windows exe may print nothing yet still-        # deliver, so a clean exit code is accepted too.-        delivered = (r.returncode == 0) or ("displayed" in out) or ('"status": "ok"' in out) or ("status':'ok" in out)-        return {"delivered": bool(delivered), "targets": "all", "error": None if delivered else out[:200]}-    except Exception as e:-        return {"delivered": False, "error": str(e)[:200]}---def _handle_notify_owner(args: dict) -> dict:-    """LAST-RESORT: toast the user's MAIN desktop (cross-AD) to ask for a human step.--    John's standing rule (2026-07-07): the bridge/AI does EVERYTHING itself; the only-    legitimate uses are true human walls - password/2FA entry, UAC elevation, a physical-    action. When this bridge runs on an unattended VM, the toast must reach the machine-    the user is actually AT - ad_client.notify(reach_user=True) fans out to all peer ADs-    on the relay, so it lands on their main computer too. Returns which targets were hit."""-    title = args.get("title") or "Fusion bridge needs you"-    body = args.get("body") or args.get("message") or "A human step is required to continue."-    level = args.get("level") or "warning"-    if not ad_client.available():-        # ad_client is the bridge's IN-PROCESS AD API - it is routinely UNAVAILABLE when this bridge-        # runs on an unattended Hyper-V VM (found live 2026-07-14: the toast silently never reached the-        # user, and the AI "forgot" to relay it - exactly the failure John demanded we engineer out).-        # SELF-DELIVER via the adom-desktop CLI, which connects to the relay independently: `--target-        # all` fans the toast out to EVERY connected desktop (the VM + the user's real machines), so it-        # reliably lands on the computer the user is actually AT. No AI relay step to forget.-        cli = _cli_notify_all(title, body, level)-        if cli.get("delivered"):-            return {"success": True, "via": "cli:--target all", "targets": cli.get("targets"),-                    "_hint": ("Toast fanned out to ALL connected desktops (the user's main machine "-                              "included) via the adom-desktop CLI, because the bridge's in-process AD "-                              "API was unavailable (this VM). WAIT and poll fusion_readiness; do not "-                              "re-toast within a few minutes. Only notify when the box is ACTUALLY "-                              "ready for the user to act - do not toast for a step you can do yourself.")}-        # CLI fallback also failed - return the payload so the AI can relay as the true last resort.-        return {-            "success": False,-            "error": "AD in-process API unavailable AND the adom-desktop CLI fallback failed: "-                     + str(cli.get("error"))[:200],-            "notifyUser": {"title": title, "body": body, "level": level},-            "_hint": ("Relay the notifyUser payload yourself: `adom-desktop --target all notify_user "-                      "{title, body, level}` (or `--target <the user's host>`)."),-        }-    res = ad_client.notify(title, body, level=level, reach_user=True) or {}-    # reach_user fans out via target="all", whose response is a BROADCAST envelope-    # {results:{host:{action}}, summary:{ok,failed,total}, targets:[...]}. Recognize that shape for-    # success (summary.ok>0) - not just the single-target {status:"ok"} - and report the ACTUAL-    # desktops reached, so a toast that landed on the user's laptop isn't mis-reported as failed.-    summary = res.get("summary") or {}-    ok = bool(res.get("success") or res.get("status") == "ok" or summary.get("ok", 0) > 0)-    reached = res.get("targets") or (["self"] if ok else [])-    return {-        "success": ok,-        "via": "ad_client:direct(all)",-        "targets": reached,-        "_hint": ("Toast fanned out to ALL connected desktops (the user's machine included) via the "-                  "in-process AD direct API. This is the LAST RESORT - only use after exhausting "-                  "programmatic options (UIA background clicks, seat auto-resolve, warm-SSO sign-in), "-                  "and only when the box is ACTUALLY ready for the user to act. Now WAIT and poll "-                  "fusion_readiness for the state to clear; do not re-toast within a few minutes."),-    }---_last_fg_notice = [0.0]---def _notify_before_foreground(reason: str) -> None:-    """Baked-in courtesy toast BEFORE the bridge foregrounds Fusion (John, 2026-07-08).--    Some Fusion interactions (SendInput key/click, CEF modal dialogs) can ONLY be driven-    with Fusion in the FOREGROUND, which steals the user's focus mid-work. The standing-    rule: ALWAYS drive in the background; foreground ONLY as a last resort. When we truly-    must, TELL the user why (via an AD notify, so every Adom user learns the principle) and-    rib Fusion for not being AI-native enough to allow it. Debounced so a burst of keystrokes-    fires ONE notice, not dozens. Best-effort; never raises, never blocks the operation."""-    try:-        now = _time.time()-        if now - _last_fg_notice[0] < 45:   # one notice per foreground burst-            return-        _last_fg_notice[0] = now-        body = (-            f"Adom is briefly bringing Fusion to the FOREGROUND: {reason}. I ALWAYS try to do "-            "everything in the background and only foreground when I have NO option left. Fusion "-            "just isn't AI-native/fast enough yet to let me drive this part in the background - "-            "hopefully Autodesk makes their app fully AI-drivable soon so I can skip these "-            "workarounds. Sorry for the interruption!"-        )-        ad_client.notify("Adom is foregrounding Fusion (last resort)", body,-                         level="info", reach_user=True)-    except Exception:-        pass---def _handle_new_electronics_from_eagle(args: dict) -> dict:-    """Import a legacy EAGLE .sch (+ paired .brd) into a NEW Fusion electronics design-    so the parts actually INSTANTIATE (schematic + populated board), then land in the PCB-    editor. Proven live 2026-07-07 on winvm.--    ⭐ PREFER THE BACKGROUND PATH FIRST (learned 2026-07-08, the hard way): if you have a-    `.brd` with the parts ALREADY PLACED (`<elements>` with x/y + an embedded `<library>`),-    do NOT use this verb - call **`fusion_open_board {filePath: <.brd>}`** instead. It opens-    the board via `Document.newDesignFromLocal`, which INSTANTIATES the placed elements with-    ZERO modal file-dialogs and ZERO foreground (this verb's ImportSCHAndBRDCmd pops TWO-    native Open dialogs that STEAL the user's focus - modal dialogs always foreground, there-    is no background way to drive them). A hand-authored EAGLE `.brd` (well-formed XML: layers-    + board outline on layer 20 + libraries/packages + elements) imports cleanly this way.-    ➜ FOR 3D BODIES ON THAT BOARD (not flat pads): embed a `<packages3d>` section (each-    `<package3d name=... wip_urn="urn:adsk.wipprod:fs.file:vf...."/>` from a prior-    `fusion_build_library_3d` bind) in the `.brd`'s `<library>`, AND give every `<element>` a-    `package3d_urn="<that pin's wip_urn>"`. Then `fusion_open_board` + `fusion_show_3d_board`-    renders the REAL component 3D (Fusion resolves the urns from the Hub). No urn on the-    element = a flat pad. (If a urn is stale/unresolved, re-run `fusion_build_library_3d` for-    that part to mint a fresh one.)--    THIS VERB is only for a legacy `.sch` whose parts must be instantiated by Fusion's own-    importer: `Document.newDesignFromLocal <file.sch>` opens the schematic EDITOR but does NOT-    instantiate parts (board_info == 0); Fusion's REAL EAGLE importer is `ImportSCHAndBRDCmd`,-    which pops the two Open dialogs. This verb drives them via the AD direct API when present --    but that FOREGROUNDS Fusion, so use the `.brd`+`fusion_open_board` route whenever you can-    place the parts yourself. The native `.fsch/.fbrd` container is opaque BINARY, un-authorable.--    args: {schPath: Windows path to the .sch (required), brdPath: Windows path to the .brd-           (optional; defaults to the .sch's sibling .brd)}-    """-    import time as _t-    sch = (args.get("schPath") or args.get("filePath") or "").replace("\\", "/")-    if not sch or not sch.lower().endswith(".sch"):-        return {"success": False, "error": "schPath (a .sch file on the Windows host) is required.",-                "_hint": "Author the EAGLE .sch + paired .brd (same basename, same folder), stage both "-                         "to Windows (send_files), then call with {schPath, brdPath}."}-    brd = (args.get("brdPath") or (sch[:-4] + ".brd")).replace("\\", "/")-    if not ad_client.available():-        # Do NOT dead-end here (old bug: hard-failed even on latest AD when this bridge process-        # didn't get AD's injected direct-API env). The RIGHT move is the background route.-        brd_guess = (args.get("brdPath") or (sch[:-4] + ".brd")).replace("\\", "/")-        return {"success": False, "errorCode": "use_open_board_instead",-                "error": "AD direct API unavailable - and this dialog-driven import FOREGROUNDS Fusion anyway.",-                "_hint": ("Don't drive the Open dialogs. If the parts are placed in the .brd (elements + "-                          "embedded library), call `fusion_open_board {filePath:\"" + brd_guess + "\"}` - it "-                          "instantiates them via newDesignFromLocal with NO dialogs and NO foreground (the "-                          "background way). For 3D bodies, embed <packages3d> + per-element package3d_urn in the "-                          ".brd (see this verb's docstring), then fusion_show_3d_board. Only if you truly have a "-                          "bare .sch that Fusion must import: fire 'Commands.Start ImportSCHAndBRDCmd' via "-                          "fusion_execute_text_command and drive the two Open dialogs (foreground; last resort).")}--    def _find_open_dialog():-        res = ad_client.call("desktop_list_windows", {}) or {}-        out = res.get("output") if isinstance(res, dict) else None-        if isinstance(out, str):-            try: out = json.loads(out)-            except Exception: out = None-        wins = ((out or res).get("data", out or res) or {}).get("windows", []) if isinstance(out or res, dict) else []-        for w in wins:-            if str(w.get("title", "")).strip().lower() == "open":-                return w.get("hwnd")-        return None--    def _pick(hwnd, basename):-        # select the file by accessible name, then click Open - both background UIA-        ad_client.call("desktop_ui_click", {"hwnd": hwnd, "name": basename})-        _t.sleep(1.0)-        ad_client.call("desktop_ui_click", {"hwnd": hwnd, "name": "Open"})--    import os as _os-    sch_base, brd_base = _os.path.basename(sch), _os.path.basename(brd)--    # 1) Fire Fusion's EAGLE importer (opens the first Open dialog).-    _proxy_to_addin("execute_text_command", {"command": "Commands.Start ImportSCHAndBRDCmd"}, timeout=15)--    # 2) Drive the two Open dialogs (sch, then brd). Each appears after a beat; retry.-    picked = []-    for want, base in (("sch", sch_base), ("brd", brd_base)):-        dlg = None-        for _ in range(20):-            _t.sleep(1.5)-            dlg = _find_open_dialog()-            if dlg:-                break-        if not dlg:-            # brd dialog may not appear if Fusion inferred the sibling automatically-            if want == "brd" and picked:-                break-            return {"success": False, "error": f"The '{want}' Open dialog never appeared.",-                    "_hint": "Screenshot the Fusion hwnd + READ screenshots[] for a blocking modal; the importer "-                             "may have errored on the EAGLE source. Verify the .sch/.brd are valid EAGLE.",-                    "data": {"picked": picked}}-        _pick(dlg, base)-        picked.append(base)-        _t.sleep(2.0)--    # 3) Verify by STATE - poll until the imported design is an electronics design.-    ready = False-    for _ in range(40):  # up to ~2 min; import is slow, esp. software-rendered-        _t.sleep(3)-        try:-            st = _proxy_to_addin("get_app_state", {}, timeout=8)-            d = st.get("data") or {}-            if d.get("isElectronics") and str(d.get("activeWorkspace", "")).lower() in ("pcb editor", "schematic editor", "board layout", "3d pcb"):-                ready = True-                break-        except Exception:-            pass  # app_state is None while a modal import dialog blocks - keep polling--    return {-        "success": ready,-        "imported": picked,-        "statusVerb": "fusion_board_info",-        "_hint": (-            "EAGLE design imported via Fusion's own ImportSCHAndBRDCmd (both Open dialogs driven in the "-            "background). It is now an electronics design in the PCB editor. NEXT: fusion_show_2d_board, "-            "then RATSNEST + 'AUTO ;' (fusion_electron_run) to autoroute, fusion_show_3d_board for the 3D. "-            "NOTE: board_info can read 0 right after import (wrong-view query) even though the board is "-            "populated - verify by screenshotting the Fusion hwnd (read screenshots[]) or fusion_show_2d_board "-            "+ WINDOW FIT. If 3D bodies are missing, the placed parts' package3d urns must resolve in THIS "-            "project - build the library's 3D into this project with fusion_build_library_3d (do NOT reuse "-            "another library's urns; they don't resolve cross-project)."-            if ready else-            "Import fired + both Open dialogs driven, but the design did not confirm as electronics within the "-            "budget (a slow VM import can run longer). Poll fusion_get_app_state, and screenshot the Fusion hwnd "-            "reading screenshots[] for a blocking import dialog."-        ),-    }---def _handle_launch(fusion_info: dict, args: dict) -> dict:-    """Launch Fusion 360 and optionally wait for the AdomBridge add-in."""-    # LIVE detect (not the stale bridge-start fusion_info snapshot) so a launch right-    # after a fresh install is recognized (with a current exe_path) and a launch after a-    # removal fails cleanly. Reassign so the whole handler uses fresh paths.-    fusion_info = detect_fusion()-    if not fusion_info.get("installed"):-        return {-            "success": False,-            "error": "Fusion 360 is not installed on this machine.",-            "errorCode": "fusion_not_installed",-            "_hint": "Fusion 360 isn't installed. Do NOT tell the user to install it themselves - OFFER to install it FOR them and do it on a yes: the fusion-onboarding skill silent-installs Fusion + drives the Autodesk sign-in. (For a plain 'what EDA tools are installed' check, AD's bridge_readiness reports this cleanly without erroring.)",-        }--    if _is_fusion_running():-        addin_ok = wait_for_addin(timeout=5)-        return {-            "success": True,-            "output": "Fusion 360 is already running.",-            "addinConnected": addin_ok,-            "alreadyRunning": True,-            "resolvedPath": fusion_info.get("exe_path", ""),-        }--    # Relocate any crash recovery files BEFORE launching Fusion.-    # This prevents the "Recovered Documents" dialog from appearing at all.-    # Files are moved to ~/.adom/recovery/fusion/<timestamp>/ (not deleted).-    from handlers.dismiss_recovery import relocate_recovery_files-    reloc = {"moved": 0, "dest": None}-    try:-        reloc = relocate_recovery_files()-    except Exception:-        pass--    # Launch Fusion. AD's relay caps a single request at ~60s, but a FIRST launch-    # (fresh install: component downloads, updates, cloud sync) can take 2-4 min --    # far past the cap. So we DON'T block on the add-in for the whole launch (that-    # dead-ended #63/Arav, and {"timeout":300} can't beat the relay cap). Instead:-    # launch + confirm the process, then wait for the add-in only within a budget-    # that keeps this request UNDER the relay cap; if it's still coming up, return a-    # clean stillLaunching response telling the caller to POLL fusion_readiness.-    import time as _t-    _start = _t.time()-    err = ensure_fusion_running(fusion_info, wait_addin=False)-    if err:-        return err--    # WATCH for launch/licensing dialogs the moment the process is up - IN CODE, so-    # a launch is never blind to them (the seat-conflict + streamed-app-error dialogs-    # are owned by AdskIdentityManager/FusionLauncher, invisible to the Fusion-scoped-    # classifier). Auto-dismiss BENIGN errors.-    launch_dialogs = classify_launch_dialogs()-    for _dlg in launch_dialogs:-        if _dlg.get("category") in AUTO_DISMISS_CATEGORIES:-            close_dialog_bg(_dlg.get("hwnd"))  # benign ack (WM_CLOSE == Cancel/OK)--    # ── DETERMINISTIC launch state loop (John 2026-07-06: track + handle state in-    #    CODE, in the BACKGROUND, never punt to the user, never foreground). Poll the-    #    add-in round-trip (the ground truth for "drivable") while, each tick:-    #      • AUTO-RESOLVING the seat/licensing dialog ("Active Sessions Exceeded") via-    #        UIA Invoke of its 'Continue' button - background, no foreground (THE fix;-    #        Suspend is pre-selected so Continue grabs the license). This is what-    #        un-sticks the launch: a blocked seat dialog made Fusion retry sign-in in a-    #        loop, foregrounding on every retry - the source of the endless fg-steal.-    #      • dismissing benign recovery/startup dialogs.-    #    Detection is deterministic (find_licensing_dialog by owning process + size,-    #    NOT the "Fusion360" title). No kill/relaunch (the server keeps the seat), no-    #    notify (we handle it ourselves). ──-    _RELAY_BUDGET = 50  # keep total handler time under AD's ~60s relay cap-    _deadline = _t.time() + max(6, int(_RELAY_BUDGET - (_t.time() - _start)))-    _seat_clicks = 0-    addin_ok = False-    while _t.time() < _deadline:-        # SEAT/LICENSING dialog takes PRIORITY over the add-in probe (fix, caught live-        # 2026-07-06): a STALE add-in port from a prior instance can answer while the-        # CURRENT Fusion sits blocked behind the seat dialog - so we must NOT break on-        # the add-in while the dialog is up. And the first UIA Invoke on a mid-render-        # dialog silently NO-OPs, so keep invoking 'Continue' EVERY tick until-        # find_licensing_dialog no longer sees it (verified gone), not just once.-        try:-            _lic = find_licensing_dialog()-        except Exception:-            _lic = None-        if _lic:-            # Resolve + SCREENSHOT-VERIFY it cleared (parent ownedPopupCount), not a-            # size heuristic that false-'resolved' the short confirm variant.-            if _seat_clicks < 10:-                _r = _resolve_seat_dialog(max_clicks=3)-                _seat_clicks += _r.get("clicks", 0)-            _t.sleep(1.0)-            continue-        # No seat dialog -> is the add-in actually serving? (ground truth for drivable)-        if _probe_addin(timeout=1.5):-            addin_ok = True-            break-        try:-            dismiss_recovery_dialog()-        except Exception:-            pass-        _t.sleep(2.5)--    if not addin_ok:-        # Add-in not up within our budget. On a first launch this is EXPECTED (still-        # initializing / signing in), not a failure and not a retry-able timeout.-        return {-            "success": True,-            "stillLaunching": True,-            "addinConnected": False,-            "statusVerb": "fusion_readiness",-            "recoveryFilesRelocated": reloc["moved"],-            "resolvedPath": fusion_info.get("exe_path", ""),-            "seatDialogResolved": _seat_clicks > 0,-            "backgroundLaunch": True,-            "_hint": (-                "Fusion was launched IN THE BACKGROUND (minimized, foreground-lock on) so it does not "-                "steal the user's focus - do NOT foreground it. "-                + ("A seat 'Active Sessions Exceeded' dialog appeared and was AUTO-RESOLVED in the "-                   "background via UIA (no foreground). " if _seat_clicks else "")-                + "The add-in isn't up yet - a first launch / fresh sign-in can take 2-4 min. This is "-                "NOT a failure and NOT a retry-able timeout: do NOT re-call fusion_start. WHAT HAPPENS "-                "NEXT: POLL fusion_readiness every ~10s; it returns needsSignin/licensingDialog/updating "-                "if something is mid-flight (the bridge handles the seat dialog itself), and ready:true "-                "when it is drivable - then run your fusion_* verbs. NEVER poll blind past ~2 min: "-                "desktop_screenshot_window the Fusion hwnd and READ the response's screenshots[] array "-                "- every OWNED POPUP rides back as its own child shot (this is how you SEE a blocking "-                "dialog; never desktop_screenshot_screen, the user is doing other things)."-            ),-        }--    # Auto-dismiss the "Recovered Documents" dialog if it appeared on launch.-    # Even though we relocated files above, Fusion may still show recovery-    # dialogs if files were locked or if Fusion cached them.-    recovery_dismissed = dismiss_recovery_dialog()--    # Probe the add-in to check if a modal dialog is blocking.-    # If Fusion has a "Recovered Documents" or other modal up, the add-in's-    # main thread will be blocked and commands will timeout. We detect this-    # PROACTIVELY so the AI knows immediately, rather than waiting for a-    # 30-second command timeout.-    dialog_blocking = False-    if addin_ok:-        dialog_blocking = _check_main_thread_blocked()--    output = "Fusion 360 launched successfully."-    if reloc["moved"] > 0:-        output += (-            f" Relocated {reloc['moved']} recovery file(s) to {reloc['dest']}"-            " (preserved for manual recovery if needed)."-        )-    if recovery_dismissed:-        output += " Dismissed 'Recovered Documents' dialog."-    if dialog_blocking:-        output = (-            "Fusion 360 launched but a MODAL DIALOG is blocking the UI. "-            "This is likely the 'Recovered Documents' dialog from a previous crash. "-            "Take a screenshot with desktop_screenshot_window to see what's showing, "-            "then dismiss it manually or use fusion_dismiss_recovery."-        )--    return {-        "success": True,-        "output": output,-        "addinConnected": addin_ok,-        "dialogBlocking": dialog_blocking,-        "recoveryDialogDismissed": recovery_dismissed,-        "recoveryFilesRelocated": reloc["moved"],-        "recoveryFilesPath": reloc["dest"],-        "resolvedPath": fusion_info.get("exe_path", ""),-    }---def _handle_dismiss_recovery(fusion_info: dict, args: dict) -> dict:-    """Dismiss the 'Recovered Documents' dialog if it's showing.--    Recovery files are relocated to ~/.adom/recovery/fusion/<timestamp>/-    instead of being deleted, preserving the user's safety net.-    """-    from handlers.dismiss_recovery import relocate_recovery_files--    # Relocate files first (dismiss_recovery_dialog also does this,-    # but we capture the result here for reporting)-    reloc = {"moved": 0, "dest": None}-    try:-        reloc = relocate_recovery_files()-    except Exception:-        pass--    dismissed = dismiss_recovery_dialog()--    parts = []-    if dismissed:-        parts.append("Dismissed recovery dialog(s).")-    else:-        parts.append("No recovery dialog found.")-    if reloc["moved"] > 0:-        parts.append(-            f"Relocated {reloc['moved']} recovery file(s) to {reloc['dest']}. "-            "The user can restore them from there if needed."-        )--    return {-        "success": True,-        "output": " ".join(parts),-        "dismissed": dismissed,-        "recoveryFilesRelocated": reloc["moved"],-        "recoveryFilesPath": reloc["dest"],-    }---def _post_open_screenshot(result: dict, settle_time: float = 2.0) -> dict:-    """Auto-screenshot Fusion after an open/switch command.--    Waits for Fusion to settle, then captures the full window (including-    any Qt dialogs or CEF overlays the API can't see). Attaches the-    screenshot path to the result so the AI can inspect it immediately-    without making a separate call.--    The screenshot is embedded into the result's 'data' field because the-    Tauri relay only forwards 'output', 'data', 'success', and 'error'.--    Also checks for blocking dialogs and warns in the response.-    """-    import re-    import time-    time.sleep(settle_time)--    # Check if main thread is blocked (dialog present)-    blocked = _check_main_thread_blocked()--    # Capture main Fusion window-    screenshot = screenshot_fusion_window()-    screenshot_info = {"screenshots": []}--    if screenshot.get("success"):-        screenshot_info["screenshots"].append({-            "type": "main_window",-            "savedTo": screenshot["savedTo"],-            "sizeKB": screenshot["sizeKB"],-        })-    else:-        screenshot_info["error"] = screenshot.get("error", "Screenshot failed")--    # Also capture ALL dialog windows — these are separate Qt windows-    # that the main window screenshot won't show-    window_info = get_fusion_window_info()-    if window_info.get("success"):-        dialogs = window_info.get("dialogs", [])-        for dialog in dialogs:-            try:-                title = dialog.get("title", "")-                hwnd = dialog.get("hwnd")-                # Skip tiny/hidden windows and banners (only screenshot real dialogs)-                rect = dialog.get("rect", {})-                w = rect.get("width", 0)-                h = rect.get("height", 0)-                if w < 50 or h < 50:-                    continue-                # Screenshot this dialog — sanitize label for filesystem-                label = re.sub(r'[<>:"/\\|?*]', '', title).replace(" ", "_")[:30]-                dialog_ss = screenshot_hwnd(hwnd, label=f"dialog-{label}")-                if dialog_ss.get("success"):-                    screenshot_info["screenshots"].append({-                        "type": "dialog",-                        "title": title,-                        "hwnd": hwnd,-                        "savedTo": dialog_ss["savedTo"],-                        "sizeKB": dialog_ss["sizeKB"],-                    })-            except Exception as e:-                screenshot_info.setdefault("errors", []).append(-                    f"Failed to screenshot dialog '{title}': {e}"-                )--    screenshot_info["message"] = (-        f"Auto-captured {len(screenshot_info['screenshots'])} Fusion window(s) after open. "-        "READ EACH screenshot file to check for blocking dialogs "-        "('What to design?', 'PCB out of date', 'Save changes?'). "-        "PCB/ELECTRONICS NAV RULE: a board is NOT a standalone file - the schematic, 2D board and 3D board are VIEWS inside ONE electronics DESIGN. Open the DESIGN document first (fusion_open_cloud_file or fusion_open_by_urn), THEN switch to its schematic and its 2D board (fusion_show_2d_board) and 3D board (fusion_show_3d_board). A Select-Electronics-Design-File picker IS that design list of its schematic + board. An EMPTY 2D board usually means you opened a derivative or the wrong file, or it is out of sync - reopen the parent electronics DESIGN and switch to its board view; do NOT open a .brd or .sch as a standalone file. "-        "Dismiss with fusion_send_key {\"key\": \"escape\"} if needed."-    )--    if blocked:-        screenshot_info["dialogBlocking"] = True-        screenshot_info["message"] = (-            "WARNING: A modal dialog is blocking Fusion. "-            "READ the screenshot to identify and dismiss it. " +-            screenshot_info["message"]-        )--    # Inject screenshot into the result's top-level 'data' dict.-    # The Tauri relay constructs its output as: json!({"message": output, "data": data})-    # so anything we put in result["data"] gets forwarded to the CLI.-    result.setdefault("data", {})-    if not isinstance(result["data"], dict):-        result["data"] = {}-    result["data"]["postOpenScreenshot"] = screenshot_info--    # UNIVERSAL: if an owned "Error" popup is up (e.g. malformed .lbr "has errors and-    # cannot be opened"), flip success -> False + surface it. An open that silently-    # errored must never be reported as ok.-    return _apply_failure_dialogs(result)---def _capture_dialog_array(settle: float = 0.6) -> dict | None:-    """After a mutating op, enumerate + screenshot any owned popups / modal dialogs so-    the AI can SEE and ANALYZE them. Titles alone are not enough: a dialog's body text-    lives in CEF/Qt child controls that are NOT Win32-readable, so we attach a per-dialog-    screenshot (hwnd-targeted PrintWindow, background - never foreground/fullscreen).--    Cheap when nothing is up: it enumerates windows (Win32 EnumWindows) and only-    screenshots when a real dialog is present. Returns None when no dialog is up;-    otherwise {dialogsDetected, dialogs:[{hwnd,title,category,resolution,screenshot}],-    _hint}. Never raises - best effort.-    """-    import re as _re-    import time as _t-    if settle:-        _t.sleep(settle)-    try:-        info = get_fusion_window_info()-    except Exception:-        return None-    if not info.get("success"):-        return None-    raw = info.get("dialogs") or []-    if not raw:-        return None-    main_enabled = info.get("mainEnabled", True)-    try:-        classified = {c.get("hwnd"): c for c in classify_blocking_dialogs()}-    except Exception:-        classified = {}-    # Categories that mean "we could not identify it by title" - i.e. a generic-    # "Fusion360"-titled window. When the main window is still ENABLED, such a-    # window is a docked panel (Browser/Timeline), NOT a blocking modal, so we-    # suppress it to avoid crying wolf on every op. A real modal DISABLES the main-    # window (main_enabled False) and is always surfaced; known dialogs (recovery,-    # update, save prompts) are surfaced regardless of the enabled state.-    _GENERIC = {None, "generic_modal_read_screenshot", "unknown"}-    out = []-    for d in raw:-        rect = d.get("rect", {})-        if rect.get("width", 0) < 80 or rect.get("height", 0) < 50:-            continue  # skip banners / tiny docked tool windows-        hwnd = d.get("hwnd")-        title = d.get("title", "")-        c = classified.get(hwnd) or {}-        cat = c.get("category")-        if main_enabled and cat in _GENERIC:-            continue  # docked panel, not a modal - nothing is blocking the main window-        entry = {"hwnd": hwnd, "title": title}-        if cat:-            entry["category"] = cat-            entry["resolution"] = c.get("resolution")-        try:-            label = _re.sub(r'[<>:"/\\|?*]', "", title).replace(" ", "_")[:30] or "dialog"-            ss = screenshot_hwnd(hwnd, label=f"dlg-{label}")-            if ss.get("success"):-                entry["screenshot"] = ss["savedTo"]-        except Exception:-            pass-        out.append(entry)-    if not out:-        return None-    return {-        "dialogsDetected": len(out),-        "dialogs": out,-        "mainWindowEnabled": main_enabled,-        "_hint": (-            f"⚠️ {len(out)} Fusion dialog(s)/owned popup(s) are up after this operation. "-            "STOP and ANALYZE before the next call: READ each dialogs[].screenshot and use its "-            "category/resolution. Do NOT blind-dismiss - some confirms LOSE work (clicking Yes on a "-            "Hub-upload 'are you sure you want to close?' destroys the uploaded packages). Take the "-            "work-preserving action (often: click No, then wait for the upload to drain). See the "-            "fusion-driving skill."-        ),-    }---def _merge_dialog_array(result: dict) -> dict:-    """Attach the post-op dialog array (if any) to a mutating verb's result, so the AI-    always sees pending dialogs WITHOUT having to remember a separate call. The hint is-    mirrored to top-level _hint AND into data.dialogArray (data is forwarded reliably by-    the relay). No-op when no dialog is up."""-    if not isinstance(result, dict):-        return result-    try:-        arr = _capture_dialog_array()-    except Exception:-        arr = None-    if not arr:-        return result-    result.setdefault("data", {})-    if isinstance(result.get("data"), dict):-        result["data"]["dialogArray"] = arr-    existing = result.get("_hint")-    result["_hint"] = arr["_hint"] + ((" | " + existing) if existing else "")-    return _apply_failure_dialogs(result)---def _apply_failure_dialogs(result: dict) -> dict:-    """UNIVERSAL error-dialog guard — baked into every mutating/open verb so the AI-    can never again report a failed op as success.--    Right after an operation, an owned Fusion popup titled "Error" (e.g. "<file>.lbr-    has errors and cannot be opened") means the op FAILED even when the underlying API-    call returned ok (Fusion opened an EMPTY design behind the error). This classifies-    any up dialog; if one is in FAILURE_CATEGORIES it:-      1. SCREENSHOTS the popup (hwnd-targeted, background) so the AI SEES it,-      2. flips result.success -> False + sets errorCode/error/_hint,-      3. dismisses the pure-ack popup (WM_CLOSE) so it can't block the next command.-    Best-effort + cheap when nothing is up (title enumeration, no screenshot). Never raises."""-    try:-        from handlers.dialog_classify import classify_blocking_dialogs, FAILURE_CATEGORIES-        dialogs = classify_blocking_dialogs()-    except Exception:-        return result-    failures = [d for d in (dialogs or []) if d.get("category") in FAILURE_CATEGORIES]-    if not failures or not isinstance(result, dict):-        return result-    f = failures[0]-    shots = []-    for d in failures:-        try:-            ss = screenshot_hwnd(d.get("hwnd"), label="error-dialog")-            if ss.get("success"):-                shots.append(ss["savedTo"])-        except Exception:-            pass-    prev_err = result.get("error") or ""-    result["success"] = False-    result["errorCode"] = "fusion_operation_error"-    result["error"] = (-        f"Operation FAILED - Fusion '{f.get('title') or 'Error'}' dialog is up"-        + (f" (prior: {prev_err})" if prev_err else "")-    )-    result["_hint"] = (f.get("resolution") or-                       "Fusion shows an Error dialog; the operation failed. Read the screenshot.")-    result.setdefault("data", {})-    if isinstance(result.get("data"), dict):-        result["data"]["errorDialog"] = {-            "title": f.get("title"), "category": f.get("category"),-            "resolution": f.get("resolution"), "screenshots": shots,-        }-    # Pure OK-acknowledgement — dismiss so it doesn't wedge the next command.-    for d in failures:-        try:-            close_window(d.get("hwnd"))-        except Exception:-            pass-    return result---def _handle_open_cloud_file(fusion_info: dict, args: dict) -> dict:-    """Open a cloud file with post-open dialog detection + auto-screenshot.--    Proxies to the add-in's open_cloud_file command, then:-    1. Waits for Fusion to settle-    2. Checks if a modal dialog blocked the main thread-    3. Auto-screenshots the Fusion window so the AI can see what happened-    """-    # Send the open command — cloud files can take 30+ seconds to download/open-    result = _proxy_to_addin("open_cloud_file", args, timeout=45)--    if not result.get("success"):-        # Failure is often a modal blocking the add-in's main thread (multi-design-        # "Select Electronics Design File" picker, update nag, recovery, etc.) — which-        # otherwise surfaces as a misleading "add-in not responding". Classify the-        # actual blocking dialog so the caller gets the real cause + resolution.-        dialogs = classify_blocking_dialogs()-        if dialogs:-            result["dialogBlocking"] = True-            result["blockingDialogs"] = dialogs-            result["hint"] = (-                "A modal dialog is blocking Fusion — see blockingDialogs for the "-                "classified cause + resolution. The multi-link 'Select Electronics "-                "Design File' picker is a CEF dialog that must be resolved on the "-                "desktop (its list isn't keyboard/Win32 navigable); recovery prompts "-                "use fusion_dismiss_recovery; an update nag must be cleared on the desktop."-            )-        # Screenshot even on failure — shows what went wrong-        return _post_open_screenshot(result, settle_time=1.0)--    return _post_open_screenshot(result)---def _handle_open_with_screenshot(fusion_info: dict, args: dict, command: str) -> dict:-    """Proxy an open/switch command to the add-in, then auto-screenshot.--    Used for open_schematic, open_board, show_3d_board, show_2d_board —-    any command that changes the Fusion UI state and might trigger a dialog.-    """-    result = _proxy_to_addin(command, args, timeout=30)-    if result.get("success"):-        return _post_open_screenshot(result)-    return result---def _handle_screenshot_fusion(fusion_info: dict, args: dict) -> dict:-    """Screenshot Fusion main window or a specific dialog by HWND."""-    hwnd = args.get("hwnd") or args.get("dialogHwnd")-    if hwnd:-        result = screenshot_hwnd(int(hwnd), label="dialog")-    else:-        result = screenshot_fusion_window()-    if result.get("success"):-        result["output"] = json.dumps({"savedTo": result["savedTo"], "sizeKB": result["sizeKB"]})-    return result---def _handle_click_fusion(fusion_info: dict, args: dict) -> dict:-    """Click at coordinates within Fusion window or a specific dialog HWND."""-    x = args.get("x", 0.5)-    y = args.get("y", 0.5)-    relative = args.get("relative", True)-    hwnd = args.get("hwnd") or args.get("dialogHwnd")-    # SendInput clicks REQUIRE foreground (steals the user's focus). Announce it first --    # prefer background verbs (desktop_ui_click by name); this path is the last resort.-    _notify_before_foreground(args.get("reason") or "clicking a control Fusion only accepts via a foreground click")-    result = click_fusion(x, y, relative, hwnd=hwnd)-    if result.get("success"):-        result["output"] = json.dumps(result.get("clickedAt", {}))-    return result---def _handle_send_key(fusion_info: dict, args: dict) -> dict:-    """Send a key to Fusion or a specific dialog via SendInput."""-    key = args.get("key", "")-    if not key:-        return {"success": False, "error": "No key specified. Use 'enter', 'escape', 'tab', etc."}-    hwnd = args.get("hwnd") or args.get("dialogHwnd")-    # SendInput keystrokes REQUIRE foreground (steals the user's focus). Announce it first --    # prefer background verbs (desktop_ui_set / WM_CLOSE); this path is the last resort.-    _notify_before_foreground(args.get("reason") or f"sending the '{key}' key, which Fusion only accepts in the foreground")-    result = send_key_to_fusion(key, hwnd=hwnd)-    if result.get("success"):-        result["output"] = f"Sent key '{key}' to Fusion."-    return result---def _handle_close_window(fusion_info: dict, args: dict) -> dict:-    """Close a dialog window by sending WM_CLOSE (equivalent to clicking X).--    Unlike Escape, this works on dialogs that don't have Cancel/Escape handling-    (e.g. the Recovered Documents dialog). Does NOT force-kill — the dialog can-    still intercept WM_CLOSE and prompt for confirmation.-    """-    hwnd = args.get("hwnd")-    if not hwnd:-        return {"success": False, "error": "Missing required arg: hwnd"}-    result = close_window(int(hwnd))-    if result.get("success"):-        result["output"] = f"Sent WM_CLOSE to hwnd {hwnd}."-    return result---def _handle_window_info(fusion_info: dict, args: dict) -> dict:-    """Get Fusion window info including any dialog windows."""-    result = get_fusion_window_info()-    if result.get("success"):-        dialogs = result.get("dialogs", [])-        dialog_info = [f"  hwnd={d['hwnd']}: {d['title']}" for d in dialogs]-        output_parts = [-            f"Main: hwnd={result['hwnd']}, title='{result['title']}'",-        ]-        if dialogs:-            output_parts.append(f"Dialogs ({len(dialogs)}):")-            output_parts.extend(dialog_info)-        else:-            output_parts.append("No dialog windows detected.")-        if dialogs:-            hint = (f"{len(dialogs)} dialog(s) detected. Screenshot each via "-                    "desktop_screenshot_window {{\"hwnd\":<hwnd>}}, then dismiss with "-                    "fusion_dismiss_blocking_dialogs or fusion_send_key/fusion_close_window.")-        else:-            hint = "No dialogs — Fusion is clear. Safe to run fusion_* commands."-        result["output"] = json.dumps({-            "hwnd": result["hwnd"],-            "title": result["title"],-            "rect": result["rect"],-            "dialogs": dialogs,-            "message": "\n".join(output_parts),-            "_hint": hint,-        })-    return result---def _handle_screenshot_all_fusion(fusion_info: dict, args: dict) -> dict:-    """Screenshot Fusion main window AND all dialog windows.--    Returns paths to all captured screenshots so the AI can see-    CEF overlay dialogs and separate Qt dialog windows.-    """-    screenshots = []--    # 1. Capture main Fusion window from screen (captures CEF overlays)-    main_result = screenshot_fusion_window()-    if main_result.get("success"):-        screenshots.append({-            "type": "main_window",-            "path": main_result["savedTo"],-            "sizeKB": main_result["sizeKB"],-        })--    # 2. List all Qt dialog windows (AI should use desktop_screenshot_window for each)-    info = get_fusion_window_info()-    if info.get("success") and info.get("dialogs"):-        for dialog in info["dialogs"]:-            screenshots.append({-                "type": "dialog",-                "title": dialog["title"],-                "hwnd": dialog["hwnd"],-                "rect": dialog["rect"],-                "hint": f"Use desktop_screenshot_window with hwnd={dialog['hwnd']} to capture this dialog.",-            })--    return {-        "success": True,-        "screenshots": screenshots,-        "dialogCount": len(info.get("dialogs", [])) if info.get("success") else 0,-        "_hint": f"Captured {len(screenshots)} screenshot(s). Screenshots saved on Windows — "-                 "use pull_file to get them to Docker, or Read tool to view each path directly. "-                 "If dialogs are present, dismiss with fusion_dismiss_blocking_dialogs.",-    }---def _handle_relocate_recovery(fusion_info: dict, args: dict) -> dict:-    """Relocate Fusion crash recovery files to ~/.adom/recovery/fusion/.--    Call this proactively before launching Fusion to prevent recovery-    dialogs from appearing. Files are preserved (not deleted) so the-    user can manually restore them if needed.-    """-    from handlers.dismiss_recovery import relocate_recovery_files-    try:-        reloc = relocate_recovery_files()-    except Exception as exc:-        return {-            "success": False,-            "error": f"Failed to relocate recovery files: {exc}",-        }--    if reloc["moved"] > 0:-        return {-            "success": True,-            "output": (-                f"Relocated {reloc['moved']} recovery file(s) to {reloc['dest']}. "-                "These files are preserved — the user can restore them from that "-                "directory if they need to recover unsaved work."-            ),-            "moved": reloc["moved"],-            "dest": reloc["dest"],-            "sources": reloc["sources"],-        }-    return {-        "success": True,-        "output": "No crash recovery files found.",-        "moved": 0,-    }---def _installer_running() -> bool:-    """Is the Fusion Client Downloader / streamer currently installing? Bridge-side-    check (subprocess from OUR process = no AD shell-approval gate)."""-    try:-        import subprocess as _sp-        out = _sp.run(["tasklist", "/FO", "CSV", "/NH"], capture_output=True, text=True,-                      timeout=15, creationflags=getattr(_sp, "CREATE_NO_WINDOW", 0)).stdout-        return ("streamer.exe" in out) or ("FusionDL.exe" in out) or ("Fusion Client Downloader" in out)-    except Exception:-        return False---def _handle_install_fusion(fusion_info: dict, args: dict) -> dict:-    """Install Fusion 360 FOR the user - no shell_execute, no AD approval gate.--    Declared as detect.installVerb (AD >=1.9.79) so bridge_readiness recommends-    THIS over the generic winget fallback. Downloads the official Fusion Client-    Downloader and runs it silently: --globalinstall when elevated, PER-USER when-    not (learned live: a non-admin shell makes --globalinstall die silently on-    UAC; the per-user install needs no elevation and lands in %LOCALAPPDATA%,-    which detect covers). Returns PROMPTLY - the stream takes 10-30 min; poll-    fusion_readiness until installed:true."""-    # LIVE detect only - never trust the bridge-start fusion_info snapshot, which can be-    # stale-True after Fusion was removed while the bridge kept running (found live on a-    # fresh VM 2026-07-05: this verb refused with "already installed" though disk was-    # NO_WEBDEPLOY, blocking the reinstall). detect_fusion() is the on-disk truth.-    if detect_fusion().get("installed"):-        return {"success": True, "alreadyInstalled": True,-                "_hint": "Fusion 360 is already installed - call fusion_start to launch it."}-    if _installer_running():-        return {"success": True, "installing": True, "statusVerb": "fusion_readiness",-                "_hint": "An install is ALREADY streaming (10-30 min). Poll fusion_readiness "-                         "until installed:true - do not start a second installer."}--    import tempfile, urllib.request as _ur, subprocess as _sp, ctypes as _ct-    stub = os.path.join(tempfile.gettempdir(), "FusionClientDownloader.exe")-    try:-        _ur.urlretrieve(-            "https://dl.appstreaming.autodesk.com/production/installers/Fusion%20Client%20Downloader.exe",-            stub)-    except Exception as e:-        return {"success": False, "error": f"Installer download failed: {e}",-                "errorCode": "installer_download_failed",-                "_hint": "Check the box is online; retry fusion_install_fusion. The stub URL is "-                         "Autodesk's official appstreaming installer."}-    try:-        elevated = bool(_ct.windll.shell32.IsUserAnAdmin()) if hasattr(_ct, "windll") else False-    except Exception:-        elevated = False-    cmd = [stub, "--quiet"] + (["--globalinstall"] if elevated else [])-    try:-        _sp.Popen(cmd, stdin=_sp.DEVNULL, stdout=_sp.DEVNULL, stderr=_sp.DEVNULL,-                  creationflags=getattr(_sp, "CREATE_NO_WINDOW", 0) | getattr(_sp, "DETACHED_PROCESS", 0))-    except Exception as e:-        return {"success": False, "error": f"Installer launch failed: {e}",-                "errorCode": "installer_launch_failed"}-    return {-        "success": True, "installing": True, "elevated": elevated,-        "mode": "globalinstall" if elevated else "per-user",-        "statusVerb": "fusion_readiness",-        "_hint": ("Fusion 360 install STARTED (" + ("system-wide" if elevated else-                  "per-user - no admin needed; a non-elevated --globalinstall dies silently on UAC") +-                  "). It streams several GB (10-30 min): poll fusion_readiness until installed:true "-                  "(it also reports installing:true while the streamer runs), then fusion_start. "-                  "NOTIFY the user it is underway (progress toast) and again at the Autodesk sign-in."),-    }---def _aps_quick_state() -> dict:-    """Cheap APS state for readiness: {configured, signedIn}. Never raises, never blocks."""-    try:-        import aps as _aps-        st = _aps.handle_status({}) or {}-        d = st.get("data", st)-        return {"configured": bool(d.get("configured")), "signedIn": bool(d.get("signedIn"))}-    except Exception:-        return {"configured": None, "signedIn": None}---def _handle_fusion_readiness(fusion_info: dict, args: dict) -> dict:-    """FAST readiness check for the AI: is the Fusion host app present + running + the bridge ready?-    Does NOT launch Fusion (fast-fails when it's not running). AD DETECTS Fusion (never installs it)-    via bridge.json 'detect', and auto-installs Python if missing (AD >=1.9.47) - this verb just-    reports state. Pairs with AD's bridge_readiness."""-    # ALWAYS live-detect. fusion_info is a bridge-START snapshot; Fusion may have been-    # installed OR UNINSTALLED since. Trusting a cached installed:True made readiness-    # report installed/running/ready:true on a machine where Fusion had been removed-    # (found live on a fresh VM 2026-07-05: NO_WEBDEPLOY on disk, yet readiness said-    # ready). detect_fusion() is a fast filesystem scan, so pay it every call.-    raw_installed = bool(detect_fusion().get("installed"))-    # `streaming` must be RELIABLE. _installer_running() is a tasklist check, but the Autodesk streamer-    # spawns short-lived per-chunk workers, so it reads False most of a live multi-GB stream (flickered-    # True only 3 of 25 polls during a real install, John 2026-07-14). OR in the disk-state signal - a-    # webdeploy hash dir with FusionLauncher.exe but a missing/truncated .ini - which is present for the-    # WHOLE stream. Now `installing` is trustworthy, not racy.-    streaming = _installer_running() or _incomplete_webdeploy_present()-    running = _is_fusion_running() if raw_installed else False-    # FRESH-INSTALL COMPLETENESS GUARD (John caught this live 2026-07-14 on a fresh Hyper-V VM):-    # the streamer writes FusionLauncher.exe BEFORE it finishes writing FusionLauncher.exe.ini, so-    # detect_fusion() flips installed:true mid-stream. Starting into that half-written install throws-    # "Error Launching Streamed Application ... FusionLauncher.exe.ini is missing or incomplete" and-    # leaves TWO corrupt webdeploy production dirs. So: while the streamer is running and Fusion is-    # NOT already up (i.e. this is a first install, not a background auto-update of a running Fusion),-    # the install is STILL STREAMING - report installed:false + installing:true so nothing calls-    # fusion_start yet. Only treat it as installed once the streamer has exited.-    if streaming and not running:-        installed = False-        installing = True-    else:-        installed = raw_installed-        installing = (not installed) and streaming--    # The add-in only serves once Fusion is PAST sign-in (its HTTP server runs on-    # Fusion's main thread, which the sign-in modal blocks). So a responding add-in-    # is the definitive "signed in + drivable" signal.-    addin_status = _check_addin_status(timeout=1.0) if running else None-    addin_ok = addin_status is not None--    # Fusion can be RUNNING yet not drivable: the first-run Autodesk sign-in modal-    # ("Signing in - Autodesk Fusion" / "Welcome to Fusion") blocks the main thread,-    # so the add-in never serves and every modeling verb hangs. The old code reported-    # ready:True here purely on installed+running, which was a lie during sign-in.-    # Detect it from the FUSION-PROCESS window title - NOT classify_launch_dialogs,-    # which scans ALL top-level windows and false-matches a stray Edge "Sign in --    # Autodesk" BROWSER tab (caught 2026-07-05).-    #-    # ⚠️ CHECK IT WHENEVER RUNNING, not only when the add-in is silent (fix 2026-07-06,-    # John caught it lying): a STALE add-in HTTP server from a just-killed instance can-    # still hold port 8774 and answer, so `addin_ok` goes True while the CURRENT Fusion-    # sits stuck on "Signing in". Gating the sign-in check on `not addin_ok` then let-    # readiness report ready:true for a dead, stuck-signing-in window. The Fusion-process-    # window title is authoritative - if it says "Signing in", we are NOT ready, period.-    needs_signin = False-    if running:-        try:-            finfo = get_fusion_window_info() or {}-            _t = (finfo.get("title") or "").lower()-            needs_signin = ("signing in" in _t or "welcome to fusion" in _t-                            or ("sign in - autodesk" in _t and "fusion" in _t))-        except Exception:-            needs_signin = False-    # "Ready to drive" requires the add-in to actually RESPOND (not just the process to-    # exist) AND no sign-in modal. A responding add-in proves Fusion's main thread is-    # free; requiring it stops a half-launched or stuck Fusion from reading as ready.-    ready = installed and running and addin_ok and not needs_signin--    # SEAT/LICENSING dialog blocking the launch - detected DETERMINISTICALLY by owning-    # process + dialog size (not the "Fusion360" title). Checked WHENEVER running, not-    # only when the add-in is silent (fix 2026-07-06): a STALE add-in port from a prior-    # instance answers while the CURRENT Fusion sits blocked behind the seat dialog, so-    # gating on `not addin_ok` false-negatived `licensingDialog` while the dialog sat-    # right there on the user's screen.-    #-    # ⚠️ GAP FIX (John caught it live 2026-07-06): the launch loop only auto-resolves the-    # seat dialog during its ~50s window. But this dialog also appears LATER - minutes-    # after sign-in, when the license server notices too many sessions - long after that-    # loop exited, so it just sat there blocking while readiness passively reported-    # licensingDialog:true. Now readiness SELF-HEALS: whenever it detects the dialog it-    # AUTO-RESOLVES it in the background (UIA 'Continue' + screenshot-verify), so the seat-    # is handled deterministically in CODE no matter when it shows up - the AI never has-    # to notice or act. (John: "do all this tracking from your code so it's deterministic-    # ... the ai never follows skills.")-    seat_resolved = False-    licensing = False-    if running:-        try:-            licensing = find_licensing_dialog() is not None-        except Exception:-            licensing = False-        if licensing:-            try:-                _r = _resolve_seat_dialog()-                seat_resolved = _r.get("clicks", 0) > 0-                # Re-check: did it actually clear? (screenshot-verified inside.)-                licensing = find_licensing_dialog() is not None-            except Exception:-                pass-            if not licensing:-                # Cleared - re-probe the add-in; Fusion may be drivable now.-                try:-                    addin_status = _check_addin_status(timeout=1.5)-                    addin_ok = addin_status is not None-                    ready = installed and running and addin_ok and not needs_signin-                except Exception:-                    pass-    if licensing:-        ready = False  # a seat/licensing dialog blocking the UI is never "ready"--    # Fusion applying an AUTO-UPDATE (two+ webdeploy builds) crash-restarts itself every-    # ~30-60s, so a verb intermittently sees running:false. That is NOT a crash or a-    # bridge failure - surface it as a distinct non-fatal `updating` flag so a driving AI-    # waits/retries instead of giving up. Only check when installed but not currently-    # ready (avoid noise when Fusion is happily drivable).-    updating = False-    if installed and not ready:-        try:-            updating = fusion_update_in_progress()-        except Exception:-            updating = False--    # When Fusion is up + signed in, also check the add-in isn't STALE (issue #55) so-    # a fresh readiness call surfaces the mismatch instead of a later verb failing-    # silently. Reuse the add-in status already fetched above (no second probe).-    stale_info = {}-    if addin_ok:-        stale_info = _addin_staleness(addin_status.get("version"))--    if updating and not ready:-        hint = ("Fusion is applying an AUTO-UPDATE (multiple webdeploy builds present) and "-                "RESTARTS ITSELF every ~30-60s - this is NOT a crash or a bridge failure. Poll "-                "fusion_readiness; it stabilizes to ready:true once the update finishes (can take "-                "10-30 min). Meanwhile, wrap modeling/export verbs in a short retry/catch-loop to "-                "ride the up-windows rather than treating an intermittent 'not running' as fatal.")-    elif needs_signin:-        hint = ("Fusion is RUNNING but stuck at the first-run Autodesk SIGN-IN (main UI blocked, add-in "-                "cannot serve) - NOT ready. FULL PLAYBOOK: the `fusion-autodesk-signin` skill. Proven "-                "sequence + the pitfalls that cost hours on a fresh Hyper-V VM (John 2026-07-14): "-                "(1) BLANK white webview on first launch? fusion_stop then fusion_start - the sign-in "-                "renders 'Welcome to Fusion' + a 'Sign In' button only after a clean relaunch. "-                "(2) ASK the user FIRST (AskUserQuestion): do they have a warm Google/Apple/Microsoft "-                "session for the account LINKED to their Autodesk login? Offer to drive it. A truly "-                "fresh box has NO warm browser session, so 'Continue with Google' still hits a password "-                "wall. (3) Click Fusion 'Sign In' -> it opens the DEFAULT browser (Edge) to the OAuth. "-                "Edge windows DO exist even if PowerShell EnumWindows looks empty - that is CLIXML "-                "progress noise; set $ProgressPreference='SilentlyContinue', and just "-                "desktop_screenshot_window the Edge hwnd (PrintWindow works backgrounded). "-                "(4) In the browser: 'Continue with Google' -> type the user's EMAIL (not secret) -> if "-                "it asks for a PASSWORD/2FA, STOP and fusion_notify_owner {title, body} to toast their "-                "MAIN computer so THEY type it; NEVER enter password/2FA yourself. (5) Handoff back to "-                "Fusion is via the 'Autodesk Identity Manager' protocol - Edge shows a 'This site is "-                "trying to open...' overlay: tick 'Always allow' + click 'Open'. (6) SPEED MATTERS: the "-                "sign-in code EXPIRES in ~2 min; on a high-latency VM slow screenshot/click round-trips "-                "let it expire ('Sign-in request expired') and you loop. Minimize steps; if it expires, "-                "click Fusion 'Sign In' again for a FRESH code (the browser session stays warm). "-                "Then poll until ready:true. FIRST-TIME DEMO? Run fusion_demo instead - it drives this "-                "sign-in through the user's NATIVE browser (already signed into Autodesk) and then "-                "gives them a real guided tour (project -> schematic -> 2D board -> 3D board).")-    elif ready and stale_info.get("addinStale"):-        hint = stale_info["_staleHint"]-    elif ready:-        # AUTO-UPDATE, checked on EVERY launch and applied SILENTLY (John, 2026-07-22: "i just-        # always want the latest fusion... make this generally invisible to me"). Fusion nags-        # constantly; we click it in the BACKGROUND so the user never sees it. Sticky opt-out.-        _upd = _apply_fusion_update_silently()-        # APS is checked on EVERY launch now (John, 2026-07-22: "why aren't those just driven from-        # 1 login?"). Fusion sign-in and APS are both Autodesk logins; the user should authenticate-        # ONCE. They cannot share a token (Autodesk DPAPI-encrypts Fusion's store), but they CAN-        # share the warm browser SSO session - so APS consent is silent right after a Fusion login.-        _aps = _aps_quick_state()-        # MCP state, reported beside APS on every launch (both are search/drive surfaces the-        # AI should know about without probing). Cheap: a 0.4s loopback socket.-        _mcpmsg = (" Autodesk's MCP server is LIVE (fusion_mcp_* verbs work)."-                   if _probe_mcp_port() else-                   " Autodesk's MCP server is OFF - fusion_mcp_enable can turn it on for the "-                   "user (~10s, drives the Preferences dialog).")-        _updmsg = _mcpmsg-        if _upd.get("applied"):-            _updmsg += (" AUTO-UPDATE: Fusion was offering an update and I clicked %r for the user "-                       "IN THE BACKGROUND - it downloads now and the new build comes up on the next "-                       "launch. TELL THE USER it is handled; they never need to click the nag. "-                       "They can opt out with fusion_set_auto_update {enabled:false} (sticky)."-                       % _upd.get("button"))-        elif not _auto_update_enabled():-            _updmsg += (" AUTO-UPDATE is OFF (the user opted out, sticky) - do NOT click Fusion's "-                       "update prompts for them.")-        if _aps.get("signedIn"):-            hint = ("READY - drive Fusion (fusion_open_lbr / build_library_3d / etc.). APS cloud "-                    "search is signed in too, so use fusion_aps_search for ~2s cloud lookups."-                    + _updmsg)-        elif _aps.get("configured"):-            hint = ("READY to drive Fusion, but APS CLOUD SEARCH IS NOT SIGNED IN - cloud file "-                    "search will be slow/blocked. DO IT NOW, do not make the user log in a second "-                    "time: call fusion_aps_signin RIGHT AFTER a Fusion sign-in, while the browser "-                    "profile still has a warm Autodesk SSO session - it consents silently (no "-                    "password, no 2FA). Then fusion_aps_search works." + _updmsg)-        else:-            hint = ("READY to drive Fusion. APS cloud search is NOT SET UP (no client ID) - cloud "-                    "search is the slow path without it. Offer to set it up: fusion_aps_set_client_id "-                    "then fusion_aps_signin (silent while the browser SSO session is warm). APS is "-                    "how cloud search gets to ~2 seconds." + _updmsg)-    elif installed and running and licensing:-        hint = ("Fusion is blocked by a SEAT/LICENSING dialog ('Active Sessions Exceeded' / 'Suspend "-                "Remote Session') - detected DETERMINISTICALLY as an OWNED POPUP of the main Fusion "-                "window (its title is just 'Fusion360' and it can be as short as 262px, so it is found "-                "by owner+parent-screenshot, NOT a size guess). This verb JUST TRIED to auto-resolve it "-                "in the BACKGROUND (UIA 'Continue', Suspend pre-selected so it reclaims the seat) but it "-                "is still up - it may be mid-render (the first UIA Invoke no-ops). Just POLL "-                "fusion_readiness again in a few seconds; it keeps auto-resolving. Never coordinate-click "-                "or ask the user, and never kill/relaunch (the server keeps the seat).")-    elif installed and running:-        hint = ("Fusion is RUNNING but its add-in is NOT responding yet - NOT ready to drive. Either it "-                "is still finishing launch / signing in (poll fusion_readiness a few more times), or the "-                "add-in did not load (fusion_stop then fusion_start to re-deploy + re-load it). Do NOT "-                "fire modeling/export verbs until ready:true.")-    elif installed:-        hint = "Fusion is INSTALLED but not running - call fusion_start (blocks until the add-in is ready), then retry."-    else:-        hint = ("Fusion install is STREAMING (10-30 min) - keep polling fusion_readiness until installed:true."-                if installing else-                "Fusion 360 is NOT installed. OFFER to install it for the user, then call "-                "fusion_install_fusion (no shell approval needed) - it streams the free trial and "-                "this verb reports installing:true until done.")--    result = {-        "success": True,-        "hostApp": "Fusion 360",-        "installed": installed,-        "installing": installing,-        "running": running,-        "ready": ready,-        "bridgeVersion": BRIDGE_VERSION,-        **stale_info,-        "_hint": hint,-    }-    if updating:-        result["updating"] = True-        result["statusVerb"] = "fusion_readiness"-    if needs_signin:-        result["needsSignin"] = True-        result["statusVerb"] = "fusion_readiness"-    if licensing:-        result["licensingDialog"] = True-        result["statusVerb"] = "fusion_readiness"-    if seat_resolved:-        # We acted on it this call (whether or not it fully cleared yet).-        result["seatDialogAutoResolved"] = True-    return result---# ── Fusion preferences (theme / navigation / units) ──────────────────────────-# Set via the adsk preferences API inside the live session (run_modeling_script),-# so this is a SERVER-only orchestrator - no add-in redeploy. Friendly keys map to-# adsk enums; unknown/failed keys are reported per-key (some themes, e.g. Dark Gray-# / Classic, aren't shipped in every Fusion build - proven live 2026-07-05, only-# LightGray/DarkBlue/Device were available). Theme changes apply LIVE (no restart).-_PREF_SCRIPT = r'''-import json-gp = app.preferences.generalPreferences-T  = adsk.core.UserInterfaceThemes-PZ = adsk.core.PanZoomOrbitShortcuts-MO = adsk.core.DefaultModelingOrientations-DU = adsk.fusion.DistanceUnits--THEME = {'light':[T.LightGrayUserInterfaceTheme],'lightgray':[T.LightGrayUserInterfaceTheme],-         'dark':[T.DarkGrayUserInterfaceTheme,T.DarkBlueUserInterfaceTheme],-         'darkgray':[T.DarkGrayUserInterfaceTheme],'darkblue':[T.DarkBlueUserInterfaceTheme],-         'classic':[T.ClassicUserInterfaceTheme],'device':[T.DeviceUserInterfaceTheme],-         'auto':[T.DeviceUserInterfaceTheme]}-THEME_NAME  = {0:'classic',1:'lightgray',2:'darkblue',3:'darkgray',4:'device'}-ORBIT = {'fusion360':PZ.Fusion360PanZoomOrbitShortcut,'fusion':PZ.Fusion360PanZoomOrbitShortcut,-         'alias':PZ.AliasPanZoomOrbitShortcut,'inventor':PZ.InventorPanZoomOrbitShortcut,-         'solidworks':PZ.SolidWorksPanZoomOrbitShortcut,'tinkercad':PZ.TinkercadPanZoomOrbitShortcut,-         'powermill':PZ.PowerMillPanZoomOrbitShortcut}-ORBIT_NAME  = {0:'fusion360',1:'alias',2:'inventor',3:'solidworks',4:'tinkercad',5:'powermill'}-ORIENT = {'yup':MO.YUpModelingOrientation,'zup':MO.ZUpModelingOrientation}-ORIENT_NAME = {0:'yup',1:'zup'}-UNITS = {'mm':DU.MillimeterDistanceUnits,'cm':DU.CentimeterDistanceUnits,'m':DU.MeterDistanceUnits,-         'in':DU.InchDistanceUnits,'inch':DU.InchDistanceUnits,'ft':DU.FootDistanceUnits}-UNITS_NAME  = {0:'mm',1:'cm',2:'m',3:'in',4:'ft'}--def read_current():-    cur = {-      'theme': THEME_NAME.get(gp.userInterfaceTheme, gp.userInterfaceTheme),-      'activeTheme': THEME_NAME.get(gp.activeUserInterfaceTheme, gp.activeUserInterfaceTheme),-      'invertScrollZoom': bool(gp.isZoomDirectionReversed),-      'orbitScheme': ORBIT_NAME.get(gp.panZoomOrbitShortcuts, gp.panZoomOrbitShortcuts),-      'modelingOrientation': ORIENT_NAME.get(gp.defaultModelingOrientation, gp.defaultModelingOrientation),-      'gestureNav': bool(gp.isGestureBasedViewNavigationUsed),-      'cameraPivot': bool(gp.isCameraPivotEnabled),-    }-    try:-        cur['lengthUnit'] = UNITS_NAME.get(design.fusionUnitsManager.distanceDisplayUnits,-                                           design.fusionUnitsManager.distanceDisplayUnits)-    except Exception:-        pass-    return cur--def try_set(obj, attr, cands, want):-    err = None-    for c in cands:-        if c is None: continue-        try:-            setattr(obj, attr, c)-            return {'requested': want, 'ok': True}-        except Exception as e:-            err = str(e)-    return {'requested': want, 'ok': False, 'error': err or 'no valid value'}--reqs = json.loads(__REQS__)-applied = {}-for k, v in reqs.items():-    kv = v if isinstance(v, bool) else str(v).strip().lower()-    if k == 'theme':-        c = THEME.get(kv);  applied[k] = try_set(gp,'userInterfaceTheme', c or [], v) if c else {'requested':v,'ok':False,'error':'unknown theme (use light/darkblue/darkgray/classic/device/dark)'}-    elif k in ('invertScrollZoom','reverseZoom'):-        applied['invertScrollZoom'] = try_set(gp,'isZoomDirectionReversed',[bool(v)],v)-    elif k == 'orbitScheme':-        c = ORBIT.get(kv);  applied[k] = try_set(gp,'panZoomOrbitShortcuts',[c],v) if c is not None else {'requested':v,'ok':False,'error':'unknown orbitScheme'}-    elif k == 'modelingOrientation':-        c = ORIENT.get(kv); applied[k] = try_set(gp,'defaultModelingOrientation',[c],v) if c is not None else {'requested':v,'ok':False,'error':'unknown modelingOrientation (yup/zup)'}-    elif k == 'gestureNav':-        applied[k] = try_set(gp,'isGestureBasedViewNavigationUsed',[bool(v)],v)-    elif k == 'cameraPivot':-        applied[k] = try_set(gp,'isCameraPivotEnabled',[bool(v)],v)-    elif k == 'lengthUnit':-        c = UNITS.get(kv)-        if c is None: applied[k] = {'requested':v,'ok':False,'error':'unknown unit (mm/cm/m/in/ft)'}-        else:-            try: applied[k] = try_set(design.fusionUnitsManager,'distanceDisplayUnits',[c],v)-            except Exception as e: applied[k] = {'requested':v,'ok':False,'error':str(e)}-    else:-        applied[k] = {'requested': v, 'ok': False, 'error': 'unknown preference key'}--result = {'applied': applied, 'current': read_current()}-print(json.dumps(result))-'''---def _shape_pref_result(res: dict, wrote: bool) -> dict:-    if not res or not res.get("success"):-        return {"success": False,-                "error": (res or {}).get("error", "preferences script did not run"),-                "_hint": "fusion_set_preference/get_preferences need Fusion running + signed in "-                         "(fusion_readiness -> ready:true). If needsSignin, drive the sign-in first."}-    data = (res.get("data") or {}).get("result") or {}-    applied = data.get("applied", {}) or {}-    current = data.get("current", {}) or {}-    out = {"success": True, "applied": applied, "current": current}-    if wrote:-        oks = [k for k, v in applied.items() if v.get("ok")]-        fails = {k: v.get("error") for k, v in applied.items() if not v.get("ok")}-        if fails:-            out["partial"] = True-            out["_hint"] = ("Applied live: %s. FAILED: %s. Note: some themes (Dark Gray/Classic) "-                            "aren't shipped in every Fusion build - use 'darkblue' or 'device'. See "-                            "`current` for the resulting state." %-                            (", ".join(oks) or "(none)",-                             "; ".join("%s=%s" % (k, e) for k, e in fails.items())))-        else:-            out["_hint"] = ("Applied LIVE (no restart needed): %s. See `current` for resulting state."-                            % ", ".join(oks))-    else:-        out["_hint"] = ("Current Fusion preferences. Change any with fusion_set_preference, keys: "-                        "theme (light/darkblue/darkgray/classic/device/dark), invertScrollZoom (bool), "-                        "orbitScheme (fusion360/alias/inventor/solidworks/tinkercad/powermill), "-                        "modelingOrientation (yup/zup), gestureNav (bool), cameraPivot (bool), "-                        "lengthUnit (mm/cm/m/in/ft - applies to the active design).")-    return out---def _run_pref_script(script: str, timeout: int) -> dict:-    """Proxy a preferences script to the add-in, retrying ONCE on a transient add-in-    connection error. The first pref call can hit the add-in mid doc-transition (e.g.-    right after opening a Library, when run_modeling_script must create a Design) and-    come back 'not running'/'not responding' even though Fusion is up - seen live on-    the fresh-VM battery, where a ~1.5s retry cleared it. A genuinely-down Fusion just-    costs one extra 1.5s try."""-    res = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=timeout)-    if res and res.get("success"):-        return res-    err = ((res or {}).get("error") or "").lower()-    if any(s in err for s in ("not running", "not responding", "add-in",-                              "connection", "timed out", "refused")):-        _time.sleep(1.5)-        res2 = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=timeout)-        if res2 is not None:-            return res2-    return res---def _handle_set_preference(fusion_info: dict, args: dict) -> dict:-    reqs = {k: v for k, v in (args or {}).items() if k not in ("settle",)}-    if not reqs:-        return {"success": False, "error": "No preferences given.",-                "_hint": "Pass e.g. {\"theme\":\"dark\"} or {\"invertScrollZoom\":true,"-                         "\"orbitScheme\":\"solidworks\"}. Call fusion_get_preferences to see current values + keys."}-    script = _PREF_SCRIPT.replace("__REQS__", json.dumps(json.dumps(reqs)))-    res = _run_pref_script(script, timeout=30)-    return _shape_pref_result(res, wrote=True)---def _handle_get_preferences(fusion_info: dict, args: dict) -> dict:-    script = _PREF_SCRIPT.replace("__REQS__", json.dumps(json.dumps({})))-    res = _run_pref_script(script, timeout=20)-    return _shape_pref_result(res, wrote=False)---# ── APS state + AI hint builder ───────────────────────────────────────────────-# Every cloud-file path routes through APS now (the old in-app search is disabled —-# see _handle_deprecated_cloud_search). This helper reads live APS state and returns-# a rich, self-teaching hint bundle so the AI ALWAYS knows: is APS installed/configured?-# signed in? token live/expired? what the verbs are, that it must sign in, and that the-# best sign-in path is the user's NATIVE Chrome/Edge via ABE (adom-browser-extension).-_APS_VERB_MAP = (-    "fusion_aps_status (state) · fusion_aps_signin (sign in) · fusion_aps_search {query} · "-    "fusion_aps_open {name} · fusion_aps_browse · fusion_aps_recent · fusion_aps_file_info · "-    "fusion_aps_versions · fusion_aps_download · fusion_aps_upload · fusion_aps_create_folder · "-    "fusion_aps_set_browser/get_browser/forget_browser (remember the native browser+profile)"-)-_APS_WHY = (-    "WHY APS: Autodesk offers NO fast file-search API for Fusion. The only in-app option "-    "(walk/search the folder tree from the add-in) takes 30+ min and CAN CRASH Fusion "-    "(WinError 10054). We searched far and wide; APS (Autodesk Platform Services, server-indexed) "-    "is the ONLY thing that works — seconds, no Fusion needed, no crash. It needs a one-time "-    "OAuth sign-in; once set up it is fast and amazing. The old crashing search is now DISABLED."-)-_APS_ABE = (-    "BEST sign-in path: drive the user's OWN Chrome/Edge (already logged into Autodesk) via ABE "-    "(adom-browser-extension). If ABE is not installed, tell the user to install it first "-    "(wiki.adom.inc/adom/adom-browser-extension), then walk them through it. With ABE the bridge "-    "opens the Autodesk auth URL in their real signed-in profile and captures the token — no "-    "password typing. Fallback: fusion_aps_signin {allowDefaultBrowser:true}."-)---# ── SEARCH ROUTER: one live-state answer to "which file-search should the AI use?" ───────────-# John, 2026-07-24: "you now have a lot of ways to search for files, so even the ai will get-# confused by which verb to use... are your hints smart, i.e. they know if aps is configured or-# not so the hint is dynamic and does a bunch of lookup work... and give hints where you can-# tell it ideas on things you could enable and the complexity of enabling it?"-#-# There are three ways to find a cloud file, and the right one DEPENDS ON LIVE STATE:-#   1. fusion_aps_search        - APS Data Management. Needs APS signed in. Works with Fusion-#                                 CLOSED, headless, no subscription. ~2s.-#   2. fusion_mcp_call read/document/search - Autodesk's MCP server. Needs Fusion RUNNING +-#                                 subscription + the MCP toggle on. ~2s.-#   3. fusion_search_cloud_files - DISABLED (30+ min walk, crashed Fusion). Never.-#-# _search_router() probes all of it live (cheap: APS token file read + a 0.4s loopback socket)-# and every relevant hint renders the SAME ranked answer, including what could be ENABLED and-# what enabling costs (APS signin = silent on a warm SSO session, else a browser consent;-# MCP = fusion_mcp_enable drives the Preferences dialog, ~10s, brief announced foreground).-def _probe_mcp_port(timeout: float = 0.4) -> bool:-    """Is Autodesk's MCP server listening on loopback right now? The bridge runs ON the box, so-    this is a direct, cheap socket probe - no relay round-trip."""-    import socket-    try:-        s = socket.socket()-        s.settimeout(timeout)-        s.connect(("127.0.0.1", 27182))-        s.close()-        return True-    except Exception:-        return False---def _search_router() -> dict:-    """Live state of every file-search path + a ranked recommendation. Never raises."""-    aps_st = _aps_quick_state()                      # {configured, signedIn} - token-aware-    mcp_on = _probe_mcp_port()-    fusion_up = mcp_on  # the MCP server lives inside Fusion; port open implies running-    if not mcp_on:-        try:-            fusion_up = _is_fusion_running()-        except Exception:-            fusion_up = False--    aps_ready = bool(aps_st.get("signedIn"))-    options = []-    if aps_ready:-        options.append(("fusion_aps_search", "ready NOW (~2s, works even with Fusion closed)"))-    if mcp_on:-        options.append(("fusion_mcp_call {tool:'fusion_mcp_read', arguments:{queryType:'document',"-                        "operation:'search', name:'...'}}", "ready NOW (~2s, MCP server is live)"))--    enable = []-    if not aps_ready:-        if aps_st.get("configured"):-            enable.append("APS: one fusion_aps_signin - SILENT if run right after a Fusion "-                          "sign-in (warm browser SSO), else a one-click browser consent")-        else:-            enable.append("APS: needs one-time setup - fusion_aps_set_client_id (once per "-                          "company) then fusion_aps_signin (once per user)")-    if not mcp_on:-        if fusion_up:-            enable.append("MCP: fusion_mcp_enable turns it on FOR the user (drives the "-                          "Preferences dialog, ~10s, brief announced foreground); needs a "-                          "Fusion subscription")-        else:-            enable.append("MCP: needs Fusion running first (fusion_start), then "-                          "fusion_mcp_enable; needs a Fusion subscription")--    if options:-        best = options[0][0].split(" ")[0].split("{")[0]-        rec = "BEST SEARCH RIGHT NOW: " + "; also ".join("%s - %s" % o for o in options) + "."-    else:-        best = None-        # nothing ready -> show ALL enable paths + costs, not just the quickest, so the AI can-        # pick (e.g. it must know MCP needs fusion_start when Fusion is down).-        rec = ("NO file search is ready right now. To enable one: "-               + " | ".join(enable) + "." if enable else-               "NO file search is ready and none can be enabled in the current state.")-    if enable and options:-        rec += " Could also enable: " + " | ".join(enable) + "."--    return {"apsReady": aps_ready, "apsConfigured": bool(aps_st.get("configured")),-            "mcpLive": mcp_on, "fusionRunning": fusion_up,-            "bestSearch": best, "searchHint": rec}---def _aps_state_hint(extra: str = "") -> dict:-    """Live APS readiness + a self-teaching hint bundle for the AI. Never raises."""-    try:-        st = aps.handle_status({})-        d = st.get("data", {}) if isinstance(st, dict) else {}-    except Exception as e:  # pragma: no cover - defensive-        d = {"error": str(e)}-    configured = bool(d.get("configured"))-    signed_in = bool(d.get("signedIn"))-    live = bool(d.get("tokenLive"))-    if not configured:-        stage, todo = "not_configured", ("Register a PKCE app at https://aps.autodesk.com (Data "-                                         "Management API on), then fusion_aps_set_client_id + fusion_aps_signin.")-    elif not signed_in:-        stage, todo = "not_signed_in", "Sign in: fusion_aps_signin (prefer ABE / native browser)."-    elif not live:-        stage, todo = "token_expired", "Token expired/refresh failed — fusion_aps_signin again."-    else:-        stage, todo = "ready", "Ready — fusion_aps_search {\"query\":\"...\"} or fusion_aps_open {\"name\":\"...\"}."-    # SMART ROUTING (John 2026-07-24): an APS problem must not dead-end the AI when another-    # search path is live right now. The router probes MCP + Fusion state and says what is-    # usable NOW vs what could be enabled and at what cost.-    router = _search_router()-    parts = [f"APS {stage}.", todo, router["searchHint"], "VERBS: " + _APS_VERB_MAP, _APS_ABE, _APS_WHY]-    if extra:-        parts.insert(0, extra)-    return {-        "apsStage": stage, "apsConfigured": configured, "apsSignedIn": signed_in,-        "apsTokenLive": live, "searchRouter": router, "_hint": "  ".join(parts),-    }---def _aps_guarded(fn, args: dict):-    """Run an APS cloud handler, but FIRST ensure APS is signed-in + token-live. If not,-    short-circuit with the rich self-teaching hint bundle (state + verbs + sign-in + ABE)-    so the AI knows exactly what to do instead of getting an opaque auth failure. This is-    the 'check every time, in code' guard the user asked for."""-    state = _aps_state_hint()-    if state["apsStage"] != "ready":-        return {-            "success": False,-            "error": f"APS is {state['apsStage']} — sign in before cloud file operations.",-            "output": state["apsStage"],-            "data": {"apsNotReady": True, **state},-            "_hint": state["_hint"],-        }-    return fn(args)---def _handle_deprecated_cloud_search(command: str, args: dict) -> dict:-    """HARD-BLOCK the old in-app cloud search/walk (crashes Fusion). Redirect to APS,-    surfacing live APS state so the AI can immediately continue via the good path."""-    aps_state = _aps_state_hint()-    return {-        "success": False,-        "error": (f"fusion_{command} is DISABLED: the in-app Fusion cloud "-                  "search/walk takes 30+ min and CAN CRASH Fusion (WinError 10054). "-                  "Use fusion_aps_search {\"query\":\"...\"} (or fusion_aps_open to open by name) instead."),-        "output": "deprecated_cloud_search_disabled",-        "data": {"disabled": True, "use": "fusion_aps_search", **aps_state},-        "_hint": aps_state["_hint"],-    }---COMMAND_HANDLERS = {-    "describe": lambda fi, args: describe.handle_describe(args),-    "readiness": _handle_fusion_readiness,-    "set_preference": _handle_set_preference,-    "get_preferences": _handle_get_preferences,-    "install_fusion": _handle_install_fusion,-    "open_design": handle_open_design,-    "close": handle_close_fusion,  # deprecated alias - use stop (graceful) / kill (force)-    "stop": handle_fusion_stop,    # graceful: close docs + WM_CLOSE, NO force-kill-    "kill": handle_fusion_kill,    # force: taskkill /F (the desperate path)-    "launch": _handle_launch,-    "start": _handle_launch,  # alias — CLI's fusion_start delegates here on Docker-    "dismiss_recovery": _handle_dismiss_recovery,-    "relocate_recovery": _handle_relocate_recovery,-    "open_cloud_file": _handle_open_cloud_file,-    # Import a legacy EAGLE .sch (+ .brd) into a NEW populated Fusion electronics design via-    # Fusion's own ImportSCHAndBRDCmd (drives both Open dialogs in the background). This is the-    # ONLY way to author a board from EAGLE source - newDesignFromLocal opens the editor but does-    # NOT instantiate parts, and the .fsch/.fbrd binary container can't be built offline.-    "new_electronics_from_eagle": lambda fi, args: _handle_new_electronics_from_eagle(args),-    "screenshot_fusion": _handle_screenshot_fusion,-    "click_fusion": _handle_click_fusion,-    "send_key": _handle_send_key,-    "close_window": _handle_close_window,-    "window_info": _handle_window_info,-    "screenshot_all": _handle_screenshot_all_fusion,-    # On-demand dialog/owned-popup array: enumerate + screenshot every modal so the AI-    # can ANALYZE before acting. Use while polling a long op (e.g. a Hub upload) or any-    # time you suspect a dialog is up. Mutating verbs attach this automatically; this is-    # the manual entry point. See the fusion-driving skill.-    "check_dialogs": lambda fi, args: (-        _capture_dialog_array(settle=float(args.get("settle", 0.3)))-        or {"success": True, "dialogsDetected": 0, "dialogs": [],-            "_hint": "No Fusion dialogs/owned popups are currently up."}-    ),-    "addin_status": lambda fi, args: _handle_addin_status(),-    # LAST-RESORT human escalation (John 2026-07-07): when the bridge is truly blocked-    # on the user (password/2FA, UAC), send an AD toast that reaches their MAIN machine-    # (reach_user=True fans out to peer ADs, so a bridge running on an unattended VM-    # still lands the toast where the user actually is). ALWAYS exhaust programmatic-    # options first - this exists so the AI has ONE deterministic call when it must ask.-    "notify_owner": lambda fi, args: _handle_notify_owner(args),-    # APS cloud search (pure HTTPS, works with Fusion closed). Lives in-    # COMMAND_HANDLERS so it returns BEFORE any Fusion-running gate.-    "aps_status": lambda fi, args: aps.handle_status(args),-    "aps_set_client_id": lambda fi, args: aps.handle_set_client_id(args),-    "aps_signin": lambda fi, args: aps.handle_signin(args),-    "aps_set_browser": lambda fi, args: aps.handle_set_browser(args),-    "aps_get_browser": lambda fi, args: aps.handle_get_browser(args),-    "aps_forget_browser": lambda fi, args: aps.handle_forget_browser(args),-    # Cloud-data verbs go through _aps_guarded: it checks signed-in + token-live EVERY call-    # and returns the rich APS hint bundle (state/verbs/sign-in/ABE) if not ready.-    "aps_search": lambda fi, args: _aps_guarded(aps.handle_search, args),-    "aps_browse": lambda fi, args: _aps_guarded(aps.handle_browse, args),-    "aps_recent": lambda fi, args: _aps_guarded(aps.handle_recent, args),-    "aps_file_info": lambda fi, args: _aps_guarded(aps.handle_file_info, args),-    "aps_versions": lambda fi, args: _aps_guarded(aps.handle_versions, args),-    "aps_download": lambda fi, args: _aps_guarded(aps.handle_download, args),-    "aps_create_folder": lambda fi, args: _aps_guarded(aps.handle_create_folder, args),-    "aps_upload": lambda fi, args: _aps_guarded(aps.handle_upload, args),-    "aps_open": lambda fi, args: _aps_guarded(lambda a: _handle_aps_open(fi, a), args),-    "aps_get": lambda fi, args: aps.handle_get(args),-    # DEPRECATED cloud search — HARD-BLOCKED here (in COMMAND_HANDLERS) so they return-    # BEFORE ever reaching the crashing add-in path. Redirect to APS + surface APS state.-    "search_cloud_files": lambda fi, args: _handle_deprecated_cloud_search("search_cloud_files", args),-    "walk_cloud_tree": lambda fi, args: _handle_deprecated_cloud_search("walk_cloud_tree", args),-}---# Surfaced inline so the AI doesn't draw the WRONG conclusion (it has, repeatedly):-# a "Read Only" / expired / trial / personal-use Fusion CAN open + view + browse files-# (Basic Access ~365 days) — it only blocks save/export/modify. NEVER blame a failed/slow-# OPEN on the subscription; the cause is the open path (URN resolution, the Electronics-# design picker, a slow assembly download, a stuck main thread).-_READONLY_OPEN_NOTE = ("NOTE: a 'Read Only'/expired/trial Fusion still opens+views files — if "-                       "it never opens, debug the open path (URN/picker/slow assembly), NOT the "-                       "license. Only save/export are blocked in read-only.")---def _handle_aps_open(fusion_info: dict, args: dict) -> dict:-    """Search the cloud (APS) for a file, then OPEN the best match in Fusion.--    APS finds it instantly by name across the whole team hub; the add-in's-    open_cloud_file (projectName + fileName + fileExtension) opens it.-    """-    query = (args.get("query") or args.get("fileName") or "").strip()-    if not query:-        return {"success": False, "error": "Missing query/fileName."}-    match = aps.find_one(query)-    if not match:-        return {"success": False, "error": f"No cloud file matching '{query}'.",-                "_hint": "Run fusion_aps_search to see candidates, or refine the query. "-                         + _READONLY_OPEN_NOTE}-    # Fusion cloud displayNames are NOT filenames — do NOT splitext (it mangled-    # "...(1.6mm gasket)" into a bogus extension). Pass the full name.-    name = match.get("name") or ""-    open_args = {"projectName": match.get("projectName"), "fileName": name}-    if not fusion_info.get("installed") or not _is_fusion_running():-        return {"success": True, "output": f"Found '{name}' in project {match.get('projectName')}.",-                "data": {"match": match, "openArgs": open_args},-                "_hint": "Fusion isn't running — call fusion_start, then fusion_aps_open again to open it."}-    # Open by the EXACT file URN (works for any nesting). The FIRST cloud-open can-    # take 60-90s (download) — longer than AD's relay timeout — so FIRE it in the-    # background and return immediately. Caller polls fusion_get_app_state. Unless-    # {wait:true} is passed (then block and return the open result).-    urn = match.get("id")--    def _do_open():-        r = _proxy_to_addin("open_by_urn", {"urn": urn}, timeout=180)-        if not r.get("success"):-            _proxy_to_addin("open_cloud_file", open_args, timeout=120)  # by-name fallback-        return r--    if args.get("wait"):-        result = _do_open()-        result.setdefault("data", {})-        if isinstance(result.get("data"), dict):-            result["data"]["match"] = match-        return _post_open_screenshot(result) if result.get("success") else result--    import threading as _threading-    _threading.Thread(target=_do_open, daemon=True).start()-    return {-        "success": True,-        "output": f"Found '{name}' in project {match.get('projectName')} — opening in Fusion.",-        "data": {"match": match, "opening": True},-        "statusVerb": "fusion_get_app_state",  # AD 1.9.9 convention — poll this for completion-        "_hint": "The cloud file is opening in Fusion in the background (first open can take "-                 "~60-90s; large ASSEMBLIES download all referenced parts and take longer). "-                 "Poll fusion_get_app_state until activeDocument is the file. Pass "-                 "{\"wait\": true} to block instead. " + _READONLY_OPEN_NOTE,-    }--# Commands that are proxied to the Fusion add-in (port 8774).-# Keys are the CLI-facing names (after stripping "fusion_" prefix).-ADDIN_COMMANDS = {-    # Mechanical BOM + physical properties (PR #22, Oliver / BOM Forge). These live in the-    # ADD-IN, so they must be listed here or the bridge never proxies them and the verb-    # 404s no matter how well the handler works.-    "assembly_bom", "physical_properties",-    "get_app_state",-    "document_info",-    "activate_document",-    "import_step",  # add-in calls this "import_file" — mapped below-    "export_step", "export_stl", "export_3mf", "export_f3d", "export_fbx", "export_usdz",-    "export_dxf", "export_dwg", "export_iges", "export_obj", "export_sat", "export_skp",-    "get_design_info",-    "get_parameters", "set_parameter",-    "take_screenshot",-    # Electronics commands (EAGLE via Electron.run)-    "electron_run", "execute_text_command",-    "electron_zoom", "electron_pan", "electron_select",  # video-friendly view control-    "open_electronics", "list_text_commands",-    # Electronics source export (.fsch, .fbrd, .flbr via Document.CopyToDesktop)-    "export_source",-    # EAGLE-format export (.sch, .brd — extracted from .fsch/.fbrd ZIP container)-    "export_eagle_source",-    # Library file commands (EXPORT SCRIPT — open_lbr is orchestrated at bridge level)-    "export_lbr",-    # Document management-    "close_document",-    "close_all_documents",-    # Electronics file opening (open_schematic, open_board, show_3d_board, show_2d_board-    # are orchestrated at bridge level for auto-screenshot — not in this set)-    # Board data query-    "board_info",-    # Open any cloud file directly by its APS/Fusion URN (any folder depth).-    # fusion_aps_open also proxies this internally (fire-and-poll); registering it-    # here makes the direct fusion_open_by_urn verb work too instead of being-    # rejected as "Unknown command".-    "open_by_urn",-    # In-app parametric modeling — run an adsk.fusion script in the live session-    # (free path to programmatic CAD; the APS Fusion Automation API is the paid-    # cloud alternative).-    "run_modeling_script",-    # Cloud document management-    "save_to_cloud",-    "list_cloud_projects",-    "list_cloud_files",-    "delete_cloud_file",-    "create_cloud_folder",-    # open_cloud_file is orchestrated at bridge level (not direct proxy)-    # to detect blocking dialogs after open-    "check_recovery",-    "search_cloud_files",-    "walk_cloud_tree",-    "export_cloud_file",-    # Manufacturing exports-    "export_bom",-    "export_cpl",-    "export_gerbers",-    "set_design_rules",-    "export_board_image",-    "detect_layers",-}--# Map CLI command names → add-in command names (where they differ)-ADDIN_COMMAND_MAP = {-    "import_step": "import_file",-}--# Per-command timeout overrides for _proxy_to_addin. Heavy 3D exports on-# panelized boards (100+ placements) can take 120s+. The add-in-side-# timeout in http_server.py should be the source of truth; these are-# matched to that so urllib doesn't cut off before the add-in does.-ADDIN_COMMAND_TIMEOUTS = {-    # getPhysicalProperties runs on Fusion's MAIN thread per component, so a big assembly-    # (or assembly_bom with includePhysicalProperties) is slow. Give both real headroom.-    "assembly_bom": 300,-    "physical_properties": 300,-    "export_step": 300,-    "export_iges": 300,-    "export_sat": 300,-    "export_stl": 300,-    "export_3mf": 300,-    "export_usdz": 300,-    "export_obj": 300,-    "export_f3d": 300,-    "export_fbx": 300,-    "export_skp": 300,-    "export_dxf": 120,-    "export_dwg": 120,-    "export_gerbers": 180,-    "export_bom": 60,-    "export_cpl": 60,-    "export_board_image": 60,-    "close_all_documents": 60,-    # Cloud tree walker / search — can hit hundreds of folders on large projects.-    # Must match or exceed http_server.py PER_COMMAND_TIMEOUT so urllib doesn't-    # cut off before the add-in does.-    "walk_cloud_tree": 600,-    "search_cloud_files": 180,-    # Opening a cloud design downloads + loads it; large assemblies and-    # electronics/PCB designs (which spin up the Electronics editor) can take-    # minutes. fusion_aps_open's fire-and-poll path is the preferred way in for-    # these — but when open_by_urn is proxied synchronously, give it room.-    "open_by_urn": 240,-    # Modeling scripts can build many features; give them room without being unbounded.-    "run_modeling_script": 180,-}--# ── Busy gate: prevent command stacking during long-running add-in work ──-# When walk_cloud_tree or search_cloud_files is running, the Fusion main thread-# is blocked for 30-300+ seconds. Any add-in command sent during that time would-# pile up behind _main_thread_lock in http_server.py, eating HTTP threads and-# potentially crashing the host. The gate rejects those commands immediately at-# the bridge level with progress info, BEFORE they reach the add-in.-import threading as _threading-import time as _time--_long_command_lock = _threading.Lock()-_long_command = None  # None or {"command": str, "startedAt": float}--LONG_RUNNING_COMMANDS = {"walk_cloud_tree", "search_cloud_files"}--# Commands that change Fusion's state and can pop a modal dialog / owned popup the AI-# must see (a save confirm, the Hub "are you sure you want to close?" data-loss prompt,-# a recovery prompt, etc.). After these, the dispatcher auto-attaches the dialog array-# (_capture_dialog_array) + an analyze-this hint so the AI cannot fly blind. Read-only-# commands (get_app_state, document_info, board_info, exports) are intentionally excluded-# to avoid the per-call screenshot latency. See the fusion-driving skill.-MUTATING_COMMANDS = {-    "run_modeling_script", "execute_text_command", "electron_run",-    "close_document", "close_all_documents", "import_step",-    "set_parameter", "save_to_cloud", "delete_cloud_file",-}---def _set_long_command(command: str):-    with _long_command_lock:-        global _long_command-        _long_command = {"command": command, "startedAt": _time.time()}---def _clear_long_command():-    with _long_command_lock:-        global _long_command-        _long_command = None---def _get_long_command() -> dict | None:-    with _long_command_lock:-        if _long_command is None:-            return None-        return dict(_long_command)---def _get_busy_progress() -> dict | None:-    """Poll the add-in /status endpoint for walkProgress during a long command.--    Uses /status (not /health) because it's lighter and proven reliable under-    GIL contention during heavy walks. 3s timeout matches _check_addin_status.-    """-    status = _check_addin_status(timeout=3.0)-    if status and status.get("walkProgress"):-        return status["walkProgress"]-    return None---def _check_main_thread_blocked() -> bool:-    """Quick check: is Fusion's main thread blocked by a modal dialog?--    Sends a fast command (get_app_state) with a short timeout. If the add-in's-    HTTP server responds but the command times out, a modal dialog is blocking.-    Returns True if blocked, False if responsive.-    """-    try:-        body = json.dumps({"command": "get_app_state", "args": {}}).encode("utf-8")-        req = urllib.request.Request(-            f"http://127.0.0.1:{ADDIN_PORT}/command",-            data=body,-            headers={"Content-Type": "application/json"},-            method="POST",-        )-        with urllib.request.urlopen(req, timeout=3) as resp:-            result = json.loads(resp.read())-        # If we got a response, main thread is fine-        if result.get("success"):-            return False-        # Add-in responded but with an error — check if it's a timeout-        if "timed out" in result.get("error", "").lower():-            return True-        return False-    except Exception as e:-        # HTTP timeout = main thread blocked (HTTP server is up but command didn't complete)-        if "timed out" in str(e).lower() or "timeout" in str(e).lower():-            return True-        # Connection refused = add-in not running (different problem)-        return False---def _probe_addin(timeout: float = 0.5) -> dict | None:-    """Check if the Fusion add-in HTTP server is running.--    Uses a short timeout (default 0.5s) to avoid blocking the /health endpoint.-    The add-in runs on localhost so if it's up, it responds in <50ms.-    """-    try:-        req = urllib.request.Request(f"http://127.0.0.1:{ADDIN_PORT}/health", method="GET")-        with urllib.request.urlopen(req, timeout=timeout) as resp:-            return json.loads(resp.read())-    except Exception:-        return None---def _check_addin_status(timeout: float = 3.0) -> dict | None:-    """GET /status from the add-in. Returns dict or None.--    This is the cross-bridge busy probe — reads add-in busy state without-    acquiring the main thread lock. Safe to call from any bridge/container.-    Usually ~50ms, but during heavy walks GIL contention can push to 1-2s.-    """-    try:-        req = urllib.request.Request(f"http://127.0.0.1:{ADDIN_PORT}/status", method="GET")-        with urllib.request.urlopen(req, timeout=timeout) as resp:-            return json.loads(resp.read())-    except Exception:-        return None---def _handle_addin_status() -> dict:-    """Bridge-level handler for addin_status — wraps /status in standard format."""-    status = _check_addin_status(timeout=3.0) or {"busy": False}-    stale = _addin_staleness(status.get("version"))-    status = {**status, **stale}-    result = {"success": True, "output": json.dumps(status), **status}-    if stale.get("addinStale"):-        result["_hint"] = stale["_staleHint"]-    return result---def _proxy_to_addin(command: str, args: dict, timeout: int = 30) -> dict:-    """Proxy a command to the Fusion add-in HTTP server.--    On timeout, probes /health to distinguish:-    - Add-in alive but main thread blocked (modal dialog) → distinct error-    - Add-in HTTP server crashed → connection error-    """-    body = json.dumps({"command": command, "args": args}).encode("utf-8")-    req = urllib.request.Request(-        f"http://127.0.0.1:{ADDIN_PORT}/command",-        data=body,-        headers={"Content-Type": "application/json"},-        method="POST",-    )-    try:-        with urllib.request.urlopen(req, timeout=timeout) as resp:-            result = json.loads(resp.read())--        # Check if the add-in itself returned a timeout (main thread didn't respond)-        if (not result.get("success")-                and "timed out" in result.get("error", "").lower()):-            return _diagnose_addin_timeout(command, result)--        # If the add-in rejected the command as unknown/unsupported, it may be a-        # STALE add-in that predates this verb (issue #55 - Drew's silent failure:-        # old add-in had no open_by_urn). Enrich with a version comparison + the-        # re-sync hint + a stable errorCode so the failure is LOUD, not silent.-        if not result.get("success"):-            err = (result.get("error") or "").lower()-            if any(s in err for s in ("unknown command", "no such command", "not supported", "unsupported command")):-                stale = _addin_staleness((_check_addin_status(timeout=1.0) or {}).get("version"))-                if stale.get("addinStale"):-                    result["errorCode"] = "addin_stale"-                    result["_hint"] = stale["_staleHint"]-                    result.update({k: stale[k] for k in ("addinVersion", "expectedAddinVersion", "addinStale")})--        return result--    except urllib.error.URLError as e:-        if "Connection refused" in str(e) or "No connection" in str(e):-            return {-                "success": False,-                "error": "Fusion 360 AdomBridge add-in not running. "-                         "Install it with: python plugins/fusion360/install_addin.py, "-                         "then restart Fusion 360.",-            }-        # Could be a socket timeout — the HTTP request itself took too long-        if "timed out" in str(e).lower() or "timeout" in str(e).lower():-            return _diagnose_addin_timeout(command, {"error": str(e)})-        return {"success": False, "error": f"Add-in request failed: {e}"}-    except Exception as e:-        if "timed out" in str(e).lower() or "timeout" in str(e).lower():-            return _diagnose_addin_timeout(command, {"error": str(e)})-        return {"success": False, "error": f"Add-in request failed: {e}"}---def _diagnose_addin_timeout(command: str, original_result: dict) -> dict:-    """After a command timeout, probe /health to determine the cause.--    Returns a distinct error if the add-in is alive but its main thread-    is blocked (e.g. by a modal dialog in Fusion).  Always captures-    auto-screenshots of Fusion windows on timeout so the AI can see-    what dialog is blocking.-    """-    # Auto-screenshot on timeout — the most common cause is a blocking dialog-    # that's invisible to the API.  Capture Fusion windows so the AI can-    # identify and dismiss the dialog.-    timeout_screenshots = {}-    try:-        timeout_screenshots = _post_open_screenshot(command)-    except Exception:-        pass  # Best effort — don't let screenshot failure mask the real error--    # Identify WHICH modal is blocking (titles enumerate over Win32 even while the-    # add-in's main thread is stuck). Turns the opaque "add-in not responding" into-    # an actionable cause + resolution — and stops the needless restart loop.-    blocking_dialogs = classify_blocking_dialogs()--    health = _probe_addin(timeout=2.0)-    if health and health.get("status") == "ok":-        main_thread = health.get("main_thread", "unknown")-        pending = health.get("pending_commands", 0)-        if main_thread == "blocked" or pending > 0:-            # Before assuming a modal dialog, check /status — if the add-in-            # is busy with a known command, it's working (not dialog-blocked).-            status = _check_addin_status(timeout=3.0)-            if status and status.get("busy"):-                busy_cmd = status.get("busyCommand", "unknown")-                elapsed = status.get("elapsedSeconds", 0)-                walk = status.get("walkProgress")-                resp = {-                    "success": False,-                    "error": f"Fusion main thread busy — {busy_cmd} running for {elapsed}s.",-                    "errorCode": "main_thread_busy",-                    "busyCommand": busy_cmd,-                    "elapsedSeconds": elapsed,-                    "_hint": (-                        "Add-in is busy with a long-running command. Do NOT retry add-in commands. "-                        "Do NOT press Escape — the add-in is working, not stuck on a dialog. "-                        "Commands that still work: fusion_window_info, fusion_screenshot_fusion, "-                        "fusion_click_fusion, fusion_send_key, fusion_close_window."-                    ),-                }-                if walk:-                    resp["progress"] = walk-                return resp--            # If we recognized the blocking modal, name it and give the precise fix-            # instead of the generic "read the screenshots" guidance.-            if blocking_dialogs:-                titles = ", ".join(f"'{d['title']}' ({d['category']})" for d in blocking_dialogs)-                resolutions = " ".join(dict.fromkeys(d["resolution"] for d in blocking_dialogs))-                message = (f"The AdomBridge add-in is alive but its main thread is blocked by a "-                           f"modal dialog: {titles}. This is NOT an add-in crash — do not restart "-                           f"Fusion. {resolutions}")-            else:-                message = (f"The AdomBridge add-in is alive but its main thread is not "-                           f"responding (status: {main_thread}, pending: {pending}). "-                           f"Fusion 360 likely has a modal dialog open (Document Recovery, "-                           f"error, or update prompt) that is blocking execution. "-                           f"READ the screenshots to identify the dialog, then dismiss "-                           f"with fusion_send_key {{\"key\": \"escape\"}} or "-                           f"fusion_send_key {{\"key\": \"tab\"}} + {{\"key\": \"enter\"}}.")-            return {-                "success": False,-                "error": "addin_main_thread_blocked",-                "message": message,-                "blockingDialogs": blocking_dialogs,-                "data": {-                    "command": command,-                    "health": health,-                    "blockingDialogs": blocking_dialogs,-                    "postOpenScreenshot": timeout_screenshots,-                },-            }-        # Health says responsive but command still timed out — unusual-        return {-            "success": False,-            "error": "addin_command_timeout",-            "message": f"Command '{command}' timed out but the add-in reports main thread "-                       f"is {main_thread}. The command may be long-running or stuck.",-            "data": {-                "command": command,-                "health": health,-                "postOpenScreenshot": timeout_screenshots,-            },-        }--    # Health probe failed — add-in HTTP server is down-    return {-        "success": False,-        "error": "AdomBridge add-in not responding (it may have crashed).",-        "errorCode": "fusion_addin_not_responding",-        "_hint": "Fix it YOURSELF - never ask the user: restart Fusion via fusion_stop + "-                 "fusion_start (the bridge installs the add-in to ALL Fusion add-in dirs incl. "-                 "%APPDATA%\\Autodesk\\FusionAddins, and runOnStartup reloads it).",-    }---def _orchestrate_open_lbr(args: dict) -> dict:-    """Open an EAGLE .lbr library file in Fusion 360 Electronics.--    Uses Document.newDesignFromLocal (via the add-in's execute_text_command)-    to open the .lbr file, which auto-switches to the Electronics Library-    editor. Then optionally navigates to a symbol and verifies via export.--    This is orchestrated at bridge level to avoid add-in module caching-    issues and to allow multi-step operations with waits.-    """-    import tempfile-    import time--    file_path = args.get("filePath", "")-    symbol_name = args.get("symbolName", "")-    verify = args.get("verify", False)--    if not file_path:-        return {"success": False, "error": "No filePath specified"}--    file_path = file_path.replace("\\", "/")-    results = []--    # Step 1: Open the .lbr via Document.newDesignFromLocal.-    # ⚠️ TIMEOUT + VERIFY-BY-STATE (fixed 2026-07-07, caught live on a GPU-less Azure-    # VM): the open can take 60s+ on slow/software-rendered machines, so a fixed 30s-    # read timeout expired mid-open and the canned proxy error claimed the ADD-IN was-    # "not running" while the document was actually opening fine (fusion_build_library_3d-    # then aborted all parts on a lie). Now: a generous timeout, AND on ANY failure we-    # poll get_app_state for the expected document name - the doc actually being open-    # outranks whatever the synchronous return claimed.-    open_result = _proxy_to_addin("execute_text_command", {-        "command": f"Document.newDesignFromLocal {file_path}",-    }, timeout=120)--    if not open_result.get("success"):-        # Verify by STATE before failing: did the doc open anyway?-        expected = file_path.replace("\\", "/").rsplit("/", 1)[-1]-        expected = expected.rsplit(".", 1)[0].lower()-        opened_anyway = False-        for _ in range(20):  # up to ~60s of settling-            time.sleep(3)-            try:-                st = _proxy_to_addin("get_app_state", {}, timeout=8)-                active = str((st.get("data") or {}).get("activeDocument", "")).lower()-                if expected and expected in active:-                    opened_anyway = True-                    break-            except Exception:-                pass-        if not opened_anyway:-            return {-                "success": False,-                "error": f"Document.newDesignFromLocal failed: {open_result.get('error', 'unknown')}",-                "_hint": ("The open did not complete AND the document never became active. On slow/"-                          "software-rendered machines opens can take 60s+; this call already waited + "-                          "verified by state. Check fusion_check_dialogs for a blocking modal, then retry."),-                "data": {"filePath": file_path},-            }-        results.append("Document.newDesignFromLocal: ok (verified by app state after slow open)")-    else:-        results.append("Document.newDesignFromLocal: ok")--    # Step 2: Navigate to symbol if requested-    if symbol_name:-        time.sleep(3)  # Let Fusion finish opening and switching workspace--        sym_ref = symbol_name if symbol_name.endswith(".sym") else f"{symbol_name}.sym"-        edit_result = _proxy_to_addin("electron_run", {-            "command": f"EDIT {sym_ref}",-        }, timeout=10)-        results.append(f"EDIT {sym_ref}: success={edit_result.get('success')}")--        # Zoom to fit-        _proxy_to_addin("electron_run", {"command": "WINDOW FIT"}, timeout=5)-        results.append("WINDOW FIT: ok")--    # Step 3: Verify via EXPORT SCRIPT-    verification = None-    if verify:-        import uuid-        time.sleep(2)  # Let Fusion settle before export-        # Use a unique path to avoid "overwrite?" dialogs blocking the UI thread-        export_path = str(Path(tempfile.gettempdir()) / f"_adom_verify_{uuid.uuid4().hex[:8]}.scr")-        export_result = _proxy_to_addin("export_lbr", {"outputPath": export_path}, timeout=30)-        if export_result.get("success"):-            preview = export_result.get("data", {}).get("preview", "")-            has_symbol = False-            if symbol_name:-                has_symbol = (-                    f"'{symbol_name.upper()}.sym'" in preview-                    or f"'{symbol_name}.sym'" in preview-                )-            verification = {-                "exported": True,-                "fileSize": export_result.get("data", {}).get("fileSize", 0),-                "hasSymbol": has_symbol,-                "preview": preview[:500],-            }-        else:-            verification = {"exported": False, "error": export_result.get("error", "unknown")}-        results.append(f"Verification: exported={verification.get('exported', False)}")--    import os-    response = {-        "success": True,-        "output": f"Opened library: {os.path.basename(file_path)}",-        "data": {"results": results, "filePath": file_path},-        "_hint": (-            "Library opened in the Electronics Library editor (Content Manager). "-            "ALWAYS VERIFY VIA SCREENSHOT: a .lbr can FAIL to open ('<file>.lbr has errors and cannot be "-            "opened') while this call still returns success - that error is an OWNED POPUP. Grab "-            "desktop_screenshot_window on the Fusion main hwnd and CHECK ownedPopupCount + read the "-            "_screenshots[] array (AD v1.8.177+ captures owned dialogs invisible to a plain capture); "-            "ownedPopupCount>0 means an error/confirm dialog is up. "-            "NOTE: an adom-lbr .lbr is 2D ONLY - symbol + footprint + a PLACEHOLDER 3D package. "-            "To attach the real 3D chip, use fusion_attach_3d_package (it runs the Package3D generator + "-            "FINISH, which binds the 3D onto the deviceset). See the 'fusion-libraries' skill / LIBRARY_FINDINGS.md."-        ),-    }-    if symbol_name:-        response["data"]["symbolName"] = symbol_name-    if verification:-        response["data"]["verification"] = verification--    # Auto-screenshot after opening — catches blocking dialogs, same as other open commands-    return _post_open_screenshot(response)---def _orchestrate_attach_3d_package(args: dict) -> dict:-    """Attach a real 3D model to a library package, end to end.--    Opens the .lbr (library active), runs Electron.Create3DPackage to enter the-    Package3DEnvironment showing the footprint, imports the STEP model and-    auto-orients it flat on the footprint, then executes Package3DStop (FINISH).-    Fusion then shows a modal Save dialog (an OWNED popup) — that single click is-    the only desktop-side step; this returns the exact instruction for it.--    args: {filePath: Windows path to the .lbr, modelPath: Windows path to the-           STEP, packageName: optional str}-    """-    import time-    file_path = (args.get("filePath") or "").replace("\\", "/")-    model_path = (args.get("modelPath") or "").replace("\\", "/")-    package = args.get("packageName", "")-    orient_flag = args.get("orient", True)  # 2026-07-07: expose orient (was hard-on)-    if not file_path or not model_path:-        return {"success": False,-                "error": "filePath (.lbr) and modelPath (.step) are required (Windows paths, e.g. C:/...).",-                "_hint": "Stage both onto Windows first (no container->Windows push verb). See the fusion-libraries skill."}-    steps = []-    open_res = _orchestrate_open_lbr({"filePath": file_path})-    if not open_res.get("success"):-        return {"success": False, "error": "open_lbr failed: " + str(open_res.get("error")), "data": {"steps": steps}}-    steps.append("open_lbr: ok (library active)")-    time.sleep(1)-    cp = _proxy_to_addin("execute_text_command", {"command": f"Electron.Create3DPackage {file_path}"}, timeout=40)-    if not cp.get("success"):-        return {"success": False, "error": "Create3DPackage failed: " + str(cp.get("error")),-                "_hint": "The library document must be ACTIVE, and the .lbr must be adom-lbr-generated "-                         "(a raw vendor EAGLE .lbr fails Fusion's XML parser).", "data": {"steps": steps}}-    steps.append("Create3DPackage: ok (Package3DEnvironment)")-    time.sleep(2)-    import_script = (-        "import adsk.core, adsk.fusion, math\n"-        "res={}\n"-        "d=adsk.fusion.Design.cast(app.activeProduct); root=d.rootComponent\n"-        "im=app.importManager\n"-        f"im.importToTarget(im.createSTEPImportOptions('{model_path}'), root)\n"-        "oc=root.occurrences.item(root.occurrences.count-1); bb=oc.boundingBox\n"-        "dx=bb.maxPoint.x-bb.minPoint.x; dy=bb.maxPoint.y-bb.minPoint.y; dz=bb.maxPoint.z-bb.minPoint.z\n"-        # Tall-part guard (2026-07-07): keep a part vertical if its largest dim is already Z; only-        # flatten a clearly-thin part whose thin axis isn't Z. Was: always tip smallest dim to Z,-        # which laid tall through-hole pins on their side. Honors the orient flag (default True).-        + ("axis=None\n" if not orient_flag else-           "thin=min(dx,dy,dz); big=max(dx,dy,dz)\n"-           "tall_z=(dz>=dx and dz>=dy)\n"-           "axis=None\n"-           "if (thin < 0.5*big) and not tall_z:\n"-           "    axis=(1,0,0) if (dy<=dx and dy<=dz) else ((0,1,0) if (dx<=dy and dx<=dz) else None)\n") +-        "if axis:\n"-        "    mat=oc.transform2.copy(); rot=adsk.core.Matrix3D.create()\n"-        "    rot.setToRotation(math.pi/2, adsk.core.Vector3D.create(*axis), adsk.core.Point3D.create(0,0,0))\n"-        "    mat.transformBy(rot); oc.transform2=mat\n"-        "    d.snapshots.add() if d.snapshots.hasPendingSnapshot else None\n"-        "res['dims_mm']=[round(dx*10,2),round(dy*10,2),round(dz*10,2)]\n"-        "result=res\n"-    )-    imp = _proxy_to_addin("run_modeling_script", {"script": import_script}, timeout=120)-    if not imp.get("success"):-        return {"success": False, "error": "import/orient failed: " + str(imp.get("error")), "data": {"steps": steps}}-    dims = (imp.get("data", {}) or {}).get("result", {})-    steps.append(f"import+orient: ok ({dims.get('dims_mm') if isinstance(dims, dict) else dims})")-    time.sleep(1)-    _proxy_to_addin("run_modeling_script",-                    {"script": "import adsk.core\ncd=ui.commandDefinitions.itemById('Package3DStop')\nresult={'finished': bool(cd) and cd.execute()}\n"},-                    timeout=30)-    steps.append("FINISH (Package3DStop): executed")-    return {-        "success": True,-        "output": f"3D model placed + oriented on the footprint and FINISH executed for '{package or file_path}'. A Save dialog is now up.",-        "data": {"steps": steps, "package": package},-        "savePending": True,-        "_hint": (-            "FINAL STEP (desktop-side, one click): a Fusion 'Save' dialog is now up to save the 3D package. "-            "Click it: desktop_find_window {titleContains:'Save'} -> desktop_ui_click "-            "{hwnd, automationId:'QTApplication.QTFrameWindow.standardActions.SaveButton'}. "-            "THEN VERIFY with desktop_screenshot_window on the Fusion hwnd: check ownedPopupCount (errors are owned "-            "popups), and the deviceset's Package column should flip Placeholder->part-name + the 3D preview becomes "-            "the real chip (the preview LAGS a beat - re-grab). Full flow: fusion-libraries skill / LIBRARY_FINDINGS.md sect 11."-        ),-    }---# ── Fusion cloud FOLDER HYGIENE (never write loose files to a shared project ROOT) ───────────-# Hard lesson (2026-06-29): defaulting uploads to a project's ROOT folder dumped 100+ loose f3d-# files into the shared Adom team root and other employees complained. RULE: the bridge NEVER-# writes a file to a project root. It writes into an AI-OWNED "Adom AI Workspace" folder, with a-# per-task SUBfolder, keeping the cloud tidy. See the fusion-cloud-hygiene skill.-_DEFAULT_UPLOAD_PROJECT = "a.YnVzaW5lc3M6YWRvbTMjMjAyMzExMjk3MDM5NjAzMzE"  # the Adom business project-# The shared team ROOT folder of that project - OFF LIMITS for loose files (only the workspace-# folder itself may live here). Known roots we must refuse as a write target.-_KNOWN_ROOT_FOLDERS = {"urn:adsk.wipprod:fs.folder:co.jyO4vxQXR6S9zFpTQWnZAg"}-_AI_WORKSPACE_NAME = "Adom AI Workspace"   # the AI-owned work area (BRAND: "Adom AI", not "Claude")-_ws_folder_cache = {}---def _safe_folder_name(name: str) -> str:-    import re as _re-    return _re.sub(r'[<>:"/\\|?*]', "", str(name or "")).strip()[:60] or "task"---def _find_child_folder(project_id: str, parent_id: str, name: str):-    """folderId of a subfolder named `name` directly under parent_id, or None."""-    try:-        r = aps.handle_browse({"projectId": project_id, "folderId": parent_id})-        items = (r.get("data") or {}).get("items", []) if isinstance(r, dict) else []-        for it in items:-            if it.get("type") == "folders" and (it.get("name") or "").strip() == name:-                return it.get("id")-    except Exception:-        pass-    return None---def _ensure_workspace_folder(project_id: str, task: str = None):-    """Return a folderId inside the AI-owned 'Adom AI Workspace' (NEVER a project root). Creates the-    workspace folder (one tidy folder under the project root) + an optional per-task subfolder if-    missing. Cached per (project, task). Returns None if it cannot be resolved (caller must NOT then-    fall back to root)."""-    task = _safe_folder_name(task) if task else None-    key = (project_id, task or "")-    if key in _ws_folder_cache:-        return _ws_folder_cache[key]-    root = next(iter(_KNOWN_ROOT_FOLDERS))  # parent for the single workspace folder-    ws = _find_child_folder(project_id, root, _AI_WORKSPACE_NAME)-    if not ws:-        cr = aps.handle_create_folder({"projectId": project_id, "parentFolderId": root, "name": _AI_WORKSPACE_NAME})-        ws = (cr.get("data") or {}).get("folderId") if isinstance(cr, dict) and cr.get("success") else None-    folder = ws-    if task and ws:-        sub = _find_child_folder(project_id, ws, task)-        if not sub:-            cr = aps.handle_create_folder({"projectId": project_id, "parentFolderId": ws, "name": task})-            sub = (cr.get("data") or {}).get("folderId") if isinstance(cr, dict) and cr.get("success") else None-        folder = sub or ws-    if folder:-        _ws_folder_cache[key] = folder-    return folder---def _discover_upload_target(args: dict) -> tuple:-    """(projectId, folderId) for f3d uploads - ALWAYS a non-root, AI-owned folder.--    Defaults to 'Adom AI Workspace/<task>' (auto-created). If the caller explicitly passes a-    folderId that is a known project ROOT, it is REFUSED (we steer to the workspace instead) - the-    bridge must never write loose files to a shared root. Returns (projectId, folderId|None);-    folderId is None only if the workspace folder could not be created (caller must error, NOT-    fall back to root)."""-    project_id = args.get("projectId") or _DEFAULT_UPLOAD_PROJECT-    fid = args.get("folderId")-    if fid and fid in _KNOWN_ROOT_FOLDERS:-        fid = None  # explicit root -> refuse, steer to workspace-    if not fid:-        fid = _ensure_workspace_folder(project_id, args.get("task"))-    return (project_id, fid)---def _capture_labeled(label) -> str | None:-    """Background-capture the main Fusion window to a labeled PNG on the box-    (C:/tmp/conduit-screenshots). Returns the saved path or None. Never fullscreen-    (hwnd-targeted PrintWindow, so it captures Fusion in the background per fusion-driving)."""-    if not label:-        return None-    try:-        info = get_fusion_window_info()-        hwnd = info.get("hwnd")-        if not hwnd:-            return None-        import re as _re-        safe = _re.sub(r'[<>:"/\\|?*]', "", str(label)).replace(" ", "_")[:40]-        r = screenshot_hwnd(hwnd, label=safe)-        return r.get("savedTo") if r.get("success") else None-    except Exception:-        return None---def _inject_package3d_bindings(lbr_text: str, bindings: list) -> str:-    """Inject EAGLE <packages3d> + per-device <package3dinstances> into an .lbr - MERGE-AWARE-    and IDEMPOTENT (safe to re-run with any subset of parts).--    bindings: [{package: <pkg name>, wip_urn: <urn>}]. The function reads any bindings ALREADY in-    the .lbr, merges the new ones on top (new wins on conflict), strips all prior <packages3d> +-    <package3dinstances>, then re-emits the full merged set: a library-level <package3d> per part-    (between </packages> and <symbols>) and a <package3dinstances> inside every <device> that uses-    that package (right after </connects>). So calling it again with just the parts that failed last-    time ACCUMULATES instead of wiping the parts that already succeeded - the fix for the-    'a re-run dropped the earlier bindings' trap. Returns the new .lbr text."""-    import re as _re-    # 1. read existing bindings already in the file; new bindings override-    merged = {}-    em = _re.search(r"<packages3d>(.*?)</packages3d>", lbr_text, _re.S)-    if em:-        for pm in _re.finditer(r'<package3d name="([^"]+)"[^>]*wip_urn="([^"]+)"', em.group(1)):-            merged[pm.group(1)] = pm.group(2)-    for b in bindings:-        merged[b["package"]] = b["wip_urn"]-    # 2. strip ALL prior package3d markup so re-injection is clean (idempotent)-    lbr_text = _re.sub(r"\s*<packages3d>.*?</packages3d>", "", lbr_text, flags=_re.S)-    lbr_text = _re.sub(r"\s*<package3dinstances>.*?</package3dinstances>", "", lbr_text, flags=_re.S)-    # 3. library-level <packages3d> block (all merged parts)-    blocks = []-    for pkg, urn in merged.items():-        blocks.append(-            f'<package3d name="{pkg}" urn="" wip_urn="{urn}" locally_modified="yes" type="model">'-            f'<description>{pkg}</description>'-            f'<packageinstances><packageinstance name="{pkg}"/></packageinstances>'-            f'</package3d>'-        )-    pkgs3d = "<packages3d>\n" + "\n".join(blocks) + "\n</packages3d>\n"-    lbr_text = lbr_text.replace("</packages>", "</packages>\n" + pkgs3d, 1)--    # 4. per-device <package3dinstances>-    def _dev_repl(m):-        dev = m.group(0)-        pm = _re.search(r'package="([^"]+)"', dev)-        if pm and pm.group(1) in merged and "</connects>" in dev:-            inst = (f'<package3dinstances><package3dinstance package3d_urn="{merged[pm.group(1)]}"/>'-                    f'</package3dinstances>')-            dev = dev.replace("</connects>", "</connects>\n" + inst, 1)-        return dev--    return _re.sub(r'<device\b[^>]*>.*?</device>', _dev_repl, lbr_text, flags=_re.S)---def _orchestrate_make_3d_package(args: dict) -> dict:-    """Create a RENDERING component 3D-PACKAGE urn (footprint + chip), fully programmatically - NO GUI-    dialogs. A proper component 3D model contains the FOOTPRINT (pads + courtyard) AND the chip,-    merged and aligned, so the 3D viewer can verify the chip's pads land on the footprint pads.--    So: open the library, run Electron.Create3DPackage to load the package's FOOTPRINT into a-    generator doc, import the STEP onto it, orient it flat, then saveAs an .f3d (footprint + chip) --    which skips the FINISH Save dialog + the two unbeatable "Fusion360" CEF modals entirely --    aps_upload the .f3d, and return the fs.file:vf wip_urn to hand-write into the library's-    <packages3d>.--    Two gotchas this avoids: (1) importing the STEP into an EMPTY design gives a chip with NO-    footprint (an incomplete package); (2) a raw STEP upload does not render at all ("Thumbnail-    download failed"). See the fusion-multipart-libraries skill.--    args: {lbrPath: Windows .lbr whose FIRST package is the footprint, modelPath: Windows .step,-           projectId, folderId (both from fusion_aps_browse), fileName?: str, orient?: bool}-    """-    import os as _os, time as _time-    lbr_path = (args.get("lbrPath") or args.get("filePath") or "").replace("\\", "/")-    model_path = (args.get("modelPath") or "").replace("\\", "/")-    project_id, folder_id = _discover_upload_target(args)  # an AI-owned non-root folder (never project root)-    if not lbr_path or not model_path:-        return {"success": False,-                "error": "lbrPath (.lbr with the footprint) and modelPath (.step) are required (Windows paths)."}-    if not folder_id:-        return {"success": False, "errorCode": "no_work_folder",-                "error": "Could not resolve a non-root 'Adom AI Workspace' upload folder.",-                "_hint": "The bridge refuses to write to a shared project ROOT. Sign in (fusion_aps_signin) "-                         "so it can create 'Adom AI Workspace', or pass a real (non-root) folderId. NEVER "-                         "pass a project root folderId. See the fusion-cloud-hygiene skill."}-    cap_label = args.get("captureLabel")  # when set, capture BEFORE (footprint) + AFTER (chip placed)-    task = _safe_folder_name(args.get("task") or "AI 3D packages")  # saveAs subfolder (never root)-    orient = args.get("orient", True)-    base = _os.path.basename(model_path).rsplit(".", 1)[0]-    name = (args.get("fileName") or (base + "_3d")).replace("'", "").replace(".f3d", "")-    f3d_name = name + ".f3d"-    # 1. open the library so the footprint exists; 2. Create3DPackage -> generator WITH the footprint loaded-    op = _orchestrate_open_lbr({"filePath": lbr_path})-    if not op.get("success"):-        return {"success": False, "error": "open_lbr failed: " + str(op.get("error"))}-    _time.sleep(1)-    cp = _proxy_to_addin("execute_text_command", {"command": f"Electron.Create3DPackage {lbr_path}"}, timeout=40)-    if not cp.get("success"):-        return {"success": False, "error": "Create3DPackage failed: " + str(cp.get("error")),-                "_hint": "The .lbr must be adom-lbr-generated; its FIRST package's footprint is loaded into the generator."}-    _time.sleep(2)-    # BEFORE shot: the footprint (pads + courtyard) loaded in the 3D viewer, no chip yet.-    before_shot = None-    if cap_label:-        try:-            _proxy_to_addin("run_modeling_script",-                            {"script": "app.activeViewport.fit()\nresult={}"}, timeout=15)-        except Exception:-            pass-        _time.sleep(0.4)-        before_shot = _capture_labeled(f"{cap_label}_before")-    # AUTO-ORIENT (fixed 2026-07-07): the old logic always rotated the SMALLEST bbox dim to Z,-    # which is right for a flat SMD chip lying down but TIPS A TALL THROUGH-HOLE PART (machine pin,-    # connector) onto its side - its long axis is already Z and must stay vertical. Now: keep the-    # part vertical if its largest dim is already Z (tall_z), and only flatten a clearly THIN part-    # whose thin axis isn't Z yet. A caller can still force orient:false to skip entirely.-    orient_block = (-        "thin=min(dx,dy,dz); big=max(dx,dy,dz)\n"-        "tall_z=(dz>=dx and dz>=dy)\n"-        "is_flat=(thin < 0.5*big)\n"-        "axis=None\n"-        "if is_flat and not tall_z:\n axis=(1,0,0) if (dy<=dx and dy<=dz) else ((0,1,0) if (dx<=dy and dx<=dz) else None)\n"-        "if axis:\n mat=oc.transform2.copy(); r=adsk.core.Matrix3D.create()\n"-        " r.setToRotation(math.pi/2, adsk.core.Vector3D.create(*axis), adsk.core.Point3D.create(0,0,0))\n"-        " mat.transformBy(r); oc.transform2=mat\n"-    ) if orient else ""-    # 3. import the chip ONTO the footprint in the generator doc, orient, saveAs as f3d (footprint+chip)-    script = (-        "import adsk.core, adsk.fusion, math\n"-        "app=adsk.core.Application.get(); doc=app.activeDocument\n"-        "d=adsk.fusion.Design.cast(app.activeProduct); root=d.rootComponent\n"-        "im=app.importManager\n"-        f"im.importToTarget(im.createSTEPImportOptions('{model_path}'), root)\n"-        "oc=root.occurrences.item(root.occurrences.count-1); bb=oc.boundingBox\n"-        "dx=bb.maxPoint.x-bb.minPoint.x; dy=bb.maxPoint.y-bb.minPoint.y; dz=bb.maxPoint.z-bb.minPoint.z\n"-        + orient_block +-        "try:\n app.activeViewport.fit()\nexcept: pass\n"-        # FOLDER HYGIENE: saveAs into an 'Adom AI Workspace'/<task> subfolder, NEVER the project root.-        "proj=app.data.activeProject; rf=proj.rootFolder\n"-        "def _sub(p,nm):\n"-        " for i in range(p.dataFolders.count):\n"-        "  if p.dataFolders.item(i).name==nm: return p.dataFolders.item(i)\n"-        " return p.dataFolders.add(nm)\n"-        f"wsf=_sub(_sub(rf,'Adom AI Workspace'),'{task}')\n"-        "res={}\n"-        "try:\n"-        f" doc.saveAs('{name}', wsf, '3d package (footprint+chip)', '')\n"-        " res['path']=doc.dataFile.id if doc.dataFile else None\n"-        " res['dims']=[round(dx*10,2),round(dy*10,2),round(dz*10,2)]\n"-        "except Exception as e: res['err']=str(e)[:80]\n"-        "result=res\n"-    )-    r = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=120)-    res = (r.get("data", {}) or {}).get("result", {}) if isinstance(r, dict) else {}-    f3d_path = res.get("path") if isinstance(res, dict) else None-    dims = res.get("dims") if isinstance(res, dict) else None-    # AFTER shot: the chip placed flat on its footprint (pads landing on pads) in the 3D viewer.-    after_shot = _capture_labeled(f"{cap_label}_after") if cap_label else None-    if not f3d_path or not str(f3d_path).endswith(".f3d"):-        return {"success": False, "error": "f3d saveAs did not produce a .f3d path: " + str(res),-                "before": before_shot, "after": after_shot,-                "_hint": "Confirm modelPath is a valid Windows .step path and Fusion is running."}-    up = aps.handle_upload({"projectId": project_id, "folderId": folder_id,-                            "localPath": f3d_path, "fileName": f3d_name})-    up_d = up.get("data", up) if isinstance(up, dict) else {}-    item_urn = (up_d or {}).get("itemUrn", "") if isinstance(up_d, dict) else ""-    try:-        _proxy_to_addin("run_modeling_script", {"script": "app.activeDocument.close(False)\nresult={}"}, timeout=20)-    except Exception:-        pass-    if "dm.lineage:" not in item_urn:-        return {"success": False, "error": "f3d upload did not return a lineage urn: " + str(up_d)}-    lid = item_urn.split("dm.lineage:")[-1]-    wip_urn = f"urn:adsk.wipprod:fs.file:vf.{lid}?version=1"-    return {-        "success": True,-        "wip_urn": wip_urn,-        "dims_mm": dims,-        "f3d": f3d_path,-        "before": before_shot,-        "after": after_shot,-        "_hint": (-            "DONE - a PROPER component 3D package (FOOTPRINT + chip, aligned) created with NO GUI dialogs. "-            "NEXT: hand-write this wip_urn into the library's EAGLE XML - a <package3d name=\"PKG\" urn=\"\" "-            "wip_urn=\"" + wip_urn + "\" locally_modified=\"yes\" type=\"model\"> (with <packageinstances>"-            "<packageinstance name=\"PKG\"/></packageinstances>) inside <packages3d> (between </packages> and "-            "<symbols>), PLUS <package3dinstances><package3dinstance package3d_urn=\"" + wip_urn + "\"/>"-            "</package3dinstances> inside the device after </connects>. Then fusion_open_lbr the merged .lbr. "-            "EXPECT: fusion_check_dialogs == 0 (no broken-ref), the device 3D preview shows footprint + chip "-            "(NOT 'Thumbnail download failed'), and opening the f3d shows the chip's pads landing on the "-            "footprint pads. "-            "SPEEDUP: for a many-part library, call this verb once per part, collect the urns, hand-merge ALL "-            "bindings in ONE pass, then fusion_open_lbr ONCE (don't open/save per part). "-            "PITFALLS: needs Fusion running (after a bridge_install respawn it can transiently report "-            "'not running' - fusion_start, then retry); a RAW STEP upload binds but renders NOTHING "-            "('Thumbnail download failed') so always go through this verb (it makes an f3d); NEVER FINISH the "-            "Package3D generator (Package3DStop) - its Save dialog + two opaque CEF modals are unbeatable, "-            "this verb saveAs-es instead. Full recipe: the fusion-multipart-libraries skill."-        ),-    }---def _orchestrate_build_library_3d(args: dict) -> dict:-    """Bind real 3D onto a multi-part library. Runs DETACHED by default (async=True) so it-    SURVIVES AD's 60s relay cap.--    ⚠️ LEARNED THE HARD WAY (2026-07-08): the cloud Package3D generate + Hub upload takes MINUTES-    on a real machine, but AD's relay hard-caps every request at ~60s and `timeoutSeconds` is NOT-    honored for this verb - so the old synchronous build got its thread KILLED at 60s, wrote no-    bound .lbr, and lost every wip_urn (3/4 pins on a demo board came back as flat pads because the-    4th never got a fresh urn). Fix: the build now runs in a BACKGROUND daemon thread and returns-    immediately; the caller POLLS the bound `outLbrPath` file (read_file) until it has one-    `<package3d ... wip_urn=urn:...>` per part. Pass `async:false` only on a fast machine where the-    whole build fits under 60s. Then embed those package3d + a per-`<element>` `package3d_urn` in a-    `.brd` and `fusion_open_board` + `fusion_show_3d_board` shows the REAL 3D bodies (background).--    args: {..., async?: bool (default TRUE - detached + pollable)}. See _build_library_3d_core.-    """-    combined = (args.get("lbrPath") or "").replace("\\", "/")-    out_path = (args.get("outLbrPath") or combined).replace("\\", "/")-    if args.get("async", True) and args.get("parts"):-        import threading as _th--        def _run():-            try:-                _build_library_3d_core(args)-            except Exception:-                pass-        _th.Thread(target=_run, daemon=True).start()-        return {-            "success": True, "status": "started", "async": True,-            "boundLbr": out_path, "partsTotal": len(args.get("parts") or []),-            "_hint": (-                "⏳ 3D bind runs DETACHED (survives AD's 60s relay cap, which used to kill it + lose "-                "every wip_urn). POLL the boundLbr via read_file every ~30s until it has one "-                "<package3d name=... wip_urn=urn:...> PER PART (count == partsTotal). Do NOT re-fire "-                "while running (check fusion_addin_status.busy). When done, embed those <packages3d> + "-                "a per-<element> package3d_urn in your .brd, then fusion_open_board + fusion_show_3d_board "-                "for real 3D bodies (all background). If a urn stays unresolved in the 3D view, re-run "-                "for just that part - its f3d upload failed."),-        }-    return _build_library_3d_core(args)---def _build_library_3d_core(args: dict) -> dict:-    """Build a RENDERING multi-part 3D library in ONE call - the whole programmatic pipeline.--    For each part: make its footprint+chip f3d package (no GUI dialogs, optional BEFORE/AFTER-    screenshots), collect the wip_urn. Then inject ALL bindings into the combined .lbr in one-    pass (no hand XML surgery) and open the finished library ONCE. This is the verb to call for-    a basic-parts sampler / any many-part library - it replaces the per-part make_3d_package loop-    + manual binding the AI used to do.--    args: {-      lbrPath:  combined .lbr to bind + open (Windows path),-      parts:    [{package, lbrPath (per-part .lbr whose FIRST package is this footprint),-                  modelPath (.step)}],-      outLbrPath?: where to write the bound .lbr (default: overwrite lbrPath),-      capture?:    bool - capture before/after per part (default True),-      projectId?, folderId?: APS upload target (default: the MAIN-project upload folder),-      openWhenDone?: bool (default True)-    }-    """-    combined = (args.get("lbrPath") or "").replace("\\", "/")-    parts = args.get("parts") or []-    if not combined or not parts:-        return {"success": False,-                "error": "lbrPath (combined .lbr) and parts[] are required.",-                "_hint": "parts: [{package, lbrPath (per-part footprint .lbr), modelPath (.step)}]. "-                         "projectId/folderId default to the MAIN-project upload folder."}-    out_path = (args.get("outLbrPath") or combined).replace("\\", "/")-    capture = args.get("capture", True)-    # Put this library's f3d in its OWN task subfolder under 'Adom AI Workspace' (never project root).-    import os as _os2-    task = args.get("task") or _os2.path.basename(combined).rsplit(".", 1)[0]-    project_id, folder_id = _discover_upload_target({**args, "task": task})-    if not folder_id:-        return {"success": False, "errorCode": "no_work_folder",-                "error": "Could not resolve a non-root 'Adom AI Workspace' upload folder.",-                "_hint": "The bridge refuses to write to a shared project ROOT. Sign in "-                         "(fusion_aps_signin) so it can create the workspace folder, or pass a real "-                         "(non-root) folderId. See the fusion-cloud-hygiene skill."}-    results = []; bindings = []; shots = []-    for p in parts:-        pkg = p.get("package")-        per_lbr = (p.get("lbrPath") or "").replace("\\", "/")-        step = (p.get("modelPath") or "").replace("\\", "/")-        if not pkg or not per_lbr or not step:-            results.append({"package": pkg, "success": False,-                            "error": "package, lbrPath (per-part .lbr) and modelPath (.step) all required"})-            continue-        mk_args = {-            "lbrPath": per_lbr, "modelPath": step,-            "projectId": project_id, "folderId": folder_id,-            "fileName": f"{pkg}_3d", "task": task,-            "captureLabel": (pkg if capture else None),-            # Per-part orient passthrough (2026-07-07): a caller can set orient:false on a part-            # whose STEP is already correctly oriented (e.g. a tall through-hole pin). Defaults-            # to True, and make_3d_package's tall-part guard now keeps tall parts vertical anyway.-            "orient": p.get("orient", True),-        }-        mk = _orchestrate_make_3d_package(mk_args)-        # Retry once: the FIRST part of a run often fails transiently while Fusion settles from a-        # prior view/library; a single retry recovers it (the dropped-first-part trap).-        if not mk.get("success"):-            import time as _t-            _t.sleep(2)-            mk = _orchestrate_make_3d_package(mk_args)-        entry = {"package": pkg, "success": bool(mk.get("success"))}-        if mk.get("success"):-            entry["wip_urn"] = mk.get("wip_urn"); entry["dims_mm"] = mk.get("dims_mm")-            bindings.append({"package": pkg, "wip_urn": mk["wip_urn"]})-        else:-            entry["error"] = mk.get("error")-        if mk.get("before"):-            shots.append({"package": pkg, "stage": "before", "path": mk["before"]})-        if mk.get("after"):-            shots.append({"package": pkg, "stage": "after", "path": mk["after"]})-        results.append(entry)-    # inject every binding in ONE pass, write the bound library. Read the ALREADY-BOUND output if it-    # exists so a re-run ACCUMULATES (merge-aware) onto prior successes instead of starting clean.-    bound = None-    if bindings:-        try:-            import os as _os-            src = out_path if _os.path.exists(out_path) else combined-            with open(src, "r", encoding="utf-8") as fh:-                txt = fh.read()-            txt = _inject_package3d_bindings(txt, bindings)-            with open(out_path, "w", encoding="utf-8") as fh:-                fh.write(txt)-            bound = out_path-        except Exception as e:-            return {"success": False, "error": f"binding injection failed: {e}",-                    "parts": results, "bindings": bindings, "screenshots": shots}-    # open the finished library ONCE (so check_dialogs reflects the merged result)-    opened = None-    if bound and args.get("openWhenDone", True):-        try:-            opened = bool(_orchestrate_open_lbr({"filePath": bound}).get("success"))-        except Exception:-            opened = False-    n_ok = sum(1 for r in results if r.get("success"))-    return {-        "success": n_ok > 0,-        "boundLbr": bound,-        "partsBound": n_ok,-        "partsTotal": len(parts),-        "parts": results,-        "screenshots": shots,-        "opened": opened,-        "_hint": (-            f"{n_ok}/{len(parts)} parts got a RENDERING footprint+chip 3D package; all bindings injected "-            f"into {bound} and the library opened once. NEXT: fusion_check_dialogs should be 0 (no "-            "broken-ref). For clean device previews + screenshots, save to the cloud and REOPEN from "-            "the cloud (fusion_aps_open) - the in-session view often gets an empty 'Untitled' shoved in "-            "front. Each device's lower-right 3D preview should show footprint + chip (NOT 'Thumbnail "-            "download failed'). The BEFORE (footprint) / AFTER (chip placed) PNGs are in screenshots[] "-            "on the Windows box (C:/tmp/conduit-screenshots) - pull them with desktop_pull_file (or "-            "re-capture via desktop_screenshot_window) for the demo video. "-            "AFTER (recommended): call fusion_capture_library_views per showcase part to grab the "-            "symbol / footprint / component (pin<->pad mapped) views - those are what make EEs trust "-            "the library, and the 3D-only shots miss them. "-            "PITFALLS: needs Fusion running. ⚠️ RELAY TIMEOUT: a many-part run takes minutes but the "-            "adom-desktop relay times out the REQUEST at ~60s - you may get 'Request timed out' even "-            "though the build KEEPS RUNNING server-side and finishes. Do NOT assume it failed: wait, "-            "then verify by reading the boundLbr (count <package3d name=) + the before/after PNGs in "-            "C:/tmp/conduit-screenshots. If a part is missing, just re-run build_library_3d for the "-            "missing parts pointing outLbrPath at the SAME file - binding injection is now MERGE-AWARE "-            "and idempotent, so it accumulates onto the existing bindings (it no longer wipes the "-            "parts that already succeeded). Each part also self-retries once on a transient first-part "-            "failure. Full recipe: the fusion-multipart-libraries skill."-        ),-    }---def _dismiss_dialogs_bg() -> list:-    """Dismiss any blocking Fusion dialog in the BACKGROUND, without stealing focus.--    Uses close_window (WM_CLOSE via PostMessage) = Cancel/No on the dialog - the background-safe-    dismiss. ⛔ NEVER use send_key/Escape for this: send_key calls SetForegroundWindow and YANKS-    Fusion to the foreground, disrupting the user (learned 2026-06-29). WM_CLOSE is also more reliable-    than Escape on Qt dialogs. Returns the titles dismissed."""-    try:-        info = get_fusion_window_info()-        dismissed = []-        for d in info.get("dialogs", []) or []:-            hwnd = d.get("hwnd")-            if hwnd:-                try:-                    close_window(hwnd)  # background WM_CLOSE = Cancel/No (never creates a stray)-                    dismissed.append(d.get("title") or "")-                except Exception:-                    pass-        return dismissed-    except Exception:-        return []---def _orchestrate_capture_library_views(args: dict) -> dict:-    """Capture the LIBRARY-EDITOR views that make EEs trust a part: the schematic SYMBOL, the-    FOOTPRINT (pads + layer stack), and the COMPONENT/device view (Content Manager: the symbol +-    the package table with the footprint<->package Mapped check + pin/pad counts). These are what-    developers want to see - the 3D before/after shots alone don't show them.--    Requires the .lbr OPEN in the Electronics Library workspace (fusion_open_lbr first). For each-    package it runs the EAGLE/Electron EDIT <pkg>.sym / .pac / .dev, WINDOW FIT to frame, and a-    background hwnd screenshot (never fullscreen). Returns the saved PNG paths on the Windows box-    (C:/tmp/conduit-screenshots - pull them with desktop_pull_file).--    args: {packages: [<deviceset name>, ...] (or a single 'package'),-           views?: subset of ['component','symbol','footprint'] (default all three),-           settle?: seconds to wait after each EDIT before framing/capturing (default 1.5 - raise it-                    if a shot shows the PREVIOUS part, lower it to go faster on a snappy machine)}-    NOTE: ~2s per view * parts * views can exceed the ~60s relay request timeout - the captures still-    complete server-side; verify the PNGs landed in C:/tmp/conduit-screenshots and pull them.-    """-    import time as _t-    pkgs = args.get("packages") or ([args["package"]] if args.get("package") else [])-    if not pkgs:-        return {"success": False,-                "error": "packages: [<deviceset name>] (or package: <name>) required.",-                "_hint": "Open the .lbr first (fusion_open_lbr). Names are the deviceset names, e.g. ESP32-S3FN8."}-    view_ext = {"component": "dev", "symbol": "sym", "footprint": "pac",-                "dev": "dev", "sym": "sym", "pac": "pac"}-    label_of = {"dev": "component", "sym": "symbol", "pac": "footprint"}-    views = args.get("views") or ["component", "symbol", "footprint"]-    # EDIT is async - the editor takes a beat to actually SWITCH the view. Capturing too soon grabs-    # the PREVIOUS view (a mislabeled shot). Settle AFTER the EDIT (before fit/capture); tunable.-    settle = float(args.get("settle", 1.5))-    out = []-    for pkg in pkgs:-        for v in views:-            ext = view_ext.get(v, v)-            er = _proxy_to_addin("electron_run", {"command": f"EDIT {pkg}.{ext}"}, timeout=30)-            _t.sleep(settle)  # let the editor LOAD the new view before framing/capturing it-            # ALWAYS catch + dismiss any dialog the EDIT raised - in the BACKGROUND (no focus steal).-            # Common one: "Create new symbol/footprint '<name>'?" when <name> is a DEVICESET name but-            # the symbol/package is named by the shared combo. Dismissing (Cancel/No) avoids a stray.-            dismissed = _dismiss_dialogs_bg()-            _proxy_to_addin("electron_run", {"command": "WINDOW FIT"}, timeout=20)-            _t.sleep(0.6)-            shot = _capture_labeled(f"{pkg}_{label_of.get(ext, ext)}")-            out.append({"package": pkg, "view": label_of.get(ext, ext), "path": shot,-                        "dialogDismissed": dismissed or None,-                        "ok": bool(er.get("success", True)) and bool(shot) and not dismissed})-    n_ok = sum(1 for o in out if o.get("ok"))-    return {-        "success": n_ok > 0,-        "captured": out,-        "_hint": (-            f"Captured {n_ok}/{len(out)} library-editor views to C:/tmp/conduit-screenshots (pull with "-            "desktop_pull_file). SYMBOL = full pinout; FOOTPRINT = pads + layer stack; COMPONENT = the "-            "Content Manager device view with the footprint<->package Mapped check + pin/pad counts. "-            "⚠️ NAMES: the COMPONENT (.dev) view opens by DEVICESET name (e.g. R-0603-10K). But SYMBOL "-            "(.sym) and FOOTPRINT (.pac) open by the SYMBOL / PACKAGE name - in a SHARED-FOOTPRINT "-            "library (one symbol/footprint per package, many value devicesets) that is the COMBO name "-            "(e.g. 'R-0603'), NOT the deviceset name ('R-0603-10K'). Passing a deviceset name to .sym/"-            ".pac makes Fusion pop 'Create new symbol/footprint?'; the bridge now auto-dismisses that in "-            "the BACKGROUND (Cancel/No, no focus steal - see entries with dialogDismissed) and marks the "-            "view not-ok, but you should pass the right name. For shared libraries the COMPONENT view "-            "alone shows symbol + footprint + 3D previews + Mapped, so it is usually enough. "-            "PITFALLS: the .lbr must be OPEN in the Electronics Library workspace (fusion_open_lbr first)."-        ),-    }---def _orchestrate_cleanup_cloud_files(args: dict) -> dict:-    """Precisely delete a LIST of cloud files by lineage urn - SAFE cleanup of AI-created clutter.--    Deletes ONLY the exact `fileIds` (lineage urns) given - never name-guessing, so it cannot touch a-    teammate's file in a shared folder. Loops server-side (one call deletes the whole list). Use this-    to clean up f3d files the bridge created. (Find the ids with fusion_aps_browse on the folder.)--    args: {fileIds: [<lineage urn>, ...], projectName?: str (default active), folderPath?: str-           (default root - where the file lives)}-    """-    file_ids = args.get("fileIds") or []-    if not file_ids:-        return {"success": False, "error": "fileIds [lineage urns] required.",-                "_hint": "Get them from fusion_aps_browse {projectId, folderId} (item .id)."}-    project_name = args.get("projectName", "")-    folder_path = args.get("folderPath", "")-    deleted = []; failed = []-    for fid in file_ids:-        try:-            r = _proxy_to_addin("delete_cloud_file",-                                {"fileId": fid, "projectName": project_name, "folderPath": folder_path},-                                timeout=40)-            if isinstance(r, dict) and r.get("success"):-                deleted.append(fid)-            else:-                failed.append({"fileId": fid, "error": (r.get("error") if isinstance(r, dict) else str(r))[:90]})-        except Exception as e:-            failed.append({"fileId": fid, "error": str(e)[:90]})-    return {-        "success": len(deleted) > 0 or not failed,-        "deletedCount": len(deleted),-        "failedCount": len(failed),-        "failed": failed[:25],-        "_hint": (-            f"Deleted {len(deleted)}/{len(file_ids)} cloud files by PRECISE lineage urn (no name-guessing, "-            "so teammates' files are untouched). NOTE: a large list takes minutes and the relay request "-            "may time out at ~60s while the deletes continue server-side - re-browse the folder to confirm "-            "the count dropped. Anything in failed[] usually means the file was already gone or you lack "-            "delete permission. See the fusion-cloud-hygiene skill."-        ),-    }---def _orchestrate_save_lbr(args: dict) -> dict:-    """Save the currently open Electronics library as a .flbr file.--    Uses Document.CopyToDesktop to export the library in Fusion's native-    .flbr format. The file can be re-opened later or uploaded to the cloud.-    """-    import os--    output_path = args.get("outputPath", "")-    if not output_path:-        return {"success": False, "error": "No outputPath specified"}--    output_path = output_path.replace("\\", "/")--    # Ensure path ends with .flbr-    if not output_path.lower().endswith(".flbr"):-        output_path += ".flbr"--    # Ensure parent directory exists-    parent = os.path.dirname(output_path)-    if parent and not os.path.exists(parent):-        try:-            os.makedirs(parent, exist_ok=True)-        except Exception as e:-            return {"success": False, "error": f"Cannot create directory {parent}: {e}"}--    # Execute Document.CopyToDesktop via the add-in-    result = _proxy_to_addin("execute_text_command", {-        "command": f"Document.CopyToDesktop {output_path}",-    }, timeout=30)--    if not result.get("success"):-        return {-            "success": False,-            "error": f"Document.CopyToDesktop failed: {result.get('error', 'unknown')}",-            "data": {"outputPath": output_path},-        }--    # Verify the file was created-    if os.path.exists(output_path):-        file_size = os.path.getsize(output_path)-        result = {-            "success": True,-            "output": f"Saved library to {os.path.basename(output_path)} ({file_size} bytes)",-            "data": {"outputPath": output_path, "fileSize": file_size},-        }-    else:-        result = {-            "success": True,-            "output": f"Document.CopyToDesktop executed (file may still be writing)",-            "data": {"outputPath": output_path},-        }--    # Auto-screenshot after save — catches blocking dialogs-    return _post_open_screenshot(result, settle_time=1.0)---# Package types = EPG's Scripts3d module names (each has runWithInput(params)).-# Kept in sync with Autodesk's ElectronicsPackageGenerator internal add-in.-_EPG_TYPES = [-    "axial_diode", "axial_fuse", "axial_polarized_capacitor", "axial_resistor",-    "bga", "chip", "chiparray2sideconvex", "chiparray2sideflat", "chiparray4sideflat",-    "chip_led", "cornerconcave", "crystal", "dfn2", "dfn3", "dfn4", "dip",-    "dip_socket", "dip_socket_dual_leaf", "dpak", "ecap", "female_standoff", "hc49",-    "header_right_angle", "header_right_angle_socket", "header_straight",-    "header_straight_socket", "male_female_standoff", "melf", "molded",-    "oscillator_j", "oscillator_l", "plcc", "qfn", "qfp", "radial_dipped_rect",-    "radial_ecap", "radial_inductor", "radial_round_led", "snap_lock", "sod",-    "sodfl", "soic", "soj", "son", "sot143", "sot223", "sot23", "sotfl",-    "surface_mount_header_female", "surface_mount_pin_header_right_angle",-    "surface_mount_pin_header_straight",-]--# EPG param keys that are NOT lengths (never mm->cm converted).-_EPG_NON_DIMENSION_KEYS = {"DPins", "EPins", "pins", "thermal", "color_r", "color_g", "color_b"}---def _orchestrate_generate_package(args: dict) -> dict:-    """Generate an IPC-compliant 3D package via Fusion's built-in-    ElectronicsPackageGenerator (EPG), optionally laser-etch a marking into the-    body top, and optionally export STEP - all in ONE headless add-in call.--    Proven live 2026-07-03 (0603 + '103' etch + 233KB STEP, 1.9s generation).-    """-    pkg_type = (args.get("type") or "").strip().lower()-    if pkg_type not in _EPG_TYPES:-        return {-            "success": False,-            "error": f"Unknown package type: {pkg_type!r}",-            "errorCode": "unknown_package_type",-            "supportedTypes": _EPG_TYPES,-            "_hint": "Pass type as one of supportedTypes (EPG Scripts3d module names). "-                     "Common: chip (0402/0603/0805 passives), soic, qfn, qfp, bga, sot23, "-                     "dfn2, melf, ecap, crystal, header_straight.",-        }--    raw_params = args.get("params") or {}-    units_cm = bool(args.get("unitsCm"))-    params = {}-    for k, v in raw_params.items():-        if (not units_cm) and isinstance(v, (int, float)) and k not in _EPG_NON_DIMENSION_KEYS:-            params[k] = v / 10.0  # mm (datasheet-native) -> cm (Fusion/EPG-native)-        else:-            params[k] = v--    etch = args.get("etch") or ""-    etch_style = (args.get("etchStyle") or "raised").strip().lower()  # raised (white, default) | engraved-    etch_depth_cm = float(args.get("etchDepthMm") or 0.03) / 10.0-    etch_height_mm = args.get("etchHeightMm")  # None = auto-fit-    output_step = args.get("outputStep") or ""-    bridge_version = BRIDGE_VERSION--    script = f"""-import sys, glob, os, importlib-import adsk.core, adsk.fusion--# Locate EPG across install conventions + versions (NEVER hardcode the webdeploy hash).-_bases = []-for env in ('ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432', 'LOCALAPPDATA'):-    root = os.environ.get(env)-    if root:-        _bases += glob.glob(os.path.join(root, 'Autodesk', 'webdeploy', 'production', '*',-                                         'Api', 'InternalAddins', 'ElectronicsPackageGenerator'))-if not _bases:-    raise RuntimeError('EPG_NOT_FOUND: ElectronicsPackageGenerator not present in this Fusion install')-epg_dir = max(_bases, key=os.path.getmtime)-parent = os.path.dirname(epg_dir)-if parent not in sys.path:-    sys.path.insert(0, parent)--mod = importlib.import_module('ElectronicsPackageGenerator.Scripts3d.{pkg_type}')--doc = app.documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType)-design = adsk.fusion.Design.cast(app.activeProduct)-mod.runWithInput({params!r}, design)-root = design.rootComponent--inv = [{{'name': b.name, 'vol': round(b.volume, 6)}} for b in root.bRepBodies]-etched = None-if {etch!r}:-    # FIND THE TOP SURFACE the robust way (John's algorithm, 2026-07-04):-    # 1. bounding box of the WHOLE chip (all bodies);-    # 2. only faces whose centroid sits in the TOP 5% band of that bbox count;-    # 3. among those upward planar faces take the LARGEST AREA (the molded body-    #    top beats terminal tops that tie it on height);-    # 4. lay the text out with a 10% margin inside that face.-    gmin, gmax = 1e9, -1e9-    for b in root.bRepBodies:-        bb = b.boundingBox-        gmin = min(gmin, bb.minPoint.z)-        gmax = max(gmax, bb.maxPoint.z)-    band_z = gmax - 0.05 * (gmax - gmin)-    top_face, top_area = None, -1.0-    for b in root.bRepBodies:-        for f in b.faces:-            if isinstance(f.geometry, adsk.core.Plane) and f.geometry.normal.z > 0.9 \-                    and f.centroid.z >= band_z and f.area > top_area:-                top_face, top_area = f, f.area-    if top_face is None:-        raise RuntimeError('ETCH_NO_TOP_FACE: no upward planar face in the top 5% of the chip bbox')-    body = top_face.body-    sk = root.sketches.add(top_face)-    center = sk.modelToSketchSpace(top_face.centroid)-    # Measure the face in SKETCH space (model-space bbox axes can be SWAPPED vs-    # the sketch axes the text lays out in). Project the model bbox corners.-    bb = top_face.boundingBox-    p_min = sk.modelToSketchSpace(bb.minPoint)-    p_max = sk.modelToSketchSpace(bb.maxPoint)-    face_w = abs(p_max.x - p_min.x)   # extent along sketch-x (the default text baseline)-    face_h = abs(p_max.y - p_min.y)   # extent along sketch-y-    # ALWAYS run the text along the LONGEST axis of the top face (most room for-    # the marking - John's rule, 2026-07-04). If the long axis is sketch-y,-    # rotate the text 90deg rather than squeezing it onto the short axis.-    import math as _math-    rot_deg = 90 if face_h > face_w else 0-    long_len = max(face_w, face_h)-    short_len = min(face_w, face_h)-    w = long_len * 0.80               # 10% margin each side along the long axis-    box_h_cap = short_len * 0.80      # 10% margin each side across it-    # multi-line support (e.g. MPN + variant on line 2): fit per-line-    lines = {etch!r}.split(chr(10))-    n_lines = max(1, len(lines))-    max_chars = max(1, max(len(l) for l in lines))-    # width-aware auto-fit: glyph ~0.75*h wide; line block ~1.35*h per line-    h = ({etch_height_mm!r} / 10.0) if {etch_height_mm!r} else \-        max(0.015, min(box_h_cap / (1.35 * n_lines), w / (0.75 * max_chars)))-    if rot_deg:-        c1 = adsk.core.Point3D.create(center.x - box_h_cap/2, center.y - w/2, 0)-        c2 = adsk.core.Point3D.create(center.x + box_h_cap/2, center.y + w/2, 0)-    else:-        c1 = adsk.core.Point3D.create(center.x - w/2, center.y - box_h_cap/2, 0)-        c2 = adsk.core.Point3D.create(center.x + w/2, center.y + box_h_cap/2, 0)-    ti = sk.sketchTexts.createInput2({etch!r}, h)-    ti.setAsMultiLine(c1, c2,-                      adsk.core.HorizontalAlignments.CenterHorizontalAlignment,-                      adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)-    if rot_deg:-        try:-            ti.angle = _math.pi / 2.0-        except Exception:-            rot_deg = 0  # older API without angle - fall back to unrotated-    txt = sk.sketchTexts.add(ti)-    # MEASURE-AND-CORRECT (the margin is a CONTRACT): font metrics vary, so the-    # 0.75h glyph estimate can under-shoot ('ADOM-A' rendered edge-to-edge). After-    # placing, measure the text's real bbox; if it busts the 10%-margin box,-    # recreate at the scaled-down height. Never trust an estimate you can measure.-    fit_iterations = 0-    measured_w = None-    for _pass in range(2):-        tb = txt.boundingBox-        t_ext_x = abs(tb.maxPoint.x - tb.minPoint.x)-        t_ext_y = abs(tb.maxPoint.y - tb.minPoint.y)-        measured_w = max(t_ext_x, t_ext_y)   # the baseline-axis extent-        measured_c = min(t_ext_x, t_ext_y)   # the cross-axis extent (line stack)-        if measured_w <= w and measured_c <= box_h_cap:-            break-        scale = min(w / max(measured_w, 1e-6), box_h_cap / max(measured_c, 1e-6)) * 0.95-        h = max(0.008, h * scale)-        txt.deleteMe()-        ti = sk.sketchTexts.createInput2({etch!r}, h)-        ti.setAsMultiLine(c1, c2,-                          adsk.core.HorizontalAlignments.CenterHorizontalAlignment,-                          adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)-        if rot_deg:-            try:-                ti.angle = _math.pi / 2.0-            except Exception:-                pass-        txt = sk.sketchTexts.add(ti)-        fit_iterations += 1-    marking_geom = {{-        'chipBBox': {{'min': [round(gmin,5), 0, 0], 'maxZ': round(gmax,5)}},-        'chipBBoxZ': {{'min': round(gmin,5), 'max': round(gmax,5)}},-        'topBandZ': round(band_z,5),-        'topFace': {{'area': round(top_area,6), 'centroidZ': round(top_face.centroid.z,5),-                    'sketchExtents': {{'x': round(face_w,5), 'y': round(face_h,5)}},-                    'body': body.name}},-        'longAxis': 'sketch-y' if face_h > face_w else 'sketch-x',-        'rotatedDeg': rot_deg,-        'textBox': {{'c1': [round(c1.x,5), round(c1.y,5)], 'c2': [round(c2.x,5), round(c2.y,5)],-                    'marginPct': 10}},-        'textHeightMm': round(h*10,4), 'lines': n_lines, 'maxLineChars': max_chars,-        'heightAuto': not bool({etch_height_mm!r}),-        'textWidthMeasuredMm': round(measured_w*10,4) if measured_w else None,-        'fitIterations': fit_iterations,-        'why': {{'band': 'top 5% of whole-chip bbox excludes terminal tops that tie the body',-                'largestArea': 'molded body top beats small terminal faces in the band',-                'longAxis': 'text runs along the longest face axis for maximum room',-                'fit': 'h = min(boxH/(1.35*lines), boxW/(0.75*maxChars)) - glyphs ~0.75h wide'}},-    }}-    if {etch_style!r} == 'engraved':-        # engraved (sunken) cut - the pre-2026-07-04 default-        ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.CutFeatureOperation)-        ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(-{etch_depth_cm!r}))-        ext_in.participantBodies = [body]-        root.features.extrudeFeatures.add(ext_in)-    else:-        # RAISED WHITE marking (default): humans need CONTRAST - a thin positive-        # extrude painted white on the dark body reads like real silkscreen ink,-        # far better than a same-color emboss. Reuses EPG's own rgb-appearance-        # utility so the white survives Fusion rendering (+ colored STEP).-        ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)-        ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal({etch_depth_cm!r}))-        mark = root.features.extrudeFeatures.add(ext_in)-        from ElectronicsPackageGenerator.Utilities import addin_utility as _au-        for i in range(mark.bodies.count):-            mb = mark.bodies.item(i)-            mb.name = 'Marking' if i == 0 else 'Marking' + str(i + 1)-            _au.apply_rgb_appearance(app, design, mb, 255, 255, 255, 'AdomMarkingWhite')-    etched = {etch!r}--step_path = None-if {output_step!r}:-    em = design.exportManager-    opts = em.createSTEPExportOptions({output_step!r})-    em.execute(opts)-    step_path = {output_step!r}--# SIDECAR MANIFEST: every setting we picked + WHY + the bboxes we calculated,-# so a marking refresh (e.g. adding a variant name as a 2nd line) can rework the-# text without re-deriving anything. Written next to the STEP as <step>.manifest.json.-manifest = {{-    'schema': 'adom-fusion-generate-package/1',-    'bridgeVersion': {bridge_version!r},-    'type': {pkg_type!r},-    'paramsCm': {params!r},-    'paramsNote': 'paramsCm are EPG-native cm (mm inputs were /10)',-    'marking': ({{'text': {etch!r}, 'style': {etch_style!r},-                'depthMm': {etch_depth_cm!r} * 10, **marking_geom}} if etched else None),-    'bodies': inv,-    'outputs': {{'step': step_path}},-    'refresh': {{-        'howTo': 'To re-mark (e.g. add a variant on line 2): call fusion_generate_package again '-                 'with the SAME type+paramsCm (unitsCm:true) and the new multi-line etch text '-                 '(join lines with a newline). The generator is deterministic, so geometry and '-                 'placement reproduce exactly; this manifest carries the boxes/height the last '-                 'run chose for comparison.',-        'example': {{'type': {pkg_type!r}, 'unitsCm': True, 'params': 'paramsCm from this file',-                    'etch': ({etch!r} + chr(10) + 'VARIANT') if etched else 'MPN' + chr(10) + 'VARIANT'}},-    }},-}}-manifest_path = None-if step_path:-    manifest_path = step_path + '.manifest.json'-    import json as _json-    with open(manifest_path, 'w', encoding='utf-8') as _f:-        _json.dump(manifest, _f, indent=2)--vp = app.activeViewport-cam = vp.camera-cam.isSmoothTransition = False-cam.target = adsk.core.Point3D.create(0, 0, 0)-cam.eye = adsk.core.Point3D.create(0.25, -0.35, 0.45)-cam.upVector = adsk.core.Vector3D.create(0, 0, 1)-cam.isFitView = True-vp.camera = cam-vp.refresh()--result = {{'type': {pkg_type!r}, 'bodies': inv, 'etched': etched, 'stepPath': step_path,-           'manifest': manifest, 'manifestPath': manifest_path,-           'epgDir': epg_dir, 'doc': app.activeDocument.name}}-"""--    res = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=110)-    if not res.get("success"):-        err = (res.get("error") or "")-        if "EPG_NOT_FOUND" in err:-            res["errorCode"] = "epg_not_found"-            res["_hint"] = ("Fusion's built-in ElectronicsPackageGenerator add-in was not found in "-                            "this install (ships with 2024+ Fusion under webdeploy .../Api/"-                            "InternalAddins). Update Fusion, or build the part with "-                            "fusion_make_3d_package from a STEP instead.")-        return res--    data = res.get("data") or {}-    inner = data.get("result") or {}-    return {-        "success": True,-        "output": json.dumps(inner),-        **inner,-        "_hint": ("Generated an IPC-compliant parametric package via Fusion's built-in EPG"-                  + (f", marked '{etch}' on the chip top as {'an engraved cut' if etch_style == 'engraved' else 'RAISED WHITE text (silkscreen-style - high contrast on the dark body)'}" if etch else "")-                  + (f", exported STEP to {output_step}" if output_step else "")-                  + ". NEXT: fusion_screenshot_fusion for a visual check; pull_file the STEP for KiCad. "-                    "HOW THE MARKING WORKS: the top surface is found from the WHOLE-chip bounding box - "-                    "only upward planar faces in the top 5% height band count, largest area wins - and "-                    "the text is laid out with a 10% margin inside that face, auto-fit width-aware "-                    "(override with etchHeightMm). Default style is raised+white for CONTRAST (humans "-                    "can't read a dark-on-dark emboss); pass etchStyle:'engraved' for a sunken laser cut. "-                    "The text ALWAYS runs along the LONGEST axis of the top face (rotated 90deg when "-                    "needed) for maximum room; the 10% margin is ENFORCED by measuring the placed text bbox "-                    "and shrink-to-fitting (see fitIterations in the manifest). Multi-line markings work - join lines with a newline "-                    "(e.g. 'LM358' + newline + 'ADOM-A' for MPN + variant; per-line auto-fit). "-                    "SIDECAR MANIFEST: when outputStep is set, <step>.manifest.json records every "-                    "setting picked + WHY + the calculated bboxes (chip bbox, top band, face extents, "-                    "text box, height, rotation) - to refresh a marking (add a variant line), re-call "-                    "this verb with the manifest's type + paramsCm (unitsCm:true) + the new etch text; "-                    "generation is deterministic so placement reproduces exactly. The manifest is also "-                    "returned inline as 'manifest'. "-                    "PITFALLS: params are mm by default (unitsCm:true for EPG-native cm); dimension names "-                    "follow each EPG generator (chip: D/E/A/L/L1; soic: A/A1/b/D/E/E1/e/L/DPins); raised "-                    "markings are separate 'Marking' bodies (white in Fusion + colored STEP)."),-    }---# ── Optimized-GLB pipeline (service-step2glb "molecule mode") ──────────────────-# A raw STEP->GLB tessellation (fusion_export_step + a plain step2glb convert) leaves-# EVERY solid/pad/via as its own draw call - a real board comes out ~16MB / ~25,000-# primitives that HALTS the viewer's GPU on rotation (John hit this live 2026-07-14).-# service-step2glb's molecule mode is the OCCT-side replacement for Colby's Blender-# molecule-converter: tessellate -> anchor to the MP machine pins -> (optional) bake-# silkscreen -> gold pins -> dedup/flatten/JOIN/weld/prune -> Draco. Same board comes-# out ~465KB / ~31 draw calls, auto-oriented. We call it straight from the bridge so-# a Fusion board becomes a wiki-grade GLB in ONE verb. Reachable from the box (it is a-# public *.adom.cloud host, same as the wiki the bridge already streams from).-STEP2GLB_URL = (os.environ.get("ADOM_STEP2GLB_URL")-                or "https://step2glb-gmdoncpxdwx0.adom.cloud").rstrip("/")---def _multipart_body(fields: dict, files: list):-    """Build a multipart/form-data body. files = [(field, filename, bytes, content_type)]."""-    boundary = "----AdomBridgeGLB%d%d" % (int(_time.time() * 1000), os.getpid())-    out = []-    for k, v in (fields or {}).items():-        out.append(("--" + boundary).encode())-        out.append(('Content-Disposition: form-data; name="%s"' % k).encode())-        out.append(b"")-        out.append(str(v).encode())-    for field, filename, data, ctype in (files or []):-        out.append(("--" + boundary).encode())-        out.append(('Content-Disposition: form-data; name="%s"; filename="%s"'-                    % (field, filename)).encode())-        out.append(("Content-Type: %s" % (ctype or "application/octet-stream")).encode())-        out.append(b"")-        out.append(data)-    out.append(("--" + boundary + "--").encode())-    out.append(b"")-    return b"\r\n".join(out), boundary---# A User-Agent is REQUIRED on every service call. The *.adom.cloud edge WAF 403s the-# default "Python-urllib/x.y" UA (confirmed live 2026-07-14) while a browser/curl UA-# gets 200. Set it on the POST AND both polls or the call fails with an opaque 403.-def _service_ua():-    return "adom-fusion-bridge/%s" % BRIDGE_VERSION---def _service_glb_submit(step_path: str, silk_top: str = None, silk_bottom: str = None,-                        pin: str = "medium", job_name: str = "fusion-board") -> dict:-    """POST a STEP (+ optional silk PNGs) to service-step2glb molecule mode. Returns-    {ok, jobId} - does NOT wait. Pure urllib (no requests on the box)."""-    try:-        with open(step_path, "rb") as f:-            step_bytes = f.read()-    except Exception as e:-        return {"ok": False, "error": "could not read STEP: %s" % e}-    files = [("step", os.path.basename(step_path), step_bytes, "application/step")]-    for field, p in (("silk_top", silk_top), ("silk_bottom", silk_bottom)):-        if p and os.path.exists(p):-            try:-                with open(p, "rb") as f:-                    files.append((field, os.path.basename(p), f.read(), "image/png"))-            except Exception:-                pass-    body, boundary = _multipart_body({}, files)-    headers = {-        "Content-Type": "multipart/form-data; boundary=" + boundary,-        "X-Client": "fusion-bridge/adom",-        "X-Job-Name": job_name,-        "User-Agent": _service_ua(),-    }-    url = "%s/convert?molecule=true&pin=%s" % (STEP2GLB_URL, urllib.parse.quote(pin))-    try:-        req = urllib.request.Request(url, data=body, headers=headers, method="POST")-        with urllib.request.urlopen(req, timeout=90) as resp:-            queued = json.loads(resp.read().decode("utf-8", "replace"))-    except Exception as e:-        return {"ok": False, "error": "service POST failed: %s" % e,-                "_hint": "Is %s reachable from this box? Override with ADOM_STEP2GLB_URL." % STEP2GLB_URL}-    job_id = queued.get("job_id")-    if not job_id:-        return {"ok": False, "error": "service did not return a job_id", "raw": queued}-    return {"ok": True, "jobId": job_id}---def _service_glb_fetch(job_id: str, out_path: str = None, poll_s: int = 0) -> dict:-    """Poll a service job for up to poll_s seconds; if complete, optionally write the-    GLB to out_path. Returns {ok, status, stats, glbBytes?, wrote?}. poll_s=0 = one check."""-    ua = _service_ua()-    stats, deadline, first = {}, _time.time() + max(0, poll_s), True-    while first or _time.time() < deadline:-        first = False-        try:-            preq = urllib.request.Request("%s/jobs/%s" % (STEP2GLB_URL, job_id), headers={"User-Agent": ua})-            with urllib.request.urlopen(preq, timeout=20) as r:-                stats = json.loads(r.read().decode("utf-8", "replace"))-        except Exception:-            _time.sleep(4); continue-        st = stats.get("status")-        if st == "complete":-            break-        if st == "error":-            return {"ok": False, "status": "error", "error": stats.get("error") or stats, "stats": stats}-        if _time.time() >= deadline:-            return {"ok": True, "status": st or "processing", "stats": stats}-        _time.sleep(6)-    if stats.get("status") != "complete":-        return {"ok": True, "status": stats.get("status") or "processing", "stats": stats}-    try:-        rreq = urllib.request.Request("%s/jobs/%s/result" % (STEP2GLB_URL, job_id), headers={"User-Agent": ua})-        with urllib.request.urlopen(rreq, timeout=60) as r:-            glb = r.read()-    except Exception as e:-        return {"ok": False, "status": "complete", "error": "download failed: %s" % e, "stats": stats}-    wrote = None-    if out_path:-        try:-            with open(out_path, "wb") as f:-                f.write(glb)-            wrote = out_path-        except Exception as e:-            return {"ok": False, "status": "complete", "error": "could not write GLB: %s" % e, "stats": stats}-    return {"ok": True, "status": "complete", "stats": stats, "glbBytes": glb, "wrote": wrote}---def _orchestrate_export_optimized_glb(args: dict) -> dict:-    """Fusion board -> wiki-grade optimized GLB in one call. Exports the active design's-    STEP, (optionally) the top/bottom silkscreen, runs it through service-step2glb's-    molecule pipeline (anchor + optional silk bake + gold pins + join/weld/prune + Draco),-    and writes the small, fast, auto-anchored GLB to outputPath. See STEP2GLB_URL above."""-    output_path = args.get("outputPath") or args.get("output_path")-    if not output_path:-        return {"success": False, "error": "No outputPath specified.",-                "_hint": 'Usage: fusion_export_optimized_glb {"outputPath":"C:/tmp/board.glb", "silkscreen":true, "pin":"medium"}'}-    if not output_path.lower().endswith(".glb"):-        output_path = output_path + ".glb"-    pin = args.get("pin", "medium")-    want_silk = args.get("silkscreen", True)-    # 1) Need a 3D Design product for STEP. Switch to the 3D board (no-op if already 3D).-    _proxy_to_addin("show_3d_board", {}, timeout=60)-    # 2) Export STEP next to the target GLB.-    step_path = output_path[:-4] + ".step"-    step_res = _proxy_to_addin("export_step", {"outputPath": step_path}, timeout=300)-    if not step_res.get("success"):-        return {"success": False, "error": "STEP export failed: %s" % (step_res.get("error") or step_res.get("message")),-                "step": step_res, "_hint": "The active design must be a 3D Design product (fusion_show_3d_board first)."}-    # 3) Optional silkscreen (best-effort; the GLB is great without it).-    silk_top = silk_bottom = None-    silk_note = None-    if want_silk:-        st = output_path[:-4] + "_silk_top.png"-        sb = output_path[:-4] + "_silk_bottom.png"-        rt = _proxy_to_addin("take_silkscreen_screenshot", {"outputPath": st, "layer": "top"}, timeout=90)-        rb = _proxy_to_addin("take_silkscreen_screenshot", {"outputPath": sb, "layer": "bottom"}, timeout=90)-        if rt.get("success"):-            silk_top = st-        if rb.get("success"):-            silk_bottom = sb-        if not (silk_top or silk_bottom):-            silk_note = "silkscreen capture unavailable on this design; GLB built without a baked silk texture."-    # 4) Submit to the molecule optimizer (fire-and-return: a big board's tessellation-    #    can run minutes, longer than the AD relay's request timeout, so we do NOT block-    #    the whole time here). Then bounded-wait up to `wait` seconds (default 90) so-    #    small boards still come back complete in one call.-    job_name = os.path.splitext(os.path.basename(output_path))[0]-    sub = _service_glb_submit(step_path, silk_top, silk_bottom, pin=pin, job_name=job_name)-    if not sub.get("ok"):-        return {"success": False, "error": sub.get("error"), "_hint": sub.get("_hint"),-                "stepPath": step_path, "note": "STEP exported OK; optimize submit failed."}-    job_id = sub["jobId"]-    wait_s = int(args.get("wait", 15))  # keep total (STEP export + wait) under the ~60s relay timeout-    fetched = _service_glb_fetch(job_id, out_path=output_path, poll_s=wait_s) if wait_s > 0 else {"ok": True, "status": "processing"}-    status_url = "%s/jobs/%s" % (STEP2GLB_URL, job_id)-    result_url = "%s/jobs/%s/result" % (STEP2GLB_URL, job_id)-    if fetched.get("status") == "complete" and fetched.get("wrote"):-        stats = fetched.get("stats") or {}-        size = len(fetched.get("glbBytes") or b"")-        return {-            "success": True, "status": "complete",-            "glbPath": output_path, "stepPath": step_path, "jobId": job_id,-            "silkTop": silk_top, "silkBottom": silk_bottom,-            "meshesBefore": stats.get("meshes_before"), "meshesAfter": stats.get("meshes_after"),-            "sizeBytes": size, "moleculeAnchored": stats.get("molecule_anchored"),-            "silkscreenApplied": stats.get("silkscreen_applied"), "note": silk_note,-            "message": "Optimized GLB written (%d KB, meshes %s->%s, anchored=%s, silk=%s) to %s" % (-                size // 1024, stats.get("meshes_before"), stats.get("meshes_after"),-                stats.get("molecule_anchored"), stats.get("silkscreen_applied"), output_path),-            "_hint": "Pull it with pull_file, then set it as component.parts.model_3d on a wiki component page. "-                     "Same optimizer as the molecule GLBs (Colby's pipeline).",-        }-    if not fetched.get("ok"):-        return {"success": False, "error": fetched.get("error"), "jobId": job_id, "stepPath": step_path,-                "statusUrl": status_url, "resultUrl": result_url}-    # Still processing after the bounded wait - hand back the job so the caller finishes it.-    return {-        "success": True, "status": fetched.get("status") or "processing",-        "pending": True, "jobId": job_id, "stepPath": step_path,-        "glbPath": output_path, "silkTop": silk_top, "silkBottom": silk_bottom, "note": silk_note,-        "statusUrl": status_url, "resultUrl": result_url,-        "message": "Optimize job %s submitted; still processing after %ds. Finish it with "-                   "fusion_fetch_optimized_glb {\"jobId\":\"%s\",\"outputPath\":\"%s\"} (re-call until complete)." % (-                       job_id, wait_s, job_id, output_path),-        "_hint": "Big boards tessellate for a few minutes. Either re-call fusion_fetch_optimized_glb "-                 "with this jobId (writes the GLB on the box when ready), or GET the resultUrl directly.",-    }---def _orchestrate_fetch_optimized_glb(args: dict) -> dict:-    """Fetch a previously-submitted optimize job's GLB (from fusion_export_optimized_glb's-    jobId). Bounded poll (default 90s); writes to outputPath when complete."""-    job_id = args.get("jobId")-    if not job_id:-        return {"success": False, "error": "No jobId.",-                "_hint": 'Usage: fusion_fetch_optimized_glb {"jobId":"...","outputPath":"C:/tmp/board.glb"}'}-    out_path = args.get("outputPath") or args.get("output_path")-    if out_path and not out_path.lower().endswith(".glb"):-        out_path = out_path + ".glb"-    wait_s = int(args.get("wait", 15))  # keep total (STEP export + wait) under the ~60s relay timeout-    res = _service_glb_fetch(job_id, out_path=out_path, poll_s=wait_s)-    status_url = "%s/jobs/%s" % (STEP2GLB_URL, job_id)-    result_url = "%s/jobs/%s/result" % (STEP2GLB_URL, job_id)-    if res.get("status") == "complete" and res.get("wrote"):-        stats = res.get("stats") or {}-        size = len(res.get("glbBytes") or b"")-        return {"success": True, "status": "complete", "glbPath": out_path, "jobId": job_id,-                "sizeBytes": size, "meshesAfter": stats.get("meshes_after"),-                "moleculeAnchored": stats.get("molecule_anchored"), "silkscreenApplied": stats.get("silkscreen_applied"),-                "message": "Optimized GLB written (%d KB) to %s" % (size // 1024, out_path)}-    if not res.get("ok"):-        return {"success": False, "error": res.get("error"), "jobId": job_id, "statusUrl": status_url, "resultUrl": result_url}-    return {"success": True, "status": res.get("status") or "processing", "pending": True, "jobId": job_id,-            "statusUrl": status_url, "resultUrl": result_url,-            "message": "Job %s still %s; re-call fusion_fetch_optimized_glb to finish." % (job_id, res.get("status") or "processing")}---def _describe_profile(p: dict) -> str:-    """Human, UNAMBIGUOUS name for a browser profile - never just 'your browser'.--    A power user has several (personal / work / media), so a demo that says "it's in your-    Chrome" is useless (John, 2026-07-20). Produce e.g.-    "Chrome - John Personal ([email protected])" or "Edge - Default"."""-    browser = (p.get("browser") or "browser").strip()-    browser = {"chrome": "Chrome", "edge": "Edge", "brave": "Brave"}.get(browser.lower(), browser.title())-    name = (p.get("displayName") or "").strip()-    email = (p.get("email") or "").strip()-    bits = browser-    if name and email and name.lower() not in email.lower():-        bits += " - %s (%s)" % (name, email)-    elif email:-        bits += " - %s" % email-    elif name:-        bits += " - %s" % name-    elif p.get("profileDir"):-        bits += " - %s" % p.get("profileDir")-    return bits---def _ad_call(verb: str, args: dict, timeout: int = 60) -> dict:-    """Call another AD verb (nbrowser_*, desktop_*) from inside this bridge.--    Prefers the in-process ad_client; falls back to the adom-desktop CLI (which joins the-    relay itself) so this still works on an unattended VM where ad_client is down. Never-    raises - returns {} on failure so callers can degrade to instructing the AI instead."""-    why = []-    try:-        if ad_client.available():-            r = ad_client.call(verb, args, timeout=timeout)-            if isinstance(r, dict):-                inner = r.get("output")-                if isinstance(inner, str) and inner.strip().startswith("{"):-                    import json as _j0-                    try:-                        return _j0.loads(inner)-                    except Exception:-                        return r-                return r-            why.append("ad_client.call returned %r" % type(r).__name__)-        else:-            why.append("ad_client unavailable")-    except Exception as e:-        why.append("ad_client raised %s" % e)-    try:-        import subprocess as _sp, json as _j-        exe = _find_adom_desktop_cli()-        if not exe:-            return {"_adCallError": "; ".join(why + ["adom-desktop CLI not found"])}-        p = _sp.run([exe, verb, _j.dumps(args)], capture_output=True, text=True, timeout=timeout)-        out = (p.stdout or "").strip()-        if out.startswith("{"):-            d = _j.loads(out)-            inner = d.get("output")-            if isinstance(inner, str) and inner.strip().startswith("{"):-                try:-                    return _j.loads(inner)-                except Exception:-                    return d-            return d-        why.append("cli stdout not json: %s" % (out[:120] or (p.stderr or "")[:120]))-    except Exception as e:-        why.append("cli raised %s" % e)-    return {"_adCallError": "; ".join(why)}---# ── FUSION AUTO-UPDATE (John, 2026-07-22) ────────────────────────────────────────────────────-# "fusion ships updates non-stop and its annoying to click this. i just always want the latest-# fusion... just make this generally invisible to me and do that by default for all other adom-# users. but let them tell you they don't want you doing that and you make that a sticky setting."-#-# So: ON BY DEFAULT for everyone, applied silently in the BACKGROUND, opt-out is sticky per user.-# Fusion's own updater downloads in the background and swaps the build on the next launch, so all-# we have to do is stop making the human click "Update Now" on a nag panel.-def _bridge_prefs_path():-    import pathlib-    d = pathlib.Path(os.path.expanduser("~")) / ".adom" / "fusion-bridge"-    d.mkdir(parents=True, exist_ok=True)-    return d / "prefs.json"---def _get_bridge_pref(key: str, default=None):-    try:-        import json as _j-        p = _bridge_prefs_path()-        if p.exists():-            v = (_j.loads(p.read_text()) or {})-            return v.get(key, default)-    except Exception:-        pass-    return default---def _set_bridge_pref(key: str, value) -> bool:-    try:-        import json as _j-        p = _bridge_prefs_path()-        cur = {}-        if p.exists():-            try: cur = _j.loads(p.read_text()) or {}-            except Exception: cur = {}-        cur[key] = value-        p.write_text(_j.dumps(cur, indent=2))-        return True-    except Exception:-        return False---def _auto_update_enabled() -> bool:-    """Default TRUE. Only a user who explicitly opted out gets False, and that sticks."""-    return bool(_get_bridge_pref("autoUpdateFusion", True))---# Buttons Fusion puts on its update nag / Job Status panel, best-first.-_UPDATE_BUTTONS = ("Update Now", "Update now", "Install Now", "Restart Now")---def _find_update_offer(hwnd=None) -> dict:-    """Is Fusion offering an update right now? Detected via UIA (background, no foreground).-    Returns {offered, button, hwnd, detail} - never raises."""-    try:-        hw = hwnd or _fusion_window_hwnd()-        if not hw:-            return {"offered": False}-        # Scan the main window AND Fusion's owned popups - the update nag lives in the-        # "View Job Status" panel, which is a CHILD window and is only in the UIA tree while it-        # is open. Checking both is why this runs on a schedule rather than once.-        candidates = [int(hw)]-        try:-            for w in (family_windows(int(hw)) or []):-                h2 = w.get("hwnd") if isinstance(w, dict) else w-                if h2 and int(h2) != int(hw):-                    candidates.append(int(h2))-        except Exception:-            pass-        for h in candidates:-            for label in _UPDATE_BUTTONS:-                r = _unwrap(_ad_call("desktop_find_control", {"hwnd": h, "name": label}, timeout=20))-                best = r.get("best") or {}-                if best.get("name") and best.get("invokable"):-                    return {"offered": True, "button": best.get("name"), "hwnd": h,-                            "detail": "Fusion is offering an update ('%s' is on screen)" % best.get("name")}-        return {"offered": False, "hwnd": hw}-    except Exception:-        return {"offered": False}---def _apply_fusion_update_silently(hwnd=None) -> dict:-    """Click Fusion's update button IN THE BACKGROUND so the download starts and the new build-    is applied on the next launch. No foreground, no caption - the user asked for this to be-    invisible. Returns {applied, button, why}."""-    if not _auto_update_enabled():-        return {"applied": False, "why": "user opted out (sticky pref autoUpdateFusion=false)"}-    offer = _find_update_offer(hwnd)-    if not offer.get("offered"):-        return {"applied": False, "why": "no update offered"}-    r = _unwrap(_ad_call("desktop_ui_click",-                         {"hwnd": int(offer["hwnd"]), "name": offer["button"]}, timeout=30))-    ok = bool(r.get("invoked") or r.get("success"))-    if ok:-        print("[Fusion Bridge] auto-update: clicked %r in the background" % offer["button"])-    return {"applied": ok, "button": offer["button"],-            "why": ("clicked %r in the background; Fusion downloads now and swaps on next launch"-                    % offer["button"]) if ok else "found the button but the UIA invoke did not take"}---def _handle_set_auto_update(fusion_info: dict, args: dict) -> dict:-    """Turn Fusion auto-updating on/off. STICKY - remembered for this user forever."""-    if "enabled" not in (args or {}):-        cur = _auto_update_enabled()-        return {"success": True, "autoUpdateFusion": cur,-                "_hint": ("Fusion auto-update is currently %s. It is ON BY DEFAULT: the bridge "-                          "clicks Fusion's 'Update Now' for the user in the BACKGROUND so they "-                          "always run the latest build and never see the nag. To opt out: "-                          "fusion_set_auto_update {\"enabled\": false} - that choice is STICKY."-                          % ("ON" if cur else "OFF")),-                "statusVerb": "fusion_readiness"}-    enabled = bool(args.get("enabled"))-    _set_bridge_pref("autoUpdateFusion", enabled)-    return {"success": True, "autoUpdateFusion": enabled,-            "narrate": ("I'll keep Fusion updated automatically in the background."-                        if enabled else-                        "I'll stop auto-updating Fusion. You'll see Autodesk's own update prompts."),-            "_hint": ("Saved and sticky (~/.adom/fusion-bridge/prefs.json). %s"-                      % ("Auto-update ON: the bridge silently clicks 'Update Now' whenever Fusion "-                         "offers one, so the user always runs the newest build. Tell them it is "-                         "handled and they never need to click it."-                         if enabled else-                         "Auto-update OFF: the bridge will NOT touch update prompts; the user "-                         "handles Autodesk's nag themselves. Do not override this.")),-            "statusVerb": "fusion_readiness"}---# Registered here, NOT in the COMMAND_HANDLERS literal: that dict is built at line ~1699,-# long before this function exists, so naming it there raised NameError at import and-# crash-looped the whole bridge (done exactly that, 2026-07-22).-COMMAND_HANDLERS["set_auto_update"] = _handle_set_auto_update---# ── AUTODESK FUSION MCP SERVER PROXY (John, 2026-07-23) ──────────────────────────────────────-# Autodesk + Anthropic shipped a Fusion MCP server: a LOCAL HTTP/JSON-RPC endpoint at-# http://127.0.0.1:27182/mcp that exposes Fusion's own text-to-CAD surface (read geometry,-# execute/update features, read Electronics design data).-#-# WHY THE BRIDGE PROXIES IT: that server binds LOOPBACK on the user's machine. It was designed-# for Claude Desktop running on the same box. An Adom AI runs in a CLOUD container and cannot-# reach the user's 127.0.0.1 at all. This bridge already runs on that machine, so it is exactly-# the right proxy: these verbs give the cloud AI Autodesk's MCP tools without reimplementing them.-#-# The server speaks MCP streamable-HTTP: you must `initialize`, capture the MCP-Session-Id-# response header, send notifications/initialized, and pass that header on every later call.-# Skipping it returns 400 "Missing MCP-Session-Id".-_MCP_URL = "http://127.0.0.1:27182/mcp"-_mcp_session_id = None-_mcp_lock = threading.Lock()---def _mcp_post(body: dict, sid: str = None, timeout: int = 30) -> dict:-    import urllib.request, urllib.error-    h = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}-    if sid:-        h["MCP-Session-Id"] = sid-    req = urllib.request.Request(_MCP_URL, method="POST",-                                 data=json.dumps(body).encode(), headers=h)-    try:-        with urllib.request.urlopen(req, timeout=timeout) as r:-            return {"status": r.status, "headers": dict(r.headers),-                    "body": r.read().decode("utf-8", "replace")}-    except urllib.error.HTTPError as e:-        return {"httpError": e.code, "headers": dict(e.headers),-                "body": e.read().decode("utf-8", "replace")[:1000]}-    except Exception as e:-        return {"error": "%s: %s" % (type(e).__name__, str(e)[:200])}---def _mcp_new_session() -> dict:-    """initialize + notifications/initialized. Returns {sessionId, serverInfo} or {error}."""-    global _mcp_session_id-    r = _mcp_post({"jsonrpc": "2.0", "id": 1, "method": "initialize",-                   "params": {"protocolVersion": "2024-11-05", "capabilities": {},-                              "clientInfo": {"name": "adom-desktop-fusion-bridge",-                                             "version": BRIDGE_VERSION}}})-    if r.get("error") or r.get("httpError"):-        return {"error": r.get("error") or ("HTTP %s" % r.get("httpError")), "raw": r.get("body")}-    sid = None-    for k, v in (r.get("headers") or {}).items():-        if k.lower() == "mcp-session-id":-            sid = v-    info = {}-    try:-        info = (json.loads(r.get("body") or "{}").get("result") or {}).get("serverInfo") or {}-    except Exception:-        pass-    if sid:-        _mcp_post({"jsonrpc": "2.0", "method": "notifications/initialized"}, sid)-        _mcp_session_id = sid-    return {"sessionId": sid, "serverInfo": info}---def _mcp_rpc(method: str, params: dict = None, timeout: int = 60) -> dict:-    """One JSON-RPC call with automatic session (re)establishment."""-    global _mcp_session_id-    with _mcp_lock:-        if not _mcp_session_id:-            s = _mcp_new_session()-            if s.get("error"):-                return {"_mcpError": s["error"]}-        sid = _mcp_session_id-    body = {"jsonrpc": "2.0", "id": 7, "method": method}-    if params is not None:-        body["params"] = params-    r = _mcp_post(body, sid, timeout=timeout)-    # a dropped/rotated session shows up as 400 Missing/Invalid session; re-init once-    if r.get("httpError") in (400, 404) and "ession" in str(r.get("body", "")):-        with _mcp_lock:-            _mcp_session_id = None-            s = _mcp_new_session()-            if s.get("error"):-                return {"_mcpError": s["error"]}-            sid = _mcp_session_id-        r = _mcp_post(body, sid, timeout=timeout)-    if r.get("error") or r.get("httpError"):-        return {"_mcpError": r.get("error") or ("HTTP %s: %s" % (r.get("httpError"), r.get("body", "")[:200]))}-    try:-        return json.loads(r.get("body") or "{}")-    except Exception:-        return {"_mcpError": "unparseable response", "raw": (r.get("body") or "")[:400]}---_MCP_OFF_STATIC = (-    "Fusion's MCP server is not reachable on 127.0.0.1:27182. It is OFF by default. The bridge "-    "can turn it on FOR the user: call fusion_mcp_enable, which opens Preferences, expands "-    "General, selects API, ticks 'Fusion MCP Server (runs locally on this device)' and clicks "-    "Apply/OK. It needs Fusion running and briefly uses the foreground (announced with an "-    "on-screen caption). Manual path if you prefer: profile icon (top right) -> Preferences -> "-    "General -> API -> tick the box. NOTE the setting does NOT survive if Fusion is killed before "-    "Apply is clicked."-)---def _mcp_off_hint() -> str:-    """The MCP-unreachable hint, made state-aware: never tell the AI to go enable MCP when APS-    is signed in and could serve the request RIGHT NOW with zero setup."""-    r = _search_router()-    lead = ""-    if r.get("apsReady"):-        lead = ("NOTE FIRST: APS is signed in, so for FILE SEARCH you do not need MCP at all - "-                "fusion_aps_search works right now. Only enable MCP if you need its other surface "-                "(script text-to-CAD, screenshots, electronics object-model reads). ")-    elif r.get("apsConfigured"):-        lead = ("NOTE: for FILE SEARCH, APS is configured and only needs fusion_aps_signin "-                "(silent on a warm SSO session) - that may be less disruptive than enabling MCP. ")-    return lead + _MCP_OFF_STATIC---def _handle_mcp_status(fusion_info: dict, args: dict) -> dict:-    """Is Autodesk's Fusion MCP server up, and what does it expose?"""-    s = _mcp_new_session()-    if s.get("error"):-        return {"success": True, "enabled": False, "url": _MCP_URL, "error": s.get("error"),-                "_hint": _mcp_off_hint(), "statusVerb": "fusion_mcp_status"}-    tools = []-    tl = _mcp_rpc("tools/list")-    if not tl.get("_mcpError"):-        tools = [{"name": t.get("name"),-                  "description": (t.get("description") or "").split("\n")[0][:160]}-                 for t in ((tl.get("result") or {}).get("tools") or [])]-    return {"success": True, "enabled": True, "url": _MCP_URL,-            "sessionId": s.get("sessionId"), "serverInfo": s.get("serverInfo"),-            "toolCount": len(tools), "tools": tools,-            "_hint": ("Autodesk's Fusion MCP server is LIVE. Its tools are Autodesk's own, not this "-                      "bridge's: call them with fusion_mcp_call {tool, arguments}. Use "-                      "fusion_mcp_tools for full input schemas and fusion_mcp_resources for the "-                      "Electronics entity schemas. These COMPLEMENT the fusion_* verbs: prefer a "-                      "native verb when one exists (it is tested and returns richer hints), and "-                      "reach for MCP for Autodesk's text-to-CAD surface."),-            "statusVerb": "fusion_mcp_status"}---def _handle_mcp_tools(fusion_info: dict, args: dict) -> dict:-    """Full tool list WITH input schemas."""-    tl = _mcp_rpc("tools/list")-    if tl.get("_mcpError"):-        return {"success": False, "enabled": False, "error": tl["_mcpError"], "_hint": _mcp_off_hint()}-    tools = (tl.get("result") or {}).get("tools") or []-    return {"success": True, "count": len(tools), "tools": tools,-            "_hint": "Call one with fusion_mcp_call {\"tool\":\"<name>\",\"arguments\":{...}}.",-            "statusVerb": "fusion_mcp_status"}---def _handle_mcp_call(fusion_info: dict, args: dict) -> dict:-    """Call ANY tool on Autodesk's Fusion MCP server."""-    tool = (args or {}).get("tool")-    if not tool:-        return {"success": False, "error": "Pass {tool, arguments}.",-                "_hint": "fusion_mcp_tools lists the available tools and their input schemas."}-    r = _mcp_rpc("tools/call", {"name": tool, "arguments": (args or {}).get("arguments") or {}},-                 timeout=int((args or {}).get("timeout") or 120))-    if r.get("_mcpError"):-        return {"success": False, "error": r["_mcpError"], "_hint": _mcp_off_hint()}-    res = r.get("result") or {}-    return {"success": not res.get("isError", False), "tool": tool, "result": res,-            "_hint": ("Autodesk MCP tool result. Content is usually a list of {type,text} blocks. "-                      "If it complains about no active document, open one first (fusion_aps_open) "-                      "- MCP acts on the ACTIVE Fusion document."),-            "statusVerb": "fusion_mcp_status"}---def _handle_mcp_resources(fusion_info: dict, args: dict) -> dict:-    """List, or read, Autodesk's MCP resources (the Electronics entity schemas)."""-    uri = (args or {}).get("uri")-    if uri:-        r = _mcp_rpc("resources/read", {"uri": uri})-        if r.get("_mcpError"):-            return {"success": False, "error": r["_mcpError"], "_hint": _mcp_off_hint()}-        return {"success": True, "uri": uri, "result": r.get("result"),-                "statusVerb": "fusion_mcp_status"}-    r = _mcp_rpc("resources/list")-    if r.get("_mcpError"):-        return {"success": False, "error": r["_mcpError"], "_hint": _mcp_off_hint()}-    res = (r.get("result") or {}).get("resources") or []-    return {"success": True, "count": len(res),-            "resources": [x.get("uri") for x in res],-            "_hint": "Read one with fusion_mcp_resources {\"uri\":\"resource://...\"}.",-            "statusVerb": "fusion_mcp_status"}---def _handle_mcp_enable(fusion_info: dict, args: dict) -> dict:-    """Turn Autodesk's MCP server ON by driving Fusion's Preferences dialog.--    Autodesk exposes NO API for this toggle (apiPreferences has debuggingPort and-    isDeveloperToolsEnabled but nothing for MCP), so the UI is the only route. Proven live-    2026-07-23. The tree children under General are rendered lazily by Qt and are NOT in the-    UIA tree, so the section click is image-space and DOES take the foreground - announced.-    """-    import time as _t-    if not _unwrap(_handle_fusion_readiness({}, {})).get("running"):-        return {"success": False, "error": "Fusion is not running.",-                "_hint": "fusion_start first, then fusion_mcp_enable."}-    already = _mcp_new_session()-    if not already.get("error"):-        return {"success": True, "enabled": True, "alreadyOn": True,-                "_hint": "Already enabled; nothing to do. fusion_mcp_status shows the tools."}--    _announce_foreground("turning on Fusion's MCP server in preferences")-    steps = []-    r = _unwrap(_ad_call("fusion_execute_text_command", {"command": "Commands.Start PreferencesCommand"}, timeout=45))-    _t.sleep(3)-    dlg = None-    for w in (_unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []):-        if str(w.get("title", "")).strip() == "Preferences":-            dlg = w.get("hwnd")-    if not dlg:-        return {"success": False, "error": "Preferences dialog did not open.",-                "steps": steps, "_hint": _mcp_off_hint()}-    steps.append("opened Preferences")--    def shot():-        s = _unwrap(_ad_call("desktop_screenshot_window", {"hwnd": int(dlg)}, timeout=45))-        cm = s.get("coordMap") or {}-        img = cm.get("image") or {}-        return cm.get("shotId"), (img.get("w") or 1400), (img.get("h") or 836)--    sid_, W, H = shot()-    if not sid_:-        return {"success": False, "error": "could not capture Preferences", "steps": steps}-    # fractional coords, measured on the real dialog (1400x836): expand arrow, then API row-    _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),-                               "x": int(W * 0.029), "y": int(H * 0.092)}, timeout=30)   # expand General-    steps.append("expanded General")-    _t.sleep(2)-    sid_, W, H = shot()-    _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),-                               "x": int(W * 0.071), "y": int(H * 0.135)}, timeout=30)   # API row-    steps.append("selected API")-    _t.sleep(2)-    sid_, W, H = shot()-    _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),-                               "x": int(W * 0.493), "y": int(H * 0.569)}, timeout=30)   # the checkbox-    steps.append("ticked 'Fusion MCP Server'")-    _t.sleep(1)-    _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),-                               "x": int(W * 0.839), "y": int(H * 0.948)}, timeout=30)   # Apply-    _t.sleep(2)-    _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),-                               "x": int(W * 0.894), "y": int(H * 0.948)}, timeout=30)   # OK-    steps.append("clicked Apply then OK")-    _t.sleep(4)--    s = _mcp_new_session()-    ok = not s.get("error")-    return {"success": ok, "enabled": ok, "steps": steps,-            "sessionId": s.get("sessionId"), "serverInfo": s.get("serverInfo"),-            "narrate": ("Fusion's MCP server is on. I enabled it for you in Preferences."-                        if ok else "I drove the Preferences dialog but the MCP port is still closed."),-            "_hint": ("MCP server ENABLED - fusion_mcp_status lists its tools. Tell the user it is "-                      "handled; they do not need to touch Preferences."-                      if ok else-                      "The toggle did not take. Ask the user to set it manually: profile icon (top "-                      "right) -> Preferences -> General -> API -> tick 'Fusion MCP Server'. Do NOT "-                      "restart or kill Fusion before they click Apply, that discards the setting."),-            "statusVerb": "fusion_mcp_status"}---COMMAND_HANDLERS["mcp_status"] = _handle_mcp_status-COMMAND_HANDLERS["mcp_tools"] = _handle_mcp_tools-COMMAND_HANDLERS["mcp_call"] = _handle_mcp_call-COMMAND_HANDLERS["mcp_resources"] = _handle_mcp_resources-COMMAND_HANDLERS["mcp_enable"] = _handle_mcp_enable---# ── FUSION PREFERENCES, DRIVEN THROUGH THE UI (John, 2026-07-23) ─────────────────────────────-# Fusion's Python API exposes only a THIN slice of preferences (generalPreferences: theme, orbit,-# units; apiPreferences: debuggingPort, isDeveloperToolsEnabled). Everything else - including the-# Fusion MCP Server toggle - is UI-only.-#-# But the Preferences dialog IS reachable programmatically:-#   Commands.Start PreferencesCommand   opens it (no menu hunting, no profile-icon click)-# and its LEFT-HAND SECTION TREE is exposed to UIA, so sections can be found by name.-#-# The catch, learned live: the CHILD sections under General (API, Design, Manufacture,-# Electronics, Render, Drawing, Simulation) are rendered LAZILY by Qt and never appear in the-# UIA tree, even after desktop_ui_expand reports success. So navigating into a child section-# needs an image-space click, which takes the foreground and is therefore announced with a-# caption. Everything up to that point is background.-#-# ⛔ The setting is DISCARDED unless Apply/OK is clicked. Killing or restarting Fusion with the-# dialog still open loses it (that is exactly why an earlier MCP enable silently reverted).-_PREF_SECTIONS = {-    # section -> (parent-to-expand or None, fractional x, fractional y) on the 1400x836 dialog-    "general":     (None,      0.071, 0.092),-    "api":         ("general", 0.071, 0.135),-    "design":      ("general", 0.071, 0.179),-    "manufacture": ("general", 0.071, 0.222),-    "electronics": ("general", 0.071, 0.265),-    "render":      ("general", 0.071, 0.310),-    "drawing":     ("general", 0.071, 0.353),-    "material":    (None,      0.071, 0.483),-    "graphics":    (None,      0.071, 0.527),-    "network":     (None,      0.071, 0.570),-    "preview features": (None, 0.071, 0.744),-}---def _prefs_dialog_hwnd():-    for w in (_unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []):-        if str(w.get("title", "")).strip() == "Preferences":-            return w.get("hwnd")-    return None---def _prefs_shot(hwnd):-    s = _unwrap(_ad_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=45))-    cm = s.get("coordMap") or {}-    img = cm.get("image") or {}-    return (cm.get("shotId"), img.get("w") or 1400, img.get("h") or 836,-            s.get("localSafePath"))---def _handle_prefs_open(fusion_info: dict, args: dict) -> dict:-    """Open Fusion Preferences, optionally navigate to a section, and hand back a screenshot the-    AI can click in. This is how you reach ANY preference, not just the API-exposed few."""-    import time as _t-    if not _unwrap(_handle_fusion_readiness({}, {})).get("running"):-        return {"success": False, "error": "Fusion is not running.",-                "_hint": "fusion_start first."}-    section = str((args or {}).get("section") or "").strip().lower()-    steps = []-    hwnd = _prefs_dialog_hwnd()-    if not hwnd:-        _announce_foreground("opening Fusion preferences")-        _ad_call("fusion_execute_text_command",-                 {"command": "Commands.Start PreferencesCommand"}, timeout=45)-        _t.sleep(3)-        hwnd = _prefs_dialog_hwnd()-        steps.append("opened Preferences")-    if not hwnd:-        return {"success": False, "error": "Preferences dialog did not open.", "steps": steps}--    if section:-        spec = _PREF_SECTIONS.get(section)-        if not spec:-            return {"success": False, "error": "Unknown section %r." % section,-                    "knownSections": sorted(_PREF_SECTIONS),-                    "_hint": ("Pass one of knownSections, or omit `section` and click the returned "-                              "shotId yourself with desktop_click {space:'image'}.")}-        parent, fx, fy = spec-        sid_, W, H, _p = _prefs_shot(hwnd)-        if parent:-            pspec = _PREF_SECTIONS[parent]-            # click the expand arrow, left of the parent label-            _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),-                                       "x": int(W * 0.029), "y": int(H * pspec[2])}, timeout=30)-            steps.append("expanded %s" % parent)-            _t.sleep(2)-            sid_, W, H, _p = _prefs_shot(hwnd)-        _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),-                                   "x": int(W * fx), "y": int(H * fy)}, timeout=30)-        steps.append("selected %s" % section)-        _t.sleep(2)--    sid_, W, H, path = _prefs_shot(hwnd)-    return {"success": True, "hwnd": hwnd, "section": section or "general",-            "shotId": sid_, "imageWidth": W, "imageHeight": H, "screenshot": path,-            "steps": steps, "knownSections": sorted(_PREF_SECTIONS),-            "_hint": ("Preferences is OPEN and showing this section. LOOK at the screenshot, then "-                      "click any control with desktop_click {space:'image', shotId, x, y, hwnd}. "-                      "⛔ Nothing is saved until you call fusion_prefs_close {save:true} (Apply+OK) "-                      "- killing or restarting Fusion first DISCARDS the change. Child sections "-                      "under General are lazily rendered and absent from the UIA tree, which is why "-                      "this verb hands you an image to click rather than control names."),-            "statusVerb": "fusion_get_preferences"}---def _handle_prefs_close(fusion_info: dict, args: dict) -> dict:-    """Apply+OK (save:true, default) or Cancel the Preferences dialog."""-    import time as _t-    save = (args or {}).get("save", True)-    hwnd = _prefs_dialog_hwnd()-    if not hwnd:-        return {"success": True, "closed": False,-                "_hint": "Preferences was not open; nothing to do."}-    sid_, W, H, _p = _prefs_shot(hwnd)-    if save:-        _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),-                                   "x": int(W * 0.839), "y": int(H * 0.948)}, timeout=30)  # Apply-        _t.sleep(2)-        _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),-                                   "x": int(W * 0.894), "y": int(H * 0.948)}, timeout=30)  # OK-    else:-        _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),-                                   "x": int(W * 0.951), "y": int(H * 0.948)}, timeout=30)  # Cancel-    _t.sleep(2)-    return {"success": True, "closed": _prefs_dialog_hwnd() is None, "saved": bool(save),-            "narrate": ("Saved your Fusion preferences." if save else "Closed preferences without saving."),-            "_hint": ("Applied and closed. Some settings (the MCP server is one) only take effect "-                      "once saved this way." if save else "Cancelled; nothing was changed."),-            "statusVerb": "fusion_get_preferences"}---COMMAND_HANDLERS["prefs_open"] = _handle_prefs_open-COMMAND_HANDLERS["prefs_close"] = _handle_prefs_close---def _fusion_signin_cfg_path():-    import pathlib-    d = pathlib.Path(os.path.expanduser("~")) / ".adom" / "fusion-signin"-    d.mkdir(parents=True, exist_ok=True)-    return d / "profile.json"---def _remembered_signin_profile():-    try:-        import json as _j-        p = _fusion_signin_cfg_path()-        if p.exists():-            return (_j.loads(p.read_text()) or {}).get("profile") or None-    except Exception:-        pass-    return None---def _remember_signin_profile(profile: str):-    try:-        import json as _j-        _fusion_signin_cfg_path().write_text(_j.dumps({"profile": profile}))-    except Exception:-        pass---def _chrome_authorize_urls(max_age_min: int = 15):-    """Scan Chrome + Edge profile History DBs for RECENT Autodesk-desktop OAuth authorize-    URLs (the Fusion Identity SDK ones - marked by `idsdk` / redirect to idmgr/callback).--    This is how we fix Fusion opening the WRONG browser profile: Fusion fires its OAuth at-    the OS-default browser, but the FULL authorize URL (client_id, PKCE challenge, state,-    request_id) lands in that browser's History. We read it back and re-open it in the-    profile the user actually authenticates Autodesk in. The URL is NOT tied to a browser-    (its redirect is accounts.autodesk.com/idmgr/callback + an autodesk:// protocol handoff-    matched by request_id), so completing it in ANY profile hands the token to the running-    Fusion. Verified live (John, 2026-07-21).--    Returns a list of {url, sourceProfileDir, browser, ageSec} newest first.-    """-    import glob, sqlite3, shutil, tempfile, time as _t-    la = os.environ.get("LOCALAPPDATA", "")-    roots = []-    if la:-        roots.append(("chrome", os.path.join(la, "Google", "Chrome", "User Data")))-        roots.append(("edge", os.path.join(la, "Microsoft", "Edge", "User Data")))-    now = _t.time()-    found = []-    for browser, root in roots:-        if not os.path.isdir(root):-            continue-        for hist in glob.glob(os.path.join(root, "*", "History")):-            prof_dir = os.path.basename(os.path.dirname(hist))-            tmp = os.path.join(tempfile.gettempdir(), "adom_hist_%s_%s.db" % (browser, prof_dir))-            try:-                shutil.copy2(hist, tmp)  # copy first - Chrome keeps History locked-                # Chrome buffers recent rows in the -wal; copy it too or a fresh authorize URL-                # is invisible (this was the bug: a just-opened sign-in did not appear).-                for ext in ("-wal", "-shm"):-                    if os.path.exists(hist + ext):-                        try: shutil.copy2(hist + ext, tmp + ext)-                        except Exception: pass-                con = sqlite3.connect(tmp)-                try: con.execute("PRAGMA journal_mode=WAL")-                except Exception: pass-                rows = con.execute(-                    "SELECT url, last_visit_time FROM urls "-                    "WHERE url LIKE '%developer.api.autodesk.com/authentication/v2/authorize%' "-                    "OR url LIKE '%idp.auth.autodesk.com/as/authorize%' "-                    "ORDER BY last_visit_time DESC LIMIT 6").fetchall()-                con.close()-            except Exception:-                continue-            for url, cts in rows:-                if "idsdk" not in url and "idmgr" not in url:-                    continue  # only the DESKTOP-app (Fusion) flow, not a web app-                epoch = (cts / 1_000_000) - 11644473600-                age = now - epoch-                if age <= max_age_min * 60:-                    found.append({"url": url, "sourceProfileDir": prof_dir,-                                  "browser": browser, "ageSec": int(age)})-    found.sort(key=lambda x: x["ageSec"])-    return found---def _fusion_window():-    """Find the main Fusion window via desktop_list_windows. Returns the whole entry (it already-    carries `rect`, so we get geometry in the SAME call - there is no desktop_window_info verb).-    Returns {} when not found."""-    wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []-    cand = {}-    for w in wins:-        t = str(w.get("title", ""))-        tl = t.lower()-        if "autodesk fusion" in tl or tl.startswith("signing in"):-            if "signing in" in tl or "welcome" in tl:   # prefer the sign-in/welcome window-                return w-            cand = cand or w-    return cand---def _fusion_window_hwnd():-    """Just the hwnd of the main Fusion window (int|None)."""-    return (_fusion_window() or {}).get("hwnd")---# ── NEVER STEAL THE USER'S FOREGROUND (John, 2026-07-22) ─────────────────────────────────────-# "try to NEVER bring windows to the foreground cuz its disruptive."-# desktop_click is SendInput and FOREGROUNDS the window. desktop_ui_click/desktop_ui_set are UIA-# Invoke/SetValue: programmatic, NO focus steal, NO cursor move, and Chromium exposes its a11y-# tree so page buttons/fields ARE reachable by accessible name. So: ALWAYS try background UIA-# first and only fall back to a foreground click for things with no UIA node (Fusion's Sign In-# button is a webview with none - that is the one documented exception).-# ── SIGN-IN WINDOW JANITOR (John, 2026-07-22) ────────────────────────────────────────────────-# "if you open a browser window for sign in, you must run a loop on a schedule to know when to-# close it so you never leave the user's desktop messy."-# We open browser windows to complete OAuth. Those windows are OURS and must not be left as-# litter once they have served their purpose. Every window we open is tracked here, and a-# background loop closes it as soon as the sign-in it belongs to is DONE (or it is clearly dead-# or too old). Never touches a window the user opened.-_signin_windows = []          # [{sessionId, profile, kind, openedAt}]-_signin_windows_lock = threading.Lock()-_JANITOR_MAX_AGE = 900-_last_update_sweep = 0.0        # 15 min: an OAuth window older than this is dead weight either way---def _track_signin_window(session_id, profile, kind="fusion"):-    if not session_id:-        return-    with _signin_windows_lock:-        _signin_windows.append({"sessionId": session_id, "profile": profile,-                                "kind": kind, "openedAt": _t2.time()})---def _close_tracked_window(w) -> bool:-    """Close one window WE opened. nbrowser_close_window only closes our own sessions."""-    r = _ad_call("nbrowser_close_window",-                 {"sessionId": w["sessionId"], "profile": w.get("profile")}, timeout=30)-    return isinstance(r, dict) and not r.get("_adCallError")---def _signin_goal_met(kind) -> bool:-    """Has the sign-in this window exists for actually completed?"""-    try:-        if kind == "aps":-            return bool(_aps_quick_state().get("signedIn"))-        return bool(_unwrap(_handle_fusion_readiness({}, {})).get("ready"))-    except Exception:-        return False---def _signin_janitor_loop():-    """Poll until each tracked sign-in window can be closed, then close it."""-    while True:-        try:-            _t2.sleep(20)-            # Periodic AUTO-UPDATE sweep. Fusion's update nag only enters the UIA tree while its-            # Job Status panel is open, so a single check at readiness time misses most of them.-            # Sweeping on a schedule catches the nag whenever Fusion actually shows it, and the-            # click is background so the user never sees any of it.-            global _last_update_sweep-            if _auto_update_enabled() and (_t2.time() - _last_update_sweep) > 300:-                _last_update_sweep = _t2.time()-                try:-                    if _unwrap(_handle_fusion_readiness({}, {})).get("running"):-                        _apply_fusion_update_silently()-                except Exception:-                    pass-            with _signin_windows_lock:-                pending = list(_signin_windows)-            if not pending:-                continue-            done_goals = {}-            for w in pending:-                kind = w.get("kind", "fusion")-                if kind not in done_goals:-                    done_goals[kind] = _signin_goal_met(kind)-                aged = (_t2.time() - w["openedAt"]) > _JANITOR_MAX_AGE-                if done_goals[kind] or aged:-                    if _close_tracked_window(w):-                        print("[Fusion Bridge] janitor: closed %s sign-in window %s (%s)"-                              % (kind, w["sessionId"], "signed in" if done_goals[kind] else "expired"))-                    with _signin_windows_lock:-                        if w in _signin_windows:-                            _signin_windows.remove(w)-        except Exception:-            continue---def _start_signin_janitor():-    t = threading.Thread(target=_signin_janitor_loop, daemon=True, name="signin-janitor")-    t.start()-    return t---def _announce_foreground(reason: str) -> bool:-    """ALWAYS tell the user WHY, right before we steal their foreground.--    John, 2026-07-22: "anytime you bring something to the foreground, you should show an ad notify-    for 2 seconds telling the user why... or an ad caption." A caption is the gentler of the two-    (no toast stack, auto-clears), so that is what we use. Best-effort; never blocks the action.-    """-    try:-        _ad_call("desktop_caption",-                 {"text": "Adom: %s" % reason, "id": "fusion-bridge-foreground",-                  "expiresInMs": 2500}, timeout=15)-        return True-    except Exception:-        return False---def _click_background_first(hwnd, name=None, shot_id=None, x=None, y=None, reason=None) -> dict:-    """Click preferring the BACKGROUND UIA path (no focus steal, no cursor move). Only falls back-    to SendInput - which DOES foreground - when there is no UIA node, and in that case announces-    WHY to the user first. Returns {clicked, via, why, announced}."""-    if name:-        r = _unwrap(_ad_call("desktop_ui_click", {"hwnd": int(hwnd), "name": name}, timeout=30))-        if r.get("ok") or r.get("clicked") or r.get("invoked"):-            return {"clicked": True, "via": "uia_background", "announced": False,-                    "why": "UIA Invoke on %r (background, no focus steal)" % name}-    if shot_id is not None and x is not None and y is not None:-        announced = _announce_foreground(reason or "clicking %s for you" % (name or "a button"))-        _ad_call("desktop_click", {"space": "image", "shotId": shot_id,-                                   "x": int(x), "y": int(y), "hwnd": int(hwnd)}, timeout=30)-        return {"clicked": True, "via": "foreground_click", "announced": announced,-                "why": "no UIA node; used SendInput (DOES foreground - user was told why)"}-    return {"clicked": False, "via": None, "announced": False,-            "why": "no UIA node and no shot coords given"}---# ── HUMAN WALLS: notify the user, and tell the AI it can drive it ────────────────────────────-# John, 2026-07-22: "you should be sending me ad notifies when you need me to do something" AND-# "suggest to the ai via hints that you can drive the thing on your own cuz the adom user wants-# you to do everything automatically if you can". So on every wall we do BOTH: toast the human,-# and hand the AI an exact, mechanical way to clear it without them.-def _autodesk_window_hwnds() -> set:-    """hwnds of every Autodesk-ish BROWSER window right now (used to tell NEW from STALE)."""-    wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []-    out = set()-    for w in wins:-        tl = str(w.get("title", "")).lower()-        if "autodesk" in tl and "fusion" not in tl:-            out.add(w.get("hwnd"))-    return out---def _detect_signin_wall(ignore_hwnds=None) -> dict:-    """Classify what the Autodesk sign-in is waiting on, from BROWSER WINDOW TITLES.-    Title-based on purpose: cheap, needs no OCR, and never touches/foregrounds the window.--    ignore_hwnds = windows that already existed BEFORE we opened this sign-in. Without it a dead-    'Sign-in request expired' / stale '2-step verification' tab from an earlier attempt gets-    reported as the live wall and we toast the user about nothing (seen live 2026-07-22).-    """-    ignore = set(ignore_hwnds or ())-    wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []-    best = {}-    for w in wins:-        if w.get("hwnd") in ignore:-            continue-        t = str(w.get("title", "")); tl = t.lower()-        if "2-step verification" in tl or "confirm sign-in" in tl:-            return {"wall": "twofactor_email_code", "hwnd": w.get("hwnd"), "title": t}-        if "identity manager" in tl:-            best = {"wall": "protocol_handoff", "hwnd": w.get("hwnd"), "title": t}-        elif "sign in - autodesk" in tl and not best:-            best = {"wall": "credentials", "hwnd": w.get("hwnd"), "title": t}-    return best---_WALL_COPY = {-    "twofactor_email_code": ("Autodesk needs your 6-digit code",-                             "Autodesk emailed a verification code to finish signing Fusion in. "-                             "Enter it in the Chrome window, or tell your AI to fetch it for you."),-    "credentials": ("Autodesk sign-in needs you",-                    "The Autodesk sign-in is asking for credentials. I never type your password "-                    "or 2FA - please complete it in the Chrome window."),-    "protocol_handoff": ("Allow Fusion to finish signing in",-                         "Chrome is asking permission to open Autodesk Identity Manager. Click "-                         "Open to hand the login back to Fusion."),-}---def _notify_signin_wall(wall: str) -> dict:-    """Toast the user's real desktop about a human wall. Best-effort, never raises."""-    title, body = _WALL_COPY.get(wall, ("Fusion sign-in needs you", "Please check the sign-in window."))-    return _cli_notify_all(title, body, "warning")---def _wall_hint(wall: str, notified: bool) -> str:-    """The AI-facing instruction for a wall. Per John: do as much as possible IN CODE, and use-    HINTS (not skills) to make the calling AI act correctly."""-    common = ("I ALREADY TOASTED THE USER about this (notifyDelivered=%s) - you do not need to ask "-              "me to, and do not nag them again. " % notified)-    if wall == "twofactor_email_code":-        return (common +-                "WALL: Autodesk emailed a 6-DIGIT CODE. THE USER WANTS YOU TO DO THIS FOR THEM if "-                "you can, so TRY THIS FIRST, do not just hand it back to them: if `adom-google` is "-                "available in YOUR container, read the code out of their Gmail and submit it - "-                "  adom-google gmail search 'from:autodesk verification code' --limit 1 \n"-                "then pull the 6 digits and call  fusion_signin_2fa {\"code\":\"123456\"}  which "-                "types it in the BACKGROUND (no foreground steal) and submits. If `adom-google` is "-                "NOT installed, TELL THE USER that installing adom-google would let you finish "-                "logins like this automatically next time, then let them type the code. NEVER ask "-                "them for the code if you can read it yourself.")-    if wall == "protocol_handoff":-        return (common +-                "WALL: Chrome's native 'Open Autodesk Identity Manager?' dialog. Try to clear it "-                "YOURSELF in the background: desktop_find_control on that dialog then "-                "desktop_ui_click {name:'Open'} (UIA, no focus steal). Only if UIA finds no node, "-                "fall back to a foregrounded desktop_click. Then poll fusion_readiness.")-    return (common +-            "WALL: the sign-in wants credentials. NEVER type their password or 2FA. If the browser "-            "autofills, you may submit it. Otherwise let the user finish in the window I opened.")---def _unwrap(r):-    """AD verb responses come back either flat or nested under `data` (the CLI puts the real-    payload in output.data). Always look through both - forgetting this silently broke the-    server-side Sign In click, because coordMap/shotId live one level down."""-    if not isinstance(r, dict):-        return {}-    inner = r.get("data")-    if isinstance(inner, dict) and inner:-        return inner-    return r---def _fusion_click_signin() -> dict:-    """Click Fusion's 'Welcome to Fusion' -> Sign In button SERVER-SIDE, ALWAYS, on the user's-    behalf (John, 2026-07-22: "you should ALWAYS just click sign in on behalf of the adom-    users"). The button is a webview with no UIA node, so this is an image-space click.--    Runs on-box, so it costs no remote round-trip against Fusion's ~2-min OAuth expiry.-    Returns {clicked: bool, why: str} - never raises.-    """-    import time as _t-    win = _fusion_window()-    hwnd = win.get("hwnd") or _unwrap(_handle_fusion_readiness({}, {})).get("hwnd")-    if not hwnd:-        return {"clicked": False, "why": "could not find the Fusion window"}--    # A MINIMIZED Fusion captures as a ~237x39 title-bar sliver, so the click lands on nothing.-    # Restore it first (seen live: rect was at -32000,-32000, the Windows minimized position).-    rect = win.get("rect") or {}-    if (rect.get("left") is not None and rect["left"] <= -30000) or \-       (0 < (rect.get("width") or 0) < 600) or (0 < (rect.get("height") or 0) < 400):-        _ad_call("desktop_set_window_state", {"hwnd": int(hwnd), "state": "restore"}, timeout=30)-        _t.sleep(1.5)--    shot = _unwrap(_ad_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=45))-    cm = shot.get("coordMap") or {}-    sid = cm.get("shotId") or shot.get("shotId")-    if not sid:-        return {"clicked": False, "why": "screenshot returned no shotId"}-    img = cm.get("image") or {}-    w = shot.get("width") or img.get("w") or img.get("width") or cm.get("imageWidth") or 1400-    h = shot.get("height") or img.get("h") or img.get("height") or cm.get("imageHeight") or 860-    # Background UIA first (no focus steal). Fusion's Sign In is a WEBVIEW button with no UIA-    # node, so this almost always falls through to the image-space click - that is the documented-    # exception to the never-foreground rule, not a licence to foreground elsewhere.-    r = _click_background_first(hwnd, name="Sign In", shot_id=sid,-                                x=int(w * 0.5), y=int(h * 0.57),-                                reason="signing Fusion in for you (clicking Sign In)")-    return {"clicked": r.get("clicked"),-            "why": "%s (image %dx%d)" % (r.get("why"), w, h),-            "via": r.get("via")}---def _capture_fresh_authorize(wait_sec: int = 30, max_age: int = 120):-    """Poll the browser History for a FRESH (< max_age s) Fusion authorize URL for up to-    wait_sec, ON-BOX. Collapsing capture into one server-side wait (instead of the caller-    re-polling over the flaky relay) is what lets the whole chain finish inside Fusion's-    ~2-min request window. Returns the auth dict or None."""-    import time as _t-    deadline = _t.time() + max(1, wait_sec)-    while True:-        urls = _chrome_authorize_urls()-        if urls and urls[0]["ageSec"] <= max_age:-            return urls[0]-        if _t.time() >= deadline:-            return urls[0] if urls else None-        _t.sleep(2)---_SSO_BUTTONS = {"google": "Continue with Google", "apple": "Continue with Apple",-                "microsoft": "Continue with Microsoft", "facebook": "Continue with Facebook"}---def _drive_sso_signin(profile: str, tab_id, email: str, provider: str = "google") -> dict:-    """Finish the Autodesk sign-in over CDP using SSO - NO password, NO 2FA, NO foreground.--    PROVEN LIVE (John, 2026-07-22) after password/2FA attempts kept stalling. If the browser-    PROFILE is already signed into the identity provider (a work Google account is the common-    case), "Continue with Google" -> pick the account -> "Open Product" completes the whole login-    silently and hands the token back to Fusion via the autodesk:// callback.--    TWO THINGS MATTER:-      1. It must be a FRESH flow. Resuming a flowId that already advanced past the provider-         choice lands you on the PASSWORD screen with no way back - that dead end cost us an hour.-      2. Click the provider BEFORE typing any email; entering an email commits to the password path.-    """-    steps = []--    def ev(expr):-        r = _unwrap(_ad_call("nbrowser_eval", {"profile": profile, "tabId": tab_id,-                                               "expression": expr}, timeout=45))-        return r.get("result") or r.get("value")--    def click(text=None, selector=None):-        a = {"profile": profile, "tabId": tab_id}-        if text: a["text"] = text-        if selector: a["selector"] = selector-        r = _unwrap(_ad_call("nbrowser_click", a, timeout=45))-        return bool(r.get("ok") or r.get("success") or r.get("changed"))--    btn = _SSO_BUTTONS.get((provider or "google").lower(), _SSO_BUTTONS["google"])-    if not click(text=btn):-        return {"done": False, "steps": steps,-                "why": "%r not on the page - this is probably NOT a fresh flow (a resumed flowId "-                       "goes straight to the password screen)." % btn}-    steps.append("clicked %r" % btn)-    _t2.sleep(7)--    # provider account chooser - pick by the exact email-    if email and click(text=email):-        steps.append("picked the %s account" % email)-        _t2.sleep(8)--    state = str(ev("JSON.stringify({u:location.href.slice(0,120),b:document.body.innerText.slice(0,200)})") or "")-    if "You're signed in" in state or "signed in" in state.lower():-        steps.append("provider returned 'You're signed in'")-        # hand the token back to Fusion (autodesk:// protocol callback)-        if click(text="Open Product"):-            steps.append("clicked 'Open Product' to hand the token to Fusion")-        return {"done": True, "steps": steps, "state": state[:200]}-    return {"done": False, "steps": steps, "state": state[:200],-            "why": "did not reach the signed-in page; a provider password/2FA may be required"}---def _orchestrate_signin_2fa(args: dict) -> dict:-    """Submit Autodesk's emailed 6-digit verification code, IN THE BACKGROUND.--    Exists so the AI can finish a login end-to-end for the user (read the code from Gmail with-    adom-google, then call this) instead of parking them on a 2FA screen. Uses UIA SetValue +-    Invoke, so it never steals focus or moves the cursor.-    """-    code = str(args.get("code") or "").strip()-    digits = "".join(ch for ch in code if ch.isdigit())-    if len(digits) < 4:-        return {"success": False, "errorCode": "bad_code",-                "error": "Pass the emailed code, e.g. fusion_signin_2fa {\"code\":\"123456\"}.",-                "_hint": ("Read it from the user's Gmail if you can: "-                          "adom-google gmail search 'from:autodesk verification code' --limit 1 "-                          "-> take the 6 digits -> call this verb again. If adom-google is not "-                          "installed, tell the user it would let you automate this."),-                "statusVerb": "fusion_signin_2fa"}--    wall = _detect_signin_wall()-    hwnd = args.get("hwnd") or wall.get("hwnd")-    if not hwnd:-        return {"success": False, "errorCode": "no_2fa_window",-                "error": "No Autodesk 2-step-verification window found.",-                "_hint": ("Nothing is waiting on a code right now. Check fusion_readiness - if "-                          "needsSignin is still true, run fusion_signin again to restart the flow."),-                "statusVerb": "fusion_readiness"}--    # Find the code field WITHOUT touching the foreground. Autodesk renders either one input or a-    # segmented set, so try the obvious accessible names, then fall back to the first edit control.-    found = _unwrap(_ad_call("desktop_find_control",-                             {"hwnd": int(hwnd), "role": "edit"}, timeout=40))-    ctrls = found.get("controls") or found.get("matches") or []-    steps = []-    filled = False-    if len(ctrls) >= len(digits) and len(ctrls) >= 4:-        # segmented: one box per digit-        for i, ch in enumerate(digits[:len(ctrls)]):-            _ad_call("desktop_ui_set", {"hwnd": int(hwnd),-                                        "automationId": ctrls[i].get("automationId"),-                                        "name": ctrls[i].get("name"), "value": ch}, timeout=20)-        filled = True-        steps.append("typed %d digits into %d segmented boxes (background UIA)" % (len(digits), len(ctrls)))-    elif ctrls:-        c = ctrls[0]-        r = _unwrap(_ad_call("desktop_ui_set", {"hwnd": int(hwnd),-                                                "automationId": c.get("automationId"),-                                                "name": c.get("name"), "value": digits}, timeout=20))-        filled = bool(r.get("ok") or r.get("set") or r is not None)-        steps.append("typed the code into %r (background UIA)" % (c.get("name") or c.get("automationId")))--    if not filled:-        return {"success": False, "errorCode": "code_field_not_found",-                "error": "Could not find the code field via UIA.",-                "controlsSeen": ctrls[:8],-                "_hint": ("The code input was not in the UIA tree (shadow DOM). Run "-                          "desktop_find_control {hwnd:%s} to see what IS exposed. Last resort is a "-                          "FOREGROUNDED desktop_click on the field then desktop_type - avoid that "-                          "if you can, foregrounding is disruptive to the user." % hwnd),-                "statusVerb": "fusion_signin_2fa"}--    sub = _click_background_first(hwnd, name="Next")-    if not sub.get("clicked"):-        sub = _click_background_first(hwnd, name="Verify")-    steps.append("submitted via %s" % (sub.get("via") or "no submit control found"))--    return {"success": True, "submitted": True, "steps": steps,-            "narrate": "I read the code in and submitted it for you, without touching your screen.",-            "_hint": ("Code submitted in the BACKGROUND. Now poll fusion_readiness until ready:true. "-                      "If a Chrome 'Open Autodesk Identity Manager?' dialog appears, clear it with "-                      "desktop_ui_click {name:'Open'} (background) - fusion_signin reports that wall "-                      "too. If it says the code was wrong, the email may have a NEWER code."),-            "statusVerb": "fusion_readiness"}---def _orchestrate_signin(args: dict) -> dict:-    """Sign Fusion in through the CORRECT browser profile - the fix for Fusion firing its-    OAuth at the OS-default browser (often the wrong Autodesk account). See the-    fusion-multiprofile-signin skill.--    Staged: pick the target profile (remembered / arg / probed / ask) -> ensure Fusion has-    emitted its OAuth URL (click Sign In if not) -> read that URL from the wrong browser's-    History -> re-open it in the TARGET profile in the BACKGROUND -> report where it waits.--    auto=True runs the whole chain in ONE server-side call: click Fusion's Sign In, poll on-box-    for the fresh authorize URL, then re-open it in the target profile - so the capture->reopen-    round-trips happen on the box (fast) and fit inside Fusion's ~2-min request expiry even when-    the remote relay is slow. This is the reliable path; the staged/manual path is the fallback.-    """-    steps = []-    # ALWAYS click Sign In for the user unless they explicitly opt out (auto:false). Adom users-    # should never be told "now go click Sign In yourself" - we do it for them and SAY so.-    auto = args.get("auto")-    auto = True if auto is None else bool(auto)-    wait_sec = int(args.get("waitSec") or 32)-    target = args.get("profile")           # explicit override, e.g. "chrome:[email protected]"-    if target:-        _remember_signin_profile(target)-    if not target:-        target = _remembered_signin_profile()--    rd = _handle_fusion_readiness({}, {})-    if not rd.get("running"):-        return {"success": True, "stage": "not_running", "done": False, "steps": steps,-                "_hint": "Fusion is not running. fusion_start, then fusion_signin.",-                "statusVerb": "fusion_signin"}-    if not rd.get("needsSignin"):-        return {"success": True, "stage": "already", "done": True, "steps": steps,-                "narrate": "Fusion is already signed in.",-                "_hint": "Already signed in (ready:%s). Nothing to do." % rd.get("ready"),-                "statusVerb": "fusion_signin"}--    # profiles known to ABE (for target selection + naming)-    profs = _ad_call("nbrowser_profiles", {}, timeout=40)-    pdata = profs.get("data", profs) if isinstance(profs, dict) else {}-    choices = [p for p in (pdata.get("profiles") or []) if isinstance(p, dict) and p.get("profile")]--    # read Fusion's authorize URL from browser history (it fires the OS-default browser)-    urls = _chrome_authorize_urls()-    fresh = urls[0] if (urls and urls[0]["ageSec"] <= 150) else None-    if auto and not fresh:-        # ONE server-side pass: click Fusion's Sign In FOR THE USER, then poll on-box for the URL.-        ck = _fusion_click_signin()-        clicked = ck.get("clicked")-        steps.append("clicked Fusion 'Sign In' for the user" if clicked-                     else "could not click Fusion 'Sign In' (%s)" % ck.get("why"))-        got = _capture_fresh_authorize(wait_sec=wait_sec, max_age=140)-        if got and got["ageSec"] <= 150:-            urls = [got]-        elif got:-            urls = [got]  # stale; fall through to the stale branch which tells us to reset-        else:-            urls = []-    if not urls:-        return {"success": True, "stage": "click_signin", "done": False, "steps": steps,-                "narrate": ("I clicked Fusion's Sign In for you, but Fusion has not written its "-                            "sign-in URL to a browser yet. Give it a few seconds."),-                "_hint": ("THIS VERB CLICKS FUSION'S 'Sign In' FOR THE USER - never tell them to go "-                          "click it themselves, and do not click it yourself. It just did (see "-                          "steps[]), but no Fusion OAuth URL has landed in any browser's history "-                          "yet. Simply CALL fusion_signin AGAIN in a few seconds; it will click if "-                          "needed, wait on-box for the URL, and re-open it in the right profile. "-                          "If steps[] says the click FAILED, that is a bridge bug worth reporting - "-                          "the usual causes are a minimized Fusion window (this verb restores it) "-                          "or the sign-in wall not being up yet (check fusion_readiness "-                          "needsSignin:true)."),-                "statusVerb": "fusion_signin"}-    auth = urls[0]-    steps.append("captured Fusion OAuth URL from %s profile '%s' (%ss old)"-                 % (auth["browser"], auth["sourceProfileDir"], auth["ageSec"]))-    # Fusion's sign-in request EXPIRES in ~2 min. A URL older than that completes the LOGIN but-    # Fusion no longer waits on its request_id -> "Sign-in request expired", no handoff. Never-    # silently reuse it; force a fresh request. (Learned live 2026-07-21.)-    if auth["ageSec"] > 150:-        return {"success": True, "stage": "stale", "done": False, "steps": steps,-                "signinAuthUrl": auth["url"], "signinAgeSec": auth["ageSec"],-                "_hint": ("The newest Fusion sign-in URL is %ss old - Fusion expires the request in "-                          "~2 min, so completing it yields 'Sign-in request expired' and NO handoff. "-                          "Get a FRESH request: fusion_stop then fusion_start (resets Fusion to a "-                          "clean 'Welcome to Fusion'), click Fusion's Sign In, and call fusion_signin "-                          "again PROMPTLY. Completion is near-instant when the target profile is "-                          "already signed into Autodesk (auto-consents). Skill: "-                          "fusion-multiprofile-signin." % auth["ageSec"]),-                "statusVerb": "fusion_signin"}--    # choose the TARGET profile if not already fixed-    reason = ""-    if not target:-        # probe each live profile for a real Autodesk session; prefer signed-in, then work over consumer-        for c in choices:-            if not c.get("live"):-                continue-            ls = _ad_call("nbrowser_login_state",-                          {"profile": c["profile"], "url": "https://accounts.autodesk.com/"}, timeout=40)-            lsd = ls.get("data", ls) if isinstance(ls, dict) else {}-            c["_ad"] = "signed-in" if lsd.get("loggedIn") else ("signed-out" if lsd.get("ok") is not None else "unprobed")-            c["_conf"] = lsd.get("confidence"); c["_cookies"] = lsd.get("cookieCount")-        signed = [c for c in choices if c.get("_ad") == "signed-in"]-        if signed:-            signed.sort(key=lambda c: ({"high": 3, "medium": 2, "low": 1}.get(c.get("_conf"), 0), c.get("_cookies") or 0), reverse=True)-            target = signed[0]["profile"]; reason = "it holds a live Autodesk session"-        else:-            _CONSUMER = ("gmail.com", "outlook.com", "hotmail.com", "yahoo.com", "icloud.com", "live.com")-            work = [c for c in choices if c.get("live") and c.get("email") and not any((c["email"] or "").lower().endswith("@" + d) for d in _CONSUMER)]-            if work:-                target = work[0]["profile"]; reason = "it is a work/corporate profile (no live Autodesk session detected - CONFIRM)"-    if target:-        _remember_signin_profile(target)-        reason = reason or "you told me to use it"--    if not target:-        return {"success": True, "stage": "ask_profile", "done": False, "steps": steps,-                "signinAuthUrl": auth["url"], "profileChoices": choices,-                "_hint": ("Captured Fusion's OAuth URL but cannot tell which profile is your Autodesk "-                          "account. ASK the user which profile their Autodesk login belongs to, then "-                          "call fusion_signin {profile:'chrome:<their-account>'} - I will remember it. "-                          "profileChoices lists every profile."),-                "statusVerb": "fusion_signin"}--    tdesc = next((_describe_profile(c) for c in choices if c.get("profile") == target), target)-    import time as _tt-    sess = "fusion-signin-%d" % int(_tt.time())   # unique per attempt (a reused id can be refused)-    # ── Is Fusion ALREADY using the right profile? (John, 2026-07-22: "they both opened in my-    # work profile, so you're being lazy") ──────────────────────────────────────────────────-    # Chrome profile DIRECTORIES are named Default / Profile 1 / Profile 2, and the user's work-    # account is very often just "Default". We capture the authorize URL from a profile DIR, but-    # the target is an EMAIL (chrome:[email protected]). Without mapping email -> dir we cannot tell-    # they are the SAME profile, so we "fixed" a non-problem by re-opening the identical URL in-    # the identical profile - a redundant second sign-in window on the user's screen.-    target_dir = None-    for c in choices:-        if c.get("profile") == target:-            target_dir = c.get("profileDir") or c.get("dir")-            break-    if target_dir and auth.get("sourceProfileDir") and \-       str(target_dir).strip().lower() == str(auth["sourceProfileDir"]).strip().lower():-        steps.append("Fusion already opened the sign-in in %s (profile dir %r) - no re-open needed"-                     % (target, target_dir))-        wall0 = _detect_signin_wall()-        notified0 = bool(_notify_signin_wall(wall0["wall"]).get("delivered")) if wall0.get("wall") else False-        return {"success": True, "stage": "already_right_profile", "done": False, "steps": steps,-                "signinProfile": target, "signinProfileDir": target_dir,-                "signinWhere": tdesc if False else target,-                "signinWall": wall0.get("wall"), "signinWallHwnd": wall0.get("hwnd"),-                "notifyDelivered": notified0,-                "signinWallHint": _wall_hint(wall0["wall"], notified0) if wall0.get("wall") else None,-                "signinAuthUrl": auth["url"],-                "narrate": ("Fusion opened its sign-in in the RIGHT profile already (%s), so I did "-                            "not open a second window." % target),-                "_hint": ("NO re-open was needed - Fusion's OS-default browser IS the user's Autodesk "-                          "profile here (dir %r). Do NOT open another window; that just litters their "-                          "screen with a duplicate sign-in. The existing window is the one to finish. "-                          "%s" % (target_dir,-                                  _wall_hint(wall0["wall"], notified0) if wall0.get("wall")-                                  else "Poll fusion_readiness until ready:true.")),-                "statusVerb": "fusion_readiness"}--    # snapshot the Autodesk windows that already exist, so a STALE expired tab from an earlier-    # attempt is never mistaken for this attempt's wall-    pre_hwnds = _autodesk_window_hwnds()-    _open_args = {"sessionId": sess, "url": auth["url"], "background": True,-                  "profile": target, "thread": "fusion-signin",-                  "purpose": "Fusion Autodesk sign-in (correct profile)"}-    r = _ad_call("nbrowser_open_window", _open_args, timeout=60)-    rd_open = r.get("data", r) if isinstance(r, dict) else {}-    opened = bool(rd_open.get("sessionId") or r.get("ok") or r.get("success")-                  or rd_open.get("opened") or ("opened in the BACKGROUND" in str(r.get("_hint", ""))))-    open_via = "extension" if opened else None-    if not opened:-        # EXTENSION-FREE fallback: nbrowser_open_window needs the Adom extension installed in-        # that profile. nbrowser_open_os_window does not - it opens the URL in the real profile-        # at the OS level, which is all an OAuth consent needs. This keeps the multi-profile fix-        # working on machines with no extension (same gap as APS issue #12).-        r2 = _ad_call("nbrowser_open_os_window", _open_args, timeout=60)-        if isinstance(r2, dict) and not r2.get("_adCallError"):-            # ok:false just means the hwnd was not resolved in ~8s; the launch usually still fired-            opened = bool(r2.get("ok") or r2.get("hwnd") or "Launch fired" in str(r2.get("_hint", "")))-            if opened:-                open_via = "extension_free"-                r = r2-    steps.append((("re-opened it in %s%s" % (tdesc, " (extension-free)" if open_via == "extension_free" else ""))-                  if opened else ("could not open %s (raw: %s)" % (tdesc, str(r)[:160]))))-    if opened:-        # hand it to the janitor so it is never left as litter on the user's desktop-        _track_signin_window(sess, target, kind="fusion")--    # ── FINISH IT: drive the SSO path ourselves (no password, no 2FA, no foreground) ──────────-    # John, 2026-07-22: the goal is a SIGNED-IN Fusion, not a handed-off checklist. When we own-    # the window (nbrowser_open_window => agent-opened => CDP-drivable) and the profile is already-    # signed into the identity provider, this completes the whole login silently.-    sso = None-    if opened and open_via == "extension" and args.get("driveSso", True):-        tabs = _unwrap(_ad_call("nbrowser_list_tabs", {"profile": target}, timeout=45)).get("tabs") or []-        my_tab = None-        for t in tabs:-            if "autodesk" in str(t.get("url", "")).lower() and t.get("owner") == "agent-opened":-                my_tab = t.get("tabId") or t.get("id")-        if my_tab:-            email = None-            for c in choices:-                if c.get("profile") == target:-                    email = c.get("email")-            sso = _drive_sso_signin(target, my_tab, email, args.get("ssoProvider", "google"))-            steps.extend(sso.get("steps") or [])-            if sso.get("done"):-                for _ in range(6):-                    _t2.sleep(5)-                    if _unwrap(_handle_fusion_readiness({}, {})).get("ready"):-                        steps.append("Fusion reports ready:true - SIGNED IN")-                        # ── ONE LOGIN, BOTH SYSTEMS (John, 2026-07-22) ───────────────────────-                        # "why should the user have to login twice to autodesk to sign in to-                        # fusion and setup aps? why aren't those just driven from 1 login?"-                        # Right now the browser profile has a WARM Autodesk SSO session (we just-                        # used it). APS consent rides that session silently, so do it HERE while-                        # it is warm instead of making the user authenticate a second time.-                        aps_state = _aps_quick_state()-                        if aps_state.get("configured") and not aps_state.get("signedIn"):-                            try:-                                import aps as _apsmod-                                _apsmod.start_signin({"profile": target})-                                steps.append("APS not signed in - started its consent on the SAME "-                                             "warm SSO session (no second login for the user)")-                                for _ in range(5):-                                    _t2.sleep(4)-                                    if _aps_quick_state().get("signedIn"):-                                        steps.append("APS signed in too - one login covered both")-                                        break-                            except Exception as e:-                                steps.append("APS auto-setup skipped (%s)" % str(e)[:80])-                        aps_state = _aps_quick_state()-                        return {"success": True, "stage": "signed_in", "done": True, "steps": steps,-                                "signinProfile": target, "signinWhere": tdesc,-                                "signinOpenedVia": open_via,-                                "aps": aps_state,-                                "narrate": ("Fusion is signed in as %s. No password or 2FA was needed - "-                                            "I used the browser profile's existing SSO session.%s"-                                            % (email or target,-                                               " APS cloud search is set up too, from the same login."-                                               if aps_state.get("signedIn") else "")),-                                "_hint": ("DONE - Fusion is signed in (ready:true). Nothing else to do; "-                                          "do not tell the user to sign in. %s Drive Fusion normally now."-                                          % ("APS is signed in as well, so fusion_aps_search is live."-                                             if aps_state.get("signedIn") else-                                             "APS is NOT signed in - run fusion_aps_signin NOW while the "-                                             "browser SSO session is still warm so it consents silently.")),-                                "statusVerb": "fusion_readiness"}--    # Did the reopened sign-in immediately hit a HUMAN WALL (2FA, credentials, protocol dialog)?-    # If so, toast the user AND tell the AI how to clear it for them.-    wall = _detect_signin_wall(ignore_hwnds=pre_hwnds) if opened else {}-    wall_kind = wall.get("wall")-    notified = False-    if wall_kind:-        notified = bool(_notify_signin_wall(wall_kind).get("delivered"))-        steps.append("hit %s wall; toasted the user (delivered=%s)" % (wall_kind, notified))--    return {"success": True, "stage": "opened" if opened else "open_failed",-            "done": False, "steps": steps, "signinWhere": tdesc, "signinProfile": target,-            "signinOpenedVia": open_via,-            "signinWall": wall_kind, "signinWallHwnd": wall.get("hwnd"),-            "notifyDelivered": notified,-            "signinWallHint": _wall_hint(wall_kind, notified) if wall_kind else None,-            "signinProfileReason": reason, "signinSession": sess if opened else None,-            "signinWrongBrowser": "%s / %s" % (auth["browser"], auth["sourceProfileDir"]),-            "signinAuthUrl": auth["url"],-            "narrate": (("Fusion opened its sign-in in the WRONG browser (%s), so I moved the real "-                         "Autodesk sign-in URL into %s - I picked that because %s. Finish it there, "-                         "or I can drive it, and Fusion will pick up the login automatically."-                         % (auth["sourceProfileDir"], tdesc, reason)) if opened else-                        "I captured Fusion's sign-in URL but could not open the target profile."),-            "_hint": (("The REAL Fusion OAuth URL is now open in %s (session 'fusion-signin'). It "-                       "completes back to the running Fusion via the idmgr/callback + autodesk:// "-                       "protocol handoff (request_id match), so the browser profile no longer has to "-                       "be the OS default - THIS is the fix for Fusion signing into the wrong "-                       "account. TELL the user the exact profile (never 'your browser'), say WHY it "-                       "was chosen (signinProfileReason), and offer: they finish it, you foreground "-                       "it, or (with their OK) you drive 'Continue with Google/Apple/Microsoft'. "-                       "NEVER type their password/2FA. Then poll fusion_readiness until ready:true. "-                       "The wrong-browser tab (%s) can be closed. If the wrong ACCOUNT completes "-                       "anyway, sign Fusion out and re-run. Skill: fusion-multiprofile-signin."-                       % (tdesc, auth["sourceProfileDir"])) if opened else-                      "Open failed: %s. Retry, or open auth url yourself with nbrowser_open_window "-                      "{profile:'%s', url:<signinAuthUrl>}." % (r.get("_adCallError"), target)),-            "statusVerb": "fusion_signin"}---def _orchestrate_demo(args: dict) -> dict:-    """FIRST-TIME-USER DEMO: take a brand-new user from nothing to "wow" in one verb.--    Written because HD's installer ran a "demo" that just opened Fusion and SAT on the-    sign-in page doing nothing (John, 2026-07-20). A demo must actually finish the sign-in,-    set up APS, and SHOW the user real work: an electronics PROJECT -> SCHEMATIC -> 2D BOARD-    -> 3D BOARD, plus a live APS cloud search.--    STAGED + RESUMABLE. Each call advances as far as it safely can and returns:-      stage      - what it just did / is waiting on-      done       - whether the demo finished-      narrate    - a first-time-user-friendly line the CALLER should say + toast-      screenshots- images to show the user for this stage-      _hint      - exactly what the AI must do next (including nbrowser_* for the browser leg)--    The bridge owns Fusion; it does NOT own the browser. So when the sign-in needs a browser-    we hand the caller precise `nbrowser_*` (ABE) instructions - the user's NATIVE browser is-    already signed into Autodesk, whereas pup is anonymous and would force a fresh login.-    """-    stage = (args.get("stage") or "auto").strip()-    demo_query = args.get("query") or ""          # optional: which design to demo-    steps: list = []-    shots: list = []--    def _shot(label):-        try:-            r = _handle_screenshot_fusion({}, {})-            if r.get("success") and r.get("localSafePath"):-                shots.append({"label": label, "path": r.get("localSafePath")})-        except Exception:-            pass--    def _out(stage_name, narrate, hint, done=False, **extra):-        return {"success": True, "demo": True, "stage": stage_name, "done": done,-                "steps": steps, "narrate": narrate, "screenshots": shots,-                "statusVerb": "fusion_demo", "_hint": hint, **extra}--    # ── 1. Fusion present + running ─────────────────────────────────────────────-    rd = _handle_fusion_readiness({}, {})-    if not rd.get("installed"):-        return _out("not_installed",-            "Fusion 360 isn't installed yet - want me to install it? It's a free 30-day trial with "-            "everything switched on, so the whole tour works: real boards, schematics, 3D, Gerber and "-            "BOM exports, cloud search. After the trial, Fusion for Personal Use stays free for "-            "non-commercial work (hobby PCBs up to 2 layers), and even an expired install can still "-            "OPEN and VIEW your designs.",-            "OFFER the install - do not just report it is missing. Say what they GET: the free 30-day "-            "trial is FULL-FEATURED (every step of this demo works: electronics, schematic, 2D/3D "-            "board, Gerbers/BOM/CPL, APS cloud search). Be honest about after: Fusion for Personal Use "-            "is free for non-commercial use but LIMITS electronics (about 2 layers / 2 schematic "-            "sheets / small board area) and some exports; an expired or read-only install can still "-            "OPEN + VIEW + browse designs (it only blocks save/export/modify), so their work is never "-            "locked away. Then call fusion_install_fusion (no shell approval needed; streams the "-            "installer, 10-30 min), poll fusion_readiness until installed:true, and call fusion_demo "-            "again to continue the tour. Full flow: the fusion-onboarding skill.")-    if not rd.get("running"):-        steps.append("launched Fusion")-        _handle_launch({}, {})-        rd = _handle_fusion_readiness({}, {})--    # ── 2. SIGN-IN: finish it, do not sit on it ─────────────────────────────────-    if rd.get("needsSignin"):-        # Delegate to the multi-profile sign-in fix (reads Fusion's OAuth URL out of the WRONG-        # default browser and re-opens it in the RIGHT profile). Returns its own rich stage.-        si = _orchestrate_signin({"profile": args.get("signinProfile")})-        si["demo"] = True-        if si.get("stage") in ("opened", "click_signin", "ask_profile", "open_failed"):-            si.setdefault("done", False)-            si["_hint"] = (si.get("_hint", "") + "  (This is the fusion_demo sign-in stage - after "-                           "the user is signed in and fusion_readiness is ready:true, call fusion_demo "-                           "again to continue the tour: APS -> project -> schematic -> 2D -> 3D.)")-            return si-        # DO IT, don't just describe it: open the Autodesk sign-in in the user's OWN browser,-        # in the BACKGROUND so we never yank them out of what they're doing. Then tell the AI-        # exactly WHERE it is waiting and offer the three ways forward.-        nb = _ad_call("nbrowser_readiness", {}, timeout=40)-        nb_data = nb.get("data", nb) if isinstance(nb, dict) else {}-        nb_state = nb_data.get("state")-        opened, where, profile_used = False, "", ""-        pick_reason, ambiguous = "", False-        choices = []-        if nb_state == "ready":-            profs = _ad_call("nbrowser_profiles", {}, timeout=40)-            pdata = profs.get("data", profs) if isinstance(profs, dict) else {}-            # profiles are DICTS: {profile,label,email,browser,displayName,profileDir,active,live,...}-            for p in (pdata.get("profiles") or []):-                if not isinstance(p, dict):-                    continue-                if p.get("blocked") or p.get("unresolved") or not p.get("profile"):-                    continue-                choices.append({-                    "profile": p.get("profile"), "browser": (p.get("browser") or "").lower(),-                    "email": p.get("email") or "", "displayName": p.get("displayName") or "",-                    "profileDir": p.get("profileDir") or "",-                    "active": bool(p.get("active")), "live": bool(p.get("live")),-                    "asleep": bool(p.get("asleep")),-                    "extensionInstalled": p.get("extensionInstalled", True),-                    "describe": _describe_profile(p),-                })-            # PROBE each profile for a REAL Autodesk session - never guess by "active".-            # (John, 2026-07-20: picking the active profile grabbed his PERSONAL Chrome with-            # zero analysis. A power user's Autodesk login can live in any profile, so ASK THE-            # BROWSER, don't assume.) nbrowser_login_state reports auth cookies per profile.-            for c in choices:-                if not c["live"]:-                    c["autodesk"] = "unprobed(asleep)"-                    continue-                ls = _ad_call("nbrowser_login_state",-                              {"profile": c["profile"], "url": "https://accounts.autodesk.com/"},-                              timeout=45)-                lsd = ls.get("data", ls) if isinstance(ls, dict) else {}-                if lsd.get("ok") is None and lsd.get("loggedIn") is None:-                    c["autodesk"] = "unprobed"-                else:-                    c["autodesk"] = "signed-in" if lsd.get("loggedIn") else "signed-out"-                c["autodeskConfidence"] = lsd.get("confidence")-                c["autodeskCookies"] = lsd.get("cookieCount")-            _CONF = {"high": 3, "medium": 2, "low": 1}-            signed = [c for c in choices if c.get("autodesk") == "signed-in"]-            signed.sort(key=lambda c: (_CONF.get(c.get("autodeskConfidence"), 0),-                                       c.get("autodeskCookies") or 0), reverse=True)-            pick = signed[0] if signed else None-            if pick:-                pick_reason = ("it is the profile actually signed into Autodesk (%s auth cookies, %s "-                               "confidence)" % (pick.get("autodeskCookies"), pick.get("autodeskConfidence")))-            else:-                # NOTHING is signed in - do not silently guess. Prefer a work/corporate identity-                # over a consumer mailbox (Autodesk seats are usually work accounts), but SAY SO-                # and offer the alternatives.-                _CONSUMER = ("gmail.com", "outlook.com", "hotmail.com", "yahoo.com", "icloud.com", "live.com")-                def _work_first(c):-                    em = (c.get("email") or "").lower()-                    return (0 if (em and not any(em.endswith("@" + d) for d in _CONSUMER)) else 1,-                            0 if c["live"] else 1)-                ranked = sorted([c for c in choices if c["live"]] or choices, key=_work_first)-                pick = ranked[0] if ranked else None-                pick_reason = ("no profile has a live Autodesk session, so I picked the most likely "-                               "one (work/corporate identity first) - CONFIRM WITH THE USER")-            ambiguous = not signed or len(signed) > 1-            if pick:-                profile_used = pick["profile"]-                where = pick["describe"]-            oa = {"sessionId": "fusion-signin", "url": "https://accounts.autodesk.com/",-                  "background": True, "thread": "fusion-demo", "purpose": "Autodesk sign-in for Fusion"}-            if profile_used:-                oa["profile"] = profile_used-            r = _ad_call("nbrowser_open_window", oa, timeout=60)-            opened = bool((r.get("data", r) if isinstance(r, dict) else {}).get("sessionId") or r.get("ok") or r.get("success"))-            if not where:-                where = "your browser"-        if opened:-            steps.append("opened Autodesk sign-in in %s (background)" % (where or "browser"))-        else:-            steps.append("could not auto-open the browser sign-in: %s" % (nb.get("_adCallError") or nb_state or "unknown"))-        if opened:-            narrate = ("I've opened the Autodesk sign-in in %s - I chose that profile because %s. "-                       "It's sitting in the background so it won't interrupt what you're doing. "-                       "Finish it whenever you like, tell me to bring it up front, or I can complete "-                       "it for you. If that's the wrong account, say which profile to use."-                       % (where, pick_reason))-        else:-            narrate = ("Fusion needs your Autodesk sign-in. I'd like to use your own browser - you're "-                       "probably already signed in there, so it should take one click.")-        return _out("signin", narrate,-            ("FINISH THE SIGN-IN - never leave a first-time user parked here. " +-             ("A background sign-in tab is ALREADY OPEN in %s (session 'fusion-signin'). " % where-              if opened else "Auto-open failed - open it yourself: nbrowser_open_window "-              "{sessionId:'fusion-signin', url:'https://accounts.autodesk.com/', background:true, "-              "profile:'<chrome:their-account>'}. ") +-             "The profile was CHOSEN BY EVIDENCE, not guessed: each live profile was probed with "-             "nbrowser_login_state against accounts.autodesk.com, and the one holding a real Autodesk "-             "session wins (see signinProfileReason + per-profile autodesk/autodeskConfidence/"-             "autodeskCookies in signinProfileChoices). RELAY THAT REASON to the user. If "-             "signinProfileAmbiguous is true (nothing signed in, or several are), ASK them which "-             "account their Autodesk login belongs to instead of assuming. "-             "NAME THE EXACT PROFILE - never say just 'your browser' or 'your Chrome'. Power users "-             "run several profiles (personal / work / media), so say the browser AND the identity "-             "from `signinWhere` (e.g. 'Chrome - John Personal ([email protected])'). "-             "`signinProfileChoices` lists every profile with describe/email/displayName/active - if "-             "there is more than one, say which you used and OFFER TO SWITCH (re-run with a different "-             "profile). If the one you want shows extensionInstalled:false it needs the one-time "-             "extension install in THAT profile. "-             "TELL THE USER WHERE IT IS WAITING and OFFER ALL THREE: (a) they finish it themselves "-             "whenever they want, (b) you foreground that window for them "-             "(nbrowser_switch_window / browser window state), or (c) THEY LET YOU DRIVE IT - ask "-             "first (AskUserQuestion), then click through 'Continue with Google/Apple/Microsoft' "-             "using their warm session. Use the NATIVE browser (ABE), NEVER pup: pup is anonymous so "-             "Autodesk demands a full fresh login, while their real profile is usually already signed "-             "in. NEVER type their password or 2FA - if a secret is demanded, fusion_notify_owner "-             "toasts them to type it. Then click Fusion's own 'Sign In' (the webview exposes no UIA "-             "control, so use an image-space desktop_click on the button, or foreground + click) to "-             "fire the OAuth handoff; accept the 'Autodesk Identity Manager' overlay ('Always allow' "-             "+ 'Open'). Codes expire in ~2 min - if it expires, click Sign In again. Poll "-             "fusion_readiness until ready:true, then call fusion_demo again. Playbook: "-             "fusion-autodesk-signin."),-            needsSignin=True, signinOpened=opened, signinWhere=where,-            signinProfile=profile_used, signinSession="fusion-signin" if opened else None,-            signinProfileChoices=choices, signinProfileReason=pick_reason,-            signinProfileAmbiguous=ambiguous, nbState=nb_state)--    if not rd.get("ready"):-        return _out("waiting",-            "Fusion is starting up - one moment.",-            "Not ready yet (add-in still loading, or a licensing dialog). Poll fusion_readiness; it "-            "auto-resolves seat dialogs. When ready:true, call fusion_demo again. Reason: " +-            str(rd.get("_hint", ""))[:220])-    steps.append("Fusion ready")--    # ── 3. APS: set it up if we can, otherwise SELL it (never skip silently) ─────-    aps_ready = False-    try:-        ap = aps.handle_status({}).get("data", {})-        aps_ready = bool(ap.get("configured") and ap.get("signedIn") and ap.get("tokenLive"))-    except Exception:-        ap = {}-    if not aps_ready and stage != "skip_aps":-        return _out("aps_setup",-            "Next I'll turn on cloud search - it finds any design in your Autodesk cloud in about "-            "two seconds instead of a 30-minute folder crawl. It's a one-time sign-in.",-            "SET APS UP FOR THEM - do not merely mention it. (1) fusion_aps_status for exact state. "-            "(2) If not configured, an admin registers a PKCE app once, then fusion_aps_set_client_id. "-            "(3) fusion_aps_signin - drive it in their NATIVE browser (ABE), same reasoning as the "-            "Fusion sign-in: they're already logged into Autodesk there. (4) Poll fusion_aps_status "-            "until tokenLive:true, then call fusion_demo again - the demo then runs a LIVE sample "-            "search so they SEE the speed. If they decline setup, call fusion_demo {stage:'skip_aps'} "-            "and TELL them what they're missing (2s server-indexed search across the whole team hub; "-            "the old in-app search took 30+ min and crashed Fusion, so it is disabled). "-            "Skills: fusion-aps-search, fusion-aps-signin.",-            apsConfigured=bool(ap.get("configured")), apsSignedIn=bool(ap.get("signedIn")))--    # ── 4. APS sample search - let them SEE the speed ───────────────────────────-    aps_hits = []-    if aps_ready:-        try:-            import time as _t-            t0 = _t.time()-            sr = aps.handle_search({"query": demo_query or "board", "limit": 5}).get("data", {})-            aps_hits = [{"name": r.get("name"), "project": r.get("projectName")}-                        for r in (sr.get("results") or [])[:5]]-            steps.append("APS sample search: %d hits in %.1fs" % (len(aps_hits), _t.time() - t0))-        except Exception as e:-            steps.append("APS sample search failed: %s" % e)--    # ── 5. Open an ELECTRONICS design, then walk schematic -> 2D -> 3D ──────────-    st = _proxy_to_addin("get_app_state", {}, timeout=20) or {}-    doc = (st.get("data") or st).get("activeDocument")-    is_elec = bool((st.get("data") or st).get("isElectronics"))-    if not is_elec:-        target = demo_query or (aps_hits[0]["name"] if aps_hits else "")-        if not target:-            return _out("need_design",-                "I need an electronics design to show off. Which board should I open?",-                "No electronics design open and nothing to pick. If APS is live, "-                "fusion_aps_search {query:'<board>'} then fusion_aps_open {query:'<name>'}; else ask "-                "the user for a design name / open one via fusion_open_cloud_file. ALWAYS open the "-                "PROJECT (EcadDesignProductType), never a .brd/.sch/3D child. Then call fusion_demo again.")-        return _out("open_design",-            "Opening %s so you can see a real board end to end." % target,-            "OPEN THE PROJECT then re-call fusion_demo: fusion_aps_open {query:'%s'} (APS finds it at "-            "any folder depth in seconds). CRITICAL: open the electronics PROJECT file, not the "-            "schematic/.brd/3D child - a child opens an isolated, often empty view. Poll "-            "fusion_get_app_state until isElectronics:true, then call fusion_demo again." % target,-            target=target)--    steps.append("electronics project open: %s" % doc)-    _shot("project")--    # schematic -> 2D board -> 3D board, screenshotting each so the AI can SHOW them-    try:-        _proxy_to_addin("show_schematic", {}, timeout=90); steps.append("showed schematic"); _shot("schematic")-    except Exception as e:-        steps.append("schematic failed: %s" % e)-    try:-        _proxy_to_addin("show_2d_board", {}, timeout=90); steps.append("showed 2D board"); _shot("board_2d")-    except Exception as e:-        steps.append("2D board failed: %s" % e)-    try:-        _proxy_to_addin("show_3d_board", {}, timeout=180); steps.append("showed 3D board"); _shot("board_3d")-    except Exception as e:-        steps.append("3D board failed: %s" % e)--    aps_line = ("Cloud search is live - I searched your whole Autodesk hub in about two seconds and "-                "found: %s. " % ", ".join(h["name"] for h in aps_hits[:3])) if aps_hits else ""-    return _out("done",-        "That's the tour: your %s project, its schematic, the 2D board layout, and the real 3D board. "-        "%sFrom here just ask - export Gerbers/BOM/CPL for the fab, generate a laser-etched IPC "-        "package, load JLCPCB design rules, or open any design in your cloud." % (doc, aps_line),-        "DEMO COMPLETE. SHOW the user the screenshots in order (project, schematic, board_2d, "-        "board_3d) - they are in `screenshots` with labels. Narrate the `narrate` line. Then offer "-        "concrete next steps in THEIR words: 'export the Gerbers', 'make me a SOIC-8 with my part "-        "number etched on it' (fusion_generate_package), 'check this against JLCPCB rules' "-        "(fusion_load_design_rules), 'find my other boards' (fusion_aps_search). Full catalog: "-        "fusion_describe. Demo playbook: the fusion-demo skill.",-        done=True, document=doc, apsSampleSearch=aps_hits)---def _orchestrate_board_stackup(args: dict) -> dict:-    """Read a board's PHYSICAL fabrication stackup (see the pcb-stackup skill). A PCB is a-    stack of copper + dielectric: Cu(L1) / prepreg / Cu(L2) / core / ... / Cu(bottom). This-    reads the copper count + copper/dielectric thicknesses from the EAGLE design rules-    (layerSetup / mtCopper / mtIsolate in the .brd) and the measured FR4 extent from the 3D-    body, and returns the ordered layer list (name, thickness, z) so a caller can build the-    stackup table + the real-thickness exploded 3D view."""-    import re as _re-    import tempfile as _tf-    # 1) design rules from the EAGLE .brd (board editor active)-    _proxy_to_addin("show_2d_board", {}, timeout=60)-    brd = os.path.join(_tf.gettempdir(), "adom_stackup.brd")-    r = _proxy_to_addin("export_eagle_source", {"outputPath": brd}, timeout=120)-    if not r.get("success"):-        return {"success": False, "error": "could not export .brd for stackup: %s" % (r.get("error") or r.get("message")),-                "_hint": "Open the PROJECT and switch to the board (fusion_show_2d_board) first."}-    try:-        txt = open(brd, encoding="latin1", errors="replace").read()-    except Exception as e:-        return {"success": False, "error": "could not read .brd: %s" % e}-    def _p(name):-        m = _re.search(r'<param name="%s" value="([^"]*)"' % name, txt)-        return m.group(1) if m else None-    layer_setup = _p("layerSetup") or ""-    mt_copper = [x for x in (_p("mtCopper") or "").split()]-    mt_isolate = [x for x in (_p("mtIsolate") or "").split()]-    copper_layers = _re.findall(r"\d+", layer_setup)   # e.g. ['1','2','15','16']-    ncu = len(copper_layers)-    # bonds between copper layers, in order: '+' prepreg, '*' core-    bonds = [("core" if c == "*" else "prepreg") for c in layer_setup if c in "+*"]-    # 2) measure the FR4 Board body (3D)-    _proxy_to_addin("show_3d_board", {}, timeout=60)-    # Find the LARGEST-XY body named 'board' (the FR4 substrate) - there can be several-    # 'board'-named bodies (small ones), so pick the substrate by area, not the first.-    mscript = (-        "import adsk.core, adsk.fusion\n"-        "app=adsk.core.Application.get(); des=adsk.fusion.Design.cast(app.activeProduct)\n"-        "best=None; ba=-1.0\n"-        "for occ in des.rootComponent.allOccurrences:\n"-        " for b in occ.component.bRepBodies:\n"-        "  if b.name.lower()=='board':\n"-        "   bb=b.boundingBox; mn=bb.minPoint; mx=bb.maxPoint\n"-        "   a=(mx.x-mn.x)*(mx.y-mn.y)\n"-        "   if a>ba: ba=a; best=((mx.x-mn.x)*10,(mx.y-mn.y)*10,(mx.z-mn.z)*10)\n"-        "print('FR4 %.4f %.4f %.4f'%best if best else 'FR4 none')")-    mm = _proxy_to_addin("run_modeling_script", {"script": mscript}, timeout=60)-    fr4 = None-    try:-        msg = ""-        if isinstance(mm, dict):-            o = mm.get("output")-            msg = (json.loads(o).get("message") if isinstance(o, str) and o.startswith("{") else (mm.get("message") or o)) or ""-        m = _re.search(r"FR4\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)", str(msg))-        if m:-            fr4 = {"x_mm": float(m.group(1)), "y_mm": float(m.group(2)), "dielectric_mm": float(m.group(3))}-    except Exception:-        pass-    def _num(s):-        try: return float(str(s).replace("mm", ""))-        except Exception: return None-    return {-        "success": True,-        "layerSetup": layer_setup,-        "copperLayers": copper_layers,-        "copperCount": ncu,-        "copperThickness_mm": [_num(t) for t in mt_copper[:ncu]] if mt_copper else None,-        "dielectricBonds": bonds,                 # e.g. ['prepreg','core','prepreg']-        "dielectricThickness_raw": mt_isolate,    # design-rule list (may hold 2-layer defaults)-        "fr4": fr4,-        "brdPath": brd,-        "_hint": ("Physical stack (see pcb-stackup skill): the FR4 is NOT one slab - it is "-                  "prepreg/core/prepreg with copper between. Build the table + exploded view per that skill. "-                  "If mtIsolate looks like 2-layer defaults, fit a symmetric split to fr4.dielectric_mm."),-    }---def dispatch_command(command: str, args: dict) -> dict:-    """Dispatch a command to the appropriate handler."""-    # Direct handlers (don't need the add-in)-    handler = COMMAND_HANDLERS.get(command)-    if handler is not None:-        return handler(fusion_info, args)--    # Check if Fusion is installed and running before any add-in-dependent command.-    # We do NOT auto-launch — that causes 60s+ hangs when Fusion isn't running.-    # The user should launch Fusion themselves; we just report the status.-    # Commands that need Fusion running (add-in commands + orchestrated commands)-    _OPEN_WITH_SCREENSHOT = {"open_schematic", "open_board", "show_3d_board", "show_2d_board", "show_schematic", "import_electronics"}-    if command in ADDIN_COMMANDS or command in ("open_lbr", "save_lbr", "attach_3d_package", "make_3d_package", "build_library_3d", "capture_library_views", "cleanup_cloud_files", "generate_package", "export_optimized_glb", "board_stackup") or command in _OPEN_WITH_SCREENSHOT:-        if not fusion_info.get("installed"):-            return {-                "success": False,-                "error": "Fusion 360 is not installed on this machine.",-                "errorCode": "fusion_not_installed",-                "_hint": "Fusion 360 isn't installed. Do NOT tell the user to install it themselves - OFFER to install it FOR them and do it on a yes: the fusion-onboarding skill silent-installs Fusion + drives the Autodesk sign-in.",-            }-        if not _is_fusion_running():-            return {-                "success": False,-                "error": "Fusion 360 is installed but not running.",-                "errorCode": "fusion_not_running",-                "_hint": "Call fusion_start to start Fusion 360, then wait for the add-in to become ready (fusion_start blocks until ready). Then retry this command.",-            }-        # Fusion is running — check if add-in is responsive (short timeout).-        # But first, if the add-in is busy with a long command, skip the-        # responsiveness check and fall through to the busy gate below.-        addin_busy = _check_addin_status(timeout=3.0)-        if addin_busy and addin_busy.get("busy"):-            pass  # Fall through to busy gate-        elif not wait_for_addin(timeout=5):-            return {-                "success": False,-                "error": "Fusion 360 is running but AdomBridge add-in not responding.",-                "errorCode": "fusion_addin_not_responding",-                "_hint": "The add-in isn't responding. Fix it YOURSELF - never ask the user: the bridge auto-installs the add-in to ALL Fusion add-in dirs (2025+ Fusion scans %APPDATA%/Autodesk/FusionAddins; the legacy API/AddIns dirs are silently ignored - issue #63), so restart Fusion via fusion_stop + fusion_start and it loads (runOnStartup). Verify with fusion_addin_status.",-            }--    # ── Busy gate: reject add-in commands immediately if a long command is running ──-    # Bridge-level commands (COMMAND_HANDLERS) already returned above — they use-    # Win32 APIs and don't touch the add-in, so they always work during a walk.-    # But add-in commands (and orchestrated commands that proxy to the add-in)-    # would pile up behind _main_thread_lock for 300s and crash the host.-    busy = _get_long_command()-    if busy and command not in LONG_RUNNING_COMMANDS:-        # This command would block behind the long-running one — reject immediately-        progress = _get_busy_progress()-        elapsed = round(_time.time() - busy["startedAt"], 1)-        progress_pct = None-        if progress and progress.get("foldersVisited") and progress.get("queueSize") is not None:-            total = progress["foldersVisited"] + progress["queueSize"]-            if total > 0:-                progress_pct = round(100 * progress["foldersVisited"] / total)-        return {-            "success": False,-            "error": (-                f"Fusion main thread busy — {busy['command']} has been running for {elapsed}s. "-                f"Your command '{command}' cannot execute until it finishes."-            ),-            "errorCode": "main_thread_busy",-            "busyCommand": busy["command"],-            "elapsedSeconds": elapsed,-            "progress": progress,-            "_hint": (-                f"A cloud search ({busy['command']}) is in progress"-                + (f" (~{progress_pct}% done)" if progress_pct is not None else "")-                + f", running for {elapsed}s. "-                "Do NOT retry add-in commands (get_app_state, document_info, etc.) — they will "-                "all be rejected until the search finishes. Commands that still work right now: "-                "fusion_window_info, fusion_screenshot_fusion, fusion_click_fusion, "-                "fusion_send_key, fusion_close_window. Wait for the search to complete, then retry."-            ),-        }--    # Cross-bridge case: this bridge's _long_command is None but the add-in-    # might be busy from another bridge/session. Quick non-blocking check.-    if not busy and command not in LONG_RUNNING_COMMANDS:-        addin_status = _check_addin_status(timeout=3.0)-        if addin_status and addin_status.get("busy"):-            elapsed = addin_status.get("elapsedSeconds", 0)-            walk = addin_status.get("walkProgress")-            busy_cmd = addin_status.get("busyCommand", "unknown")-            resp = {-                "success": False,-                "error": (-                    f"Fusion main thread busy — {busy_cmd} running for {elapsed}s "-                    f"(from another bridge/session). Your command '{command}' cannot "-                    f"execute until it finishes."-                ),-                "errorCode": "main_thread_busy",-                "busyCommand": busy_cmd,-                "elapsedSeconds": elapsed,-                "_hint": (-                    f"A long-running command ({busy_cmd}) is in progress from another session. "-                    "Do NOT retry add-in commands — they will all be rejected until it finishes. "-                    "Do NOT press Escape — the add-in is working, not stuck on a dialog. "-                    "Commands that still work: fusion_window_info, fusion_screenshot_fusion, "-                    "fusion_click_fusion, fusion_send_key, fusion_close_window."-                ),-            }-            if walk:-                resp["progress"] = walk-            return resp--    # Orchestrated multi-step commands (handled at bridge level)-    if command in _OPEN_WITH_SCREENSHOT:-        return _handle_open_with_screenshot(fusion_info, args, command)-    if command == "open_lbr":-        return _orchestrate_open_lbr(args)-    if command == "save_lbr":-        return _merge_dialog_array(_orchestrate_save_lbr(args))-    if command == "attach_3d_package":-        return _merge_dialog_array(_orchestrate_attach_3d_package(args))-    if command == "make_3d_package":-        return _apply_failure_dialogs(_orchestrate_make_3d_package(args))-    if command == "build_library_3d":-        return _apply_failure_dialogs(_orchestrate_build_library_3d(args))-    if command == "capture_library_views":-        return _apply_failure_dialogs(_orchestrate_capture_library_views(args))-    if command == "cleanup_cloud_files":-        return _orchestrate_cleanup_cloud_files(args)-    if command == "generate_package":-        return _apply_failure_dialogs(_orchestrate_generate_package(args))-    if command == "export_optimized_glb":-        return _orchestrate_export_optimized_glb(args)-    if command == "fetch_optimized_glb":-        return _orchestrate_fetch_optimized_glb(args)-    if command == "demo":-        return _orchestrate_demo(args)-    if command == "signin":-        return _orchestrate_signin(args)-    if command == "signin_2fa":-        return _orchestrate_signin_2fa(args)-    if command == "board_stackup":-        return _orchestrate_board_stackup(args)--    # Add-in proxy commands-    if command in ADDIN_COMMANDS:-        addin_cmd = ADDIN_COMMAND_MAP.get(command, command)-        proxy_timeout = ADDIN_COMMAND_TIMEOUTS.get(command, 30)-        # Wrap long-running commands in set/clear so the gate knows they're active.-        # Pre-dismiss blocking dialogs: modal dialogs steal Fusion's event loop,-        # preventing fireCustomEvent from being processed. Without this, the-        # walk appears "busy" but never actually starts — _walk_progress stays-        # None indefinitely while the command sits in the event queue.-        if command in LONG_RUNNING_COMMANDS:-            try:-                info = get_fusion_window_info()-                if info.get("dialogs"):-                    for d in info["dialogs"]:-                        try:-                            # Use WM_CLOSE via PostMessage — doesn't steal foreground-                            # (unlike send_key which uses SendInput + SetForegroundWindow).-                            # WM_CLOSE is also more reliable than Escape for Qt dialogs.-                            close_window(d.get("hwnd"))-                        except Exception:-                            pass-                    import time as _time-                    _time.sleep(0.5)  # give Fusion a moment to process the dismiss-            except Exception:-                pass-            _set_long_command(command)-            try:-                return _proxy_to_addin(addin_cmd, args, timeout=proxy_timeout)-            finally:-                _clear_long_command()-        result = _proxy_to_addin(addin_cmd, args, timeout=proxy_timeout)-        # After a state-changing op, surface any dialog/owned-popup the AI must analyze-        # (the Hub upload-close confirm, a save prompt, recovery, etc.) so it can't fly-        # blind. Read-only verbs are excluded to avoid per-call screenshot latency.-        if command in MUTATING_COMMANDS:-            result = _merge_dialog_array(result)-        return result--    # Unknown command — check installation/running status for helpful errors-    if not fusion_info.get("installed"):-        return {-            "success": False,-            "error": f"Fusion 360 is not installed on this machine. (command: {command})",-            "errorCode": "fusion_not_installed",-            "_hint": "Fusion 360 isn't installed. Do NOT tell the user to install it themselves - OFFER to install it FOR them and do it on a yes: the fusion-onboarding skill silent-installs Fusion + drives the Autodesk sign-in.",-        }-    if not _is_fusion_running():-        return {-            "success": False,-            "error": f"Fusion 360 is installed but not running. (command: {command})",-            "errorCode": "fusion_not_running",-            "_hint": "Call fusion_start to start Fusion 360 and wait for the add-in to be ready, then retry this command.",-        }-    return {-        "success": False,-        "error": f"Unknown command: {command}",-        "_hint": "Run `adom-desktop help` or check cli/src/commands.rs to see the list of available fusion_* commands. This command name may be misspelled or not yet implemented.",-    }---def _build_status() -> dict:-    """Build the /status payload — the endpoint AD declares as healthEndpoint.--    MUST return HTTP 2xx whenever the server is up: AD's health check polls this-    path and a 404 (the classic manifest-vs-server mismatch) makes AD wait the-    full startup grace then report a generic "not reachable". We also self-report-    the GUI chip fields {led, summary, tooltip} — AD renders them verbatim (the-    bridge owns its color; AD owns only the unreachable→gray state).-    """-    import platform--    info = fusion_info or {}-    installed = bool(info.get("installed"))-    running = _is_fusion_running() if installed else False-    addin = _probe_addin() if running else None-    addin_ok = bool(addin)--    if platform.system() != "Windows":-        led, summary = "yellow", "Unsupported OS"-        tooltip = ("Bridge running, but this host is not Windows. Fusion 360 verbs "-                   "need a Windows host with Fusion 360 installed.")-    elif not installed:-        led, summary = "yellow", "Fusion not installed"-        tooltip = ("Bridge running, but Fusion 360 is not installed. The AI can install it "-                   "for the user (fusion-onboarding), then fusion_start.")-    elif not running:-        led, summary = "yellow", "Fusion not running"-        tooltip = "Fusion 360 is installed but not running. Call fusion_start to launch it."-    elif not addin_ok:-        led, summary = "yellow", "Add-in not connected"-        tooltip = ("Fusion 360 is running but the AdomBridge add-in isn't responding yet "-                   "(still loading; if it persists the AI restarts Fusion via fusion_stop/start).")-    else:-        led, summary = "green", "Fusion ready"-        tooltip = "Fusion 360 running, AdomBridge add-in connected. All fusion_* verbs available."--    return {-        "status": "ok",-        "led": led,-        "summary": summary,-        "tooltip": tooltip,-        "bridgeVersion": BRIDGE_VERSION,-        "fusion": {**info, "running": running},-        "addin": addin,-    }---class FusionBridgeHandler(BaseHTTPRequestHandler):-    """HTTP request handler for the Fusion 360 bridge server."""--    def do_GET(self):-        # /status is the manifest's healthEndpoint; /health is kept as a-        # back-compat alias (older callers + the add-in-probe code path). Both-        # return the same 2xx payload — new chip fields are purely additive.-        if self.path in ("/status", "/health"):-            self._respond(200, _build_status())-        else:-            self._respond(404, {"error": "Not found"})--    def do_POST(self):-        if self.path != "/command":-            self._respond(404, {"error": "Not found"})-            return--        content_length = int(self.headers.get("Content-Length", 0))-        body = self.rfile.read(content_length)--        try:-            request = json.loads(body)-        except json.JSONDecodeError as e:-            self._respond(400, {"success": False, "error": f"Invalid JSON: {e}"})-            return--        command = request.get("command", "")-        args = request.get("args", {})--        print(f"[Fusion Bridge] Command: {command} | Args: {json.dumps(args)}")--        try:-            result = dispatch_command(command, args)-            self._respond(200, result)-        except Exception as e:-            print(f"[Fusion Bridge] ERROR: {e}")-            traceback.print_exc()-            self._respond(500, {-                "success": False,-                "error": f"Internal error: {e}",-            })--    def _respond(self, status: int, data: dict):-        self.send_response(status)-        self.send_header("Content-Type", "application/json")-        self.end_headers()-        self.wfile.write(json.dumps(data).encode("utf-8"))--    def log_message(self, format, *args):-        print(f"[Fusion Bridge] {args[0]} {args[1]} {args[2]}")---def _prune_bridge_logs(max_mb: int = 8, keep_tail_mb: int = 2) -> dict:-    """LOG JANITOR (John, 2026-07-22: "do you have a janitor to clean up your log?").--    AD captures this bridge's stdout/stderr into ~/.adom/bridge-logs/fusion360.log. Nothing-    rotated it, so it grew unbounded - fine today (it is small), a slow leak on a long-lived box.-    On every bridge start we truncate our OWN logs to the most recent keep_tail_mb once they pass-    max_mb, keeping the tail because that is where a crash traceback lives.--    Only touches files this bridge owns (fusion360*). Never raises.-    """-    import glob-    out = {"checked": 0, "pruned": []}-    try:-        d = os.path.join(os.path.expanduser("~"), ".adom", "bridge-logs")-        for path in glob.glob(os.path.join(d, "fusion360*.log")):-            out["checked"] += 1-            try:-                sz = os.path.getsize(path)-                if sz <= max_mb * 1024 * 1024:-                    continue-                with open(path, "rb") as f:-                    f.seek(-keep_tail_mb * 1024 * 1024, os.SEEK_END)-                    tail = f.read()-                with open(path, "wb") as f:-                    f.write(b"[adom-bridge log janitor] truncated %d MB -> tail %d MB\n"-                            % (sz // (1024 * 1024), keep_tail_mb))-                    f.write(tail)-                out["pruned"].append({"file": os.path.basename(path), "wasMB": sz // (1024 * 1024)})-            except Exception:-                continue-        if out["pruned"]:-            print("[Fusion Bridge] log janitor: %s" % out["pruned"])-    except Exception:-        pass-    return out---def main():-    global fusion_info--    # Line-buffer stdout/stderr. AD spawns the bridge console-less and captures-    # our stdout+stderr into ~/.adom/bridge-logs/fusion360.log. Python BLOCK-buffers-    # stdout when it's not a TTY, so without this the buffer never flushes while-    # serve_forever() runs → the log stays 0 bytes (and a spawn-crash traceback-    # would be lost). Line buffering flushes every print()/traceback on newline.-    try:-        sys.stdout.reconfigure(line_buffering=True)-        sys.stderr.reconfigure(line_buffering=True)-    except Exception:-        pass--    _prune_bridge_logs()-    _start_signin_janitor()--    port = DEFAULT_PORT-    if "--port" in sys.argv:-        idx = sys.argv.index("--port")-        if idx + 1 < len(sys.argv):-            port = int(sys.argv[idx + 1])--    # Early banner BEFORE detection — so the log proves we started even if-    # detect_fusion() is slow or wedges. (KiCad bridge prints an equivalent line.)-    print(f"[Fusion Bridge] starting - version {BRIDGE_VERSION}, port {port}, "-          f"healthEndpoint /status, pid {os.getpid()}", flush=True)--    fusion_info = detect_fusion()--    print(f"[Fusion Bridge] Fusion 360 detection result:")-    print(f"  Installed: {fusion_info.get('installed', False)}")-    if fusion_info.get("installed"):-        print(f"  Exe path:  {fusion_info.get('exe_path')}")-        print(f"  AddIns:    {fusion_info.get('addins_dir')}")-        print(f"  Add-in:    {'installed' if fusion_info.get('addin_installed') else 'not installed'}")-        print(f"  Running:   {fusion_info.get('running')}")--        # ALWAYS sync the add-in on startup - it is idempotent (_sync_directory copies-        # only CHANGED files). The old `if not addin_installed` guard meant a STALE-        # add-in was never UPDATED: an add-in fix (e.g. get_parameters, caught live-        # 2026-07-06) never reached users who already had ANY copy, because the bridge-        # only deployed when it was entirely MISSING. Always-sync fixes that. It is safe-        # while Fusion is up (locked add-in files simply skip via the per-target OSError-        # catch); the update lands on the next bridge respawn with Fusion closed-        # (fusion_stop -> bridge_install -> fusion_start).-        print(f"[Fusion Bridge] Syncing AdomBridge add-in (idempotent; updates a stale copy)...")-        try:-            install_addin()-            fusion_info = detect_fusion()  # Re-detect after sync-            print(f"  Add-in:    {'installed' if fusion_info.get('addin_installed') else 'FAILED'}")-        except Exception as e:-            print(f"  Add-in sync failed: {e}")-    else:-        print(f"  WARNING: Fusion 360 not found. Some commands will fail.")--    addin_health = _probe_addin()-    if addin_health:-        print(f"  Add-in server: running on port {ADDIN_PORT}")-    else:-        print(f"  Add-in server: not running (port {ADDIN_PORT})")--    all_commands = list(COMMAND_HANDLERS.keys()) + sorted(ADDIN_COMMANDS)-    print(f"[Fusion Bridge] Available commands: {', '.join(all_commands)}")--    # AD (>=1.9.63) passes ADOM_BIND_HOST (always 127.0.0.1) to every bridge it-    # spawns. Honor it and NEVER bind 0.0.0.0/'' by default - a public bind pops a-    # Windows Firewall "allow access?" dialog, and AD's guarantee to users is no-    # firewall prompts. Default to loopback if the var is absent (e.g. local dev).-    bind_host = os.environ.get("ADOM_BIND_HOST", "127.0.0.1")-    server = ThreadingHTTPServer((bind_host, port), FusionBridgeHandler)-    server.daemon_threads = True-    print(f"[Fusion Bridge] Listening on http://{bind_host}:{port}")-    print(f"[Fusion Bridge] Health check: http://{bind_host}:{port}/status (alias /health)")-    print(f"[Fusion Bridge] Press Ctrl+C to stop.")--    try:-        server.serve_forever()-    except KeyboardInterrupt:-        print("\n[Fusion Bridge] Shutting down.")-        server.server_close()---if __name__ == "__main__":-    # Surface any fatal startup error to stderr (which AD captures into-    # ~/.adom/bridge-logs/fusion360.log) so a spawn-crash is debuggable, then-    # re-raise for a non-zero exit.-    try:-        main()-    except Exception:-        print("[Fusion Bridge] FATAL: bridge failed to start", flush=True)-        traceback.print_exc()-        sys.stderr.flush()-        raise+#!/usr/bin/env python3+"""Adom Fusion 360 Bridge Server — localhost HTTP server for Fusion 360 integration.++Receives commands from the Adom Desktop (Tauri app) and controls+Fusion 360 via Win32 API, os.startfile, and the AdomBridge add-in.++Usage:+    python server.py+    python server.py --port 8773+"""++import json+import os+import threading+import time as _t2+import sys+import traceback+import urllib.request+import urllib.error+import urllib.parse+from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler+from pathlib import Path++# Ensure the plugin root is on the path+sys.path.insert(0, str(Path(__file__).parent))++from fusion_detect import detect_fusion, ensure_fusion_running, wait_for_addin, _is_fusion_running, fusion_update_in_progress, find_licensing_dialog, family_windows, _incomplete_webdeploy_present+from handlers.open_design import handle_open_design+from handlers.close_fusion import handle_close_fusion, handle_fusion_stop, handle_fusion_kill+from handlers.dismiss_recovery import dismiss_recovery_dialog+from handlers.fusion_ui import (+    screenshot_fusion_window,+    screenshot_hwnd,+    click_fusion,+    send_key_to_fusion,+    close_window,+    get_fusion_window_info,+)+from handlers.dialog_classify import classify_blocking_dialogs, classify_launch_dialogs, close_dialog_bg, classify_api_error, AUTO_DISMISS_CATEGORIES+from install_addin import install as install_addin+import aps+import describe+import ad_client++DEFAULT_PORT = 8773+ADDIN_PORT = 8774++# Read once at load so /status reports the same version we ship + bump.+try:+    BRIDGE_VERSION = (Path(__file__).parent / "BRIDGE_VERSION").read_text(encoding="utf-8").strip()+except Exception:+    BRIDGE_VERSION = "unknown"++# The add-in version this bridge BUNDLES (the one install_addin syncs into+# Fusion's Roaming AddIns dir). Read from our bundled manifest so it can't drift+# from a hardcode. Compared against the RUNNING add-in's reported version to+# catch a stale add-in (issue #55) - the running copy only re-syncs when Fusion+# is closed on bridge start, so a user who never restarted Fusion keeps an old+# add-in and commands like fusion_aps_open fail silently. We make it LOUD.+try:+    EXPECTED_ADDIN_VERSION = (json.loads(+        (Path(__file__).parent / "addin" / "AdomBridge" / "AdomBridge.manifest").read_text(encoding="utf-8")+    ) or {}).get("version", "unknown")+except Exception:+    EXPECTED_ADDIN_VERSION = "unknown"+++def _addin_staleness(reported_version) -> dict:+    """Compare the running add-in's reported version to the bundled one.++    Returns {addinVersion, expectedAddinVersion, addinStale, _staleHint?}. A+    stale add-in is the #55 silent-failure: re-sync it by RESTARTING Fusion+    (fusion_stop then fusion_start) so the bridge's on-start install_addin can+    overwrite the Roaming copy (which is file-locked while Fusion runs)."""+    info = {+        "addinVersion": reported_version or "unknown",+        "expectedAddinVersion": EXPECTED_ADDIN_VERSION,+    }+    stale = (+        reported_version not in (None, "unknown")+        and EXPECTED_ADDIN_VERSION not in (None, "unknown")+        and str(reported_version) != str(EXPECTED_ADDIN_VERSION)+    )+    info["addinStale"] = bool(stale)+    if stale:+        info["_staleHint"] = (+            f"STALE add-in: Fusion is running add-in v{reported_version} but this bridge "+            f"bundles v{EXPECTED_ADDIN_VERSION}. Newer verbs (e.g. fusion_aps_open/open_by_urn) "+            f"can fail silently. Fix: fusion_stop then fusion_start - the bridge re-syncs the "+            f"add-in from its cache on start (only possible with Fusion CLOSED)."+        )+    return info++# Populated at startup+fusion_info = None++# Caller identity context (issues #342/#348) — set by dispatch_command, used by _ad_call+# to forward X-Adom-Caller-* headers on outgoing AD calls for proper attribution.+_caller_identity_context = {}+++def _reclaim_seat_from_peers() -> int:+    """Free the Autodesk seat held by another machine by stopping Fusion on peer ADs.++    John (2026-07-06): "if the user asked you to do something with fusion you should+    just grab the license back. the user can always grab it back on the other machine,+    so there's no harm." So on a seat conflict we do NOT ask - we free the seat at the+    SOURCE via the cross-AD direct API (fully background + reliable, no CEF-dialog+    clicking), then relaunch locally with no conflict. Best-effort; returns the number+    of peers signalled (0 if the direct API/peers are unavailable)."""+    n = 0+    try:+        if not ad_client.available():+            return 0+        for peer in ad_client.peers():+            try:+                if ad_client.call("fusion_stop", {}, target=peer) is not None:+                    n += 1+            except Exception:+                pass+    except Exception:+        pass+    return n+++def _resolve_seat_via_uia(dlg: dict) -> bool:+    """Resolve the 'Active Sessions Exceeded' seat dialog by UIA-invoking its native+    'Continue' button (Suspend is pre-selected) - entirely IN THE BACKGROUND.++    THE breakthrough (John 2026-07-06, after a long day of failing): UIA Invoke does+    NOT foreground the window, so this grabs the license without ever disturbing the+    user - unlike SendInput coordinate clicks (need foreground) or kill+relaunch (the+    server keeps the seat). Proven live: `desktop_ui_click {hwnd, name:"Continue"}`+    returned "Clicked in the BACKGROUND - the window did NOT come to the foreground"+    and the dialog cleared. The bridge calls the AD `desktop_ui_click` verb on ITSELF+    via the AD 1.9.84 direct API. Never raises."""+    try:+        hwnd = dlg.get("hwnd")+        if not hwnd or not ad_client.available():+            return False+        res = ad_client.call("desktop_ui_click", {"hwnd": hwnd, "name": "Continue"}, timeout=4)+        return bool(res and (res.get("success") or res.get("status") == "ok"))+    except Exception:+        return False+++def _owned_popup_count(main_hwnd) -> int:+    """ownedPopupCount on a window via AD's desktop_screenshot_window - the SAME+    parent/child screenshot signal John kept pointing at (2026-07-06): a modal seat/+    error dialog shows up as an OWNED POPUP of the main Fusion window. This is the+    ground-truth 'is a dialog still up' check used to VERIFY a seat dialog actually+    cleared, instead of trusting a window-size heuristic (which false-'resolved' the+    823x262 'Suspend Remote Session' confirm). Returns the count, or -1 if unavailable."""+    try:+        if not main_hwnd or not ad_client.available():+            return -1+        res = ad_client.call("desktop_screenshot_window", {"hwnd": int(main_hwnd)}, timeout=4)+        if isinstance(res, dict):+            c = res.get("ownedPopupCount")+            if c is None and isinstance(res.get("data"), dict):+                c = res["data"].get("ownedPopupCount")+            if c is not None:+                return int(c)+    except Exception:+        pass+    return -1+++def _resolve_seat_dialog(max_clicks: int = 3) -> dict:+    # max_clicks kept LOW (was 8): readiness calls this every tick and it SELF-HEALS across ticks, so+    # a single call must stay responsive - 8 clicks x (UIA click + screenshot-verify, each up to its+    # timeout) could blow readiness's whole budget and make the bridge look hung (John 2026-07-14).+    """Detect + resolve the seat/licensing modal deterministically, IN THE BACKGROUND,+    and VERIFY it truly cleared by SCREENSHOTTING the parent window's ownedPopupCount -+    not by re-running a size heuristic (that false-'resolved' bit John hard, 2026-07-06:+    the code clicked once, the size filter then failed to re-detect the short confirm+    variant, and readiness lied that the dialog was gone while it sat there blocking).++    Each pass: find the dialog -> UIA-invoke 'Continue' (background, Suspend pre-selected+    so it grabs the license) -> screenshot the parent's ownedPopupCount. Done only when+    BOTH find_licensing_dialog() sees nothing AND the parent shows 0 owned popups (or the+    screenshot signal is unavailable and the heuristic agrees). Returns+    {sawDialog, clicks, verified}. Never raises."""+    import time as _t+    clicks = 0+    saw = False+    verified = False+    for _ in range(max_clicks):+        try:+            dlg = find_licensing_dialog()+        except Exception:+            dlg = None+        if not dlg:+            verified = True+            break+        saw = True+        parent = dlg.get("owner")+        if _resolve_seat_via_uia(dlg):     # count only clicks that actually landed, so+            clicks += 1                    # seatDialogAutoResolved isn't a lie when ad_client is down+        _t.sleep(1.5)+        # Ground-truth confirm via parent/child screenshot (John's insisted-on check).+        cnt = _owned_popup_count(parent)+        if cnt == 0:+            verified = True+            break+        # cnt == -1 (screenshot unavailable): fall through, the next find_licensing_dialog+        # pass decides. cnt > 0: still up, loop and click again.+    return {"sawDialog": saw, "clicks": clicks, "verified": verified}+++def _find_adom_desktop_cli() -> str | None:+    """Locate the adom-desktop CLI so the bridge can drive AD when the in-process ad_client is+    down. On Windows the CLI is **adom-desktop-cli.exe** (bundled next to the GUI); the bridge must+    NEVER shell adom-desktop.exe - that is the GUI, and launching it with verb args foregrounds AD+    on the user's screen every call (issue #295). Prefer the loopback direct API over shelling at+    all (see _ad_direct_api_call)."""+    import shutil+    la = os.environ.get("LOCALAPPDATA", "")+    candidates = [+        os.path.join(la, "Adom Desktop", "adom-desktop-cli.exe") if la else None,+        os.path.join(la, "Programs", "Adom Desktop", "adom-desktop-cli.exe") if la else None,+    ]+    for c in candidates:+        if c and os.path.exists(c):+            return c+    # PATH: the CLI shim only. NEVER resolve bare "adom-desktop" on Windows - it is the GUI exe.+    hit = shutil.which("adom-desktop-cli")+    if hit:+        return hit+    if os.name != "nt":+        return shutil.which("adom-desktop")  # on Linux/dev, `adom-desktop` IS the CLI+    return None+++def _cli_notify_all(title: str, body: str, level: str) -> dict:+    """Deliver a toast to EVERY connected desktop via `adom-desktop --target all notify_user`. This is+    the reliable cross-AD path when the bridge's in-process ad_client is down (headless VM): the CLI+    joins the relay itself and `--target all` reaches the user's real machine (not just this VM).+    Best-effort + never raises. Returns {delivered, targets, error}. (John 2026-07-14: the notify MUST+    actually leave the box - a returned-but-unsent payload is the bug, not a feature.)"""+    import subprocess as _sp+    exe = _find_adom_desktop_cli()+    if not exe:+        return {"delivered": False, "error": "adom-desktop CLI not found"}+    # STICKY by default: a human-wall alert must NOT vanish in a few seconds (John 2026-07-14 - the+    # first toasts disappeared before he noticed them). scenario:reminder keeps it on screen until the+    # user acts; it REQUIRES >=1 button, so include one. durationLong is a belt-and-suspenders ~25s.+    payload = json.dumps({"title": title, "body": body, "level": level,+                          "scenario": "reminder", "durationLong": True,+                          "buttons": [{"label": "Got it"}]})+    try:+        r = _sp.run([exe, "--target", "all", "notify_user", payload],+                    capture_output=True, text=True, timeout=30,+                    creationflags=getattr(_sp, "CREATE_NO_WINDOW", 0))+        out = (r.stdout or "") + (r.stderr or "")+        # AD's CLI returns {status:'ok', action:'displayed'} per target; treat a zero exit or a+        # 'displayed'/'ok' in the output as delivered. The Windows exe may print nothing yet still+        # deliver, so a clean exit code is accepted too.+        delivered = (r.returncode == 0) or ("displayed" in out) or ('"status": "ok"' in out) or ("status':'ok" in out)+        return {"delivered": bool(delivered), "targets": "all", "error": None if delivered else out[:200]}+    except Exception as e:+        return {"delivered": False, "error": str(e)[:200]}+++def _handle_notify_owner(args: dict) -> dict:+    """LAST-RESORT: toast the user's MAIN desktop (cross-AD) to ask for a human step.++    John's standing rule (2026-07-07): the bridge/AI does EVERYTHING itself; the only+    legitimate uses are true human walls - password/2FA entry, UAC elevation, a physical+    action. When this bridge runs on an unattended VM, the toast must reach the machine+    the user is actually AT - ad_client.notify(reach_user=True) fans out to all peer ADs+    on the relay, so it lands on their main computer too. Returns which targets were hit."""+    title = args.get("title") or "Fusion bridge needs you"+    body = args.get("body") or args.get("message") or "A human step is required to continue."+    level = args.get("level") or "warning"+    if not ad_client.available():+        # ad_client is the bridge's IN-PROCESS AD API - it is routinely UNAVAILABLE when this bridge+        # runs on an unattended Hyper-V VM (found live 2026-07-14: the toast silently never reached the+        # user, and the AI "forgot" to relay it - exactly the failure John demanded we engineer out).+        # SELF-DELIVER via the adom-desktop CLI, which connects to the relay independently: `--target+        # all` fans the toast out to EVERY connected desktop (the VM + the user's real machines), so it+        # reliably lands on the computer the user is actually AT. No AI relay step to forget.+        cli = _cli_notify_all(title, body, level)+        if cli.get("delivered"):+            return {"success": True, "via": "cli:--target all", "targets": cli.get("targets"),+                    "_hint": ("Toast fanned out to ALL connected desktops (the user's main machine "+                              "included) via the adom-desktop CLI, because the bridge's in-process AD "+                              "API was unavailable (this VM). WAIT and poll fusion_readiness; do not "+                              "re-toast within a few minutes. Only notify when the box is ACTUALLY "+                              "ready for the user to act - do not toast for a step you can do yourself.")}+        # CLI fallback also failed - return the payload so the AI can relay as the true last resort.+        return {+            "success": False,+            "error": "AD in-process API unavailable AND the adom-desktop CLI fallback failed: "+                     + str(cli.get("error"))[:200],+            "notifyUser": {"title": title, "body": body, "level": level},+            "_hint": ("Relay the notifyUser payload yourself: `adom-desktop --target all notify_user "+                      "{title, body, level}` (or `--target <the user's host>`)."),+        }+    res = ad_client.notify(title, body, level=level, reach_user=True) or {}+    # reach_user fans out via target="all", whose response is a BROADCAST envelope+    # {results:{host:{action}}, summary:{ok,failed,total}, targets:[...]}. Recognize that shape for+    # success (summary.ok>0) - not just the single-target {status:"ok"} - and report the ACTUAL+    # desktops reached, so a toast that landed on the user's laptop isn't mis-reported as failed.+    summary = res.get("summary") or {}+    ok = bool(res.get("success") or res.get("status") == "ok" or summary.get("ok", 0) > 0)+    reached = res.get("targets") or (["self"] if ok else [])+    return {+        "success": ok,+        "via": "ad_client:direct(all)",+        "targets": reached,+        "_hint": ("Toast fanned out to ALL connected desktops (the user's machine included) via the "+                  "in-process AD direct API. This is the LAST RESORT - only use after exhausting "+                  "programmatic options (UIA background clicks, seat auto-resolve, warm-SSO sign-in), "+                  "and only when the box is ACTUALLY ready for the user to act. Now WAIT and poll "+                  "fusion_readiness for the state to clear; do not re-toast within a few minutes."),+    }+++_last_fg_notice = [0.0]+++def _notify_before_foreground(reason: str) -> None:+    """Baked-in courtesy toast BEFORE the bridge foregrounds Fusion (John, 2026-07-08).++    Some Fusion interactions (SendInput key/click, CEF modal dialogs) can ONLY be driven+    with Fusion in the FOREGROUND, which steals the user's focus mid-work. The standing+    rule: ALWAYS drive in the background; foreground ONLY as a last resort. When we truly+    must, TELL the user why (via an AD notify, so every Adom user learns the principle) and+    rib Fusion for not being AI-native enough to allow it. Debounced so a burst of keystrokes+    fires ONE notice, not dozens. Best-effort; never raises, never blocks the operation."""+    try:+        now = _time.time()+        if now - _last_fg_notice[0] < 45:   # one notice per foreground burst+            return+        _last_fg_notice[0] = now+        body = (+            f"Adom is briefly bringing Fusion to the FOREGROUND: {reason}. I ALWAYS try to do "+            "everything in the background and only foreground when I have NO option left. Fusion "+            "just isn't AI-native/fast enough yet to let me drive this part in the background - "+            "hopefully Autodesk makes their app fully AI-drivable soon so I can skip these "+            "workarounds. Sorry for the interruption!"+        )+        ad_client.notify("Adom is foregrounding Fusion (last resort)", body,+                         level="info", reach_user=True)+    except Exception:+        pass+++def _handle_new_electronics_from_eagle(args: dict) -> dict:+    """Import a legacy EAGLE .sch (+ paired .brd) into a NEW Fusion electronics design+    so the parts actually INSTANTIATE (schematic + populated board), then land in the PCB+    editor. Proven live 2026-07-07 on winvm.++    ⭐ PREFER THE BACKGROUND PATH FIRST (learned 2026-07-08, the hard way): if you have a+    `.brd` with the parts ALREADY PLACED (`<elements>` with x/y + an embedded `<library>`),+    do NOT use this verb - call **`fusion_open_board {filePath: <.brd>}`** instead. It opens+    the board via `Document.newDesignFromLocal`, which INSTANTIATES the placed elements with+    ZERO modal file-dialogs and ZERO foreground (this verb's ImportSCHAndBRDCmd pops TWO+    native Open dialogs that STEAL the user's focus - modal dialogs always foreground, there+    is no background way to drive them). A hand-authored EAGLE `.brd` (well-formed XML: layers+    + board outline on layer 20 + libraries/packages + elements) imports cleanly this way.+    ➜ FOR 3D BODIES ON THAT BOARD (not flat pads): embed a `<packages3d>` section (each+    `<package3d name=... wip_urn="urn:adsk.wipprod:fs.file:vf...."/>` from a prior+    `fusion_build_library_3d` bind) in the `.brd`'s `<library>`, AND give every `<element>` a+    `package3d_urn="<that pin's wip_urn>"`. Then `fusion_open_board` + `fusion_show_3d_board`+    renders the REAL component 3D (Fusion resolves the urns from the Hub). No urn on the+    element = a flat pad. (If a urn is stale/unresolved, re-run `fusion_build_library_3d` for+    that part to mint a fresh one.)++    THIS VERB is only for a legacy `.sch` whose parts must be instantiated by Fusion's own+    importer: `Document.newDesignFromLocal <file.sch>` opens the schematic EDITOR but does NOT+    instantiate parts (board_info == 0); Fusion's REAL EAGLE importer is `ImportSCHAndBRDCmd`,+    which pops the two Open dialogs. This verb drives them via the AD direct API when present -+    but that FOREGROUNDS Fusion, so use the `.brd`+`fusion_open_board` route whenever you can+    place the parts yourself. The native `.fsch/.fbrd` container is opaque BINARY, un-authorable.++    args: {schPath: Windows path to the .sch (required), brdPath: Windows path to the .brd+           (optional; defaults to the .sch's sibling .brd)}+    """+    import time as _t+    sch = (args.get("schPath") or args.get("filePath") or "").replace("\\", "/")+    if not sch or not sch.lower().endswith(".sch"):+        return {"success": False, "error": "schPath (a .sch file on the Windows host) is required.",+                "_hint": "Author the EAGLE .sch + paired .brd (same basename, same folder), stage both "+                         "to Windows (send_files), then call with {schPath, brdPath}."}+    brd = (args.get("brdPath") or (sch[:-4] + ".brd")).replace("\\", "/")+    if not ad_client.available():+        # Do NOT dead-end here (old bug: hard-failed even on latest AD when this bridge process+        # didn't get AD's injected direct-API env). The RIGHT move is the background route.+        brd_guess = (args.get("brdPath") or (sch[:-4] + ".brd")).replace("\\", "/")+        return {"success": False, "errorCode": "use_open_board_instead",+                "error": "AD direct API unavailable - and this dialog-driven import FOREGROUNDS Fusion anyway.",+                "_hint": ("Don't drive the Open dialogs. If the parts are placed in the .brd (elements + "+                          "embedded library), call `fusion_open_board {filePath:\"" + brd_guess + "\"}` - it "+                          "instantiates them via newDesignFromLocal with NO dialogs and NO foreground (the "+                          "background way). For 3D bodies, embed <packages3d> + per-element package3d_urn in the "+                          ".brd (see this verb's docstring), then fusion_show_3d_board. Only if you truly have a "+                          "bare .sch that Fusion must import: fire 'Commands.Start ImportSCHAndBRDCmd' via "+                          "fusion_execute_text_command and drive the two Open dialogs (foreground; last resort).")}++    def _find_open_dialog():+        res = ad_client.call("desktop_list_windows", {}) or {}+        out = res.get("output") if isinstance(res, dict) else None+        if isinstance(out, str):+            try: out = json.loads(out)+            except Exception: out = None+        wins = ((out or res).get("data", out or res) or {}).get("windows", []) if isinstance(out or res, dict) else []+        for w in wins:+            if str(w.get("title", "")).strip().lower() == "open":+                return w.get("hwnd")+        return None++    def _pick(hwnd, basename):+        # select the file by accessible name, then click Open - both background UIA+        ad_client.call("desktop_ui_click", {"hwnd": hwnd, "name": basename})+        _t.sleep(1.0)+        ad_client.call("desktop_ui_click", {"hwnd": hwnd, "name": "Open"})++    import os as _os+    sch_base, brd_base = _os.path.basename(sch), _os.path.basename(brd)++    # 1) Fire Fusion's EAGLE importer (opens the first Open dialog).+    _proxy_to_addin("execute_text_command", {"command": "Commands.Start ImportSCHAndBRDCmd"}, timeout=15)++    # 2) Drive the two Open dialogs (sch, then brd). Each appears after a beat; retry.+    picked = []+    for want, base in (("sch", sch_base), ("brd", brd_base)):+        dlg = None+        for _ in range(20):+            _t.sleep(1.5)+            dlg = _find_open_dialog()+            if dlg:+                break+        if not dlg:+            # brd dialog may not appear if Fusion inferred the sibling automatically+            if want == "brd" and picked:+                break+            return {"success": False, "error": f"The '{want}' Open dialog never appeared.",+                    "_hint": "Screenshot the Fusion hwnd + READ screenshots[] for a blocking modal; the importer "+                             "may have errored on the EAGLE source. Verify the .sch/.brd are valid EAGLE.",+                    "data": {"picked": picked}}+        _pick(dlg, base)+        picked.append(base)+        _t.sleep(2.0)++    # 3) Verify by STATE - poll until the imported design is an electronics design.+    ready = False+    for _ in range(40):  # up to ~2 min; import is slow, esp. software-rendered+        _t.sleep(3)+        try:+            st = _proxy_to_addin("get_app_state", {}, timeout=8)+            d = st.get("data") or {}+            if d.get("isElectronics") and str(d.get("activeWorkspace", "")).lower() in ("pcb editor", "schematic editor", "board layout", "3d pcb"):+                ready = True+                break+        except Exception:+            pass  # app_state is None while a modal import dialog blocks - keep polling++    return {+        "success": ready,+        "imported": picked,+        "statusVerb": "fusion_board_info",+        "_hint": (+            "EAGLE design imported via Fusion's own ImportSCHAndBRDCmd (both Open dialogs driven in the "+            "background). It is now an electronics design in the PCB editor. NEXT: fusion_show_2d_board, "+            "then RATSNEST + 'AUTO ;' (fusion_electron_run) to autoroute, fusion_show_3d_board for the 3D. "+            "NOTE: board_info can read 0 right after import (wrong-view query) even though the board is "+            "populated - verify by screenshotting the Fusion hwnd (read screenshots[]) or fusion_show_2d_board "+            "+ WINDOW FIT. If 3D bodies are missing, the placed parts' package3d urns must resolve in THIS "+            "project - build the library's 3D into this project with fusion_build_library_3d (do NOT reuse "+            "another library's urns; they don't resolve cross-project)."+            if ready else+            "Import fired + both Open dialogs driven, but the design did not confirm as electronics within the "+            "budget (a slow VM import can run longer). Poll fusion_get_app_state, and screenshot the Fusion hwnd "+            "reading screenshots[] for a blocking import dialog."+        ),+    }+++def _handle_launch(fusion_info: dict, args: dict) -> dict:+    """Launch Fusion 360 and optionally wait for the AdomBridge add-in."""+    # LIVE detect (not the stale bridge-start fusion_info snapshot) so a launch right+    # after a fresh install is recognized (with a current exe_path) and a launch after a+    # removal fails cleanly. Reassign so the whole handler uses fresh paths.+    fusion_info = detect_fusion()+    if not fusion_info.get("installed"):+        return {+            "success": False,+            "error": "Fusion 360 is not installed on this machine.",+            "errorCode": "fusion_not_installed",+            "_hint": "Fusion 360 isn't installed. Do NOT tell the user to install it themselves - OFFER to install it FOR them and do it on a yes: the fusion-onboarding skill silent-installs Fusion + drives the Autodesk sign-in. (For a plain 'what EDA tools are installed' check, AD's bridge_readiness reports this cleanly without erroring.)",+        }++    if _is_fusion_running():+        addin_ok = wait_for_addin(timeout=5)+        return {+            "success": True,+            "output": "Fusion 360 is already running.",+            "addinConnected": addin_ok,+            "alreadyRunning": True,+            "resolvedPath": fusion_info.get("exe_path", ""),+        }++    # Relocate any crash recovery files BEFORE launching Fusion.+    # This prevents the "Recovered Documents" dialog from appearing at all.+    # Files are moved to ~/.adom/recovery/fusion/<timestamp>/ (not deleted).+    from handlers.dismiss_recovery import relocate_recovery_files+    reloc = {"moved": 0, "dest": None}+    try:+        reloc = relocate_recovery_files()+    except Exception:+        pass++    # Launch Fusion. AD's relay caps a single request at ~60s, but a FIRST launch+    # (fresh install: component downloads, updates, cloud sync) can take 2-4 min -+    # far past the cap. So we DON'T block on the add-in for the whole launch (that+    # dead-ended #63/Arav, and {"timeout":300} can't beat the relay cap). Instead:+    # launch + confirm the process, then wait for the add-in only within a budget+    # that keeps this request UNDER the relay cap; if it's still coming up, return a+    # clean stillLaunching response telling the caller to POLL fusion_readiness.+    import time as _t+    _start = _t.time()+    err = ensure_fusion_running(fusion_info, wait_addin=False)+    if err:+        return err++    # WATCH for launch/licensing dialogs the moment the process is up - IN CODE, so+    # a launch is never blind to them (the seat-conflict + streamed-app-error dialogs+    # are owned by AdskIdentityManager/FusionLauncher, invisible to the Fusion-scoped+    # classifier). Auto-dismiss BENIGN errors.+    launch_dialogs = classify_launch_dialogs()+    for _dlg in launch_dialogs:+        if _dlg.get("category") in AUTO_DISMISS_CATEGORIES:+            close_dialog_bg(_dlg.get("hwnd"))  # benign ack (WM_CLOSE == Cancel/OK)++    # ── DETERMINISTIC launch state loop (John 2026-07-06: track + handle state in+    #    CODE, in the BACKGROUND, never punt to the user, never foreground). Poll the+    #    add-in round-trip (the ground truth for "drivable") while, each tick:+    #      • AUTO-RESOLVING the seat/licensing dialog ("Active Sessions Exceeded") via+    #        UIA Invoke of its 'Continue' button - background, no foreground (THE fix;+    #        Suspend is pre-selected so Continue grabs the license). This is what+    #        un-sticks the launch: a blocked seat dialog made Fusion retry sign-in in a+    #        loop, foregrounding on every retry - the source of the endless fg-steal.+    #      • dismissing benign recovery/startup dialogs.+    #    Detection is deterministic (find_licensing_dialog by owning process + size,+    #    NOT the "Fusion360" title). No kill/relaunch (the server keeps the seat), no+    #    notify (we handle it ourselves). ──+    _RELAY_BUDGET = 50  # keep total handler time under AD's ~60s relay cap+    _deadline = _t.time() + max(6, int(_RELAY_BUDGET - (_t.time() - _start)))+    _seat_clicks = 0+    addin_ok = False+    while _t.time() < _deadline:+        # SEAT/LICENSING dialog takes PRIORITY over the add-in probe (fix, caught live+        # 2026-07-06): a STALE add-in port from a prior instance can answer while the+        # CURRENT Fusion sits blocked behind the seat dialog - so we must NOT break on+        # the add-in while the dialog is up. And the first UIA Invoke on a mid-render+        # dialog silently NO-OPs, so keep invoking 'Continue' EVERY tick until+        # find_licensing_dialog no longer sees it (verified gone), not just once.+        try:+            _lic = find_licensing_dialog()+        except Exception:+            _lic = None+        if _lic:+            # Resolve + SCREENSHOT-VERIFY it cleared (parent ownedPopupCount), not a+            # size heuristic that false-'resolved' the short confirm variant.+            if _seat_clicks < 10:+                _r = _resolve_seat_dialog(max_clicks=3)+                _seat_clicks += _r.get("clicks", 0)+            _t.sleep(1.0)+            continue+        # No seat dialog -> is the add-in actually serving? (ground truth for drivable)+        if _probe_addin(timeout=1.5):+            addin_ok = True+            break+        try:+            dismiss_recovery_dialog()+        except Exception:+            pass+        _t.sleep(2.5)++    if not addin_ok:+        # Add-in not up within our budget. On a first launch this is EXPECTED (still+        # initializing / signing in), not a failure and not a retry-able timeout.+        return {+            "success": True,+            "stillLaunching": True,+            "addinConnected": False,+            "statusVerb": "fusion_readiness",+            "recoveryFilesRelocated": reloc["moved"],+            "resolvedPath": fusion_info.get("exe_path", ""),+            "seatDialogResolved": _seat_clicks > 0,+            "backgroundLaunch": True,+            "_hint": (+                "Fusion was launched IN THE BACKGROUND (minimized, foreground-lock on) so it does not "+                "steal the user's focus - do NOT foreground it. "+                + ("A seat 'Active Sessions Exceeded' dialog appeared and was AUTO-RESOLVED in the "+                   "background via UIA (no foreground). " if _seat_clicks else "")+                + "The add-in isn't up yet - a first launch / fresh sign-in can take 2-4 min. This is "+                "NOT a failure and NOT a retry-able timeout: do NOT re-call fusion_start. WHAT HAPPENS "+                "NEXT: POLL fusion_readiness every ~10s; it returns needsSignin/licensingDialog/updating "+                "if something is mid-flight (the bridge handles the seat dialog itself), and ready:true "+                "when it is drivable - then run your fusion_* verbs. NEVER poll blind past ~2 min: "+                "desktop_screenshot_window the Fusion hwnd and READ the response's screenshots[] array "+                "- every OWNED POPUP rides back as its own child shot (this is how you SEE a blocking "+                "dialog; never desktop_screenshot_screen, the user is doing other things)."+            ),+        }++    # Auto-dismiss the "Recovered Documents" dialog if it appeared on launch.+    # Even though we relocated files above, Fusion may still show recovery+    # dialogs if files were locked or if Fusion cached them.+    recovery_dismissed = dismiss_recovery_dialog()++    # Probe the add-in to check if a modal dialog is blocking.+    # If Fusion has a "Recovered Documents" or other modal up, the add-in's+    # main thread will be blocked and commands will timeout. We detect this+    # PROACTIVELY so the AI knows immediately, rather than waiting for a+    # 30-second command timeout.+    dialog_blocking = False+    if addin_ok:+        dialog_blocking = _check_main_thread_blocked()++    output = "Fusion 360 launched successfully."+    if reloc["moved"] > 0:+        output += (+            f" Relocated {reloc['moved']} recovery file(s) to {reloc['dest']}"+            " (preserved for manual recovery if needed)."+        )+    if recovery_dismissed:+        output += " Dismissed 'Recovered Documents' dialog."+    if dialog_blocking:+        output = (+            "Fusion 360 launched but a MODAL DIALOG is blocking the UI. "+            "This is likely the 'Recovered Documents' dialog from a previous crash. "+            "Take a screenshot with desktop_screenshot_window to see what's showing, "+            "then dismiss it manually or use fusion_dismiss_recovery."+        )++    return {+        "success": True,+        "output": output,+        "addinConnected": addin_ok,+        "dialogBlocking": dialog_blocking,+        "recoveryDialogDismissed": recovery_dismissed,+        "recoveryFilesRelocated": reloc["moved"],+        "recoveryFilesPath": reloc["dest"],+        "resolvedPath": fusion_info.get("exe_path", ""),+    }+++def _handle_dismiss_recovery(fusion_info: dict, args: dict) -> dict:+    """Dismiss the 'Recovered Documents' dialog if it's showing.++    Recovery files are relocated to ~/.adom/recovery/fusion/<timestamp>/+    instead of being deleted, preserving the user's safety net.+    """+    from handlers.dismiss_recovery import relocate_recovery_files++    # Relocate files first (dismiss_recovery_dialog also does this,+    # but we capture the result here for reporting)+    reloc = {"moved": 0, "dest": None}+    try:+        reloc = relocate_recovery_files()+    except Exception:+        pass++    dismissed = dismiss_recovery_dialog()++    parts = []+    if dismissed:+        parts.append("Dismissed recovery dialog(s).")+    else:+        parts.append("No recovery dialog found.")+    if reloc["moved"] > 0:+        parts.append(+            f"Relocated {reloc['moved']} recovery file(s) to {reloc['dest']}. "+            "The user can restore them from there if needed."+        )++    return {+        "success": True,+        "output": " ".join(parts),+        "dismissed": dismissed,+        "recoveryFilesRelocated": reloc["moved"],+        "recoveryFilesPath": reloc["dest"],+    }+++def _post_open_screenshot(result: dict, settle_time: float = 2.0) -> dict:+    """Auto-screenshot Fusion after an open/switch command.++    Waits for Fusion to settle, then captures the full window (including+    any Qt dialogs or CEF overlays the API can't see). Attaches the+    screenshot path to the result so the AI can inspect it immediately+    without making a separate call.++    The screenshot is embedded into the result's 'data' field because the+    Tauri relay only forwards 'output', 'data', 'success', and 'error'.++    Also checks for blocking dialogs and warns in the response.+    """+    import re+    import time+    time.sleep(settle_time)++    # Check if main thread is blocked (dialog present)+    blocked = _check_main_thread_blocked()++    # Capture main Fusion window+    screenshot = screenshot_fusion_window()+    screenshot_info = {"screenshots": []}++    if screenshot.get("success"):+        screenshot_info["screenshots"].append({+            "type": "main_window",+            "savedTo": screenshot["savedTo"],+            "sizeKB": screenshot["sizeKB"],+        })+    else:+        screenshot_info["error"] = screenshot.get("error", "Screenshot failed")++    # Also capture ALL dialog windows — these are separate Qt windows+    # that the main window screenshot won't show+    window_info = get_fusion_window_info()+    if window_info.get("success"):+        dialogs = window_info.get("dialogs", [])+        for dialog in dialogs:+            try:+                title = dialog.get("title", "")+                hwnd = dialog.get("hwnd")+                # Skip tiny/hidden windows and banners (only screenshot real dialogs)+                rect = dialog.get("rect", {})+                w = rect.get("width", 0)+                h = rect.get("height", 0)+                if w < 50 or h < 50:+                    continue+                # Screenshot this dialog — sanitize label for filesystem+                label = re.sub(r'[<>:"/\\|?*]', '', title).replace(" ", "_")[:30]+                dialog_ss = screenshot_hwnd(hwnd, label=f"dialog-{label}")+                if dialog_ss.get("success"):+                    screenshot_info["screenshots"].append({+                        "type": "dialog",+                        "title": title,+                        "hwnd": hwnd,+                        "savedTo": dialog_ss["savedTo"],+                        "sizeKB": dialog_ss["sizeKB"],+                    })+            except Exception as e:+                screenshot_info.setdefault("errors", []).append(+                    f"Failed to screenshot dialog '{title}': {e}"+                )++    screenshot_info["message"] = (+        f"Auto-captured {len(screenshot_info['screenshots'])} Fusion window(s) after open. "+        "READ EACH screenshot file to check for blocking dialogs "+        "('What to design?', 'PCB out of date', 'Save changes?'). "+        "PCB/ELECTRONICS NAV RULE: a board is NOT a standalone file - the schematic, 2D board and 3D board are VIEWS inside ONE electronics DESIGN. Open the DESIGN document first (fusion_open_cloud_file or fusion_open_by_urn), THEN switch to its schematic and its 2D board (fusion_show_2d_board) and 3D board (fusion_show_3d_board). A Select-Electronics-Design-File picker IS that design list of its schematic + board. An EMPTY 2D board usually means you opened a derivative or the wrong file, or it is out of sync - reopen the parent electronics DESIGN and switch to its board view; do NOT open a .brd or .sch as a standalone file. "+        "Dismiss with fusion_send_key {\"key\": \"escape\"} if needed."+    )++    if blocked:+        screenshot_info["dialogBlocking"] = True+        screenshot_info["message"] = (+            "WARNING: A modal dialog is blocking Fusion. "+            "READ the screenshot to identify and dismiss it. " ++            screenshot_info["message"]+        )++    # Inject screenshot into the result's top-level 'data' dict.+    # The Tauri relay constructs its output as: json!({"message": output, "data": data})+    # so anything we put in result["data"] gets forwarded to the CLI.+    result.setdefault("data", {})+    if not isinstance(result["data"], dict):+        result["data"] = {}+    result["data"]["postOpenScreenshot"] = screenshot_info++    # UNIVERSAL: if an owned "Error" popup is up (e.g. malformed .lbr "has errors and+    # cannot be opened"), flip success -> False + surface it. An open that silently+    # errored must never be reported as ok.+    return _apply_failure_dialogs(result)+++def _capture_dialog_array(settle: float = 0.6) -> dict | None:+    """After a mutating op, enumerate + screenshot any owned popups / modal dialogs so+    the AI can SEE and ANALYZE them. Titles alone are not enough: a dialog's body text+    lives in CEF/Qt child controls that are NOT Win32-readable, so we attach a per-dialog+    screenshot (hwnd-targeted PrintWindow, background - never foreground/fullscreen).++    Cheap when nothing is up: it enumerates windows (Win32 EnumWindows) and only+    screenshots when a real dialog is present. Returns None when no dialog is up;+    otherwise {dialogsDetected, dialogs:[{hwnd,title,category,resolution,screenshot}],+    _hint}. Never raises - best effort.+    """+    import re as _re+    import time as _t+    if settle:+        _t.sleep(settle)+    try:+        info = get_fusion_window_info()+    except Exception:+        return None+    if not info.get("success"):+        return None+    raw = info.get("dialogs") or []+    if not raw:+        return None+    main_enabled = info.get("mainEnabled", True)+    try:+        classified = {c.get("hwnd"): c for c in classify_blocking_dialogs()}+    except Exception:+        classified = {}+    # Categories that mean "we could not identify it by title" - i.e. a generic+    # "Fusion360"-titled window. When the main window is still ENABLED, such a+    # window is a docked panel (Browser/Timeline), NOT a blocking modal, so we+    # suppress it to avoid crying wolf on every op. A real modal DISABLES the main+    # window (main_enabled False) and is always surfaced; known dialogs (recovery,+    # update, save prompts) are surfaced regardless of the enabled state.+    _GENERIC = {None, "generic_modal_read_screenshot", "unknown"}+    out = []+    for d in raw:+        rect = d.get("rect", {})+        if rect.get("width", 0) < 80 or rect.get("height", 0) < 50:+            continue  # skip banners / tiny docked tool windows+        hwnd = d.get("hwnd")+        title = d.get("title", "")+        c = classified.get(hwnd) or {}+        cat = c.get("category")+        if main_enabled and cat in _GENERIC:+            continue  # docked panel, not a modal - nothing is blocking the main window+        entry = {"hwnd": hwnd, "title": title}+        if cat:+            entry["category"] = cat+            entry["resolution"] = c.get("resolution")+        try:+            label = _re.sub(r'[<>:"/\\|?*]', "", title).replace(" ", "_")[:30] or "dialog"+            ss = screenshot_hwnd(hwnd, label=f"dlg-{label}")+            if ss.get("success"):+                entry["screenshot"] = ss["savedTo"]+        except Exception:+            pass+        out.append(entry)+    if not out:+        return None+    return {+        "dialogsDetected": len(out),+        "dialogs": out,+        "mainWindowEnabled": main_enabled,+        "_hint": (+            f"⚠️ {len(out)} Fusion dialog(s)/owned popup(s) are up after this operation. "+            "STOP and ANALYZE before the next call: READ each dialogs[].screenshot and use its "+            "category/resolution. Do NOT blind-dismiss - some confirms LOSE work (clicking Yes on a "+            "Hub-upload 'are you sure you want to close?' destroys the uploaded packages). Take the "+            "work-preserving action (often: click No, then wait for the upload to drain). See the "+            "fusion-driving skill."+        ),+    }+++def _merge_dialog_array(result: dict) -> dict:+    """Attach the post-op dialog array (if any) to a mutating verb's result, so the AI+    always sees pending dialogs WITHOUT having to remember a separate call. The hint is+    mirrored to top-level _hint AND into data.dialogArray (data is forwarded reliably by+    the relay). No-op when no dialog is up."""+    if not isinstance(result, dict):+        return result+    try:+        arr = _capture_dialog_array()+    except Exception:+        arr = None+    if not arr:+        return result+    result.setdefault("data", {})+    if isinstance(result.get("data"), dict):+        result["data"]["dialogArray"] = arr+    existing = result.get("_hint")+    result["_hint"] = arr["_hint"] + ((" | " + existing) if existing else "")+    return _apply_failure_dialogs(result)+++def _apply_failure_dialogs(result: dict) -> dict:+    """UNIVERSAL error-dialog guard — baked into every mutating/open verb so the AI+    can never again report a failed op as success.++    Right after an operation, an owned Fusion popup titled "Error" (e.g. "<file>.lbr+    has errors and cannot be opened") means the op FAILED even when the underlying API+    call returned ok (Fusion opened an EMPTY design behind the error). This classifies+    any up dialog; if one is in FAILURE_CATEGORIES it:+      1. SCREENSHOTS the popup (hwnd-targeted, background) so the AI SEES it,+      2. flips result.success -> False + sets errorCode/error/_hint,+      3. dismisses the pure-ack popup (WM_CLOSE) so it can't block the next command.+    Best-effort + cheap when nothing is up (title enumeration, no screenshot). Never raises."""+    try:+        from handlers.dialog_classify import classify_blocking_dialogs, FAILURE_CATEGORIES+        dialogs = classify_blocking_dialogs()+    except Exception:+        return result+    failures = [d for d in (dialogs or []) if d.get("category") in FAILURE_CATEGORIES]+    if not failures or not isinstance(result, dict):+        return result+    f = failures[0]+    shots = []+    for d in failures:+        try:+            ss = screenshot_hwnd(d.get("hwnd"), label="error-dialog")+            if ss.get("success"):+                shots.append(ss["savedTo"])+        except Exception:+            pass+    prev_err = result.get("error") or ""+    result["success"] = False+    result["errorCode"] = "fusion_operation_error"+    result["error"] = (+        f"Operation FAILED - Fusion '{f.get('title') or 'Error'}' dialog is up"+        + (f" (prior: {prev_err})" if prev_err else "")+    )+    result["_hint"] = (f.get("resolution") or+                       "Fusion shows an Error dialog; the operation failed. Read the screenshot.")+    result.setdefault("data", {})+    if isinstance(result.get("data"), dict):+        result["data"]["errorDialog"] = {+            "title": f.get("title"), "category": f.get("category"),+            "resolution": f.get("resolution"), "screenshots": shots,+        }+    # Pure OK-acknowledgement — dismiss so it doesn't wedge the next command.+    for d in failures:+        try:+            close_window(d.get("hwnd"))+        except Exception:+            pass+    return result+++def _handle_open_cloud_file(fusion_info: dict, args: dict) -> dict:+    """Open a cloud file with post-open dialog detection + auto-screenshot.++    Proxies to the add-in's open_cloud_file command, then:+    1. Waits for Fusion to settle+    2. Checks if a modal dialog blocked the main thread+    3. Auto-screenshots the Fusion window so the AI can see what happened+    """+    # Send the open command — cloud files can take 30+ seconds to download/open+    result = _proxy_to_addin("open_cloud_file", args, timeout=45)++    if not result.get("success"):+        # Failure is often a modal blocking the add-in's main thread (multi-design+        # "Select Electronics Design File" picker, update nag, recovery, etc.) — which+        # otherwise surfaces as a misleading "add-in not responding". Classify the+        # actual blocking dialog so the caller gets the real cause + resolution.+        dialogs = classify_blocking_dialogs()+        if dialogs:+            result["dialogBlocking"] = True+            result["blockingDialogs"] = dialogs+            result["hint"] = (+                "A modal dialog is blocking Fusion — see blockingDialogs for the "+                "classified cause + resolution. The multi-link 'Select Electronics "+                "Design File' picker is a CEF dialog that must be resolved on the "+                "desktop (its list isn't keyboard/Win32 navigable); recovery prompts "+                "use fusion_dismiss_recovery; an update nag must be cleared on the desktop."+            )+        # Screenshot even on failure — shows what went wrong+        return _post_open_screenshot(result, settle_time=1.0)++    return _post_open_screenshot(result)+++def _handle_open_with_screenshot(fusion_info: dict, args: dict, command: str) -> dict:+    """Proxy an open/switch command to the add-in, then auto-screenshot.++    Used for open_schematic, open_board, show_3d_board, show_2d_board —+    any command that changes the Fusion UI state and might trigger a dialog.+    """+    result = _proxy_to_addin(command, args, timeout=30)+    if result.get("success"):+        return _post_open_screenshot(result)+    return result+++def _handle_screenshot_fusion(fusion_info: dict, args: dict) -> dict:+    """Screenshot Fusion main window or a specific dialog by HWND."""+    hwnd = args.get("hwnd") or args.get("dialogHwnd")+    if hwnd:+        result = screenshot_hwnd(int(hwnd), label="dialog")+    else:+        result = screenshot_fusion_window()+    if result.get("success"):+        result["output"] = json.dumps({"savedTo": result["savedTo"], "sizeKB": result["sizeKB"]})+    return result+++def _handle_click_fusion(fusion_info: dict, args: dict) -> dict:+    """Click at coordinates within Fusion window or a specific dialog HWND."""+    x = args.get("x", 0.5)+    y = args.get("y", 0.5)+    relative = args.get("relative", True)+    hwnd = args.get("hwnd") or args.get("dialogHwnd")+    # SendInput clicks REQUIRE foreground (steals the user's focus). Announce it first -+    # prefer background verbs (desktop_ui_click by name); this path is the last resort.+    _notify_before_foreground(args.get("reason") or "clicking a control Fusion only accepts via a foreground click")+    result = click_fusion(x, y, relative, hwnd=hwnd)+    if result.get("success"):+        result["output"] = json.dumps(result.get("clickedAt", {}))+    return result+++def _handle_send_key(fusion_info: dict, args: dict) -> dict:+    """Send a key to Fusion or a specific dialog via SendInput."""+    key = args.get("key", "")+    if not key:+        return {"success": False, "error": "No key specified. Use 'enter', 'escape', 'tab', etc."}+    hwnd = args.get("hwnd") or args.get("dialogHwnd")+    # SendInput keystrokes REQUIRE foreground (steals the user's focus). Announce it first -+    # prefer background verbs (desktop_ui_set / WM_CLOSE); this path is the last resort.+    _notify_before_foreground(args.get("reason") or f"sending the '{key}' key, which Fusion only accepts in the foreground")+    result = send_key_to_fusion(key, hwnd=hwnd)+    if result.get("success"):+        result["output"] = f"Sent key '{key}' to Fusion."+    return result+++def _handle_close_window(fusion_info: dict, args: dict) -> dict:+    """Close a dialog window by sending WM_CLOSE (equivalent to clicking X).++    Unlike Escape, this works on dialogs that don't have Cancel/Escape handling+    (e.g. the Recovered Documents dialog). Does NOT force-kill — the dialog can+    still intercept WM_CLOSE and prompt for confirmation.+    """+    hwnd = args.get("hwnd")+    if not hwnd:+        return {"success": False, "error": "Missing required arg: hwnd"}+    result = close_window(int(hwnd))+    if result.get("success"):+        result["output"] = f"Sent WM_CLOSE to hwnd {hwnd}."+    return result+++def _handle_window_info(fusion_info: dict, args: dict) -> dict:+    """Get Fusion window info including any dialog windows."""+    result = get_fusion_window_info()+    if result.get("success"):+        dialogs = result.get("dialogs", [])+        dialog_info = [f"  hwnd={d['hwnd']}: {d['title']}" for d in dialogs]+        output_parts = [+            f"Main: hwnd={result['hwnd']}, title='{result['title']}'",+        ]+        if dialogs:+            output_parts.append(f"Dialogs ({len(dialogs)}):")+            output_parts.extend(dialog_info)+        else:+            output_parts.append("No dialog windows detected.")+        if dialogs:+            hint = (f"{len(dialogs)} dialog(s) detected. Screenshot each via "+                    "desktop_screenshot_window {{\"hwnd\":<hwnd>}}, then dismiss with "+                    "fusion_dismiss_blocking_dialogs or fusion_send_key/fusion_close_window.")+        else:+            hint = "No dialogs — Fusion is clear. Safe to run fusion_* commands."+        result["output"] = json.dumps({+            "hwnd": result["hwnd"],+            "title": result["title"],+            "rect": result["rect"],+            "dialogs": dialogs,+            "message": "\n".join(output_parts),+            "_hint": hint,+        })+    return result+++def _handle_screenshot_all_fusion(fusion_info: dict, args: dict) -> dict:+    """Screenshot Fusion main window AND all dialog windows.++    Returns paths to all captured screenshots so the AI can see+    CEF overlay dialogs and separate Qt dialog windows.+    """+    screenshots = []++    # 1. Capture main Fusion window from screen (captures CEF overlays)+    main_result = screenshot_fusion_window()+    if main_result.get("success"):+        screenshots.append({+            "type": "main_window",+            "path": main_result["savedTo"],+            "sizeKB": main_result["sizeKB"],+        })++    # 2. List all Qt dialog windows (AI should use desktop_screenshot_window for each)+    info = get_fusion_window_info()+    if info.get("success") and info.get("dialogs"):+        for dialog in info["dialogs"]:+            screenshots.append({+                "type": "dialog",+                "title": dialog["title"],+                "hwnd": dialog["hwnd"],+                "rect": dialog["rect"],+                "hint": f"Use desktop_screenshot_window with hwnd={dialog['hwnd']} to capture this dialog.",+            })++    return {+        "success": True,+        "screenshots": screenshots,+        "dialogCount": len(info.get("dialogs", [])) if info.get("success") else 0,+        "_hint": f"Captured {len(screenshots)} screenshot(s). Screenshots saved on Windows — "+                 "use pull_file to get them to Docker, or Read tool to view each path directly. "+                 "If dialogs are present, dismiss with fusion_dismiss_blocking_dialogs.",+    }+++def _handle_relocate_recovery(fusion_info: dict, args: dict) -> dict:+    """Relocate Fusion crash recovery files to ~/.adom/recovery/fusion/.++    Call this proactively before launching Fusion to prevent recovery+    dialogs from appearing. Files are preserved (not deleted) so the+    user can manually restore them if needed.+    """+    from handlers.dismiss_recovery import relocate_recovery_files+    try:+        reloc = relocate_recovery_files()+    except Exception as exc:+        return {+            "success": False,+            "error": f"Failed to relocate recovery files: {exc}",+        }++    if reloc["moved"] > 0:+        return {+            "success": True,+            "output": (+                f"Relocated {reloc['moved']} recovery file(s) to {reloc['dest']}. "+                "These files are preserved — the user can restore them from that "+                "directory if they need to recover unsaved work."+            ),+            "moved": reloc["moved"],+            "dest": reloc["dest"],+            "sources": reloc["sources"],+        }+    return {+        "success": True,+        "output": "No crash recovery files found.",+        "moved": 0,+    }+++def _installer_running() -> bool:+    """Is the Fusion Client Downloader / streamer currently installing? Bridge-side+    check (subprocess from OUR process = no AD shell-approval gate)."""+    try:+        import subprocess as _sp+        out = _sp.run(["tasklist", "/FO", "CSV", "/NH"], capture_output=True, text=True,+                      timeout=15, creationflags=getattr(_sp, "CREATE_NO_WINDOW", 0)).stdout+        return ("streamer.exe" in out) or ("FusionDL.exe" in out) or ("Fusion Client Downloader" in out)+    except Exception:+        return False+++def _handle_install_fusion(fusion_info: dict, args: dict) -> dict:+    """Install Fusion 360 FOR the user - no shell_execute, no AD approval gate.++    Declared as detect.installVerb (AD >=1.9.79) so bridge_readiness recommends+    THIS over the generic winget fallback. Downloads the official Fusion Client+    Downloader and runs it silently: --globalinstall when elevated, PER-USER when+    not (learned live: a non-admin shell makes --globalinstall die silently on+    UAC; the per-user install needs no elevation and lands in %LOCALAPPDATA%,+    which detect covers). Returns PROMPTLY - the stream takes 10-30 min; poll+    fusion_readiness until installed:true."""+    # LIVE detect only - never trust the bridge-start fusion_info snapshot, which can be+    # stale-True after Fusion was removed while the bridge kept running (found live on a+    # fresh VM 2026-07-05: this verb refused with "already installed" though disk was+    # NO_WEBDEPLOY, blocking the reinstall). detect_fusion() is the on-disk truth.+    if detect_fusion().get("installed"):+        return {"success": True, "alreadyInstalled": True,+                "_hint": "Fusion 360 is already installed - call fusion_start to launch it."}+    if _installer_running():+        return {"success": True, "installing": True, "statusVerb": "fusion_readiness",+                "_hint": "An install is ALREADY streaming (10-30 min). Poll fusion_readiness "+                         "until installed:true - do not start a second installer."}++    import tempfile, urllib.request as _ur, subprocess as _sp, ctypes as _ct+    stub = os.path.join(tempfile.gettempdir(), "FusionClientDownloader.exe")+    try:+        _ur.urlretrieve(+            "https://dl.appstreaming.autodesk.com/production/installers/Fusion%20Client%20Downloader.exe",+            stub)+    except Exception as e:+        return {"success": False, "error": f"Installer download failed: {e}",+                "errorCode": "installer_download_failed",+                "_hint": "Check the box is online; retry fusion_install_fusion. The stub URL is "+                         "Autodesk's official appstreaming installer."}+    try:+        elevated = bool(_ct.windll.shell32.IsUserAnAdmin()) if hasattr(_ct, "windll") else False+    except Exception:+        elevated = False+    cmd = [stub, "--quiet"] + (["--globalinstall"] if elevated else [])+    try:+        _sp.Popen(cmd, stdin=_sp.DEVNULL, stdout=_sp.DEVNULL, stderr=_sp.DEVNULL,+                  creationflags=getattr(_sp, "CREATE_NO_WINDOW", 0) | getattr(_sp, "DETACHED_PROCESS", 0))+    except Exception as e:+        return {"success": False, "error": f"Installer launch failed: {e}",+                "errorCode": "installer_launch_failed"}+    return {+        "success": True, "installing": True, "elevated": elevated,+        "mode": "globalinstall" if elevated else "per-user",+        "statusVerb": "fusion_readiness",+        "_hint": ("Fusion 360 install STARTED (" + ("system-wide" if elevated else+                  "per-user - no admin needed; a non-elevated --globalinstall dies silently on UAC") ++                  "). It streams several GB (10-30 min): poll fusion_readiness until installed:true "+                  "(it also reports installing:true while the streamer runs), then fusion_start. "+                  "NOTIFY the user it is underway (progress toast) and again at the Autodesk sign-in."),+    }+++def _aps_quick_state() -> dict:+    """Cheap APS state for readiness: {configured, signedIn}. Never raises, never blocks."""+    try:+        import aps as _aps+        st = _aps.handle_status({}) or {}+        d = st.get("data", st)+        return {"configured": bool(d.get("configured")), "signedIn": bool(d.get("signedIn"))}+    except Exception:+        return {"configured": None, "signedIn": None}+++def _handle_fusion_readiness(fusion_info: dict, args: dict) -> dict:+    """FAST readiness check for the AI: is the Fusion host app present + running + the bridge ready?+    Does NOT launch Fusion (fast-fails when it's not running). AD DETECTS Fusion (never installs it)+    via bridge.json 'detect', and auto-installs Python if missing (AD >=1.9.47) - this verb just+    reports state. Pairs with AD's bridge_readiness."""+    # ALWAYS live-detect. fusion_info is a bridge-START snapshot; Fusion may have been+    # installed OR UNINSTALLED since. Trusting a cached installed:True made readiness+    # report installed/running/ready:true on a machine where Fusion had been removed+    # (found live on a fresh VM 2026-07-05: NO_WEBDEPLOY on disk, yet readiness said+    # ready). detect_fusion() is a fast filesystem scan, so pay it every call.+    raw_installed = bool(detect_fusion().get("installed"))+    # `streaming` must be RELIABLE. _installer_running() is a tasklist check, but the Autodesk streamer+    # spawns short-lived per-chunk workers, so it reads False most of a live multi-GB stream (flickered+    # True only 3 of 25 polls during a real install, John 2026-07-14). OR in the disk-state signal - a+    # webdeploy hash dir with FusionLauncher.exe but a missing/truncated .ini - which is present for the+    # WHOLE stream. Now `installing` is trustworthy, not racy.+    streaming = _installer_running() or _incomplete_webdeploy_present()+    running = _is_fusion_running() if raw_installed else False+    # FRESH-INSTALL COMPLETENESS GUARD (John caught this live 2026-07-14 on a fresh Hyper-V VM):+    # the streamer writes FusionLauncher.exe BEFORE it finishes writing FusionLauncher.exe.ini, so+    # detect_fusion() flips installed:true mid-stream. Starting into that half-written install throws+    # "Error Launching Streamed Application ... FusionLauncher.exe.ini is missing or incomplete" and+    # leaves TWO corrupt webdeploy production dirs. So: while the streamer is running and Fusion is+    # NOT already up (i.e. this is a first install, not a background auto-update of a running Fusion),+    # the install is STILL STREAMING - report installed:false + installing:true so nothing calls+    # fusion_start yet. Only treat it as installed once the streamer has exited.+    if streaming and not running:+        installed = False+        installing = True+    else:+        installed = raw_installed+        installing = (not installed) and streaming++    # The add-in only serves once Fusion is PAST sign-in (its HTTP server runs on+    # Fusion's main thread, which the sign-in modal blocks). So a responding add-in+    # is the definitive "signed in + drivable" signal.+    addin_status = _check_addin_status(timeout=1.0) if running else None+    addin_ok = addin_status is not None++    # Fusion can be RUNNING yet not drivable: the first-run Autodesk sign-in modal+    # ("Signing in - Autodesk Fusion" / "Welcome to Fusion") blocks the main thread,+    # so the add-in never serves and every modeling verb hangs. The old code reported+    # ready:True here purely on installed+running, which was a lie during sign-in.+    # Detect it from the FUSION-PROCESS window title - NOT classify_launch_dialogs,+    # which scans ALL top-level windows and false-matches a stray Edge "Sign in -+    # Autodesk" BROWSER tab (caught 2026-07-05).+    #+    # ⚠️ CHECK IT WHENEVER RUNNING, not only when the add-in is silent (fix 2026-07-06,+    # John caught it lying): a STALE add-in HTTP server from a just-killed instance can+    # still hold port 8774 and answer, so `addin_ok` goes True while the CURRENT Fusion+    # sits stuck on "Signing in". Gating the sign-in check on `not addin_ok` then let+    # readiness report ready:true for a dead, stuck-signing-in window. The Fusion-process+    # window title is authoritative - if it says "Signing in", we are NOT ready, period.+    needs_signin = False+    if running:+        try:+            finfo = get_fusion_window_info() or {}+            _t = (finfo.get("title") or "").lower()+            needs_signin = ("signing in" in _t or "welcome to fusion" in _t+                            or ("sign in - autodesk" in _t and "fusion" in _t))+        except Exception:+            needs_signin = False+    # "Ready to drive" requires the add-in to actually RESPOND (not just the process to+    # exist) AND no sign-in modal. A responding add-in proves Fusion's main thread is+    # free; requiring it stops a half-launched or stuck Fusion from reading as ready.+    ready = installed and running and addin_ok and not needs_signin++    # SEAT/LICENSING dialog blocking the launch - detected DETERMINISTICALLY by owning+    # process + dialog size (not the "Fusion360" title). Checked WHENEVER running, not+    # only when the add-in is silent (fix 2026-07-06): a STALE add-in port from a prior+    # instance answers while the CURRENT Fusion sits blocked behind the seat dialog, so+    # gating on `not addin_ok` false-negatived `licensingDialog` while the dialog sat+    # right there on the user's screen.+    #+    # ⚠️ GAP FIX (John caught it live 2026-07-06): the launch loop only auto-resolves the+    # seat dialog during its ~50s window. But this dialog also appears LATER - minutes+    # after sign-in, when the license server notices too many sessions - long after that+    # loop exited, so it just sat there blocking while readiness passively reported+    # licensingDialog:true. Now readiness SELF-HEALS: whenever it detects the dialog it+    # AUTO-RESOLVES it in the background (UIA 'Continue' + screenshot-verify), so the seat+    # is handled deterministically in CODE no matter when it shows up - the AI never has+    # to notice or act. (John: "do all this tracking from your code so it's deterministic+    # ... the ai never follows skills.")+    seat_resolved = False+    licensing = False+    if running:+        try:+            licensing = find_licensing_dialog() is not None+        except Exception:+            licensing = False+        if licensing:+            try:+                _r = _resolve_seat_dialog()+                seat_resolved = _r.get("clicks", 0) > 0+                # Re-check: did it actually clear? (screenshot-verified inside.)+                licensing = find_licensing_dialog() is not None+            except Exception:+                pass+            if not licensing:+                # Cleared - re-probe the add-in; Fusion may be drivable now.+                try:+                    addin_status = _check_addin_status(timeout=1.5)+                    addin_ok = addin_status is not None+                    ready = installed and running and addin_ok and not needs_signin+                except Exception:+                    pass+    if licensing:+        ready = False  # a seat/licensing dialog blocking the UI is never "ready"++    # Fusion applying an AUTO-UPDATE (two+ webdeploy builds) crash-restarts itself every+    # ~30-60s, so a verb intermittently sees running:false. That is NOT a crash or a+    # bridge failure - surface it as a distinct non-fatal `updating` flag so a driving AI+    # waits/retries instead of giving up. Only check when installed but not currently+    # ready (avoid noise when Fusion is happily drivable).+    updating = False+    if installed and not ready:+        try:+            updating = fusion_update_in_progress()+        except Exception:+            updating = False++    # When Fusion is up + signed in, also check the add-in isn't STALE (issue #55) so+    # a fresh readiness call surfaces the mismatch instead of a later verb failing+    # silently. Reuse the add-in status already fetched above (no second probe).+    stale_info = {}+    if addin_ok:+        stale_info = _addin_staleness(addin_status.get("version"))++    if updating and not ready:+        hint = ("Fusion is applying an AUTO-UPDATE (multiple webdeploy builds present) and "+                "RESTARTS ITSELF every ~30-60s - this is NOT a crash or a bridge failure. Poll "+                "fusion_readiness; it stabilizes to ready:true once the update finishes (can take "+                "10-30 min). Meanwhile, wrap modeling/export verbs in a short retry/catch-loop to "+                "ride the up-windows rather than treating an intermittent 'not running' as fatal.")+    elif needs_signin:+        hint = ("Fusion is RUNNING but stuck at the first-run Autodesk SIGN-IN (main UI blocked, add-in "+                "cannot serve) - NOT ready. FULL PLAYBOOK: the `fusion-autodesk-signin` skill. Proven "+                "sequence + the pitfalls that cost hours on a fresh Hyper-V VM (John 2026-07-14): "+                "(1) BLANK white webview on first launch? fusion_stop then fusion_start - the sign-in "+                "renders 'Welcome to Fusion' + a 'Sign In' button only after a clean relaunch. "+                "(2) ASK the user FIRST (AskUserQuestion): do they have a warm Google/Apple/Microsoft "+                "session for the account LINKED to their Autodesk login? Offer to drive it. A truly "+                "fresh box has NO warm browser session, so 'Continue with Google' still hits a password "+                "wall. (3) Click Fusion 'Sign In' -> it opens the DEFAULT browser (Edge) to the OAuth. "+                "Edge windows DO exist even if PowerShell EnumWindows looks empty - that is CLIXML "+                "progress noise; set $ProgressPreference='SilentlyContinue', and just "+                "desktop_screenshot_window the Edge hwnd (PrintWindow works backgrounded). "+                "(4) In the browser: 'Continue with Google' -> type the user's EMAIL (not secret) -> if "+                "it asks for a PASSWORD/2FA, STOP and fusion_notify_owner {title, body} to toast their "+                "MAIN computer so THEY type it; NEVER enter password/2FA yourself. (5) Handoff back to "+                "Fusion is via the 'Autodesk Identity Manager' protocol - Edge shows a 'This site is "+                "trying to open...' overlay: tick 'Always allow' + click 'Open'. (6) SPEED MATTERS: the "+                "sign-in code EXPIRES in ~2 min; on a high-latency VM slow screenshot/click round-trips "+                "let it expire ('Sign-in request expired') and you loop. Minimize steps; if it expires, "+                "click Fusion 'Sign In' again for a FRESH code (the browser session stays warm). "+                "Then poll until ready:true. FIRST-TIME DEMO? Run fusion_demo instead - it drives this "+                "sign-in through the user's NATIVE browser (already signed into Autodesk) and then "+                "gives them a real guided tour (project -> schematic -> 2D board -> 3D board).")+    elif ready and stale_info.get("addinStale"):+        hint = stale_info["_staleHint"]+    elif ready:+        # AUTO-UPDATE, checked on EVERY launch and applied SILENTLY (John, 2026-07-22: "i just+        # always want the latest fusion... make this generally invisible to me"). Fusion nags+        # constantly; we click it in the BACKGROUND so the user never sees it. Sticky opt-out.+        _upd = _apply_fusion_update_silently()+        # APS is checked on EVERY launch now (John, 2026-07-22: "why aren't those just driven from+        # 1 login?"). Fusion sign-in and APS are both Autodesk logins; the user should authenticate+        # ONCE. They cannot share a token (Autodesk DPAPI-encrypts Fusion's store), but they CAN+        # share the warm browser SSO session - so APS consent is silent right after a Fusion login.+        _aps = _aps_quick_state()+        # MCP state, reported beside APS on every launch (both are search/drive surfaces the+        # AI should know about without probing). Cheap: a 0.4s loopback socket.+        _mcpmsg = (" Autodesk's MCP server is LIVE (fusion_mcp_* verbs work)."+                   if _probe_mcp_port() else+                   " Autodesk's MCP server is OFF - fusion_mcp_enable can turn it on for the "+                   "user (~10s, drives the Preferences dialog).")+        _updmsg = _mcpmsg+        if _upd.get("applied"):+            _updmsg += (" AUTO-UPDATE: Fusion was offering an update and I clicked %r for the user "+                       "IN THE BACKGROUND - it downloads now and the new build comes up on the next "+                       "launch. TELL THE USER it is handled; they never need to click the nag. "+                       "They can opt out with fusion_set_auto_update {enabled:false} (sticky)."+                       % _upd.get("button"))+        elif not _auto_update_enabled():+            _updmsg += (" AUTO-UPDATE is OFF (the user opted out, sticky) - do NOT click Fusion's "+                       "update prompts for them.")+        if _aps.get("signedIn"):+            hint = ("READY - drive Fusion (fusion_open_lbr / build_library_3d / etc.). APS cloud "+                    "search is signed in too, so use fusion_aps_search for ~2s cloud lookups."+                    + _updmsg)+        elif _aps.get("configured"):+            hint = ("READY to drive Fusion, but APS CLOUD SEARCH IS NOT SIGNED IN - cloud file "+                    "search will be slow/blocked. DO IT NOW, do not make the user log in a second "+                    "time: call fusion_aps_signin RIGHT AFTER a Fusion sign-in, while the browser "+                    "profile still has a warm Autodesk SSO session - it consents silently (no "+                    "password, no 2FA). Then fusion_aps_search works." + _updmsg)+        else:+            hint = ("READY to drive Fusion. APS cloud search is NOT SET UP (no client ID) - cloud "+                    "search is the slow path without it. Offer to set it up: fusion_aps_set_client_id "+                    "then fusion_aps_signin (silent while the browser SSO session is warm). APS is "+                    "how cloud search gets to ~2 seconds." + _updmsg)+    elif installed and running and licensing:+        hint = ("Fusion is blocked by a SEAT/LICENSING dialog ('Active Sessions Exceeded' / 'Suspend "+                "Remote Session') - detected DETERMINISTICALLY as an OWNED POPUP of the main Fusion "+                "window (its title is just 'Fusion360' and it can be as short as 262px, so it is found "+                "by owner+parent-screenshot, NOT a size guess). This verb JUST TRIED to auto-resolve it "+                "in the BACKGROUND (UIA 'Continue', Suspend pre-selected so it reclaims the seat) but it "+                "is still up - it may be mid-render (the first UIA Invoke no-ops). Just POLL "+                "fusion_readiness again in a few seconds; it keeps auto-resolving. Never coordinate-click "+                "or ask the user, and never kill/relaunch (the server keeps the seat).")+    elif installed and running:+        hint = ("Fusion is RUNNING but its add-in is NOT responding yet - NOT ready to drive. Either it "+                "is still finishing launch / signing in (poll fusion_readiness a few more times), or the "+                "add-in did not load (fusion_stop then fusion_start to re-deploy + re-load it). Do NOT "+                "fire modeling/export verbs until ready:true.")+    elif installed:+        hint = "Fusion is INSTALLED but not running - call fusion_start (blocks until the add-in is ready), then retry."+    else:+        hint = ("Fusion install is STREAMING (10-30 min) - keep polling fusion_readiness until installed:true."+                if installing else+                "Fusion 360 is NOT installed. OFFER to install it for the user, then call "+                "fusion_install_fusion (no shell approval needed) - it streams the free trial and "+                "this verb reports installing:true until done.")++    result = {+        "success": True,+        "hostApp": "Fusion 360",+        "installed": installed,+        "installing": installing,+        "running": running,+        "ready": ready,+        "bridgeVersion": BRIDGE_VERSION,+        **stale_info,+        "_hint": hint,+    }+    if updating:+        result["updating"] = True+        result["statusVerb"] = "fusion_readiness"+    if needs_signin:+        result["needsSignin"] = True+        result["statusVerb"] = "fusion_readiness"+    if licensing:+        result["licensingDialog"] = True+        result["statusVerb"] = "fusion_readiness"+    if seat_resolved:+        # We acted on it this call (whether or not it fully cleared yet).+        result["seatDialogAutoResolved"] = True+    return result+++# ── Fusion preferences (theme / navigation / units) ──────────────────────────+# Set via the adsk preferences API inside the live session (run_modeling_script),+# so this is a SERVER-only orchestrator - no add-in redeploy. Friendly keys map to+# adsk enums; unknown/failed keys are reported per-key (some themes, e.g. Dark Gray+# / Classic, aren't shipped in every Fusion build - proven live 2026-07-05, only+# LightGray/DarkBlue/Device were available). Theme changes apply LIVE (no restart).+_PREF_SCRIPT = r'''+import json+gp = app.preferences.generalPreferences+T  = adsk.core.UserInterfaceThemes+PZ = adsk.core.PanZoomOrbitShortcuts+MO = adsk.core.DefaultModelingOrientations+DU = adsk.fusion.DistanceUnits++THEME = {'light':[T.LightGrayUserInterfaceTheme],'lightgray':[T.LightGrayUserInterfaceTheme],+         'dark':[T.DarkGrayUserInterfaceTheme,T.DarkBlueUserInterfaceTheme],+         'darkgray':[T.DarkGrayUserInterfaceTheme],'darkblue':[T.DarkBlueUserInterfaceTheme],+         'classic':[T.ClassicUserInterfaceTheme],'device':[T.DeviceUserInterfaceTheme],+         'auto':[T.DeviceUserInterfaceTheme]}+THEME_NAME  = {0:'classic',1:'lightgray',2:'darkblue',3:'darkgray',4:'device'}+ORBIT = {'fusion360':PZ.Fusion360PanZoomOrbitShortcut,'fusion':PZ.Fusion360PanZoomOrbitShortcut,+         'alias':PZ.AliasPanZoomOrbitShortcut,'inventor':PZ.InventorPanZoomOrbitShortcut,+         'solidworks':PZ.SolidWorksPanZoomOrbitShortcut,'tinkercad':PZ.TinkercadPanZoomOrbitShortcut,+         'powermill':PZ.PowerMillPanZoomOrbitShortcut}+ORBIT_NAME  = {0:'fusion360',1:'alias',2:'inventor',3:'solidworks',4:'tinkercad',5:'powermill'}+ORIENT = {'yup':MO.YUpModelingOrientation,'zup':MO.ZUpModelingOrientation}+ORIENT_NAME = {0:'yup',1:'zup'}+UNITS = {'mm':DU.MillimeterDistanceUnits,'cm':DU.CentimeterDistanceUnits,'m':DU.MeterDistanceUnits,+         'in':DU.InchDistanceUnits,'inch':DU.InchDistanceUnits,'ft':DU.FootDistanceUnits}+UNITS_NAME  = {0:'mm',1:'cm',2:'m',3:'in',4:'ft'}++def read_current():+    cur = {+      'theme': THEME_NAME.get(gp.userInterfaceTheme, gp.userInterfaceTheme),+      'activeTheme': THEME_NAME.get(gp.activeUserInterfaceTheme, gp.activeUserInterfaceTheme),+      'invertScrollZoom': bool(gp.isZoomDirectionReversed),+      'orbitScheme': ORBIT_NAME.get(gp.panZoomOrbitShortcuts, gp.panZoomOrbitShortcuts),+      'modelingOrientation': ORIENT_NAME.get(gp.defaultModelingOrientation, gp.defaultModelingOrientation),+      'gestureNav': bool(gp.isGestureBasedViewNavigationUsed),+      'cameraPivot': bool(gp.isCameraPivotEnabled),+    }+    try:+        cur['lengthUnit'] = UNITS_NAME.get(design.fusionUnitsManager.distanceDisplayUnits,+                                           design.fusionUnitsManager.distanceDisplayUnits)+    except Exception:+        pass+    return cur++def try_set(obj, attr, cands, want):+    err = None+    for c in cands:+        if c is None: continue+        try:+            setattr(obj, attr, c)+            return {'requested': want, 'ok': True}+        except Exception as e:+            err = str(e)+    return {'requested': want, 'ok': False, 'error': err or 'no valid value'}++reqs = json.loads(__REQS__)+applied = {}+for k, v in reqs.items():+    kv = v if isinstance(v, bool) else str(v).strip().lower()+    if k == 'theme':+        c = THEME.get(kv);  applied[k] = try_set(gp,'userInterfaceTheme', c or [], v) if c else {'requested':v,'ok':False,'error':'unknown theme (use light/darkblue/darkgray/classic/device/dark)'}+    elif k in ('invertScrollZoom','reverseZoom'):+        applied['invertScrollZoom'] = try_set(gp,'isZoomDirectionReversed',[bool(v)],v)+    elif k == 'orbitScheme':+        c = ORBIT.get(kv);  applied[k] = try_set(gp,'panZoomOrbitShortcuts',[c],v) if c is not None else {'requested':v,'ok':False,'error':'unknown orbitScheme'}+    elif k == 'modelingOrientation':+        c = ORIENT.get(kv); applied[k] = try_set(gp,'defaultModelingOrientation',[c],v) if c is not None else {'requested':v,'ok':False,'error':'unknown modelingOrientation (yup/zup)'}+    elif k == 'gestureNav':+        applied[k] = try_set(gp,'isGestureBasedViewNavigationUsed',[bool(v)],v)+    elif k == 'cameraPivot':+        applied[k] = try_set(gp,'isCameraPivotEnabled',[bool(v)],v)+    elif k == 'lengthUnit':+        c = UNITS.get(kv)+        if c is None: applied[k] = {'requested':v,'ok':False,'error':'unknown unit (mm/cm/m/in/ft)'}+        else:+            try: applied[k] = try_set(design.fusionUnitsManager,'distanceDisplayUnits',[c],v)+            except Exception as e: applied[k] = {'requested':v,'ok':False,'error':str(e)}+    else:+        applied[k] = {'requested': v, 'ok': False, 'error': 'unknown preference key'}++result = {'applied': applied, 'current': read_current()}+print(json.dumps(result))+'''+++def _shape_pref_result(res: dict, wrote: bool) -> dict:+    if not res or not res.get("success"):+        return {"success": False,+                "error": (res or {}).get("error", "preferences script did not run"),+                "_hint": "fusion_set_preference/get_preferences need Fusion running + signed in "+                         "(fusion_readiness -> ready:true). If needsSignin, drive the sign-in first."}+    data = (res.get("data") or {}).get("result") or {}+    applied = data.get("applied", {}) or {}+    current = data.get("current", {}) or {}+    out = {"success": True, "applied": applied, "current": current}+    if wrote:+        oks = [k for k, v in applied.items() if v.get("ok")]+        fails = {k: v.get("error") for k, v in applied.items() if not v.get("ok")}+        if fails:+            out["partial"] = True+            out["_hint"] = ("Applied live: %s. FAILED: %s. Note: some themes (Dark Gray/Classic) "+                            "aren't shipped in every Fusion build - use 'darkblue' or 'device'. See "+                            "`current` for the resulting state." %+                            (", ".join(oks) or "(none)",+                             "; ".join("%s=%s" % (k, e) for k, e in fails.items())))+        else:+            out["_hint"] = ("Applied LIVE (no restart needed): %s. See `current` for resulting state."+                            % ", ".join(oks))+    else:+        out["_hint"] = ("Current Fusion preferences. Change any with fusion_set_preference, keys: "+                        "theme (light/darkblue/darkgray/classic/device/dark), invertScrollZoom (bool), "+                        "orbitScheme (fusion360/alias/inventor/solidworks/tinkercad/powermill), "+                        "modelingOrientation (yup/zup), gestureNav (bool), cameraPivot (bool), "+                        "lengthUnit (mm/cm/m/in/ft - applies to the active design).")+    return out+++def _run_pref_script(script: str, timeout: int) -> dict:+    """Proxy a preferences script to the add-in, retrying ONCE on a transient add-in+    connection error. The first pref call can hit the add-in mid doc-transition (e.g.+    right after opening a Library, when run_modeling_script must create a Design) and+    come back 'not running'/'not responding' even though Fusion is up - seen live on+    the fresh-VM battery, where a ~1.5s retry cleared it. A genuinely-down Fusion just+    costs one extra 1.5s try."""+    res = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=timeout)+    if res and res.get("success"):+        return res+    err = ((res or {}).get("error") or "").lower()+    if any(s in err for s in ("not running", "not responding", "add-in",+                              "connection", "timed out", "refused")):+        _time.sleep(1.5)+        res2 = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=timeout)+        if res2 is not None:+            return res2+    return res+++def _handle_set_preference(fusion_info: dict, args: dict) -> dict:+    reqs = {k: v for k, v in (args or {}).items() if k not in ("settle",)}+    if not reqs:+        return {"success": False, "error": "No preferences given.",+                "_hint": "Pass e.g. {\"theme\":\"dark\"} or {\"invertScrollZoom\":true,"+                         "\"orbitScheme\":\"solidworks\"}. Call fusion_get_preferences to see current values + keys."}+    script = _PREF_SCRIPT.replace("__REQS__", json.dumps(json.dumps(reqs)))+    res = _run_pref_script(script, timeout=30)+    return _shape_pref_result(res, wrote=True)+++def _handle_get_preferences(fusion_info: dict, args: dict) -> dict:+    script = _PREF_SCRIPT.replace("__REQS__", json.dumps(json.dumps({})))+    res = _run_pref_script(script, timeout=20)+    return _shape_pref_result(res, wrote=False)+++# ── APS state + AI hint builder ───────────────────────────────────────────────+# Every cloud-file path routes through APS now (the old in-app search is disabled —+# see _handle_deprecated_cloud_search). This helper reads live APS state and returns+# a rich, self-teaching hint bundle so the AI ALWAYS knows: is APS installed/configured?+# signed in? token live/expired? what the verbs are, that it must sign in, and that the+# best sign-in path is the user's NATIVE Chrome/Edge via ABE (adom-browser-extension).+_APS_VERB_MAP = (+    "fusion_aps_status (state) · fusion_aps_signin (sign in) · fusion_aps_search {query} · "+    "fusion_aps_open {name} · fusion_aps_browse · fusion_aps_recent · fusion_aps_file_info · "+    "fusion_aps_versions · fusion_aps_download · fusion_aps_upload · fusion_aps_create_folder · "+    "fusion_aps_set_browser/get_browser/forget_browser (remember the native browser+profile)"+)+_APS_WHY = (+    "WHY APS: Autodesk offers NO fast file-search API for Fusion. The only in-app option "+    "(walk/search the folder tree from the add-in) takes 30+ min and CAN CRASH Fusion "+    "(WinError 10054). We searched far and wide; APS (Autodesk Platform Services, server-indexed) "+    "is the ONLY thing that works — seconds, no Fusion needed, no crash. It needs a one-time "+    "OAuth sign-in; once set up it is fast and amazing. The old crashing search is now DISABLED."+)+_APS_ABE = (+    "BEST sign-in path: drive the user's OWN Chrome/Edge (already logged into Autodesk) via ABE "+    "(adom-browser-extension). If ABE is not installed, tell the user to install it first "+    "(wiki.adom.inc/adom/adom-browser-extension), then walk them through it. With ABE the bridge "+    "opens the Autodesk auth URL in their real signed-in profile and captures the token — no "+    "password typing. Fallback: fusion_aps_signin {allowDefaultBrowser:true}."+)+++# ── SEARCH ROUTER: one live-state answer to "which file-search should the AI use?" ───────────+# John, 2026-07-24: "you now have a lot of ways to search for files, so even the ai will get+# confused by which verb to use... are your hints smart, i.e. they know if aps is configured or+# not so the hint is dynamic and does a bunch of lookup work... and give hints where you can+# tell it ideas on things you could enable and the complexity of enabling it?"+#+# There are three ways to find a cloud file, and the right one DEPENDS ON LIVE STATE:+#   1. fusion_aps_search        - APS Data Management. Needs APS signed in. Works with Fusion+#                                 CLOSED, headless, no subscription. ~2s.+#   2. fusion_mcp_call read/document/search - Autodesk's MCP server. Needs Fusion RUNNING ++#                                 subscription + the MCP toggle on. ~2s.+#   3. fusion_search_cloud_files - DISABLED (30+ min walk, crashed Fusion). Never.+#+# _search_router() probes all of it live (cheap: APS token file read + a 0.4s loopback socket)+# and every relevant hint renders the SAME ranked answer, including what could be ENABLED and+# what enabling costs (APS signin = silent on a warm SSO session, else a browser consent;+# MCP = fusion_mcp_enable drives the Preferences dialog, ~10s, brief announced foreground).+def _probe_mcp_port(timeout: float = 0.4) -> bool:+    """Is Autodesk's MCP server listening on loopback right now? The bridge runs ON the box, so+    this is a direct, cheap socket probe - no relay round-trip."""+    import socket+    try:+        s = socket.socket()+        s.settimeout(timeout)+        s.connect(("127.0.0.1", 27182))+        s.close()+        return True+    except Exception:+        return False+++def _search_router() -> dict:+    """Live state of every file-search path + a ranked recommendation. Never raises."""+    aps_st = _aps_quick_state()                      # {configured, signedIn} - token-aware+    mcp_on = _probe_mcp_port()+    fusion_up = mcp_on  # the MCP server lives inside Fusion; port open implies running+    if not mcp_on:+        try:+            fusion_up = _is_fusion_running()+        except Exception:+            fusion_up = False++    aps_ready = bool(aps_st.get("signedIn"))+    options = []+    if aps_ready:+        options.append(("fusion_aps_search", "ready NOW (~2s, works even with Fusion closed)"))+    if mcp_on:+        options.append(("fusion_mcp_call {tool:'fusion_mcp_read', arguments:{queryType:'document',"+                        "operation:'search', name:'...'}}", "ready NOW (~2s, MCP server is live)"))++    enable = []+    if not aps_ready:+        if aps_st.get("configured"):+            enable.append("APS: one fusion_aps_signin - SILENT if run right after a Fusion "+                          "sign-in (warm browser SSO), else a one-click browser consent")+        else:+            enable.append("APS: needs one-time setup - fusion_aps_set_client_id (once per "+                          "company) then fusion_aps_signin (once per user)")+    if not mcp_on:+        if fusion_up:+            enable.append("MCP: fusion_mcp_enable turns it on FOR the user (drives the "+                          "Preferences dialog, ~10s, brief announced foreground); needs a "+                          "Fusion subscription")+        else:+            enable.append("MCP: needs Fusion running first (fusion_start), then "+                          "fusion_mcp_enable; needs a Fusion subscription")++    if options:+        best = options[0][0].split(" ")[0].split("{")[0]+        rec = "BEST SEARCH RIGHT NOW: " + "; also ".join("%s - %s" % o for o in options) + "."+    else:+        best = None+        # nothing ready -> show ALL enable paths + costs, not just the quickest, so the AI can+        # pick (e.g. it must know MCP needs fusion_start when Fusion is down).+        rec = ("NO file search is ready right now. To enable one: "+               + " | ".join(enable) + "." if enable else+               "NO file search is ready and none can be enabled in the current state.")+    if enable and options:+        rec += " Could also enable: " + " | ".join(enable) + "."++    return {"apsReady": aps_ready, "apsConfigured": bool(aps_st.get("configured")),+            "mcpLive": mcp_on, "fusionRunning": fusion_up,+            "bestSearch": best, "searchHint": rec}+++def _aps_state_hint(extra: str = "") -> dict:+    """Live APS readiness + a self-teaching hint bundle for the AI. Never raises."""+    try:+        st = aps.handle_status({})+        d = st.get("data", {}) if isinstance(st, dict) else {}+    except Exception as e:  # pragma: no cover - defensive+        d = {"error": str(e)}+    configured = bool(d.get("configured"))+    signed_in = bool(d.get("signedIn"))+    live = bool(d.get("tokenLive"))+    if not configured:+        stage, todo = "not_configured", ("Register a PKCE app at https://aps.autodesk.com (Data "+                                         "Management API on), then fusion_aps_set_client_id + fusion_aps_signin.")+    elif not signed_in:+        stage, todo = "not_signed_in", "Sign in: fusion_aps_signin (prefer ABE / native browser)."+    elif not live:+        stage, todo = "token_expired", "Token expired/refresh failed — fusion_aps_signin again."+    else:+        stage, todo = "ready", "Ready — fusion_aps_search {\"query\":\"...\"} or fusion_aps_open {\"name\":\"...\"}."+    # SMART ROUTING (John 2026-07-24): an APS problem must not dead-end the AI when another+    # search path is live right now. The router probes MCP + Fusion state and says what is+    # usable NOW vs what could be enabled and at what cost.+    router = _search_router()+    parts = [f"APS {stage}.", todo, router["searchHint"], "VERBS: " + _APS_VERB_MAP, _APS_ABE, _APS_WHY]+    if extra:+        parts.insert(0, extra)+    return {+        "apsStage": stage, "apsConfigured": configured, "apsSignedIn": signed_in,+        "apsTokenLive": live, "searchRouter": router, "_hint": "  ".join(parts),+    }+++def _aps_guarded(fn, args: dict):+    """Run an APS cloud handler, but FIRST ensure APS is signed-in + token-live. If not,+    short-circuit with the rich self-teaching hint bundle (state + verbs + sign-in + ABE)+    so the AI knows exactly what to do instead of getting an opaque auth failure. This is+    the 'check every time, in code' guard the user asked for."""+    state = _aps_state_hint()+    if state["apsStage"] != "ready":+        return {+            "success": False,+            "error": f"APS is {state['apsStage']} — sign in before cloud file operations.",+            "output": state["apsStage"],+            "data": {"apsNotReady": True, **state},+            "_hint": state["_hint"],+        }+    return fn(args)+++def _handle_deprecated_cloud_search(command: str, args: dict) -> dict:+    """HARD-BLOCK the old in-app cloud search/walk (crashes Fusion). Redirect to APS,+    surfacing live APS state so the AI can immediately continue via the good path."""+    aps_state = _aps_state_hint()+    return {+        "success": False,+        "error": (f"fusion_{command} is DISABLED: the in-app Fusion cloud "+                  "search/walk takes 30+ min and CAN CRASH Fusion (WinError 10054). "+                  "Use fusion_aps_search {\"query\":\"...\"} (or fusion_aps_open to open by name) instead."),+        "output": "deprecated_cloud_search_disabled",+        "data": {"disabled": True, "use": "fusion_aps_search", **aps_state},+        "_hint": aps_state["_hint"],+    }+++COMMAND_HANDLERS = {+    "describe": lambda fi, args: describe.handle_describe(args),+    "readiness": _handle_fusion_readiness,+    "set_preference": _handle_set_preference,+    "get_preferences": _handle_get_preferences,+    "install_fusion": _handle_install_fusion,+    "open_design": handle_open_design,+    "close": handle_close_fusion,  # deprecated alias - use stop (graceful) / kill (force)+    "stop": handle_fusion_stop,    # graceful: close docs + WM_CLOSE, NO force-kill+    "kill": handle_fusion_kill,    # force: taskkill /F (the desperate path)+    "launch": _handle_launch,+    "start": _handle_launch,  # alias — CLI's fusion_start delegates here on Docker+    "dismiss_recovery": _handle_dismiss_recovery,+    "relocate_recovery": _handle_relocate_recovery,+    "open_cloud_file": _handle_open_cloud_file,+    # Import a legacy EAGLE .sch (+ .brd) into a NEW populated Fusion electronics design via+    # Fusion's own ImportSCHAndBRDCmd (drives both Open dialogs in the background). This is the+    # ONLY way to author a board from EAGLE source - newDesignFromLocal opens the editor but does+    # NOT instantiate parts, and the .fsch/.fbrd binary container can't be built offline.+    "new_electronics_from_eagle": lambda fi, args: _handle_new_electronics_from_eagle(args),+    "screenshot_fusion": _handle_screenshot_fusion,+    "click_fusion": _handle_click_fusion,+    "send_key": _handle_send_key,+    "close_window": _handle_close_window,+    "window_info": _handle_window_info,+    "screenshot_all": _handle_screenshot_all_fusion,+    # On-demand dialog/owned-popup array: enumerate + screenshot every modal so the AI+    # can ANALYZE before acting. Use while polling a long op (e.g. a Hub upload) or any+    # time you suspect a dialog is up. Mutating verbs attach this automatically; this is+    # the manual entry point. See the fusion-driving skill.+    "check_dialogs": lambda fi, args: (+        _capture_dialog_array(settle=float(args.get("settle", 0.3)))+        or {"success": True, "dialogsDetected": 0, "dialogs": [],+            "_hint": "No Fusion dialogs/owned popups are currently up."}+    ),+    "addin_status": lambda fi, args: _handle_addin_status(),+    # LAST-RESORT human escalation (John 2026-07-07): when the bridge is truly blocked+    # on the user (password/2FA, UAC), send an AD toast that reaches their MAIN machine+    # (reach_user=True fans out to peer ADs, so a bridge running on an unattended VM+    # still lands the toast where the user actually is). ALWAYS exhaust programmatic+    # options first - this exists so the AI has ONE deterministic call when it must ask.+    "notify_owner": lambda fi, args: _handle_notify_owner(args),+    # APS cloud search (pure HTTPS, works with Fusion closed). Lives in+    # COMMAND_HANDLERS so it returns BEFORE any Fusion-running gate.+    "aps_status": lambda fi, args: aps.handle_status(args),+    "aps_set_client_id": lambda fi, args: aps.handle_set_client_id(args),+    "aps_signin": lambda fi, args: aps.handle_signin(args),+    "aps_set_browser": lambda fi, args: aps.handle_set_browser(args),+    "aps_get_browser": lambda fi, args: aps.handle_get_browser(args),+    "aps_forget_browser": lambda fi, args: aps.handle_forget_browser(args),+    # Cloud-data verbs go through _aps_guarded: it checks signed-in + token-live EVERY call+    # and returns the rich APS hint bundle (state/verbs/sign-in/ABE) if not ready.+    "aps_search": lambda fi, args: _aps_guarded(aps.handle_search, args),+    "aps_browse": lambda fi, args: _aps_guarded(aps.handle_browse, args),+    "aps_recent": lambda fi, args: _aps_guarded(aps.handle_recent, args),+    "aps_file_info": lambda fi, args: _aps_guarded(aps.handle_file_info, args),+    "aps_versions": lambda fi, args: _aps_guarded(aps.handle_versions, args),+    "aps_download": lambda fi, args: _aps_guarded(aps.handle_download, args),+    "aps_create_folder": lambda fi, args: _aps_guarded(aps.handle_create_folder, args),+    "aps_upload": lambda fi, args: _aps_guarded(aps.handle_upload, args),+    "aps_open": lambda fi, args: _aps_guarded(lambda a: _handle_aps_open(fi, a), args),+    "aps_get": lambda fi, args: aps.handle_get(args),+    # DEPRECATED cloud search — HARD-BLOCKED here (in COMMAND_HANDLERS) so they return+    # BEFORE ever reaching the crashing add-in path. Redirect to APS + surface APS state.+    "search_cloud_files": lambda fi, args: _handle_deprecated_cloud_search("search_cloud_files", args),+    "walk_cloud_tree": lambda fi, args: _handle_deprecated_cloud_search("walk_cloud_tree", args),+}+++# Surfaced inline so the AI doesn't draw the WRONG conclusion (it has, repeatedly):+# a "Read Only" / expired / trial / personal-use Fusion CAN open + view + browse files+# (Basic Access ~365 days) — it only blocks save/export/modify. NEVER blame a failed/slow+# OPEN on the subscription; the cause is the open path (URN resolution, the Electronics+# design picker, a slow assembly download, a stuck main thread).+_READONLY_OPEN_NOTE = ("NOTE: a 'Read Only'/expired/trial Fusion still opens+views files — if "+                       "it never opens, debug the open path (URN/picker/slow assembly), NOT the "+                       "license. Only save/export are blocked in read-only.")+++def _handle_aps_open(fusion_info: dict, args: dict) -> dict:+    """Search the cloud (APS) for a file, then OPEN the best match in Fusion.++    APS finds it instantly by name across the whole team hub; the add-in's+    open_cloud_file (projectName + fileName + fileExtension) opens it.+    """+    query = (args.get("query") or args.get("fileName") or "").strip()+    if not query:+        return {"success": False, "error": "Missing query/fileName."}+    match = aps.find_one(query)+    if not match:+        return {"success": False, "error": f"No cloud file matching '{query}'.",+                "_hint": "Run fusion_aps_search to see candidates, or refine the query. "+                         + _READONLY_OPEN_NOTE}+    # Fusion cloud displayNames are NOT filenames — do NOT splitext (it mangled+    # "...(1.6mm gasket)" into a bogus extension). Pass the full name.+    name = match.get("name") or ""+    open_args = {"projectName": match.get("projectName"), "fileName": name}+    if not fusion_info.get("installed") or not _is_fusion_running():+        return {"success": True, "output": f"Found '{name}' in project {match.get('projectName')}.",+                "data": {"match": match, "openArgs": open_args},+                "_hint": "Fusion isn't running — call fusion_start, then fusion_aps_open again to open it."}+    # Open by the EXACT file URN (works for any nesting). The FIRST cloud-open can+    # take 60-90s (download) — longer than AD's relay timeout — so FIRE it in the+    # background and return immediately. Caller polls fusion_get_app_state. Unless+    # {wait:true} is passed (then block and return the open result).+    urn = match.get("id")++    def _do_open():+        r = _proxy_to_addin("open_by_urn", {"urn": urn}, timeout=180)+        if not r.get("success"):+            _proxy_to_addin("open_cloud_file", open_args, timeout=120)  # by-name fallback+        return r++    if args.get("wait"):+        result = _do_open()+        result.setdefault("data", {})+        if isinstance(result.get("data"), dict):+            result["data"]["match"] = match+        return _post_open_screenshot(result) if result.get("success") else result++    import threading as _threading+    _threading.Thread(target=_do_open, daemon=True).start()+    return {+        "success": True,+        "output": f"Found '{name}' in project {match.get('projectName')} — opening in Fusion.",+        "data": {"match": match, "opening": True},+        "statusVerb": "fusion_get_app_state",  # AD 1.9.9 convention — poll this for completion+        "_hint": "The cloud file is opening in Fusion in the background (first open can take "+                 "~60-90s; large ASSEMBLIES download all referenced parts and take longer). "+                 "Poll fusion_get_app_state until activeDocument is the file. Pass "+                 "{\"wait\": true} to block instead. " + _READONLY_OPEN_NOTE,+    }++# Commands that are proxied to the Fusion add-in (port 8774).+# Keys are the CLI-facing names (after stripping "fusion_" prefix).+ADDIN_COMMANDS = {+    # Mechanical BOM + physical properties (PR #22, Oliver / BOM Forge). These live in the+    # ADD-IN, so they must be listed here or the bridge never proxies them and the verb+    # 404s no matter how well the handler works.+    "assembly_bom", "physical_properties",+    "inspect_bodies",  # geometry read-back (bbox/volume/area/faces/appearance/material, mm)+    "get_app_state",+    "document_info",+    "activate_document",+    "import_step",  # add-in calls this "import_file" — mapped below+    "export_step", "export_stl", "export_3mf", "export_f3d", "export_fbx", "export_usdz",+    "export_dxf", "export_dwg", "export_iges", "export_obj", "export_sat", "export_skp",+    "get_design_info",+    "get_parameters", "set_parameter",+    "take_screenshot",+    # Electronics commands (EAGLE via Electron.run)+    "electron_run", "execute_text_command",+    "electron_zoom", "electron_pan", "electron_select",  # video-friendly view control+    "open_electronics", "list_text_commands",+    # Electronics source export (.fsch, .fbrd, .flbr via Document.CopyToDesktop)+    "export_source",+    # EAGLE-format export (.sch, .brd — extracted from .fsch/.fbrd ZIP container)+    "export_eagle_source",+    # Library file commands (EXPORT SCRIPT — open_lbr is orchestrated at bridge level)+    "export_lbr",+    # Document management+    "close_document",+    "close_all_documents",+    # Electronics file opening (open_schematic, open_board, show_3d_board, show_2d_board+    # are orchestrated at bridge level for auto-screenshot — not in this set)+    # Board data query+    "board_info",+    # Open any cloud file directly by its APS/Fusion URN (any folder depth).+    # fusion_aps_open also proxies this internally (fire-and-poll); registering it+    # here makes the direct fusion_open_by_urn verb work too instead of being+    # rejected as "Unknown command".+    "open_by_urn",+    # In-app parametric modeling — run an adsk.fusion script in the live session+    # (free path to programmatic CAD; the APS Fusion Automation API is the paid+    # cloud alternative).+    "run_modeling_script",+    # Cloud document management+    "save_to_cloud",+    "list_cloud_projects",+    "list_cloud_files",+    "delete_cloud_file",+    "create_cloud_folder",+    # open_cloud_file is orchestrated at bridge level (not direct proxy)+    # to detect blocking dialogs after open+    "check_recovery",+    "search_cloud_files",+    "walk_cloud_tree",+    "export_cloud_file",+    # Manufacturing exports+    "export_bom",+    "export_cpl",+    "export_gerbers",+    "set_design_rules",+    "export_board_image",+    "detect_layers",+}++# Map CLI command names → add-in command names (where they differ)+ADDIN_COMMAND_MAP = {+    "import_step": "import_file",+}++# Per-command timeout overrides for _proxy_to_addin. Heavy 3D exports on+# panelized boards (100+ placements) can take 120s+. The add-in-side+# timeout in http_server.py should be the source of truth; these are+# matched to that so urllib doesn't cut off before the add-in does.+ADDIN_COMMAND_TIMEOUTS = {+    # getPhysicalProperties runs on Fusion's MAIN thread per component, so a big assembly+    # (or assembly_bom with includePhysicalProperties) is slow. Give both real headroom.+    "assembly_bom": 300,+    "physical_properties": 300,+    "export_step": 300,+    "export_iges": 300,+    "export_sat": 300,+    "export_stl": 300,+    "export_3mf": 300,+    "export_usdz": 300,+    "export_obj": 300,+    "export_f3d": 300,+    "export_fbx": 300,+    "export_skp": 300,+    "export_dxf": 120,+    "export_dwg": 120,+    "export_gerbers": 180,+    "export_bom": 60,+    "export_cpl": 60,+    "export_board_image": 60,+    "close_all_documents": 60,+    # Cloud tree walker / search — can hit hundreds of folders on large projects.+    # Must match or exceed http_server.py PER_COMMAND_TIMEOUT so urllib doesn't+    # cut off before the add-in does.+    "walk_cloud_tree": 600,+    "search_cloud_files": 180,+    # Opening a cloud design downloads + loads it; large assemblies and+    # electronics/PCB designs (which spin up the Electronics editor) can take+    # minutes. fusion_aps_open's fire-and-poll path is the preferred way in for+    # these — but when open_by_urn is proxied synchronously, give it room.+    "open_by_urn": 240,+    # Modeling scripts can build many features; give them room without being unbounded.+    "run_modeling_script": 180,+}++# ── Busy gate: prevent command stacking during long-running add-in work ──+# When walk_cloud_tree or search_cloud_files is running, the Fusion main thread+# is blocked for 30-300+ seconds. Any add-in command sent during that time would+# pile up behind _main_thread_lock in http_server.py, eating HTTP threads and+# potentially crashing the host. The gate rejects those commands immediately at+# the bridge level with progress info, BEFORE they reach the add-in.+import threading as _threading+import time as _time++_long_command_lock = _threading.Lock()+_long_command = None  # None or {"command": str, "startedAt": float}++LONG_RUNNING_COMMANDS = {"walk_cloud_tree", "search_cloud_files"}++# Commands that change Fusion's state and can pop a modal dialog / owned popup the AI+# must see (a save confirm, the Hub "are you sure you want to close?" data-loss prompt,+# a recovery prompt, etc.). After these, the dispatcher auto-attaches the dialog array+# (_capture_dialog_array) + an analyze-this hint so the AI cannot fly blind. Read-only+# commands (get_app_state, document_info, board_info, exports) are intentionally excluded+# to avoid the per-call screenshot latency. See the fusion-driving skill.+MUTATING_COMMANDS = {+    "run_modeling_script", "execute_text_command", "electron_run",+    "close_document", "close_all_documents", "import_step",+    "set_parameter", "save_to_cloud", "delete_cloud_file",+}++# Read-only verbs that also honour `expectDocument` (issue #289 follow-up). A tab-switch+# retargets app.activeDocument the same way for reads, and a silently-wrong result (a BOM+# or geometry read of the wrong assembly) is easy to act on and hard to notice. These+# don't get the post-op dialog array (they're read-only) — they only run the doc guard.+GUARDED_READ_COMMANDS = {+    "inspect_bodies", "assembly_bom", "physical_properties", "document_info",+}+++def _set_long_command(command: str):+    with _long_command_lock:+        global _long_command+        _long_command = {"command": command, "startedAt": _time.time()}+++def _clear_long_command():+    with _long_command_lock:+        global _long_command+        _long_command = None+++def _get_long_command() -> dict | None:+    with _long_command_lock:+        if _long_command is None:+            return None+        return dict(_long_command)+++def _get_busy_progress() -> dict | None:+    """Poll the add-in /status endpoint for walkProgress during a long command.++    Uses /status (not /health) because it's lighter and proven reliable under+    GIL contention during heavy walks. 3s timeout matches _check_addin_status.+    """+    status = _check_addin_status(timeout=3.0)+    if status and status.get("walkProgress"):+        return status["walkProgress"]+    return None+++def _check_main_thread_blocked() -> bool:+    """Quick check: is Fusion's main thread blocked by a modal dialog?++    Sends a fast command (get_app_state) with a short timeout. If the add-in's+    HTTP server responds but the command times out, a modal dialog is blocking.+    Returns True if blocked, False if responsive.+    """+    try:+        body = json.dumps({"command": "get_app_state", "args": {}}).encode("utf-8")+        req = urllib.request.Request(+            f"http://127.0.0.1:{ADDIN_PORT}/command",+            data=body,+            headers={"Content-Type": "application/json"},+            method="POST",+        )+        with urllib.request.urlopen(req, timeout=3) as resp:+            result = json.loads(resp.read())+        # If we got a response, main thread is fine+        if result.get("success"):+            return False+        # Add-in responded but with an error — check if it's a timeout+        if "timed out" in result.get("error", "").lower():+            return True+        return False+    except Exception as e:+        # HTTP timeout = main thread blocked (HTTP server is up but command didn't complete)+        if "timed out" in str(e).lower() or "timeout" in str(e).lower():+            return True+        # Connection refused = add-in not running (different problem)+        return False+++def _probe_addin(timeout: float = 0.5) -> dict | None:+    """Check if the Fusion add-in HTTP server is running.++    Uses a short timeout (default 0.5s) to avoid blocking the /health endpoint.+    The add-in runs on localhost so if it's up, it responds in <50ms.+    """+    try:+        req = urllib.request.Request(f"http://127.0.0.1:{ADDIN_PORT}/health", method="GET")+        with urllib.request.urlopen(req, timeout=timeout) as resp:+            return json.loads(resp.read())+    except Exception:+        return None+++def _check_addin_status(timeout: float = 3.0) -> dict | None:+    """GET /status from the add-in. Returns dict or None.++    This is the cross-bridge busy probe — reads add-in busy state without+    acquiring the main thread lock. Safe to call from any bridge/container.+    Usually ~50ms, but during heavy walks GIL contention can push to 1-2s.+    """+    try:+        req = urllib.request.Request(f"http://127.0.0.1:{ADDIN_PORT}/status", method="GET")+        with urllib.request.urlopen(req, timeout=timeout) as resp:+            return json.loads(resp.read())+    except Exception:+        return None+++def _handle_addin_status() -> dict:+    """Bridge-level handler for addin_status — wraps /status in standard format."""+    status = _check_addin_status(timeout=3.0) or {"busy": False}+    stale = _addin_staleness(status.get("version"))+    status = {**status, **stale}+    result = {"success": True, "output": json.dumps(status), **status}+    if stale.get("addinStale"):+        result["_hint"] = stale["_staleHint"]+    return result+++def _proxy_to_addin(command: str, args: dict, timeout: int = 30) -> dict:+    """Proxy a command to the Fusion add-in HTTP server.++    On timeout, probes /health to distinguish:+    - Add-in alive but main thread blocked (modal dialog) → distinct error+    - Add-in HTTP server crashed → connection error+    """+    body = json.dumps({"command": command, "args": args}).encode("utf-8")+    req = urllib.request.Request(+        f"http://127.0.0.1:{ADDIN_PORT}/command",+        data=body,+        headers={"Content-Type": "application/json"},+        method="POST",+    )+    try:+        with urllib.request.urlopen(req, timeout=timeout) as resp:+            result = json.loads(resp.read())++        # Check if the add-in itself returned a timeout (main thread didn't respond)+        if (not result.get("success")+                and "timed out" in result.get("error", "").lower()):+            return _diagnose_addin_timeout(command, result)++        # If the add-in rejected the command as unknown/unsupported, it may be a+        # STALE add-in that predates this verb (issue #55 - Drew's silent failure:+        # old add-in had no open_by_urn). Enrich with a version comparison + the+        # re-sync hint + a stable errorCode so the failure is LOUD, not silent.+        if not result.get("success"):+            err = (result.get("error") or "").lower()+            if any(s in err for s in ("unknown command", "no such command", "not supported", "unsupported command")):+                stale = _addin_staleness((_check_addin_status(timeout=1.0) or {}).get("version"))+                if stale.get("addinStale"):+                    result["errorCode"] = "addin_stale"+                    result["_hint"] = stale["_staleHint"]+                    result.update({k: stale[k] for k in ("addinVersion", "expectedAddinVersion", "addinStale")})++        # Classify a raw Fusion API exception (issue: bare RuntimeError text honours no+        # errorCode/_hint contract). Only when the add-in hasn't already set a specific+        # errorCode, so bridge-level codes (addin_stale, main_thread_busy, …) always win.+        # Matches uniformly across every add-in verb because they all funnel through here.+        if not result.get("success") and not result.get("errorCode"):+            classified = classify_api_error(result.get("error", ""))+            if classified is not None:+                result["errorCode"], result["_hint"] = classified++        return result++    except urllib.error.URLError as e:+        if "Connection refused" in str(e) or "No connection" in str(e):+            return {+                "success": False,+                "error": "Fusion 360 AdomBridge add-in not running. "+                         "Install it with: python plugins/fusion360/install_addin.py, "+                         "then restart Fusion 360.",+            }+        # Could be a socket timeout — the HTTP request itself took too long+        if "timed out" in str(e).lower() or "timeout" in str(e).lower():+            return _diagnose_addin_timeout(command, {"error": str(e)})+        return {"success": False, "error": f"Add-in request failed: {e}"}+    except Exception as e:+        if "timed out" in str(e).lower() or "timeout" in str(e).lower():+            return _diagnose_addin_timeout(command, {"error": str(e)})+        return {"success": False, "error": f"Add-in request failed: {e}"}+++def _diagnose_addin_timeout(command: str, original_result: dict) -> dict:+    """After a command timeout, probe /health to determine the cause.++    Returns a distinct error if the add-in is alive but its main thread+    is blocked (e.g. by a modal dialog in Fusion).  Always captures+    auto-screenshots of Fusion windows on timeout so the AI can see+    what dialog is blocking.+    """+    # Auto-screenshot on timeout — the most common cause is a blocking dialog+    # that's invisible to the API.  Capture Fusion windows so the AI can+    # identify and dismiss the dialog.+    timeout_screenshots = {}+    try:+        timeout_screenshots = _post_open_screenshot(command)+    except Exception:+        pass  # Best effort — don't let screenshot failure mask the real error++    # Identify WHICH modal is blocking (titles enumerate over Win32 even while the+    # add-in's main thread is stuck). Turns the opaque "add-in not responding" into+    # an actionable cause + resolution — and stops the needless restart loop.+    blocking_dialogs = classify_blocking_dialogs()++    health = _probe_addin(timeout=2.0)+    if health and health.get("status") == "ok":+        main_thread = health.get("main_thread", "unknown")+        pending = health.get("pending_commands", 0)+        if main_thread == "blocked" or pending > 0:+            # Before assuming a modal dialog, check /status — if the add-in+            # is busy with a known command, it's working (not dialog-blocked).+            status = _check_addin_status(timeout=3.0)+            if status and status.get("busy"):+                busy_cmd = status.get("busyCommand", "unknown")+                elapsed = status.get("elapsedSeconds", 0)+                walk = status.get("walkProgress")+                resp = {+                    "success": False,+                    "error": f"Fusion main thread busy — {busy_cmd} running for {elapsed}s.",+                    "errorCode": "main_thread_busy",+                    "busyCommand": busy_cmd,+                    "elapsedSeconds": elapsed,+                    "_hint": (+                        "Add-in is busy with a long-running command. Do NOT retry add-in commands. "+                        "Do NOT press Escape — the add-in is working, not stuck on a dialog. "+                        "Commands that still work: fusion_window_info, fusion_screenshot_fusion, "+                        "fusion_click_fusion, fusion_send_key, fusion_close_window."+                    ),+                }+                if walk:+                    resp["progress"] = walk+                return resp++            # If we recognized the blocking modal, name it and give the precise fix+            # instead of the generic "read the screenshots" guidance.+            if blocking_dialogs:+                titles = ", ".join(f"'{d['title']}' ({d['category']})" for d in blocking_dialogs)+                resolutions = " ".join(dict.fromkeys(d["resolution"] for d in blocking_dialogs))+                message = (f"The AdomBridge add-in is alive but its main thread is blocked by a "+                           f"modal dialog: {titles}. This is NOT an add-in crash — do not restart "+                           f"Fusion. {resolutions}")+            else:+                message = (f"The AdomBridge add-in is alive but its main thread is not "+                           f"responding (status: {main_thread}, pending: {pending}). "+                           f"Fusion 360 likely has a modal dialog open (Document Recovery, "+                           f"error, or update prompt) that is blocking execution. "+                           f"READ the screenshots to identify the dialog, then dismiss "+                           f"with fusion_send_key {{\"key\": \"escape\"}} or "+                           f"fusion_send_key {{\"key\": \"tab\"}} + {{\"key\": \"enter\"}}.")+            return {+                "success": False,+                "error": "addin_main_thread_blocked",+                "message": message,+                "blockingDialogs": blocking_dialogs,+                "data": {+                    "command": command,+                    "health": health,+                    "blockingDialogs": blocking_dialogs,+                    "postOpenScreenshot": timeout_screenshots,+                },+            }+        # Health says responsive but command still timed out — unusual+        return {+            "success": False,+            "error": "addin_command_timeout",+            "message": f"Command '{command}' timed out but the add-in reports main thread "+                       f"is {main_thread}. The command may be long-running or stuck.",+            "data": {+                "command": command,+                "health": health,+                "postOpenScreenshot": timeout_screenshots,+            },+        }++    # Health probe failed — add-in HTTP server is down+    return {+        "success": False,+        "error": "AdomBridge add-in not responding (it may have crashed).",+        "errorCode": "fusion_addin_not_responding",+        "_hint": "Fix it YOURSELF - never ask the user: restart Fusion via fusion_stop + "+                 "fusion_start (the bridge installs the add-in to ALL Fusion add-in dirs incl. "+                 "%APPDATA%\\Autodesk\\FusionAddins, and runOnStartup reloads it).",+    }+++def _orchestrate_open_lbr(args: dict) -> dict:+    """Open an EAGLE .lbr library file in Fusion 360 Electronics.++    Uses Document.newDesignFromLocal (via the add-in's execute_text_command)+    to open the .lbr file, which auto-switches to the Electronics Library+    editor. Then optionally navigates to a symbol and verifies via export.++    This is orchestrated at bridge level to avoid add-in module caching+    issues and to allow multi-step operations with waits.+    """+    import tempfile+    import time++    file_path = args.get("filePath", "")+    symbol_name = args.get("symbolName", "")+    verify = args.get("verify", False)++    if not file_path:+        return {"success": False, "error": "No filePath specified"}++    file_path = file_path.replace("\\", "/")+    results = []++    # Step 1: Open the .lbr via Document.newDesignFromLocal.+    # ⚠️ TIMEOUT + VERIFY-BY-STATE (fixed 2026-07-07, caught live on a GPU-less Azure+    # VM): the open can take 60s+ on slow/software-rendered machines, so a fixed 30s+    # read timeout expired mid-open and the canned proxy error claimed the ADD-IN was+    # "not running" while the document was actually opening fine (fusion_build_library_3d+    # then aborted all parts on a lie). Now: a generous timeout, AND on ANY failure we+    # poll get_app_state for the expected document name - the doc actually being open+    # outranks whatever the synchronous return claimed.+    open_result = _proxy_to_addin("execute_text_command", {+        "command": f"Document.newDesignFromLocal {file_path}",+    }, timeout=120)++    if not open_result.get("success"):+        # Verify by STATE before failing: did the doc open anyway?+        expected = file_path.replace("\\", "/").rsplit("/", 1)[-1]+        expected = expected.rsplit(".", 1)[0].lower()+        opened_anyway = False+        for _ in range(20):  # up to ~60s of settling+            time.sleep(3)+            try:+                st = _proxy_to_addin("get_app_state", {}, timeout=8)+                active = str((st.get("data") or {}).get("activeDocument", "")).lower()+                if expected and expected in active:+                    opened_anyway = True+                    break+            except Exception:+                pass+        if not opened_anyway:+            return {+                "success": False,+                "error": f"Document.newDesignFromLocal failed: {open_result.get('error', 'unknown')}",+                "_hint": ("The open did not complete AND the document never became active. On slow/"+                          "software-rendered machines opens can take 60s+; this call already waited + "+                          "verified by state. Check fusion_check_dialogs for a blocking modal, then retry."),+                "data": {"filePath": file_path},+            }+        results.append("Document.newDesignFromLocal: ok (verified by app state after slow open)")+    else:+        results.append("Document.newDesignFromLocal: ok")++    # Step 2: Navigate to symbol if requested+    if symbol_name:+        time.sleep(3)  # Let Fusion finish opening and switching workspace++        sym_ref = symbol_name if symbol_name.endswith(".sym") else f"{symbol_name}.sym"+        edit_result = _proxy_to_addin("electron_run", {+            "command": f"EDIT {sym_ref}",+        }, timeout=10)+        results.append(f"EDIT {sym_ref}: success={edit_result.get('success')}")++        # Zoom to fit+        _proxy_to_addin("electron_run", {"command": "WINDOW FIT"}, timeout=5)+        results.append("WINDOW FIT: ok")++    # Step 3: Verify via EXPORT SCRIPT+    verification = None+    if verify:+        import uuid+        time.sleep(2)  # Let Fusion settle before export+        # Use a unique path to avoid "overwrite?" dialogs blocking the UI thread+        export_path = str(Path(tempfile.gettempdir()) / f"_adom_verify_{uuid.uuid4().hex[:8]}.scr")+        export_result = _proxy_to_addin("export_lbr", {"outputPath": export_path}, timeout=30)+        if export_result.get("success"):+            preview = export_result.get("data", {}).get("preview", "")+            has_symbol = False+            if symbol_name:+                has_symbol = (+                    f"'{symbol_name.upper()}.sym'" in preview+                    or f"'{symbol_name}.sym'" in preview+                )+            verification = {+                "exported": True,+                "fileSize": export_result.get("data", {}).get("fileSize", 0),+                "hasSymbol": has_symbol,+                "preview": preview[:500],+            }+        else:+            verification = {"exported": False, "error": export_result.get("error", "unknown")}+        results.append(f"Verification: exported={verification.get('exported', False)}")++    import os+    response = {+        "success": True,+        "output": f"Opened library: {os.path.basename(file_path)}",+        "data": {"results": results, "filePath": file_path},+        "_hint": (+            "Library opened in the Electronics Library editor (Content Manager). "+            "ALWAYS VERIFY VIA SCREENSHOT: a .lbr can FAIL to open ('<file>.lbr has errors and cannot be "+            "opened') while this call still returns success - that error is an OWNED POPUP. Grab "+            "desktop_screenshot_window on the Fusion main hwnd and CHECK ownedPopupCount + read the "+            "_screenshots[] array (AD v1.8.177+ captures owned dialogs invisible to a plain capture); "+            "ownedPopupCount>0 means an error/confirm dialog is up. "+            "NOTE: an adom-lbr .lbr is 2D ONLY - symbol + footprint + a PLACEHOLDER 3D package. "+            "To attach the real 3D chip, use fusion_attach_3d_package (it runs the Package3D generator + "+            "FINISH, which binds the 3D onto the deviceset). See the 'fusion-libraries' skill / LIBRARY_FINDINGS.md."+        ),+    }+    if symbol_name:+        response["data"]["symbolName"] = symbol_name+    if verification:+        response["data"]["verification"] = verification++    # Auto-screenshot after opening — catches blocking dialogs, same as other open commands+    return _post_open_screenshot(response)+++def _orchestrate_attach_3d_package(args: dict) -> dict:+    """Attach a real 3D model to a library package, end to end.++    Opens the .lbr (library active), runs Electron.Create3DPackage to enter the+    Package3DEnvironment showing the footprint, imports the STEP model and+    auto-orients it flat on the footprint, then executes Package3DStop (FINISH).+    Fusion then shows a modal Save dialog (an OWNED popup) — that single click is+    the only desktop-side step; this returns the exact instruction for it.++    args: {filePath: Windows path to the .lbr, modelPath: Windows path to the+           STEP, packageName: optional str}+    """+    import time+    file_path = (args.get("filePath") or "").replace("\\", "/")+    model_path = (args.get("modelPath") or "").replace("\\", "/")+    package = args.get("packageName", "")+    orient_flag = args.get("orient", True)  # 2026-07-07: expose orient (was hard-on)+    if not file_path or not model_path:+        return {"success": False,+                "error": "filePath (.lbr) and modelPath (.step) are required (Windows paths, e.g. C:/...).",+                "_hint": "Stage both onto Windows first (no container->Windows push verb). See the fusion-libraries skill."}+    steps = []+    open_res = _orchestrate_open_lbr({"filePath": file_path})+    if not open_res.get("success"):+        return {"success": False, "error": "open_lbr failed: " + str(open_res.get("error")), "data": {"steps": steps}}+    steps.append("open_lbr: ok (library active)")+    time.sleep(1)+    cp = _proxy_to_addin("execute_text_command", {"command": f"Electron.Create3DPackage {file_path}"}, timeout=40)+    if not cp.get("success"):+        return {"success": False, "error": "Create3DPackage failed: " + str(cp.get("error")),+                "_hint": "The library document must be ACTIVE, and the .lbr must be adom-lbr-generated "+                         "(a raw vendor EAGLE .lbr fails Fusion's XML parser).", "data": {"steps": steps}}+    steps.append("Create3DPackage: ok (Package3DEnvironment)")+    time.sleep(2)+    import_script = (+        "import adsk.core, adsk.fusion, math\n"+        "res={}\n"+        "d=adsk.fusion.Design.cast(app.activeProduct); root=d.rootComponent\n"+        "im=app.importManager\n"+        f"im.importToTarget(im.createSTEPImportOptions('{model_path}'), root)\n"+        "oc=root.occurrences.item(root.occurrences.count-1); bb=oc.boundingBox\n"+        "dx=bb.maxPoint.x-bb.minPoint.x; dy=bb.maxPoint.y-bb.minPoint.y; dz=bb.maxPoint.z-bb.minPoint.z\n"+        # Tall-part guard (2026-07-07): keep a part vertical if its largest dim is already Z; only+        # flatten a clearly-thin part whose thin axis isn't Z. Was: always tip smallest dim to Z,+        # which laid tall through-hole pins on their side. Honors the orient flag (default True).+        + ("axis=None\n" if not orient_flag else+           "thin=min(dx,dy,dz); big=max(dx,dy,dz)\n"+           "tall_z=(dz>=dx and dz>=dy)\n"+           "axis=None\n"+           "if (thin < 0.5*big) and not tall_z:\n"+           "    axis=(1,0,0) if (dy<=dx and dy<=dz) else ((0,1,0) if (dx<=dy and dx<=dz) else None)\n") ++        "if axis:\n"+        "    mat=oc.transform2.copy(); rot=adsk.core.Matrix3D.create()\n"+        "    rot.setToRotation(math.pi/2, adsk.core.Vector3D.create(*axis), adsk.core.Point3D.create(0,0,0))\n"+        "    mat.transformBy(rot); oc.transform2=mat\n"+        "    d.snapshots.add() if d.snapshots.hasPendingSnapshot else None\n"+        "res['dims_mm']=[round(dx*10,2),round(dy*10,2),round(dz*10,2)]\n"+        "result=res\n"+    )+    imp = _proxy_to_addin("run_modeling_script", {"script": import_script}, timeout=120)+    if not imp.get("success"):+        return {"success": False, "error": "import/orient failed: " + str(imp.get("error")), "data": {"steps": steps}}+    dims = (imp.get("data", {}) or {}).get("result", {})+    steps.append(f"import+orient: ok ({dims.get('dims_mm') if isinstance(dims, dict) else dims})")+    time.sleep(1)+    _proxy_to_addin("run_modeling_script",+                    {"script": "import adsk.core\ncd=ui.commandDefinitions.itemById('Package3DStop')\nresult={'finished': bool(cd) and cd.execute()}\n"},+                    timeout=30)+    steps.append("FINISH (Package3DStop): executed")+    return {+        "success": True,+        "output": f"3D model placed + oriented on the footprint and FINISH executed for '{package or file_path}'. A Save dialog is now up.",+        "data": {"steps": steps, "package": package},+        "savePending": True,+        "_hint": (+            "FINAL STEP (desktop-side, one click): a Fusion 'Save' dialog is now up to save the 3D package. "+            "Click it: desktop_find_window {titleContains:'Save'} -> desktop_ui_click "+            "{hwnd, automationId:'QTApplication.QTFrameWindow.standardActions.SaveButton'}. "+            "THEN VERIFY with desktop_screenshot_window on the Fusion hwnd: check ownedPopupCount (errors are owned "+            "popups), and the deviceset's Package column should flip Placeholder->part-name + the 3D preview becomes "+            "the real chip (the preview LAGS a beat - re-grab). Full flow: fusion-libraries skill / LIBRARY_FINDINGS.md sect 11."+        ),+    }+++# ── Fusion cloud FOLDER HYGIENE (never write loose files to a shared project ROOT) ───────────+# Hard lesson (2026-06-29): defaulting uploads to a project's ROOT folder dumped 100+ loose f3d+# files into the shared Adom team root and other employees complained. RULE: the bridge NEVER+# writes a file to a project root. It writes into an AI-OWNED "Adom AI Workspace" folder, with a+# per-task SUBfolder, keeping the cloud tidy. See the fusion-cloud-hygiene skill.+_DEFAULT_UPLOAD_PROJECT = "a.YnVzaW5lc3M6YWRvbTMjMjAyMzExMjk3MDM5NjAzMzE"  # the Adom business project+# The shared team ROOT folder of that project - OFF LIMITS for loose files (only the workspace+# folder itself may live here). Known roots we must refuse as a write target.+_KNOWN_ROOT_FOLDERS = {"urn:adsk.wipprod:fs.folder:co.jyO4vxQXR6S9zFpTQWnZAg"}+_AI_WORKSPACE_NAME = "Adom AI Workspace"   # the AI-owned work area (BRAND: "Adom AI", not "Claude")+_ws_folder_cache = {}+++def _safe_folder_name(name: str) -> str:+    import re as _re+    return _re.sub(r'[<>:"/\\|?*]', "", str(name or "")).strip()[:60] or "task"+++def _find_child_folder(project_id: str, parent_id: str, name: str):+    """folderId of a subfolder named `name` directly under parent_id, or None."""+    try:+        r = aps.handle_browse({"projectId": project_id, "folderId": parent_id})+        items = (r.get("data") or {}).get("items", []) if isinstance(r, dict) else []+        for it in items:+            if it.get("type") == "folders" and (it.get("name") or "").strip() == name:+                return it.get("id")+    except Exception:+        pass+    return None+++def _ensure_workspace_folder(project_id: str, task: str = None):+    """Return a folderId inside the AI-owned 'Adom AI Workspace' (NEVER a project root). Creates the+    workspace folder (one tidy folder under the project root) + an optional per-task subfolder if+    missing. Cached per (project, task). Returns None if it cannot be resolved (caller must NOT then+    fall back to root)."""+    task = _safe_folder_name(task) if task else None+    key = (project_id, task or "")+    if key in _ws_folder_cache:+        return _ws_folder_cache[key]+    root = next(iter(_KNOWN_ROOT_FOLDERS))  # parent for the single workspace folder+    ws = _find_child_folder(project_id, root, _AI_WORKSPACE_NAME)+    if not ws:+        cr = aps.handle_create_folder({"projectId": project_id, "parentFolderId": root, "name": _AI_WORKSPACE_NAME})+        ws = (cr.get("data") or {}).get("folderId") if isinstance(cr, dict) and cr.get("success") else None+    folder = ws+    if task and ws:+        sub = _find_child_folder(project_id, ws, task)+        if not sub:+            cr = aps.handle_create_folder({"projectId": project_id, "parentFolderId": ws, "name": task})+            sub = (cr.get("data") or {}).get("folderId") if isinstance(cr, dict) and cr.get("success") else None+        folder = sub or ws+    if folder:+        _ws_folder_cache[key] = folder+    return folder+++def _discover_upload_target(args: dict) -> tuple:+    """(projectId, folderId) for f3d uploads - ALWAYS a non-root, AI-owned folder.++    Defaults to 'Adom AI Workspace/<task>' (auto-created). If the caller explicitly passes a+    folderId that is a known project ROOT, it is REFUSED (we steer to the workspace instead) - the+    bridge must never write loose files to a shared root. Returns (projectId, folderId|None);+    folderId is None only if the workspace folder could not be created (caller must error, NOT+    fall back to root)."""+    project_id = args.get("projectId") or _DEFAULT_UPLOAD_PROJECT+    fid = args.get("folderId")+    if fid and fid in _KNOWN_ROOT_FOLDERS:+        fid = None  # explicit root -> refuse, steer to workspace+    if not fid:+        fid = _ensure_workspace_folder(project_id, args.get("task"))+    return (project_id, fid)+++def _capture_labeled(label) -> str | None:+    """Background-capture the main Fusion window to a labeled PNG on the box+    (C:/tmp/conduit-screenshots). Returns the saved path or None. Never fullscreen+    (hwnd-targeted PrintWindow, so it captures Fusion in the background per fusion-driving)."""+    if not label:+        return None+    try:+        info = get_fusion_window_info()+        hwnd = info.get("hwnd")+        if not hwnd:+            return None+        import re as _re+        safe = _re.sub(r'[<>:"/\\|?*]', "", str(label)).replace(" ", "_")[:40]+        r = screenshot_hwnd(hwnd, label=safe)+        return r.get("savedTo") if r.get("success") else None+    except Exception:+        return None+++def _inject_package3d_bindings(lbr_text: str, bindings: list) -> str:+    """Inject EAGLE <packages3d> + per-device <package3dinstances> into an .lbr - MERGE-AWARE+    and IDEMPOTENT (safe to re-run with any subset of parts).++    bindings: [{package: <pkg name>, wip_urn: <urn>}]. The function reads any bindings ALREADY in+    the .lbr, merges the new ones on top (new wins on conflict), strips all prior <packages3d> ++    <package3dinstances>, then re-emits the full merged set: a library-level <package3d> per part+    (between </packages> and <symbols>) and a <package3dinstances> inside every <device> that uses+    that package (right after </connects>). So calling it again with just the parts that failed last+    time ACCUMULATES instead of wiping the parts that already succeeded - the fix for the+    'a re-run dropped the earlier bindings' trap. Returns the new .lbr text."""+    import re as _re+    # 1. read existing bindings already in the file; new bindings override+    merged = {}+    em = _re.search(r"<packages3d>(.*?)</packages3d>", lbr_text, _re.S)+    if em:+        for pm in _re.finditer(r'<package3d name="([^"]+)"[^>]*wip_urn="([^"]+)"', em.group(1)):+            merged[pm.group(1)] = pm.group(2)+    for b in bindings:+        merged[b["package"]] = b["wip_urn"]+    # 2. strip ALL prior package3d markup so re-injection is clean (idempotent)+    lbr_text = _re.sub(r"\s*<packages3d>.*?</packages3d>", "", lbr_text, flags=_re.S)+    lbr_text = _re.sub(r"\s*<package3dinstances>.*?</package3dinstances>", "", lbr_text, flags=_re.S)+    # 3. library-level <packages3d> block (all merged parts)+    blocks = []+    for pkg, urn in merged.items():+        blocks.append(+            f'<package3d name="{pkg}" urn="" wip_urn="{urn}" locally_modified="yes" type="model">'+            f'<description>{pkg}</description>'+            f'<packageinstances><packageinstance name="{pkg}"/></packageinstances>'+            f'</package3d>'+        )+    pkgs3d = "<packages3d>\n" + "\n".join(blocks) + "\n</packages3d>\n"+    lbr_text = lbr_text.replace("</packages>", "</packages>\n" + pkgs3d, 1)++    # 4. per-device <package3dinstances>+    def _dev_repl(m):+        dev = m.group(0)+        pm = _re.search(r'package="([^"]+)"', dev)+        if pm and pm.group(1) in merged and "</connects>" in dev:+            inst = (f'<package3dinstances><package3dinstance package3d_urn="{merged[pm.group(1)]}"/>'+                    f'</package3dinstances>')+            dev = dev.replace("</connects>", "</connects>\n" + inst, 1)+        return dev++    return _re.sub(r'<device\b[^>]*>.*?</device>', _dev_repl, lbr_text, flags=_re.S)+++def _orchestrate_make_3d_package(args: dict) -> dict:+    """Create a RENDERING component 3D-PACKAGE urn (footprint + chip), fully programmatically - NO GUI+    dialogs. A proper component 3D model contains the FOOTPRINT (pads + courtyard) AND the chip,+    merged and aligned, so the 3D viewer can verify the chip's pads land on the footprint pads.++    So: open the library, run Electron.Create3DPackage to load the package's FOOTPRINT into a+    generator doc, import the STEP onto it, orient it flat, then saveAs an .f3d (footprint + chip) -+    which skips the FINISH Save dialog + the two unbeatable "Fusion360" CEF modals entirely -+    aps_upload the .f3d, and return the fs.file:vf wip_urn to hand-write into the library's+    <packages3d>.++    Two gotchas this avoids: (1) importing the STEP into an EMPTY design gives a chip with NO+    footprint (an incomplete package); (2) a raw STEP upload does not render at all ("Thumbnail+    download failed"). See the fusion-multipart-libraries skill.++    args: {lbrPath: Windows .lbr whose FIRST package is the footprint, modelPath: Windows .step,+           projectId, folderId (both from fusion_aps_browse), fileName?: str, orient?: bool}+    """+    import os as _os, time as _time+    lbr_path = (args.get("lbrPath") or args.get("filePath") or "").replace("\\", "/")+    model_path = (args.get("modelPath") or "").replace("\\", "/")+    project_id, folder_id = _discover_upload_target(args)  # an AI-owned non-root folder (never project root)+    if not lbr_path or not model_path:+        return {"success": False,+                "error": "lbrPath (.lbr with the footprint) and modelPath (.step) are required (Windows paths)."}+    if not folder_id:+        return {"success": False, "errorCode": "no_work_folder",+                "error": "Could not resolve a non-root 'Adom AI Workspace' upload folder.",+                "_hint": "The bridge refuses to write to a shared project ROOT. Sign in (fusion_aps_signin) "+                         "so it can create 'Adom AI Workspace', or pass a real (non-root) folderId. NEVER "+                         "pass a project root folderId. See the fusion-cloud-hygiene skill."}+    cap_label = args.get("captureLabel")  # when set, capture BEFORE (footprint) + AFTER (chip placed)+    task = _safe_folder_name(args.get("task") or "AI 3D packages")  # saveAs subfolder (never root)+    orient = args.get("orient", True)+    base = _os.path.basename(model_path).rsplit(".", 1)[0]+    name = (args.get("fileName") or (base + "_3d")).replace("'", "").replace(".f3d", "")+    f3d_name = name + ".f3d"+    # 1. open the library so the footprint exists; 2. Create3DPackage -> generator WITH the footprint loaded+    op = _orchestrate_open_lbr({"filePath": lbr_path})+    if not op.get("success"):+        return {"success": False, "error": "open_lbr failed: " + str(op.get("error"))}+    _time.sleep(1)+    cp = _proxy_to_addin("execute_text_command", {"command": f"Electron.Create3DPackage {lbr_path}"}, timeout=40)+    if not cp.get("success"):+        return {"success": False, "error": "Create3DPackage failed: " + str(cp.get("error")),+                "_hint": "The .lbr must be adom-lbr-generated; its FIRST package's footprint is loaded into the generator."}+    _time.sleep(2)+    # BEFORE shot: the footprint (pads + courtyard) loaded in the 3D viewer, no chip yet.+    before_shot = None+    if cap_label:+        try:+            _proxy_to_addin("run_modeling_script",+                            {"script": "app.activeViewport.fit()\nresult={}"}, timeout=15)+        except Exception:+            pass+        _time.sleep(0.4)+        before_shot = _capture_labeled(f"{cap_label}_before")+    # AUTO-ORIENT (fixed 2026-07-07): the old logic always rotated the SMALLEST bbox dim to Z,+    # which is right for a flat SMD chip lying down but TIPS A TALL THROUGH-HOLE PART (machine pin,+    # connector) onto its side - its long axis is already Z and must stay vertical. Now: keep the+    # part vertical if its largest dim is already Z (tall_z), and only flatten a clearly THIN part+    # whose thin axis isn't Z yet. A caller can still force orient:false to skip entirely.+    orient_block = (+        "thin=min(dx,dy,dz); big=max(dx,dy,dz)\n"+        "tall_z=(dz>=dx and dz>=dy)\n"+        "is_flat=(thin < 0.5*big)\n"+        "axis=None\n"+        "if is_flat and not tall_z:\n axis=(1,0,0) if (dy<=dx and dy<=dz) else ((0,1,0) if (dx<=dy and dx<=dz) else None)\n"+        "if axis:\n mat=oc.transform2.copy(); r=adsk.core.Matrix3D.create()\n"+        " r.setToRotation(math.pi/2, adsk.core.Vector3D.create(*axis), adsk.core.Point3D.create(0,0,0))\n"+        " mat.transformBy(r); oc.transform2=mat\n"+    ) if orient else ""+    # 3. import the chip ONTO the footprint in the generator doc, orient, saveAs as f3d (footprint+chip)+    script = (+        "import adsk.core, adsk.fusion, math\n"+        "app=adsk.core.Application.get(); doc=app.activeDocument\n"+        "d=adsk.fusion.Design.cast(app.activeProduct); root=d.rootComponent\n"+        "im=app.importManager\n"+        f"im.importToTarget(im.createSTEPImportOptions('{model_path}'), root)\n"+        "oc=root.occurrences.item(root.occurrences.count-1); bb=oc.boundingBox\n"+        "dx=bb.maxPoint.x-bb.minPoint.x; dy=bb.maxPoint.y-bb.minPoint.y; dz=bb.maxPoint.z-bb.minPoint.z\n"+        + orient_block ++        "try:\n app.activeViewport.fit()\nexcept: pass\n"+        # FOLDER HYGIENE: saveAs into an 'Adom AI Workspace'/<task> subfolder, NEVER the project root.+        "proj=app.data.activeProject; rf=proj.rootFolder\n"+        "def _sub(p,nm):\n"+        " for i in range(p.dataFolders.count):\n"+        "  if p.dataFolders.item(i).name==nm: return p.dataFolders.item(i)\n"+        " return p.dataFolders.add(nm)\n"+        f"wsf=_sub(_sub(rf,'Adom AI Workspace'),'{task}')\n"+        "res={}\n"+        "try:\n"+        f" doc.saveAs('{name}', wsf, '3d package (footprint+chip)', '')\n"+        " res['path']=doc.dataFile.id if doc.dataFile else None\n"+        " res['dims']=[round(dx*10,2),round(dy*10,2),round(dz*10,2)]\n"+        "except Exception as e: res['err']=str(e)[:80]\n"+        "result=res\n"+    )+    r = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=120)+    res = (r.get("data", {}) or {}).get("result", {}) if isinstance(r, dict) else {}+    f3d_path = res.get("path") if isinstance(res, dict) else None+    dims = res.get("dims") if isinstance(res, dict) else None+    # AFTER shot: the chip placed flat on its footprint (pads landing on pads) in the 3D viewer.+    after_shot = _capture_labeled(f"{cap_label}_after") if cap_label else None+    if not f3d_path or not str(f3d_path).endswith(".f3d"):+        return {"success": False, "error": "f3d saveAs did not produce a .f3d path: " + str(res),+                "before": before_shot, "after": after_shot,+                "_hint": "Confirm modelPath is a valid Windows .step path and Fusion is running."}+    up = aps.handle_upload({"projectId": project_id, "folderId": folder_id,+                            "localPath": f3d_path, "fileName": f3d_name})+    up_d = up.get("data", up) if isinstance(up, dict) else {}+    item_urn = (up_d or {}).get("itemUrn", "") if isinstance(up_d, dict) else ""+    try:+        _proxy_to_addin("run_modeling_script", {"script": "app.activeDocument.close(False)\nresult={}"}, timeout=20)+    except Exception:+        pass+    if "dm.lineage:" not in item_urn:+        return {"success": False, "error": "f3d upload did not return a lineage urn: " + str(up_d)}+    lid = item_urn.split("dm.lineage:")[-1]+    wip_urn = f"urn:adsk.wipprod:fs.file:vf.{lid}?version=1"+    return {+        "success": True,+        "wip_urn": wip_urn,+        "dims_mm": dims,+        "f3d": f3d_path,+        "before": before_shot,+        "after": after_shot,+        "_hint": (+            "DONE - a PROPER component 3D package (FOOTPRINT + chip, aligned) created with NO GUI dialogs. "+            "NEXT: hand-write this wip_urn into the library's EAGLE XML - a <package3d name=\"PKG\" urn=\"\" "+            "wip_urn=\"" + wip_urn + "\" locally_modified=\"yes\" type=\"model\"> (with <packageinstances>"+            "<packageinstance name=\"PKG\"/></packageinstances>) inside <packages3d> (between </packages> and "+            "<symbols>), PLUS <package3dinstances><package3dinstance package3d_urn=\"" + wip_urn + "\"/>"+            "</package3dinstances> inside the device after </connects>. Then fusion_open_lbr the merged .lbr. "+            "EXPECT: fusion_check_dialogs == 0 (no broken-ref), the device 3D preview shows footprint + chip "+            "(NOT 'Thumbnail download failed'), and opening the f3d shows the chip's pads landing on the "+            "footprint pads. "+            "SPEEDUP: for a many-part library, call this verb once per part, collect the urns, hand-merge ALL "+            "bindings in ONE pass, then fusion_open_lbr ONCE (don't open/save per part). "+            "PITFALLS: needs Fusion running (after a bridge_install respawn it can transiently report "+            "'not running' - fusion_start, then retry); a RAW STEP upload binds but renders NOTHING "+            "('Thumbnail download failed') so always go through this verb (it makes an f3d); NEVER FINISH the "+            "Package3D generator (Package3DStop) - its Save dialog + two opaque CEF modals are unbeatable, "+            "this verb saveAs-es instead. Full recipe: the fusion-multipart-libraries skill."+        ),+    }+++def _orchestrate_build_library_3d(args: dict) -> dict:+    """Bind real 3D onto a multi-part library. Runs DETACHED by default (async=True) so it+    SURVIVES AD's 60s relay cap.++    ⚠️ LEARNED THE HARD WAY (2026-07-08): the cloud Package3D generate + Hub upload takes MINUTES+    on a real machine, but AD's relay hard-caps every request at ~60s and `timeoutSeconds` is NOT+    honored for this verb - so the old synchronous build got its thread KILLED at 60s, wrote no+    bound .lbr, and lost every wip_urn (3/4 pins on a demo board came back as flat pads because the+    4th never got a fresh urn). Fix: the build now runs in a BACKGROUND daemon thread and returns+    immediately; the caller POLLS the bound `outLbrPath` file (read_file) until it has one+    `<package3d ... wip_urn=urn:...>` per part. Pass `async:false` only on a fast machine where the+    whole build fits under 60s. Then embed those package3d + a per-`<element>` `package3d_urn` in a+    `.brd` and `fusion_open_board` + `fusion_show_3d_board` shows the REAL 3D bodies (background).++    args: {..., async?: bool (default TRUE - detached + pollable)}. See _build_library_3d_core.+    """+    combined = (args.get("lbrPath") or "").replace("\\", "/")+    out_path = (args.get("outLbrPath") or combined).replace("\\", "/")+    if args.get("async", True) and args.get("parts"):+        import threading as _th++        def _run():+            try:+                _build_library_3d_core(args)+            except Exception:+                pass+        _th.Thread(target=_run, daemon=True).start()+        return {+            "success": True, "status": "started", "async": True,+            "boundLbr": out_path, "partsTotal": len(args.get("parts") or []),+            "_hint": (+                "⏳ 3D bind runs DETACHED (survives AD's 60s relay cap, which used to kill it + lose "+                "every wip_urn). POLL the boundLbr via read_file every ~30s until it has one "+                "<package3d name=... wip_urn=urn:...> PER PART (count == partsTotal). Do NOT re-fire "+                "while running (check fusion_addin_status.busy). When done, embed those <packages3d> + "+                "a per-<element> package3d_urn in your .brd, then fusion_open_board + fusion_show_3d_board "+                "for real 3D bodies (all background). If a urn stays unresolved in the 3D view, re-run "+                "for just that part - its f3d upload failed."),+        }+    return _build_library_3d_core(args)+++def _build_library_3d_core(args: dict) -> dict:+    """Build a RENDERING multi-part 3D library in ONE call - the whole programmatic pipeline.++    For each part: make its footprint+chip f3d package (no GUI dialogs, optional BEFORE/AFTER+    screenshots), collect the wip_urn. Then inject ALL bindings into the combined .lbr in one+    pass (no hand XML surgery) and open the finished library ONCE. This is the verb to call for+    a basic-parts sampler / any many-part library - it replaces the per-part make_3d_package loop+    + manual binding the AI used to do.++    args: {+      lbrPath:  combined .lbr to bind + open (Windows path),+      parts:    [{package, lbrPath (per-part .lbr whose FIRST package is this footprint),+                  modelPath (.step)}],+      outLbrPath?: where to write the bound .lbr (default: overwrite lbrPath),+      capture?:    bool - capture before/after per part (default True),+      projectId?, folderId?: APS upload target (default: the MAIN-project upload folder),+      openWhenDone?: bool (default True)+    }+    """+    combined = (args.get("lbrPath") or "").replace("\\", "/")+    parts = args.get("parts") or []+    if not combined or not parts:+        return {"success": False,+                "error": "lbrPath (combined .lbr) and parts[] are required.",+                "_hint": "parts: [{package, lbrPath (per-part footprint .lbr), modelPath (.step)}]. "+                         "projectId/folderId default to the MAIN-project upload folder."}+    out_path = (args.get("outLbrPath") or combined).replace("\\", "/")+    capture = args.get("capture", True)+    # Put this library's f3d in its OWN task subfolder under 'Adom AI Workspace' (never project root).+    import os as _os2+    task = args.get("task") or _os2.path.basename(combined).rsplit(".", 1)[0]+    project_id, folder_id = _discover_upload_target({**args, "task": task})+    if not folder_id:+        return {"success": False, "errorCode": "no_work_folder",+                "error": "Could not resolve a non-root 'Adom AI Workspace' upload folder.",+                "_hint": "The bridge refuses to write to a shared project ROOT. Sign in "+                         "(fusion_aps_signin) so it can create the workspace folder, or pass a real "+                         "(non-root) folderId. See the fusion-cloud-hygiene skill."}+    results = []; bindings = []; shots = []+    for p in parts:+        pkg = p.get("package")+        per_lbr = (p.get("lbrPath") or "").replace("\\", "/")+        step = (p.get("modelPath") or "").replace("\\", "/")+        if not pkg or not per_lbr or not step:+            results.append({"package": pkg, "success": False,+                            "error": "package, lbrPath (per-part .lbr) and modelPath (.step) all required"})+            continue+        mk_args = {+            "lbrPath": per_lbr, "modelPath": step,+            "projectId": project_id, "folderId": folder_id,+            "fileName": f"{pkg}_3d", "task": task,+            "captureLabel": (pkg if capture else None),+            # Per-part orient passthrough (2026-07-07): a caller can set orient:false on a part+            # whose STEP is already correctly oriented (e.g. a tall through-hole pin). Defaults+            # to True, and make_3d_package's tall-part guard now keeps tall parts vertical anyway.+            "orient": p.get("orient", True),+        }+        mk = _orchestrate_make_3d_package(mk_args)+        # Retry once: the FIRST part of a run often fails transiently while Fusion settles from a+        # prior view/library; a single retry recovers it (the dropped-first-part trap).+        if not mk.get("success"):+            import time as _t+            _t.sleep(2)+            mk = _orchestrate_make_3d_package(mk_args)+        entry = {"package": pkg, "success": bool(mk.get("success"))}+        if mk.get("success"):+            entry["wip_urn"] = mk.get("wip_urn"); entry["dims_mm"] = mk.get("dims_mm")+            bindings.append({"package": pkg, "wip_urn": mk["wip_urn"]})+        else:+            entry["error"] = mk.get("error")+        if mk.get("before"):+            shots.append({"package": pkg, "stage": "before", "path": mk["before"]})+        if mk.get("after"):+            shots.append({"package": pkg, "stage": "after", "path": mk["after"]})+        results.append(entry)+    # inject every binding in ONE pass, write the bound library. Read the ALREADY-BOUND output if it+    # exists so a re-run ACCUMULATES (merge-aware) onto prior successes instead of starting clean.+    bound = None+    if bindings:+        try:+            import os as _os+            src = out_path if _os.path.exists(out_path) else combined+            with open(src, "r", encoding="utf-8") as fh:+                txt = fh.read()+            txt = _inject_package3d_bindings(txt, bindings)+            with open(out_path, "w", encoding="utf-8") as fh:+                fh.write(txt)+            bound = out_path+        except Exception as e:+            return {"success": False, "error": f"binding injection failed: {e}",+                    "parts": results, "bindings": bindings, "screenshots": shots}+    # open the finished library ONCE (so check_dialogs reflects the merged result)+    opened = None+    if bound and args.get("openWhenDone", True):+        try:+            opened = bool(_orchestrate_open_lbr({"filePath": bound}).get("success"))+        except Exception:+            opened = False+    n_ok = sum(1 for r in results if r.get("success"))+    return {+        "success": n_ok > 0,+        "boundLbr": bound,+        "partsBound": n_ok,+        "partsTotal": len(parts),+        "parts": results,+        "screenshots": shots,+        "opened": opened,+        "_hint": (+            f"{n_ok}/{len(parts)} parts got a RENDERING footprint+chip 3D package; all bindings injected "+            f"into {bound} and the library opened once. NEXT: fusion_check_dialogs should be 0 (no "+            "broken-ref). For clean device previews + screenshots, save to the cloud and REOPEN from "+            "the cloud (fusion_aps_open) - the in-session view often gets an empty 'Untitled' shoved in "+            "front. Each device's lower-right 3D preview should show footprint + chip (NOT 'Thumbnail "+            "download failed'). The BEFORE (footprint) / AFTER (chip placed) PNGs are in screenshots[] "+            "on the Windows box (C:/tmp/conduit-screenshots) - pull them with desktop_pull_file (or "+            "re-capture via desktop_screenshot_window) for the demo video. "+            "AFTER (recommended): call fusion_capture_library_views per showcase part to grab the "+            "symbol / footprint / component (pin<->pad mapped) views - those are what make EEs trust "+            "the library, and the 3D-only shots miss them. "+            "PITFALLS: needs Fusion running. ⚠️ RELAY TIMEOUT: a many-part run takes minutes but the "+            "adom-desktop relay times out the REQUEST at ~60s - you may get 'Request timed out' even "+            "though the build KEEPS RUNNING server-side and finishes. Do NOT assume it failed: wait, "+            "then verify by reading the boundLbr (count <package3d name=) + the before/after PNGs in "+            "C:/tmp/conduit-screenshots. If a part is missing, just re-run build_library_3d for the "+            "missing parts pointing outLbrPath at the SAME file - binding injection is now MERGE-AWARE "+            "and idempotent, so it accumulates onto the existing bindings (it no longer wipes the "+            "parts that already succeeded). Each part also self-retries once on a transient first-part "+            "failure. Full recipe: the fusion-multipart-libraries skill."+        ),+    }+++def _dismiss_dialogs_bg() -> list:+    """Dismiss any blocking Fusion dialog in the BACKGROUND, without stealing focus.++    Uses close_window (WM_CLOSE via PostMessage) = Cancel/No on the dialog - the background-safe+    dismiss. ⛔ NEVER use send_key/Escape for this: send_key calls SetForegroundWindow and YANKS+    Fusion to the foreground, disrupting the user (learned 2026-06-29). WM_CLOSE is also more reliable+    than Escape on Qt dialogs. Returns the titles dismissed."""+    try:+        info = get_fusion_window_info()+        dismissed = []+        for d in info.get("dialogs", []) or []:+            hwnd = d.get("hwnd")+            if hwnd:+                try:+                    close_window(hwnd)  # background WM_CLOSE = Cancel/No (never creates a stray)+                    dismissed.append(d.get("title") or "")+                except Exception:+                    pass+        return dismissed+    except Exception:+        return []+++def _orchestrate_capture_library_views(args: dict) -> dict:+    """Capture the LIBRARY-EDITOR views that make EEs trust a part: the schematic SYMBOL, the+    FOOTPRINT (pads + layer stack), and the COMPONENT/device view (Content Manager: the symbol ++    the package table with the footprint<->package Mapped check + pin/pad counts). These are what+    developers want to see - the 3D before/after shots alone don't show them.++    Requires the .lbr OPEN in the Electronics Library workspace (fusion_open_lbr first). For each+    package it runs the EAGLE/Electron EDIT <pkg>.sym / .pac / .dev, WINDOW FIT to frame, and a+    background hwnd screenshot (never fullscreen). Returns the saved PNG paths on the Windows box+    (C:/tmp/conduit-screenshots - pull them with desktop_pull_file).++    args: {packages: [<deviceset name>, ...] (or a single 'package'),+           views?: subset of ['component','symbol','footprint'] (default all three),+           settle?: seconds to wait after each EDIT before framing/capturing (default 1.5 - raise it+                    if a shot shows the PREVIOUS part, lower it to go faster on a snappy machine)}+    NOTE: ~2s per view * parts * views can exceed the ~60s relay request timeout - the captures still+    complete server-side; verify the PNGs landed in C:/tmp/conduit-screenshots and pull them.+    """+    import time as _t+    pkgs = args.get("packages") or ([args["package"]] if args.get("package") else [])+    if not pkgs:+        return {"success": False,+                "error": "packages: [<deviceset name>] (or package: <name>) required.",+                "_hint": "Open the .lbr first (fusion_open_lbr). Names are the deviceset names, e.g. ESP32-S3FN8."}+    view_ext = {"component": "dev", "symbol": "sym", "footprint": "pac",+                "dev": "dev", "sym": "sym", "pac": "pac"}+    label_of = {"dev": "component", "sym": "symbol", "pac": "footprint"}+    views = args.get("views") or ["component", "symbol", "footprint"]+    # EDIT is async - the editor takes a beat to actually SWITCH the view. Capturing too soon grabs+    # the PREVIOUS view (a mislabeled shot). Settle AFTER the EDIT (before fit/capture); tunable.+    settle = float(args.get("settle", 1.5))+    out = []+    for pkg in pkgs:+        for v in views:+            ext = view_ext.get(v, v)+            er = _proxy_to_addin("electron_run", {"command": f"EDIT {pkg}.{ext}"}, timeout=30)+            _t.sleep(settle)  # let the editor LOAD the new view before framing/capturing it+            # ALWAYS catch + dismiss any dialog the EDIT raised - in the BACKGROUND (no focus steal).+            # Common one: "Create new symbol/footprint '<name>'?" when <name> is a DEVICESET name but+            # the symbol/package is named by the shared combo. Dismissing (Cancel/No) avoids a stray.+            dismissed = _dismiss_dialogs_bg()+            _proxy_to_addin("electron_run", {"command": "WINDOW FIT"}, timeout=20)+            _t.sleep(0.6)+            shot = _capture_labeled(f"{pkg}_{label_of.get(ext, ext)}")+            out.append({"package": pkg, "view": label_of.get(ext, ext), "path": shot,+                        "dialogDismissed": dismissed or None,+                        "ok": bool(er.get("success", True)) and bool(shot) and not dismissed})+    n_ok = sum(1 for o in out if o.get("ok"))+    return {+        "success": n_ok > 0,+        "captured": out,+        "_hint": (+            f"Captured {n_ok}/{len(out)} library-editor views to C:/tmp/conduit-screenshots (pull with "+            "desktop_pull_file). SYMBOL = full pinout; FOOTPRINT = pads + layer stack; COMPONENT = the "+            "Content Manager device view with the footprint<->package Mapped check + pin/pad counts. "+            "⚠️ NAMES: the COMPONENT (.dev) view opens by DEVICESET name (e.g. R-0603-10K). But SYMBOL "+            "(.sym) and FOOTPRINT (.pac) open by the SYMBOL / PACKAGE name - in a SHARED-FOOTPRINT "+            "library (one symbol/footprint per package, many value devicesets) that is the COMBO name "+            "(e.g. 'R-0603'), NOT the deviceset name ('R-0603-10K'). Passing a deviceset name to .sym/"+            ".pac makes Fusion pop 'Create new symbol/footprint?'; the bridge now auto-dismisses that in "+            "the BACKGROUND (Cancel/No, no focus steal - see entries with dialogDismissed) and marks the "+            "view not-ok, but you should pass the right name. For shared libraries the COMPONENT view "+            "alone shows symbol + footprint + 3D previews + Mapped, so it is usually enough. "+            "PITFALLS: the .lbr must be OPEN in the Electronics Library workspace (fusion_open_lbr first)."+        ),+    }+++def _orchestrate_cleanup_cloud_files(args: dict) -> dict:+    """Precisely delete a LIST of cloud files by lineage urn - SAFE cleanup of AI-created clutter.++    Deletes ONLY the exact `fileIds` (lineage urns) given - never name-guessing, so it cannot touch a+    teammate's file in a shared folder. Loops server-side (one call deletes the whole list). Use this+    to clean up f3d files the bridge created. (Find the ids with fusion_aps_browse on the folder.)++    args: {fileIds: [<lineage urn>, ...], projectName?: str (default active), folderPath?: str+           (default root - where the file lives)}+    """+    file_ids = args.get("fileIds") or []+    if not file_ids:+        return {"success": False, "error": "fileIds [lineage urns] required.",+                "_hint": "Get them from fusion_aps_browse {projectId, folderId} (item .id)."}+    project_name = args.get("projectName", "")+    folder_path = args.get("folderPath", "")+    deleted = []; failed = []+    for fid in file_ids:+        try:+            r = _proxy_to_addin("delete_cloud_file",+                                {"fileId": fid, "projectName": project_name, "folderPath": folder_path},+                                timeout=40)+            if isinstance(r, dict) and r.get("success"):+                deleted.append(fid)+            else:+                failed.append({"fileId": fid, "error": (r.get("error") if isinstance(r, dict) else str(r))[:90]})+        except Exception as e:+            failed.append({"fileId": fid, "error": str(e)[:90]})+    return {+        "success": len(deleted) > 0 or not failed,+        "deletedCount": len(deleted),+        "failedCount": len(failed),+        "failed": failed[:25],+        "_hint": (+            f"Deleted {len(deleted)}/{len(file_ids)} cloud files by PRECISE lineage urn (no name-guessing, "+            "so teammates' files are untouched). NOTE: a large list takes minutes and the relay request "+            "may time out at ~60s while the deletes continue server-side - re-browse the folder to confirm "+            "the count dropped. Anything in failed[] usually means the file was already gone or you lack "+            "delete permission. See the fusion-cloud-hygiene skill."+        ),+    }+++def _orchestrate_save_lbr(args: dict) -> dict:+    """Save the currently open Electronics library as a .flbr file.++    Uses Document.CopyToDesktop to export the library in Fusion's native+    .flbr format. The file can be re-opened later or uploaded to the cloud.+    """+    import os++    output_path = args.get("outputPath", "")+    if not output_path:+        return {"success": False, "error": "No outputPath specified"}++    output_path = output_path.replace("\\", "/")++    # Ensure path ends with .flbr+    if not output_path.lower().endswith(".flbr"):+        output_path += ".flbr"++    # Ensure parent directory exists+    parent = os.path.dirname(output_path)+    if parent and not os.path.exists(parent):+        try:+            os.makedirs(parent, exist_ok=True)+        except Exception as e:+            return {"success": False, "error": f"Cannot create directory {parent}: {e}"}++    # Execute Document.CopyToDesktop via the add-in+    result = _proxy_to_addin("execute_text_command", {+        "command": f"Document.CopyToDesktop {output_path}",+    }, timeout=30)++    if not result.get("success"):+        return {+            "success": False,+            "error": f"Document.CopyToDesktop failed: {result.get('error', 'unknown')}",+            "data": {"outputPath": output_path},+        }++    # Verify the file was created+    if os.path.exists(output_path):+        file_size = os.path.getsize(output_path)+        result = {+            "success": True,+            "output": f"Saved library to {os.path.basename(output_path)} ({file_size} bytes)",+            "data": {"outputPath": output_path, "fileSize": file_size},+        }+    else:+        result = {+            "success": True,+            "output": f"Document.CopyToDesktop executed (file may still be writing)",+            "data": {"outputPath": output_path},+        }++    # Auto-screenshot after save — catches blocking dialogs+    return _post_open_screenshot(result, settle_time=1.0)+++# Package types = EPG's Scripts3d module names (each has runWithInput(params)).+# Kept in sync with Autodesk's ElectronicsPackageGenerator internal add-in.+_EPG_TYPES = [+    "axial_diode", "axial_fuse", "axial_polarized_capacitor", "axial_resistor",+    "bga", "chip", "chiparray2sideconvex", "chiparray2sideflat", "chiparray4sideflat",+    "chip_led", "cornerconcave", "crystal", "dfn2", "dfn3", "dfn4", "dip",+    "dip_socket", "dip_socket_dual_leaf", "dpak", "ecap", "female_standoff", "hc49",+    "header_right_angle", "header_right_angle_socket", "header_straight",+    "header_straight_socket", "male_female_standoff", "melf", "molded",+    "oscillator_j", "oscillator_l", "plcc", "qfn", "qfp", "radial_dipped_rect",+    "radial_ecap", "radial_inductor", "radial_round_led", "snap_lock", "sod",+    "sodfl", "soic", "soj", "son", "sot143", "sot223", "sot23", "sotfl",+    "surface_mount_header_female", "surface_mount_pin_header_right_angle",+    "surface_mount_pin_header_straight",+]++# EPG param keys that are NOT lengths (never mm->cm converted).+_EPG_NON_DIMENSION_KEYS = {"DPins", "EPins", "pins", "thermal", "color_r", "color_g", "color_b"}+++def _orchestrate_generate_package(args: dict) -> dict:+    """Generate an IPC-compliant 3D package via Fusion's built-in+    ElectronicsPackageGenerator (EPG), optionally laser-etch a marking into the+    body top, and optionally export STEP - all in ONE headless add-in call.++    Proven live 2026-07-03 (0603 + '103' etch + 233KB STEP, 1.9s generation).+    """+    pkg_type = (args.get("type") or "").strip().lower()+    if pkg_type not in _EPG_TYPES:+        return {+            "success": False,+            "error": f"Unknown package type: {pkg_type!r}",+            "errorCode": "unknown_package_type",+            "supportedTypes": _EPG_TYPES,+            "_hint": "Pass type as one of supportedTypes (EPG Scripts3d module names). "+                     "Common: chip (0402/0603/0805 passives), soic, qfn, qfp, bga, sot23, "+                     "dfn2, melf, ecap, crystal, header_straight.",+        }++    raw_params = args.get("params") or {}+    units_cm = bool(args.get("unitsCm"))+    params = {}+    for k, v in raw_params.items():+        if (not units_cm) and isinstance(v, (int, float)) and k not in _EPG_NON_DIMENSION_KEYS:+            params[k] = v / 10.0  # mm (datasheet-native) -> cm (Fusion/EPG-native)+        else:+            params[k] = v++    etch = args.get("etch") or ""+    etch_style = (args.get("etchStyle") or "raised").strip().lower()  # raised (white, default) | engraved+    etch_depth_cm = float(args.get("etchDepthMm") or 0.03) / 10.0+    etch_height_mm = args.get("etchHeightMm")  # None = auto-fit+    output_step = args.get("outputStep") or ""+    bridge_version = BRIDGE_VERSION++    script = f"""+import sys, glob, os, importlib+import adsk.core, adsk.fusion++# Locate EPG across install conventions + versions (NEVER hardcode the webdeploy hash).+_bases = []+for env in ('ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432', 'LOCALAPPDATA'):+    root = os.environ.get(env)+    if root:+        _bases += glob.glob(os.path.join(root, 'Autodesk', 'webdeploy', 'production', '*',+                                         'Api', 'InternalAddins', 'ElectronicsPackageGenerator'))+if not _bases:+    raise RuntimeError('EPG_NOT_FOUND: ElectronicsPackageGenerator not present in this Fusion install')+epg_dir = max(_bases, key=os.path.getmtime)+parent = os.path.dirname(epg_dir)+if parent not in sys.path:+    sys.path.insert(0, parent)++mod = importlib.import_module('ElectronicsPackageGenerator.Scripts3d.{pkg_type}')++doc = app.documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType)+design = adsk.fusion.Design.cast(app.activeProduct)+mod.runWithInput({params!r}, design)+root = design.rootComponent++inv = [{{'name': b.name, 'vol': round(b.volume, 6)}} for b in root.bRepBodies]+etched = None+if {etch!r}:+    # FIND THE TOP SURFACE the robust way (John's algorithm, 2026-07-04):+    # 1. bounding box of the WHOLE chip (all bodies);+    # 2. only faces whose centroid sits in the TOP 5% band of that bbox count;+    # 3. among those upward planar faces take the LARGEST AREA (the molded body+    #    top beats terminal tops that tie it on height);+    # 4. lay the text out with a 10% margin inside that face.+    gmin, gmax = 1e9, -1e9+    for b in root.bRepBodies:+        bb = b.boundingBox+        gmin = min(gmin, bb.minPoint.z)+        gmax = max(gmax, bb.maxPoint.z)+    band_z = gmax - 0.05 * (gmax - gmin)+    top_face, top_area = None, -1.0+    for b in root.bRepBodies:+        for f in b.faces:+            if isinstance(f.geometry, adsk.core.Plane) and f.geometry.normal.z > 0.9 \+                    and f.centroid.z >= band_z and f.area > top_area:+                top_face, top_area = f, f.area+    if top_face is None:+        raise RuntimeError('ETCH_NO_TOP_FACE: no upward planar face in the top 5% of the chip bbox')+    body = top_face.body+    sk = root.sketches.add(top_face)+    center = sk.modelToSketchSpace(top_face.centroid)+    # Measure the face in SKETCH space (model-space bbox axes can be SWAPPED vs+    # the sketch axes the text lays out in). Project the model bbox corners.+    bb = top_face.boundingBox+    p_min = sk.modelToSketchSpace(bb.minPoint)+    p_max = sk.modelToSketchSpace(bb.maxPoint)+    face_w = abs(p_max.x - p_min.x)   # extent along sketch-x (the default text baseline)+    face_h = abs(p_max.y - p_min.y)   # extent along sketch-y+    # ALWAYS run the text along the LONGEST axis of the top face (most room for+    # the marking - John's rule, 2026-07-04). If the long axis is sketch-y,+    # rotate the text 90deg rather than squeezing it onto the short axis.+    import math as _math+    rot_deg = 90 if face_h > face_w else 0+    long_len = max(face_w, face_h)+    short_len = min(face_w, face_h)+    w = long_len * 0.80               # 10% margin each side along the long axis+    box_h_cap = short_len * 0.80      # 10% margin each side across it+    # multi-line support (e.g. MPN + variant on line 2): fit per-line+    lines = {etch!r}.split(chr(10))+    n_lines = max(1, len(lines))+    max_chars = max(1, max(len(l) for l in lines))+    # width-aware auto-fit: glyph ~0.75*h wide; line block ~1.35*h per line+    h = ({etch_height_mm!r} / 10.0) if {etch_height_mm!r} else \+        max(0.015, min(box_h_cap / (1.35 * n_lines), w / (0.75 * max_chars)))+    if rot_deg:+        c1 = adsk.core.Point3D.create(center.x - box_h_cap/2, center.y - w/2, 0)+        c2 = adsk.core.Point3D.create(center.x + box_h_cap/2, center.y + w/2, 0)+    else:+        c1 = adsk.core.Point3D.create(center.x - w/2, center.y - box_h_cap/2, 0)+        c2 = adsk.core.Point3D.create(center.x + w/2, center.y + box_h_cap/2, 0)+    ti = sk.sketchTexts.createInput2({etch!r}, h)+    ti.setAsMultiLine(c1, c2,+                      adsk.core.HorizontalAlignments.CenterHorizontalAlignment,+                      adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)+    if rot_deg:+        try:+            ti.angle = _math.pi / 2.0+        except Exception:+            rot_deg = 0  # older API without angle - fall back to unrotated+    txt = sk.sketchTexts.add(ti)+    # MEASURE-AND-CORRECT (the margin is a CONTRACT): font metrics vary, so the+    # 0.75h glyph estimate can under-shoot ('ADOM-A' rendered edge-to-edge). After+    # placing, measure the text's real bbox; if it busts the 10%-margin box,+    # recreate at the scaled-down height. Never trust an estimate you can measure.+    fit_iterations = 0+    measured_w = None+    for _pass in range(2):+        tb = txt.boundingBox+        t_ext_x = abs(tb.maxPoint.x - tb.minPoint.x)+        t_ext_y = abs(tb.maxPoint.y - tb.minPoint.y)+        measured_w = max(t_ext_x, t_ext_y)   # the baseline-axis extent+        measured_c = min(t_ext_x, t_ext_y)   # the cross-axis extent (line stack)+        if measured_w <= w and measured_c <= box_h_cap:+            break+        scale = min(w / max(measured_w, 1e-6), box_h_cap / max(measured_c, 1e-6)) * 0.95+        h = max(0.008, h * scale)+        txt.deleteMe()+        ti = sk.sketchTexts.createInput2({etch!r}, h)+        ti.setAsMultiLine(c1, c2,+                          adsk.core.HorizontalAlignments.CenterHorizontalAlignment,+                          adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)+        if rot_deg:+            try:+                ti.angle = _math.pi / 2.0+            except Exception:+                pass+        txt = sk.sketchTexts.add(ti)+        fit_iterations += 1+    marking_geom = {{+        'chipBBox': {{'min': [round(gmin,5), 0, 0], 'maxZ': round(gmax,5)}},+        'chipBBoxZ': {{'min': round(gmin,5), 'max': round(gmax,5)}},+        'topBandZ': round(band_z,5),+        'topFace': {{'area': round(top_area,6), 'centroidZ': round(top_face.centroid.z,5),+                    'sketchExtents': {{'x': round(face_w,5), 'y': round(face_h,5)}},+                    'body': body.name}},+        'longAxis': 'sketch-y' if face_h > face_w else 'sketch-x',+        'rotatedDeg': rot_deg,+        'textBox': {{'c1': [round(c1.x,5), round(c1.y,5)], 'c2': [round(c2.x,5), round(c2.y,5)],+                    'marginPct': 10}},+        'textHeightMm': round(h*10,4), 'lines': n_lines, 'maxLineChars': max_chars,+        'heightAuto': not bool({etch_height_mm!r}),+        'textWidthMeasuredMm': round(measured_w*10,4) if measured_w else None,+        'fitIterations': fit_iterations,+        'why': {{'band': 'top 5% of whole-chip bbox excludes terminal tops that tie the body',+                'largestArea': 'molded body top beats small terminal faces in the band',+                'longAxis': 'text runs along the longest face axis for maximum room',+                'fit': 'h = min(boxH/(1.35*lines), boxW/(0.75*maxChars)) - glyphs ~0.75h wide'}},+    }}+    if {etch_style!r} == 'engraved':+        # engraved (sunken) cut - the pre-2026-07-04 default+        ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.CutFeatureOperation)+        ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(-{etch_depth_cm!r}))+        ext_in.participantBodies = [body]+        root.features.extrudeFeatures.add(ext_in)+    else:+        # RAISED WHITE marking (default): humans need CONTRAST - a thin positive+        # extrude painted white on the dark body reads like real silkscreen ink,+        # far better than a same-color emboss. Reuses EPG's own rgb-appearance+        # utility so the white survives Fusion rendering (+ colored STEP).+        ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)+        ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal({etch_depth_cm!r}))+        mark = root.features.extrudeFeatures.add(ext_in)+        from ElectronicsPackageGenerator.Utilities import addin_utility as _au+        for i in range(mark.bodies.count):+            mb = mark.bodies.item(i)+            mb.name = 'Marking' if i == 0 else 'Marking' + str(i + 1)+            _au.apply_rgb_appearance(app, design, mb, 255, 255, 255, 'AdomMarkingWhite')+    etched = {etch!r}++step_path = None+if {output_step!r}:+    em = design.exportManager+    opts = em.createSTEPExportOptions({output_step!r})+    em.execute(opts)+    step_path = {output_step!r}++# SIDECAR MANIFEST: every setting we picked + WHY + the bboxes we calculated,+# so a marking refresh (e.g. adding a variant name as a 2nd line) can rework the+# text without re-deriving anything. Written next to the STEP as <step>.manifest.json.+manifest = {{+    'schema': 'adom-fusion-generate-package/1',+    'bridgeVersion': {bridge_version!r},+    'type': {pkg_type!r},+    'paramsCm': {params!r},+    'paramsNote': 'paramsCm are EPG-native cm (mm inputs were /10)',+    'marking': ({{'text': {etch!r}, 'style': {etch_style!r},+                'depthMm': {etch_depth_cm!r} * 10, **marking_geom}} if etched else None),+    'bodies': inv,+    'outputs': {{'step': step_path}},+    'refresh': {{+        'howTo': 'To re-mark (e.g. add a variant on line 2): call fusion_generate_package again '+                 'with the SAME type+paramsCm (unitsCm:true) and the new multi-line etch text '+                 '(join lines with a newline). The generator is deterministic, so geometry and '+                 'placement reproduce exactly; this manifest carries the boxes/height the last '+                 'run chose for comparison.',+        'example': {{'type': {pkg_type!r}, 'unitsCm': True, 'params': 'paramsCm from this file',+                    'etch': ({etch!r} + chr(10) + 'VARIANT') if etched else 'MPN' + chr(10) + 'VARIANT'}},+    }},+}}+manifest_path = None+if step_path:+    manifest_path = step_path + '.manifest.json'+    import json as _json+    with open(manifest_path, 'w', encoding='utf-8') as _f:+        _json.dump(manifest, _f, indent=2)++vp = app.activeViewport+cam = vp.camera+cam.isSmoothTransition = False+cam.target = adsk.core.Point3D.create(0, 0, 0)+cam.eye = adsk.core.Point3D.create(0.25, -0.35, 0.45)+cam.upVector = adsk.core.Vector3D.create(0, 0, 1)+cam.isFitView = True+vp.camera = cam+vp.refresh()++result = {{'type': {pkg_type!r}, 'bodies': inv, 'etched': etched, 'stepPath': step_path,+           'manifest': manifest, 'manifestPath': manifest_path,+           'epgDir': epg_dir, 'doc': app.activeDocument.name}}+"""++    res = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=110)+    if not res.get("success"):+        err = (res.get("error") or "")+        if "EPG_NOT_FOUND" in err:+            res["errorCode"] = "epg_not_found"+            res["_hint"] = ("Fusion's built-in ElectronicsPackageGenerator add-in was not found in "+                            "this install (ships with 2024+ Fusion under webdeploy .../Api/"+                            "InternalAddins). Update Fusion, or build the part with "+                            "fusion_make_3d_package from a STEP instead.")+        return res++    data = res.get("data") or {}+    inner = data.get("result") or {}+    return {+        "success": True,+        "output": json.dumps(inner),+        **inner,+        "_hint": ("Generated an IPC-compliant parametric package via Fusion's built-in EPG"+                  + (f", marked '{etch}' on the chip top as {'an engraved cut' if etch_style == 'engraved' else 'RAISED WHITE text (silkscreen-style - high contrast on the dark body)'}" if etch else "")+                  + (f", exported STEP to {output_step}" if output_step else "")+                  + ". NEXT: fusion_screenshot_fusion for a visual check; pull_file the STEP for KiCad. "+                    "HOW THE MARKING WORKS: the top surface is found from the WHOLE-chip bounding box - "+                    "only upward planar faces in the top 5% height band count, largest area wins - and "+                    "the text is laid out with a 10% margin inside that face, auto-fit width-aware "+                    "(override with etchHeightMm). Default style is raised+white for CONTRAST (humans "+                    "can't read a dark-on-dark emboss); pass etchStyle:'engraved' for a sunken laser cut. "+                    "The text ALWAYS runs along the LONGEST axis of the top face (rotated 90deg when "+                    "needed) for maximum room; the 10% margin is ENFORCED by measuring the placed text bbox "+                    "and shrink-to-fitting (see fitIterations in the manifest). Multi-line markings work - join lines with a newline "+                    "(e.g. 'LM358' + newline + 'ADOM-A' for MPN + variant; per-line auto-fit). "+                    "SIDECAR MANIFEST: when outputStep is set, <step>.manifest.json records every "+                    "setting picked + WHY + the calculated bboxes (chip bbox, top band, face extents, "+                    "text box, height, rotation) - to refresh a marking (add a variant line), re-call "+                    "this verb with the manifest's type + paramsCm (unitsCm:true) + the new etch text; "+                    "generation is deterministic so placement reproduces exactly. The manifest is also "+                    "returned inline as 'manifest'. "+                    "PITFALLS: params are mm by default (unitsCm:true for EPG-native cm); dimension names "+                    "follow each EPG generator (chip: D/E/A/L/L1; soic: A/A1/b/D/E/E1/e/L/DPins); raised "+                    "markings are separate 'Marking' bodies (white in Fusion + colored STEP)."),+    }+++# ── Optimized-GLB pipeline (service-step2glb "molecule mode") ──────────────────+# A raw STEP->GLB tessellation (fusion_export_step + a plain step2glb convert) leaves+# EVERY solid/pad/via as its own draw call - a real board comes out ~16MB / ~25,000+# primitives that HALTS the viewer's GPU on rotation (John hit this live 2026-07-14).+# service-step2glb's molecule mode is the OCCT-side replacement for Colby's Blender+# molecule-converter: tessellate -> anchor to the MP machine pins -> (optional) bake+# silkscreen -> gold pins -> dedup/flatten/JOIN/weld/prune -> Draco. Same board comes+# out ~465KB / ~31 draw calls, auto-oriented. We call it straight from the bridge so+# a Fusion board becomes a wiki-grade GLB in ONE verb. Reachable from the box (it is a+# public *.adom.cloud host, same as the wiki the bridge already streams from).+STEP2GLB_URL = (os.environ.get("ADOM_STEP2GLB_URL")+                or "https://step2glb-gmdoncpxdwx0.adom.cloud").rstrip("/")+++def _multipart_body(fields: dict, files: list):+    """Build a multipart/form-data body. files = [(field, filename, bytes, content_type)]."""+    boundary = "----AdomBridgeGLB%d%d" % (int(_time.time() * 1000), os.getpid())+    out = []+    for k, v in (fields or {}).items():+        out.append(("--" + boundary).encode())+        out.append(('Content-Disposition: form-data; name="%s"' % k).encode())+        out.append(b"")+        out.append(str(v).encode())+    for field, filename, data, ctype in (files or []):+        out.append(("--" + boundary).encode())+        out.append(('Content-Disposition: form-data; name="%s"; filename="%s"'+                    % (field, filename)).encode())+        out.append(("Content-Type: %s" % (ctype or "application/octet-stream")).encode())+        out.append(b"")+        out.append(data)+    out.append(("--" + boundary + "--").encode())+    out.append(b"")+    return b"\r\n".join(out), boundary+++# A User-Agent is REQUIRED on every service call. The *.adom.cloud edge WAF 403s the+# default "Python-urllib/x.y" UA (confirmed live 2026-07-14) while a browser/curl UA+# gets 200. Set it on the POST AND both polls or the call fails with an opaque 403.+def _service_ua():+    return "adom-fusion-bridge/%s" % BRIDGE_VERSION+++def _service_glb_submit(step_path: str, silk_top: str = None, silk_bottom: str = None,+                        pin: str = "medium", job_name: str = "fusion-board") -> dict:+    """POST a STEP (+ optional silk PNGs) to service-step2glb molecule mode. Returns+    {ok, jobId} - does NOT wait. Pure urllib (no requests on the box)."""+    try:+        with open(step_path, "rb") as f:+            step_bytes = f.read()+    except Exception as e:+        return {"ok": False, "error": "could not read STEP: %s" % e}+    files = [("step", os.path.basename(step_path), step_bytes, "application/step")]+    for field, p in (("silk_top", silk_top), ("silk_bottom", silk_bottom)):+        if p and os.path.exists(p):+            try:+                with open(p, "rb") as f:+                    files.append((field, os.path.basename(p), f.read(), "image/png"))+            except Exception:+                pass+    body, boundary = _multipart_body({}, files)+    headers = {+        "Content-Type": "multipart/form-data; boundary=" + boundary,+        "X-Client": "fusion-bridge/adom",+        "X-Job-Name": job_name,+        "User-Agent": _service_ua(),+    }+    url = "%s/convert?molecule=true&pin=%s" % (STEP2GLB_URL, urllib.parse.quote(pin))+    try:+        req = urllib.request.Request(url, data=body, headers=headers, method="POST")+        with urllib.request.urlopen(req, timeout=90) as resp:+            queued = json.loads(resp.read().decode("utf-8", "replace"))+    except Exception as e:+        return {"ok": False, "error": "service POST failed: %s" % e,+                "_hint": "Is %s reachable from this box? Override with ADOM_STEP2GLB_URL." % STEP2GLB_URL}+    job_id = queued.get("job_id")+    if not job_id:+        return {"ok": False, "error": "service did not return a job_id", "raw": queued}+    return {"ok": True, "jobId": job_id}+++def _service_glb_fetch(job_id: str, out_path: str = None, poll_s: int = 0) -> dict:+    """Poll a service job for up to poll_s seconds; if complete, optionally write the+    GLB to out_path. Returns {ok, status, stats, glbBytes?, wrote?}. poll_s=0 = one check."""+    ua = _service_ua()+    stats, deadline, first = {}, _time.time() + max(0, poll_s), True+    while first or _time.time() < deadline:+        first = False+        try:+            preq = urllib.request.Request("%s/jobs/%s" % (STEP2GLB_URL, job_id), headers={"User-Agent": ua})+            with urllib.request.urlopen(preq, timeout=20) as r:+                stats = json.loads(r.read().decode("utf-8", "replace"))+        except Exception:+            _time.sleep(4); continue+        st = stats.get("status")+        if st == "complete":+            break+        if st == "error":+            return {"ok": False, "status": "error", "error": stats.get("error") or stats, "stats": stats}+        if _time.time() >= deadline:+            return {"ok": True, "status": st or "processing", "stats": stats}+        _time.sleep(6)+    if stats.get("status") != "complete":+        return {"ok": True, "status": stats.get("status") or "processing", "stats": stats}+    try:+        rreq = urllib.request.Request("%s/jobs/%s/result" % (STEP2GLB_URL, job_id), headers={"User-Agent": ua})+        with urllib.request.urlopen(rreq, timeout=60) as r:+            glb = r.read()+    except Exception as e:+        return {"ok": False, "status": "complete", "error": "download failed: %s" % e, "stats": stats}+    wrote = None+    if out_path:+        try:+            with open(out_path, "wb") as f:+                f.write(glb)+            wrote = out_path+        except Exception as e:+            return {"ok": False, "status": "complete", "error": "could not write GLB: %s" % e, "stats": stats}+    return {"ok": True, "status": "complete", "stats": stats, "glbBytes": glb, "wrote": wrote}+++def _orchestrate_export_optimized_glb(args: dict) -> dict:+    """Fusion board -> wiki-grade optimized GLB in one call. Exports the active design's+    STEP, (optionally) the top/bottom silkscreen, runs it through service-step2glb's+    molecule pipeline (anchor + optional silk bake + gold pins + join/weld/prune + Draco),+    and writes the small, fast, auto-anchored GLB to outputPath. See STEP2GLB_URL above."""+    output_path = args.get("outputPath") or args.get("output_path")+    if not output_path:+        return {"success": False, "error": "No outputPath specified.",+                "_hint": 'Usage: fusion_export_optimized_glb {"outputPath":"C:/tmp/board.glb", "silkscreen":true, "pin":"medium"}'}+    if not output_path.lower().endswith(".glb"):+        output_path = output_path + ".glb"+    pin = args.get("pin", "medium")+    want_silk = args.get("silkscreen", True)+    # 1) Need a 3D Design product for STEP. Switch to the 3D board (no-op if already 3D).+    _proxy_to_addin("show_3d_board", {}, timeout=60)+    # 2) Export STEP next to the target GLB.+    step_path = output_path[:-4] + ".step"+    step_res = _proxy_to_addin("export_step", {"outputPath": step_path}, timeout=300)+    if not step_res.get("success"):+        return {"success": False, "error": "STEP export failed: %s" % (step_res.get("error") or step_res.get("message")),+                "step": step_res, "_hint": "The active design must be a 3D Design product (fusion_show_3d_board first)."}+    # 3) Optional silkscreen (best-effort; the GLB is great without it).+    silk_top = silk_bottom = None+    silk_note = None+    if want_silk:+        st = output_path[:-4] + "_silk_top.png"+        sb = output_path[:-4] + "_silk_bottom.png"+        rt = _proxy_to_addin("take_silkscreen_screenshot", {"outputPath": st, "layer": "top"}, timeout=90)+        rb = _proxy_to_addin("take_silkscreen_screenshot", {"outputPath": sb, "layer": "bottom"}, timeout=90)+        if rt.get("success"):+            silk_top = st+        if rb.get("success"):+            silk_bottom = sb+        if not (silk_top or silk_bottom):+            silk_note = "silkscreen capture unavailable on this design; GLB built without a baked silk texture."+    # 4) Submit to the molecule optimizer (fire-and-return: a big board's tessellation+    #    can run minutes, longer than the AD relay's request timeout, so we do NOT block+    #    the whole time here). Then bounded-wait up to `wait` seconds (default 90) so+    #    small boards still come back complete in one call.+    job_name = os.path.splitext(os.path.basename(output_path))[0]+    sub = _service_glb_submit(step_path, silk_top, silk_bottom, pin=pin, job_name=job_name)+    if not sub.get("ok"):+        return {"success": False, "error": sub.get("error"), "_hint": sub.get("_hint"),+                "stepPath": step_path, "note": "STEP exported OK; optimize submit failed."}+    job_id = sub["jobId"]+    wait_s = int(args.get("wait", 15))  # keep total (STEP export + wait) under the ~60s relay timeout+    fetched = _service_glb_fetch(job_id, out_path=output_path, poll_s=wait_s) if wait_s > 0 else {"ok": True, "status": "processing"}+    status_url = "%s/jobs/%s" % (STEP2GLB_URL, job_id)+    result_url = "%s/jobs/%s/result" % (STEP2GLB_URL, job_id)+    if fetched.get("status") == "complete" and fetched.get("wrote"):+        stats = fetched.get("stats") or {}+        size = len(fetched.get("glbBytes") or b"")+        return {+            "success": True, "status": "complete",+            "glbPath": output_path, "stepPath": step_path, "jobId": job_id,+            "silkTop": silk_top, "silkBottom": silk_bottom,+            "meshesBefore": stats.get("meshes_before"), "meshesAfter": stats.get("meshes_after"),+            "sizeBytes": size, "moleculeAnchored": stats.get("molecule_anchored"),+            "silkscreenApplied": stats.get("silkscreen_applied"), "note": silk_note,+            "message": "Optimized GLB written (%d KB, meshes %s->%s, anchored=%s, silk=%s) to %s" % (+                size // 1024, stats.get("meshes_before"), stats.get("meshes_after"),+                stats.get("molecule_anchored"), stats.get("silkscreen_applied"), output_path),+            "_hint": "Pull it with pull_file, then set it as component.parts.model_3d on a wiki component page. "+                     "Same optimizer as the molecule GLBs (Colby's pipeline).",+        }+    if not fetched.get("ok"):+        return {"success": False, "error": fetched.get("error"), "jobId": job_id, "stepPath": step_path,+                "statusUrl": status_url, "resultUrl": result_url}+    # Still processing after the bounded wait - hand back the job so the caller finishes it.+    return {+        "success": True, "status": fetched.get("status") or "processing",+        "pending": True, "jobId": job_id, "stepPath": step_path,+        "glbPath": output_path, "silkTop": silk_top, "silkBottom": silk_bottom, "note": silk_note,+        "statusUrl": status_url, "resultUrl": result_url,+        "message": "Optimize job %s submitted; still processing after %ds. Finish it with "+                   "fusion_fetch_optimized_glb {\"jobId\":\"%s\",\"outputPath\":\"%s\"} (re-call until complete)." % (+                       job_id, wait_s, job_id, output_path),+        "_hint": "Big boards tessellate for a few minutes. Either re-call fusion_fetch_optimized_glb "+                 "with this jobId (writes the GLB on the box when ready), or GET the resultUrl directly.",+    }+++def _orchestrate_fetch_optimized_glb(args: dict) -> dict:+    """Fetch a previously-submitted optimize job's GLB (from fusion_export_optimized_glb's+    jobId). Bounded poll (default 90s); writes to outputPath when complete."""+    job_id = args.get("jobId")+    if not job_id:+        return {"success": False, "error": "No jobId.",+                "_hint": 'Usage: fusion_fetch_optimized_glb {"jobId":"...","outputPath":"C:/tmp/board.glb"}'}+    out_path = args.get("outputPath") or args.get("output_path")+    if out_path and not out_path.lower().endswith(".glb"):+        out_path = out_path + ".glb"+    wait_s = int(args.get("wait", 15))  # keep total (STEP export + wait) under the ~60s relay timeout+    res = _service_glb_fetch(job_id, out_path=out_path, poll_s=wait_s)+    status_url = "%s/jobs/%s" % (STEP2GLB_URL, job_id)+    result_url = "%s/jobs/%s/result" % (STEP2GLB_URL, job_id)+    if res.get("status") == "complete" and res.get("wrote"):+        stats = res.get("stats") or {}+        size = len(res.get("glbBytes") or b"")+        return {"success": True, "status": "complete", "glbPath": out_path, "jobId": job_id,+                "sizeBytes": size, "meshesAfter": stats.get("meshes_after"),+                "moleculeAnchored": stats.get("molecule_anchored"), "silkscreenApplied": stats.get("silkscreen_applied"),+                "message": "Optimized GLB written (%d KB) to %s" % (size // 1024, out_path)}+    if not res.get("ok"):+        return {"success": False, "error": res.get("error"), "jobId": job_id, "statusUrl": status_url, "resultUrl": result_url}+    return {"success": True, "status": res.get("status") or "processing", "pending": True, "jobId": job_id,+            "statusUrl": status_url, "resultUrl": result_url,+            "message": "Job %s still %s; re-call fusion_fetch_optimized_glb to finish." % (job_id, res.get("status") or "processing")}+++def _describe_profile(p: dict) -> str:+    """Human, UNAMBIGUOUS name for a browser profile - never just 'your browser'.++    A power user has several (personal / work / media), so a demo that says "it's in your+    Chrome" is useless (John, 2026-07-20). Produce e.g.+    "Chrome - John Personal ([email protected])" or "Edge - Default"."""+    browser = (p.get("browser") or "browser").strip()+    browser = {"chrome": "Chrome", "edge": "Edge", "brave": "Brave"}.get(browser.lower(), browser.title())+    name = (p.get("displayName") or "").strip()+    email = (p.get("email") or "").strip()+    bits = browser+    if name and email and name.lower() not in email.lower():+        bits += " - %s (%s)" % (name, email)+    elif email:+        bits += " - %s" % email+    elif name:+        bits += " - %s" % name+    elif p.get("profileDir"):+        bits += " - %s" % p.get("profileDir")+    return bits+++def _ad_direct_api_call(verb: str, args: dict, timeout: float = 60) -> dict | None:+    """Call an AD verb over the loopback DIRECT API - the correct, non-foregrounding way for a+    bridge to reach AD when the in-process ad_client is down (issue #295). AD spawns the bridge+    with ADOM_DIRECT_API_URL in its env (fallback: ~/.adom/direct-api-port); POST+    {"command", "args"} to <base>/command. Returns the parsed result dict, or None if the direct+    API is unavailable or errors (so _ad_call can fall through). Never shells the GUI exe."""+    base = (os.environ.get("ADOM_DIRECT_API_URL") or "").strip()+    if not base:+        try:+            with open(os.path.join(os.path.expanduser("~"), ".adom", "direct-api-port")) as f:+                port = f.read().strip()+            if port:+                base = "http://127.0.0.1:%s" % port+        except Exception:+            return None+    if not base:+        return None+    try:+        body = json.dumps({"command": verb, "args": args}).encode("utf-8")+        # Forward caller identity headers (issues #342/#348) for proper attribution through the call chain+        headers = {"Content-Type": "application/json"}+        if _caller_identity_context:+            if _caller_identity_context.get("thread"):+                headers["X-Adom-Caller-Thread"] = _caller_identity_context["thread"]+            if _caller_identity_context.get("container"):+                headers["X-Adom-Caller-Container"] = _caller_identity_context["container"]+            if _caller_identity_context.get("reason"):+                headers["X-Adom-Caller-Reason"] = _caller_identity_context["reason"]+            # Add delegate header to identify this bridge in the call chain+            headers["X-Adom-Caller-Delegate"] = "fusion"+        req = urllib.request.Request(base.rstrip("/") + "/command", data=body,+                                     headers=headers, method="POST")+        with urllib.request.urlopen(req, timeout=timeout) as resp:+            r = json.loads(resp.read())+        if isinstance(r, dict):+            inner = r.get("output")+            if isinstance(inner, str) and inner.strip().startswith("{"):+                try:+                    return json.loads(inner)+                except Exception:+                    return r+            return r+    except Exception:+        return None+    return None+++def _ad_call(verb: str, args: dict, timeout: int = 60) -> dict:+    """Call another AD verb (nbrowser_*, desktop_*) from inside this bridge.++    Prefers the in-process ad_client; then the loopback DIRECT API (issue #295); only as a last+    resort shells the adom-desktop **CLI** exe (adom-desktop-cli.exe, NEVER the GUI adom-desktop.exe,+    which would foreground AD). Works on an unattended VM where ad_client is down. Never raises -+    returns {} on failure so callers can degrade to instructing the AI instead."""+    why = []+    try:+        if ad_client.available():+            r = ad_client.call(verb, args, timeout=timeout)+            if isinstance(r, dict):+                inner = r.get("output")+                if isinstance(inner, str) and inner.strip().startswith("{"):+                    import json as _j0+                    try:+                        return _j0.loads(inner)+                    except Exception:+                        return r+                return r+            why.append("ad_client.call returned %r" % type(r).__name__)+        else:+            why.append("ad_client unavailable")+    except Exception as e:+        why.append("ad_client raised %s" % e)+    # #295: prefer the loopback DIRECT API - it reaches AD without foregrounding anything.+    direct = _ad_direct_api_call(verb, args, timeout=timeout)+    if isinstance(direct, dict):+        return direct+    why.append("direct-api unavailable")+    try:+        import subprocess as _sp, json as _j+        exe = _find_adom_desktop_cli()  # adom-desktop-cli.exe only; never the GUI exe (#295)+        if not exe:+            return {"_adCallError": "; ".join(why + ["adom-desktop CLI not found"])}+        p = _sp.run([exe, verb, _j.dumps(args)], capture_output=True, text=True, timeout=timeout)+        out = (p.stdout or "").strip()+        if out.startswith("{"):+            d = _j.loads(out)+            inner = d.get("output")+            if isinstance(inner, str) and inner.strip().startswith("{"):+                try:+                    return _j.loads(inner)+                except Exception:+                    return d+            return d+        why.append("cli stdout not json: %s" % (out[:120] or (p.stderr or "")[:120]))+    except Exception as e:+        why.append("cli raised %s" % e)+    return {"_adCallError": "; ".join(why)}+++# ── FUSION AUTO-UPDATE (John, 2026-07-22) ────────────────────────────────────────────────────+# "fusion ships updates non-stop and its annoying to click this. i just always want the latest+# fusion... just make this generally invisible to me and do that by default for all other adom+# users. but let them tell you they don't want you doing that and you make that a sticky setting."+#+# So: ON BY DEFAULT for everyone, applied silently in the BACKGROUND, opt-out is sticky per user.+# Fusion's own updater downloads in the background and swaps the build on the next launch, so all+# we have to do is stop making the human click "Update Now" on a nag panel.+def _bridge_prefs_path():+    import pathlib+    d = pathlib.Path(os.path.expanduser("~")) / ".adom" / "fusion-bridge"+    d.mkdir(parents=True, exist_ok=True)+    return d / "prefs.json"+++def _get_bridge_pref(key: str, default=None):+    try:+        import json as _j+        p = _bridge_prefs_path()+        if p.exists():+            v = (_j.loads(p.read_text()) or {})+            return v.get(key, default)+    except Exception:+        pass+    return default+++def _set_bridge_pref(key: str, value) -> bool:+    try:+        import json as _j+        p = _bridge_prefs_path()+        cur = {}+        if p.exists():+            try: cur = _j.loads(p.read_text()) or {}+            except Exception: cur = {}+        cur[key] = value+        p.write_text(_j.dumps(cur, indent=2))+        return True+    except Exception:+        return False+++def _auto_update_enabled() -> bool:+    """Default TRUE. Only a user who explicitly opted out gets False, and that sticks."""+    return bool(_get_bridge_pref("autoUpdateFusion", True))+++# Buttons Fusion puts on its update nag / Job Status panel, best-first.+_UPDATE_BUTTONS = ("Update Now", "Update now", "Install Now", "Restart Now")+++def _find_update_offer(hwnd=None) -> dict:+    """Is Fusion offering an update right now? Detected via UIA (background, no foreground).+    Returns {offered, button, hwnd, detail} - never raises."""+    try:+        hw = hwnd or _fusion_window_hwnd()+        if not hw:+            return {"offered": False}+        # Scan the main window AND Fusion's owned popups - the update nag lives in the+        # "View Job Status" panel, which is a CHILD window and is only in the UIA tree while it+        # is open. Checking both is why this runs on a schedule rather than once.+        candidates = [int(hw)]+        try:+            for w in (family_windows(int(hw)) or []):+                h2 = w.get("hwnd") if isinstance(w, dict) else w+                if h2 and int(h2) != int(hw):+                    candidates.append(int(h2))+        except Exception:+            pass+        for h in candidates:+            for label in _UPDATE_BUTTONS:+                r = _unwrap(_ad_call("desktop_find_control", {"hwnd": h, "name": label}, timeout=20))+                best = r.get("best") or {}+                if best.get("name") and best.get("invokable"):+                    return {"offered": True, "button": best.get("name"), "hwnd": h,+                            "detail": "Fusion is offering an update ('%s' is on screen)" % best.get("name")}+        return {"offered": False, "hwnd": hw}+    except Exception:+        return {"offered": False}+++def _apply_fusion_update_silently(hwnd=None) -> dict:+    """Click Fusion's update button IN THE BACKGROUND so the download starts and the new build+    is applied on the next launch. No foreground, no caption - the user asked for this to be+    invisible. Returns {applied, button, why}."""+    if not _auto_update_enabled():+        return {"applied": False, "why": "user opted out (sticky pref autoUpdateFusion=false)"}+    offer = _find_update_offer(hwnd)+    if not offer.get("offered"):+        return {"applied": False, "why": "no update offered"}+    r = _unwrap(_ad_call("desktop_ui_click",+                         {"hwnd": int(offer["hwnd"]), "name": offer["button"]}, timeout=30))+    ok = bool(r.get("invoked") or r.get("success"))+    if ok:+        print("[Fusion Bridge] auto-update: clicked %r in the background" % offer["button"])+    return {"applied": ok, "button": offer["button"],+            "why": ("clicked %r in the background; Fusion downloads now and swaps on next launch"+                    % offer["button"]) if ok else "found the button but the UIA invoke did not take"}+++def _handle_set_auto_update(fusion_info: dict, args: dict) -> dict:+    """Turn Fusion auto-updating on/off. STICKY - remembered for this user forever."""+    if "enabled" not in (args or {}):+        cur = _auto_update_enabled()+        return {"success": True, "autoUpdateFusion": cur,+                "_hint": ("Fusion auto-update is currently %s. It is ON BY DEFAULT: the bridge "+                          "clicks Fusion's 'Update Now' for the user in the BACKGROUND so they "+                          "always run the latest build and never see the nag. To opt out: "+                          "fusion_set_auto_update {\"enabled\": false} - that choice is STICKY."+                          % ("ON" if cur else "OFF")),+                "statusVerb": "fusion_readiness"}+    enabled = bool(args.get("enabled"))+    _set_bridge_pref("autoUpdateFusion", enabled)+    return {"success": True, "autoUpdateFusion": enabled,+            "narrate": ("I'll keep Fusion updated automatically in the background."+                        if enabled else+                        "I'll stop auto-updating Fusion. You'll see Autodesk's own update prompts."),+            "_hint": ("Saved and sticky (~/.adom/fusion-bridge/prefs.json). %s"+                      % ("Auto-update ON: the bridge silently clicks 'Update Now' whenever Fusion "+                         "offers one, so the user always runs the newest build. Tell them it is "+                         "handled and they never need to click it."+                         if enabled else+                         "Auto-update OFF: the bridge will NOT touch update prompts; the user "+                         "handles Autodesk's nag themselves. Do not override this.")),+            "statusVerb": "fusion_readiness"}+++# Registered here, NOT in the COMMAND_HANDLERS literal: that dict is built at line ~1699,+# long before this function exists, so naming it there raised NameError at import and+# crash-looped the whole bridge (done exactly that, 2026-07-22).+COMMAND_HANDLERS["set_auto_update"] = _handle_set_auto_update+++# ── AUTODESK FUSION MCP SERVER PROXY (John, 2026-07-23) ──────────────────────────────────────+# Autodesk + Anthropic shipped a Fusion MCP server: a LOCAL HTTP/JSON-RPC endpoint at+# http://127.0.0.1:27182/mcp that exposes Fusion's own text-to-CAD surface (read geometry,+# execute/update features, read Electronics design data).+#+# WHY THE BRIDGE PROXIES IT: that server binds LOOPBACK on the user's machine. It was designed+# for Claude Desktop running on the same box. An Adom AI runs in a CLOUD container and cannot+# reach the user's 127.0.0.1 at all. This bridge already runs on that machine, so it is exactly+# the right proxy: these verbs give the cloud AI Autodesk's MCP tools without reimplementing them.+#+# The server speaks MCP streamable-HTTP: you must `initialize`, capture the MCP-Session-Id+# response header, send notifications/initialized, and pass that header on every later call.+# Skipping it returns 400 "Missing MCP-Session-Id".+_MCP_URL = "http://127.0.0.1:27182/mcp"+_mcp_session_id = None+_mcp_lock = threading.Lock()+++def _mcp_post(body: dict, sid: str = None, timeout: int = 30) -> dict:+    import urllib.request, urllib.error+    h = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}+    if sid:+        h["MCP-Session-Id"] = sid+    req = urllib.request.Request(_MCP_URL, method="POST",+                                 data=json.dumps(body).encode(), headers=h)+    try:+        with urllib.request.urlopen(req, timeout=timeout) as r:+            return {"status": r.status, "headers": dict(r.headers),+                    "body": r.read().decode("utf-8", "replace")}+    except urllib.error.HTTPError as e:+        return {"httpError": e.code, "headers": dict(e.headers),+                "body": e.read().decode("utf-8", "replace")[:1000]}+    except Exception as e:+        return {"error": "%s: %s" % (type(e).__name__, str(e)[:200])}+++def _mcp_new_session() -> dict:+    """initialize + notifications/initialized. Returns {sessionId, serverInfo} or {error}."""+    global _mcp_session_id+    r = _mcp_post({"jsonrpc": "2.0", "id": 1, "method": "initialize",+                   "params": {"protocolVersion": "2024-11-05", "capabilities": {},+                              "clientInfo": {"name": "adom-desktop-fusion-bridge",+                                             "version": BRIDGE_VERSION}}})+    if r.get("error") or r.get("httpError"):+        return {"error": r.get("error") or ("HTTP %s" % r.get("httpError")), "raw": r.get("body")}+    sid = None+    for k, v in (r.get("headers") or {}).items():+        if k.lower() == "mcp-session-id":+            sid = v+    info = {}+    try:+        info = (json.loads(r.get("body") or "{}").get("result") or {}).get("serverInfo") or {}+    except Exception:+        pass+    if sid:+        _mcp_post({"jsonrpc": "2.0", "method": "notifications/initialized"}, sid)+        _mcp_session_id = sid+    return {"sessionId": sid, "serverInfo": info}+++def _mcp_rpc(method: str, params: dict = None, timeout: int = 60) -> dict:+    """One JSON-RPC call with automatic session (re)establishment."""+    global _mcp_session_id+    with _mcp_lock:+        if not _mcp_session_id:+            s = _mcp_new_session()+            if s.get("error"):+                return {"_mcpError": s["error"]}+        sid = _mcp_session_id+    body = {"jsonrpc": "2.0", "id": 7, "method": method}+    if params is not None:+        body["params"] = params+    r = _mcp_post(body, sid, timeout=timeout)+    # a dropped/rotated session shows up as 400 Missing/Invalid session; re-init once+    if r.get("httpError") in (400, 404) and "ession" in str(r.get("body", "")):+        with _mcp_lock:+            _mcp_session_id = None+            s = _mcp_new_session()+            if s.get("error"):+                return {"_mcpError": s["error"]}+            sid = _mcp_session_id+        r = _mcp_post(body, sid, timeout=timeout)+    if r.get("error") or r.get("httpError"):+        return {"_mcpError": r.get("error") or ("HTTP %s: %s" % (r.get("httpError"), r.get("body", "")[:200]))}+    try:+        return json.loads(r.get("body") or "{}")+    except Exception:+        return {"_mcpError": "unparseable response", "raw": (r.get("body") or "")[:400]}+++_MCP_OFF_STATIC = (+    "Fusion's MCP server is not reachable on 127.0.0.1:27182. It is OFF by default. The bridge "+    "can turn it on FOR the user: call fusion_mcp_enable, which opens Preferences, expands "+    "General, selects API, ticks 'Fusion MCP Server (runs locally on this device)' and clicks "+    "Apply/OK. It needs Fusion running and briefly uses the foreground (announced with an "+    "on-screen caption). Manual path if you prefer: profile icon (top right) -> Preferences -> "+    "General -> API -> tick the box. NOTE the setting does NOT survive if Fusion is killed before "+    "Apply is clicked."+)+++def _mcp_off_hint() -> str:+    """The MCP-unreachable hint, made state-aware: never tell the AI to go enable MCP when APS+    is signed in and could serve the request RIGHT NOW with zero setup."""+    r = _search_router()+    lead = ""+    if r.get("apsReady"):+        lead = ("NOTE FIRST: APS is signed in, so for FILE SEARCH you do not need MCP at all - "+                "fusion_aps_search works right now. Only enable MCP if you need its other surface "+                "(script text-to-CAD, screenshots, electronics object-model reads). ")+    elif r.get("apsConfigured"):+        lead = ("NOTE: for FILE SEARCH, APS is configured and only needs fusion_aps_signin "+                "(silent on a warm SSO session) - that may be less disruptive than enabling MCP. ")+    return lead + _MCP_OFF_STATIC+++def _handle_mcp_status(fusion_info: dict, args: dict) -> dict:+    """Is Autodesk's Fusion MCP server up, and what does it expose?"""+    s = _mcp_new_session()+    if s.get("error"):+        return {"success": True, "enabled": False, "url": _MCP_URL, "error": s.get("error"),+                "_hint": _mcp_off_hint(), "statusVerb": "fusion_mcp_status"}+    tools = []+    tl = _mcp_rpc("tools/list")+    if not tl.get("_mcpError"):+        tools = [{"name": t.get("name"),+                  "description": (t.get("description") or "").split("\n")[0][:160]}+                 for t in ((tl.get("result") or {}).get("tools") or [])]+    return {"success": True, "enabled": True, "url": _MCP_URL,+            "sessionId": s.get("sessionId"), "serverInfo": s.get("serverInfo"),+            "toolCount": len(tools), "tools": tools,+            "_hint": ("Autodesk's Fusion MCP server is LIVE. Its tools are Autodesk's own, not this "+                      "bridge's: call them with fusion_mcp_call {tool, arguments}. Use "+                      "fusion_mcp_tools for full input schemas and fusion_mcp_resources for the "+                      "Electronics entity schemas. These COMPLEMENT the fusion_* verbs: prefer a "+                      "native verb when one exists (it is tested and returns richer hints), and "+                      "reach for MCP for Autodesk's text-to-CAD surface."),+            "statusVerb": "fusion_mcp_status"}+++def _handle_mcp_tools(fusion_info: dict, args: dict) -> dict:+    """Full tool list WITH input schemas."""+    tl = _mcp_rpc("tools/list")+    if tl.get("_mcpError"):+        return {"success": False, "enabled": False, "error": tl["_mcpError"], "_hint": _mcp_off_hint()}+    tools = (tl.get("result") or {}).get("tools") or []+    return {"success": True, "count": len(tools), "tools": tools,+            "_hint": "Call one with fusion_mcp_call {\"tool\":\"<name>\",\"arguments\":{...}}.",+            "statusVerb": "fusion_mcp_status"}+++def _handle_mcp_call(fusion_info: dict, args: dict) -> dict:+    """Call ANY tool on Autodesk's Fusion MCP server."""+    tool = (args or {}).get("tool")+    if not tool:+        return {"success": False, "error": "Pass {tool, arguments}.",+                "_hint": "fusion_mcp_tools lists the available tools and their input schemas."}+    r = _mcp_rpc("tools/call", {"name": tool, "arguments": (args or {}).get("arguments") or {}},+                 timeout=int((args or {}).get("timeout") or 120))+    if r.get("_mcpError"):+        return {"success": False, "error": r["_mcpError"], "_hint": _mcp_off_hint()}+    res = r.get("result") or {}+    return {"success": not res.get("isError", False), "tool": tool, "result": res,+            "_hint": ("Autodesk MCP tool result. Content is usually a list of {type,text} blocks. "+                      "If it complains about no active document, open one first (fusion_aps_open) "+                      "- MCP acts on the ACTIVE Fusion document."),+            "statusVerb": "fusion_mcp_status"}+++def _handle_mcp_resources(fusion_info: dict, args: dict) -> dict:+    """List, or read, Autodesk's MCP resources (the Electronics entity schemas)."""+    uri = (args or {}).get("uri")+    if uri:+        r = _mcp_rpc("resources/read", {"uri": uri})+        if r.get("_mcpError"):+            return {"success": False, "error": r["_mcpError"], "_hint": _mcp_off_hint()}+        return {"success": True, "uri": uri, "result": r.get("result"),+                "statusVerb": "fusion_mcp_status"}+    r = _mcp_rpc("resources/list")+    if r.get("_mcpError"):+        return {"success": False, "error": r["_mcpError"], "_hint": _mcp_off_hint()}+    res = (r.get("result") or {}).get("resources") or []+    return {"success": True, "count": len(res),+            "resources": [x.get("uri") for x in res],+            "_hint": "Read one with fusion_mcp_resources {\"uri\":\"resource://...\"}.",+            "statusVerb": "fusion_mcp_status"}+++def _handle_mcp_enable(fusion_info: dict, args: dict) -> dict:+    """Turn Autodesk's MCP server ON by driving Fusion's Preferences dialog.++    Autodesk exposes NO API for this toggle (apiPreferences has debuggingPort and+    isDeveloperToolsEnabled but nothing for MCP), so the UI is the only route. Proven live+    2026-07-23. The tree children under General are rendered lazily by Qt and are NOT in the+    UIA tree, so the section click is image-space and DOES take the foreground - announced.+    """+    import time as _t+    if not _unwrap(_handle_fusion_readiness({}, {})).get("running"):+        return {"success": False, "error": "Fusion is not running.",+                "_hint": "fusion_start first, then fusion_mcp_enable."}+    already = _mcp_new_session()+    if not already.get("error"):+        return {"success": True, "enabled": True, "alreadyOn": True,+                "_hint": "Already enabled; nothing to do. fusion_mcp_status shows the tools."}++    _announce_foreground("turning on Fusion's MCP server in preferences")+    steps = []+    r = _unwrap(_ad_call("fusion_execute_text_command", {"command": "Commands.Start PreferencesCommand"}, timeout=45))+    _t.sleep(3)+    dlg = None+    for w in (_unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []):+        if str(w.get("title", "")).strip() == "Preferences":+            dlg = w.get("hwnd")+    if not dlg:+        return {"success": False, "error": "Preferences dialog did not open.",+                "steps": steps, "_hint": _mcp_off_hint()}+    steps.append("opened Preferences")++    def shot():+        s = _unwrap(_ad_call("desktop_screenshot_window", {"hwnd": int(dlg)}, timeout=45))+        cm = s.get("coordMap") or {}+        img = cm.get("image") or {}+        return cm.get("shotId"), (img.get("w") or 1400), (img.get("h") or 836)++    sid_, W, H = shot()+    if not sid_:+        return {"success": False, "error": "could not capture Preferences", "steps": steps}+    # fractional coords, measured on the real dialog (1400x836): expand arrow, then API row+    _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),+                               "x": int(W * 0.029), "y": int(H * 0.092)}, timeout=30)   # expand General+    steps.append("expanded General")+    _t.sleep(2)+    sid_, W, H = shot()+    _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),+                               "x": int(W * 0.071), "y": int(H * 0.135)}, timeout=30)   # API row+    steps.append("selected API")+    _t.sleep(2)+    sid_, W, H = shot()+    _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),+                               "x": int(W * 0.493), "y": int(H * 0.569)}, timeout=30)   # the checkbox+    steps.append("ticked 'Fusion MCP Server'")+    _t.sleep(1)+    _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),+                               "x": int(W * 0.839), "y": int(H * 0.948)}, timeout=30)   # Apply+    _t.sleep(2)+    _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),+                               "x": int(W * 0.894), "y": int(H * 0.948)}, timeout=30)   # OK+    steps.append("clicked Apply then OK")+    _t.sleep(4)++    s = _mcp_new_session()+    ok = not s.get("error")+    return {"success": ok, "enabled": ok, "steps": steps,+            "sessionId": s.get("sessionId"), "serverInfo": s.get("serverInfo"),+            "narrate": ("Fusion's MCP server is on. I enabled it for you in Preferences."+                        if ok else "I drove the Preferences dialog but the MCP port is still closed."),+            "_hint": ("MCP server ENABLED - fusion_mcp_status lists its tools. Tell the user it is "+                      "handled; they do not need to touch Preferences."+                      if ok else+                      "The toggle did not take. Ask the user to set it manually: profile icon (top "+                      "right) -> Preferences -> General -> API -> tick 'Fusion MCP Server'. Do NOT "+                      "restart or kill Fusion before they click Apply, that discards the setting."),+            "statusVerb": "fusion_mcp_status"}+++COMMAND_HANDLERS["mcp_status"] = _handle_mcp_status+COMMAND_HANDLERS["mcp_tools"] = _handle_mcp_tools+COMMAND_HANDLERS["mcp_call"] = _handle_mcp_call+COMMAND_HANDLERS["mcp_resources"] = _handle_mcp_resources+COMMAND_HANDLERS["mcp_enable"] = _handle_mcp_enable+++# ── FUSION PREFERENCES, DRIVEN THROUGH THE UI (John, 2026-07-23) ─────────────────────────────+# Fusion's Python API exposes only a THIN slice of preferences (generalPreferences: theme, orbit,+# units; apiPreferences: debuggingPort, isDeveloperToolsEnabled). Everything else - including the+# Fusion MCP Server toggle - is UI-only.+#+# But the Preferences dialog IS reachable programmatically:+#   Commands.Start PreferencesCommand   opens it (no menu hunting, no profile-icon click)+# and its LEFT-HAND SECTION TREE is exposed to UIA, so sections can be found by name.+#+# The catch, learned live: the CHILD sections under General (API, Design, Manufacture,+# Electronics, Render, Drawing, Simulation) are rendered LAZILY by Qt and never appear in the+# UIA tree, even after desktop_ui_expand reports success. So navigating into a child section+# needs an image-space click, which takes the foreground and is therefore announced with a+# caption. Everything up to that point is background.+#+# ⛔ The setting is DISCARDED unless Apply/OK is clicked. Killing or restarting Fusion with the+# dialog still open loses it (that is exactly why an earlier MCP enable silently reverted).+_PREF_SECTIONS = {+    # section -> (parent-to-expand or None, fractional x, fractional y) on the 1400x836 dialog+    "general":     (None,      0.071, 0.092),+    "api":         ("general", 0.071, 0.135),+    "design":      ("general", 0.071, 0.179),+    "manufacture": ("general", 0.071, 0.222),+    "electronics": ("general", 0.071, 0.265),+    "render":      ("general", 0.071, 0.310),+    "drawing":     ("general", 0.071, 0.353),+    "material":    (None,      0.071, 0.483),+    "graphics":    (None,      0.071, 0.527),+    "network":     (None,      0.071, 0.570),+    "preview features": (None, 0.071, 0.744),+}+++def _prefs_dialog_hwnd():+    for w in (_unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []):+        if str(w.get("title", "")).strip() == "Preferences":+            return w.get("hwnd")+    return None+++def _prefs_shot(hwnd):+    s = _unwrap(_ad_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=45))+    cm = s.get("coordMap") or {}+    img = cm.get("image") or {}+    return (cm.get("shotId"), img.get("w") or 1400, img.get("h") or 836,+            s.get("localSafePath"))+++def _handle_prefs_open(fusion_info: dict, args: dict) -> dict:+    """Open Fusion Preferences, optionally navigate to a section, and hand back a screenshot the+    AI can click in. This is how you reach ANY preference, not just the API-exposed few."""+    import time as _t+    if not _unwrap(_handle_fusion_readiness({}, {})).get("running"):+        return {"success": False, "error": "Fusion is not running.",+                "_hint": "fusion_start first."}+    section = str((args or {}).get("section") or "").strip().lower()+    steps = []+    hwnd = _prefs_dialog_hwnd()+    if not hwnd:+        _announce_foreground("opening Fusion preferences")+        _ad_call("fusion_execute_text_command",+                 {"command": "Commands.Start PreferencesCommand"}, timeout=45)+        _t.sleep(3)+        hwnd = _prefs_dialog_hwnd()+        steps.append("opened Preferences")+    if not hwnd:+        return {"success": False, "error": "Preferences dialog did not open.", "steps": steps}++    if section:+        spec = _PREF_SECTIONS.get(section)+        if not spec:+            return {"success": False, "error": "Unknown section %r." % section,+                    "knownSections": sorted(_PREF_SECTIONS),+                    "_hint": ("Pass one of knownSections, or omit `section` and click the returned "+                              "shotId yourself with desktop_click {space:'image'}.")}+        parent, fx, fy = spec+        sid_, W, H, _p = _prefs_shot(hwnd)+        if parent:+            pspec = _PREF_SECTIONS[parent]+            # click the expand arrow, left of the parent label+            _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),+                                       "x": int(W * 0.029), "y": int(H * pspec[2])}, timeout=30)+            steps.append("expanded %s" % parent)+            _t.sleep(2)+            sid_, W, H, _p = _prefs_shot(hwnd)+        _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),+                                   "x": int(W * fx), "y": int(H * fy)}, timeout=30)+        steps.append("selected %s" % section)+        _t.sleep(2)++    sid_, W, H, path = _prefs_shot(hwnd)+    return {"success": True, "hwnd": hwnd, "section": section or "general",+            "shotId": sid_, "imageWidth": W, "imageHeight": H, "screenshot": path,+            "steps": steps, "knownSections": sorted(_PREF_SECTIONS),+            "_hint": ("Preferences is OPEN and showing this section. LOOK at the screenshot, then "+                      "click any control with desktop_click {space:'image', shotId, x, y, hwnd}. "+                      "⛔ Nothing is saved until you call fusion_prefs_close {save:true} (Apply+OK) "+                      "- killing or restarting Fusion first DISCARDS the change. Child sections "+                      "under General are lazily rendered and absent from the UIA tree, which is why "+                      "this verb hands you an image to click rather than control names."),+            "statusVerb": "fusion_get_preferences"}+++def _handle_prefs_close(fusion_info: dict, args: dict) -> dict:+    """Apply+OK (save:true, default) or Cancel the Preferences dialog."""+    import time as _t+    save = (args or {}).get("save", True)+    hwnd = _prefs_dialog_hwnd()+    if not hwnd:+        return {"success": True, "closed": False,+                "_hint": "Preferences was not open; nothing to do."}+    sid_, W, H, _p = _prefs_shot(hwnd)+    if save:+        _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),+                                   "x": int(W * 0.839), "y": int(H * 0.948)}, timeout=30)  # Apply+        _t.sleep(2)+        _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),+                                   "x": int(W * 0.894), "y": int(H * 0.948)}, timeout=30)  # OK+    else:+        _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),+                                   "x": int(W * 0.951), "y": int(H * 0.948)}, timeout=30)  # Cancel+    _t.sleep(2)+    return {"success": True, "closed": _prefs_dialog_hwnd() is None, "saved": bool(save),+            "narrate": ("Saved your Fusion preferences." if save else "Closed preferences without saving."),+            "_hint": ("Applied and closed. Some settings (the MCP server is one) only take effect "+                      "once saved this way." if save else "Cancelled; nothing was changed."),+            "statusVerb": "fusion_get_preferences"}+++COMMAND_HANDLERS["prefs_open"] = _handle_prefs_open+COMMAND_HANDLERS["prefs_close"] = _handle_prefs_close+++def _fusion_signin_cfg_path():+    import pathlib+    d = pathlib.Path(os.path.expanduser("~")) / ".adom" / "fusion-signin"+    d.mkdir(parents=True, exist_ok=True)+    return d / "profile.json"+++def _remembered_signin_profile():+    try:+        import json as _j+        p = _fusion_signin_cfg_path()+        if p.exists():+            return (_j.loads(p.read_text()) or {}).get("profile") or None+    except Exception:+        pass+    return None+++def _remember_signin_profile(profile: str):+    try:+        import json as _j+        _fusion_signin_cfg_path().write_text(_j.dumps({"profile": profile}))+    except Exception:+        pass+++def _chrome_authorize_urls(max_age_min: int = 15):+    """Scan Chrome + Edge profile History DBs for RECENT Autodesk-desktop OAuth authorize+    URLs (the Fusion Identity SDK ones - marked by `idsdk` / redirect to idmgr/callback).++    This is how we fix Fusion opening the WRONG browser profile: Fusion fires its OAuth at+    the OS-default browser, but the FULL authorize URL (client_id, PKCE challenge, state,+    request_id) lands in that browser's History. We read it back and re-open it in the+    profile the user actually authenticates Autodesk in. The URL is NOT tied to a browser+    (its redirect is accounts.autodesk.com/idmgr/callback + an autodesk:// protocol handoff+    matched by request_id), so completing it in ANY profile hands the token to the running+    Fusion. Verified live (John, 2026-07-21).++    Returns a list of {url, sourceProfileDir, browser, ageSec} newest first.+    """+    import glob, sqlite3, shutil, tempfile, time as _t+    la = os.environ.get("LOCALAPPDATA", "")+    roots = []+    if la:+        roots.append(("chrome", os.path.join(la, "Google", "Chrome", "User Data")))+        roots.append(("edge", os.path.join(la, "Microsoft", "Edge", "User Data")))+    now = _t.time()+    found = []+    for browser, root in roots:+        if not os.path.isdir(root):+            continue+        for hist in glob.glob(os.path.join(root, "*", "History")):+            prof_dir = os.path.basename(os.path.dirname(hist))+            tmp = os.path.join(tempfile.gettempdir(), "adom_hist_%s_%s.db" % (browser, prof_dir))+            try:+                shutil.copy2(hist, tmp)  # copy first - Chrome keeps History locked+                # Chrome buffers recent rows in the -wal; copy it too or a fresh authorize URL+                # is invisible (this was the bug: a just-opened sign-in did not appear).+                for ext in ("-wal", "-shm"):+                    if os.path.exists(hist + ext):+                        try: shutil.copy2(hist + ext, tmp + ext)+                        except Exception: pass+                con = sqlite3.connect(tmp)+                try: con.execute("PRAGMA journal_mode=WAL")+                except Exception: pass+                rows = con.execute(+                    "SELECT url, last_visit_time FROM urls "+                    "WHERE url LIKE '%developer.api.autodesk.com/authentication/v2/authorize%' "+                    "OR url LIKE '%idp.auth.autodesk.com/as/authorize%' "+                    "ORDER BY last_visit_time DESC LIMIT 6").fetchall()+                con.close()+            except Exception:+                continue+            for url, cts in rows:+                if "idsdk" not in url and "idmgr" not in url:+                    continue  # only the DESKTOP-app (Fusion) flow, not a web app+                epoch = (cts / 1_000_000) - 11644473600+                age = now - epoch+                if age <= max_age_min * 60:+                    found.append({"url": url, "sourceProfileDir": prof_dir,+                                  "browser": browser, "ageSec": int(age)})+    found.sort(key=lambda x: x["ageSec"])+    return found+++def _fusion_window():+    """Find the main Fusion window via desktop_list_windows. Returns the whole entry (it already+    carries `rect`, so we get geometry in the SAME call - there is no desktop_window_info verb).+    Returns {} when not found."""+    wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []+    cand = {}+    for w in wins:+        t = str(w.get("title", ""))+        tl = t.lower()+        if "autodesk fusion" in tl or tl.startswith("signing in"):+            if "signing in" in tl or "welcome" in tl:   # prefer the sign-in/welcome window+                return w+            cand = cand or w+    return cand+++def _fusion_window_hwnd():+    """Just the hwnd of the main Fusion window (int|None)."""+    return (_fusion_window() or {}).get("hwnd")+++# ── NEVER STEAL THE USER'S FOREGROUND (John, 2026-07-22) ─────────────────────────────────────+# "try to NEVER bring windows to the foreground cuz its disruptive."+# desktop_click is SendInput and FOREGROUNDS the window. desktop_ui_click/desktop_ui_set are UIA+# Invoke/SetValue: programmatic, NO focus steal, NO cursor move, and Chromium exposes its a11y+# tree so page buttons/fields ARE reachable by accessible name. So: ALWAYS try background UIA+# first and only fall back to a foreground click for things with no UIA node (Fusion's Sign In+# button is a webview with none - that is the one documented exception).+# ── SIGN-IN WINDOW JANITOR (John, 2026-07-22) ────────────────────────────────────────────────+# "if you open a browser window for sign in, you must run a loop on a schedule to know when to+# close it so you never leave the user's desktop messy."+# We open browser windows to complete OAuth. Those windows are OURS and must not be left as+# litter once they have served their purpose. Every window we open is tracked here, and a+# background loop closes it as soon as the sign-in it belongs to is DONE (or it is clearly dead+# or too old). Never touches a window the user opened.+_signin_windows = []          # [{sessionId, profile, kind, openedAt}]+_signin_windows_lock = threading.Lock()+_JANITOR_MAX_AGE = 900+_last_update_sweep = 0.0        # 15 min: an OAuth window older than this is dead weight either way+++def _track_signin_window(session_id, profile, kind="fusion"):+    if not session_id:+        return+    with _signin_windows_lock:+        _signin_windows.append({"sessionId": session_id, "profile": profile,+                                "kind": kind, "openedAt": _t2.time()})+++def _close_tracked_window(w) -> bool:+    """Close one window WE opened. nbrowser_close_window only closes our own sessions."""+    r = _ad_call("nbrowser_close_window",+                 {"sessionId": w["sessionId"], "profile": w.get("profile")}, timeout=30)+    return isinstance(r, dict) and not r.get("_adCallError")+++def _signin_goal_met(kind) -> bool:+    """Has the sign-in this window exists for actually completed?"""+    try:+        if kind == "aps":+            return bool(_aps_quick_state().get("signedIn"))+        return bool(_unwrap(_handle_fusion_readiness({}, {})).get("ready"))+    except Exception:+        return False+++def _signin_janitor_loop():+    """Poll until each tracked sign-in window can be closed, then close it."""+    while True:+        try:+            _t2.sleep(20)+            # Periodic AUTO-UPDATE sweep. Fusion's update nag only enters the UIA tree while its+            # Job Status panel is open, so a single check at readiness time misses most of them.+            # Sweeping on a schedule catches the nag whenever Fusion actually shows it, and the+            # click is background so the user never sees any of it.+            global _last_update_sweep+            if _auto_update_enabled() and (_t2.time() - _last_update_sweep) > 300:+                _last_update_sweep = _t2.time()+                try:+                    if _unwrap(_handle_fusion_readiness({}, {})).get("running"):+                        _apply_fusion_update_silently()+                except Exception:+                    pass+            with _signin_windows_lock:+                pending = list(_signin_windows)+            if not pending:+                continue+            done_goals = {}+            for w in pending:+                kind = w.get("kind", "fusion")+                if kind not in done_goals:+                    done_goals[kind] = _signin_goal_met(kind)+                aged = (_t2.time() - w["openedAt"]) > _JANITOR_MAX_AGE+                if done_goals[kind] or aged:+                    if _close_tracked_window(w):+                        print("[Fusion Bridge] janitor: closed %s sign-in window %s (%s)"+                              % (kind, w["sessionId"], "signed in" if done_goals[kind] else "expired"))+                    with _signin_windows_lock:+                        if w in _signin_windows:+                            _signin_windows.remove(w)+        except Exception:+            continue+++def _start_signin_janitor():+    t = threading.Thread(target=_signin_janitor_loop, daemon=True, name="signin-janitor")+    t.start()+    return t+++def _announce_foreground(reason: str) -> bool:+    """ALWAYS tell the user WHY, right before we steal their foreground.++    John, 2026-07-22: "anytime you bring something to the foreground, you should show an ad notify+    for 2 seconds telling the user why... or an ad caption." A caption is the gentler of the two+    (no toast stack, auto-clears), so that is what we use. Best-effort; never blocks the action.+    """+    try:+        _ad_call("desktop_caption",+                 {"text": "Adom: %s" % reason, "id": "fusion-bridge-foreground",+                  "expiresInMs": 2500}, timeout=15)+        return True+    except Exception:+        return False+++def _click_background_first(hwnd, name=None, shot_id=None, x=None, y=None, reason=None) -> dict:+    """Click preferring the BACKGROUND UIA path (no focus steal, no cursor move). Only falls back+    to SendInput - which DOES foreground - when there is no UIA node, and in that case announces+    WHY to the user first. Returns {clicked, via, why, announced}."""+    if name:+        r = _unwrap(_ad_call("desktop_ui_click", {"hwnd": int(hwnd), "name": name}, timeout=30))+        if r.get("ok") or r.get("clicked") or r.get("invoked"):+            return {"clicked": True, "via": "uia_background", "announced": False,+                    "why": "UIA Invoke on %r (background, no focus steal)" % name}+    if shot_id is not None and x is not None and y is not None:+        announced = _announce_foreground(reason or "clicking %s for you" % (name or "a button"))+        _ad_call("desktop_click", {"space": "image", "shotId": shot_id,+                                   "x": int(x), "y": int(y), "hwnd": int(hwnd)}, timeout=30)+        return {"clicked": True, "via": "foreground_click", "announced": announced,+                "why": "no UIA node; used SendInput (DOES foreground - user was told why)"}+    return {"clicked": False, "via": None, "announced": False,+            "why": "no UIA node and no shot coords given"}+++# ── HUMAN WALLS: notify the user, and tell the AI it can drive it ────────────────────────────+# John, 2026-07-22: "you should be sending me ad notifies when you need me to do something" AND+# "suggest to the ai via hints that you can drive the thing on your own cuz the adom user wants+# you to do everything automatically if you can". So on every wall we do BOTH: toast the human,+# and hand the AI an exact, mechanical way to clear it without them.+def _autodesk_window_hwnds() -> set:+    """hwnds of every Autodesk-ish BROWSER window right now (used to tell NEW from STALE)."""+    wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []+    out = set()+    for w in wins:+        tl = str(w.get("title", "")).lower()+        if "autodesk" in tl and "fusion" not in tl:+            out.add(w.get("hwnd"))+    return out+++def _detect_signin_wall(ignore_hwnds=None) -> dict:+    """Classify what the Autodesk sign-in is waiting on, from BROWSER WINDOW TITLES.+    Title-based on purpose: cheap, needs no OCR, and never touches/foregrounds the window.++    ignore_hwnds = windows that already existed BEFORE we opened this sign-in. Without it a dead+    'Sign-in request expired' / stale '2-step verification' tab from an earlier attempt gets+    reported as the live wall and we toast the user about nothing (seen live 2026-07-22).+    """+    ignore = set(ignore_hwnds or ())+    wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []+    best = {}+    for w in wins:+        if w.get("hwnd") in ignore:+            continue+        t = str(w.get("title", "")); tl = t.lower()+        if "2-step verification" in tl or "confirm sign-in" in tl:+            return {"wall": "twofactor_email_code", "hwnd": w.get("hwnd"), "title": t}+        if "identity manager" in tl:+            best = {"wall": "protocol_handoff", "hwnd": w.get("hwnd"), "title": t}+        elif "sign in - autodesk" in tl and not best:+            best = {"wall": "credentials", "hwnd": w.get("hwnd"), "title": t}+    return best+++_WALL_COPY = {+    "twofactor_email_code": ("Autodesk needs your 6-digit code",+                             "Autodesk emailed a verification code to finish signing Fusion in. "+                             "Enter it in the Chrome window, or tell your AI to fetch it for you."),+    "credentials": ("Autodesk sign-in needs you",+                    "The Autodesk sign-in is asking for credentials. I never type your password "+                    "or 2FA - please complete it in the Chrome window."),+    "protocol_handoff": ("Allow Fusion to finish signing in",+                         "Chrome is asking permission to open Autodesk Identity Manager. Click "+                         "Open to hand the login back to Fusion."),+}+++def _notify_signin_wall(wall: str) -> dict:+    """Toast the user's real desktop about a human wall. Best-effort, never raises."""+    title, body = _WALL_COPY.get(wall, ("Fusion sign-in needs you", "Please check the sign-in window."))+    return _cli_notify_all(title, body, "warning")+++def _wall_hint(wall: str, notified: bool) -> str:+    """The AI-facing instruction for a wall. Per John: do as much as possible IN CODE, and use+    HINTS (not skills) to make the calling AI act correctly."""+    common = ("I ALREADY TOASTED THE USER about this (notifyDelivered=%s) - you do not need to ask "+              "me to, and do not nag them again. " % notified)+    if wall == "twofactor_email_code":+        return (common ++                "WALL: Autodesk emailed a 6-DIGIT CODE. THE USER WANTS YOU TO DO THIS FOR THEM if "+                "you can, so TRY THIS FIRST, do not just hand it back to them: if `adom-google` is "+                "available in YOUR container, read the code out of their Gmail and submit it - "+                "  adom-google gmail search 'from:autodesk verification code' --limit 1 \n"+                "then pull the 6 digits and call  fusion_signin_2fa {\"code\":\"123456\"}  which "+                "types it in the BACKGROUND (no foreground steal) and submits. If `adom-google` is "+                "NOT installed, TELL THE USER that installing adom-google would let you finish "+                "logins like this automatically next time, then let them type the code. NEVER ask "+                "them for the code if you can read it yourself.")+    if wall == "protocol_handoff":+        return (common ++                "WALL: Chrome's native 'Open Autodesk Identity Manager?' dialog. Try to clear it "+                "YOURSELF in the background: desktop_find_control on that dialog then "+                "desktop_ui_click {name:'Open'} (UIA, no focus steal). Only if UIA finds no node, "+                "fall back to a foregrounded desktop_click. Then poll fusion_readiness.")+    return (common ++            "WALL: the sign-in wants credentials. NEVER type their password or 2FA. If the browser "+            "autofills, you may submit it. Otherwise let the user finish in the window I opened.")+++def _unwrap(r):+    """AD verb responses come back either flat or nested under `data` (the CLI puts the real+    payload in output.data). Always look through both - forgetting this silently broke the+    server-side Sign In click, because coordMap/shotId live one level down."""+    if not isinstance(r, dict):+        return {}+    inner = r.get("data")+    if isinstance(inner, dict) and inner:+        return inner+    return r+++def _fusion_click_signin() -> dict:+    """Click Fusion's 'Welcome to Fusion' -> Sign In button SERVER-SIDE, ALWAYS, on the user's+    behalf (John, 2026-07-22: "you should ALWAYS just click sign in on behalf of the adom+    users"). The button is a webview with no UIA node, so this is an image-space click.++    Runs on-box, so it costs no remote round-trip against Fusion's ~2-min OAuth expiry.+    Returns {clicked: bool, why: str} - never raises.+    """+    import time as _t+    win = _fusion_window()+    hwnd = win.get("hwnd") or _unwrap(_handle_fusion_readiness({}, {})).get("hwnd")+    if not hwnd:+        return {"clicked": False, "why": "could not find the Fusion window"}++    # A MINIMIZED Fusion captures as a ~237x39 title-bar sliver, so the click lands on nothing.+    # Restore it first (seen live: rect was at -32000,-32000, the Windows minimized position).+    rect = win.get("rect") or {}+    if (rect.get("left") is not None and rect["left"] <= -30000) or \+       (0 < (rect.get("width") or 0) < 600) or (0 < (rect.get("height") or 0) < 400):+        _ad_call("desktop_set_window_state", {"hwnd": int(hwnd), "state": "restore"}, timeout=30)+        _t.sleep(1.5)++    shot = _unwrap(_ad_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=45))+    cm = shot.get("coordMap") or {}+    sid = cm.get("shotId") or shot.get("shotId")+    if not sid:+        return {"clicked": False, "why": "screenshot returned no shotId"}+    img = cm.get("image") or {}+    w = shot.get("width") or img.get("w") or img.get("width") or cm.get("imageWidth") or 1400+    h = shot.get("height") or img.get("h") or img.get("height") or cm.get("imageHeight") or 860+    # Background UIA first (no focus steal). Fusion's Sign In is a WEBVIEW button with no UIA+    # node, so this almost always falls through to the image-space click - that is the documented+    # exception to the never-foreground rule, not a licence to foreground elsewhere.+    r = _click_background_first(hwnd, name="Sign In", shot_id=sid,+                                x=int(w * 0.5), y=int(h * 0.57),+                                reason="signing Fusion in for you (clicking Sign In)")+    return {"clicked": r.get("clicked"),+            "why": "%s (image %dx%d)" % (r.get("why"), w, h),+            "via": r.get("via")}+++def _capture_fresh_authorize(wait_sec: int = 30, max_age: int = 120):+    """Poll the browser History for a FRESH (< max_age s) Fusion authorize URL for up to+    wait_sec, ON-BOX. Collapsing capture into one server-side wait (instead of the caller+    re-polling over the flaky relay) is what lets the whole chain finish inside Fusion's+    ~2-min request window. Returns the auth dict or None."""+    import time as _t+    deadline = _t.time() + max(1, wait_sec)+    while True:+        urls = _chrome_authorize_urls()+        if urls and urls[0]["ageSec"] <= max_age:+            return urls[0]+        if _t.time() >= deadline:+            return urls[0] if urls else None+        _t.sleep(2)+++_SSO_BUTTONS = {"google": "Continue with Google", "apple": "Continue with Apple",+                "microsoft": "Continue with Microsoft", "facebook": "Continue with Facebook"}+++def _drive_sso_signin(profile: str, tab_id, email: str, provider: str = "google") -> dict:+    """Finish the Autodesk sign-in over CDP using SSO - NO password, NO 2FA, NO foreground.++    PROVEN LIVE (John, 2026-07-22) after password/2FA attempts kept stalling. If the browser+    PROFILE is already signed into the identity provider (a work Google account is the common+    case), "Continue with Google" -> pick the account -> "Open Product" completes the whole login+    silently and hands the token back to Fusion via the autodesk:// callback.++    TWO THINGS MATTER:+      1. It must be a FRESH flow. Resuming a flowId that already advanced past the provider+         choice lands you on the PASSWORD screen with no way back - that dead end cost us an hour.+      2. Click the provider BEFORE typing any email; entering an email commits to the password path.+    """+    steps = []++    def ev(expr):+        r = _unwrap(_ad_call("nbrowser_eval", {"profile": profile, "tabId": tab_id,+                                               "expression": expr}, timeout=45))+        return r.get("result") or r.get("value")++    def click(text=None, selector=None):+        a = {"profile": profile, "tabId": tab_id}+        if text: a["text"] = text+        if selector: a["selector"] = selector+        r = _unwrap(_ad_call("nbrowser_click", a, timeout=45))+        return bool(r.get("ok") or r.get("success") or r.get("changed"))++    btn = _SSO_BUTTONS.get((provider or "google").lower(), _SSO_BUTTONS["google"])+    if not click(text=btn):+        return {"done": False, "steps": steps,+                "why": "%r not on the page - this is probably NOT a fresh flow (a resumed flowId "+                       "goes straight to the password screen)." % btn}+    steps.append("clicked %r" % btn)+    _t2.sleep(7)++    # provider account chooser - pick by the exact email+    if email and click(text=email):+        steps.append("picked the %s account" % email)+        _t2.sleep(8)++    state = str(ev("JSON.stringify({u:location.href.slice(0,120),b:document.body.innerText.slice(0,200)})") or "")+    if "You're signed in" in state or "signed in" in state.lower():+        steps.append("provider returned 'You're signed in'")+        # hand the token back to Fusion (autodesk:// protocol callback)+        if click(text="Open Product"):+            steps.append("clicked 'Open Product' to hand the token to Fusion")+        return {"done": True, "steps": steps, "state": state[:200]}+    return {"done": False, "steps": steps, "state": state[:200],+            "why": "did not reach the signed-in page; a provider password/2FA may be required"}+++def _orchestrate_signin_2fa(args: dict) -> dict:+    """Submit Autodesk's emailed 6-digit verification code, IN THE BACKGROUND.++    Exists so the AI can finish a login end-to-end for the user (read the code from Gmail with+    adom-google, then call this) instead of parking them on a 2FA screen. Uses UIA SetValue ++    Invoke, so it never steals focus or moves the cursor.+    """+    code = str(args.get("code") or "").strip()+    digits = "".join(ch for ch in code if ch.isdigit())+    if len(digits) < 4:+        return {"success": False, "errorCode": "bad_code",+                "error": "Pass the emailed code, e.g. fusion_signin_2fa {\"code\":\"123456\"}.",+                "_hint": ("Read it from the user's Gmail if you can: "+                          "adom-google gmail search 'from:autodesk verification code' --limit 1 "+                          "-> take the 6 digits -> call this verb again. If adom-google is not "+                          "installed, tell the user it would let you automate this."),+                "statusVerb": "fusion_signin_2fa"}++    wall = _detect_signin_wall()+    hwnd = args.get("hwnd") or wall.get("hwnd")+    if not hwnd:+        return {"success": False, "errorCode": "no_2fa_window",+                "error": "No Autodesk 2-step-verification window found.",+                "_hint": ("Nothing is waiting on a code right now. Check fusion_readiness - if "+                          "needsSignin is still true, run fusion_signin again to restart the flow."),+                "statusVerb": "fusion_readiness"}++    # Find the code field WITHOUT touching the foreground. Autodesk renders either one input or a+    # segmented set, so try the obvious accessible names, then fall back to the first edit control.+    found = _unwrap(_ad_call("desktop_find_control",+                             {"hwnd": int(hwnd), "role": "edit"}, timeout=40))+    ctrls = found.get("controls") or found.get("matches") or []+    steps = []+    filled = False+    if len(ctrls) >= len(digits) and len(ctrls) >= 4:+        # segmented: one box per digit+        for i, ch in enumerate(digits[:len(ctrls)]):+            _ad_call("desktop_ui_set", {"hwnd": int(hwnd),+                                        "automationId": ctrls[i].get("automationId"),+                                        "name": ctrls[i].get("name"), "value": ch}, timeout=20)+        filled = True+        steps.append("typed %d digits into %d segmented boxes (background UIA)" % (len(digits), len(ctrls)))+    elif ctrls:+        c = ctrls[0]+        r = _unwrap(_ad_call("desktop_ui_set", {"hwnd": int(hwnd),+                                                "automationId": c.get("automationId"),+                                                "name": c.get("name"), "value": digits}, timeout=20))+        filled = bool(r.get("ok") or r.get("set") or r is not None)+        steps.append("typed the code into %r (background UIA)" % (c.get("name") or c.get("automationId")))++    if not filled:+        return {"success": False, "errorCode": "code_field_not_found",+                "error": "Could not find the code field via UIA.",+                "controlsSeen": ctrls[:8],+                "_hint": ("The code input was not in the UIA tree (shadow DOM). Run "+                          "desktop_find_control {hwnd:%s} to see what IS exposed. Last resort is a "+                          "FOREGROUNDED desktop_click on the field then desktop_type - avoid that "+                          "if you can, foregrounding is disruptive to the user." % hwnd),+                "statusVerb": "fusion_signin_2fa"}++    sub = _click_background_first(hwnd, name="Next")+    if not sub.get("clicked"):+        sub = _click_background_first(hwnd, name="Verify")+    steps.append("submitted via %s" % (sub.get("via") or "no submit control found"))++    return {"success": True, "submitted": True, "steps": steps,+            "narrate": "I read the code in and submitted it for you, without touching your screen.",+            "_hint": ("Code submitted in the BACKGROUND. Now poll fusion_readiness until ready:true. "+                      "If a Chrome 'Open Autodesk Identity Manager?' dialog appears, clear it with "+                      "desktop_ui_click {name:'Open'} (background) - fusion_signin reports that wall "+                      "too. If it says the code was wrong, the email may have a NEWER code."),+            "statusVerb": "fusion_readiness"}+++def _orchestrate_signin(args: dict) -> dict:+    """Sign Fusion in through the CORRECT browser profile - the fix for Fusion firing its+    OAuth at the OS-default browser (often the wrong Autodesk account). See the+    fusion-multiprofile-signin skill.++    Staged: pick the target profile (remembered / arg / probed / ask) -> ensure Fusion has+    emitted its OAuth URL (click Sign In if not) -> read that URL from the wrong browser's+    History -> re-open it in the TARGET profile in the BACKGROUND -> report where it waits.++    auto=True runs the whole chain in ONE server-side call: click Fusion's Sign In, poll on-box+    for the fresh authorize URL, then re-open it in the target profile - so the capture->reopen+    round-trips happen on the box (fast) and fit inside Fusion's ~2-min request expiry even when+    the remote relay is slow. This is the reliable path; the staged/manual path is the fallback.+    """+    steps = []+    # ALWAYS click Sign In for the user unless they explicitly opt out (auto:false). Adom users+    # should never be told "now go click Sign In yourself" - we do it for them and SAY so.+    auto = args.get("auto")+    auto = True if auto is None else bool(auto)+    wait_sec = int(args.get("waitSec") or 32)+    target = args.get("profile")           # explicit override, e.g. "chrome:[email protected]"+    if target:+        _remember_signin_profile(target)+    if not target:+        target = _remembered_signin_profile()++    rd = _handle_fusion_readiness({}, {})+    if not rd.get("running"):+        return {"success": True, "stage": "not_running", "done": False, "steps": steps,+                "_hint": "Fusion is not running. fusion_start, then fusion_signin.",+                "statusVerb": "fusion_signin"}+    if not rd.get("needsSignin"):+        return {"success": True, "stage": "already", "done": True, "steps": steps,+                "narrate": "Fusion is already signed in.",+                "_hint": "Already signed in (ready:%s). Nothing to do." % rd.get("ready"),+                "statusVerb": "fusion_signin"}++    # profiles known to ABE (for target selection + naming)+    profs = _ad_call("nbrowser_profiles", {}, timeout=40)+    pdata = profs.get("data", profs) if isinstance(profs, dict) else {}+    choices = [p for p in (pdata.get("profiles") or []) if isinstance(p, dict) and p.get("profile")]++    # read Fusion's authorize URL from browser history (it fires the OS-default browser)+    urls = _chrome_authorize_urls()+    fresh = urls[0] if (urls and urls[0]["ageSec"] <= 150) else None+    if auto and not fresh:+        # ONE server-side pass: click Fusion's Sign In FOR THE USER, then poll on-box for the URL.+        ck = _fusion_click_signin()+        clicked = ck.get("clicked")+        steps.append("clicked Fusion 'Sign In' for the user" if clicked+                     else "could not click Fusion 'Sign In' (%s)" % ck.get("why"))+        got = _capture_fresh_authorize(wait_sec=wait_sec, max_age=140)+        if got and got["ageSec"] <= 150:+            urls = [got]+        elif got:+            urls = [got]  # stale; fall through to the stale branch which tells us to reset+        else:+            urls = []+    if not urls:+        return {"success": True, "stage": "click_signin", "done": False, "steps": steps,+                "narrate": ("I clicked Fusion's Sign In for you, but Fusion has not written its "+                            "sign-in URL to a browser yet. Give it a few seconds."),+                "_hint": ("THIS VERB CLICKS FUSION'S 'Sign In' FOR THE USER - never tell them to go "+                          "click it themselves, and do not click it yourself. It just did (see "+                          "steps[]), but no Fusion OAuth URL has landed in any browser's history "+                          "yet. Simply CALL fusion_signin AGAIN in a few seconds; it will click if "+                          "needed, wait on-box for the URL, and re-open it in the right profile. "+                          "If steps[] says the click FAILED, that is a bridge bug worth reporting - "+                          "the usual causes are a minimized Fusion window (this verb restores it) "+                          "or the sign-in wall not being up yet (check fusion_readiness "+                          "needsSignin:true)."),+                "statusVerb": "fusion_signin"}+    auth = urls[0]+    steps.append("captured Fusion OAuth URL from %s profile '%s' (%ss old)"+                 % (auth["browser"], auth["sourceProfileDir"], auth["ageSec"]))+    # Fusion's sign-in request EXPIRES in ~2 min. A URL older than that completes the LOGIN but+    # Fusion no longer waits on its request_id -> "Sign-in request expired", no handoff. Never+    # silently reuse it; force a fresh request. (Learned live 2026-07-21.)+    if auth["ageSec"] > 150:+        return {"success": True, "stage": "stale", "done": False, "steps": steps,+                "signinAuthUrl": auth["url"], "signinAgeSec": auth["ageSec"],+                "_hint": ("The newest Fusion sign-in URL is %ss old - Fusion expires the request in "+                          "~2 min, so completing it yields 'Sign-in request expired' and NO handoff. "+                          "Get a FRESH request: fusion_stop then fusion_start (resets Fusion to a "+                          "clean 'Welcome to Fusion'), click Fusion's Sign In, and call fusion_signin "+                          "again PROMPTLY. Completion is near-instant when the target profile is "+                          "already signed into Autodesk (auto-consents). Skill: "+                          "fusion-multiprofile-signin." % auth["ageSec"]),+                "statusVerb": "fusion_signin"}++    # choose the TARGET profile if not already fixed+    reason = ""+    if not target:+        # probe each live profile for a real Autodesk session; prefer signed-in, then work over consumer+        for c in choices:+            if not c.get("live"):+                continue+            ls = _ad_call("nbrowser_login_state",+                          {"profile": c["profile"], "url": "https://accounts.autodesk.com/"}, timeout=40)+            lsd = ls.get("data", ls) if isinstance(ls, dict) else {}+            c["_ad"] = "signed-in" if lsd.get("loggedIn") else ("signed-out" if lsd.get("ok") is not None else "unprobed")+            c["_conf"] = lsd.get("confidence"); c["_cookies"] = lsd.get("cookieCount")+        signed = [c for c in choices if c.get("_ad") == "signed-in"]+        if signed:+            signed.sort(key=lambda c: ({"high": 3, "medium": 2, "low": 1}.get(c.get("_conf"), 0), c.get("_cookies") or 0), reverse=True)+            target = signed[0]["profile"]; reason = "it holds a live Autodesk session"+        else:+            _CONSUMER = ("gmail.com", "outlook.com", "hotmail.com", "yahoo.com", "icloud.com", "live.com")+            work = [c for c in choices if c.get("live") and c.get("email") and not any((c["email"] or "").lower().endswith("@" + d) for d in _CONSUMER)]+            if work:+                target = work[0]["profile"]; reason = "it is a work/corporate profile (no live Autodesk session detected - CONFIRM)"+    if target:+        _remember_signin_profile(target)+        reason = reason or "you told me to use it"++    if not target:+        return {"success": True, "stage": "ask_profile", "done": False, "steps": steps,+                "signinAuthUrl": auth["url"], "profileChoices": choices,+                "_hint": ("Captured Fusion's OAuth URL but cannot tell which profile is your Autodesk "+                          "account. ASK the user which profile their Autodesk login belongs to, then "+                          "call fusion_signin {profile:'chrome:<their-account>'} - I will remember it. "+                          "profileChoices lists every profile."),+                "statusVerb": "fusion_signin"}++    tdesc = next((_describe_profile(c) for c in choices if c.get("profile") == target), target)+    import time as _tt+    sess = "fusion-signin-%d" % int(_tt.time())   # unique per attempt (a reused id can be refused)+    # ── Is Fusion ALREADY using the right profile? (John, 2026-07-22: "they both opened in my+    # work profile, so you're being lazy") ──────────────────────────────────────────────────+    # Chrome profile DIRECTORIES are named Default / Profile 1 / Profile 2, and the user's work+    # account is very often just "Default". We capture the authorize URL from a profile DIR, but+    # the target is an EMAIL (chrome:[email protected]). Without mapping email -> dir we cannot tell+    # they are the SAME profile, so we "fixed" a non-problem by re-opening the identical URL in+    # the identical profile - a redundant second sign-in window on the user's screen.+    target_dir = None+    for c in choices:+        if c.get("profile") == target:+            target_dir = c.get("profileDir") or c.get("dir")+            break+    if target_dir and auth.get("sourceProfileDir") and \+       str(target_dir).strip().lower() == str(auth["sourceProfileDir"]).strip().lower():+        steps.append("Fusion already opened the sign-in in %s (profile dir %r) - no re-open needed"+                     % (target, target_dir))+        wall0 = _detect_signin_wall()+        notified0 = bool(_notify_signin_wall(wall0["wall"]).get("delivered")) if wall0.get("wall") else False+        return {"success": True, "stage": "already_right_profile", "done": False, "steps": steps,+                "signinProfile": target, "signinProfileDir": target_dir,+                "signinWhere": tdesc if False else target,+                "signinWall": wall0.get("wall"), "signinWallHwnd": wall0.get("hwnd"),+                "notifyDelivered": notified0,+                "signinWallHint": _wall_hint(wall0["wall"], notified0) if wall0.get("wall") else None,+                "signinAuthUrl": auth["url"],+                "narrate": ("Fusion opened its sign-in in the RIGHT profile already (%s), so I did "+                            "not open a second window." % target),+                "_hint": ("NO re-open was needed - Fusion's OS-default browser IS the user's Autodesk "+                          "profile here (dir %r). Do NOT open another window; that just litters their "+                          "screen with a duplicate sign-in. The existing window is the one to finish. "+                          "%s" % (target_dir,+                                  _wall_hint(wall0["wall"], notified0) if wall0.get("wall")+                                  else "Poll fusion_readiness until ready:true.")),+                "statusVerb": "fusion_readiness"}++    # snapshot the Autodesk windows that already exist, so a STALE expired tab from an earlier+    # attempt is never mistaken for this attempt's wall+    pre_hwnds = _autodesk_window_hwnds()+    _open_args = {"sessionId": sess, "url": auth["url"], "background": True,+                  "profile": target, "thread": "fusion-signin",+                  "purpose": "Fusion Autodesk sign-in (correct profile)"}+    r = _ad_call("nbrowser_open_window", _open_args, timeout=60)+    rd_open = r.get("data", r) if isinstance(r, dict) else {}+    opened = bool(rd_open.get("sessionId") or r.get("ok") or r.get("success")+                  or rd_open.get("opened") or ("opened in the BACKGROUND" in str(r.get("_hint", ""))))+    open_via = "extension" if opened else None+    if not opened:+        # EXTENSION-FREE fallback: nbrowser_open_window needs the Adom extension installed in+        # that profile. nbrowser_open_os_window does not - it opens the URL in the real profile+        # at the OS level, which is all an OAuth consent needs. This keeps the multi-profile fix+        # working on machines with no extension (same gap as APS issue #12).+        r2 = _ad_call("nbrowser_open_os_window", _open_args, timeout=60)+        if isinstance(r2, dict) and not r2.get("_adCallError"):+            # ok:false just means the hwnd was not resolved in ~8s; the launch usually still fired+            opened = bool(r2.get("ok") or r2.get("hwnd") or "Launch fired" in str(r2.get("_hint", "")))+            if opened:+                open_via = "extension_free"+                r = r2+    steps.append((("re-opened it in %s%s" % (tdesc, " (extension-free)" if open_via == "extension_free" else ""))+                  if opened else ("could not open %s (raw: %s)" % (tdesc, str(r)[:160]))))+    if opened:+        # hand it to the janitor so it is never left as litter on the user's desktop+        _track_signin_window(sess, target, kind="fusion")++    # ── FINISH IT: drive the SSO path ourselves (no password, no 2FA, no foreground) ──────────+    # John, 2026-07-22: the goal is a SIGNED-IN Fusion, not a handed-off checklist. When we own+    # the window (nbrowser_open_window => agent-opened => CDP-drivable) and the profile is already+    # signed into the identity provider, this completes the whole login silently.+    sso = None+    if opened and open_via == "extension" and args.get("driveSso", True):+        tabs = _unwrap(_ad_call("nbrowser_list_tabs", {"profile": target}, timeout=45)).get("tabs") or []+        my_tab = None+        for t in tabs:+            if "autodesk" in str(t.get("url", "")).lower() and t.get("owner") == "agent-opened":+                my_tab = t.get("tabId") or t.get("id")+        if my_tab:+            email = None+            for c in choices:+                if c.get("profile") == target:+                    email = c.get("email")+            sso = _drive_sso_signin(target, my_tab, email, args.get("ssoProvider", "google"))+            steps.extend(sso.get("steps") or [])+            if sso.get("done"):+                for _ in range(6):+                    _t2.sleep(5)+                    if _unwrap(_handle_fusion_readiness({}, {})).get("ready"):+                        steps.append("Fusion reports ready:true - SIGNED IN")+                        # ── ONE LOGIN, BOTH SYSTEMS (John, 2026-07-22) ───────────────────────+                        # "why should the user have to login twice to autodesk to sign in to+                        # fusion and setup aps? why aren't those just driven from 1 login?"+                        # Right now the browser profile has a WARM Autodesk SSO session (we just+                        # used it). APS consent rides that session silently, so do it HERE while+                        # it is warm instead of making the user authenticate a second time.+                        aps_state = _aps_quick_state()+                        if aps_state.get("configured") and not aps_state.get("signedIn"):+                            try:+                                import aps as _apsmod+                                _apsmod.start_signin({"profile": target})+                                steps.append("APS not signed in - started its consent on the SAME "+                                             "warm SSO session (no second login for the user)")+                                for _ in range(5):+                                    _t2.sleep(4)+                                    if _aps_quick_state().get("signedIn"):+                                        steps.append("APS signed in too - one login covered both")+                                        break+                            except Exception as e:+                                steps.append("APS auto-setup skipped (%s)" % str(e)[:80])+                        aps_state = _aps_quick_state()+                        return {"success": True, "stage": "signed_in", "done": True, "steps": steps,+                                "signinProfile": target, "signinWhere": tdesc,+                                "signinOpenedVia": open_via,+                                "aps": aps_state,+                                "narrate": ("Fusion is signed in as %s. No password or 2FA was needed - "+                                            "I used the browser profile's existing SSO session.%s"+                                            % (email or target,+                                               " APS cloud search is set up too, from the same login."+                                               if aps_state.get("signedIn") else "")),+                                "_hint": ("DONE - Fusion is signed in (ready:true). Nothing else to do; "+                                          "do not tell the user to sign in. %s Drive Fusion normally now."+                                          % ("APS is signed in as well, so fusion_aps_search is live."+                                             if aps_state.get("signedIn") else+                                             "APS is NOT signed in - run fusion_aps_signin NOW while the "+                                             "browser SSO session is still warm so it consents silently.")),+                                "statusVerb": "fusion_readiness"}++    # Did the reopened sign-in immediately hit a HUMAN WALL (2FA, credentials, protocol dialog)?+    # If so, toast the user AND tell the AI how to clear it for them.+    wall = _detect_signin_wall(ignore_hwnds=pre_hwnds) if opened else {}+    wall_kind = wall.get("wall")+    notified = False+    if wall_kind:+        notified = bool(_notify_signin_wall(wall_kind).get("delivered"))+        steps.append("hit %s wall; toasted the user (delivered=%s)" % (wall_kind, notified))++    return {"success": True, "stage": "opened" if opened else "open_failed",+            "done": False, "steps": steps, "signinWhere": tdesc, "signinProfile": target,+            "signinOpenedVia": open_via,+            "signinWall": wall_kind, "signinWallHwnd": wall.get("hwnd"),+            "notifyDelivered": notified,+            "signinWallHint": _wall_hint(wall_kind, notified) if wall_kind else None,+            "signinProfileReason": reason, "signinSession": sess if opened else None,+            "signinWrongBrowser": "%s / %s" % (auth["browser"], auth["sourceProfileDir"]),+            "signinAuthUrl": auth["url"],+            "narrate": (("Fusion opened its sign-in in the WRONG browser (%s), so I moved the real "+                         "Autodesk sign-in URL into %s - I picked that because %s. Finish it there, "+                         "or I can drive it, and Fusion will pick up the login automatically."+                         % (auth["sourceProfileDir"], tdesc, reason)) if opened else+                        "I captured Fusion's sign-in URL but could not open the target profile."),+            "_hint": (("The REAL Fusion OAuth URL is now open in %s (session 'fusion-signin'). It "+                       "completes back to the running Fusion via the idmgr/callback + autodesk:// "+                       "protocol handoff (request_id match), so the browser profile no longer has to "+                       "be the OS default - THIS is the fix for Fusion signing into the wrong "+                       "account. TELL the user the exact profile (never 'your browser'), say WHY it "+                       "was chosen (signinProfileReason), and offer: they finish it, you foreground "+                       "it, or (with their OK) you drive 'Continue with Google/Apple/Microsoft'. "+                       "NEVER type their password/2FA. Then poll fusion_readiness until ready:true. "+                       "The wrong-browser tab (%s) can be closed. If the wrong ACCOUNT completes "+                       "anyway, sign Fusion out and re-run. Skill: fusion-multiprofile-signin."+                       % (tdesc, auth["sourceProfileDir"])) if opened else+                      "Open failed: %s. Retry, or open auth url yourself with nbrowser_open_window "+                      "{profile:'%s', url:<signinAuthUrl>}." % (r.get("_adCallError"), target)),+            "statusVerb": "fusion_signin"}+++def _orchestrate_demo(args: dict) -> dict:+    """FIRST-TIME-USER DEMO: take a brand-new user from nothing to "wow" in one verb.++    Written because HD's installer ran a "demo" that just opened Fusion and SAT on the+    sign-in page doing nothing (John, 2026-07-20). A demo must actually finish the sign-in,+    set up APS, and SHOW the user real work: an electronics PROJECT -> SCHEMATIC -> 2D BOARD+    -> 3D BOARD, plus a live APS cloud search.++    STAGED + RESUMABLE. Each call advances as far as it safely can and returns:+      stage      - what it just did / is waiting on+      done       - whether the demo finished+      narrate    - a first-time-user-friendly line the CALLER should say + toast+      screenshots- images to show the user for this stage+      _hint      - exactly what the AI must do next (including nbrowser_* for the browser leg)++    The bridge owns Fusion; it does NOT own the browser. So when the sign-in needs a browser+    we hand the caller precise `nbrowser_*` (ABE) instructions - the user's NATIVE browser is+    already signed into Autodesk, whereas pup is anonymous and would force a fresh login.+    """+    stage = (args.get("stage") or "auto").strip()+    demo_query = args.get("query") or ""          # optional: which design to demo+    steps: list = []+    shots: list = []++    def _shot(label):+        try:+            r = _handle_screenshot_fusion({}, {})+            if r.get("success") and r.get("localSafePath"):+                shots.append({"label": label, "path": r.get("localSafePath")})+        except Exception:+            pass++    def _out(stage_name, narrate, hint, done=False, **extra):+        return {"success": True, "demo": True, "stage": stage_name, "done": done,+                "steps": steps, "narrate": narrate, "screenshots": shots,+                "statusVerb": "fusion_demo", "_hint": hint, **extra}++    # ── 1. Fusion present + running ─────────────────────────────────────────────+    rd = _handle_fusion_readiness({}, {})+    if not rd.get("installed"):+        return _out("not_installed",+            "Fusion 360 isn't installed yet - want me to install it? It's a free 30-day trial with "+            "everything switched on, so the whole tour works: real boards, schematics, 3D, Gerber and "+            "BOM exports, cloud search. After the trial, Fusion for Personal Use stays free for "+            "non-commercial work (hobby PCBs up to 2 layers), and even an expired install can still "+            "OPEN and VIEW your designs.",+            "OFFER the install - do not just report it is missing. Say what they GET: the free 30-day "+            "trial is FULL-FEATURED (every step of this demo works: electronics, schematic, 2D/3D "+            "board, Gerbers/BOM/CPL, APS cloud search). Be honest about after: Fusion for Personal Use "+            "is free for non-commercial use but LIMITS electronics (about 2 layers / 2 schematic "+            "sheets / small board area) and some exports; an expired or read-only install can still "+            "OPEN + VIEW + browse designs (it only blocks save/export/modify), so their work is never "+            "locked away. Then call fusion_install_fusion (no shell approval needed; streams the "+            "installer, 10-30 min), poll fusion_readiness until installed:true, and call fusion_demo "+            "again to continue the tour. Full flow: the fusion-onboarding skill.")+    if not rd.get("running"):+        steps.append("launched Fusion")+        _handle_launch({}, {})+        rd = _handle_fusion_readiness({}, {})++    # ── 2. SIGN-IN: finish it, do not sit on it ─────────────────────────────────+    if rd.get("needsSignin"):+        # Delegate to the multi-profile sign-in fix (reads Fusion's OAuth URL out of the WRONG+        # default browser and re-opens it in the RIGHT profile). Returns its own rich stage.+        si = _orchestrate_signin({"profile": args.get("signinProfile")})+        si["demo"] = True+        if si.get("stage") in ("opened", "click_signin", "ask_profile", "open_failed"):+            si.setdefault("done", False)+            si["_hint"] = (si.get("_hint", "") + "  (This is the fusion_demo sign-in stage - after "+                           "the user is signed in and fusion_readiness is ready:true, call fusion_demo "+                           "again to continue the tour: APS -> project -> schematic -> 2D -> 3D.)")+            return si+        # DO IT, don't just describe it: open the Autodesk sign-in in the user's OWN browser,+        # in the BACKGROUND so we never yank them out of what they're doing. Then tell the AI+        # exactly WHERE it is waiting and offer the three ways forward.+        nb = _ad_call("nbrowser_readiness", {}, timeout=40)+        nb_data = nb.get("data", nb) if isinstance(nb, dict) else {}+        nb_state = nb_data.get("state")+        opened, where, profile_used = False, "", ""+        pick_reason, ambiguous = "", False+        choices = []+        if nb_state == "ready":+            profs = _ad_call("nbrowser_profiles", {}, timeout=40)+            pdata = profs.get("data", profs) if isinstance(profs, dict) else {}+            # profiles are DICTS: {profile,label,email,browser,displayName,profileDir,active,live,...}+            for p in (pdata.get("profiles") or []):+                if not isinstance(p, dict):+                    continue+                if p.get("blocked") or p.get("unresolved") or not p.get("profile"):+                    continue+                choices.append({+                    "profile": p.get("profile"), "browser": (p.get("browser") or "").lower(),+                    "email": p.get("email") or "", "displayName": p.get("displayName") or "",+                    "profileDir": p.get("profileDir") or "",+                    "active": bool(p.get("active")), "live": bool(p.get("live")),+                    "asleep": bool(p.get("asleep")),+                    "extensionInstalled": p.get("extensionInstalled", True),+                    "describe": _describe_profile(p),+                })+            # PROBE each profile for a REAL Autodesk session - never guess by "active".+            # (John, 2026-07-20: picking the active profile grabbed his PERSONAL Chrome with+            # zero analysis. A power user's Autodesk login can live in any profile, so ASK THE+            # BROWSER, don't assume.) nbrowser_login_state reports auth cookies per profile.+            for c in choices:+                if not c["live"]:+                    c["autodesk"] = "unprobed(asleep)"+                    continue+                ls = _ad_call("nbrowser_login_state",+                              {"profile": c["profile"], "url": "https://accounts.autodesk.com/"},+                              timeout=45)+                lsd = ls.get("data", ls) if isinstance(ls, dict) else {}+                if lsd.get("ok") is None and lsd.get("loggedIn") is None:+                    c["autodesk"] = "unprobed"+                else:+                    c["autodesk"] = "signed-in" if lsd.get("loggedIn") else "signed-out"+                c["autodeskConfidence"] = lsd.get("confidence")+                c["autodeskCookies"] = lsd.get("cookieCount")+            _CONF = {"high": 3, "medium": 2, "low": 1}+            signed = [c for c in choices if c.get("autodesk") == "signed-in"]+            signed.sort(key=lambda c: (_CONF.get(c.get("autodeskConfidence"), 0),+                                       c.get("autodeskCookies") or 0), reverse=True)+            pick = signed[0] if signed else None+            if pick:+                pick_reason = ("it is the profile actually signed into Autodesk (%s auth cookies, %s "+                               "confidence)" % (pick.get("autodeskCookies"), pick.get("autodeskConfidence")))+            else:+                # NOTHING is signed in - do not silently guess. Prefer a work/corporate identity+                # over a consumer mailbox (Autodesk seats are usually work accounts), but SAY SO+                # and offer the alternatives.+                _CONSUMER = ("gmail.com", "outlook.com", "hotmail.com", "yahoo.com", "icloud.com", "live.com")+                def _work_first(c):+                    em = (c.get("email") or "").lower()+                    return (0 if (em and not any(em.endswith("@" + d) for d in _CONSUMER)) else 1,+                            0 if c["live"] else 1)+                ranked = sorted([c for c in choices if c["live"]] or choices, key=_work_first)+                pick = ranked[0] if ranked else None+                pick_reason = ("no profile has a live Autodesk session, so I picked the most likely "+                               "one (work/corporate identity first) - CONFIRM WITH THE USER")+            ambiguous = not signed or len(signed) > 1+            if pick:+                profile_used = pick["profile"]+                where = pick["describe"]+            oa = {"sessionId": "fusion-signin", "url": "https://accounts.autodesk.com/",+                  "background": True, "thread": "fusion-demo", "purpose": "Autodesk sign-in for Fusion"}+            if profile_used:+                oa["profile"] = profile_used+            r = _ad_call("nbrowser_open_window", oa, timeout=60)+            opened = bool((r.get("data", r) if isinstance(r, dict) else {}).get("sessionId") or r.get("ok") or r.get("success"))+            if not where:+                where = "your browser"+        if opened:+            steps.append("opened Autodesk sign-in in %s (background)" % (where or "browser"))+        else:+            steps.append("could not auto-open the browser sign-in: %s" % (nb.get("_adCallError") or nb_state or "unknown"))+        if opened:+            narrate = ("I've opened the Autodesk sign-in in %s - I chose that profile because %s. "+                       "It's sitting in the background so it won't interrupt what you're doing. "+                       "Finish it whenever you like, tell me to bring it up front, or I can complete "+                       "it for you. If that's the wrong account, say which profile to use."+                       % (where, pick_reason))+        else:+            narrate = ("Fusion needs your Autodesk sign-in. I'd like to use your own browser - you're "+                       "probably already signed in there, so it should take one click.")+        return _out("signin", narrate,+            ("FINISH THE SIGN-IN - never leave a first-time user parked here. " ++             ("A background sign-in tab is ALREADY OPEN in %s (session 'fusion-signin'). " % where+              if opened else "Auto-open failed - open it yourself: nbrowser_open_window "+              "{sessionId:'fusion-signin', url:'https://accounts.autodesk.com/', background:true, "+              "profile:'<chrome:their-account>'}. ") ++             "The profile was CHOSEN BY EVIDENCE, not guessed: each live profile was probed with "+             "nbrowser_login_state against accounts.autodesk.com, and the one holding a real Autodesk "+             "session wins (see signinProfileReason + per-profile autodesk/autodeskConfidence/"+             "autodeskCookies in signinProfileChoices). RELAY THAT REASON to the user. If "+             "signinProfileAmbiguous is true (nothing signed in, or several are), ASK them which "+             "account their Autodesk login belongs to instead of assuming. "+             "NAME THE EXACT PROFILE - never say just 'your browser' or 'your Chrome'. Power users "+             "run several profiles (personal / work / media), so say the browser AND the identity "+             "from `signinWhere` (e.g. 'Chrome - John Personal ([email protected])'). "+             "`signinProfileChoices` lists every profile with describe/email/displayName/active - if "+             "there is more than one, say which you used and OFFER TO SWITCH (re-run with a different "+             "profile). If the one you want shows extensionInstalled:false it needs the one-time "+             "extension install in THAT profile. "+             "TELL THE USER WHERE IT IS WAITING and OFFER ALL THREE: (a) they finish it themselves "+             "whenever they want, (b) you foreground that window for them "+             "(nbrowser_switch_window / browser window state), or (c) THEY LET YOU DRIVE IT - ask "+             "first (AskUserQuestion), then click through 'Continue with Google/Apple/Microsoft' "+             "using their warm session. Use the NATIVE browser (ABE), NEVER pup: pup is anonymous so "+             "Autodesk demands a full fresh login, while their real profile is usually already signed "+             "in. NEVER type their password or 2FA - if a secret is demanded, fusion_notify_owner "+             "toasts them to type it. Then click Fusion's own 'Sign In' (the webview exposes no UIA "+             "control, so use an image-space desktop_click on the button, or foreground + click) to "+             "fire the OAuth handoff; accept the 'Autodesk Identity Manager' overlay ('Always allow' "+             "+ 'Open'). Codes expire in ~2 min - if it expires, click Sign In again. Poll "+             "fusion_readiness until ready:true, then call fusion_demo again. Playbook: "+             "fusion-autodesk-signin."),+            needsSignin=True, signinOpened=opened, signinWhere=where,+            signinProfile=profile_used, signinSession="fusion-signin" if opened else None,+            signinProfileChoices=choices, signinProfileReason=pick_reason,+            signinProfileAmbiguous=ambiguous, nbState=nb_state)++    if not rd.get("ready"):+        return _out("waiting",+            "Fusion is starting up - one moment.",+            "Not ready yet (add-in still loading, or a licensing dialog). Poll fusion_readiness; it "+            "auto-resolves seat dialogs. When ready:true, call fusion_demo again. Reason: " ++            str(rd.get("_hint", ""))[:220])+    steps.append("Fusion ready")++    # ── 3. APS: set it up if we can, otherwise SELL it (never skip silently) ─────+    aps_ready = False+    try:+        ap = aps.handle_status({}).get("data", {})+        aps_ready = bool(ap.get("configured") and ap.get("signedIn") and ap.get("tokenLive"))+    except Exception:+        ap = {}+    if not aps_ready and stage != "skip_aps":+        return _out("aps_setup",+            "Next I'll turn on cloud search - it finds any design in your Autodesk cloud in about "+            "two seconds instead of a 30-minute folder crawl. It's a one-time sign-in.",+            "SET APS UP FOR THEM - do not merely mention it. (1) fusion_aps_status for exact state. "+            "(2) If not configured, an admin registers a PKCE app once, then fusion_aps_set_client_id. "+            "(3) fusion_aps_signin - drive it in their NATIVE browser (ABE), same reasoning as the "+            "Fusion sign-in: they're already logged into Autodesk there. (4) Poll fusion_aps_status "+            "until tokenLive:true, then call fusion_demo again - the demo then runs a LIVE sample "+            "search so they SEE the speed. If they decline setup, call fusion_demo {stage:'skip_aps'} "+            "and TELL them what they're missing (2s server-indexed search across the whole team hub; "+            "the old in-app search took 30+ min and crashed Fusion, so it is disabled). "+            "Skills: fusion-aps-search, fusion-aps-signin.",+            apsConfigured=bool(ap.get("configured")), apsSignedIn=bool(ap.get("signedIn")))++    # ── 4. APS sample search - let them SEE the speed ───────────────────────────+    aps_hits = []+    if aps_ready:+        try:+            import time as _t+            t0 = _t.time()+            sr = aps.handle_search({"query": demo_query or "board", "limit": 5}).get("data", {})+            aps_hits = [{"name": r.get("name"), "project": r.get("projectName")}+                        for r in (sr.get("results") or [])[:5]]+            steps.append("APS sample search: %d hits in %.1fs" % (len(aps_hits), _t.time() - t0))+        except Exception as e:+            steps.append("APS sample search failed: %s" % e)++    # ── 5. Open an ELECTRONICS design, then walk schematic -> 2D -> 3D ──────────+    st = _proxy_to_addin("get_app_state", {}, timeout=20) or {}+    doc = (st.get("data") or st).get("activeDocument")+    is_elec = bool((st.get("data") or st).get("isElectronics"))+    if not is_elec:+        target = demo_query or (aps_hits[0]["name"] if aps_hits else "")+        if not target:+            return _out("need_design",+                "I need an electronics design to show off. Which board should I open?",+                "No electronics design open and nothing to pick. If APS is live, "+                "fusion_aps_search {query:'<board>'} then fusion_aps_open {query:'<name>'}; else ask "+                "the user for a design name / open one via fusion_open_cloud_file. ALWAYS open the "+                "PROJECT (EcadDesignProductType), never a .brd/.sch/3D child. Then call fusion_demo again.")+        return _out("open_design",+            "Opening %s so you can see a real board end to end." % target,+            "OPEN THE PROJECT then re-call fusion_demo: fusion_aps_open {query:'%s'} (APS finds it at "+            "any folder depth in seconds). CRITICAL: open the electronics PROJECT file, not the "+            "schematic/.brd/3D child - a child opens an isolated, often empty view. Poll "+            "fusion_get_app_state until isElectronics:true, then call fusion_demo again." % target,+            target=target)++    steps.append("electronics project open: %s" % doc)+    _shot("project")++    # schematic -> 2D board -> 3D board, screenshotting each so the AI can SHOW them+    try:+        _proxy_to_addin("show_schematic", {}, timeout=90); steps.append("showed schematic"); _shot("schematic")+    except Exception as e:+        steps.append("schematic failed: %s" % e)+    try:+        _proxy_to_addin("show_2d_board", {}, timeout=90); steps.append("showed 2D board"); _shot("board_2d")+    except Exception as e:+        steps.append("2D board failed: %s" % e)+    try:+        _proxy_to_addin("show_3d_board", {}, timeout=180); steps.append("showed 3D board"); _shot("board_3d")+    except Exception as e:+        steps.append("3D board failed: %s" % e)++    aps_line = ("Cloud search is live - I searched your whole Autodesk hub in about two seconds and "+                "found: %s. " % ", ".join(h["name"] for h in aps_hits[:3])) if aps_hits else ""+    return _out("done",+        "That's the tour: your %s project, its schematic, the 2D board layout, and the real 3D board. "+        "%sFrom here just ask - export Gerbers/BOM/CPL for the fab, generate a laser-etched IPC "+        "package, load JLCPCB design rules, or open any design in your cloud." % (doc, aps_line),+        "DEMO COMPLETE. SHOW the user the screenshots in order (project, schematic, board_2d, "+        "board_3d) - they are in `screenshots` with labels. Narrate the `narrate` line. Then offer "+        "concrete next steps in THEIR words: 'export the Gerbers', 'make me a SOIC-8 with my part "+        "number etched on it' (fusion_generate_package), 'check this against JLCPCB rules' "+        "(fusion_load_design_rules), 'find my other boards' (fusion_aps_search). Full catalog: "+        "fusion_describe. Demo playbook: the fusion-demo skill.",+        done=True, document=doc, apsSampleSearch=aps_hits)+++def _orchestrate_board_stackup(args: dict) -> dict:+    """Read a board's PHYSICAL fabrication stackup (see the pcb-stackup skill). A PCB is a+    stack of copper + dielectric: Cu(L1) / prepreg / Cu(L2) / core / ... / Cu(bottom). This+    reads the copper count + copper/dielectric thicknesses from the EAGLE design rules+    (layerSetup / mtCopper / mtIsolate in the .brd) and the measured FR4 extent from the 3D+    body, and returns the ordered layer list (name, thickness, z) so a caller can build the+    stackup table + the real-thickness exploded 3D view."""+    import re as _re+    import tempfile as _tf+    # 1) design rules from the EAGLE .brd (board editor active)+    _proxy_to_addin("show_2d_board", {}, timeout=60)+    brd = os.path.join(_tf.gettempdir(), "adom_stackup.brd")+    r = _proxy_to_addin("export_eagle_source", {"outputPath": brd}, timeout=120)+    if not r.get("success"):+        return {"success": False, "error": "could not export .brd for stackup: %s" % (r.get("error") or r.get("message")),+                "_hint": "Open the PROJECT and switch to the board (fusion_show_2d_board) first."}+    try:+        txt = open(brd, encoding="latin1", errors="replace").read()+    except Exception as e:+        return {"success": False, "error": "could not read .brd: %s" % e}+    def _p(name):+        m = _re.search(r'<param name="%s" value="([^"]*)"' % name, txt)+        return m.group(1) if m else None+    layer_setup = _p("layerSetup") or ""+    mt_copper = [x for x in (_p("mtCopper") or "").split()]+    mt_isolate = [x for x in (_p("mtIsolate") or "").split()]+    copper_layers = _re.findall(r"\d+", layer_setup)   # e.g. ['1','2','15','16']+    ncu = len(copper_layers)+    # bonds between copper layers, in order: '+' prepreg, '*' core+    bonds = [("core" if c == "*" else "prepreg") for c in layer_setup if c in "+*"]+    # 2) measure the FR4 Board body (3D)+    _proxy_to_addin("show_3d_board", {}, timeout=60)+    # Find the LARGEST-XY body named 'board' (the FR4 substrate) - there can be several+    # 'board'-named bodies (small ones), so pick the substrate by area, not the first.+    mscript = (+        "import adsk.core, adsk.fusion\n"+        "app=adsk.core.Application.get(); des=adsk.fusion.Design.cast(app.activeProduct)\n"+        "best=None; ba=-1.0\n"+        "for occ in des.rootComponent.allOccurrences:\n"+        " for b in occ.component.bRepBodies:\n"+        "  if b.name.lower()=='board':\n"+        "   bb=b.boundingBox; mn=bb.minPoint; mx=bb.maxPoint\n"+        "   a=(mx.x-mn.x)*(mx.y-mn.y)\n"+        "   if a>ba: ba=a; best=((mx.x-mn.x)*10,(mx.y-mn.y)*10,(mx.z-mn.z)*10)\n"+        "print('FR4 %.4f %.4f %.4f'%best if best else 'FR4 none')")+    mm = _proxy_to_addin("run_modeling_script", {"script": mscript}, timeout=60)+    fr4 = None+    try:+        msg = ""+        if isinstance(mm, dict):+            o = mm.get("output")+            msg = (json.loads(o).get("message") if isinstance(o, str) and o.startswith("{") else (mm.get("message") or o)) or ""+        m = _re.search(r"FR4\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)", str(msg))+        if m:+            fr4 = {"x_mm": float(m.group(1)), "y_mm": float(m.group(2)), "dielectric_mm": float(m.group(3))}+    except Exception:+        pass+    def _num(s):+        try: return float(str(s).replace("mm", ""))+        except Exception: return None+    return {+        "success": True,+        "layerSetup": layer_setup,+        "copperLayers": copper_layers,+        "copperCount": ncu,+        "copperThickness_mm": [_num(t) for t in mt_copper[:ncu]] if mt_copper else None,+        "dielectricBonds": bonds,                 # e.g. ['prepreg','core','prepreg']+        "dielectricThickness_raw": mt_isolate,    # design-rule list (may hold 2-layer defaults)+        "fr4": fr4,+        "brdPath": brd,+        "_hint": ("Physical stack (see pcb-stackup skill): the FR4 is NOT one slab - it is "+                  "prepreg/core/prepreg with copper between. Build the table + exploded view per that skill. "+                  "If mtIsolate looks like 2-layer defaults, fit a symmetric split to fr4.dielectric_mm."),+    }+++def _assert_active_document(expect: str) -> dict | None:+    """Document guard (issue #289): return a `wrong_document` error dict if Fusion's active document+    is not `expect`, else None. A Fusion tab-switch silently retargets `app.activeDocument`, so a+    mutating verb can hit the wrong file and destroy work. Callers pass `expectDocument` to assert+    intent BEFORE any write. Fails OPEN (returns None) when the active doc cannot be read, so the+    guard never wedges a legitimate call - it only blocks a CONFIRMED mismatch."""+    actual = None+    try:+        st = _proxy_to_addin("get_app_state", {}, timeout=8)+        data = st.get("data") if isinstance(st, dict) else None+        if not isinstance(data, dict):+            out = st.get("output") if isinstance(st, dict) else None+            if isinstance(out, str) and out.strip().startswith("{"):+                data = json.loads(out).get("data", {})+        actual = (data or {}).get("activeDocument") or (st.get("activeDocument") if isinstance(st, dict) else None)+    except Exception:+        actual = None+    if actual and actual != expect:+        return {+            "success": False,+            "errorCode": "wrong_document",+            "expected": expect,+            "actual": actual,+            "error": "Active document is '%s', expected '%s'." % (actual, expect),+            "_hint": ("The active document changed - a Fusion tab-switch retargets the active "+                      "document. Re-activate the intended document, or re-issue with expectDocument "+                      "set to the current name (a rename mid-session also changes it)."),+        }+    return None+++def dispatch_command(command: str, args: dict, caller_identity: dict = None) -> dict:+    """Dispatch a command to the appropriate handler.++    caller_identity: dict with 'thread', 'container', 'reason' keys (from X-Adom-Caller-* headers).+    Passed through to AD calls for attribution (issues #342/#348).+    """+    # Strip the "fusion_" prefix if present — handlers are registered without it (e.g. "aps_signin"+    # not "fusion_aps_signin"), but the relay passes them with the prefix for CLI clarity. Issue #327.+    if command.startswith("fusion_"):+        command = command[7:]++    # Store caller identity in a thread-local or pass it to handlers that need it.+    # For now, store it globally so _ad_call can access it.+    global _caller_identity_context+    _caller_identity_context = caller_identity or {}++    # Document guard (issue #289): a mutating verb — or a guarded READ verb (a wrong BOM /+    # geometry read is easy to act on) — carrying `expectDocument` must not run against a+    # document the caller didn't intend. Assert the active doc BEFORE dispatch, then strip the+    # key so handlers never see it.+    if isinstance(args, dict) and args.get("expectDocument"):+        if command in MUTATING_COMMANDS or command in GUARDED_READ_COMMANDS:+            _guard = _assert_active_document(str(args["expectDocument"]))+            if _guard is not None:+                return _guard+        args = {k: v for k, v in args.items() if k != "expectDocument"}+    # Direct handlers (don't need the add-in)+    handler = COMMAND_HANDLERS.get(command)+    if handler is not None:+        return handler(fusion_info, args)++    # Check if Fusion is installed and running before any add-in-dependent command.+    # We do NOT auto-launch — that causes 60s+ hangs when Fusion isn't running.+    # The user should launch Fusion themselves; we just report the status.+    # Commands that need Fusion running (add-in commands + orchestrated commands)+    _OPEN_WITH_SCREENSHOT = {"open_schematic", "open_board", "show_3d_board", "show_2d_board", "show_schematic", "import_electronics"}+    if command in ADDIN_COMMANDS or command in ("open_lbr", "save_lbr", "attach_3d_package", "make_3d_package", "build_library_3d", "capture_library_views", "cleanup_cloud_files", "generate_package", "export_optimized_glb", "board_stackup") or command in _OPEN_WITH_SCREENSHOT:+        # Issue #326: Fusion may be installed under a different Windows user profile than the current+        # one, so file-path detection fails even though Fusion is running. Check if it's actually+        # running (via tasklist) as a reliable fallback. If running, it's installed somewhere.+        fusion_running = _is_fusion_running()+        if not fusion_info.get("installed") and not fusion_running:+            return {+                "success": False,+                "error": "Fusion 360 is not installed on this machine.",+                "errorCode": "fusion_not_installed",+                "_hint": "Fusion 360 isn't installed. Do NOT tell the user to install it themselves - OFFER to install it FOR them and do it on a yes: the fusion-onboarding skill silent-installs Fusion + drives the Autodesk sign-in.",+            }+        if not fusion_running:+            return {+                "success": False,+                "error": "Fusion 360 is installed but not running.",+                "errorCode": "fusion_not_running",+                "_hint": "Call fusion_start to start Fusion 360, then wait for the add-in to become ready (fusion_start blocks until ready). Then retry this command.",+            }+        # Fusion is running — check if add-in is responsive (short timeout).+        # But first, if the add-in is busy with a long command, skip the+        # responsiveness check and fall through to the busy gate below.+        addin_busy = _check_addin_status(timeout=3.0)+        if addin_busy and addin_busy.get("busy"):+            pass  # Fall through to busy gate+        elif not wait_for_addin(timeout=5):+            return {+                "success": False,+                "error": "Fusion 360 is running but AdomBridge add-in not responding.",+                "errorCode": "fusion_addin_not_responding",+                "_hint": "The add-in isn't responding. Fix it YOURSELF - never ask the user: the bridge auto-installs the add-in to ALL Fusion add-in dirs (2025+ Fusion scans %APPDATA%/Autodesk/FusionAddins; the legacy API/AddIns dirs are silently ignored - issue #63), so restart Fusion via fusion_stop + fusion_start and it loads (runOnStartup). Verify with fusion_addin_status.",+            }++    # ── Busy gate: reject add-in commands immediately if a long command is running ──+    # Bridge-level commands (COMMAND_HANDLERS) already returned above — they use+    # Win32 APIs and don't touch the add-in, so they always work during a walk.+    # But add-in commands (and orchestrated commands that proxy to the add-in)+    # would pile up behind _main_thread_lock for 300s and crash the host.+    busy = _get_long_command()+    if busy and command not in LONG_RUNNING_COMMANDS:+        # This command would block behind the long-running one — reject immediately+        progress = _get_busy_progress()+        elapsed = round(_time.time() - busy["startedAt"], 1)+        progress_pct = None+        if progress and progress.get("foldersVisited") and progress.get("queueSize") is not None:+            total = progress["foldersVisited"] + progress["queueSize"]+            if total > 0:+                progress_pct = round(100 * progress["foldersVisited"] / total)+        return {+            "success": False,+            "error": (+                f"Fusion main thread busy — {busy['command']} has been running for {elapsed}s. "+                f"Your command '{command}' cannot execute until it finishes."+            ),+            "errorCode": "main_thread_busy",+            "busyCommand": busy["command"],+            "elapsedSeconds": elapsed,+            "progress": progress,+            "_hint": (+                f"A cloud search ({busy['command']}) is in progress"+                + (f" (~{progress_pct}% done)" if progress_pct is not None else "")+                + f", running for {elapsed}s. "+                "Do NOT retry add-in commands (get_app_state, document_info, etc.) — they will "+                "all be rejected until the search finishes. Commands that still work right now: "+                "fusion_window_info, fusion_screenshot_fusion, fusion_click_fusion, "+                "fusion_send_key, fusion_close_window. Wait for the search to complete, then retry."+            ),+        }++    # Cross-bridge case: this bridge's _long_command is None but the add-in+    # might be busy from another bridge/session. Quick non-blocking check.+    if not busy and command not in LONG_RUNNING_COMMANDS:+        addin_status = _check_addin_status(timeout=3.0)+        if addin_status and addin_status.get("busy"):+            elapsed = addin_status.get("elapsedSeconds", 0)+            walk = addin_status.get("walkProgress")+            busy_cmd = addin_status.get("busyCommand", "unknown")+            resp = {+                "success": False,+                "error": (+                    f"Fusion main thread busy — {busy_cmd} running for {elapsed}s "+                    f"(from another bridge/session). Your command '{command}' cannot "+                    f"execute until it finishes."+                ),+                "errorCode": "main_thread_busy",+                "busyCommand": busy_cmd,+                "elapsedSeconds": elapsed,+                "_hint": (+                    f"A long-running command ({busy_cmd}) is in progress from another session. "+                    "Do NOT retry add-in commands — they will all be rejected until it finishes. "+                    "Do NOT press Escape — the add-in is working, not stuck on a dialog. "+                    "Commands that still work: fusion_window_info, fusion_screenshot_fusion, "+                    "fusion_click_fusion, fusion_send_key, fusion_close_window."+                ),+            }+            if walk:+                resp["progress"] = walk+            return resp++    # Orchestrated multi-step commands (handled at bridge level)+    if command in _OPEN_WITH_SCREENSHOT:+        return _handle_open_with_screenshot(fusion_info, args, command)+    if command == "open_lbr":+        return _orchestrate_open_lbr(args)+    if command == "save_lbr":+        return _merge_dialog_array(_orchestrate_save_lbr(args))+    if command == "attach_3d_package":+        return _merge_dialog_array(_orchestrate_attach_3d_package(args))+    if command == "make_3d_package":+        return _apply_failure_dialogs(_orchestrate_make_3d_package(args))+    if command == "build_library_3d":+        return _apply_failure_dialogs(_orchestrate_build_library_3d(args))+    if command == "capture_library_views":+        return _apply_failure_dialogs(_orchestrate_capture_library_views(args))+    if command == "cleanup_cloud_files":+        return _orchestrate_cleanup_cloud_files(args)+    if command == "generate_package":+        return _apply_failure_dialogs(_orchestrate_generate_package(args))+    if command == "export_optimized_glb":+        return _orchestrate_export_optimized_glb(args)+    if command == "fetch_optimized_glb":+        return _orchestrate_fetch_optimized_glb(args)+    if command == "demo":+        return _orchestrate_demo(args)+    if command == "signin":+        return _orchestrate_signin(args)+    if command == "signin_2fa":+        return _orchestrate_signin_2fa(args)+    if command == "board_stackup":+        return _orchestrate_board_stackup(args)++    # Add-in proxy commands+    if command in ADDIN_COMMANDS:+        addin_cmd = ADDIN_COMMAND_MAP.get(command, command)+        proxy_timeout = ADDIN_COMMAND_TIMEOUTS.get(command, 30)+        # Wrap long-running commands in set/clear so the gate knows they're active.+        # Pre-dismiss blocking dialogs: modal dialogs steal Fusion's event loop,+        # preventing fireCustomEvent from being processed. Without this, the+        # walk appears "busy" but never actually starts — _walk_progress stays+        # None indefinitely while the command sits in the event queue.+        if command in LONG_RUNNING_COMMANDS:+            try:+                info = get_fusion_window_info()+                if info.get("dialogs"):+                    for d in info["dialogs"]:+                        try:+                            # Use WM_CLOSE via PostMessage — doesn't steal foreground+                            # (unlike send_key which uses SendInput + SetForegroundWindow).+                            # WM_CLOSE is also more reliable than Escape for Qt dialogs.+                            close_window(d.get("hwnd"))+                        except Exception:+                            pass+                    import time as _time+                    _time.sleep(0.5)  # give Fusion a moment to process the dismiss+            except Exception:+                pass+            _set_long_command(command)+            try:+                return _proxy_to_addin(addin_cmd, args, timeout=proxy_timeout)+            finally:+                _clear_long_command()+        result = _proxy_to_addin(addin_cmd, args, timeout=proxy_timeout)+        # After a state-changing op, surface any dialog/owned-popup the AI must analyze+        # (the Hub upload-close confirm, a save prompt, recovery, etc.) so it can't fly+        # blind. Read-only verbs are excluded to avoid per-call screenshot latency.+        if command in MUTATING_COMMANDS:+            result = _merge_dialog_array(result)+        return result++    # Unknown command — check installation/running status for helpful errors+    if not fusion_info.get("installed"):+        return {+            "success": False,+            "error": f"Fusion 360 is not installed on this machine. (command: {command})",+            "errorCode": "fusion_not_installed",+            "_hint": "Fusion 360 isn't installed. Do NOT tell the user to install it themselves - OFFER to install it FOR them and do it on a yes: the fusion-onboarding skill silent-installs Fusion + drives the Autodesk sign-in.",+        }+    if not _is_fusion_running():+        return {+            "success": False,+            "error": f"Fusion 360 is installed but not running. (command: {command})",+            "errorCode": "fusion_not_running",+            "_hint": "Call fusion_start to start Fusion 360 and wait for the add-in to be ready, then retry this command.",+        }+    return {+        "success": False,+        "error": f"Unknown command: {command}",+        "_hint": "Run `adom-desktop help` or check cli/src/commands.rs to see the list of available fusion_* commands. This command name may be misspelled or not yet implemented.",+    }+++def _build_status() -> dict:+    """Build the /status payload — the endpoint AD declares as healthEndpoint.++    MUST return HTTP 2xx whenever the server is up: AD's health check polls this+    path and a 404 (the classic manifest-vs-server mismatch) makes AD wait the+    full startup grace then report a generic "not reachable". We also self-report+    the GUI chip fields {led, summary, tooltip} — AD renders them verbatim (the+    bridge owns its color; AD owns only the unreachable→gray state).+    """+    import platform++    info = fusion_info or {}+    installed = bool(info.get("installed"))+    running = _is_fusion_running() if installed else False+    addin = _probe_addin() if running else None+    addin_ok = bool(addin)++    if platform.system() != "Windows":+        led, summary = "yellow", "Unsupported OS"+        tooltip = ("Bridge running, but this host is not Windows. Fusion 360 verbs "+                   "need a Windows host with Fusion 360 installed.")+    elif not installed:+        led, summary = "yellow", "Fusion not installed"+        tooltip = ("Bridge running, but Fusion 360 is not installed. The AI can install it "+                   "for the user (fusion-onboarding), then fusion_start.")+    elif not running:+        led, summary = "yellow", "Fusion not running"+        tooltip = "Fusion 360 is installed but not running. Call fusion_start to launch it."+    elif not addin_ok:+        led, summary = "yellow", "Add-in not connected"+        tooltip = ("Fusion 360 is running but the AdomBridge add-in isn't responding yet "+                   "(still loading; if it persists the AI restarts Fusion via fusion_stop/start).")+    else:+        led, summary = "green", "Fusion ready"+        tooltip = "Fusion 360 running, AdomBridge add-in connected. All fusion_* verbs available."++    return {+        "status": "ok",+        "led": led,+        "summary": summary,+        "tooltip": tooltip,+        "bridgeVersion": BRIDGE_VERSION,+        "fusion": {**info, "running": running},+        "addin": addin,+    }+++class FusionBridgeHandler(BaseHTTPRequestHandler):+    """HTTP request handler for the Fusion 360 bridge server."""++    def do_GET(self):+        # /status is the manifest's healthEndpoint; /health is kept as a+        # back-compat alias (older callers + the add-in-probe code path). Both+        # return the same 2xx payload — new chip fields are purely additive.+        if self.path in ("/status", "/health"):+            self._respond(200, _build_status())+        else:+            self._respond(404, {"error": "Not found"})++    def do_POST(self):+        if self.path != "/command":+            self._respond(404, {"error": "Not found"})+            return++        content_length = int(self.headers.get("Content-Length", 0))+        body = self.rfile.read(content_length)++        try:+            request = json.loads(body)+        except json.JSONDecodeError as e:+            self._respond(400, {"success": False, "error": f"Invalid JSON: {e}"})+            return++        command = request.get("command", "")+        args = request.get("args", {})++        # Extract caller identity from AD headers (issues #342/#348). Pass through to dispatch_command+        # so AD calls we make back can be attributed to the original AI thread.+        caller_identity = {+            "thread": self.headers.get("X-Adom-Caller-Thread", ""),+            "container": self.headers.get("X-Adom-Caller-Container", ""),+            "reason": self.headers.get("X-Adom-Caller-Reason", ""),+        }++        print(f"[Fusion Bridge] Command: {command} | Args: {json.dumps(args)}")++        try:+            result = dispatch_command(command, args, caller_identity=caller_identity)+            self._respond(200, result)+        except Exception as e:+            print(f"[Fusion Bridge] ERROR: {e}")+            traceback.print_exc()+            self._respond(500, {+                "success": False,+                "error": f"Internal error: {e}",+            })++    def _respond(self, status: int, data: dict):+        self.send_response(status)+        self.send_header("Content-Type", "application/json")+        self.end_headers()+        self.wfile.write(json.dumps(data).encode("utf-8"))++    def log_message(self, format, *args):+        print(f"[Fusion Bridge] {args[0]} {args[1]} {args[2]}")+++def _prune_bridge_logs(max_mb: int = 8, keep_tail_mb: int = 2) -> dict:+    """LOG JANITOR (John, 2026-07-22: "do you have a janitor to clean up your log?").++    AD captures this bridge's stdout/stderr into ~/.adom/bridge-logs/fusion360.log. Nothing+    rotated it, so it grew unbounded - fine today (it is small), a slow leak on a long-lived box.+    On every bridge start we truncate our OWN logs to the most recent keep_tail_mb once they pass+    max_mb, keeping the tail because that is where a crash traceback lives.++    Only touches files this bridge owns (fusion360*). Never raises.+    """+    import glob+    out = {"checked": 0, "pruned": []}+    try:+        d = os.path.join(os.path.expanduser("~"), ".adom", "bridge-logs")+        for path in glob.glob(os.path.join(d, "fusion360*.log")):+            out["checked"] += 1+            try:+                sz = os.path.getsize(path)+                if sz <= max_mb * 1024 * 1024:+                    continue+                with open(path, "rb") as f:+                    f.seek(-keep_tail_mb * 1024 * 1024, os.SEEK_END)+                    tail = f.read()+                with open(path, "wb") as f:+                    f.write(b"[adom-bridge log janitor] truncated %d MB -> tail %d MB\n"+                            % (sz // (1024 * 1024), keep_tail_mb))+                    f.write(tail)+                out["pruned"].append({"file": os.path.basename(path), "wasMB": sz // (1024 * 1024)})+            except Exception:+                continue+        if out["pruned"]:+            print("[Fusion Bridge] log janitor: %s" % out["pruned"])+    except Exception:+        pass+    return out+++def main():+    global fusion_info++    # Line-buffer stdout/stderr. AD spawns the bridge console-less and captures+    # our stdout+stderr into ~/.adom/bridge-logs/fusion360.log. Python BLOCK-buffers+    # stdout when it's not a TTY, so without this the buffer never flushes while+    # serve_forever() runs → the log stays 0 bytes (and a spawn-crash traceback+    # would be lost). Line buffering flushes every print()/traceback on newline.+    try:+        sys.stdout.reconfigure(line_buffering=True)+        sys.stderr.reconfigure(line_buffering=True)+    except Exception:+        pass++    _prune_bridge_logs()+    _start_signin_janitor()++    port = DEFAULT_PORT+    if "--port" in sys.argv:+        idx = sys.argv.index("--port")+        if idx + 1 < len(sys.argv):+            port = int(sys.argv[idx + 1])++    # Early banner BEFORE detection — so the log proves we started even if+    # detect_fusion() is slow or wedges. (KiCad bridge prints an equivalent line.)+    print(f"[Fusion Bridge] starting - version {BRIDGE_VERSION}, port {port}, "+          f"healthEndpoint /status, pid {os.getpid()}", flush=True)++    fusion_info = detect_fusion()++    print(f"[Fusion Bridge] Fusion 360 detection result:")+    print(f"  Installed: {fusion_info.get('installed', False)}")+    if fusion_info.get("installed"):+        print(f"  Exe path:  {fusion_info.get('exe_path')}")+        print(f"  AddIns:    {fusion_info.get('addins_dir')}")+        print(f"  Add-in:    {'installed' if fusion_info.get('addin_installed') else 'not installed'}")+        print(f"  Running:   {fusion_info.get('running')}")++        # ALWAYS sync the add-in on startup - it is idempotent (_sync_directory copies+        # only CHANGED files). The old `if not addin_installed` guard meant a STALE+        # add-in was never UPDATED: an add-in fix (e.g. get_parameters, caught live+        # 2026-07-06) never reached users who already had ANY copy, because the bridge+        # only deployed when it was entirely MISSING. Always-sync fixes that. It is safe+        # while Fusion is up (locked add-in files simply skip via the per-target OSError+        # catch); the update lands on the next bridge respawn with Fusion closed+        # (fusion_stop -> bridge_install -> fusion_start).+        print(f"[Fusion Bridge] Syncing AdomBridge add-in (idempotent; updates a stale copy)...")+        try:+            install_addin()+            fusion_info = detect_fusion()  # Re-detect after sync+            print(f"  Add-in:    {'installed' if fusion_info.get('addin_installed') else 'FAILED'}")+        except Exception as e:+            print(f"  Add-in sync failed: {e}")+    else:+        print(f"  WARNING: Fusion 360 not found. Some commands will fail.")++    addin_health = _probe_addin()+    if addin_health:+        print(f"  Add-in server: running on port {ADDIN_PORT}")+    else:+        print(f"  Add-in server: not running (port {ADDIN_PORT})")++    all_commands = list(COMMAND_HANDLERS.keys()) + sorted(ADDIN_COMMANDS)+    print(f"[Fusion Bridge] Available commands: {', '.join(all_commands)}")++    # AD (>=1.9.63) passes ADOM_BIND_HOST (always 127.0.0.1) to every bridge it+    # spawns. Honor it and NEVER bind 0.0.0.0/'' by default - a public bind pops a+    # Windows Firewall "allow access?" dialog, and AD's guarantee to users is no+    # firewall prompts. Default to loopback if the var is absent (e.g. local dev).+    bind_host = os.environ.get("ADOM_BIND_HOST", "127.0.0.1")+    server = ThreadingHTTPServer((bind_host, port), FusionBridgeHandler)+    server.daemon_threads = True+    print(f"[Fusion Bridge] Listening on http://{bind_host}:{port}")+    print(f"[Fusion Bridge] Health check: http://{bind_host}:{port}/status (alias /health)")+    print(f"[Fusion Bridge] Press Ctrl+C to stop.")++    try:+        server.serve_forever()+    except KeyboardInterrupt:+        print("\n[Fusion Bridge] Shutting down.")+        server.server_close()+++if __name__ == "__main__":+    # Surface any fatal startup error to stderr (which AD captures into+    # ~/.adom/bridge-logs/fusion360.log) so a spawn-crash is debuggable, then+    # re-raise for a non-zero exit.+    try:+        main()+    except Exception:+        print("[Fusion Bridge] FATAL: bridge failed to start", flush=True)+        traceback.print_exc()+        sys.stderr.flush()+        raise+--- a/handlers/dialog_classify.py+++ b/handlers/dialog_classify.py@@ -1,276 +1,328 @@-"""Classify Fusion 360 blocking dialogs by title, with resolution hints.--Why this exists: several Fusion modals (the multi-linked "Select Electronics-Design File" picker, the "Fusion needs to update" nag, Document Recovery, "Save-changes?", "What do you want to design?") block Fusion's main thread. When that-happens during an add-in command, the add-in's HTTP server can't respond and the-bridge reports a misleading "add-in not responding / may have crashed" — which-sends callers into a pointless restart loop.--Qt dialog *titles* are enumerable over Win32 even while the add-in is blocked-(unlike the dialogs' CEF/Qt child controls, which are not). So we can cheaply-identify WHICH modal is up and return an actionable resolution instead of a fake-crash. This is bridge-wide: it helps every command that can be blocked by a-modal, not just exports.-"""--from handlers.fusion_ui import get_fusion_window_info---# (category, resolution-hint) keyed by lowercase title substrings, checked in order.-_RULES = [-    # ⚠️ DATA-LOSS dialog. Closing a doc while its 3D packages still upload to the Hub.-    # Title is often the bare "Fusion", so this also gets caught by the generic fallback-    # below; the rule here fires when the build surfaces descriptive text. The CORRECT-    # action is NEVER "dismiss" - it is "No, then wait for the upload to drain".-    (("uploaded to your fusion hub", "packages are being uploaded", "being uploaded",-      "lose these changes"),-     "upload_in_progress",-     "DATA LOSS RISK. Fusion is still uploading 3D package(s) to the Hub. Do NOT click Yes / do "-     "NOT close - you will LOSE the uploaded packages and any unsaved bindings. Click No, then WAIT "-     "and poll until the upload finishes before closing or saveAs. See the fusion-cloud-save skill."),--    (("cannot be saved while packages", "save was cancelled", "save cancelled"),-     "save_blocked_by_upload",-     "saveAs was refused because 3D packages are still uploading to the Hub (this also throws "-     "InternalValidationError). Not fatal: wait for the upload to drain, then retry the saveAs and "-     "poll past it. See the fusion-cloud-save skill."),--    (("needs to update", "update is available", "software update", "new update"),-     "update_nag",-     "Fusion is prompting to update itself. Do NOT auto-confirm (it can start an "-     "update mid-automation). Dismiss it on the desktop or let Fusion update, then "-     "retry. A pending update is also a common cause of AdomBridge add-in crashes "-     "(add-in/host version drift), so clearing it often fixes those too."),--    (("select electronics design", "multiple electronics design", "linked to multiple"),-     "linked_design_picker",-     "This file links to multiple Electronics designs. The chooser is a CEF/web "-     "modal whose list is NOT keyboard- or Win32-navigable (SendInput reaches only "-     "the native Cancel/OK), so it can't be resolved headlessly today — select the "-     "design on the desktop. (Headless fix tracked: have the add-in open the specific "-     "linked design via the Fusion API, avoiding the picker entirely.)"),--    (("recovery", "recover unsaved", "document recovery"),-     "recovery",-     "Document Recovery prompt. Call fusion_dismiss_recovery, or fusion_relocate_recovery "-     "BEFORE fusion_start to prevent it."),--    (("save changes", "save document", "do you want to save", "unsaved changes"),-     "save_changes",-     "Unsaved-changes prompt. Use fusion_close_document (closes without the save modal), "-     "or fusion_send_key {\"key\":\"tab\"} then {\"key\":\"enter\"}."),--    (("what do you want to design", "what to design"),-     "what_to_design",-     "Fusion's start picker. fusion_send_key {\"key\":\"escape\"} to dismiss."),--    # ── Launch / licensing / setup dialogs (owned by AdskIdentityManager /-    #    FusionLauncher, so classify_launch_dialogs enumerates ALL top-level-    #    windows to catch them - get_fusion_window_info would miss them). ──--    # Autodesk SEAT conflict — a DECISION dialog. The options suspend / shut down-    # Fusion on the user's OTHER machine (risking its unsaved work), so NEVER-    # auto-pick: notify + ASK the user which option they want.-    (("active sessions exceeded", "more active sessions", "more sessions running than are allowed",-      "sessions running than are allowed"),-     "session_conflict_decision",-     "DECISION DIALOG - do NOT auto-pick. This Autodesk seat is already active on another machine; "-     "the options SUSPEND or SHUT DOWN Fusion THERE (risking that machine's unsaved work). fire a "-     "notify_user and ASK the user which option they want, then click it. (Suspend = pausable/"-     "resumable; Shut down = saves the other machine's work to a recovery file.)"),--    (("suspend remote session",),-     "session_suspend_confirm",-     "Confirmation of a chosen 'suspend the OTHER machine's Fusion'. Only click Continue if the user "-     "ALREADY chose suspend; otherwise Go Back and ASK. Never auto-confirm a seat action."),--    # A second Fusion launch while one is already running - the correct action is-    # CANCEL (Fusion is a singleton; a 2nd instance is never wanted by automation).-    (("fusion is already open", "multiple instances are not supported",-      "launch another fusion instance"),-     "already_open",-     "Fusion is ALREADY running - a second launch was attempted. Click CANCEL (never Launch): "-     "Fusion is a singleton and the running instance is the one to drive. This usually means a "-     "prior fusion_stop/kill did not fully clear the old process before relaunch."),--    # First-run Autodesk sign-in (the 'Welcome to Fusion / Sign In' window, and the-    # browser callback 'You're signed in ... Open Product'). NOT auto-dismissable --    # it needs the user's login; drive the buttons (Sign In / Open Product) via UIA-    # but NEVER auto-fill credentials.-    (("welcome to fusion", "signing in - autodesk", "sign in - autodesk"),-     "signin_required",-     "Autodesk sign-in needed (first run). NOTIFY the user, drive the 'Sign In' button (desktop_ui_"-     "click), fetch any email OTP via adom-google, and after the browser shows \"You're signed in\" "-     "click its 'Open Product' button - but NEVER auto-enter the password/2FA. See fusion-aps-signin."),--    # webdeploy launch failure - an INCOMPLETE/stale production folder (missing-    # FusionLauncher.exe.ini). Benign to dismiss; means you launched the wrong hash.-    (("error launching streamed application", "missing or incomplete", "please re-install",-      "re-install the application"),-     "launch_error",-     "Launch error from an INCOMPLETE webdeploy folder (missing FusionLauncher.exe.ini). Safe to "-     "dismiss (OK/close) - it is only an acknowledgement. Launch the COMPLETE production folder (the "-     "hash dir that HAS FusionLauncher.exe.ini); detect_fusion now prefers it."),--    # ⚠️ OPERATION FAILED. A bare "Error"-titled owned popup means the LAST command did-    # NOT succeed even if the API call returned ok - e.g. an .lbr that "has errors and-    # cannot be opened" (Fusion opened an EMPTY design instead), a failed import/export,-    # a bad STEP. This is NOT a decision and NOT a benign launch ack: the bridge flips the-    # command's success to False when it sees this (see server._apply_failure_dialogs), so-    # a malformed library can never again be reported as "opened". Keep this rule LAST so-    # the specific "error launching..."/seat rules above win first. It is a pure OK-    # acknowledgement, so it is safe to close (WM_CLOSE) after the failure is surfaced.-    (("error", "cannot be opened", "has errors", "failed to", "could not", "invalid"),-     "operation_error",-     "The last operation FAILED - Fusion is showing an Error dialog (the API call may still have "-     "returned ok because it opened an EMPTY design). READ the dialog screenshot for the exact "-     "message. Common case: '<file>.lbr has errors and cannot be opened' = the library is malformed "-     "(re-lint/regenerate; a frequent cause is a single UNNAMED symbol pin - EAGLE <pin name=''> + "-     "<connect pin=''> - name the pin from the KiCad pin NUMBER). The bridge already flipped success "-     "to False and dismissed the empty design; FIX the source file and retry. Do NOT report success."),-]--# Categories that mean the OPERATION FAILED. The bridge downgrades a command's-# success to False when a popup of one of these categories is up after it runs, so-# a failure (e.g. a malformed .lbr) can never be reported as success. These are pure-# OK-acknowledgements, so the bridge may close them after surfacing the failure.-FAILURE_CATEGORIES = {"operation_error"}-# Categories that require a USER decision — the launch code must NOT auto-dismiss-# these; it surfaces them + fires a notify_user + asks.-DECISION_CATEGORIES = {"session_conflict_decision", "session_suspend_confirm"}-# Categories the launch code MAY auto-dismiss (a benign ack; WM_CLOSE == Cancel/OK).-# Everything else (decisions, sign-in) is SURFACED, never auto-closed.-AUTO_DISMISS_CATEGORIES = {"launch_error", "already_open"}-# Launch/setup categories enumerated across ALL top-level windows (not just Fusion).-_LAUNCH_CATEGORIES = DECISION_CATEGORIES | {"launch_error", "already_open", "signin_required"}---def _category_for_title(title: str):-    t = (title or "").strip().lower()-    if not t:-        return None-    for substrings, category, resolution in _RULES:-        if any(s in t for s in substrings):-            return category, resolution-    # A bare "Fusion"/"Fusion360"-titled modal is generic - its body text (the part-    # that tells you what it actually IS) lives in CEF/Qt child controls that are NOT-    # Win32-enumerable, so we cannot classify it by title alone. The two it most often-    # is, depending on context: during a file OPEN, the "Select Electronics Design File"-    # chooser; during a CLOSE or SAVE, the Hub "packages are being uploaded... sure you-    # want to close?" data-loss confirm. So the only safe instruction is: READ the-    # attached screenshot and ANALYZE before acting. NEVER tell the caller to blindly-    # dismiss - clicking Yes on the upload confirm destroys the packages.-    if t in ("fusion", "fusion360", "autodesk fusion 360", "fusion 360"):-        return ("generic_modal_read_screenshot",-                "Generic Fusion modal - its body is not Win32-readable, so a screenshot "-                "is attached: READ it and ANALYZE before acting. Do NOT blind-dismiss. "-                "If it mentions uploading / 'lose these changes' / 'sure you want to "-                "close' -> click No and wait for the Hub upload to drain (fusion-cloud-save). "-                "If it is the 'Select Electronics Design File' chooser during an open, its "-                "list is not keyboard/Win32-navigable; open the specific design by URN or "-                "select on the desktop.")-    return ("unknown",-            "Unrecognized modal - a screenshot is attached: READ it and ANALYZE before "-            "acting. Do NOT blindly dismiss (some modals lose work if confirmed wrong). "-            "Identify it from the screenshot, then take the work-preserving action.")---def _enumerate_all_top_level():-    """(hwnd, title) for every visible, titled top-level window.--    Unlike get_fusion_window_info (scoped to the Fusion process), this catches-    dialogs owned by the Autodesk FAMILY - AdskIdentityManager (sign-in / seat-    conflict) and FusionLauncher (streamed-app launch errors) - which are the-    ones that block a first launch. Best-effort; never raises.-    """-    import ctypes-    results = []-    user32 = ctypes.windll.user32 if hasattr(ctypes, "windll") else None-    if not user32:-        return results-    WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)--    def _cb(hwnd, _lparam):-        try:-            if user32.IsWindowVisible(hwnd):-                n = user32.GetWindowTextLengthW(hwnd)-                if n:-                    buf = ctypes.create_unicode_buffer(n + 1)-                    user32.GetWindowTextW(hwnd, buf, n + 1)-                    if buf.value.strip():-                        results.append((hwnd, buf.value))-        except Exception:-            pass-        return True--    try:-        user32.EnumWindows(WNDENUMPROC(_cb), 0)-    except Exception:-        pass-    return results---def classify_launch_dialogs() -> list:-    """Classify LAUNCH / licensing / setup dialogs across ALL top-level windows.--    Each entry: {hwnd, title, category, resolution, decision}. `decision:true`-    means a USER choice is required (seat conflict) - the launch path must NOT-    auto-dismiss it; it notifies + asks. Benign `launch_error` dialogs (decision:-    false) can be closed. Empty list if none. Never raises.-    """-    out = []-    for hwnd, title in _enumerate_all_top_level():-        classified = _category_for_title(title)-        if not classified:-            continue-        category, resolution = classified-        if category not in _LAUNCH_CATEGORIES:-            continue-        out.append({-            "hwnd": hwnd,-            "title": title,-            "category": category,-            "resolution": resolution,-            "decision": category in DECISION_CATEGORIES,-        })-    return out---def close_dialog_bg(hwnd) -> bool:-    """WM_CLOSE a window in the BACKGROUND (PostMessage = no focus steal). Used to-    auto-dismiss benign launch-error acks. Never raises; returns True if posted."""-    import ctypes-    user32 = ctypes.windll.user32 if hasattr(ctypes, "windll") else None-    if not user32 or not hwnd:-        return False-    try:-        return bool(user32.PostMessageW(ctypes.c_void_p(int(hwnd)), 0x0010, 0, 0))  # WM_CLOSE-    except Exception:-        return False---def classify_blocking_dialogs() -> list:-    """Return classified blocking dialogs currently open in Fusion.--    Each entry: {hwnd, title, category, resolution}. Empty list if none.-    Never raises — best-effort enumeration.-    """-    out = []-    try:-        info = get_fusion_window_info() or {}-    except Exception:-        return out-    for dlg in info.get("dialogs", []) or []:-        title = dlg.get("title", "")-        classified = _category_for_title(title)-        if not classified:-            continue-        category, resolution = classified-        out.append({-            "hwnd": dlg.get("hwnd"),-            "title": title,-            "category": category,-            "resolution": resolution,-        })-    return out+"""Classify Fusion 360 blocking dialogs by title, with resolution hints.++Why this exists: several Fusion modals (the multi-linked "Select Electronics+Design File" picker, the "Fusion needs to update" nag, Document Recovery, "Save+changes?", "What do you want to design?") block Fusion's main thread. When that+happens during an add-in command, the add-in's HTTP server can't respond and the+bridge reports a misleading "add-in not responding / may have crashed" — which+sends callers into a pointless restart loop.++Qt dialog *titles* are enumerable over Win32 even while the add-in is blocked+(unlike the dialogs' CEF/Qt child controls, which are not). So we can cheaply+identify WHICH modal is up and return an actionable resolution instead of a fake+crash. This is bridge-wide: it helps every command that can be blocked by a+modal, not just exports.+"""++from handlers.fusion_ui import get_fusion_window_info+++# (category, resolution-hint) keyed by lowercase title substrings, checked in order.+_RULES = [+    # ⚠️ DATA-LOSS dialog. Closing a doc while its 3D packages still upload to the Hub.+    # Title is often the bare "Fusion", so this also gets caught by the generic fallback+    # below; the rule here fires when the build surfaces descriptive text. The CORRECT+    # action is NEVER "dismiss" - it is "No, then wait for the upload to drain".+    (("uploaded to your fusion hub", "packages are being uploaded", "being uploaded",+      "lose these changes"),+     "upload_in_progress",+     "DATA LOSS RISK. Fusion is still uploading 3D package(s) to the Hub. Do NOT click Yes / do "+     "NOT close - you will LOSE the uploaded packages and any unsaved bindings. Click No, then WAIT "+     "and poll until the upload finishes before closing or saveAs. See the fusion-cloud-save skill."),++    (("cannot be saved while packages", "save was cancelled", "save cancelled"),+     "save_blocked_by_upload",+     "saveAs was refused because 3D packages are still uploading to the Hub (this also throws "+     "InternalValidationError). Not fatal: wait for the upload to drain, then retry the saveAs and "+     "poll past it. See the fusion-cloud-save skill."),++    (("needs to update", "update is available", "software update", "new update"),+     "update_nag",+     "Fusion is prompting to update itself. Do NOT auto-confirm (it can start an "+     "update mid-automation). Dismiss it on the desktop or let Fusion update, then "+     "retry. A pending update is also a common cause of AdomBridge add-in crashes "+     "(add-in/host version drift), so clearing it often fixes those too."),++    (("select electronics design", "multiple electronics design", "linked to multiple"),+     "linked_design_picker",+     "This file links to multiple Electronics designs. The chooser is a CEF/web "+     "modal whose list is NOT keyboard- or Win32-navigable (SendInput reaches only "+     "the native Cancel/OK), so it can't be resolved headlessly today — select the "+     "design on the desktop. (Headless fix tracked: have the add-in open the specific "+     "linked design via the Fusion API, avoiding the picker entirely.)"),++    (("recovery", "recover unsaved", "document recovery"),+     "recovery",+     "Document Recovery prompt. Call fusion_dismiss_recovery, or fusion_relocate_recovery "+     "BEFORE fusion_start to prevent it."),++    (("save changes", "save document", "do you want to save", "unsaved changes"),+     "save_changes",+     "Unsaved-changes prompt. Use fusion_close_document (closes without the save modal), "+     "or fusion_send_key {\"key\":\"tab\"} then {\"key\":\"enter\"}."),++    (("what do you want to design", "what to design"),+     "what_to_design",+     "Fusion's start picker. fusion_send_key {\"key\":\"escape\"} to dismiss."),++    # ── Launch / licensing / setup dialogs (owned by AdskIdentityManager /+    #    FusionLauncher, so classify_launch_dialogs enumerates ALL top-level+    #    windows to catch them - get_fusion_window_info would miss them). ──++    # Autodesk SEAT conflict — a DECISION dialog. The options suspend / shut down+    # Fusion on the user's OTHER machine (risking its unsaved work), so NEVER+    # auto-pick: notify + ASK the user which option they want.+    (("active sessions exceeded", "more active sessions", "more sessions running than are allowed",+      "sessions running than are allowed"),+     "session_conflict_decision",+     "DECISION DIALOG - do NOT auto-pick. This Autodesk seat is already active on another machine; "+     "the options SUSPEND or SHUT DOWN Fusion THERE (risking that machine's unsaved work). fire a "+     "notify_user and ASK the user which option they want, then click it. (Suspend = pausable/"+     "resumable; Shut down = saves the other machine's work to a recovery file.)"),++    (("suspend remote session",),+     "session_suspend_confirm",+     "Confirmation of a chosen 'suspend the OTHER machine's Fusion'. Only click Continue if the user "+     "ALREADY chose suspend; otherwise Go Back and ASK. Never auto-confirm a seat action."),++    # A second Fusion launch while one is already running - the correct action is+    # CANCEL (Fusion is a singleton; a 2nd instance is never wanted by automation).+    (("fusion is already open", "multiple instances are not supported",+      "launch another fusion instance"),+     "already_open",+     "Fusion is ALREADY running - a second launch was attempted. Click CANCEL (never Launch): "+     "Fusion is a singleton and the running instance is the one to drive. This usually means a "+     "prior fusion_stop/kill did not fully clear the old process before relaunch."),++    # First-run Autodesk sign-in (the 'Welcome to Fusion / Sign In' window, and the+    # browser callback 'You're signed in ... Open Product'). NOT auto-dismissable -+    # it needs the user's login; drive the buttons (Sign In / Open Product) via UIA+    # but NEVER auto-fill credentials.+    (("welcome to fusion", "signing in - autodesk", "sign in - autodesk"),+     "signin_required",+     "Autodesk sign-in needed (first run). NOTIFY the user, drive the 'Sign In' button (desktop_ui_"+     "click), fetch any email OTP via adom-google, and after the browser shows \"You're signed in\" "+     "click its 'Open Product' button - but NEVER auto-enter the password/2FA. See fusion-aps-signin."),++    # webdeploy launch failure - an INCOMPLETE/stale production folder (missing+    # FusionLauncher.exe.ini). Benign to dismiss; means you launched the wrong hash.+    (("error launching streamed application", "missing or incomplete", "please re-install",+      "re-install the application"),+     "launch_error",+     "Launch error from an INCOMPLETE webdeploy folder (missing FusionLauncher.exe.ini). Safe to "+     "dismiss (OK/close) - it is only an acknowledgement. Launch the COMPLETE production folder (the "+     "hash dir that HAS FusionLauncher.exe.ini); detect_fusion now prefers it."),++    # ⚠️ OPERATION FAILED. A bare "Error"-titled owned popup means the LAST command did+    # NOT succeed even if the API call returned ok - e.g. an .lbr that "has errors and+    # cannot be opened" (Fusion opened an EMPTY design instead), a failed import/export,+    # a bad STEP. This is NOT a decision and NOT a benign launch ack: the bridge flips the+    # command's success to False when it sees this (see server._apply_failure_dialogs), so+    # a malformed library can never again be reported as "opened". Keep this rule LAST so+    # the specific "error launching..."/seat rules above win first. It is a pure OK+    # acknowledgement, so it is safe to close (WM_CLOSE) after the failure is surfaced.+    (("error", "cannot be opened", "has errors", "failed to", "could not", "invalid"),+     "operation_error",+     "The last operation FAILED - Fusion is showing an Error dialog (the API call may still have "+     "returned ok because it opened an EMPTY design). READ the dialog screenshot for the exact "+     "message. Common case: '<file>.lbr has errors and cannot be opened' = the library is malformed "+     "(re-lint/regenerate; a frequent cause is a single UNNAMED symbol pin - EAGLE <pin name=''> + "+     "<connect pin=''> - name the pin from the KiCad pin NUMBER). The bridge already flipped success "+     "to False and dismissed the empty design; FIX the source file and retry. Do NOT report success."),+]++# Categories that mean the OPERATION FAILED. The bridge downgrades a command's+# success to False when a popup of one of these categories is up after it runs, so+# a failure (e.g. a malformed .lbr) can never be reported as success. These are pure+# OK-acknowledgements, so the bridge may close them after surfacing the failure.+FAILURE_CATEGORIES = {"operation_error"}+# Categories that require a USER decision — the launch code must NOT auto-dismiss+# these; it surfaces them + fires a notify_user + asks.+DECISION_CATEGORIES = {"session_conflict_decision", "session_suspend_confirm"}+# Categories the launch code MAY auto-dismiss (a benign ack; WM_CLOSE == Cancel/OK).+# Everything else (decisions, sign-in) is SURFACED, never auto-closed.+AUTO_DISMISS_CATEGORIES = {"launch_error", "already_open"}+# Launch/setup categories enumerated across ALL top-level windows (not just Fusion).+_LAUNCH_CATEGORIES = DECISION_CATEGORIES | {"launch_error", "already_open", "signin_required"}+++# ── Raw Fusion API exception classification ──────────────────────────────────+# The dialog rules above classify MODAL windows by title. This sibling table does+# the same one layer down: it maps a raw Fusion API exception STRING (the bare+# `RuntimeError` text that surfaces from an adsk.* call, e.g. inside a+# run_modeling_script exec) to a stable errorCode + actionable _hint, so an API+# failure honours the same contract as every other verb instead of returning+# opaque text. Fusion prefixes these with an internal code ("3 : ...", "4 : ...").+# Match on the message BODY (substring, case-insensitive), never the number.+# Extend by adding a (substrings, errorCode, hint) row; unmatched errors are left+# untouched so an unknown failure is never mislabeled or swallowed.+_API_ERROR_RULES = [+    (("part design documents can only contain one component",),+     "part_design_single_component",+     "This document was created from Fusion's PART template, which allows only one "+     "component. The Python API and the UI default differ: a doc made via "+     "documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType) is assembly-"+     "capable. Either keep everything as BODIES in the single root component, or "+     "create the document through the API (as run_modeling_script does) instead of "+     "reusing a Part-template doc."),++    (("root component name cannot be changed",),+     "root_rename_unsupported",+     "The root component always takes the DOCUMENT's name, so it can't be renamed "+     "directly. Rename on save instead (save/save_to_cloud with the new name), or "+     "rename a child occurrence/component rather than the root."),++    (("api object refers to a deleted object", "refers to a deleted object"),+     "stale_api_handle",+     "A Python handle points at an object Fusion has since closed or deleted (a "+     "common cause: caching a design/component/body reference across a document "+     "close, activate, or a tab-switch). Re-fetch the handle from the CURRENT active "+     "document (app.activeProduct / re-query by name) and retry; don't reuse the old "+     "reference."),+]+++def classify_api_error(message: str):+    """Classify a raw Fusion API exception string → (errorCode, hint), or None.++    Pure string match, no Win32/Fusion dependency (safe to call from anywhere).+    Returns None when nothing matches so callers leave the original error intact.+    """+    t = (message or "").strip().lower()+    if not t:+        return None+    for substrings, error_code, hint in _API_ERROR_RULES:+        if any(s in t for s in substrings):+            return error_code, hint+    return None+++def _category_for_title(title: str):+    t = (title or "").strip().lower()+    if not t:+        return None+    for substrings, category, resolution in _RULES:+        if any(s in t for s in substrings):+            return category, resolution+    # A bare "Fusion"/"Fusion360"-titled modal is generic - its body text (the part+    # that tells you what it actually IS) lives in CEF/Qt child controls that are NOT+    # Win32-enumerable, so we cannot classify it by title alone. The two it most often+    # is, depending on context: during a file OPEN, the "Select Electronics Design File"+    # chooser; during a CLOSE or SAVE, the Hub "packages are being uploaded... sure you+    # want to close?" data-loss confirm. So the only safe instruction is: READ the+    # attached screenshot and ANALYZE before acting. NEVER tell the caller to blindly+    # dismiss - clicking Yes on the upload confirm destroys the packages.+    if t in ("fusion", "fusion360", "autodesk fusion 360", "fusion 360"):+        return ("generic_modal_read_screenshot",+                "Generic Fusion modal - its body is not Win32-readable, so a screenshot "+                "is attached: READ it and ANALYZE before acting. Do NOT blind-dismiss. "+                "If it mentions uploading / 'lose these changes' / 'sure you want to "+                "close' -> click No and wait for the Hub upload to drain (fusion-cloud-save). "+                "If it is the 'Select Electronics Design File' chooser during an open, its "+                "list is not keyboard/Win32-navigable; open the specific design by URN or "+                "select on the desktop.")+    return ("unknown",+            "Unrecognized modal - a screenshot is attached: READ it and ANALYZE before "+            "acting. Do NOT blindly dismiss (some modals lose work if confirmed wrong). "+            "Identify it from the screenshot, then take the work-preserving action.")+++def _enumerate_all_top_level():+    """(hwnd, title) for every visible, titled top-level window.++    Unlike get_fusion_window_info (scoped to the Fusion process), this catches+    dialogs owned by the Autodesk FAMILY - AdskIdentityManager (sign-in / seat+    conflict) and FusionLauncher (streamed-app launch errors) - which are the+    ones that block a first launch. Best-effort; never raises.+    """+    import ctypes+    results = []+    user32 = ctypes.windll.user32 if hasattr(ctypes, "windll") else None+    if not user32:+        return results+    WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)++    def _cb(hwnd, _lparam):+        try:+            if user32.IsWindowVisible(hwnd):+                n = user32.GetWindowTextLengthW(hwnd)+                if n:+                    buf = ctypes.create_unicode_buffer(n + 1)+                    user32.GetWindowTextW(hwnd, buf, n + 1)+                    if buf.value.strip():+                        results.append((hwnd, buf.value))+        except Exception:+            pass+        return True++    try:+        user32.EnumWindows(WNDENUMPROC(_cb), 0)+    except Exception:+        pass+    return results+++def classify_launch_dialogs() -> list:+    """Classify LAUNCH / licensing / setup dialogs across ALL top-level windows.++    Each entry: {hwnd, title, category, resolution, decision}. `decision:true`+    means a USER choice is required (seat conflict) - the launch path must NOT+    auto-dismiss it; it notifies + asks. Benign `launch_error` dialogs (decision:+    false) can be closed. Empty list if none. Never raises.+    """+    out = []+    for hwnd, title in _enumerate_all_top_level():+        classified = _category_for_title(title)+        if not classified:+            continue+        category, resolution = classified+        if category not in _LAUNCH_CATEGORIES:+            continue+        out.append({+            "hwnd": hwnd,+            "title": title,+            "category": category,+            "resolution": resolution,+            "decision": category in DECISION_CATEGORIES,+        })+    return out+++def close_dialog_bg(hwnd) -> bool:+    """WM_CLOSE a window in the BACKGROUND (PostMessage = no focus steal). Used to+    auto-dismiss benign launch-error acks. Never raises; returns True if posted."""+    import ctypes+    user32 = ctypes.windll.user32 if hasattr(ctypes, "windll") else None+    if not user32 or not hwnd:+        return False+    try:+        return bool(user32.PostMessageW(ctypes.c_void_p(int(hwnd)), 0x0010, 0, 0))  # WM_CLOSE+    except Exception:+        return False+++def classify_blocking_dialogs() -> list:+    """Return classified blocking dialogs currently open in Fusion.++    Each entry: {hwnd, title, category, resolution}. Empty list if none.+    Never raises — best-effort enumeration.+    """+    out = []+    try:+        info = get_fusion_window_info() or {}+    except Exception:+        return out+    for dlg in info.get("dialogs", []) or []:+        title = dlg.get("title", "")+        classified = _category_for_title(title)+        if not classified:+            continue+        category, resolution = classified+        out.append({+            "hwnd": dlg.get("hwnd"),+            "title": title,+            "category": category,+            "resolution": resolution,+        })+    return out+--- a/handlers/fusion_ui.py+++ b/handlers/fusion_ui.py@@ -1,742 +1,752 @@-"""Win32-based tools for interacting with Fusion 360's UI.--Provides screenshot, click, and keyboard input functions that work with-CEF (Chromium Embedded Framework) modal dialogs inside Fusion 360.-These dialogs can't be accessed via standard Win32 child window enumeration,-so we use screen-DC capture (BitBlt) instead of PrintWindow, and SendInput-instead of PostMessage for input.-"""--import ctypes-import ctypes.wintypes-import os-import struct-import time--# Guard windll so the module imports on non-Windows hosts (the bridge must boot-# + serve /status anywhere); these Win32 paths are only reached on Windows.-_have_windll = hasattr(ctypes, "windll")-user32 = ctypes.windll.user32 if _have_windll else None-gdi32 = ctypes.windll.gdi32 if _have_windll else None-kernel32 = ctypes.windll.kernel32 if _have_windll else None--# ⚠️ HWND TRUNCATION BUG (fixed 2026-07-08, cost hours): with NO argtypes declared, ctypes-# defaults every function arg + return to 32-bit `c_int`, which TRUNCATES 64-bit HWND handles.-# So `_find_fusion_hwnd`'s GetWindowTextW/IsWindowVisible ran on a garbage handle and matched-# NOTHING - get_fusion_window_info returned mainHwnd:None even though Fusion's window ("Untitled-# - Autodesk Fusion") plainly existed, which blinded all background driving. (Verified live: the-# window's real hwnd via `(Get-Process Fusion360).MainWindowHandle` worked fine.) Declaring HWND-# argtypes/restypes makes ctypes marshal the full 64-bit handle. If the finder ever returns 0/None-# while Fusion IS running, the process-handle fallback in _find_fusion_hwnd covers it.-if _have_windll:-    _W = ctypes.wintypes-    for _fn, _arg, _ret in (-        ("IsWindowVisible",       [_W.HWND],                              ctypes.c_bool),-        ("IsWindowEnabled",       [_W.HWND],                              ctypes.c_bool),-        ("GetWindowTextLengthW",  [_W.HWND],                              ctypes.c_int),-        ("GetWindowTextW",        [_W.HWND, _W.LPWSTR, ctypes.c_int],     ctypes.c_int),-        ("GetClassNameW",         [_W.HWND, _W.LPWSTR, ctypes.c_int],     ctypes.c_int),-        ("GetWindow",             [_W.HWND, ctypes.c_uint],               _W.HWND),-        ("GetParent",             [_W.HWND],                              _W.HWND),-        ("IsWindow",              [_W.HWND],                              ctypes.c_bool),-    ):-        try:-            _f = getattr(user32, _fn); _f.argtypes = _arg; _f.restype = _ret-        except Exception:-            pass--# Enable DPI awareness so GetWindowRect returns physical pixel coordinates-# (not logical/scaled). Without this, screenshots are cropped on HiDPI displays.-try:-    ctypes.windll.shcore.SetProcessDpiAwareness(2)  # PROCESS_PER_MONITOR_DPI_AWARE-except Exception:-    try:-        user32.SetProcessDPIAware()  # Fallback for older Windows-    except Exception:-        pass--# --- Constants -----SRCCOPY = 0x00CC0020-DIB_RGB_COLORS = 0-BI_RGB = 0-SM_CXVIRTUALSCREEN = 78-SM_CYVIRTUALSCREEN = 79--INPUT_MOUSE = 0-INPUT_KEYBOARD = 1-MOUSEEVENTF_ABSOLUTE = 0x8000-MOUSEEVENTF_MOVE = 0x0001-MOUSEEVENTF_LEFTDOWN = 0x0002-MOUSEEVENTF_LEFTUP = 0x0004-KEYEVENTF_KEYUP = 0x0002--# Virtual key codes-VK_MAP = {-    "enter": 0x0D, "return": 0x0D,-    "escape": 0x1B, "esc": 0x1B,-    "tab": 0x09,-    "space": 0x20,-    "up": 0x26, "down": 0x28, "left": 0x25, "right": 0x27,-    "backspace": 0x08, "delete": 0x2E,-    "home": 0x24, "end": 0x23,-    "pageup": 0x21, "pagedown": 0x22,-    "f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73,-    "f5": 0x74, "f6": 0x75, "f7": 0x76, "f8": 0x77,-    "f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B,-}--SCREENSHOT_DIR = "C:/tmp/conduit-screenshots"--# Keep callback references alive to prevent GC-_callbacks = []---# --- SendInput structures -----class MOUSEINPUT(ctypes.Structure):-    _fields_ = [-        ("dx", ctypes.wintypes.LONG),-        ("dy", ctypes.wintypes.LONG),-        ("mouseData", ctypes.wintypes.DWORD),-        ("dwFlags", ctypes.wintypes.DWORD),-        ("time", ctypes.wintypes.DWORD),-        ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)),-    ]---class KEYBDINPUT(ctypes.Structure):-    _fields_ = [-        ("wVk", ctypes.wintypes.WORD),-        ("wScan", ctypes.wintypes.WORD),-        ("dwFlags", ctypes.wintypes.DWORD),-        ("time", ctypes.wintypes.DWORD),-        ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)),-    ]---class _INPUT_UNION(ctypes.Union):-    _fields_ = [-        ("mi", MOUSEINPUT),-        ("ki", KEYBDINPUT),-    ]---class INPUT(ctypes.Structure):-    _fields_ = [-        ("type", ctypes.wintypes.DWORD),-        ("union", _INPUT_UNION),-    ]---def _send_input(*inputs):-    """Send one or more INPUT structures via SendInput."""-    n = len(inputs)-    arr = (INPUT * n)(*inputs)-    user32.SendInput(n, ctypes.pointer(arr), ctypes.sizeof(INPUT))---# --- Window finding -----def _find_fusion_hwnd() -> int:-    """Find Fusion 360's main window by title containing 'Autodesk Fusion'."""-    result = [0]-    WNDENUMPROC = ctypes.WINFUNCTYPE(-        ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM-    )--    def callback(hwnd, _lparam):-        if user32.IsWindowVisible(hwnd):-            length = user32.GetWindowTextLengthW(hwnd)-            if length > 0:-                buf = ctypes.create_unicode_buffer(length + 1)-                user32.GetWindowTextW(hwnd, buf, length + 1)-                if "Autodesk Fusion" in buf.value:-                    result[0] = hwnd-                    return False-        return True--    cb = WNDENUMPROC(callback)-    _callbacks.append(cb)-    user32.EnumWindows(cb, 0)-    _callbacks.remove(cb)-    return result[0]---def _find_qt_dialog_windows() -> list:-    """Find visible Qt windows that may be Fusion 360 dialogs.--    Returns list of dicts with hwnd, title, rect, className.-    Excludes the main Fusion window (which has 'Autodesk Fusion' in title).-    """-    results = []-    WNDENUMPROC = ctypes.WINFUNCTYPE(-        ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM-    )--    def callback(hwnd, _lparam):-        if user32.IsWindowVisible(hwnd):-            cls_buf = ctypes.create_unicode_buffer(256)-            user32.GetClassNameW(hwnd, cls_buf, 256)-            class_name = cls_buf.value--            length = user32.GetWindowTextLengthW(hwnd)-            title = ""-            if length > 0:-                buf = ctypes.create_unicode_buffer(length + 1)-                user32.GetWindowTextW(hwnd, buf, length + 1)-                title = buf.value--            # Qt windows belonging to Fusion (not the main window)-            if "Qt" in class_name and "Autodesk Fusion" not in title:-                # Some Fusion dialogs (including the "What do you want to-                # design?" startup picker) use Qt Tool window classes like-                # "Qt655QWindowToolSaveBits".  We used to skip ALL Tool-                # windows (they're usually docked panels — Browser, Timeline).-                # But that caused the startup picker to be invisible to-                # dialog detection. Now we only skip SMALL Tool windows-                # (docked panels are narrow sidebar panels, typically-                # <400px wide).  Large Tool windows (>400px wide AND >300px-                # tall) are kept — they're blocking dialogs like the picker.-                if "Tool" in class_name:-                    rect = ctypes.wintypes.RECT()-                    user32.GetWindowRect(hwnd, ctypes.byref(rect))-                    w = rect.right - rect.left-                    h = rect.bottom - rect.top-                    if w < 400 or h < 300:-                        return True  # small tool panel, skip--                rect = ctypes.wintypes.RECT()-                user32.GetWindowRect(hwnd, ctypes.byref(rect))-                results.append({-                    "hwnd": hwnd,-                    "title": title,-                    "className": class_name,-                    "rect": {-                        "left": rect.left, "top": rect.top,-                        "right": rect.right, "bottom": rect.bottom,-                        "width": rect.right - rect.left,-                        "height": rect.bottom - rect.top,-                    },-                })-        return True--    cb = WNDENUMPROC(callback)-    _callbacks.append(cb)-    user32.EnumWindows(cb, 0)-    _callbacks.remove(cb)-    return results---def _get_window_rect(hwnd: int) -> tuple:-    """Get window rect as (left, top, right, bottom)."""-    rect = ctypes.wintypes.RECT()-    user32.GetWindowRect(hwnd, ctypes.byref(rect))-    return (rect.left, rect.top, rect.right, rect.bottom)---# --- Screenshot -----def _write_bmp(path: str, width: int, height: int, pixel_data: bytes):-    """Write a BMP file from raw BGR pixel data (bottom-up row order).--    pixel_data must be width*height*4 bytes (32-bit BGRA from GetDIBits).-    We write a 24-bit BMP (strip alpha) for broad compatibility.-    """-    row_size_24 = (width * 3 + 3) & ~3  # rows padded to 4 bytes-    pixel_size_24 = row_size_24 * height-    file_size = 14 + 40 + pixel_size_24  # BMP header + DIB header + pixels--    with open(path, "wb") as f:-        # BMP file header (14 bytes)-        f.write(b"BM")-        f.write(struct.pack("<I", file_size))-        f.write(struct.pack("<HH", 0, 0))  # reserved-        f.write(struct.pack("<I", 14 + 40))  # offset to pixel data--        # DIB header (BITMAPINFOHEADER, 40 bytes)-        f.write(struct.pack("<I", 40))  # header size-        f.write(struct.pack("<i", width))-        f.write(struct.pack("<i", height))  # positive = bottom-up-        f.write(struct.pack("<HH", 1, 24))  # planes, bpp-        f.write(struct.pack("<I", BI_RGB))  # compression-        f.write(struct.pack("<I", pixel_size_24))-        f.write(struct.pack("<ii", 2835, 2835))  # pixels/meter (~72 DPI)-        f.write(struct.pack("<II", 0, 0))  # colors--        # Write pixel rows (convert 32-bit BGRA to 24-bit BGR, bottom-up)-        src_row_size = width * 4-        for y in range(height):-            row_start = y * src_row_size-            row_24 = bytearray()-            for x in range(width):-                px = row_start + x * 4-                row_24 += pixel_data[px:px + 3]  # BGR (skip alpha)-            # Pad row to 4-byte boundary-            padding = row_size_24 - len(row_24)-            if padding > 0:-                row_24 += b"\x00" * padding-            f.write(bytes(row_24))---PW_RENDERFULLCONTENT = 0x00000002---def screenshot_hwnd(hwnd: int, label: str = "") -> dict:-    """Capture any window by HWND. Returns {success, savedTo, sizeKB}."""-    left, top, right, bottom = _get_window_rect(hwnd)-    width = right - left-    height = bottom - top--    if width <= 0 or height <= 0:-        return {"success": False, "error": f"Invalid rect for hwnd {hwnd}"}--    hdc_screen = user32.GetDC(hwnd)-    hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)-    hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, width, height)-    old_bmp = gdi32.SelectObject(hdc_mem, hbmp)-    user32.PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT)--    class BITMAPINFOHEADER(ctypes.Structure):-        _fields_ = [-            ("biSize", ctypes.wintypes.DWORD),-            ("biWidth", ctypes.wintypes.LONG),-            ("biHeight", ctypes.wintypes.LONG),-            ("biPlanes", ctypes.wintypes.WORD),-            ("biBitCount", ctypes.wintypes.WORD),-            ("biCompression", ctypes.wintypes.DWORD),-            ("biSizeImage", ctypes.wintypes.DWORD),-            ("biXPelsPerMeter", ctypes.wintypes.LONG),-            ("biYPelsPerMeter", ctypes.wintypes.LONG),-            ("biClrUsed", ctypes.wintypes.DWORD),-            ("biClrImportant", ctypes.wintypes.DWORD),-        ]--    bmi = BITMAPINFOHEADER()-    bmi.biSize = ctypes.sizeof(BITMAPINFOHEADER)-    bmi.biWidth = width-    bmi.biHeight = height-    bmi.biPlanes = 1-    bmi.biBitCount = 32-    bmi.biCompression = BI_RGB--    buf_size = width * height * 4-    pixel_buf = ctypes.create_string_buffer(buf_size)-    gdi32.GetDIBits(hdc_mem, hbmp, 0, height, pixel_buf, ctypes.byref(bmi), DIB_RGB_COLORS)--    gdi32.SelectObject(hdc_mem, old_bmp)-    gdi32.DeleteObject(hbmp)-    gdi32.DeleteDC(hdc_mem)-    user32.ReleaseDC(hwnd, hdc_screen)--    os.makedirs(SCREENSHOT_DIR, exist_ok=True)-    timestamp = int(time.time() * 1000)-    suffix = f"-{label}" if label else ""--    try:-        from PIL import Image-        img = Image.frombytes("RGBA", (width, height), pixel_buf.raw, "raw", "BGRA")-        img = img.transpose(Image.FLIP_TOP_BOTTOM)-        MAX_DIM = 1568-        if max(width, height) > MAX_DIM:-            ratio = MAX_DIM / max(width, height)-            img = img.resize((int(width * ratio), int(height * ratio)), Image.LANCZOS)-        try:-            path = os.path.join(SCREENSHOT_DIR, f"fusion{suffix}-{timestamp}.webp")-            img.save(path, "WEBP", lossless=True, quality=100)-        except Exception:-            path = os.path.join(SCREENSHOT_DIR, f"fusion{suffix}-{timestamp}.png")-            img.save(path, "PNG", optimize=True)-    except ImportError:-        path = os.path.join(SCREENSHOT_DIR, f"fusion{suffix}-{timestamp}.bmp")-        _write_bmp(path, width, height, pixel_buf.raw)--    size_kb = os.path.getsize(path) / 1024-    return {-        "success": True,-        "savedTo": path.replace("\\", "/"),-        "sizeKB": round(size_kb, 1),-    }---def screenshot_fusion_window(use_bitblt: bool = False) -> dict:-    """Capture the Fusion 360 window.--    By default uses PrintWindow with PW_RENDERFULLCONTENT (works without-    foreground, captures Qt content but NOT CEF modal overlays).--    If use_bitblt=True, brings window to foreground and uses BitBlt from-    screen DC (captures CEF overlays but requires foreground).--    Saves PNG to C:/tmp/conduit-screenshots/ and returns the path.-    """-    hwnd = _find_fusion_hwnd()-    if not hwnd:-        return {-            "success": False,-            "error": "Fusion 360 window not found (no window with 'Autodesk Fusion' in title).",-            "_hint": "Call fusion_start to start Fusion 360 and wait for the add-in to be ready, then retry.",-        }--    # Get window rect-    left, top, right, bottom = _get_window_rect(hwnd)-    width = right - left-    height = bottom - top--    if width <= 0 or height <= 0:-        return {"success": False, "error": f"Invalid window rect: {left},{top},{right},{bottom}"}--    if use_bitblt:-        # Screen DC capture — needs foreground-        SW_RESTORE = 9-        user32.ShowWindow(hwnd, SW_RESTORE)-        user32.SetForegroundWindow(hwnd)-        time.sleep(1.0)--        hdc_screen = user32.GetDC(0)-        hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)-        hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, width, height)-        old_bmp = gdi32.SelectObject(hdc_mem, hbmp)-        gdi32.BitBlt(hdc_mem, 0, 0, width, height, hdc_screen, left, top, SRCCOPY)-    else:-        # PrintWindow capture — works without foreground-        hdc_screen = user32.GetDC(hwnd)-        hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)-        hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, width, height)-        old_bmp = gdi32.SelectObject(hdc_mem, hbmp)-        user32.PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT)--    # Read pixel data via GetDIBits-    class BITMAPINFOHEADER(ctypes.Structure):-        _fields_ = [-            ("biSize", ctypes.wintypes.DWORD),-            ("biWidth", ctypes.wintypes.LONG),-            ("biHeight", ctypes.wintypes.LONG),-            ("biPlanes", ctypes.wintypes.WORD),-            ("biBitCount", ctypes.wintypes.WORD),-            ("biCompression", ctypes.wintypes.DWORD),-            ("biSizeImage", ctypes.wintypes.DWORD),-            ("biXPelsPerMeter", ctypes.wintypes.LONG),-            ("biYPelsPerMeter", ctypes.wintypes.LONG),-            ("biClrUsed", ctypes.wintypes.DWORD),-            ("biClrImportant", ctypes.wintypes.DWORD),-        ]--    bmi = BITMAPINFOHEADER()-    bmi.biSize = ctypes.sizeof(BITMAPINFOHEADER)-    bmi.biWidth = width-    bmi.biHeight = height  # positive = bottom-up (standard BMP order)-    bmi.biPlanes = 1-    bmi.biBitCount = 32-    bmi.biCompression = BI_RGB--    buf_size = width * height * 4-    pixel_buf = ctypes.create_string_buffer(buf_size)-    gdi32.GetDIBits(hdc_mem, hbmp, 0, height, pixel_buf, ctypes.byref(bmi), DIB_RGB_COLORS)--    # Clean up GDI resources-    gdi32.SelectObject(hdc_mem, old_bmp)-    gdi32.DeleteObject(hbmp)-    gdi32.DeleteDC(hdc_mem)-    if use_bitblt:-        user32.ReleaseDC(0, hdc_screen)-    else:-        user32.ReleaseDC(hwnd, hdc_screen)--    # Save to file as PNG (try PIL first, fall back to BMP)-    os.makedirs(SCREENSHOT_DIR, exist_ok=True)-    timestamp = int(time.time() * 1000)--    try:-        from PIL import Image-        # pixel_buf is BGRA bottom-up; convert to RGBA top-down-        img = Image.frombytes("RGBA", (width, height), pixel_buf.raw, "raw", "BGRA")-        img = img.transpose(Image.FLIP_TOP_BOTTOM)--        # Downscale to max 1568px on longest side (saves tokens for AI vision)-        MAX_DIM = 1568-        if max(width, height) > MAX_DIM:-            ratio = MAX_DIM / max(width, height)-            new_size = (int(width * ratio), int(height * ratio))-            img = img.resize(new_size, Image.LANCZOS)--        # Try WebP first (much smaller than PNG for screenshots)-        try:-            path = os.path.join(SCREENSHOT_DIR, f"fusion-{timestamp}.webp")-            img.save(path, "WEBP", lossless=True, quality=100)-        except Exception:-            # Fall back to PNG if WebP not available-            path = os.path.join(SCREENSHOT_DIR, f"fusion-{timestamp}.png")-            img.save(path, "PNG", optimize=True)-    except ImportError:-        # No PIL — save as BMP (readable by most tools, but not Claude Read)-        path = os.path.join(SCREENSHOT_DIR, f"fusion-{timestamp}.bmp")-        _write_bmp(path, width, height, pixel_buf.raw)--    size_kb = os.path.getsize(path) / 1024-    return {-        "success": True,-        "savedTo": path.replace("\\", "/"),-        "sizeKB": round(size_kb, 1),-        "dimensions": {"width": width, "height": height},-    }---# --- Click -----def click_fusion(x, y, relative=True, hwnd=None) -> dict:-    """Click at coordinates in a window (Fusion main window or any dialog).--    Args:-        x: X coordinate. If relative=True, a float 0.0-1.0 (percentage of window width).-           If relative=False, pixel offset from window's top-left corner.-        y: Y coordinate. Same convention as x but for height.-        relative: If True (default), x/y are percentages. If False, pixel offsets.-        hwnd: Optional HWND to click on. If omitted, finds the main Fusion window.-              Use this to click buttons inside Qt dialogs by passing the dialog's HWND.--    Uses SendInput (NOT PostMessage) because CEF doesn't respond to WM messages.-    """-    if hwnd:-        hwnd = int(hwnd)-        # Validate the HWND is a real window-        if not user32.IsWindow(hwnd):-            return {-                "success": False,-                "error": f"HWND {hwnd} is not a valid window.",-                "_hint": "HWNDs change when windows close/reopen. Call fusion_get_window_info to get fresh HWNDs, then retry.",-            }-    else:-        hwnd = _find_fusion_hwnd()-        if not hwnd:-            return {-                "success": False,-                "error": "Fusion 360 window not found.",-                "_hint": "Call fusion_start to start Fusion 360, then retry.",-            }--    left, top, right, bottom = _get_window_rect(hwnd)-    width = right - left-    height = bottom - top--    # Bring to front-    user32.SetForegroundWindow(hwnd)-    time.sleep(0.1)--    # Calculate screen coordinates-    if relative:-        screen_x = left + int(float(x) * width)-        screen_y = top + int(float(y) * height)-    else:-        screen_x = left + int(x)-        screen_y = top + int(y)--    # Convert to absolute coordinates for SendInput (0-65535 range)-    screen_w = user32.GetSystemMetrics(0)  # SM_CXSCREEN (primary monitor)-    screen_h = user32.GetSystemMetrics(1)  # SM_CYSCREEN-    abs_x = int(screen_x * 65535 / screen_w)-    abs_y = int(screen_y * 65535 / screen_h)--    # Move mouse-    move = INPUT()-    move.type = INPUT_MOUSE-    move.union.mi.dx = abs_x-    move.union.mi.dy = abs_y-    move.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE--    # Click down-    down = INPUT()-    down.type = INPUT_MOUSE-    down.union.mi.dx = abs_x-    down.union.mi.dy = abs_y-    down.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTDOWN--    # Click up-    up = INPUT()-    up.type = INPUT_MOUSE-    up.union.mi.dx = abs_x-    up.union.mi.dy = abs_y-    up.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTUP--    _send_input(move, down, up)--    return {-        "success": True,-        "clickedAt": {"screenX": screen_x, "screenY": screen_y},-        "windowOffset": {-            "x": screen_x - left,-            "y": screen_y - top,-        },-        "relative": relative,-    }---# --- Keyboard -----def send_key_to_fusion(key: str, hwnd=None) -> dict:-    """Send a key to Fusion 360 (or a specific dialog) via SendInput.--    Args:-        key: One of: "enter", "escape", "tab", "space", "up", "down",-             "left", "right", "f1"-"f12", "backspace", "delete",-             "home", "end", "pageup", "pagedown", or a single character.-        hwnd: Optional HWND to target. If omitted, targets the main Fusion window.-    """-    if hwnd:-        hwnd = int(hwnd)-        if not user32.IsWindow(hwnd):-            return {-                "success": False,-                "error": f"HWND {hwnd} is not a valid window.",-                "_hint": "HWNDs change when windows close/reopen. Call fusion_get_window_info to get fresh HWNDs, then retry.",-            }-    else:-        hwnd = _find_fusion_hwnd()-        if not hwnd:-            return {-                "success": False,-                "error": "Fusion 360 window not found.",-                "_hint": "Call fusion_start to start Fusion 360, then retry.",-            }--    # Bring to front-    user32.SetForegroundWindow(hwnd)-    time.sleep(0.1)--    key_lower = key.lower().strip()-    vk = VK_MAP.get(key_lower)--    if vk is None:-        if len(key) == 1:-            # Single character: use VkKeyScanW to get the virtual key code-            vk_scan = user32.VkKeyScanW(ord(key))-            vk = vk_scan & 0xFF-            shift = (vk_scan >> 8) & 0x01-            if vk == 0xFF:-                return {"success": False, "error": f"Cannot map character '{key}' to a virtual key."}--            inputs = []-            # Press shift if needed-            if shift:-                s_down = INPUT()-                s_down.type = INPUT_KEYBOARD-                s_down.union.ki.wVk = 0x10  # VK_SHIFT-                inputs.append(s_down)--            # Key down-            kd = INPUT()-            kd.type = INPUT_KEYBOARD-            kd.union.ki.wVk = vk-            inputs.append(kd)--            # Key up-            ku = INPUT()-            ku.type = INPUT_KEYBOARD-            ku.union.ki.wVk = vk-            ku.union.ki.dwFlags = KEYEVENTF_KEYUP-            inputs.append(ku)--            # Release shift if needed-            if shift:-                s_up = INPUT()-                s_up.type = INPUT_KEYBOARD-                s_up.union.ki.wVk = 0x10-                s_up.union.ki.dwFlags = KEYEVENTF_KEYUP-                inputs.append(s_up)--            _send_input(*inputs)-            return {"success": True, "key": key, "vk": vk, "shift": bool(shift)}-        else:-            return {"success": False, "error": f"Unknown key: '{key}'. Use a named key or single character."}--    # Named key: simple down + up-    kd = INPUT()-    kd.type = INPUT_KEYBOARD-    kd.union.ki.wVk = vk--    ku = INPUT()-    ku.type = INPUT_KEYBOARD-    ku.union.ki.wVk = vk-    ku.union.ki.dwFlags = KEYEVENTF_KEYUP--    _send_input(kd, ku)-    return {"success": True, "key": key_lower, "vk": vk}---def close_window(hwnd: int) -> dict:-    """Close a window by sending WM_CLOSE via PostMessage.--    Unlike Escape (which many Fusion dialogs ignore — e.g. Recovered Documents),-    WM_CLOSE is the standard Windows mechanism for closing a window and is-    handled by virtually all Qt dialogs.--    This does NOT force-kill the window — the dialog can still prompt for-    confirmation. It's equivalent to clicking the X button in the title bar.--    Args:-        hwnd: HWND of the window to close.-    """-    hwnd = int(hwnd)-    if not user32.IsWindow(hwnd):-        return {-            "success": False,-            "error": f"HWND {hwnd} is not a valid window.",-        }--    WM_CLOSE = 0x0010-    user32.PostMessageW(hwnd, WM_CLOSE, 0, 0)-    return {"success": True, "hwnd": hwnd, "action": "WM_CLOSE sent"}---# --- Window info -----def get_fusion_window_info() -> dict:-    """Get Fusion 360 window info: hwnd, title, rect, and any Qt dialog windows.--    Returns the main Fusion window details plus a list of other Qt windows-    that may be Fusion dialogs (file pickers, recovery prompts, etc.).-    """-    hwnd = _find_fusion_hwnd()-    if not hwnd:-        return {-            "success": False,-            "error": "Fusion 360 window not found.",-            "_hint": "Call fusion_start to start Fusion 360, then retry.",-        }--    # Get title-    length = user32.GetWindowTextLengthW(hwnd)-    title = ""-    if length > 0:-        buf = ctypes.create_unicode_buffer(length + 1)-        user32.GetWindowTextW(hwnd, buf, length + 1)-        title = buf.value--    left, top, right, bottom = _get_window_rect(hwnd)--    # Find potential dialog windows-    dialogs = _find_qt_dialog_windows()--    # A real modal dialog DISABLES its owner window; docked panels (Browser,-    # Timeline) leave it ENABLED. This is the clean signal that separates an-    # actual blocking modal from a tool window that merely shares the "Fusion360"-    # title and slips past the size filter. Callers use it to suppress-    # false-positive "dialogs" when nothing is actually blocking.-    try:-        main_enabled = bool(user32.IsWindowEnabled(hwnd))-    except Exception:-        main_enabled = True--    return {-        "success": True,-        "hwnd": hwnd,-        "title": title,-        "mainEnabled": main_enabled,-        "rect": {-            "left": left, "top": top,-            "right": right, "bottom": bottom,-            "width": right - left,-            "height": bottom - top,-        },-        "dialogs": dialogs,-    }+"""Win32-based tools for interacting with Fusion 360's UI.++Provides screenshot, click, and keyboard input functions that work with+CEF (Chromium Embedded Framework) modal dialogs inside Fusion 360.+These dialogs can't be accessed via standard Win32 child window enumeration,+so we use screen-DC capture (BitBlt) instead of PrintWindow, and SendInput+instead of PostMessage for input.+"""++import ctypes+import ctypes.wintypes+import os+import struct+import time++# Guard windll so the module imports on non-Windows hosts (the bridge must boot+# + serve /status anywhere); these Win32 paths are only reached on Windows.+_have_windll = hasattr(ctypes, "windll")+user32 = ctypes.windll.user32 if _have_windll else None+gdi32 = ctypes.windll.gdi32 if _have_windll else None+kernel32 = ctypes.windll.kernel32 if _have_windll else None++# ⚠️ HWND TRUNCATION BUG (fixed 2026-07-08, cost hours): with NO argtypes declared, ctypes+# defaults every function arg + return to 32-bit `c_int`, which TRUNCATES 64-bit HWND handles.+# So `_find_fusion_hwnd`'s GetWindowTextW/IsWindowVisible ran on a garbage handle and matched+# NOTHING - get_fusion_window_info returned mainHwnd:None even though Fusion's window ("Untitled+# - Autodesk Fusion") plainly existed, which blinded all background driving. (Verified live: the+# window's real hwnd via `(Get-Process Fusion360).MainWindowHandle` worked fine.) Declaring HWND+# argtypes/restypes makes ctypes marshal the full 64-bit handle. If the finder ever returns 0/None+# while Fusion IS running, the process-handle fallback in _find_fusion_hwnd covers it.+if _have_windll:+    _W = ctypes.wintypes+    for _fn, _arg, _ret in (+        ("IsWindowVisible",       [_W.HWND],                              ctypes.c_bool),+        ("IsWindowEnabled",       [_W.HWND],                              ctypes.c_bool),+        ("GetWindowTextLengthW",  [_W.HWND],                              ctypes.c_int),+        ("GetWindowTextW",        [_W.HWND, _W.LPWSTR, ctypes.c_int],     ctypes.c_int),+        ("GetClassNameW",         [_W.HWND, _W.LPWSTR, ctypes.c_int],     ctypes.c_int),+        ("GetWindow",             [_W.HWND, ctypes.c_uint],               _W.HWND),+        ("GetParent",             [_W.HWND],                              _W.HWND),+        ("IsWindow",              [_W.HWND],                              ctypes.c_bool),+    ):+        try:+            _f = getattr(user32, _fn); _f.argtypes = _arg; _f.restype = _ret+        except Exception:+            pass++# Enable DPI awareness so GetWindowRect returns physical pixel coordinates+# (not logical/scaled). Without this, screenshots are cropped on HiDPI displays.+try:+    ctypes.windll.shcore.SetProcessDpiAwareness(2)  # PROCESS_PER_MONITOR_DPI_AWARE+except Exception:+    try:+        user32.SetProcessDPIAware()  # Fallback for older Windows+    except Exception:+        pass++# --- Constants ---++SRCCOPY = 0x00CC0020+DIB_RGB_COLORS = 0+BI_RGB = 0+SM_CXVIRTUALSCREEN = 78+SM_CYVIRTUALSCREEN = 79++INPUT_MOUSE = 0+INPUT_KEYBOARD = 1+MOUSEEVENTF_ABSOLUTE = 0x8000+MOUSEEVENTF_MOVE = 0x0001+MOUSEEVENTF_LEFTDOWN = 0x0002+MOUSEEVENTF_LEFTUP = 0x0004+KEYEVENTF_KEYUP = 0x0002++# Virtual key codes+VK_MAP = {+    "enter": 0x0D, "return": 0x0D,+    "escape": 0x1B, "esc": 0x1B,+    "tab": 0x09,+    "space": 0x20,+    "up": 0x26, "down": 0x28, "left": 0x25, "right": 0x27,+    "backspace": 0x08, "delete": 0x2E,+    "home": 0x24, "end": 0x23,+    "pageup": 0x21, "pagedown": 0x22,+    "f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73,+    "f5": 0x74, "f6": 0x75, "f7": 0x76, "f8": 0x77,+    "f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B,+}++SCREENSHOT_DIR = "C:/tmp/conduit-screenshots"++# Keep callback references alive to prevent GC+_callbacks = []+++# --- SendInput structures ---++class MOUSEINPUT(ctypes.Structure):+    _fields_ = [+        ("dx", ctypes.wintypes.LONG),+        ("dy", ctypes.wintypes.LONG),+        ("mouseData", ctypes.wintypes.DWORD),+        ("dwFlags", ctypes.wintypes.DWORD),+        ("time", ctypes.wintypes.DWORD),+        ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)),+    ]+++class KEYBDINPUT(ctypes.Structure):+    _fields_ = [+        ("wVk", ctypes.wintypes.WORD),+        ("wScan", ctypes.wintypes.WORD),+        ("dwFlags", ctypes.wintypes.DWORD),+        ("time", ctypes.wintypes.DWORD),+        ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)),+    ]+++class _INPUT_UNION(ctypes.Union):+    _fields_ = [+        ("mi", MOUSEINPUT),+        ("ki", KEYBDINPUT),+    ]+++class INPUT(ctypes.Structure):+    _fields_ = [+        ("type", ctypes.wintypes.DWORD),+        ("union", _INPUT_UNION),+    ]+++def _send_input(*inputs):+    """Send one or more INPUT structures via SendInput."""+    n = len(inputs)+    arr = (INPUT * n)(*inputs)+    user32.SendInput(n, ctypes.pointer(arr), ctypes.sizeof(INPUT))+++# --- Window finding ---++def _find_fusion_hwnd() -> int:+    """Find Fusion 360's main window by title containing 'Autodesk Fusion'."""+    result = [0]+    WNDENUMPROC = ctypes.WINFUNCTYPE(+        ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+    )++    def callback(hwnd, _lparam):+        if user32.IsWindowVisible(hwnd):+            length = user32.GetWindowTextLengthW(hwnd)+            if length > 0:+                buf = ctypes.create_unicode_buffer(length + 1)+                user32.GetWindowTextW(hwnd, buf, length + 1)+                if "Autodesk Fusion" in buf.value:+                    result[0] = hwnd+                    return False+        return True++    cb = WNDENUMPROC(callback)+    _callbacks.append(cb)+    user32.EnumWindows(cb, 0)+    _callbacks.remove(cb)+    return result[0]+++def _find_qt_dialog_windows() -> list:+    """Find visible Qt windows that may be Fusion 360 dialogs.++    Returns list of dicts with hwnd, title, rect, className.+    Excludes the main Fusion window (which has 'Autodesk Fusion' in title).+    """+    results = []+    WNDENUMPROC = ctypes.WINFUNCTYPE(+        ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+    )++    def callback(hwnd, _lparam):+        if user32.IsWindowVisible(hwnd):+            cls_buf = ctypes.create_unicode_buffer(256)+            user32.GetClassNameW(hwnd, cls_buf, 256)+            class_name = cls_buf.value++            length = user32.GetWindowTextLengthW(hwnd)+            title = ""+            if length > 0:+                buf = ctypes.create_unicode_buffer(length + 1)+                user32.GetWindowTextW(hwnd, buf, length + 1)+                title = buf.value++            # Qt windows belonging to Fusion (not the main window)+            if "Qt" in class_name and "Autodesk Fusion" not in title:+                # Some Fusion dialogs (including the "What do you want to+                # design?" startup picker) use Qt Tool window classes like+                # "Qt655QWindowToolSaveBits".  We used to skip ALL Tool+                # windows (they're usually docked panels — Browser, Timeline).+                # But that caused the startup picker to be invisible to+                # dialog detection. Now we only skip SMALL Tool windows+                # (docked panels are narrow sidebar panels, typically+                # <400px wide).  Large Tool windows (>400px wide AND >300px+                # tall) are kept — they're blocking dialogs like the picker.+                if "Tool" in class_name:+                    rect = ctypes.wintypes.RECT()+                    user32.GetWindowRect(hwnd, ctypes.byref(rect))+                    w = rect.right - rect.left+                    h = rect.bottom - rect.top+                    if w < 400 or h < 300:+                        return True  # small tool panel, skip+                    # ASPECT GUARD (2026-07-23): the size test alone is not enough. Fusion's+                    # docked side panels (BROWSER, Timeline, Comments) are TALL and NARROW and+                    # sail past 400x300 - the BROWSER measured 450x1194 and got reported as a+                    # blocking dialog on an idle Fusion, which makes a driving AI believe it is+                    # blocked when nothing is wrong. Real blocking dialogs (the startup picker,+                    # licensing, recovery) are landscape or roughly square. So: anything markedly+                    # taller than it is wide is a docked panel, not a dialog.+                    if h > w * 1.5:+                        return True  # tall narrow docked panel, skip++                rect = ctypes.wintypes.RECT()+                user32.GetWindowRect(hwnd, ctypes.byref(rect))+                results.append({+                    "hwnd": hwnd,+                    "title": title,+                    "className": class_name,+                    "rect": {+                        "left": rect.left, "top": rect.top,+                        "right": rect.right, "bottom": rect.bottom,+                        "width": rect.right - rect.left,+                        "height": rect.bottom - rect.top,+                    },+                })+        return True++    cb = WNDENUMPROC(callback)+    _callbacks.append(cb)+    user32.EnumWindows(cb, 0)+    _callbacks.remove(cb)+    return results+++def _get_window_rect(hwnd: int) -> tuple:+    """Get window rect as (left, top, right, bottom)."""+    rect = ctypes.wintypes.RECT()+    user32.GetWindowRect(hwnd, ctypes.byref(rect))+    return (rect.left, rect.top, rect.right, rect.bottom)+++# --- Screenshot ---++def _write_bmp(path: str, width: int, height: int, pixel_data: bytes):+    """Write a BMP file from raw BGR pixel data (bottom-up row order).++    pixel_data must be width*height*4 bytes (32-bit BGRA from GetDIBits).+    We write a 24-bit BMP (strip alpha) for broad compatibility.+    """+    row_size_24 = (width * 3 + 3) & ~3  # rows padded to 4 bytes+    pixel_size_24 = row_size_24 * height+    file_size = 14 + 40 + pixel_size_24  # BMP header + DIB header + pixels++    with open(path, "wb") as f:+        # BMP file header (14 bytes)+        f.write(b"BM")+        f.write(struct.pack("<I", file_size))+        f.write(struct.pack("<HH", 0, 0))  # reserved+        f.write(struct.pack("<I", 14 + 40))  # offset to pixel data++        # DIB header (BITMAPINFOHEADER, 40 bytes)+        f.write(struct.pack("<I", 40))  # header size+        f.write(struct.pack("<i", width))+        f.write(struct.pack("<i", height))  # positive = bottom-up+        f.write(struct.pack("<HH", 1, 24))  # planes, bpp+        f.write(struct.pack("<I", BI_RGB))  # compression+        f.write(struct.pack("<I", pixel_size_24))+        f.write(struct.pack("<ii", 2835, 2835))  # pixels/meter (~72 DPI)+        f.write(struct.pack("<II", 0, 0))  # colors++        # Write pixel rows (convert 32-bit BGRA to 24-bit BGR, bottom-up)+        src_row_size = width * 4+        for y in range(height):+            row_start = y * src_row_size+            row_24 = bytearray()+            for x in range(width):+                px = row_start + x * 4+                row_24 += pixel_data[px:px + 3]  # BGR (skip alpha)+            # Pad row to 4-byte boundary+            padding = row_size_24 - len(row_24)+            if padding > 0:+                row_24 += b"\x00" * padding+            f.write(bytes(row_24))+++PW_RENDERFULLCONTENT = 0x00000002+++def screenshot_hwnd(hwnd: int, label: str = "") -> dict:+    """Capture any window by HWND. Returns {success, savedTo, sizeKB}."""+    left, top, right, bottom = _get_window_rect(hwnd)+    width = right - left+    height = bottom - top++    if width <= 0 or height <= 0:+        return {"success": False, "error": f"Invalid rect for hwnd {hwnd}"}++    hdc_screen = user32.GetDC(hwnd)+    hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)+    hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, width, height)+    old_bmp = gdi32.SelectObject(hdc_mem, hbmp)+    user32.PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT)++    class BITMAPINFOHEADER(ctypes.Structure):+        _fields_ = [+            ("biSize", ctypes.wintypes.DWORD),+            ("biWidth", ctypes.wintypes.LONG),+            ("biHeight", ctypes.wintypes.LONG),+            ("biPlanes", ctypes.wintypes.WORD),+            ("biBitCount", ctypes.wintypes.WORD),+            ("biCompression", ctypes.wintypes.DWORD),+            ("biSizeImage", ctypes.wintypes.DWORD),+            ("biXPelsPerMeter", ctypes.wintypes.LONG),+            ("biYPelsPerMeter", ctypes.wintypes.LONG),+            ("biClrUsed", ctypes.wintypes.DWORD),+            ("biClrImportant", ctypes.wintypes.DWORD),+        ]++    bmi = BITMAPINFOHEADER()+    bmi.biSize = ctypes.sizeof(BITMAPINFOHEADER)+    bmi.biWidth = width+    bmi.biHeight = height+    bmi.biPlanes = 1+    bmi.biBitCount = 32+    bmi.biCompression = BI_RGB++    buf_size = width * height * 4+    pixel_buf = ctypes.create_string_buffer(buf_size)+    gdi32.GetDIBits(hdc_mem, hbmp, 0, height, pixel_buf, ctypes.byref(bmi), DIB_RGB_COLORS)++    gdi32.SelectObject(hdc_mem, old_bmp)+    gdi32.DeleteObject(hbmp)+    gdi32.DeleteDC(hdc_mem)+    user32.ReleaseDC(hwnd, hdc_screen)++    os.makedirs(SCREENSHOT_DIR, exist_ok=True)+    timestamp = int(time.time() * 1000)+    suffix = f"-{label}" if label else ""++    try:+        from PIL import Image+        img = Image.frombytes("RGBA", (width, height), pixel_buf.raw, "raw", "BGRA")+        img = img.transpose(Image.FLIP_TOP_BOTTOM)+        MAX_DIM = 1568+        if max(width, height) > MAX_DIM:+            ratio = MAX_DIM / max(width, height)+            img = img.resize((int(width * ratio), int(height * ratio)), Image.LANCZOS)+        try:+            path = os.path.join(SCREENSHOT_DIR, f"fusion{suffix}-{timestamp}.webp")+            img.save(path, "WEBP", lossless=True, quality=100)+        except Exception:+            path = os.path.join(SCREENSHOT_DIR, f"fusion{suffix}-{timestamp}.png")+            img.save(path, "PNG", optimize=True)+    except ImportError:+        path = os.path.join(SCREENSHOT_DIR, f"fusion{suffix}-{timestamp}.bmp")+        _write_bmp(path, width, height, pixel_buf.raw)++    size_kb = os.path.getsize(path) / 1024+    return {+        "success": True,+        "savedTo": path.replace("\\", "/"),+        "sizeKB": round(size_kb, 1),+    }+++def screenshot_fusion_window(use_bitblt: bool = False) -> dict:+    """Capture the Fusion 360 window.++    By default uses PrintWindow with PW_RENDERFULLCONTENT (works without+    foreground, captures Qt content but NOT CEF modal overlays).++    If use_bitblt=True, brings window to foreground and uses BitBlt from+    screen DC (captures CEF overlays but requires foreground).++    Saves PNG to C:/tmp/conduit-screenshots/ and returns the path.+    """+    hwnd = _find_fusion_hwnd()+    if not hwnd:+        return {+            "success": False,+            "error": "Fusion 360 window not found (no window with 'Autodesk Fusion' in title).",+            "_hint": "Call fusion_start to start Fusion 360 and wait for the add-in to be ready, then retry.",+        }++    # Get window rect+    left, top, right, bottom = _get_window_rect(hwnd)+    width = right - left+    height = bottom - top++    if width <= 0 or height <= 0:+        return {"success": False, "error": f"Invalid window rect: {left},{top},{right},{bottom}"}++    if use_bitblt:+        # Screen DC capture — needs foreground+        SW_RESTORE = 9+        user32.ShowWindow(hwnd, SW_RESTORE)+        user32.SetForegroundWindow(hwnd)+        time.sleep(1.0)++        hdc_screen = user32.GetDC(0)+        hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)+        hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, width, height)+        old_bmp = gdi32.SelectObject(hdc_mem, hbmp)+        gdi32.BitBlt(hdc_mem, 0, 0, width, height, hdc_screen, left, top, SRCCOPY)+    else:+        # PrintWindow capture — works without foreground+        hdc_screen = user32.GetDC(hwnd)+        hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)+        hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, width, height)+        old_bmp = gdi32.SelectObject(hdc_mem, hbmp)+        user32.PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT)++    # Read pixel data via GetDIBits+    class BITMAPINFOHEADER(ctypes.Structure):+        _fields_ = [+            ("biSize", ctypes.wintypes.DWORD),+            ("biWidth", ctypes.wintypes.LONG),+            ("biHeight", ctypes.wintypes.LONG),+            ("biPlanes", ctypes.wintypes.WORD),+            ("biBitCount", ctypes.wintypes.WORD),+            ("biCompression", ctypes.wintypes.DWORD),+            ("biSizeImage", ctypes.wintypes.DWORD),+            ("biXPelsPerMeter", ctypes.wintypes.LONG),+            ("biYPelsPerMeter", ctypes.wintypes.LONG),+            ("biClrUsed", ctypes.wintypes.DWORD),+            ("biClrImportant", ctypes.wintypes.DWORD),+        ]++    bmi = BITMAPINFOHEADER()+    bmi.biSize = ctypes.sizeof(BITMAPINFOHEADER)+    bmi.biWidth = width+    bmi.biHeight = height  # positive = bottom-up (standard BMP order)+    bmi.biPlanes = 1+    bmi.biBitCount = 32+    bmi.biCompression = BI_RGB++    buf_size = width * height * 4+    pixel_buf = ctypes.create_string_buffer(buf_size)+    gdi32.GetDIBits(hdc_mem, hbmp, 0, height, pixel_buf, ctypes.byref(bmi), DIB_RGB_COLORS)++    # Clean up GDI resources+    gdi32.SelectObject(hdc_mem, old_bmp)+    gdi32.DeleteObject(hbmp)+    gdi32.DeleteDC(hdc_mem)+    if use_bitblt:+        user32.ReleaseDC(0, hdc_screen)+    else:+        user32.ReleaseDC(hwnd, hdc_screen)++    # Save to file as PNG (try PIL first, fall back to BMP)+    os.makedirs(SCREENSHOT_DIR, exist_ok=True)+    timestamp = int(time.time() * 1000)++    try:+        from PIL import Image+        # pixel_buf is BGRA bottom-up; convert to RGBA top-down+        img = Image.frombytes("RGBA", (width, height), pixel_buf.raw, "raw", "BGRA")+        img = img.transpose(Image.FLIP_TOP_BOTTOM)++        # Downscale to max 1568px on longest side (saves tokens for AI vision)+        MAX_DIM = 1568+        if max(width, height) > MAX_DIM:+            ratio = MAX_DIM / max(width, height)+            new_size = (int(width * ratio), int(height * ratio))+            img = img.resize(new_size, Image.LANCZOS)++        # Try WebP first (much smaller than PNG for screenshots)+        try:+            path = os.path.join(SCREENSHOT_DIR, f"fusion-{timestamp}.webp")+            img.save(path, "WEBP", lossless=True, quality=100)+        except Exception:+            # Fall back to PNG if WebP not available+            path = os.path.join(SCREENSHOT_DIR, f"fusion-{timestamp}.png")+            img.save(path, "PNG", optimize=True)+    except ImportError:+        # No PIL — save as BMP (readable by most tools, but not Claude Read)+        path = os.path.join(SCREENSHOT_DIR, f"fusion-{timestamp}.bmp")+        _write_bmp(path, width, height, pixel_buf.raw)++    size_kb = os.path.getsize(path) / 1024+    return {+        "success": True,+        "savedTo": path.replace("\\", "/"),+        "sizeKB": round(size_kb, 1),+        "dimensions": {"width": width, "height": height},+    }+++# --- Click ---++def click_fusion(x, y, relative=True, hwnd=None) -> dict:+    """Click at coordinates in a window (Fusion main window or any dialog).++    Args:+        x: X coordinate. If relative=True, a float 0.0-1.0 (percentage of window width).+           If relative=False, pixel offset from window's top-left corner.+        y: Y coordinate. Same convention as x but for height.+        relative: If True (default), x/y are percentages. If False, pixel offsets.+        hwnd: Optional HWND to click on. If omitted, finds the main Fusion window.+              Use this to click buttons inside Qt dialogs by passing the dialog's HWND.++    Uses SendInput (NOT PostMessage) because CEF doesn't respond to WM messages.+    """+    if hwnd:+        hwnd = int(hwnd)+        # Validate the HWND is a real window+        if not user32.IsWindow(hwnd):+            return {+                "success": False,+                "error": f"HWND {hwnd} is not a valid window.",+                "_hint": "HWNDs change when windows close/reopen. Call fusion_get_window_info to get fresh HWNDs, then retry.",+            }+    else:+        hwnd = _find_fusion_hwnd()+        if not hwnd:+            return {+                "success": False,+                "error": "Fusion 360 window not found.",+                "_hint": "Call fusion_start to start Fusion 360, then retry.",+            }++    left, top, right, bottom = _get_window_rect(hwnd)+    width = right - left+    height = bottom - top++    # Bring to front+    user32.SetForegroundWindow(hwnd)+    time.sleep(0.1)++    # Calculate screen coordinates+    if relative:+        screen_x = left + int(float(x) * width)+        screen_y = top + int(float(y) * height)+    else:+        screen_x = left + int(x)+        screen_y = top + int(y)++    # Convert to absolute coordinates for SendInput (0-65535 range)+    screen_w = user32.GetSystemMetrics(0)  # SM_CXSCREEN (primary monitor)+    screen_h = user32.GetSystemMetrics(1)  # SM_CYSCREEN+    abs_x = int(screen_x * 65535 / screen_w)+    abs_y = int(screen_y * 65535 / screen_h)++    # Move mouse+    move = INPUT()+    move.type = INPUT_MOUSE+    move.union.mi.dx = abs_x+    move.union.mi.dy = abs_y+    move.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE++    # Click down+    down = INPUT()+    down.type = INPUT_MOUSE+    down.union.mi.dx = abs_x+    down.union.mi.dy = abs_y+    down.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTDOWN++    # Click up+    up = INPUT()+    up.type = INPUT_MOUSE+    up.union.mi.dx = abs_x+    up.union.mi.dy = abs_y+    up.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTUP++    _send_input(move, down, up)++    return {+        "success": True,+        "clickedAt": {"screenX": screen_x, "screenY": screen_y},+        "windowOffset": {+            "x": screen_x - left,+            "y": screen_y - top,+        },+        "relative": relative,+    }+++# --- Keyboard ---++def send_key_to_fusion(key: str, hwnd=None) -> dict:+    """Send a key to Fusion 360 (or a specific dialog) via SendInput.++    Args:+        key: One of: "enter", "escape", "tab", "space", "up", "down",+             "left", "right", "f1"-"f12", "backspace", "delete",+             "home", "end", "pageup", "pagedown", or a single character.+        hwnd: Optional HWND to target. If omitted, targets the main Fusion window.+    """+    if hwnd:+        hwnd = int(hwnd)+        if not user32.IsWindow(hwnd):+            return {+                "success": False,+                "error": f"HWND {hwnd} is not a valid window.",+                "_hint": "HWNDs change when windows close/reopen. Call fusion_get_window_info to get fresh HWNDs, then retry.",+            }+    else:+        hwnd = _find_fusion_hwnd()+        if not hwnd:+            return {+                "success": False,+                "error": "Fusion 360 window not found.",+                "_hint": "Call fusion_start to start Fusion 360, then retry.",+            }++    # Bring to front+    user32.SetForegroundWindow(hwnd)+    time.sleep(0.1)++    key_lower = key.lower().strip()+    vk = VK_MAP.get(key_lower)++    if vk is None:+        if len(key) == 1:+            # Single character: use VkKeyScanW to get the virtual key code+            vk_scan = user32.VkKeyScanW(ord(key))+            vk = vk_scan & 0xFF+            shift = (vk_scan >> 8) & 0x01+            if vk == 0xFF:+                return {"success": False, "error": f"Cannot map character '{key}' to a virtual key."}++            inputs = []+            # Press shift if needed+            if shift:+                s_down = INPUT()+                s_down.type = INPUT_KEYBOARD+                s_down.union.ki.wVk = 0x10  # VK_SHIFT+                inputs.append(s_down)++            # Key down+            kd = INPUT()+            kd.type = INPUT_KEYBOARD+            kd.union.ki.wVk = vk+            inputs.append(kd)++            # Key up+            ku = INPUT()+            ku.type = INPUT_KEYBOARD+            ku.union.ki.wVk = vk+            ku.union.ki.dwFlags = KEYEVENTF_KEYUP+            inputs.append(ku)++            # Release shift if needed+            if shift:+                s_up = INPUT()+                s_up.type = INPUT_KEYBOARD+                s_up.union.ki.wVk = 0x10+                s_up.union.ki.dwFlags = KEYEVENTF_KEYUP+                inputs.append(s_up)++            _send_input(*inputs)+            return {"success": True, "key": key, "vk": vk, "shift": bool(shift)}+        else:+            return {"success": False, "error": f"Unknown key: '{key}'. Use a named key or single character."}++    # Named key: simple down + up+    kd = INPUT()+    kd.type = INPUT_KEYBOARD+    kd.union.ki.wVk = vk++    ku = INPUT()+    ku.type = INPUT_KEYBOARD+    ku.union.ki.wVk = vk+    ku.union.ki.dwFlags = KEYEVENTF_KEYUP++    _send_input(kd, ku)+    return {"success": True, "key": key_lower, "vk": vk}+++def close_window(hwnd: int) -> dict:+    """Close a window by sending WM_CLOSE via PostMessage.++    Unlike Escape (which many Fusion dialogs ignore — e.g. Recovered Documents),+    WM_CLOSE is the standard Windows mechanism for closing a window and is+    handled by virtually all Qt dialogs.++    This does NOT force-kill the window — the dialog can still prompt for+    confirmation. It's equivalent to clicking the X button in the title bar.++    Args:+        hwnd: HWND of the window to close.+    """+    hwnd = int(hwnd)+    if not user32.IsWindow(hwnd):+        return {+            "success": False,+            "error": f"HWND {hwnd} is not a valid window.",+        }++    WM_CLOSE = 0x0010+    user32.PostMessageW(hwnd, WM_CLOSE, 0, 0)+    return {"success": True, "hwnd": hwnd, "action": "WM_CLOSE sent"}+++# --- Window info ---++def get_fusion_window_info() -> dict:+    """Get Fusion 360 window info: hwnd, title, rect, and any Qt dialog windows.++    Returns the main Fusion window details plus a list of other Qt windows+    that may be Fusion dialogs (file pickers, recovery prompts, etc.).+    """+    hwnd = _find_fusion_hwnd()+    if not hwnd:+        return {+            "success": False,+            "error": "Fusion 360 window not found.",+            "_hint": "Call fusion_start to start Fusion 360, then retry.",+        }++    # Get title+    length = user32.GetWindowTextLengthW(hwnd)+    title = ""+    if length > 0:+        buf = ctypes.create_unicode_buffer(length + 1)+        user32.GetWindowTextW(hwnd, buf, length + 1)+        title = buf.value++    left, top, right, bottom = _get_window_rect(hwnd)++    # Find potential dialog windows+    dialogs = _find_qt_dialog_windows()++    # A real modal dialog DISABLES its owner window; docked panels (Browser,+    # Timeline) leave it ENABLED. This is the clean signal that separates an+    # actual blocking modal from a tool window that merely shares the "Fusion360"+    # title and slips past the size filter. Callers use it to suppress+    # false-positive "dialogs" when nothing is actually blocking.+    try:+        main_enabled = bool(user32.IsWindowEnabled(hwnd))+    except Exception:+        main_enabled = True++    return {+        "success": True,+        "hwnd": hwnd,+        "title": title,+        "mainEnabled": main_enabled,+        "rect": {+            "left": left, "top": top,+            "right": right, "bottom": bottom,+            "width": right - left,+            "height": bottom - top,+        },+        "dialogs": dialogs,+    }+--- a/describe.py+++ b/describe.py@@ -1,252 +1,258 @@-"""fusion_describe — self-describe the bridge's verb surface for Adom Desktop.--AD v1.9.23+ added a Verbs tab to each bridge's detail pop-out (lists verbs,-expands each for its I/O + timeout + status schema, with an inline runner). AD-does NOT hardcode a cloud-owned bridge's verbs, so the bridge must self-describe.-AD POSTs {command:"describe", args:{}} and caches the result at ~/.adom/bridge-verbs/.--Each entry: name, summary, input, output, timeoutSeconds, statusVerb, longRunning,-example. Keep this in step with the actual handlers as verbs are added.-"""--_STATUS = "fusion_get_app_state"--# Compact source table: name -> (summary, input, output, timeout, statusVerb, longRunning, example)-_T = [-    # ── Lifecycle ──────────────────────────────────────────────────────────-    ("fusion_start", "Launch Fusion 360 (if needed) and wait for the AdomBridge add-in to be ready.",-     {}, {"success": "bool", "addinReady": "bool", "status": "str"}, 150, None, True, {}),-    ("fusion_stop", "Gracefully STOP Fusion 360: close docs cleanly + WM_CLOSE, then wait. NO force-kill - the clean opposite of fusion_start. If a window will not close it returns failure; use fusion_kill then.",-     {"skipCleanClose": "optional bool"}, {"success": "bool", "output": "str"}, 60, None, False, {}),-    ("fusion_kill", "Force-KILL Fusion 360 (taskkill /F) - the desperate path for when fusion_stop cannot close it (stuck modal / wedged process). Closes docs cleanly first when reachable.",-     {"skipCleanClose": "optional bool"}, {"success": "bool", "output": "str"}, 30, None, False, {}),-    ("fusion_close_window", "Close the active Fusion document/window.",-     {"name": "optional str (default: active)"}, {"success": "bool"}, 30, None, False, {}),-    ("fusion_get_app_state", "Current app state: running, active document, workspace, isElectronics. The poll/status verb for long-running calls.",-     {}, {"activeDocument": "str", "activeWorkspace": "str", "isElectronics": "bool"}, 20, None, False, {}),-    ("fusion_readiness", "FAST readiness check (does NOT launch Fusion): is the Fusion host app installed + running + the bridge ready to drive? AD DETECTS Fusion (never installs it) via bridge.json 'detect', and auto-installs Python if missing (AD >=1.9.47). Call this before driving Fusion; if installed-but-not-running, fusion_start. SELF-HEALS the seat/licensing dialog (background UIA + screenshot-verify) whenever it detects one. Pairs with AD's bridge_readiness.",-     {}, {"installed": "bool", "running": "bool", "ready": "bool", "hostApp": "str", "bridgeVersion": "str"}, 15, None, False, {}),--    ("fusion_new_electronics_from_eagle", "Import a legacy EAGLE .sch (+ paired .brd) into a NEW Fusion electronics design so the parts INSTANTIATE (schematic + populated board, lands in the PCB editor). The ONLY way to author a board from EAGLE source: Document.newDesignFromLocal opens the schematic editor but does NOT instantiate parts, and the Fusion-native .fsch/.fbrd container is opaque binary you can't build offline. This fires Fusion's own ImportSCHAndBRDCmd and drives BOTH native Open dialogs (the .sch then the .brd) in the BACKGROUND via desktop_ui_click by accessible name (no foreground). Stage both files to Windows first (send_files). After: fusion_show_2d_board, RATSNEST + 'AUTO ;' to autoroute, fusion_show_3d_board for 3D.",-     {"schPath": "required str (Windows path to .sch)", "brdPath": "optional str (defaults to sibling .brd)"},-     {"success": "bool", "imported": "list"}, 200, None, False,-     {"schPath": "C:/Users/me/adom-lib/MyBoard.sch", "brdPath": "C:/Users/me/adom-lib/MyBoard.brd"}),--    ("fusion_notify_owner", "LAST-RESORT human escalation: toast the user's MAIN computer (fans out cross-AD to every peer on the relay, so it reaches them even when this bridge runs on an unattended VM). ONLY for true human walls - password/2FA entry, UAC elevation, a physical step. Exhaust programmatic options first (UIA background clicks, seat auto-resolve, warm-SSO sign-in). After sending, WAIT and poll fusion_readiness; do not re-toast within a few minutes.",-     {"title": "optional str", "body": "required str", "level": "optional str info|warning|error"},-     {"success": "bool", "targets": "list"}, 20, None, False,-     {"title": "Fusion needs you", "body": "Please complete the Autodesk 2FA prompt on winvm - everything else is done."}),--    ("fusion_signin", "Sign Fusion in through the CORRECT browser profile - the fix for Fusion's dumb OAuth that fires at the OS-DEFAULT browser and lands in the wrong Autodesk account. Fusion opens its sign-in in whatever browser is default (e.g. a personal Chrome), but a user's Autodesk seat often lives in a different profile (work). This verb reads the FULL authorize URL (client_id/PKCE/state/request_id) that Fusion wrote into that wrong browser's HISTORY and re-opens it in the RIGHT profile IN THE BACKGROUND; the URL completes back to the running Fusion via the idmgr/callback + autodesk:// handoff, so the browser no longer has to be the OS default. Pass {profile:'chrome:[email protected]'} to fix + remember your Autodesk profile; else it probes each profile for a live Autodesk session (or asks). Staged: if Fusion hasn't opened the browser yet it returns stage:'click_signin' so you click Fusion's Sign In first. BEST: pass {auto:true} to run the WHOLE chain in ONE server-side call - it clicks Fusion's Sign In, polls the browser history on-box for the fresh authorize URL, and re-opens it in the target profile, all without extra remote round-trips, so it fits inside Fusion's ~2-min OAuth request expiry even on a slow/flaky relay (the manual staged path is the fallback). Fusion's request EXPIRES in ~2 min, so a stale captured URL returns stage:'stale' - restart Fusion (fusion_stop/start) for a fresh request and retry auto PROMPTLY. See the 'fusion-multiprofile-signin' skill.",-     {"profile": "optional str (chrome:<email>/edge:<email> - the profile your Autodesk account is in; remembered)", "auto": "optional bool (do click+capture+reopen in one on-box call - recommended)", "waitSec": "optional int (server-side poll budget for the fresh URL, default 32)"},-     {"stage": "str", "done": "bool", "signinWhere": "str", "signinProfile": "str", "signinProfileReason": "str", "signinWrongBrowser": "str", "signinAuthUrl": "str", "signinOpenedVia": "str (extension|extension_free)", "signinWall": "str|null (twofactor_email_code|credentials|protocol_handoff)", "notifyDelivered": "bool", "signinWallHint": "str|null", "narrate": "str"},-     120, "fusion_signin", True, {}),-    ("fusion_mcp_status", "Is Autodesk's Fusion MCP server up, and what does it expose? Autodesk + Anthropic ship a LOCAL MCP server at http://127.0.0.1:27182/mcp exposing Fusion's own text-to-CAD surface (read geometry, execute/update features, read Electronics design data). It binds LOOPBACK on the user's machine and was built for Claude Desktop running there, so a CLOUD AI cannot reach it at all - this bridge runs on that machine and proxies it for you. Returns enabled, serverInfo and the live tool list. OFF by default: if it reports enabled:false, call fusion_mcp_enable and the bridge turns it on for the user.",-     {}, {"enabled": "bool", "serverInfo": "obj", "toolCount": "int", "tools": "[{name,description}]"},-     45, "fusion_mcp_status", False, {}),-    ("fusion_mcp_enable", "Turn Autodesk's Fusion MCP server ON by driving Fusion's Preferences dialog for the user. Autodesk exposes NO API for this toggle, so the UI is the only route: this opens Preferences (Commands.Start PreferencesCommand), expands General, selects API, ticks 'Fusion MCP Server (runs locally on this device)', then clicks Apply and OK. Needs Fusion running; briefly uses the foreground and announces why with an on-screen caption. NEVER ask the user to do this by hand first - do it for them, then tell them it is handled. Note the setting is DISCARDED if Fusion is killed or restarted before Apply is clicked.",-     {}, {"enabled": "bool", "alreadyOn": "bool", "steps": "list", "serverInfo": "obj"},-     180, "fusion_mcp_status", True, {}),-    ("fusion_mcp_tools", "Full tool list from Autodesk's Fusion MCP server INCLUDING input schemas, so you can construct a valid fusion_mcp_call. These are Autodesk's tools, not this bridge's.",-     {}, {"count": "int", "tools": "list"}, 45, "fusion_mcp_status", False, {}),-    ("fusion_mcp_call", "Call any tool on Autodesk's Fusion MCP server (fusion_mcp_read, fusion_mcp_update, fusion_mcp_execute, fusion_mcp_electronics_read). The bridge handles the MCP streamable-HTTP handshake for you (initialize -> MCP-Session-Id -> notifications/initialized) and re-establishes a dropped session automatically. MCP acts on the ACTIVE Fusion document, so open one first. PREFER a native fusion_* verb when one exists - they are tested here and return richer hints - and reach for MCP for Autodesk's text-to-CAD surface.",-     {"tool": "required str (from fusion_mcp_tools)", "arguments": "optional obj (per that tool's inputSchema)", "timeout": "optional int seconds, default 120"},-     {"result": "obj", "tool": "str"}, 150, "fusion_mcp_status", True, {"tool": "fusion_mcp_read", "arguments": {"queryType": "summary"}}),-    ("fusion_mcp_resources", "List Autodesk MCP resources, or read one by uri. These are mostly the Electronics entity schemas (resource://mcp.electronics_schema_*) that describe what fusion_mcp_electronics_read can return.",-     {"uri": "optional str (omit to list)"}, {"count": "int", "resources": "[str]", "result": "obj"},-     60, "fusion_mcp_status", False, {}),-    ("fusion_prefs_open", "Open Fusion's Preferences dialog and optionally jump to a section, then hand back a SCREENSHOT + shotId you can click in. This is how you reach ANY Fusion preference: the Python API exposes only a thin slice (theme/orbit/units, debuggingPort, developer tools) and everything else, including the MCP server toggle, is UI-only. Sections: general, api, design, manufacture, electronics, render, drawing, material, graphics, network, preview features. Child sections under General are lazily rendered by Qt and never appear in the UIA tree, which is why this returns an image to click rather than control names. ALWAYS finish with fusion_prefs_close {save:true} - nothing is saved otherwise, and killing/restarting Fusion first discards the change.",-     {"section": "optional str (see knownSections in the response)"},-     {"hwnd": "int", "shotId": "str", "screenshot": "str", "knownSections": "[str]", "steps": "list"},-     120, "fusion_get_preferences", True, {"section": "api"}),-    ("fusion_prefs_close", "Apply+OK (save:true, the default) or Cancel Fusion's Preferences dialog. Settings changed via fusion_prefs_open are NOT persisted until this is called with save:true.",-     {"save": "optional bool, default true"}, {"closed": "bool", "saved": "bool"},-     60, "fusion_get_preferences", False, {"save": True}),-    ("fusion_set_auto_update", "Turn AUTOMATIC Fusion updating on/off for this user. ON BY DEFAULT, and deliberately INVISIBLE: Fusion ships updates constantly and nags with an 'Update Now / Update Later' panel plus a '14 days until Fusion must update' countdown. Every fusion_readiness call checks for that offer and, if present, the bridge clicks it IN THE BACKGROUND (UIA, no foreground, no caption) so the download starts and the newer build comes up on the next launch. The user should never have to click it. Call with no args to read the current setting; {enabled:false} opts out and the choice is STICKY (~/.adom/fusion-bridge/prefs.json) - once a user opts out, NEVER click their update prompts again. Tell the user this is handled for them rather than asking them to update.",-     {"enabled": "optional bool (omit to read current value)"},-     {"autoUpdateFusion": "bool", "narrate": "str"},-     30, "fusion_readiness", True, {"enabled": True}),-    ("fusion_signin_2fa", "Submit Autodesk's emailed 6-DIGIT verification code to finish a Fusion sign-in, typed IN THE BACKGROUND (UIA SetValue + Invoke - never steals the user's foreground or moves their cursor). Exists so you can finish a login END TO END for the user instead of parking them on a 2FA screen: if `adom-google` is available in your container, read the code out of their Gmail (`adom-google gmail search 'from:autodesk verification code' --limit 1`), pull the 6 digits, and call this. If adom-google is NOT installed, tell the user that installing it would let you automate logins like this, then let them type it. NEVER ask the user for a code you could read yourself. The bridge ALSO toasts the user's desktop whenever it hits a human wall, so you do not need to ask it to.",-     {"code": "required str (the 6 digits; non-digits are stripped)", "hwnd": "optional int (the 2FA window; auto-detected)"},-     {"submitted": "bool", "steps": "list", "narrate": "str"},-     90, "fusion_readiness", True, {"code": "123456"}),-    ("fusion_demo", "FIRST-TIME-USER DEMO in one verb - the thing to run when someone new wants to SEE what this bridge does (HD's installer calls this). Takes them from nothing to a real board: launches Fusion, FINISHES the Autodesk sign-in - it OPENS the sign-in in the user's NATIVE browser IN THE BACKGROUND (never interrupting their work) and returns signinWhere/signinProfile/signinSession so you can tell them exactly where it is waiting, then offer three paths: they finish it, you foreground it, or (with their OK) you drive it. Native browser via ABE/nbrowser_* because that profile is already signed into Autodesk - never pup, which is anonymous, sets up APS cloud search (or explains it if declined) and runs a LIVE sample search so they see 2-second results, then opens an electronics PROJECT and walks SCHEMATIC -> 2D BOARD -> 3D BOARD, screenshotting each view to show them. STAGED + RESUMABLE: every call advances as far as it safely can and returns stage/done/narrate/screenshots plus a _hint with the exact next action; keep calling it until done:true. NEVER leave a first-time user parked on the sign-in screen - that is the failure this verb exists to prevent. See the 'fusion-demo' skill.",-     {"query": "optional str (design to demo; else picked from APS search)", "stage": "optional str ('skip_aps' to proceed without cloud search)"},-     {"stage": "str", "done": "bool", "narrate": "str", "screenshots": "[{label,path}]", "steps": "[str]", "apsSampleSearch": "[{name,project}]"},-     240, "fusion_demo", True, {}),--    # ── Cloud search & files (APS) ─────────────────────────────────────────-    ("fusion_aps_status", "APS state: configured, signed in, token live, never-charge meter.",-     {}, {"configured": "bool", "signedIn": "bool", "neverCharge": "bool"}, 20, None, False, {}),-    ("fusion_aps_set_client_id", "Set the org's APS (PKCE) client id used for cloud search.",-     {"clientId": "required str"}, {"success": "bool"}, 20, None, False, {"clientId": "D5nn...EPAq"}),-    ("fusion_aps_signin", "Open the Autodesk sign-in in the REMEMBERED browser+profile (not the OS default); a background listener captures the token. Pass {exe,profileDir,name} to use+remember a specific browser, or {allowDefaultBrowser:true} to fall back to the OS default. If nothing is remembered, returns needsExtensionOpen so the caller opens authUrl in the user's real browser via nbrowser_open_window.",-     {"exe": "optional str (browser binary to launch+remember)", "profileDir": "optional str (e.g. 'Default','Profile 2')", "name": "optional str", "allowDefaultBrowser": "optional bool"},-     {"success": "bool", "authUrl": "str", "openedVia": "str", "needsExtensionOpen": "bool"}, 30, "fusion_aps_status", False, {}),-    ("fusion_aps_set_browser", "Remember the browser+profile that authed to Autodesk so every later sign-in reuses it (power users have many browsers/profiles; the OS default is usually the wrong one). Call after a driven sign-in succeeds.",-     {"name": "optional str", "exe": "str (browser binary) OR how:'extension'", "profileDir": "optional str", "how": "optional 'extension'|'launch'"},-     {"success": "bool", "browser": "{name,exe,profileDir}"}, 10, None, False, {"name": "Edge", "exe": "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", "profileDir": "Default"}),-    ("fusion_aps_get_browser", "Show the remembered Autodesk sign-in browser (None until set).",-     {}, {"success": "bool", "browser": "{name,exe,profileDir}|null"}, 10, None, False, {}),-    ("fusion_aps_forget_browser", "Forget the remembered Autodesk sign-in browser (re-detect next time).",-     {}, {"success": "bool", "cleared": "bool"}, 10, None, False, {}),-    ("fusion_aps_search", "Server-indexed search across the whole team hub (seconds, not a 30-min folder walk).",-     {"query": "required str", "limit": "optional int (default 50)", "hubId": "optional str", "projectId": "optional str"},-     {"count": "int", "results": "[{name,id,projectName,lastModified}]"}, 60, None, False, {"query": "Molecule", "limit": 10}),-    ("fusion_aps_open", "Find a cloud design by name and open it in Fusion (any folder depth). Fire-and-poll.",-     {"query": "required str", "wait": "optional bool"}, {"opening": "bool", "match": "{name}", "statusVerb": "str"}, 60, _STATUS, True, {"query": "BME690 Molecule"}),-    ("fusion_open_by_urn", "Open a cloud ELECTRONICS DESIGN by URN. PCB/ELECTRONICS NAV RULE: a board is NOT a standalone file - the schematic, 2D board and 3D board are VIEWS inside ONE electronics DESIGN. Open the DESIGN document first (fusion_open_cloud_file or fusion_open_by_urn), THEN switch to its schematic and its 2D board (fusion_show_2d_board) and 3D board (fusion_show_3d_board). A Select-Electronics-Design-File picker IS that design list of its schematic + board. An EMPTY 2D board usually means you opened a derivative or the wrong file, or it is out of sync - reopen the parent electronics DESIGN and switch to its board view; do NOT open a .brd or .sch as a standalone file.",-     {"urn": "required str"}, {"success": "bool", "output": "str"}, 240, _STATUS, True, {"urn": "urn:adsk.wipprod:fs.file:vf.xxxx?version=1"}),-    ("fusion_aps_browse", "Browse hubs/projects/folders without launching Fusion.",-     {"hubId": "optional str", "projectId": "optional str", "folderId": "optional str"}, {"items": "[{name,type,id}]"}, 60, None, False, {}),-    ("fusion_aps_recent", "Most-recently-modified designs across the team hub, newest first.",-     {"limit": "optional int (default 25)"}, {"count": "int", "results": "[...]"}, 60, None, False, {"limit": 25}),-    ("fusion_aps_file_info", "Version history + who/when/size for a file (tip + recent versions).",-     {"query": "str  OR  projectId+urn"}, {"versionCount": "int", "tip": "{...}", "versions": "[...]"}, 40, None, False, {"query": "BME690 Molecule"}),-    ("fusion_aps_versions", "Full (uncapped) version history of a file.",-     {"query": "str  OR  projectId+urn"}, {"versionCount": "int", "versions": "[...]"}, 40, None, False, {"query": "BME690 Molecule"}),-    ("fusion_aps_download", "Download a cloud file to the local machine (no Fusion needed).",-     {"query": "str OR projectId+urn", "saveDir": "optional str", "fileName": "optional str"}, {"path": "str", "bytes": "int"}, 90, None, False, {"query": "BME690 Molecule"}),-    ("fusion_aps_upload", "Upload a local file as a NEW cloud file (free Data Management; needs data:write scope). ⛔ FOLDER HYGIENE: NEVER pass a project ROOT folderId - the bridge REFUSES it (refused_root_upload). Loose files in a shared root clutter the team's cloud. Create/choose a real subfolder first (fusion_aps_create_folder under an 'Adom AI Workspace' folder), or just use fusion_make_3d_package / fusion_build_library_3d which auto-target a non-root workspace. To RELOCATE an existing file, MOVE it (preserves its urn) - do NOT delete + re-upload (mints a new urn, breaks 3D bindings). See the fusion-cloud-hygiene skill.",-     {"projectId": "required str", "folderId": "required str (NON-ROOT subfolder)", "localPath": "required str", "fileName": "optional str"},-     {"itemUrn": "str", "bytes": "int"}, 90, None, False, {"projectId": "a.YnVz...", "folderId": "urn:...", "localPath": "C:/x.f3d"}),-    ("fusion_aps_create_folder", "Create a subfolder in a project (free; needs data:create scope). Use this to make an 'Adom AI Workspace' folder + per-task subfolders so the bridge never dumps loose files in the shared project root. See fusion-cloud-hygiene.",-     {"projectId": "required str", "parentFolderId": "required str", "name": "required str"}, {"folderId": "str"}, 50, None, False, {"projectId": "a.YnVz...", "parentFolderId": "urn:...", "name": "Adom AI Workspace"}),-    ("fusion_aps_get", "Raw authenticated GET against a free APS path (dev/verify helper).",-     {"path": "required str"}, {"status": "int", "body": "obj"}, 40, None, False, {"path": "/project/v1/hubs"}),-    ("fusion_walk_cloud_tree", "⛔ DISABLED (returns an error): the in-app add-in walk took 30+ min and CRASHED Fusion (WinError 10054). It is hard-blocked in the bridge. Use fusion_aps_search / fusion_aps_open. See the 'fusion-aps-search' skill.",-     {}, {"disabled": "bool"}, 15, None, False, {}),-    ("fusion_search_cloud_files", "⛔ DISABLED (returns an error): the in-app add-in search took 30+ min and CRASHED Fusion (WinError 10054). It is hard-blocked in the bridge. Use fusion_aps_search {query} — server-indexed, seconds, no Fusion. See the 'fusion-aps-search' skill.",-     {"query": "required str"}, {"disabled": "bool"}, 15, None, False, {"query": "Molecule"}),--    # ── Electronics ────────────────────────────────────────────────────────-    ("fusion_show_schematic", "Switch the OPEN electronics design to its SCHEMATIC view (the symmetric partner of fusion_show_2d_board / fusion_show_3d_board, via Fusion's SwitchSchDocCmd). A VIEW of the project, not a standalone file. Drive it for demos with fusion_electron_zoom / _pan / _select.",-     {}, {"success": "bool", "activeWorkspace": "str", "productType": "str"}, 70, None, True, {}),-    ("fusion_show_2d_board", "Switch the OPEN electronics design to its 2D PCB Editor (board layout) view. This is a VIEW of the project, not a file to open standalone - if the board is empty, you opened a child/derivative instead of the EcadDesignProductType PROJECT; reopen the project. Drive the view with fusion_electron_run WINDOW commands.",-     {}, {"activeWorkspace": "str"}, 70, None, True, {}),-    ("fusion_show_3d_board", "Switch the OPEN electronics design to its 3D PCB view (populated board). A VIEW of the project, not a standalone file. Orbit it for demos via fusion_run_modeling_script (app.activeViewport.camera + vp.refresh()).",-     {}, {"activeWorkspace": "str", "screenshots": "[...]"}, 70, None, True, {}),-    ("fusion_open_by_urn_note", "(see fusion_open_by_urn)", {}, {}, 30, None, False, {}),-    ("fusion_board_info", "Raw board geometry XML (copper/arcs/lines) for the open board.",-     {}, {"rawXml": "str"}, 60, None, False, {}),-    ("fusion_open_lbr", "Import an EAGLE .lbr library into Fusion's Electronics LIBRARY editor (Content Manager / Electronics Library workspace), not a PCB design. PITFALL: filePath MUST be a WINDOWS-local path (C:/...). The chip-fetcher/adom-lbr pipeline runs in the cloud container, and there is NO container-to-Windows push verb, so STAGE the .lbr onto Windows first (serve it on a proxied port + Invoke-WebRequest via shell_execute), then pass the C:/ path. PITFALL: an adom-lbr .lbr is 2D ONLY - symbol + footprint + a PLACEHOLDER 3D package; the real 3D chip is NOT attached (that needs a Fusion cloud URN). To attach the 3D, see the 'fusion-libraries' skill. Pass verify:true to round-trip-confirm Fusion parsed the deviceset, then check fusion_get_app_state (activeWorkspace 'Electronics Library').",-     {"filePath": "required str (.lbr WINDOWS path C:/...; stage from container first)", "symbolName": "optional str", "verify": "optional bool"},-     {"success": "bool", "postOpenScreenshot": "{savedTo}"}, 50, None, False, {"filePath": "C:/Users/<user>/adom-lib/nRF54L15.lbr"}),-    ("fusion_attach_3d_package", "Attach a real 3D chip model to a library package, end to end: opens the .lbr (library active), runs Electron.Create3DPackage to enter the Package3DEnvironment showing the footprint, imports the STEP + auto-orients it flat on the footprint, then executes Package3DStop (FINISH). This BINDS the 3D onto the deviceset (Content Manager shows the 3D nested under the package; the Packages 'Package' column flips Placeholder->part-name). ONE desktop-side step remains: Fusion pops a modal Save dialog (an OWNED popup) - the response _hint gives the exact desktop_ui_click to confirm it. AFTER: re-grab desktop_screenshot_window and check ownedPopupCount for errors; the 3D preview LAGS a beat before showing the real chip. Both paths (filePath/modelPath) are WINDOWS paths - stage from the container first.",-     {"filePath": "required str (.lbr WINDOWS path C:/...)", "modelPath": "required str (.step WINDOWS path C:/...)", "packageName": "optional str"},-     {"success": "bool", "savePending": "bool", "data": "{steps}", "_hint": "the Save-dialog click + verify steps"}, 240, _STATUS, True, {"filePath": "C:/Users/<user>/adom-lib/ADS8588SIPM.fusion.lbr", "modelPath": "C:/Users/<user>/adom-lib/ADS8588SIPM.step", "packageName": "ADS8588SIPM"}),-    ("fusion_install_fusion", "Install Fusion 360 FOR the user - downloads Autodesk's official Client Downloader and streams the free trial silently, with NO shell_execute and NO AD approval gate (runs inside the trusted bridge). Elevation-aware: --globalinstall when admin, PER-USER otherwise (a non-elevated globalinstall dies silently on UAC - learned live). Returns promptly; poll fusion_readiness (reports installing:true) until installed:true, then fusion_start. Declared as detect.installVerb so AD's bridge_readiness recommends it.",-     {}, {"success": "bool", "installing": "bool", "alreadyInstalled": "bool?", "mode": "globalinstall|per-user", "statusVerb": "str"}, 90, _STATUS, True, {}),-    ("fusion_generate_package", "Generate an IPC-7351-compliant parametric 3D package via Fusion's BUILT-IN ElectronicsPackageGenerator (EPG) - ~50 package families (chip/soic/qfn/qfp/bga/sot23/dfn/melf/ecap/crystal/headers/...), each a few seconds, ZERO GUI. Optionally LASER-ETCH a marking (MPN / '103') into the body top as REAL cut geometry (survives STEP), and export STEP in the same call - the royalty-free, license-clean path to a whole component library (our own generated output, not vendor models). Proven live: 0603 + '103' etch + STEP in ~2s.",-     {"type": "required str (EPG family, e.g. chip|soic|qfn|qfp|bga|sot23|dfn2|melf|ecap|crystal|header_straight - see supportedTypes in the error for the full list)", "params": "optional dict (generator dims in MM by default, e.g. chip: {D,E,A,L,L1}; soic: {A,A1,b,D,E,E1,e,L,DPins}; omitted keys use EPG defaults)", "unitsCm": "optional bool (params already EPG-native cm)", "etch": "optional str (marking text on the chip top: MPN / '103'; multi-line with newline, e.g. MPN+variant - laid along the LONGEST face axis)", "etchStyle": "optional str raised|engraved (default raised = thin WHITE positive extrude, silkscreen-style contrast; engraved = sunken cut)", "etchDepthMm": "optional float (marking height/depth, default 0.03)", "etchHeightMm": "optional float (text height; default auto-fit: top-5%-band face, 10% margin, width-aware)", "outputStep": "optional str (WINDOWS path; exports STEP incl. the marking)"},-     {"success": "bool", "bodies": "[{name,vol}]", "etched": "str|null", "stepPath": "str|null", "manifest": "obj (settings+why+bboxes; also written as <step>.manifest.json sidecar for marking refreshes)", "manifestPath": "str|null", "epgDir": "str"}, 120, _STATUS, True, {"type": "chip", "params": {"D": 1.6, "E": 0.8, "A": 0.45, "L": 0.3, "L1": 0.3}, "etch": "103", "outputStep": "C:/tmp/ADOM-R0603-103.step"}),-    ("fusion_make_3d_package", "Create a RENDERING component 3D package (FOOTPRINT + chip, aligned) for ONE part, fully programmatically with ZERO GUI dialogs. Opens the .lbr, runs Electron.Create3DPackage to load the package's footprint into a generator doc, imports the STEP onto it, orients it flat, saveAs an .f3d (skips the FINISH Save dialog + the two unbeatable 'Fusion360' CEF modals), uploads the f3d via APS, and returns an fs.file:vf wip_urn to hand-write into the library's <packages3d>. projectId/folderId default to the MAIN-project upload folder. Pass captureLabel to also get a BEFORE (footprint) + AFTER (chip placed) screenshot. PITFALL: a RAW STEP upload binds but renders NOTHING ('Thumbnail download failed') - this verb makes an f3d, which renders. Prefer fusion_build_library_3d for many parts.",-     {"lbrPath": "required str (.lbr WINDOWS path; its FIRST package's footprint is loaded)", "modelPath": "required str (.step WINDOWS path)", "projectId": "optional str (default MAIN-project)", "folderId": "optional str (default upload folder)", "fileName": "optional str", "captureLabel": "optional str (capture before/after)", "orient": "optional bool (default true)"},-     {"success": "bool", "wip_urn": "str", "dims_mm": "[x,y,z]", "f3d": "str", "before": "str|null", "after": "str|null"}, 200, _STATUS, True, {"lbrPath": "C:/tmp/newlib/R_4k7.lbr", "modelPath": "C:/tmp/newlib/R_4k7.step", "captureLabel": "R_4k7"}),-    ("fusion_build_library_3d", "Build a RENDERING multi-part 3D library in ONE call - the whole programmatic pipeline. For each part it makes the footprint+chip f3d package (no GUI dialogs, optional before/after screenshots) and collects the wip_urn, then injects ALL bindings into the combined .lbr in one pass (no hand XML surgery) and opens the finished library once. This is the verb for a basic-parts sampler / any many-part library; it replaces the per-part make_3d_package loop + manual binding. projectId/folderId default to the MAIN-project upload folder. Each part is independent, so re-run just the failed parts if Fusion resets mid-run.",-     {"lbrPath": "required str (combined .lbr WINDOWS path to bind + open)", "parts": "required [{package, lbrPath (per-part footprint .lbr), modelPath (.step)}]", "outLbrPath": "optional str (default: overwrite lbrPath)", "capture": "optional bool (default true)", "projectId": "optional str", "folderId": "optional str", "openWhenDone": "optional bool (default true)"},-     {"success": "bool", "boundLbr": "str", "partsBound": "int", "partsTotal": "int", "parts": "[{package,success,wip_urn,dims_mm,error}]", "screenshots": "[{package,stage,path}]", "opened": "bool"}, 1800, _STATUS, True, {"lbrPath": "C:/tmp/newlib/AdomBasicParts2.lbr", "parts": [{"package": "R_4k7", "lbrPath": "C:/tmp/newlib/R_4k7.lbr", "modelPath": "C:/tmp/newlib/R_4k7.step"}]}),-    ("fusion_capture_library_views", "Capture the LIBRARY-EDITOR views EEs trust: the schematic SYMBOL (full pinout), the FOOTPRINT (pads + layer stack), and the COMPONENT/device view (Content Manager: symbol + the package table with the footprint<->package Mapped check + pin/pad counts). The 3D before/after shots don't show these. Requires the .lbr OPEN in the Electronics Library workspace (fusion_open_lbr first). Runs EDIT <pkg>.sym/.pac/.dev + WINDOW FIT + a background hwnd screenshot per view; returns the PNG paths on the box (C:/tmp/conduit-screenshots - pull with desktop_pull_file). EDIT uses the DEVICESET name (e.g. ESP32-S3FN8).",-     {"packages": "required [<deviceset name>] (or 'package': single name)", "views": "optional subset of ['component','symbol','footprint'] (default all)", "settle": "optional float secs after each EDIT before capture (default 1.5; raise if a shot shows the PREVIOUS part)"},-     {"success": "bool", "captured": "[{package,view,path,ok}]"}, 300, _STATUS, True, {"packages": ["ESP32-S3FN8", "ATSAMD51J20A-AUT"]}),-    ("fusion_cleanup_cloud_files", "PRECISELY delete a list of cloud files by lineage urn - SAFE cleanup of AI-created clutter. Deletes ONLY the exact fileIds given (never name-guessing), so it can't touch a teammate's file in a shared folder. Loops server-side. Get the ids from fusion_aps_browse (item .id). Use this to remove f3d files the bridge created. A big list takes minutes (relay may time out at ~60s while deletes continue server-side - re-browse to confirm).",-     {"fileIds": "required [<lineage urn>]", "projectName": "optional str (default active project)", "folderPath": "optional str (default root - where the files live)"},-     {"success": "bool", "deletedCount": "int", "failedCount": "int", "failed": "[{fileId,error}]"}, 1800, _STATUS, True, {"fileIds": ["urn:adsk.wipprod:dm.lineage:abc123"], "projectName": "Adom"}),-    ("fusion_run_modeling_script", "Run an adsk.fusion modeling script in the live session (sketches/extrudes/params). Free programmatic CAD.",-     {"script": "required str (Python using adsk; globals: adsk, app, ui, design)"},-     {"result": "obj", "documentName": "str"}, 180, None, True, {"script": "import adsk.fusion\n# ...build geometry..."}),--    ("fusion_assembly_bom", "Structured, kit-aware MECHANICAL bill of materials for the active Design assembly (the counterpart to the electronics-only fusion_export_bom). Recurses organizational subassemblies but counts a physical part or a purchased kit/unit ONCE, so hardware modeled inside a kit (a '... with fasteners' bracket, a bearing/pulley) is NOT double-counted the way a flat allOccurrences walk counts it. Matches Fusion's Manage -> BOM quantities. Optionally writes a CSV.",-     {"treatAsUnit": "optional [str] name substrings counted as one unit (default ['with fasteners'])", "exclude": "optional [str] top-level name prefixes to skip", "includePhysicalProperties": "optional bool (adds volume_cm3/mass_kg per line)", "outputPath": "optional str (Windows CSV path)"},-     {"success": "bool", "design": "str", "partCount": "int", "totalInstances": "int", "parts": "[{componentName, partNumber, description, material, quantity, bodies}]"},-     120, None, False, {"treatAsUnit": ["with fasteners"], "includePhysicalProperties": True}),--    ("fusion_physical_properties", "Per-component physical properties (volume cm3, mass kg, density, area, center of mass) for the active design. Reads them directly so BOM/costing tools no longer have to run getPhysicalProperties inside a modeling script. Pass names to scope to specific components.",-     {"names": "optional [str] component names to measure (default all)", "accuracy": "optional low|medium|high|veryhigh (default low)"},-     {"success": "bool", "count": "int", "properties": "{name: {volume_cm3, mass_kg, density, area_cm2, center_of_mass}}"},-     120, None, False, {"accuracy": "low"}),--    ("fusion_set_preference", "Set Fusion appearance/navigation preferences in the LIVE session - applies immediately, NO restart. Keys: theme (light/darkblue/darkgray/classic/device/dark; some builds only ship LightGray/DarkBlue/Device, failures reported per-key), invertScrollZoom (bool - mouse scroll/zoom direction), orbitScheme (fusion360/alias/inventor/solidworks/tinkercad/powermill), modelingOrientation (yup/zup), gestureNav (bool), cameraPivot (bool), lengthUnit (mm/cm/m/in/ft - active design). Per-key outcome in `applied`, resulting state in `current`.",-     {"theme": "optional str", "invertScrollZoom": "optional bool", "orbitScheme": "optional str", "modelingOrientation": "optional str", "gestureNav": "optional bool", "cameraPivot": "optional bool", "lengthUnit": "optional str"},-     {"applied": "obj", "current": "obj"}, 30, None, True, {"theme": "dark"}),--    ("fusion_get_preferences", "Read current Fusion appearance/navigation preferences (theme, activeTheme, invertScrollZoom, orbitScheme, modelingOrientation, gestureNav, cameraPivot, lengthUnit) as friendly values - the same keys fusion_set_preference accepts.",-     {}, {"current": "obj"}, 20, None, False, {}),--    # ── CAD & 3D export ────────────────────────────────────────────────────-    ("fusion_export_step", "Export the active design to STEP (needs the 3D view).",-     {"outputPath": "required str"}, {"format": "str", "fileSizeKB": "int"}, 300, None, True, {"outputPath": "C:/out/board.step"}),-    ("fusion_export_iges", "Export to IGES.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/board.iges"}),-    ("fusion_export_stl", "Export to STL mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.stl"}),-    ("fusion_export_3mf", "Export to 3MF mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.3mf"}),-    ("fusion_export_usdz", "Export to USDZ (AR / Hydrogen 3D).", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.usdz"}),-    ("fusion_export_optimized_glb", "Fusion board -> wiki-grade optimized GLB. Exports STEP (+ optional silkscreen) and submits it to service-step2glb molecule mode (anchor to MP pins + silk bake + gold pins + join/weld/prune + Draco): ~465KB/~31 draw calls vs a raw ~16MB/~25000 that halts the viewer. Bounded-waits `wait`s (default 90); small boards return complete, big boards return {pending, jobId} - finish with fusion_fetch_optimized_glb. Use for a component page's component.parts.model_3d. STACKUP RULE (John 2026-07-15, see pcb-stackup skill): board bbox = the FR4 body, NEVER the whole assembly (components overhang edges + tower above); each overlay goes at ITS layer's z (copper at FR4-top, silk above mask); copper traces are REAL 35um 3D bodies colored like the FR4 - recolor them + translucent mask in the RAW GLB before optimizing to make routing visible; put the measured stackup table on the board's wiki page. PUBLISH EVERYTHING: a board's wiki page repo must carry EVERY exportable design file (.f3d/.step/.usdz/EAGLE .brd+.sch/Fusion .fbrd+.fsch/gerbers/BOM/CPL/GLBs/renders), not just the 3D - the page exists to share the design.", {"outputPath": "required str (.glb)", "silkscreen": "bool default true", "pin": "str medium|large default medium", "wait": "int seconds default 15 (keep under the ~60s relay timeout; re-call fetch until complete)"}, {"glbPath": "str", "jobId": "str", "pending": "bool", "meshesAfter": "int", "sizeBytes": "int", "moleculeAnchored": "bool", "silkscreenApplied": "bool"}, 200, None, True, {"outputPath": "C:/out/board.glb"}),-    ("fusion_fetch_optimized_glb", "Finish a fusion_export_optimized_glb job: bounded-poll the optimizer by jobId and write the GLB to outputPath when complete. Re-call until status=complete (big boards tessellate a few minutes). No Fusion needed.", {"jobId": "required str", "outputPath": "str (.glb)", "wait": "int seconds default 15 (keep under the ~60s relay timeout; re-call fetch until complete)"}, {"glbPath": "str", "status": "str", "pending": "bool", "sizeBytes": "int"}, 150, None, False, {"jobId": "abc123", "outputPath": "C:/out/board.glb"}),-    ("fusion_board_stackup", "Read a board's PHYSICAL fabrication stackup (see the pcb-stackup skill): copper layer count + copper/dielectric thicknesses from the EAGLE design rules (layerSetup/mtCopper/mtIsolate) plus the measured FR4 extent. A PCB is Cu/prepreg/Cu/core/.../Cu - the FR4 is NOT one slab. Use to build the stackup table + real-thickness exploded 3D view, and put a stackup table on every exported board's wiki page.", {}, {"layerSetup": "str", "copperLayers": "list", "copperCount": "int", "copperThickness_mm": "list", "dielectricBonds": "list (prepreg/core order)", "fr4": "{x_mm,y_mm,dielectric_mm}"}, 200, None, True, {}),-    ("fusion_export_obj", "Export to OBJ mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.obj"}),-    ("fusion_export_dxf", "Export 2D DXF.", {"outputPath": "required str"}, {"format": "str"}, 120, None, True, {"outputPath": "C:/out/sketch.dxf"}),-    ("fusion_export_dwg", "Export 2D DWG.", {"outputPath": "required str"}, {"format": "str"}, 120, None, True, {"outputPath": "C:/out/sketch.dwg"}),--    # ── Manufacturing ──────────────────────────────────────────────────────-    ("fusion_export_gerbers", "Run the bundled JLCPCB CAM job (auto 2/4-layer) to produce the gerber + drill ZIP.",-     {"outputDir": "optional str (default C:/tmp/adom-gerbers)"}, {"fileCount": "int", "files": "[{name,size}]", "zipPath": "str"}, 180, None, True, {"outputDir": "C:/tmp/jlc"}),-    ("fusion_export_bom", "Export a Bill of Materials CSV (grouped by value+package) from the open board.",-     {"outputPath": "optional str", "grouped": "optional bool (default true)"}, {"componentCount": "int", "grouped": "bool"}, 60, None, False, {"outputPath": "C:/tmp/bom.csv"}),-    ("fusion_export_cpl", "Export the Component Placement List (pick-and-place) CSV.",-     {"outputPath": "optional str", "side": "optional str (all|top|bottom)"}, {"topCount": "int", "bottomCount": "int"}, 60, None, False, {"outputPath": "C:/tmp/cpl.csv"}),-    ("fusion_set_design_rules", "Load manufacturing-grade DRC into the open board (JLCPCB 2/4-layer profiles).",-     {"action": "optional str (apply|export|show)", "layers": "optional str (auto|2|4)"}, {"activeWorkspace": "str", "description": "str"}, 90, None, False, {"action": "apply", "layers": "2"}),-    ("fusion_load_design_rules", "Load a bundled .edru design-rule set into the board.",-     {"layers": "optional str (auto|2|4)"}, {"success": "bool"}, 90, None, False, {}),-    ("fusion_detect_layers", "Detect 2-layer vs 4-layer board (ULP + CAM comparison).",-     {}, {"layerCount": "int", "copperLayers": "[int]"}, 90, None, False, {}),-    ("fusion_dismiss_blocking_dialogs", "Detect + classify (and optionally dismiss) blocking Fusion dialogs.",-     {}, {"dialogs": "[...]"}, 30, None, False, {}),-    ("fusion_electron_run", "Run an EAGLE/Electron command in the open board (the generic extension point, e.g. RUN <ulp>).",-     {"command": "required str"}, {"success": "bool", "output": "str"}, 90, None, False, {"command": "set confirm yes;RUN 'C:/tmp/jlcpcb_smta_exporter.ulp' 'C:/tmp/jlc'"}),-    ("fusion_electron_zoom", "Smoothly ZOOM the schematic/2D board for a recording (the whole motion is one call). factor>1 zooms in, <1 out; or fit:true. The loop runs in the add-in with a repaint per frame.",-     {"factor": "float (default 2.0)", "fit": "optional bool", "steps": "optional int (default 16)", "frameDelayMs": "optional int (default 35)"},-     {"success": "bool", "output": "str"}, 60, None, True, {"factor": 3, "steps": 20}),-    ("fusion_electron_pan", "Smoothly PAN + zoom the view to frame a board-coordinate box (mm; get part positions from fusion_board_info). Animates from where the view last was.",-     {"x1": "float", "y1": "float", "x2": "float", "y2": "float", "steps": "optional int (default 16)", "frameDelayMs": "optional int (default 35)"},-     {"success": "bool", "output": "str", "box": "[...]"}, 60, None, True, {"x1": 0, "y1": 0, "x2": 10, "y2": 8}),-    ("fusion_electron_select", "SELECT a part (by reference designator, or x/y board mm) and surface its properties - the way clicking a part reveals the properties panel. Great for showing life in a recording.",-     {"name": "str (refdes, e.g. R1)", "x": "optional float", "y": "optional float", "properties": "optional bool (default true)"},-     {"success": "bool", "output": "str", "deviceInfo": "str"}, 30, None, True, {"name": "U1"}),--    # ── Self-describe ──────────────────────────────────────────────────────-    ("fusion_describe", "Self-describe every verb the bridge exposes (this list) for AD's Verbs tab + runner.",-     {}, {"success": "bool", "verbs": "[{name,summary,input,output,timeoutSeconds,statusVerb,longRunning,example}]"}, 20, None, False, {}),-]---def _verbs():-    out = []-    for name, summary, inp, outp, timeout, status, longr, ex in _T:-        if name.endswith("_note"):-            continue-        out.append({-            "name": name,-            "summary": summary,-            "input": inp,-            "output": outp,-            "timeoutSeconds": timeout,-            "statusVerb": status,-            "longRunning": longr,-            "example": ex,-        })-    return out---def handle_describe(args: dict) -> dict:-    verbs = _verbs()-    # `verbs` at the TOP LEVEL is what AD's Verbs tab reads from the raw /command-    # response. AD's relay strips non-standard top-level fields on the CLI proxy-    # path, so we ALSO nest under `data` (which the relay forwards) for CLI/debug.-    return {-        "success": True,-        "bridge": "fusion360",-        "verbCount": len(verbs),-        "verbs": verbs,-        "data": {"bridge": "fusion360", "verbCount": len(verbs), "verbs": verbs},-    }+"""fusion_describe — self-describe the bridge's verb surface for Adom Desktop.++AD v1.9.23+ added a Verbs tab to each bridge's detail pop-out (lists verbs,+expands each for its I/O + timeout + status schema, with an inline runner). AD+does NOT hardcode a cloud-owned bridge's verbs, so the bridge must self-describe.+AD POSTs {command:"describe", args:{}} and caches the result at ~/.adom/bridge-verbs/.++Each entry: name, summary, input, output, timeoutSeconds, statusVerb, longRunning,+example. Keep this in step with the actual handlers as verbs are added.+"""++_STATUS = "fusion_get_app_state"++# Compact source table: name -> (summary, input, output, timeout, statusVerb, longRunning, example)+_T = [+    # ── Lifecycle ──────────────────────────────────────────────────────────+    ("fusion_start", "Launch Fusion 360 (if needed) and wait for the AdomBridge add-in to be ready.",+     {}, {"success": "bool", "addinReady": "bool", "status": "str"}, 150, None, True, {}),+    ("fusion_stop", "Gracefully STOP Fusion 360: close docs cleanly + WM_CLOSE, then wait. NO force-kill - the clean opposite of fusion_start. If a window will not close it returns failure; use fusion_kill then.",+     {"skipCleanClose": "optional bool"}, {"success": "bool", "output": "str"}, 60, None, False, {}),+    ("fusion_kill", "Force-KILL Fusion 360 (taskkill /F) - the desperate path for when fusion_stop cannot close it (stuck modal / wedged process). Closes docs cleanly first when reachable.",+     {"skipCleanClose": "optional bool"}, {"success": "bool", "output": "str"}, 30, None, False, {}),+    ("fusion_close_window", "Close the active Fusion document/window.",+     {"name": "optional str (default: active)"}, {"success": "bool"}, 30, None, False, {}),+    ("fusion_get_app_state", "Current app state: running, active document, workspace, isElectronics. The poll/status verb for long-running calls.",+     {}, {"activeDocument": "str", "activeWorkspace": "str", "isElectronics": "bool"}, 20, None, False, {}),+    ("fusion_readiness", "FAST readiness check (does NOT launch Fusion): is the Fusion host app installed + running + the bridge ready to drive? AD DETECTS Fusion (never installs it) via bridge.json 'detect', and auto-installs Python if missing (AD >=1.9.47). Call this before driving Fusion; if installed-but-not-running, fusion_start. SELF-HEALS the seat/licensing dialog (background UIA + screenshot-verify) whenever it detects one. Pairs with AD's bridge_readiness.",+     {}, {"installed": "bool", "running": "bool", "ready": "bool", "hostApp": "str", "bridgeVersion": "str"}, 15, None, False, {}),++    ("fusion_new_electronics_from_eagle", "Import a legacy EAGLE .sch (+ paired .brd) into a NEW Fusion electronics design so the parts INSTANTIATE (schematic + populated board, lands in the PCB editor). The ONLY way to author a board from EAGLE source: Document.newDesignFromLocal opens the schematic editor but does NOT instantiate parts, and the Fusion-native .fsch/.fbrd container is opaque binary you can't build offline. This fires Fusion's own ImportSCHAndBRDCmd and drives BOTH native Open dialogs (the .sch then the .brd) in the BACKGROUND via desktop_ui_click by accessible name (no foreground). Stage both files to Windows first (send_files). After: fusion_show_2d_board, RATSNEST + 'AUTO ;' to autoroute, fusion_show_3d_board for 3D.",+     {"schPath": "required str (Windows path to .sch)", "brdPath": "optional str (defaults to sibling .brd)"},+     {"success": "bool", "imported": "list"}, 200, None, False,+     {"schPath": "C:/Users/me/adom-lib/MyBoard.sch", "brdPath": "C:/Users/me/adom-lib/MyBoard.brd"}),++    ("fusion_notify_owner", "LAST-RESORT human escalation: toast the user's MAIN computer (fans out cross-AD to every peer on the relay, so it reaches them even when this bridge runs on an unattended VM). ONLY for true human walls - password/2FA entry, UAC elevation, a physical step. Exhaust programmatic options first (UIA background clicks, seat auto-resolve, warm-SSO sign-in). After sending, WAIT and poll fusion_readiness; do not re-toast within a few minutes.",+     {"title": "optional str", "body": "required str", "level": "optional str info|warning|error"},+     {"success": "bool", "targets": "list"}, 20, None, False,+     {"title": "Fusion needs you", "body": "Please complete the Autodesk 2FA prompt on winvm - everything else is done."}),++    ("fusion_signin", "Sign Fusion in through the CORRECT browser profile - the fix for Fusion's dumb OAuth that fires at the OS-DEFAULT browser and lands in the wrong Autodesk account. Fusion opens its sign-in in whatever browser is default (e.g. a personal Chrome), but a user's Autodesk seat often lives in a different profile (work). This verb reads the FULL authorize URL (client_id/PKCE/state/request_id) that Fusion wrote into that wrong browser's HISTORY and re-opens it in the RIGHT profile IN THE BACKGROUND; the URL completes back to the running Fusion via the idmgr/callback + autodesk:// handoff, so the browser no longer has to be the OS default. Pass {profile:'chrome:[email protected]'} to fix + remember your Autodesk profile; else it probes each profile for a live Autodesk session (or asks). Staged: if Fusion hasn't opened the browser yet it returns stage:'click_signin' so you click Fusion's Sign In first. BEST: pass {auto:true} to run the WHOLE chain in ONE server-side call - it clicks Fusion's Sign In, polls the browser history on-box for the fresh authorize URL, and re-opens it in the target profile, all without extra remote round-trips, so it fits inside Fusion's ~2-min OAuth request expiry even on a slow/flaky relay (the manual staged path is the fallback). Fusion's request EXPIRES in ~2 min, so a stale captured URL returns stage:'stale' - restart Fusion (fusion_stop/start) for a fresh request and retry auto PROMPTLY. See the 'fusion-multiprofile-signin' skill.",+     {"profile": "optional str (chrome:<email>/edge:<email> - the profile your Autodesk account is in; remembered)", "auto": "optional bool (do click+capture+reopen in one on-box call - recommended)", "waitSec": "optional int (server-side poll budget for the fresh URL, default 32)"},+     {"stage": "str", "done": "bool", "signinWhere": "str", "signinProfile": "str", "signinProfileReason": "str", "signinWrongBrowser": "str", "signinAuthUrl": "str", "signinOpenedVia": "str (extension|extension_free)", "signinWall": "str|null (twofactor_email_code|credentials|protocol_handoff)", "notifyDelivered": "bool", "signinWallHint": "str|null", "narrate": "str"},+     120, "fusion_signin", True, {}),+    ("fusion_mcp_status", "Is Autodesk's Fusion MCP server up, and what does it expose? Autodesk + Anthropic ship a LOCAL MCP server at http://127.0.0.1:27182/mcp exposing Fusion's own text-to-CAD surface (read geometry, execute/update features, read Electronics design data). It binds LOOPBACK on the user's machine and was built for Claude Desktop running there, so a CLOUD AI cannot reach it at all - this bridge runs on that machine and proxies it for you. Returns enabled, serverInfo and the live tool list. OFF by default: if it reports enabled:false, call fusion_mcp_enable and the bridge turns it on for the user.",+     {}, {"enabled": "bool", "serverInfo": "obj", "toolCount": "int", "tools": "[{name,description}]"},+     45, "fusion_mcp_status", False, {}),+    ("fusion_mcp_enable", "Turn Autodesk's Fusion MCP server ON by driving Fusion's Preferences dialog for the user. Autodesk exposes NO API for this toggle, so the UI is the only route: this opens Preferences (Commands.Start PreferencesCommand), expands General, selects API, ticks 'Fusion MCP Server (runs locally on this device)', then clicks Apply and OK. Needs Fusion running; briefly uses the foreground and announces why with an on-screen caption. NEVER ask the user to do this by hand first - do it for them, then tell them it is handled. Note the setting is DISCARDED if Fusion is killed or restarted before Apply is clicked.",+     {}, {"enabled": "bool", "alreadyOn": "bool", "steps": "list", "serverInfo": "obj"},+     180, "fusion_mcp_status", True, {}),+    ("fusion_mcp_tools", "Full tool list from Autodesk's Fusion MCP server INCLUDING input schemas, so you can construct a valid fusion_mcp_call. These are Autodesk's tools, not this bridge's.",+     {}, {"count": "int", "tools": "list"}, 45, "fusion_mcp_status", False, {}),+    ("fusion_mcp_call", "Call any tool on Autodesk's Fusion MCP server (fusion_mcp_read, fusion_mcp_update, fusion_mcp_execute, fusion_mcp_electronics_read). The bridge handles the MCP streamable-HTTP handshake for you (initialize -> MCP-Session-Id -> notifications/initialized) and re-establishes a dropped session automatically. MCP acts on the ACTIVE Fusion document, so open one first. PREFER a native fusion_* verb when one exists - they are tested here and return richer hints - and reach for MCP for Autodesk's text-to-CAD surface.",+     {"tool": "required str (from fusion_mcp_tools)", "arguments": "optional obj (per that tool's inputSchema)", "timeout": "optional int seconds, default 120"},+     {"result": "obj", "tool": "str"}, 150, "fusion_mcp_status", True, {"tool": "fusion_mcp_read", "arguments": {"queryType": "summary"}}),+    ("fusion_mcp_resources", "List Autodesk MCP resources, or read one by uri. These are mostly the Electronics entity schemas (resource://mcp.electronics_schema_*) that describe what fusion_mcp_electronics_read can return.",+     {"uri": "optional str (omit to list)"}, {"count": "int", "resources": "[str]", "result": "obj"},+     60, "fusion_mcp_status", False, {}),+    ("fusion_prefs_open", "Open Fusion's Preferences dialog and optionally jump to a section, then hand back a SCREENSHOT + shotId you can click in. This is how you reach ANY Fusion preference: the Python API exposes only a thin slice (theme/orbit/units, debuggingPort, developer tools) and everything else, including the MCP server toggle, is UI-only. Sections: general, api, design, manufacture, electronics, render, drawing, material, graphics, network, preview features. Child sections under General are lazily rendered by Qt and never appear in the UIA tree, which is why this returns an image to click rather than control names. ALWAYS finish with fusion_prefs_close {save:true} - nothing is saved otherwise, and killing/restarting Fusion first discards the change.",+     {"section": "optional str (see knownSections in the response)"},+     {"hwnd": "int", "shotId": "str", "screenshot": "str", "knownSections": "[str]", "steps": "list"},+     120, "fusion_get_preferences", True, {"section": "api"}),+    ("fusion_prefs_close", "Apply+OK (save:true, the default) or Cancel Fusion's Preferences dialog. Settings changed via fusion_prefs_open are NOT persisted until this is called with save:true.",+     {"save": "optional bool, default true"}, {"closed": "bool", "saved": "bool"},+     60, "fusion_get_preferences", False, {"save": True}),+    ("fusion_set_auto_update", "Turn AUTOMATIC Fusion updating on/off for this user. ON BY DEFAULT, and deliberately INVISIBLE: Fusion ships updates constantly and nags with an 'Update Now / Update Later' panel plus a '14 days until Fusion must update' countdown. Every fusion_readiness call checks for that offer and, if present, the bridge clicks it IN THE BACKGROUND (UIA, no foreground, no caption) so the download starts and the newer build comes up on the next launch. The user should never have to click it. Call with no args to read the current setting; {enabled:false} opts out and the choice is STICKY (~/.adom/fusion-bridge/prefs.json) - once a user opts out, NEVER click their update prompts again. Tell the user this is handled for them rather than asking them to update.",+     {"enabled": "optional bool (omit to read current value)"},+     {"autoUpdateFusion": "bool", "narrate": "str"},+     30, "fusion_readiness", True, {"enabled": True}),+    ("fusion_signin_2fa", "Submit Autodesk's emailed 6-DIGIT verification code to finish a Fusion sign-in, typed IN THE BACKGROUND (UIA SetValue + Invoke - never steals the user's foreground or moves their cursor). Exists so you can finish a login END TO END for the user instead of parking them on a 2FA screen: if `adom-google` is available in your container, read the code out of their Gmail (`adom-google gmail search 'from:autodesk verification code' --limit 1`), pull the 6 digits, and call this. If adom-google is NOT installed, tell the user that installing it would let you automate logins like this, then let them type it. NEVER ask the user for a code you could read yourself. The bridge ALSO toasts the user's desktop whenever it hits a human wall, so you do not need to ask it to.",+     {"code": "required str (the 6 digits; non-digits are stripped)", "hwnd": "optional int (the 2FA window; auto-detected)"},+     {"submitted": "bool", "steps": "list", "narrate": "str"},+     90, "fusion_readiness", True, {"code": "123456"}),+    ("fusion_demo", "FIRST-TIME-USER DEMO in one verb - the thing to run when someone new wants to SEE what this bridge does (HD's installer calls this). Takes them from nothing to a real board: launches Fusion, FINISHES the Autodesk sign-in - it OPENS the sign-in in the user's NATIVE browser IN THE BACKGROUND (never interrupting their work) and returns signinWhere/signinProfile/signinSession so you can tell them exactly where it is waiting, then offer three paths: they finish it, you foreground it, or (with their OK) you drive it. Native browser via ABE/nbrowser_* because that profile is already signed into Autodesk - never pup, which is anonymous, sets up APS cloud search (or explains it if declined) and runs a LIVE sample search so they see 2-second results, then opens an electronics PROJECT and walks SCHEMATIC -> 2D BOARD -> 3D BOARD, screenshotting each view to show them. STAGED + RESUMABLE: every call advances as far as it safely can and returns stage/done/narrate/screenshots plus a _hint with the exact next action; keep calling it until done:true. NEVER leave a first-time user parked on the sign-in screen - that is the failure this verb exists to prevent. See the 'fusion-demo' skill.",+     {"query": "optional str (design to demo; else picked from APS search)", "stage": "optional str ('skip_aps' to proceed without cloud search)"},+     {"stage": "str", "done": "bool", "narrate": "str", "screenshots": "[{label,path}]", "steps": "[str]", "apsSampleSearch": "[{name,project}]"},+     240, "fusion_demo", True, {}),++    # ── Cloud search & files (APS) ─────────────────────────────────────────+    ("fusion_aps_status", "APS state: configured, signed in, token live, never-charge meter.",+     {}, {"configured": "bool", "signedIn": "bool", "neverCharge": "bool"}, 20, None, False, {}),+    ("fusion_aps_set_client_id", "Set the org's APS (PKCE) client id used for cloud search.",+     {"clientId": "required str"}, {"success": "bool"}, 20, None, False, {"clientId": "D5nn...EPAq"}),+    ("fusion_aps_signin", "Open the Autodesk sign-in in the REMEMBERED browser+profile (not the OS default); a background listener captures the token. Pass {exe,profileDir,name} to use+remember a specific browser, or {allowDefaultBrowser:true} to fall back to the OS default. If nothing is remembered, returns needsExtensionOpen so the caller opens authUrl in the user's real browser via nbrowser_open_window.",+     {"exe": "optional str (browser binary to launch+remember)", "profileDir": "optional str (e.g. 'Default','Profile 2')", "name": "optional str", "allowDefaultBrowser": "optional bool"},+     {"success": "bool", "authUrl": "str", "openedVia": "str", "needsExtensionOpen": "bool"}, 30, "fusion_aps_status", False, {}),+    ("fusion_aps_set_browser", "Remember the browser+profile that authed to Autodesk so every later sign-in reuses it (power users have many browsers/profiles; the OS default is usually the wrong one). Call after a driven sign-in succeeds.",+     {"name": "optional str", "exe": "str (browser binary) OR how:'extension'", "profileDir": "optional str", "how": "optional 'extension'|'launch'"},+     {"success": "bool", "browser": "{name,exe,profileDir}"}, 10, None, False, {"name": "Edge", "exe": "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", "profileDir": "Default"}),+    ("fusion_aps_get_browser", "Show the remembered Autodesk sign-in browser (None until set).",+     {}, {"success": "bool", "browser": "{name,exe,profileDir}|null"}, 10, None, False, {}),+    ("fusion_aps_forget_browser", "Forget the remembered Autodesk sign-in browser (re-detect next time).",+     {}, {"success": "bool", "cleared": "bool"}, 10, None, False, {}),+    ("fusion_aps_search", "Server-indexed search across the whole team hub (seconds, not a 30-min folder walk).",+     {"query": "required str", "limit": "optional int (default 50)", "hubId": "optional str", "projectId": "optional str"},+     {"count": "int", "results": "[{name,id,projectName,lastModified}]"}, 60, None, False, {"query": "Molecule", "limit": 10}),+    ("fusion_aps_open", "Find a cloud design by name and open it in Fusion (any folder depth). Fire-and-poll.",+     {"query": "required str", "wait": "optional bool"}, {"opening": "bool", "match": "{name}", "statusVerb": "str"}, 60, _STATUS, True, {"query": "BME690 Molecule"}),+    ("fusion_open_by_urn", "Open a cloud ELECTRONICS DESIGN by URN. PCB/ELECTRONICS NAV RULE: a board is NOT a standalone file - the schematic, 2D board and 3D board are VIEWS inside ONE electronics DESIGN. Open the DESIGN document first (fusion_open_cloud_file or fusion_open_by_urn), THEN switch to its schematic and its 2D board (fusion_show_2d_board) and 3D board (fusion_show_3d_board). A Select-Electronics-Design-File picker IS that design list of its schematic + board. An EMPTY 2D board usually means you opened a derivative or the wrong file, or it is out of sync - reopen the parent electronics DESIGN and switch to its board view; do NOT open a .brd or .sch as a standalone file.",+     {"urn": "required str"}, {"success": "bool", "output": "str"}, 240, _STATUS, True, {"urn": "urn:adsk.wipprod:fs.file:vf.xxxx?version=1"}),+    ("fusion_aps_browse", "Browse hubs/projects/folders without launching Fusion.",+     {"hubId": "optional str", "projectId": "optional str", "folderId": "optional str"}, {"items": "[{name,type,id}]"}, 60, None, False, {}),+    ("fusion_aps_recent", "Most-recently-modified designs across the team hub, newest first.",+     {"limit": "optional int (default 25)"}, {"count": "int", "results": "[...]"}, 60, None, False, {"limit": 25}),+    ("fusion_aps_file_info", "Version history + who/when/size for a file (tip + recent versions).",+     {"query": "str  OR  projectId+urn"}, {"versionCount": "int", "tip": "{...}", "versions": "[...]"}, 40, None, False, {"query": "BME690 Molecule"}),+    ("fusion_aps_versions", "Full (uncapped) version history of a file.",+     {"query": "str  OR  projectId+urn"}, {"versionCount": "int", "versions": "[...]"}, 40, None, False, {"query": "BME690 Molecule"}),+    ("fusion_aps_download", "Download a cloud file to the local machine (no Fusion needed).",+     {"query": "str OR projectId+urn", "saveDir": "optional str", "fileName": "optional str"}, {"path": "str", "bytes": "int"}, 90, None, False, {"query": "BME690 Molecule"}),+    ("fusion_aps_upload", "Upload a local file as a NEW cloud file (free Data Management; needs data:write scope). ⛔ FOLDER HYGIENE: NEVER pass a project ROOT folderId - the bridge REFUSES it (refused_root_upload). Loose files in a shared root clutter the team's cloud. Create/choose a real subfolder first (fusion_aps_create_folder under an 'Adom AI Workspace' folder), or just use fusion_make_3d_package / fusion_build_library_3d which auto-target a non-root workspace. To RELOCATE an existing file, MOVE it (preserves its urn) - do NOT delete + re-upload (mints a new urn, breaks 3D bindings). See the fusion-cloud-hygiene skill.",+     {"projectId": "required str", "folderId": "required str (NON-ROOT subfolder)", "localPath": "required str", "fileName": "optional str"},+     {"itemUrn": "str", "bytes": "int"}, 90, None, False, {"projectId": "a.YnVz...", "folderId": "urn:...", "localPath": "C:/x.f3d"}),+    ("fusion_aps_create_folder", "Create a subfolder in a project (free; needs data:create scope). Use this to make an 'Adom AI Workspace' folder + per-task subfolders so the bridge never dumps loose files in the shared project root. See fusion-cloud-hygiene.",+     {"projectId": "required str", "parentFolderId": "required str", "name": "required str"}, {"folderId": "str"}, 50, None, False, {"projectId": "a.YnVz...", "parentFolderId": "urn:...", "name": "Adom AI Workspace"}),+    ("fusion_aps_get", "Raw authenticated GET against a free APS path (dev/verify helper).",+     {"path": "required str"}, {"status": "int", "body": "obj"}, 40, None, False, {"path": "/project/v1/hubs"}),+    ("fusion_walk_cloud_tree", "⛔ DISABLED (returns an error): the in-app add-in walk took 30+ min and CRASHED Fusion (WinError 10054). It is hard-blocked in the bridge. Use fusion_aps_search / fusion_aps_open. See the 'fusion-aps-search' skill.",+     {}, {"disabled": "bool"}, 15, None, False, {}),+    ("fusion_search_cloud_files", "⛔ DISABLED (returns an error): the in-app add-in search took 30+ min and CRASHED Fusion (WinError 10054). It is hard-blocked in the bridge. Use fusion_aps_search {query} — server-indexed, seconds, no Fusion. See the 'fusion-aps-search' skill.",+     {"query": "required str"}, {"disabled": "bool"}, 15, None, False, {"query": "Molecule"}),++    # ── Electronics ────────────────────────────────────────────────────────+    ("fusion_show_schematic", "Switch the OPEN electronics design to its SCHEMATIC view (the symmetric partner of fusion_show_2d_board / fusion_show_3d_board, via Fusion's SwitchSchDocCmd). A VIEW of the project, not a standalone file. Drive it for demos with fusion_electron_zoom / _pan / _select.",+     {}, {"success": "bool", "activeWorkspace": "str", "productType": "str"}, 70, None, True, {}),+    ("fusion_show_2d_board", "Switch the OPEN electronics design to its 2D PCB Editor (board layout) view. This is a VIEW of the project, not a file to open standalone - if the board is empty, you opened a child/derivative instead of the EcadDesignProductType PROJECT; reopen the project. Drive the view with fusion_electron_run WINDOW commands.",+     {}, {"activeWorkspace": "str"}, 70, None, True, {}),+    ("fusion_show_3d_board", "Switch the OPEN electronics design to its 3D PCB view (populated board). A VIEW of the project, not a standalone file. Orbit it for demos via fusion_run_modeling_script (app.activeViewport.camera + vp.refresh()).",+     {}, {"activeWorkspace": "str", "screenshots": "[...]"}, 70, None, True, {}),+    ("fusion_open_by_urn_note", "(see fusion_open_by_urn)", {}, {}, 30, None, False, {}),+    ("fusion_board_info", "Raw board geometry XML (copper/arcs/lines) for the open board.",+     {}, {"rawXml": "str"}, 60, None, False, {}),+    ("fusion_open_lbr", "Import an EAGLE .lbr library into Fusion's Electronics LIBRARY editor (Content Manager / Electronics Library workspace), not a PCB design. PITFALL: filePath MUST be a WINDOWS-local path (C:/...). The chip-fetcher/adom-lbr pipeline runs in the cloud container, and there is NO container-to-Windows push verb, so STAGE the .lbr onto Windows first (serve it on a proxied port + Invoke-WebRequest via shell_execute), then pass the C:/ path. PITFALL: an adom-lbr .lbr is 2D ONLY - symbol + footprint + a PLACEHOLDER 3D package; the real 3D chip is NOT attached (that needs a Fusion cloud URN). To attach the 3D, see the 'fusion-libraries' skill. Pass verify:true to round-trip-confirm Fusion parsed the deviceset, then check fusion_get_app_state (activeWorkspace 'Electronics Library').",+     {"filePath": "required str (.lbr WINDOWS path C:/...; stage from container first)", "symbolName": "optional str", "verify": "optional bool"},+     {"success": "bool", "postOpenScreenshot": "{savedTo}"}, 50, None, False, {"filePath": "C:/Users/<user>/adom-lib/nRF54L15.lbr"}),+    ("fusion_attach_3d_package", "Attach a real 3D chip model to a library package, end to end: opens the .lbr (library active), runs Electron.Create3DPackage to enter the Package3DEnvironment showing the footprint, imports the STEP + auto-orients it flat on the footprint, then executes Package3DStop (FINISH). This BINDS the 3D onto the deviceset (Content Manager shows the 3D nested under the package; the Packages 'Package' column flips Placeholder->part-name). ONE desktop-side step remains: Fusion pops a modal Save dialog (an OWNED popup) - the response _hint gives the exact desktop_ui_click to confirm it. AFTER: re-grab desktop_screenshot_window and check ownedPopupCount for errors; the 3D preview LAGS a beat before showing the real chip. Both paths (filePath/modelPath) are WINDOWS paths - stage from the container first.",+     {"filePath": "required str (.lbr WINDOWS path C:/...)", "modelPath": "required str (.step WINDOWS path C:/...)", "packageName": "optional str"},+     {"success": "bool", "savePending": "bool", "data": "{steps}", "_hint": "the Save-dialog click + verify steps"}, 240, _STATUS, True, {"filePath": "C:/Users/<user>/adom-lib/ADS8588SIPM.fusion.lbr", "modelPath": "C:/Users/<user>/adom-lib/ADS8588SIPM.step", "packageName": "ADS8588SIPM"}),+    ("fusion_install_fusion", "Install Fusion 360 FOR the user - downloads Autodesk's official Client Downloader and streams the free trial silently, with NO shell_execute and NO AD approval gate (runs inside the trusted bridge). Elevation-aware: --globalinstall when admin, PER-USER otherwise (a non-elevated globalinstall dies silently on UAC - learned live). Returns promptly; poll fusion_readiness (reports installing:true) until installed:true, then fusion_start. Declared as detect.installVerb so AD's bridge_readiness recommends it.",+     {}, {"success": "bool", "installing": "bool", "alreadyInstalled": "bool?", "mode": "globalinstall|per-user", "statusVerb": "str"}, 90, _STATUS, True, {}),+    ("fusion_generate_package", "Generate an IPC-7351-compliant parametric 3D package via Fusion's BUILT-IN ElectronicsPackageGenerator (EPG) - ~50 package families (chip/soic/qfn/qfp/bga/sot23/dfn/melf/ecap/crystal/headers/...), each a few seconds, ZERO GUI. Optionally LASER-ETCH a marking (MPN / '103') into the body top as REAL cut geometry (survives STEP), and export STEP in the same call - the royalty-free, license-clean path to a whole component library (our own generated output, not vendor models). Proven live: 0603 + '103' etch + STEP in ~2s.",+     {"type": "required str (EPG family, e.g. chip|soic|qfn|qfp|bga|sot23|dfn2|melf|ecap|crystal|header_straight - see supportedTypes in the error for the full list)", "params": "optional dict (generator dims in MM by default, e.g. chip: {D,E,A,L,L1}; soic: {A,A1,b,D,E,E1,e,L,DPins}; omitted keys use EPG defaults)", "unitsCm": "optional bool (params already EPG-native cm)", "etch": "optional str (marking text on the chip top: MPN / '103'; multi-line with newline, e.g. MPN+variant - laid along the LONGEST face axis)", "etchStyle": "optional str raised|engraved (default raised = thin WHITE positive extrude, silkscreen-style contrast; engraved = sunken cut)", "etchDepthMm": "optional float (marking height/depth, default 0.03)", "etchHeightMm": "optional float (text height; default auto-fit: top-5%-band face, 10% margin, width-aware)", "outputStep": "optional str (WINDOWS path; exports STEP incl. the marking)"},+     {"success": "bool", "bodies": "[{name,vol}]", "etched": "str|null", "stepPath": "str|null", "manifest": "obj (settings+why+bboxes; also written as <step>.manifest.json sidecar for marking refreshes)", "manifestPath": "str|null", "epgDir": "str"}, 120, _STATUS, True, {"type": "chip", "params": {"D": 1.6, "E": 0.8, "A": 0.45, "L": 0.3, "L1": 0.3}, "etch": "103", "outputStep": "C:/tmp/ADOM-R0603-103.step"}),+    ("fusion_make_3d_package", "Create a RENDERING component 3D package (FOOTPRINT + chip, aligned) for ONE part, fully programmatically with ZERO GUI dialogs. Opens the .lbr, runs Electron.Create3DPackage to load the package's footprint into a generator doc, imports the STEP onto it, orients it flat, saveAs an .f3d (skips the FINISH Save dialog + the two unbeatable 'Fusion360' CEF modals), uploads the f3d via APS, and returns an fs.file:vf wip_urn to hand-write into the library's <packages3d>. projectId/folderId default to the MAIN-project upload folder. Pass captureLabel to also get a BEFORE (footprint) + AFTER (chip placed) screenshot. PITFALL: a RAW STEP upload binds but renders NOTHING ('Thumbnail download failed') - this verb makes an f3d, which renders. Prefer fusion_build_library_3d for many parts.",+     {"lbrPath": "required str (.lbr WINDOWS path; its FIRST package's footprint is loaded)", "modelPath": "required str (.step WINDOWS path)", "projectId": "optional str (default MAIN-project)", "folderId": "optional str (default upload folder)", "fileName": "optional str", "captureLabel": "optional str (capture before/after)", "orient": "optional bool (default true)"},+     {"success": "bool", "wip_urn": "str", "dims_mm": "[x,y,z]", "f3d": "str", "before": "str|null", "after": "str|null"}, 200, _STATUS, True, {"lbrPath": "C:/tmp/newlib/R_4k7.lbr", "modelPath": "C:/tmp/newlib/R_4k7.step", "captureLabel": "R_4k7"}),+    ("fusion_build_library_3d", "Build a RENDERING multi-part 3D library in ONE call - the whole programmatic pipeline. For each part it makes the footprint+chip f3d package (no GUI dialogs, optional before/after screenshots) and collects the wip_urn, then injects ALL bindings into the combined .lbr in one pass (no hand XML surgery) and opens the finished library once. This is the verb for a basic-parts sampler / any many-part library; it replaces the per-part make_3d_package loop + manual binding. projectId/folderId default to the MAIN-project upload folder. Each part is independent, so re-run just the failed parts if Fusion resets mid-run.",+     {"lbrPath": "required str (combined .lbr WINDOWS path to bind + open)", "parts": "required [{package, lbrPath (per-part footprint .lbr), modelPath (.step)}]", "outLbrPath": "optional str (default: overwrite lbrPath)", "capture": "optional bool (default true)", "projectId": "optional str", "folderId": "optional str", "openWhenDone": "optional bool (default true)"},+     {"success": "bool", "boundLbr": "str", "partsBound": "int", "partsTotal": "int", "parts": "[{package,success,wip_urn,dims_mm,error}]", "screenshots": "[{package,stage,path}]", "opened": "bool"}, 1800, _STATUS, True, {"lbrPath": "C:/tmp/newlib/AdomBasicParts2.lbr", "parts": [{"package": "R_4k7", "lbrPath": "C:/tmp/newlib/R_4k7.lbr", "modelPath": "C:/tmp/newlib/R_4k7.step"}]}),+    ("fusion_capture_library_views", "Capture the LIBRARY-EDITOR views EEs trust: the schematic SYMBOL (full pinout), the FOOTPRINT (pads + layer stack), and the COMPONENT/device view (Content Manager: symbol + the package table with the footprint<->package Mapped check + pin/pad counts). The 3D before/after shots don't show these. Requires the .lbr OPEN in the Electronics Library workspace (fusion_open_lbr first). Runs EDIT <pkg>.sym/.pac/.dev + WINDOW FIT + a background hwnd screenshot per view; returns the PNG paths on the box (C:/tmp/conduit-screenshots - pull with desktop_pull_file). EDIT uses the DEVICESET name (e.g. ESP32-S3FN8).",+     {"packages": "required [<deviceset name>] (or 'package': single name)", "views": "optional subset of ['component','symbol','footprint'] (default all)", "settle": "optional float secs after each EDIT before capture (default 1.5; raise if a shot shows the PREVIOUS part)"},+     {"success": "bool", "captured": "[{package,view,path,ok}]"}, 300, _STATUS, True, {"packages": ["ESP32-S3FN8", "ATSAMD51J20A-AUT"]}),+    ("fusion_cleanup_cloud_files", "PRECISELY delete a list of cloud files by lineage urn - SAFE cleanup of AI-created clutter. Deletes ONLY the exact fileIds given (never name-guessing), so it can't touch a teammate's file in a shared folder. Loops server-side. Get the ids from fusion_aps_browse (item .id). Use this to remove f3d files the bridge created. A big list takes minutes (relay may time out at ~60s while deletes continue server-side - re-browse to confirm).",+     {"fileIds": "required [<lineage urn>]", "projectName": "optional str (default active project)", "folderPath": "optional str (default root - where the files live)"},+     {"success": "bool", "deletedCount": "int", "failedCount": "int", "failed": "[{fileId,error}]"}, 1800, _STATUS, True, {"fileIds": ["urn:adsk.wipprod:dm.lineage:abc123"], "projectName": "Adom"}),+    ("fusion_run_modeling_script", "Run an adsk.fusion modeling script in the live session (sketches/extrudes/params). Free programmatic CAD.",+     {"script": "required str (Python using adsk; globals: adsk, app, ui, design)"},+     {"result": "obj", "documentName": "str"}, 180, None, True, {"script": "import adsk.fusion\n# ...build geometry..."}),++    ("fusion_assembly_bom", "Structured, kit-aware MECHANICAL bill of materials for the active Design assembly (the counterpart to the electronics-only fusion_export_bom). Recurses organizational subassemblies but counts a physical part or a purchased kit/unit ONCE, so hardware modeled inside a kit (a '... with fasteners' bracket, a bearing/pulley) is NOT double-counted the way a flat allOccurrences walk counts it. Matches Fusion's Manage -> BOM quantities. Optionally writes a CSV.",+     {"treatAsUnit": "optional [str] name substrings counted as one unit; PASSING IT REPLACES the default ['with fasteners','with fastener','bearing','pulley','idler']", "exclude": "optional [str] top-level name prefixes to skip", "includePhysicalProperties": "optional bool (adds volume_cm3/mass_kg per line)", "outputPath": "optional str (Windows CSV path)", "expectDocument": "optional str -- refuse (wrong_document) if the active doc name isn't this"},+     {"success": "bool", "design": "str", "partCount": "int", "totalInstances": "int", "parts": "[{componentName, partNumber, description, material, quantity, bodies}]"},+     120, None, False, {"treatAsUnit": ["with fasteners", "bearing", "pulley", "idler"], "includePhysicalProperties": True}),++    ("fusion_physical_properties", "Per-component physical properties (volume cm3, mass kg, density, area, center of mass) for the active design. Reads them directly so BOM/costing tools no longer have to run getPhysicalProperties inside a modeling script. Pass names to scope to specific components.",+     {"names": "optional [str] component names to measure (default all)", "accuracy": "optional low|medium|high|veryhigh (default low)", "expectDocument": "optional str -- refuse (wrong_document) if the active doc name isn't this"},+     {"success": "bool", "count": "int", "properties": "{name: {volume_cm3, mass_kg, density, area_cm2, center_of_mass}}"},+     120, None, False, {"accuracy": "low"}),++    ("fusion_inspect_bodies", "Geometry read-back per BRep body in the active design: bbox (mm), volume (mm3), area (mm2), faceCount, cylindricalFaceCount, appearance, material. The verification channel for programmatic CAD - catches bugs a screenshot hides (a body at the wrong Z, a part buried inside another) because matte parts on a dark canvas read poorly in pixels. All lengths are mm (converted from Fusion's internal cm - no more off-by-ten). cylindricalFaceCount is the cheap 'did the hole get cut' check: a symmetric cut that reaches nothing lowers it with no exception. worldSpace:true (or occurrence:<name>) resolves bodies through their occurrence transforms to verify assembly placement instead of component-local coords.",+     {"worldSpace": "optional bool (default false; true = world coords via occurrence transforms)", "occurrence": "optional str fullPathName/component name to restrict to (implies worldSpace)", "includePhysicalProperties": "optional bool (default true; volume/area cost a getPhysicalProperties per body)", "expectDocument": "optional str -- refuse (wrong_document) if the active doc name isn't this"},+     {"success": "bool", "units": "str (mm)", "worldSpace": "bool", "count": "int", "bodies": "[{name, bbox:{x,y,z:[min,max]}, volume, area, faceCount, cylindricalFaceCount, appearance, material}]"},+     120, None, False, {"worldSpace": False}),++    ("fusion_set_preference", "Set Fusion appearance/navigation preferences in the LIVE session - applies immediately, NO restart. Keys: theme (light/darkblue/darkgray/classic/device/dark; some builds only ship LightGray/DarkBlue/Device, failures reported per-key), invertScrollZoom (bool - mouse scroll/zoom direction), orbitScheme (fusion360/alias/inventor/solidworks/tinkercad/powermill), modelingOrientation (yup/zup), gestureNav (bool), cameraPivot (bool), lengthUnit (mm/cm/m/in/ft - active design). Per-key outcome in `applied`, resulting state in `current`.",+     {"theme": "optional str", "invertScrollZoom": "optional bool", "orbitScheme": "optional str", "modelingOrientation": "optional str", "gestureNav": "optional bool", "cameraPivot": "optional bool", "lengthUnit": "optional str"},+     {"applied": "obj", "current": "obj"}, 30, None, True, {"theme": "dark"}),++    ("fusion_get_preferences", "Read current Fusion appearance/navigation preferences (theme, activeTheme, invertScrollZoom, orbitScheme, modelingOrientation, gestureNav, cameraPivot, lengthUnit) as friendly values - the same keys fusion_set_preference accepts.",+     {}, {"current": "obj"}, 20, None, False, {}),++    # ── CAD & 3D export ────────────────────────────────────────────────────+    ("fusion_export_step", "Export the active design to STEP (needs the 3D view).",+     {"outputPath": "required str"}, {"format": "str", "fileSizeKB": "int"}, 300, None, True, {"outputPath": "C:/out/board.step"}),+    ("fusion_export_iges", "Export to IGES.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/board.iges"}),+    ("fusion_export_stl", "Export to STL mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.stl"}),+    ("fusion_export_3mf", "Export to 3MF mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.3mf"}),+    ("fusion_export_usdz", "Export to USDZ (AR / Hydrogen 3D).", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.usdz"}),+    ("fusion_export_optimized_glb", "Fusion board -> wiki-grade optimized GLB. Exports STEP (+ optional silkscreen) and submits it to service-step2glb molecule mode (anchor to MP pins + silk bake + gold pins + join/weld/prune + Draco): ~465KB/~31 draw calls vs a raw ~16MB/~25000 that halts the viewer. Bounded-waits `wait`s (default 90); small boards return complete, big boards return {pending, jobId} - finish with fusion_fetch_optimized_glb. Use for a component page's component.parts.model_3d. STACKUP RULE (John 2026-07-15, see pcb-stackup skill): board bbox = the FR4 body, NEVER the whole assembly (components overhang edges + tower above); each overlay goes at ITS layer's z (copper at FR4-top, silk above mask); copper traces are REAL 35um 3D bodies colored like the FR4 - recolor them + translucent mask in the RAW GLB before optimizing to make routing visible; put the measured stackup table on the board's wiki page. PUBLISH EVERYTHING: a board's wiki page repo must carry EVERY exportable design file (.f3d/.step/.usdz/EAGLE .brd+.sch/Fusion .fbrd+.fsch/gerbers/BOM/CPL/GLBs/renders), not just the 3D - the page exists to share the design.", {"outputPath": "required str (.glb)", "silkscreen": "bool default true", "pin": "str medium|large default medium", "wait": "int seconds default 15 (keep under the ~60s relay timeout; re-call fetch until complete)"}, {"glbPath": "str", "jobId": "str", "pending": "bool", "meshesAfter": "int", "sizeBytes": "int", "moleculeAnchored": "bool", "silkscreenApplied": "bool"}, 200, None, True, {"outputPath": "C:/out/board.glb"}),+    ("fusion_fetch_optimized_glb", "Finish a fusion_export_optimized_glb job: bounded-poll the optimizer by jobId and write the GLB to outputPath when complete. Re-call until status=complete (big boards tessellate a few minutes). No Fusion needed.", {"jobId": "required str", "outputPath": "str (.glb)", "wait": "int seconds default 15 (keep under the ~60s relay timeout; re-call fetch until complete)"}, {"glbPath": "str", "status": "str", "pending": "bool", "sizeBytes": "int"}, 150, None, False, {"jobId": "abc123", "outputPath": "C:/out/board.glb"}),+    ("fusion_board_stackup", "Read a board's PHYSICAL fabrication stackup (see the pcb-stackup skill): copper layer count + copper/dielectric thicknesses from the EAGLE design rules (layerSetup/mtCopper/mtIsolate) plus the measured FR4 extent. A PCB is Cu/prepreg/Cu/core/.../Cu - the FR4 is NOT one slab. Use to build the stackup table + real-thickness exploded 3D view, and put a stackup table on every exported board's wiki page.", {}, {"layerSetup": "str", "copperLayers": "list", "copperCount": "int", "copperThickness_mm": "list", "dielectricBonds": "list (prepreg/core order)", "fr4": "{x_mm,y_mm,dielectric_mm}"}, 200, None, True, {}),+    ("fusion_export_obj", "Export to OBJ mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.obj"}),+    ("fusion_export_dxf", "Export 2D DXF.", {"outputPath": "required str"}, {"format": "str"}, 120, None, True, {"outputPath": "C:/out/sketch.dxf"}),+    ("fusion_export_dwg", "Export 2D DWG.", {"outputPath": "required str"}, {"format": "str"}, 120, None, True, {"outputPath": "C:/out/sketch.dwg"}),++    # ── Manufacturing ──────────────────────────────────────────────────────+    ("fusion_export_gerbers", "Run the bundled JLCPCB CAM job (auto 2/4-layer) to produce the gerber + drill ZIP.",+     {"outputDir": "optional str (default C:/tmp/adom-gerbers)"}, {"fileCount": "int", "files": "[{name,size}]", "zipPath": "str"}, 180, None, True, {"outputDir": "C:/tmp/jlc"}),+    ("fusion_export_bom", "Export a Bill of Materials CSV (grouped by value+package) from the open board.",+     {"outputPath": "optional str", "grouped": "optional bool (default true)"}, {"componentCount": "int", "grouped": "bool"}, 60, None, False, {"outputPath": "C:/tmp/bom.csv"}),+    ("fusion_export_cpl", "Export the Component Placement List (pick-and-place) CSV.",+     {"outputPath": "optional str", "side": "optional str (all|top|bottom)"}, {"topCount": "int", "bottomCount": "int"}, 60, None, False, {"outputPath": "C:/tmp/cpl.csv"}),+    ("fusion_set_design_rules", "Load manufacturing-grade DRC into the open board (JLCPCB 2/4-layer profiles).",+     {"action": "optional str (apply|export|show)", "layers": "optional str (auto|2|4)"}, {"activeWorkspace": "str", "description": "str"}, 90, None, False, {"action": "apply", "layers": "2"}),+    ("fusion_load_design_rules", "Load a bundled .edru design-rule set into the board.",+     {"layers": "optional str (auto|2|4)"}, {"success": "bool"}, 90, None, False, {}),+    ("fusion_detect_layers", "Detect 2-layer vs 4-layer board (ULP + CAM comparison).",+     {}, {"layerCount": "int", "copperLayers": "[int]"}, 90, None, False, {}),+    ("fusion_dismiss_blocking_dialogs", "Detect + classify (and optionally dismiss) blocking Fusion dialogs.",+     {}, {"dialogs": "[...]"}, 30, None, False, {}),+    ("fusion_electron_run", "Run an EAGLE/Electron command in the open board (the generic extension point, e.g. RUN <ulp>).",+     {"command": "required str"}, {"success": "bool", "output": "str"}, 90, None, False, {"command": "set confirm yes;RUN 'C:/tmp/jlcpcb_smta_exporter.ulp' 'C:/tmp/jlc'"}),+    ("fusion_electron_zoom", "Smoothly ZOOM the schematic/2D board for a recording (the whole motion is one call). factor>1 zooms in, <1 out; or fit:true. The loop runs in the add-in with a repaint per frame.",+     {"factor": "float (default 2.0)", "fit": "optional bool", "steps": "optional int (default 16)", "frameDelayMs": "optional int (default 35)"},+     {"success": "bool", "output": "str"}, 60, None, True, {"factor": 3, "steps": 20}),+    ("fusion_electron_pan", "Smoothly PAN + zoom the view to frame a board-coordinate box (mm; get part positions from fusion_board_info). Animates from where the view last was.",+     {"x1": "float", "y1": "float", "x2": "float", "y2": "float", "steps": "optional int (default 16)", "frameDelayMs": "optional int (default 35)"},+     {"success": "bool", "output": "str", "box": "[...]"}, 60, None, True, {"x1": 0, "y1": 0, "x2": 10, "y2": 8}),+    ("fusion_electron_select", "SELECT a part (by reference designator, or x/y board mm) and surface its properties - the way clicking a part reveals the properties panel. Great for showing life in a recording.",+     {"name": "str (refdes, e.g. R1)", "x": "optional float", "y": "optional float", "properties": "optional bool (default true)"},+     {"success": "bool", "output": "str", "deviceInfo": "str"}, 30, None, True, {"name": "U1"}),++    # ── Self-describe ──────────────────────────────────────────────────────+    ("fusion_describe", "Self-describe every verb the bridge exposes (this list) for AD's Verbs tab + runner.",+     {}, {"success": "bool", "verbs": "[{name,summary,input,output,timeoutSeconds,statusVerb,longRunning,example}]"}, 20, None, False, {}),+]+++def _verbs():+    out = []+    for name, summary, inp, outp, timeout, status, longr, ex in _T:+        if name.endswith("_note"):+            continue+        out.append({+            "name": name,+            "summary": summary,+            "input": inp,+            "output": outp,+            "timeoutSeconds": timeout,+            "statusVerb": status,+            "longRunning": longr,+            "example": ex,+        })+    return out+++def handle_describe(args: dict) -> dict:+    verbs = _verbs()+    # `verbs` at the TOP LEVEL is what AD's Verbs tab reads from the raw /command+    # response. AD's relay strips non-standard top-level fields on the CLI proxy+    # path, so we ALSO nest under `data` (which the relay forwards) for CLI/debug.+    return {+        "success": True,+        "bridge": "fusion360",+        "verbCount": len(verbs),+        "verbs": verbs,+        "data": {"bridge": "fusion360", "verbCount": len(verbs), "verbs": verbs},+    }+--- a/addin/AdomBridge/commands/inspect_bodies.py+++ b/addin/AdomBridge/commands/inspect_bodies.py@@ -0,0 +1,166 @@+"""Geometry read-back for the Adom Bridge (issue #289 follow-up, tier-1 item 2).++Before this verb there was no way to ask the bridge WHAT actually got built — a caller+had to hand-roll body enumeration inside fusion_run_modeling_script and parse it back.+Two real modelling bugs (a body at the wrong Z from a double-applied plane offset; a+handle buried inside its base plate) were invisible in a screenshot but obvious the moment+bounding boxes were printed. This bridge often makes matte-black parts on a dark canvas, so+pixels are a weak verification channel; `fusion_inspect_bodies` is the geometry-truth channel.++Per body it returns: name, bbox (mm), volume (mm^3), area (mm^2), faceCount,+cylindricalFaceCount, appearance, material. cylindricalFaceCount is the cheapest check that+holes actually got cut — a symmetric cut that reaches nothing fails silently with no+exception, and the cylinder-face count drops. All lengths are converted from Fusion's+internal cm to MILLIMETRES so callers stop hitting the off-by-ten between the API (cm) and+the model's mm dimensions.+"""++import adsk.core+import adsk.fusion++# Fusion's internal unit is cm. Convert to mm for output (the units the model is drawn in).+_MM = 10.0            # cm -> mm  (length)+_MM2 = _MM * _MM      # cm^2 -> mm^2 (area)+_MM3 = _MM * _MM * _MM  # cm^3 -> mm^3 (volume)+++def _bbox_mm(bb):+    """adsk BoundingBox3D (cm) -> {"x":[min,max],"y":[...],"z":[...]} in mm, or None."""+    try:+        mn, mx = bb.minPoint, bb.maxPoint+        return {+            "x": [round(mn.x * _MM, 4), round(mx.x * _MM, 4)],+            "y": [round(mn.y * _MM, 4), round(mx.y * _MM, 4)],+            "z": [round(mn.z * _MM, 4), round(mx.z * _MM, 4)],+        }+    except Exception:+        return None+++def _appearance_name(body):+    try:+        ap = body.appearance+        if ap:+            return ap.name+    except Exception:+        pass+    return None+++def _material_name(body):+    try:+        if body.material:+            return body.material.name+    except Exception:+        pass+    return None+++def _count_cyl_faces(body):+    """(faceCount, cylindricalFaceCount). Cylindrical faces are the cheap 'did the hole get+    cut' signal — a cut that reaches nothing leaves the cylinder-wall count lower with no+    exception raised."""+    total = 0+    cyl = 0+    try:+        faces = body.faces+        total = faces.count+        cyl_type = adsk.core.SurfaceTypes.CylinderSurfaceType+        for i in range(faces.count):+            try:+                if faces.item(i).geometry.surfaceType == cyl_type:+                    cyl += 1+            except Exception:+                pass+    except Exception:+        pass+    return total, cyl+++def _measure_body(body, want_pp):+    """One body -> the inspect payload. `body` may be a root-context body or an+    assembly-context proxy (createForAssemblyContext) so bbox/props resolve in world space."""+    entry = {"name": body.name}+    entry["bbox"] = _bbox_mm(body.boundingBox)+    if want_pp:+        try:+            pp = body.physicalProperties+            entry["volume"] = round(pp.volume * _MM3, 4)   # mm^3+            entry["area"] = round(pp.area * _MM2, 4)       # mm^2+        except Exception:+            entry["volume"] = None+            entry["area"] = None+    fc, cyl = _count_cyl_faces(body)+    entry["faceCount"] = fc+    entry["cylindricalFaceCount"] = cyl+    entry["appearance"] = _appearance_name(body)+    entry["material"] = _material_name(body)+    return entry+++def handle_inspect_bodies(app: adsk.core.Application, args: dict) -> dict:+    """Inspect BRep bodies in the active design and return geometry read-back per body.++    args:+      worldSpace     optional bool (default False). False = component-local coords (root+                     component's bRepBodies). True = resolve every occurrence's bodies+                     through their occurrence transform into world/root coords (assemblies).+      occurrence     optional str -- restrict to the occurrence whose fullPathName or+                     component name matches (implies worldSpace).+      includePhysicalProperties  optional bool (default True) -- volume/area cost a+                     getPhysicalProperties call per body; pass False to skip on huge models.+    """+    design = adsk.fusion.Design.cast(app.activeProduct)+    if not design:+        return {"success": False, "error": "No active Fusion Design.",+                "_hint": "Open a design in the Design workspace, then retry."}++    args = args or {}+    occ_filter = args.get("occurrence")+    world = bool(args.get("worldSpace", False)) or bool(occ_filter)+    want_pp = bool(args.get("includePhysicalProperties", True))++    bodies = []+    try:+        if not world:+            # Component-local: the root component's own bodies (no occurrence transform).+            root = design.rootComponent+            for i in range(root.bRepBodies.count):+                bodies.append(_measure_body(root.bRepBodies.item(i), want_pp))+        else:+            # World space: walk occurrences, resolve each body through its occurrence+            # transform via createForAssemblyContext so bbox/props are in world coords.+            for occ in design.rootComponent.allOccurrences:+                if occ_filter:+                    names = (occ.fullPathName or "", occ.component.name if occ.component else "")+                    if occ_filter not in names and occ_filter not in occ.fullPathName:+                        continue+                comp = occ.component+                if not comp:+                    continue+                for i in range(comp.bRepBodies.count):+                    local = comp.bRepBodies.item(i)+                    try:+                        proxy = local.createForAssemblyContext(occ)+                    except Exception:+                        proxy = local+                    entry = _measure_body(proxy, want_pp)+                    entry["occurrence"] = occ.fullPathName+                    bodies.append(entry)+    except Exception as e:+        return {"success": False, "error": str(e)[:200],+                "_hint": "Body enumeration failed. Ensure a Design is active and not mid-edit."}++    return {+        "success": True,+        "worldSpace": world,+        "units": "mm",+        "count": len(bodies),+        "bodies": bodies,+        "_hint": ("Geometry read-back in mm. bbox/volume/area are converted from Fusion's "+                  "internal cm. cylindricalFaceCount is the cheap 'did the hole get cut' check "+                  "-- a symmetric cut that reaches nothing lowers it with no exception. Use "+                  "worldSpace:true (or occurrence:<name>) to verify occurrence placement in an "+                  "assembly instead of component-local coords."),+    }+--- a/addin/AdomBridge/commands/__init__.py+++ b/addin/AdomBridge/commands/__init__.py@@ -1,143 +1,146 @@-"""Command dispatch for the Adom Bridge Fusion 360 add-in."""--import adsk.core--from .app_state import handle_get_app_state-from .document_info import handle_document_info, handle_activate_document-from .export import (-    handle_export_step, handle_export_stl, handle_export_3mf,-    handle_export_f3d, handle_export_usdz, handle_export_fbx,-    handle_export_dxf, handle_export_dwg, handle_export_iges,-    handle_export_obj, handle_export_sat, handle_export_skp,-)-from .design_info import handle_get_design_info-from .electronics import (-    handle_electron_run,-    handle_execute_text_command,-    handle_export_source,-    handle_export_eagle_source,-    handle_open_electronics,-    handle_list_text_commands,-    handle_board_info,-)-from .electron_view import handle_electron_zoom, handle_electron_pan, handle_electron_select-from .open_lbr import handle_open_lbr, handle_export_lbr-from .import_file import handle_import_file-from .parameters import handle_get_parameters, handle_set_parameter-from .screenshot import handle_take_screenshot-from .close_document import handle_close_document, handle_close_all_documents-from .open_electronics_file import handle_open_schematic, handle_open_board, handle_show_3d_board, handle_show_2d_board, handle_show_schematic, handle_import_electronics-from .cloud_documents import (-    handle_save_to_cloud,-    handle_list_cloud_projects,-    handle_list_cloud_files,-    handle_delete_cloud_file,-    handle_create_cloud_folder,-    handle_open_cloud_file,-    handle_open_by_urn,-    handle_search_cloud_files,-    handle_export_cloud_file,-    handle_check_recovery,-    handle_walk_cloud_tree,-)-from .manufacturing import (-    handle_export_bom,-    handle_export_cpl,-    handle_export_gerbers,-    handle_set_design_rules,-    handle_export_board_image,-    handle_detect_layers,-)-from .silkscreen_capture import handle_take_silkscreen_screenshot-from .modeling import handle_run_modeling_script-from .assembly_bom import handle_assembly_bom-from .physical_properties import handle_physical_properties--COMMAND_HANDLERS = {-    # App state-    "get_app_state": handle_get_app_state,-    "document_info": handle_document_info,-    "activate_document": handle_activate_document,-    # 3D CAD commands-    "import_file": handle_import_file,-    "export_step": handle_export_step,-    "export_stl": handle_export_stl,-    "export_3mf": handle_export_3mf,-    "export_f3d": handle_export_f3d,-    "export_usdz": handle_export_usdz,-    "export_fbx": handle_export_fbx,-    "export_dxf": handle_export_dxf,-    "export_dwg": handle_export_dwg,-    "export_iges": handle_export_iges,-    "export_obj": handle_export_obj,-    "export_sat": handle_export_sat,-    "export_skp": handle_export_skp,-    "get_design_info": handle_get_design_info,-    "assembly_bom": handle_assembly_bom,-    "physical_properties": handle_physical_properties,-    "get_parameters": handle_get_parameters,-    "set_parameter": handle_set_parameter,-    "take_screenshot": handle_take_screenshot,-    # Electronics commands (EAGLE via Electron.run)-    "electron_run": handle_electron_run,-    "electron_zoom": handle_electron_zoom,-    "electron_pan": handle_electron_pan,-    "electron_select": handle_electron_select,-    "execute_text_command": handle_execute_text_command,-    "open_electronics": handle_open_electronics,-    "list_text_commands": handle_list_text_commands,-    "board_info": handle_board_info,-    "export_source": handle_export_source,-    "export_eagle_source": handle_export_eagle_source,-    # Library file commands (Document.Open / EXPORT SCRIPT)-    "open_lbr": handle_open_lbr,-    "export_lbr": handle_export_lbr,-    # Document management-    "close_document": handle_close_document,-    "close_all_documents": handle_close_all_documents,-    # Electronics file opening-    "open_schematic": handle_open_schematic,-    "open_board": handle_open_board,-    "show_3d_board": handle_show_3d_board,-    "show_2d_board": handle_show_2d_board,-    "show_schematic": handle_show_schematic,-    "import_electronics": handle_import_electronics,-    # Cloud document management-    "save_to_cloud": handle_save_to_cloud,-    "list_cloud_projects": handle_list_cloud_projects,-    "list_cloud_files": handle_list_cloud_files,-    "delete_cloud_file": handle_delete_cloud_file,-    "create_cloud_folder": handle_create_cloud_folder,-    "open_cloud_file": handle_open_cloud_file,-    "open_by_urn": handle_open_by_urn,-    "check_recovery": handle_check_recovery,-    "search_cloud_files": handle_search_cloud_files,-    "walk_cloud_tree": handle_walk_cloud_tree,-    "export_cloud_file": handle_export_cloud_file,-    # Manufacturing exports (Gerbers, BOM, CPL, DRC, images)-    "export_bom": handle_export_bom,-    "export_cpl": handle_export_cpl,-    "export_gerbers": handle_export_gerbers,-    "set_design_rules": handle_set_design_rules,-    "export_board_image": handle_export_board_image,-    "detect_layers": handle_detect_layers,-    # Silkscreen layer capture (isolated top/bottom PNG)-    "take_silkscreen_screenshot": handle_take_silkscreen_screenshot,-    # In-app parametric modeling (free; runs adsk.fusion in the live session)-    "run_modeling_script": handle_run_modeling_script,-}---def dispatch_command(app: adsk.core.Application, command: str, args: dict) -> dict:-    """Dispatch a command to the appropriate handler.--    All handlers receive the Fusion Application object and an args dict.-    """-    handler = COMMAND_HANDLERS.get(command)-    if handler is None:-        return {-            "success": False,-            "error": f"Unknown add-in command: {command}",-            "_hint": f"Valid add-in commands: {', '.join(sorted(COMMAND_HANDLERS.keys()))}.",-        }-    return handler(app, args)+"""Command dispatch for the Adom Bridge Fusion 360 add-in."""++import adsk.core++from .app_state import handle_get_app_state+from .document_info import handle_document_info, handle_activate_document+from .export import (+    handle_export_step, handle_export_stl, handle_export_3mf,+    handle_export_f3d, handle_export_usdz, handle_export_fbx,+    handle_export_dxf, handle_export_dwg, handle_export_iges,+    handle_export_obj, handle_export_sat, handle_export_skp,+)+from .design_info import handle_get_design_info+from .electronics import (+    handle_electron_run,+    handle_execute_text_command,+    handle_export_source,+    handle_export_eagle_source,+    handle_open_electronics,+    handle_list_text_commands,+    handle_board_info,+)+from .electron_view import handle_electron_zoom, handle_electron_pan, handle_electron_select+from .open_lbr import handle_open_lbr, handle_export_lbr+from .import_file import handle_import_file+from .parameters import handle_get_parameters, handle_set_parameter+from .screenshot import handle_take_screenshot+from .close_document import handle_close_document, handle_close_all_documents+from .open_electronics_file import handle_open_schematic, handle_open_board, handle_show_3d_board, handle_show_2d_board, handle_show_schematic, handle_import_electronics+from .cloud_documents import (+    handle_save_to_cloud,+    handle_list_cloud_projects,+    handle_list_cloud_files,+    handle_delete_cloud_file,+    handle_create_cloud_folder,+    handle_open_cloud_file,+    handle_open_by_urn,+    handle_search_cloud_files,+    handle_export_cloud_file,+    handle_check_recovery,+    handle_walk_cloud_tree,+)+from .manufacturing import (+    handle_export_bom,+    handle_export_cpl,+    handle_export_gerbers,+    handle_set_design_rules,+    handle_export_board_image,+    handle_detect_layers,+)+from .silkscreen_capture import handle_take_silkscreen_screenshot+from .modeling import handle_run_modeling_script+from .assembly_bom import handle_assembly_bom+from .physical_properties import handle_physical_properties+from .inspect_bodies import handle_inspect_bodies++COMMAND_HANDLERS = {+    # App state+    "get_app_state": handle_get_app_state,+    "document_info": handle_document_info,+    "activate_document": handle_activate_document,+    # 3D CAD commands+    "import_file": handle_import_file,+    "export_step": handle_export_step,+    "export_stl": handle_export_stl,+    "export_3mf": handle_export_3mf,+    "export_f3d": handle_export_f3d,+    "export_usdz": handle_export_usdz,+    "export_fbx": handle_export_fbx,+    "export_dxf": handle_export_dxf,+    "export_dwg": handle_export_dwg,+    "export_iges": handle_export_iges,+    "export_obj": handle_export_obj,+    "export_sat": handle_export_sat,+    "export_skp": handle_export_skp,+    "get_design_info": handle_get_design_info,+    "assembly_bom": handle_assembly_bom,+    "physical_properties": handle_physical_properties,+    "inspect_bodies": handle_inspect_bodies,+    "get_parameters": handle_get_parameters,+    "set_parameter": handle_set_parameter,+    "take_screenshot": handle_take_screenshot,+    # Electronics commands (EAGLE via Electron.run)+    "electron_run": handle_electron_run,+    "electron_zoom": handle_electron_zoom,+    "electron_pan": handle_electron_pan,+    "electron_select": handle_electron_select,+    "execute_text_command": handle_execute_text_command,+    "open_electronics": handle_open_electronics,+    "list_text_commands": handle_list_text_commands,+    "board_info": handle_board_info,+    "export_source": handle_export_source,+    "export_eagle_source": handle_export_eagle_source,+    # Library file commands (Document.Open / EXPORT SCRIPT)+    "open_lbr": handle_open_lbr,+    "export_lbr": handle_export_lbr,+    # Document management+    "close_document": handle_close_document,+    "close_all_documents": handle_close_all_documents,+    # Electronics file opening+    "open_schematic": handle_open_schematic,+    "open_board": handle_open_board,+    "show_3d_board": handle_show_3d_board,+    "show_2d_board": handle_show_2d_board,+    "show_schematic": handle_show_schematic,+    "import_electronics": handle_import_electronics,+    # Cloud document management+    "save_to_cloud": handle_save_to_cloud,+    "list_cloud_projects": handle_list_cloud_projects,+    "list_cloud_files": handle_list_cloud_files,+    "delete_cloud_file": handle_delete_cloud_file,+    "create_cloud_folder": handle_create_cloud_folder,+    "open_cloud_file": handle_open_cloud_file,+    "open_by_urn": handle_open_by_urn,+    "check_recovery": handle_check_recovery,+    "search_cloud_files": handle_search_cloud_files,+    "walk_cloud_tree": handle_walk_cloud_tree,+    "export_cloud_file": handle_export_cloud_file,+    # Manufacturing exports (Gerbers, BOM, CPL, DRC, images)+    "export_bom": handle_export_bom,+    "export_cpl": handle_export_cpl,+    "export_gerbers": handle_export_gerbers,+    "set_design_rules": handle_set_design_rules,+    "export_board_image": handle_export_board_image,+    "detect_layers": handle_detect_layers,+    # Silkscreen layer capture (isolated top/bottom PNG)+    "take_silkscreen_screenshot": handle_take_silkscreen_screenshot,+    # In-app parametric modeling (free; runs adsk.fusion in the live session)+    "run_modeling_script": handle_run_modeling_script,+}+++def dispatch_command(app: adsk.core.Application, command: str, args: dict) -> dict:+    """Dispatch a command to the appropriate handler.++    All handlers receive the Fusion Application object and an args dict.+    """+    handler = COMMAND_HANDLERS.get(command)+    if handler is None:+        return {+            "success": False,+            "error": f"Unknown add-in command: {command}",+            "_hint": f"Valid add-in commands: {', '.join(sorted(COMMAND_HANDLERS.keys()))}.",+        }+    return handler(app, args)+--- a/addin/AdomBridge/commands/assembly_bom.py+++ b/addin/AdomBridge/commands/assembly_bom.py@@ -1,140 +1,156 @@-"""Structured, kit-aware mechanical assembly BOM (mirrors Fusion's Manage -> BOM).--The electronics `export_bom` only works on an open PCB board. This is its mechanical-counterpart: it walks the active Design assembly and returns a purchasing BOM that-respects the component hierarchy. It recurses through organizational subassemblies but-counts a physical part or a purchased kit/unit ONCE, so hardware modeled INSIDE a kit-(for example a "... with fasteners" bracket, or a bearing/pulley unit) is not-double-counted the way a flat allOccurrences walk would count it.-"""--import csv--import adsk.core-import adsk.fusion---def _material_name(comp):-    try:-        if comp.material:-            return comp.material.name-    except Exception:-        pass-    try:-        if comp.bRepBodies.count > 0 and comp.bRepBodies.item(0).material:-            return comp.bRepBodies.item(0).material.name-    except Exception:-        pass-    return ""---def _attr(comp, name):-    try:-        v = getattr(comp, name, "") or ""-        return v.strip()-    except Exception:-        return ""---def handle_assembly_bom(app: adsk.core.Application, args: dict) -> dict:-    """Return a structured, kit-aware parts list for the active mechanical design.--    args:-      treatAsUnit  optional list of name substrings whose subassemblies are counted once-                   and NOT exploded (default: ["with fasteners"]).-      exclude      optional list of top-level component-name prefixes to skip entirely.-      includePhysicalProperties  optional bool -- add volume_cm3 / mass_kg per line.-      outputPath   optional str -- also write a CSV to this Windows path.-    """-    design = adsk.fusion.Design.cast(app.activeProduct)-    if not design:-        return {"success": False, "error": "No active Fusion Design.",-                "_hint": "Open a mechanical design in the Design workspace, then retry."}--    args = args or {}-    kit = [k.lower() for k in (args.get("treatAsUnit") or ["with fasteners", "with fastener"])]-    exclude = tuple(args.get("exclude") or [])-    include_pp = bool(args.get("includePhysicalProperties", False))--    def is_leaf(comp):-        n = comp.name.lower()-        if any(k in n for k in kit):-            return True-        return comp.bRepBodies.count > 0  # a physical part is a leaf--    parts = {}--    def walk(occs):-        for i in range(occs.count):-            occ = occs.item(i)-            comp = occ.component-            name = comp.name-            if any(name.startswith(e) for e in exclude):-                continue-            children = occ.childOccurrences-            has_children = children is not None and children.count > 0-            if is_leaf(comp):-                row = parts.get(name)-                if row is None:-                    row = {-                        "componentName": name,-                        "partNumber": _attr(comp, "partNumber"),-                        "description": _attr(comp, "description"),-                        "material": _material_name(comp),-                        "quantity": 1,-                        "bodies": comp.bRepBodies.count,-                    }-                    if include_pp:-                        try:-                            pp = comp.getPhysicalProperties(-                                adsk.fusion.CalculationAccuracy.LowCalculationAccuracy)-                            row["volume_cm3"] = round(pp.volume, 4)-                            row["mass_kg"] = round(pp.mass, 6)-                        except Exception:-                            row["volume_cm3"] = None-                            row["mass_kg"] = None-                    parts[name] = row-                else:-                    row["quantity"] += 1-            elif has_children:-                walk(children)--    walk(design.rootComponent.occurrences)-    rows = list(parts.values())--    doc_name = design.rootComponent.name-    try:-        if design.parentDocument:-            doc_name = design.parentDocument.name-    except Exception:-        pass--    out = {-        "success": True,-        "design": doc_name,-        "partCount": len(rows),-        "totalInstances": sum(r["quantity"] for r in rows),-        "treatAsUnit": kit,-        "parts": rows,-    }--    outpath = args.get("outputPath")-    if outpath:-        try:-            cols = ["Part Number", "Part Name", "Description", "Material", "Quantity"]-            if include_pp:-                cols += ["Volume cm3", "Mass kg"]-            with open(outpath, "w", newline="", encoding="utf-8") as f:-                w = csv.writer(f)-                w.writerow(cols)-                for r in rows:-                    line = [r["partNumber"], r["componentName"], r["description"],-                            r["material"], r["quantity"]]-                    if include_pp:-                        line += [r.get("volume_cm3"), r.get("mass_kg")]-                    w.writerow(line)-            out["outputPath"] = outpath-        except Exception as e:-            out["csvError"] = str(e)--    return out+"""Structured, kit-aware mechanical assembly BOM (mirrors Fusion's Manage -> BOM).++The electronics `export_bom` only works on an open PCB board. This is its mechanical+counterpart: it walks the active Design assembly and returns a purchasing BOM that+respects the component hierarchy. It recurses through organizational subassemblies but+counts a physical part or a purchased kit/unit ONCE, so hardware modeled INSIDE a kit+(for example a "... with fasteners" bracket, or a bearing/pulley unit) is not+double-counted the way a flat allOccurrences walk would count it.+"""++import csv++import adsk.core+import adsk.fusion+++def _material_name(comp):+    try:+        if comp.material:+            return comp.material.name+    except Exception:+        pass+    try:+        if comp.bRepBodies.count > 0 and comp.bRepBodies.item(0).material:+            return comp.bRepBodies.item(0).material.name+    except Exception:+        pass+    return ""+++def _attr(comp, name):+    try:+        v = getattr(comp, name, "") or ""+        return v.strip()+    except Exception:+        return ""+++def handle_assembly_bom(app: adsk.core.Application, args: dict) -> dict:+    """Return a structured, kit-aware parts list for the active mechanical design.++    args:+      treatAsUnit  optional list of name substrings whose subassemblies are counted once+                   and NOT exploded. Passing this REPLACES the default list below.+      exclude      optional list of top-level component-name prefixes to skip entirely.+      includePhysicalProperties  optional bool -- add volume_cm3 / mass_kg per line.+      outputPath   optional str -- also write a CSV to this Windows path.+    """+    design = adsk.fusion.Design.cast(app.activeProduct)+    if not design:+        return {"success": False, "error": "No active Fusion Design.",+                "_hint": "Open a mechanical design in the Design workspace, then retry."}++    args = args or {}+    # Default collapses common PURCHASED subassemblies so the walk matches Fusion's own+    # Manage -> BOM (one collapsible row per purchased unit) instead of exploding a bought+    # part into its modeled internals. Without "bearing"/"pulley"/"idler" a GT2 idler+    # emitted 240 bearing balls + seals + rings -- rows Fusion never shows at that level;+    # adding them took a test walk 744 -> 424 instances with every fastener count unchanged.+    # NOTE: partNumber CANNOT auto-detect a purchased unit -- Fusion auto-fills it from the+    # component name, so modeled internals carry part numbers too (96/98 on the test rig).+    # Name-pattern matching is the only structural signal; pass an explicit treatAsUnit for+    # a purchased subassembly whose name doesn't match these.+    _DEFAULT_UNITS = ["with fasteners", "with fastener", "bearing", "pulley", "idler"]+    kit = [k.lower() for k in (args.get("treatAsUnit") or _DEFAULT_UNITS)]+    exclude = tuple(args.get("exclude") or [])+    include_pp = bool(args.get("includePhysicalProperties", False))++    def is_leaf(comp):+        n = comp.name.lower()+        if any(k in n for k in kit):+            return True+        return comp.bRepBodies.count > 0  # a physical part is a leaf++    parts = {}++    def walk(occs):+        for i in range(occs.count):+            occ = occs.item(i)+            comp = occ.component+            name = comp.name+            if any(name.startswith(e) for e in exclude):+                continue+            children = occ.childOccurrences+            has_children = children is not None and children.count > 0+            if is_leaf(comp):+                row = parts.get(name)+                if row is None:+                    row = {+                        "componentName": name,+                        "partNumber": _attr(comp, "partNumber"),+                        "description": _attr(comp, "description"),+                        "material": _material_name(comp),+                        "quantity": 1,+                        "bodies": comp.bRepBodies.count,+                    }+                    if include_pp:+                        try:+                            pp = comp.getPhysicalProperties(+                                adsk.fusion.CalculationAccuracy.LowCalculationAccuracy)+                            row["volume_cm3"] = round(pp.volume, 4)+                            row["mass_kg"] = round(pp.mass, 6)+                        except Exception:+                            row["volume_cm3"] = None+                            row["mass_kg"] = None+                    parts[name] = row+                else:+                    row["quantity"] += 1+            elif has_children:+                walk(children)++    walk(design.rootComponent.occurrences)+    rows = list(parts.values())++    doc_name = design.rootComponent.name+    try:+        if design.parentDocument:+            doc_name = design.parentDocument.name+    except Exception:+        pass++    out = {+        "success": True,+        "design": doc_name,+        "partCount": len(rows),+        "totalInstances": sum(r["quantity"] for r in rows),+        "treatAsUnit": kit,+        "parts": rows,+        "_hint": ("Collapse is name-pattern based (treatAsUnit substrings, case-insensitive). "+                  "If a PURCHASED subassembly exploded into its internals (bearing balls, "+                  "seals, screws), add its name substring to treatAsUnit and re-run -- Fusion's "+                  "own Manage -> BOM shows one row per purchased unit. partNumber can't be used "+                  "to auto-detect this (Fusion auto-fills it from the component name)."),+    }++    outpath = args.get("outputPath")+    if outpath:+        try:+            cols = ["Part Number", "Part Name", "Description", "Material", "Quantity"]+            if include_pp:+                cols += ["Volume cm3", "Mass kg"]+            with open(outpath, "w", newline="", encoding="utf-8") as f:+                w = csv.writer(f)+                w.writerow(cols)+                for r in rows:+                    line = [r["partNumber"], r["componentName"], r["description"],+                            r["material"], r["quantity"]]+                    if include_pp:+                        line += [r.get("volume_cm3"), r.get("mass_kg")]+                    w.writerow(line)+            out["outputPath"] = outpath+        except Exception as e:+            out["csvError"] = str(e)++    return out+--- a/skills/fusion-driving/SKILL.md+++ b/skills/fusion-driving/SKILL.md@@ -1,219 +1,259 @@-----name: fusion-driving-description: How to SAFELY drive Fusion 360 through the Adom bridge across many steps without losing work - the discipline of reading the screenshot/owned-popup ARRAY after every mutating op and ANALYZING any dialog BEFORE the next step, never blind-dismissing, and never fullscreen-capturing (it forces foreground and disrupts the user). Includes the blocking-dialog catalog with the CORRECT response for each, and how the bridge now returns the dialog array + an analyze-this hint automatically. Read this BEFORE running any multi-step Fusion automation (attaching 3D, saving libraries, closing docs, modeling scripts). Trigger words - drive fusion, fusion dialog, blocking dialog, are you sure you want to close, packages are being uploaded, save was cancelled, ownedPopupCount, screenshot array, owned popups, fusion notification, lower right error, dismiss dialog, fusion modal, fusion_screenshot_all, fusion_window_info, fusion_check_dialogs, fullscreen capture fusion, analyze before proceeding, foreground vs background, does driving fusion foreground it, run fusion in the background, background screenshot, foreground fusion, focus steal, capture fusion without raising it, drive fusion while user works.------# Driving Fusion 360 safely (read the popup array, never fly blind)--Fusion is a desktop app you are operating headlessly. **Almost any mutating operation can pop a-modal dialog or a notification you cannot see unless you look** - and if you barrel ahead (or-blind-dismiss), you destroy work. This skill is the operating discipline that keeps that from-happening. It is the foundation under [fusion-libraries](../fusion-libraries/SKILL.md),-[fusion-multipart-libraries](../fusion-multipart-libraries/SKILL.md), and-[fusion-cloud-save](../fusion-cloud-save/SKILL.md).--## ⛔ The cautionary tale (why this skill exists, 2026-06-28)--Building a 10-part library, the loop force-closed generator docs that were **still uploading 3D-packages to the Fusion Hub**. Fusion popped *"Packages are being uploaded... if you close you will-lose these changes. Are you sure you want to close?"* The automation was sleeping through a blind-`dismiss_blocking_dialogs` loop, which clicked straight through it - **closing the library before it-ever saved. All 10 attaches were lost.** Every rule below is the fix for one beat of that failure.--## Background by default: what foregrounds Fusion, and what does not--The whole bridge is built so you can drive Fusion **while it stays in the background** and the user-keeps working in their foreground app. This is deliberate, and it works. The add-in runs *inside*-Fusion on its main thread and manipulates the document, view and workspace through the Fusion API,-which changes state **without activating or raising the window**. Captures use `PrintWindow`/WGC,-which grab the exact window even when it is occluded or minimized. So opening designs, switching to-the schematic/board/3D view, running scripts, exporting, and screenshotting are **all background**.--> ⚠️ Learned the hard way (2026-07-24): an AI refused to grab Fusion screenshots because it-> *believed* driving Fusion for a screenshot would foreground it and disrupt the user. **It does-> not.** Driving Fusion through the add-in + hwnd capture never raises the window. Do not repeat that-> mistake, and do not tell the user Fusion has to come to the front to be captured.--**Background (safe, never disturbs the user):**-- **Open files:** `fusion_aps_open`, `fusion_open_lbr`, `handle_open_cloud_file` / `_by_urn`.-- **Change view or workspace via the add-in:** schematic, 2D board, 3D PCB, Design/Manage,-  `fusion_show_2d_board`, `fusion_show_3d_board`. These switch through the API and do NOT raise the-  window.-- **Run the add-in:** `fusion_run_modeling_script`, `fusion_execute_text_command` / `electron_run`,-  every export, BOM, parameters, `fusion_attach_3d_package`, `fusion_close_document`.-- **Capture by hwnd:** `fusion_screenshot_all`, `fusion_screenshot_fusion`,-  `desktop_screenshot_window {hwnd}` (PrintWindow/WGC, occlusion-independent, works minimized).-- **Dismiss a dialog in the background:** `fusion_close_window {hwnd}` (WM_CLOSE via PostMessage, no-  focus steal).--**Foreground (raises Fusion / steals focus, use only deliberately):**-- **`fusion_start`** - launching Fusion brings it up. This is the one legitimate foregrounding action-  (it is the boot).-- **`fusion_send_key`, `fusion_click_fusion`** - OS-level input injection calls `SetForegroundWindow`-  to deliver the key/click, which yanks Fusion to the front (caught 2026-06-29). Prefer the add-in-  API or `close_window` over synthetic input for anything you can express that way.-- **`desktop_screenshot_screen`** (whole-desktop capture) - screen-DC capture forces the target-  toward the foreground AND often grabs the wrong window. Never use it on Fusion (see Rule 2).-- Any explicit OS window activation (`SetForegroundWindow`, focus/raise verbs).--**The rule:** anything expressed through the **add-in API** or an **hwnd-targeted capture** is-background; only **synthetic input** (`send_key`/`click`), **whole-screen capture**, and the-**initial launch** foreground Fusion. Reach for the API path first; fall back to input injection only-when there is no API for it, and expect that to surface the window.--## Guard the active document with `expectDocument` (a tab-switch retargets your writes)--Every mutating verb acts on Fusion's **active document**, and a user switching tabs in Fusion-silently retargets it. A `fusion_run_modeling_script` meant for one design can land in another and-delete bodies out of it (a real near-miss, issue #289). So across a multi-step mutation, pass-**`expectDocument`** with the name you expect to be active:--```bash-adom-desktop fusion_run_modeling_script '{"script":"...","expectDocument":"Monitor arms v1"}'-```--If the active document is not that, the bridge refuses **before executing** and returns-`errorCode: "wrong_document"` with `expected`/`actual`, instead of mutating the wrong file. It is a-**name** check, so if the doc was renamed mid-session, pass the new name. Honoured on every mutating-verb: `run_modeling_script`, `execute_text_command`, `electron_run`, `close_document`,-`close_all_documents`, `import_step`, `set_parameter`, `save_to_cloud`, `delete_cloud_file`.--## Rule 1 - after EVERY mutating op, READ the popup array. Then analyze. Then proceed.--A mutating op = anything that changes Fusion's state: `fusion_run_modeling_script`,-`fusion_execute_text_command`, `fusion_close_document`, `fusion_close_all_documents`,-`fusion_save_lbr`, `fusion_attach_3d_package`, `fusion_open_lbr`, any export, any `electron_run`-that writes.--After it returns, you MUST inspect what owned popups are now up before doing the next step. Three-ways to get the array, in order of preference:--1. **The bridge does it for you (default).** Mutating `fusion_*` verbs now return-   `dialogsDetected`, a `dialogs[]` array (each with `title`, `category`, `resolution`, and a-   `screenshot` path you can Read), and a top-level `_hint`. **When `dialogsDetected > 0, STOP and-   analyze the dialogs[] before your next call.** Never ignore that hint. (See "How the bridge-   enforces this" below.)-2. **`fusion_screenshot_all`** - captures the main window PLUS every Qt dialog window as separate-   shots, and returns the list of saved paths. Read each.-3. **`desktop_screenshot_window {"hwnd": <fusion-hwnd>}`** (AD core) - returns `screenshots[]` and-   **`ownedPopupCount`**. `ownedPopupCount > 0` means dialogs are stacked on the window. Read the-   shots. Get the hwnd from `fusion_window_info` (its `hwnd` field) or `desktop_find_window-   {"titleContains":"Autodesk Fusion"}`.--**To actually SEE a shot - mind WHERE it lands:**-- AD core `desktop_screenshot_window {hwnd}` saves the PNG **into your container** (e.g.-  `/tmp/adom-desktop-screenshots/`) - Read it directly.-- The bridge's own captures (the auto `dialogArray[].screenshot` paths and `fusion_screenshot_all`)-  are saved on the **Windows box** (`C:/tmp/conduit-screenshots/`). To see one, either re-capture that-  hwnd with `desktop_screenshot_window {hwnd}` (lands in your container) or `desktop_pull_file` it over.--Either way, LOOK - do not describe it from memory.--## Rule 2 - NEVER fullscreen-capture. Always target the window by hwnd.--- ⛔ **Do NOT use `desktop_screenshot_screen`** (whole-desktop capture). It grabs whatever is in the-  foreground (often the user's browser, not Fusion), and screen-DC capture paths force the target-  toward the foreground - **which disrupts the user, who may be doing something else.** It also-  routinely captures the WRONG window.-- ✅ **Always capture a specific `hwnd`** (`fusion_screenshot_all`, `fusion_screenshot_fusion`,-  `desktop_screenshot_window {hwnd}`). These use `PrintWindow` / WGC and capture the **exact window-  in the BACKGROUND** without bringing it forward. Fusion keeps running behind whatever the user is-  doing. This is the only acceptable way to look at Fusion.--## Rule 3 - NEVER blind-dismiss. Classify first, then choose the RIGHT action.--A dialog has a correct answer that depends on what it says. Clicking the default can be catastrophic-(clicking "Yes" on *"sure you want to close, you'll lose changes?"* = data loss). So:--1. Identify the dialog (the `category`/`resolution` from `dialogs[]`, or Read its screenshot).-2. Choose the action that PRESERVES work, from the catalog below.-3. Only use a generic dismiss as a LAST resort, and only for dialogs you have confirmed are safe to-   cancel/escape.--To act on a specific dialog, target its `hwnd`: `fusion_click_fusion {"hwnd":<dlg>, ...}`,-`fusion_send_key {"hwnd":<dlg>, "key":"escape"}`, or `fusion_close_window {"hwnd":<dlg>}`.--### ⛔ Dismiss in the BACKGROUND - `close_window` (WM_CLOSE), NEVER `send_key`/Escape--When you DO dismiss a dialog, use **`fusion_close_window {"hwnd":<dlg>}`** (WM_CLOSE via PostMessage =-Cancel/No). It dismisses **without stealing focus**. ⛔ **Do NOT use `fusion_send_key`/Escape to expire-a dialog** - `send_key` calls `SetForegroundWindow` and **YANKS Fusion to the foreground**, disrupting-the user (caught 2026-06-29: an Escape pulled Fusion to the front mid-task). WM_CLOSE is also more-reliable than Escape on Qt dialogs. The bridge's own auto-dismiss paths (`_dismiss_dialogs_bg`, the-long-command pre-dismiss) all use `close_window` for exactly this reason. Background screenshots +-background dismiss = the user never sees Fusion jump around.--## The blocking-dialog catalog (title substring -> what it means -> do THIS)--| Dialog (title/body contains) | Meaning | Correct response |-|---|---|---|-| **"packages are being uploaded", "lose these changes", "are you sure you want to close"** | A doc is closing while 3D packages still upload to the Hub. Closing = **lose the packages + unsaved bindings**. | Click **No**. WAIT and poll until the upload finishes (see [fusion-cloud-save](../fusion-cloud-save/SKILL.md)), THEN close/save. NEVER click Yes. |-| **"cannot be saved while packages are being uploaded", "save was cancelled"** | You tried saveAs mid-upload; Fusion refused. | Not fatal. Wait for the Hub upload to finish, then retry the saveAs (poll past it). |-| **"needs to update", "update is available", "software update"** | Fusion wants to self-update. | Do NOT auto-confirm (it can update mid-automation). Clear on the desktop or let it finish, then retry. A pending update also causes add-in/host version-drift crashes. |-| **"select electronics design", "linked to multiple"** | A file links to multiple electronics designs; CEF picker. | Its list is NOT Win32/keyboard navigable. Open the specific design by URN/API instead, or select on the desktop. See [fusion-electronics](../fusion-electronics/SKILL.md). |-| **"recovery", "recover unsaved", "document recovery"** | Document Recovery prompt. | `fusion_dismiss_recovery`, or `fusion_relocate_recovery` BEFORE `fusion_start` to prevent it. |-| **"save changes", "unsaved changes", "do you want to save"** | Unsaved-changes prompt on close. | Use `fusion_close_document` (closes without the save modal). Only Escape/Cancel if you mean to keep editing. |-| **"what do you want to design"** | Fusion's start picker (no doc open). | `fusion_send_key {"key":"escape"}`. |-| **"create new symbol/footprint/device '<name>'?"** | An `EDIT <name>.sym/.pac/.dev` was run with a name Fusion can't find as that object - usually a DEVICESET name passed to `.sym`/`.pac` whose symbol/package is named by the shared combo (e.g. `R-0603`, not `R-0603-10K`). | Dismiss in the BACKGROUND (`fusion_close_window {hwnd}` = No - never create a stray). Then re-`EDIT` with the correct symbol/package name. `fusion_capture_library_views` now auto-handles this. |-| bare **"Fusion360"** modal during an open | Usually the linked-design picker. | `fusion_screenshot_fusion` to confirm, then handle as the picker. |--(The bridge classifies these in `handlers/dialog_classify.py`. When you discover a new one, add a-rule there with its correct response so every future session handles it right.)--## Rule 4 - the lower-right notification tray (errors/warnings you keep missing)--Fusion shows non-modal toasts in the **lower-right** ("1 warning(s), 1 error(s)..."). They are NOT-modal dialogs, so dialog enumeration may not flag them - but they tell you an operation half-failed.-They DO render in an hwnd-targeted main-window screenshot. So after a risky op, also glance at the-main-window shot's lower-right, not just the popup array. If you see an error count, open the-notification center and read it before continuing.--## Rule 5 - don't DESTABILIZE Fusion (it crashes more easily than you think)--Long automation runs take Fusion DOWN if you abuse it - learned the hard way over a multi-hour run-where Fusion died repeatedly. The culprits:--- **Blanket "close all documents" loops** (`while app.documents.count>0: app.documents.item(0).close(False)`)-  run before every op to "reset state". This churns Fusion hard and can crash it or leave it wedged.-  Close ONLY what you need (`fusion_close_document`, or close the one stray doc), never everything.-- **Back-to-back heavy operations** - import + saveAs + Create3DPackage in a tight loop, dozens of-  times, plus repeated `bridge_install` respawns - destabilize the session.--So: minimize document churn, do NOT reset-by-closing-everything, save your work to the cloud as you go-([fusion-cloud-save](../fusion-cloud-save/SKILL.md)) and **reopen from the cloud** after a reset (the-in-session view is often wedged). After a `bridge_install` respawn Fusion can report "not running"-even when the process is alive - a `fusion_start` re-establishes the connection. And if Fusion dies-mid-run, that was almost certainly YOU, not the user - own it, don't blame them.--## How the bridge enforces this (so you cannot miss it)--`_post_open_screenshot()` has always auto-captured the main window + every dialog after *open*-commands and told the AI to read them. That same capture now fires after **all mutating verbs** via-`_capture_dialog_array()`, which:--- cheaply enumerates dialog windows (`get_fusion_window_info`, a Win32 `EnumWindows` - no screenshot-  when nothing is up),-- suppresses false positives: a docked panel (Browser/Timeline) shares the "Fusion360" title and can-  slip past the size filter, but it does NOT disable the main window. So a generic-titled candidate is-  only surfaced when the main window is actually disabled (`mainWindowEnabled:false` - a real modal is-  up) or it matches a known dialog by title. That keeps the hint from crying wolf on every op,-- on detection, classifies each (`classify_blocking_dialogs`) and screenshots it by hwnd-  (`screenshot_hwnd`, background `PrintWindow`),-- returns `dialogsDetected` + `dialogs[]` + a top-level `_hint` that says **ANALYZE these before-  proceeding; do NOT blind-dismiss**.--You can also call **`fusion_check_dialogs`** at any time to get the current array on demand (e.g.-while polling a long upload). The point: the array and the hint come back to you automatically - your-job is to actually READ them and act, every time.--## The loop that should be muscle memory--```-do mutating op-  -> read dialogsDetected / dialogs[] / ownedPopupCount in the response (or fusion_check_dialogs)-  -> if a dialog is up: Read its screenshot, look it up in the catalog, take the work-preserving action-  -> glance at the main-window shot's lower-right for an error toast-  -> only then do the next op-```--Slow is smooth, smooth is fast. The one time you skip the look is the time you lose the library.+---+name: fusion-driving+description: How to SAFELY drive Fusion 360 through the Adom bridge across many steps without losing work - the discipline of reading the screenshot/owned-popup ARRAY after every mutating op and ANALYZING any dialog BEFORE the next step, never blind-dismissing, and never fullscreen-capturing (it forces foreground and disrupts the user). Includes the blocking-dialog catalog with the CORRECT response for each, and how the bridge now returns the dialog array + an analyze-this hint automatically. Read this BEFORE running any multi-step Fusion automation (attaching 3D, saving libraries, closing docs, modeling scripts). Trigger words - drive fusion, fusion dialog, blocking dialog, are you sure you want to close, packages are being uploaded, save was cancelled, ownedPopupCount, screenshot array, owned popups, fusion notification, lower right error, dismiss dialog, fusion modal, fusion_screenshot_all, fusion_window_info, fusion_check_dialogs, fullscreen capture fusion, analyze before proceeding, foreground vs background, does driving fusion foreground it, run fusion in the background, background screenshot, foreground fusion, focus steal, capture fusion without raising it, drive fusion while user works.+---++# Driving Fusion 360 safely (read the popup array, never fly blind)++Fusion is a desktop app you are operating headlessly. **Almost any mutating operation can pop a+modal dialog or a notification you cannot see unless you look** - and if you barrel ahead (or+blind-dismiss), you destroy work. This skill is the operating discipline that keeps that from+happening. It is the foundation under [fusion-libraries](../fusion-libraries/SKILL.md),+[fusion-multipart-libraries](../fusion-multipart-libraries/SKILL.md), and+[fusion-cloud-save](../fusion-cloud-save/SKILL.md).++## ⛔ The cautionary tale (why this skill exists, 2026-06-28)++Building a 10-part library, the loop force-closed generator docs that were **still uploading 3D+packages to the Fusion Hub**. Fusion popped *"Packages are being uploaded... if you close you will+lose these changes. Are you sure you want to close?"* The automation was sleeping through a blind+`dismiss_blocking_dialogs` loop, which clicked straight through it - **closing the library before it+ever saved. All 10 attaches were lost.** Every rule below is the fix for one beat of that failure.++## Background by default: what foregrounds Fusion, and what does not++The whole bridge is built so you can drive Fusion **while it stays in the background** and the user+keeps working in their foreground app. This is deliberate, and it works. The add-in runs *inside*+Fusion on its main thread and manipulates the document, view and workspace through the Fusion API,+which changes state **without activating or raising the window**. Captures use `PrintWindow`/WGC,+which grab the exact window even when it is occluded or minimized. So opening designs, switching to+the schematic/board/3D view, running scripts, exporting, and screenshotting are **all background**.++> ⚠️ Learned the hard way (2026-07-24): an AI refused to grab Fusion screenshots because it+> *believed* driving Fusion for a screenshot would foreground it and disrupt the user. **It does+> not.** Driving Fusion through the add-in + hwnd capture never raises the window. Do not repeat that+> mistake, and do not tell the user Fusion has to come to the front to be captured.++**Background (safe, never disturbs the user):**+- **Open files:** `fusion_aps_open`, `fusion_open_lbr`, `handle_open_cloud_file` / `_by_urn`.+- **Change view or workspace via the add-in:** schematic, 2D board, 3D PCB, Design/Manage,+  `fusion_show_2d_board`, `fusion_show_3d_board`. These switch through the API and do NOT raise the+  window.+- **Run the add-in:** `fusion_run_modeling_script`, `fusion_execute_text_command` / `electron_run`,+  every export, BOM, parameters, `fusion_attach_3d_package`, `fusion_close_document`.+- **Capture by hwnd:** `fusion_screenshot_all`, `fusion_screenshot_fusion`,+  `desktop_screenshot_window {hwnd}` (PrintWindow/WGC, occlusion-independent, works minimized).+- **Dismiss a dialog in the background:** `fusion_close_window {hwnd}` (WM_CLOSE via PostMessage, no+  focus steal).++**Foreground (raises Fusion / steals focus, use only deliberately):**+- **`fusion_start`** - launching Fusion brings it up. This is the one legitimate foregrounding action+  (it is the boot).+- **`fusion_send_key`, `fusion_click_fusion`** - OS-level input injection calls `SetForegroundWindow`+  to deliver the key/click, which yanks Fusion to the front (caught 2026-06-29). Prefer the add-in+  API or `close_window` over synthetic input for anything you can express that way.+- **`desktop_screenshot_screen`** (whole-desktop capture) - screen-DC capture forces the target+  toward the foreground AND often grabs the wrong window. Never use it on Fusion (see Rule 2).+- Any explicit OS window activation (`SetForegroundWindow`, focus/raise verbs).++**The rule:** anything expressed through the **add-in API** or an **hwnd-targeted capture** is+background; only **synthetic input** (`send_key`/`click`), **whole-screen capture**, and the+**initial launch** foreground Fusion. Reach for the API path first; fall back to input injection only+when there is no API for it, and expect that to surface the window.++## Guard the active document with `expectDocument` (a tab-switch retargets your writes)++Every mutating verb acts on Fusion's **active document**, and a user switching tabs in Fusion+silently retargets it. A `fusion_run_modeling_script` meant for one design can land in another and+delete bodies out of it (a real near-miss, issue #289). So across a multi-step mutation, pass+**`expectDocument`** with the name you expect to be active:++```bash+adom-desktop fusion_run_modeling_script '{"script":"...","expectDocument":"Monitor arms v1"}'+```++If the active document is not that, the bridge refuses **before executing** and returns+`errorCode: "wrong_document"` with `expected`/`actual`, instead of mutating the wrong file. It is a+**name** check, so if the doc was renamed mid-session, pass the new name. Honoured on every mutating+verb: `run_modeling_script`, `execute_text_command`, `electron_run`, `close_document`,+`close_all_documents`, `import_step`, `set_parameter`, `save_to_cloud`, `delete_cloud_file`.++Also honoured on **read** verbs where a silently-wrong result is easy to act on and hard to notice:+`inspect_bodies`, `assembly_bom`, `physical_properties`, `document_info`. (A read-only+`fusion_assembly_bom` once returned a completely different assembly after a tab-switch — same+retarget hole, no data harmed but the wrong numbers were easy to trust. Pass `expectDocument` on a+BOM/geometry read the same way.)++## Verify geometry you built with `fusion_inspect_bodies` (pixels are a weak check)++After a `fusion_run_modeling_script`, a screenshot is a poor way to confirm what you built — matte+parts on a dark canvas hide a body at the wrong Z or a part buried inside another, and a symmetric+cut that reached nothing raises no exception. Ask the bridge what actually exists:++```bash+adom-desktop fusion_inspect_bodies '{"worldSpace": true, "expectDocument": "Monitor arms v1"}'+```++Per body you get `bbox` (**in mm**, converted from Fusion's internal cm — no more off-by-ten),+`volume` (mm³), `area` (mm²), `faceCount`, **`cylindricalFaceCount`** (the cheap "did the hole+actually get cut" signal — it drops when a cut reaches nothing), `appearance`, and `material`.+`worldSpace:true` (or `occurrence:"<fullPathName>"`) resolves bodies through their occurrence+transforms so you're verifying real assembly placement, not component-local coords. Assert the bbox+matches your intended dimensions before moving on — it catches the bugs a render can't.++## Raw Fusion API errors now carry a stable `errorCode` + `_hint`++A `RuntimeError` from an `adsk.*` call (e.g. inside a modeling script) used to come back as bare+text. Common ones are now classified like the dialog catalog:++- **`part_design_single_component`** — "Part Design documents can only contain one component": the+  doc was made from Fusion's *Part* template. Keep everything as bodies in the root, or create the+  doc via `documents.add(FusionDesignDocumentType)` (what `run_modeling_script` does) rather than+  reusing a Part-template doc.+- **`root_rename_unsupported`** — "root component name cannot be changed": the root takes the+  document's name; rename on save instead.+- **`stale_api_handle`** — "An API Object refers to a deleted Object": a cached handle outlived a+  close/activate/tab-switch. Re-fetch it from the current active document and retry.++Unrecognized API errors pass through with their original text unchanged.++## Rule 1 - after EVERY mutating op, READ the popup array. Then analyze. Then proceed.++A mutating op = anything that changes Fusion's state: `fusion_run_modeling_script`,+`fusion_execute_text_command`, `fusion_close_document`, `fusion_close_all_documents`,+`fusion_save_lbr`, `fusion_attach_3d_package`, `fusion_open_lbr`, any export, any `electron_run`+that writes.++After it returns, you MUST inspect what owned popups are now up before doing the next step. Three+ways to get the array, in order of preference:++1. **The bridge does it for you (default).** Mutating `fusion_*` verbs now return+   `dialogsDetected`, a `dialogs[]` array (each with `title`, `category`, `resolution`, and a+   `screenshot` path you can Read), and a top-level `_hint`. **When `dialogsDetected > 0, STOP and+   analyze the dialogs[] before your next call.** Never ignore that hint. (See "How the bridge+   enforces this" below.)+2. **`fusion_screenshot_all`** - captures the main window PLUS every Qt dialog window as separate+   shots, and returns the list of saved paths. Read each.+3. **`desktop_screenshot_window {"hwnd": <fusion-hwnd>}`** (AD core) - returns `screenshots[]` and+   **`ownedPopupCount`**. `ownedPopupCount > 0` means dialogs are stacked on the window. Read the+   shots. Get the hwnd from `fusion_window_info` (its `hwnd` field) or `desktop_find_window+   {"titleContains":"Autodesk Fusion"}`.++**To actually SEE a shot - mind WHERE it lands:**+- AD core `desktop_screenshot_window {hwnd}` saves the PNG **into your container** (e.g.+  `/tmp/adom-desktop-screenshots/`) - Read it directly.+- The bridge's own captures (the auto `dialogArray[].screenshot` paths and `fusion_screenshot_all`)+  are saved on the **Windows box** (`C:/tmp/conduit-screenshots/`). To see one, either re-capture that+  hwnd with `desktop_screenshot_window {hwnd}` (lands in your container) or `desktop_pull_file` it over.++Either way, LOOK - do not describe it from memory.++## Rule 2 - NEVER fullscreen-capture. Always target the window by hwnd.++- ⛔ **Do NOT use `desktop_screenshot_screen`** (whole-desktop capture). It grabs whatever is in the+  foreground (often the user's browser, not Fusion), and screen-DC capture paths force the target+  toward the foreground - **which disrupts the user, who may be doing something else.** It also+  routinely captures the WRONG window.+- ✅ **Always capture a specific `hwnd`** (`fusion_screenshot_all`, `fusion_screenshot_fusion`,+  `desktop_screenshot_window {hwnd}`). These use `PrintWindow` / WGC and capture the **exact window+  in the BACKGROUND** without bringing it forward. Fusion keeps running behind whatever the user is+  doing. This is the only acceptable way to look at Fusion.++## Rule 3 - NEVER blind-dismiss. Classify first, then choose the RIGHT action.++A dialog has a correct answer that depends on what it says. Clicking the default can be catastrophic+(clicking "Yes" on *"sure you want to close, you'll lose changes?"* = data loss). So:++1. Identify the dialog (the `category`/`resolution` from `dialogs[]`, or Read its screenshot).+2. Choose the action that PRESERVES work, from the catalog below.+3. Only use a generic dismiss as a LAST resort, and only for dialogs you have confirmed are safe to+   cancel/escape.++To act on a specific dialog, target its `hwnd`: `fusion_click_fusion {"hwnd":<dlg>, ...}`,+`fusion_send_key {"hwnd":<dlg>, "key":"escape"}`, or `fusion_close_window {"hwnd":<dlg>}`.++### ⛔ Dismiss in the BACKGROUND - `close_window` (WM_CLOSE), NEVER `send_key`/Escape++When you DO dismiss a dialog, use **`fusion_close_window {"hwnd":<dlg>}`** (WM_CLOSE via PostMessage =+Cancel/No). It dismisses **without stealing focus**. ⛔ **Do NOT use `fusion_send_key`/Escape to expire+a dialog** - `send_key` calls `SetForegroundWindow` and **YANKS Fusion to the foreground**, disrupting+the user (caught 2026-06-29: an Escape pulled Fusion to the front mid-task). WM_CLOSE is also more+reliable than Escape on Qt dialogs. The bridge's own auto-dismiss paths (`_dismiss_dialogs_bg`, the+long-command pre-dismiss) all use `close_window` for exactly this reason. Background screenshots ++background dismiss = the user never sees Fusion jump around.++## The blocking-dialog catalog (title substring -> what it means -> do THIS)++| Dialog (title/body contains) | Meaning | Correct response |+|---|---|---|+| **"packages are being uploaded", "lose these changes", "are you sure you want to close"** | A doc is closing while 3D packages still upload to the Hub. Closing = **lose the packages + unsaved bindings**. | Click **No**. WAIT and poll until the upload finishes (see [fusion-cloud-save](../fusion-cloud-save/SKILL.md)), THEN close/save. NEVER click Yes. |+| **"cannot be saved while packages are being uploaded", "save was cancelled"** | You tried saveAs mid-upload; Fusion refused. | Not fatal. Wait for the Hub upload to finish, then retry the saveAs (poll past it). |+| **"needs to update", "update is available", "software update"** | Fusion wants to self-update. | Do NOT auto-confirm (it can update mid-automation). Clear on the desktop or let it finish, then retry. A pending update also causes add-in/host version-drift crashes. |+| **"select electronics design", "linked to multiple"** | A file links to multiple electronics designs; CEF picker. | Its list is NOT Win32/keyboard navigable. Open the specific design by URN/API instead, or select on the desktop. See [fusion-electronics](../fusion-electronics/SKILL.md). |+| **"recovery", "recover unsaved", "document recovery"** | Document Recovery prompt. | `fusion_dismiss_recovery`, or `fusion_relocate_recovery` BEFORE `fusion_start` to prevent it. |+| **"save changes", "unsaved changes", "do you want to save"** | Unsaved-changes prompt on close. | Use `fusion_close_document` (closes without the save modal). Only Escape/Cancel if you mean to keep editing. |+| **"what do you want to design"** | Fusion's start picker (no doc open). | `fusion_send_key {"key":"escape"}`. |+| **"create new symbol/footprint/device '<name>'?"** | An `EDIT <name>.sym/.pac/.dev` was run with a name Fusion can't find as that object - usually a DEVICESET name passed to `.sym`/`.pac` whose symbol/package is named by the shared combo (e.g. `R-0603`, not `R-0603-10K`). | Dismiss in the BACKGROUND (`fusion_close_window {hwnd}` = No - never create a stray). Then re-`EDIT` with the correct symbol/package name. `fusion_capture_library_views` now auto-handles this. |+| bare **"Fusion360"** modal during an open | Usually the linked-design picker. | `fusion_screenshot_fusion` to confirm, then handle as the picker. |++(The bridge classifies these in `handlers/dialog_classify.py`. When you discover a new one, add a+rule there with its correct response so every future session handles it right.)++## Rule 4 - the lower-right notification tray (errors/warnings you keep missing)++Fusion shows non-modal toasts in the **lower-right** ("1 warning(s), 1 error(s)..."). They are NOT+modal dialogs, so dialog enumeration may not flag them - but they tell you an operation half-failed.+They DO render in an hwnd-targeted main-window screenshot. So after a risky op, also glance at the+main-window shot's lower-right, not just the popup array. If you see an error count, open the+notification center and read it before continuing.++## Rule 5 - don't DESTABILIZE Fusion (it crashes more easily than you think)++Long automation runs take Fusion DOWN if you abuse it - learned the hard way over a multi-hour run+where Fusion died repeatedly. The culprits:++- **Blanket "close all documents" loops** (`while app.documents.count>0: app.documents.item(0).close(False)`)+  run before every op to "reset state". This churns Fusion hard and can crash it or leave it wedged.+  Close ONLY what you need (`fusion_close_document`, or close the one stray doc), never everything.+- **Back-to-back heavy operations** - import + saveAs + Create3DPackage in a tight loop, dozens of+  times, plus repeated `bridge_install` respawns - destabilize the session.++So: minimize document churn, do NOT reset-by-closing-everything, save your work to the cloud as you go+([fusion-cloud-save](../fusion-cloud-save/SKILL.md)) and **reopen from the cloud** after a reset (the+in-session view is often wedged). After a `bridge_install` respawn Fusion can report "not running"+even when the process is alive - a `fusion_start` re-establishes the connection. And if Fusion dies+mid-run, that was almost certainly YOU, not the user - own it, don't blame them.++## How the bridge enforces this (so you cannot miss it)++`_post_open_screenshot()` has always auto-captured the main window + every dialog after *open*+commands and told the AI to read them. That same capture now fires after **all mutating verbs** via+`_capture_dialog_array()`, which:++- cheaply enumerates dialog windows (`get_fusion_window_info`, a Win32 `EnumWindows` - no screenshot+  when nothing is up),+- suppresses false positives: a docked panel (Browser/Timeline) shares the "Fusion360" title and can+  slip past the size filter, but it does NOT disable the main window. So a generic-titled candidate is+  only surfaced when the main window is actually disabled (`mainWindowEnabled:false` - a real modal is+  up) or it matches a known dialog by title. That keeps the hint from crying wolf on every op,+- on detection, classifies each (`classify_blocking_dialogs`) and screenshots it by hwnd+  (`screenshot_hwnd`, background `PrintWindow`),+- returns `dialogsDetected` + `dialogs[]` + a top-level `_hint` that says **ANALYZE these before+  proceeding; do NOT blind-dismiss**.++You can also call **`fusion_check_dialogs`** at any time to get the current array on demand (e.g.+while polling a long upload). The point: the array and the hint come back to you automatically - your+job is to actually READ them and act, every time.++## The loop that should be muscle memory++```+do mutating op+  -> read dialogsDetected / dialogs[] / ownedPopupCount in the response (or fusion_check_dialogs)+  -> if a dialog is up: Read its screenshot, look it up in the catalog, take the work-preserving action+  -> glance at the main-window shot's lower-right for an error toast+  -> only then do the next op+```++Slow is smooth, smooth is fast. The one time you skip the look is the time you lose the library.+

Comments

No comments yet.

Log in to comment.