← All Pull Requests

Fusion bridge: implement the (documented-but-missing) expectDocument guard, classify API errors, geometry read-back, loud BOM under-collapse #38

Open Draft opened by Oliver 2026-08-10

Addresses the four items from Oliver's bridge feedback thread (2026-07-23 CAD session + BOM session). All changes are additive / low-risk; draft because they are untested against a live Fusion (no Windows+Fusion host in the authoring environment) — please run once before merge.

The headline: expectDocument was documented but never implemented

skills/fusion-driving/SKILL.md told callers to pass expectDocument to guard against a tab-switch retargeting their writes (issue #289, a real near-miss data loss). But no code path read the argument — neither the dispatcher nor the add-in — so passing it was a silent no-op. Users had a false data-loss guarantee.

This PR makes it real, in the bridge dispatcher (server.py), so it covers a verb the moment it proxies to the add-in — both mutating and read verbs (the read-verb case matters: the BOM session silently returned a different assembly, 68/268 vs 94/424, after a passive tab switch).

Changes

  1. server.py — real expectDocument guard (#1 + #5). New _guard_expect_document() reads the live active-document name via get_app_state and fails closed with errorCode:"wrong_document" (+expected/actual) before the verb runs. Wired into dispatch_command after the readiness/busy gates. No-op unless the caller passes expectDocument. It is a name check (matches the documented contract); a stable lineage-id assertion is noted as a follow-up.

  2. addin/AdomBridge/commands/modeling.py — classify raw API errors (#3). New _classify_modeling_error() maps the three failures that cost the session real time into stable codes + actionable hints, in the bridge's usual shape: part_design_single_component (the expensive, undocumented Part-vs-Assembly one), root_component_rename, deleted_object_reference. Unrecognised errors keep the existing generic hint.

  3. addin/AdomBridge/commands/design_info.py — geometry read-back (#2, partial). fusion_get_design_info bodies now carry bbox_mm (mm, from body.boundingBox — Fusion's internal cm ×10), faceCount, and cylindricalFaceCount. These are exactly the two signals that caught the session's invisible bugs: a body at the wrong Z, and holes that never got cut (a symmetric cut reaching nothing fails silently). Component-local only; the requested worldSpace occurrence-transform resolution is deferred (needs live-Fusion validation).

  4. addin/AdomBridge/commands/assembly_bom.py — loud under-collapse (#4). Rather than widen the shipped treatAsUnit default (which would silently change everyone's counts, and there is no purely structural signal separating a purchased vs organizational subassembly), the BOM now flags parts that look like the internals of a purchased unit (bearing ball/seal, idler, pulley, …) via possibleUnderCollapse + a _hint suggesting a treatAsUnit list. Non-behaviour-changing — counts are unchanged, only advisory fields are added. If you'd rather change the default, that's a one-liner and I'm happy to switch it.

  5. skills/fusion-driving/SKILL.md — updated to match: the guard now covers read verbs too, and is enforced in the dispatcher.

Not included (author flagged as separate, and one product decision left to you)

  • Stronger #1: target a document by stable id without activating it (this PR does the name-check the skill promised).
  • #2 worldSpace/occurrence resolution and a dedicated fusion_inspect_bodies verb.
  • #4: whether to widen the default treatAsUnit vs keep the advisory-hint approach — your call.
  • Non-bridge: sketch.modelToSketchSpace() does not project onto the sketch plane (belongs in a skill/doc).

All four Python files compile clean (py_compile).

🤖 Generated with Claude Code

Diff

--- a/server.py+++ b/server.py@@ -1,5941 +1,5998 @@⋯ 2217 unchanged lines ⋯         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+def _guard_expect_document(args: dict):+    """Enforce the caller's `expectDocument` assertion BEFORE a verb runs.++    Every fusion_* verb acts on Fusion's *active* document, and a user (or an+    auto-expanding panel) switching tabs silently retargets the next call. This+    is a documented near-miss: it deleted bodies out of an unrelated design+    (issue #289), and on a READ verb it silently returned a completely different+    BOM (68/268 vs the expected 94/424) with nothing signalling the switch.++    The `expectDocument` guard was DOCUMENTED in the fusion-driving skill but no+    code path actually read the argument, so passing it was a silent no-op — a+    false safety guarantee. This wires it up for real: when the caller passes+    {"expectDocument": "<name>"}, verify the active document IS that before we+    proxy the verb, and fail closed with a stable `wrong_document` code+    otherwise. Honoured on BOTH mutating and read verbs (issue #289 asked for+    mutating; the read-verb BOM repro asked for reads too).++    Returns an error dict to short-circuit on mismatch, or None to proceed.+    NOTE: this is a NAME check — if the document was renamed mid-session, pass+    the current name. A stable lineage-id assertion is tracked as a follow-up.+    """+    expected = (args or {}).get("expectDocument")+    if not expected:+        return None+    state = _proxy_to_addin("get_app_state", {}, timeout=8)+    actual = None+    if isinstance(state, dict):+        actual = state.get("activeDocument")+        if actual is None and isinstance(state.get("data"), dict):+            actual = state["data"].get("activeDocument")+    if actual != expected:+        return {+            "success": False,+            "errorCode": "wrong_document",+            "expected": expected,+            "actual": actual,+            "_hint": (+                "REFUSED before running: the active Fusion document is not the one you "+                "asserted with expectDocument, so the verb was not executed (it would "+                "otherwise have acted on the wrong file). Re-activate the intended "+                "document with fusion_activate_document, or re-issue with expectDocument "+                "set to the current active document. This is a NAME check: if the doc was "+                "renamed mid-session, pass the new name."+            ),+        }+    return None+++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++    # Honour the caller's expectDocument assertion on ANY add-in verb (mutating OR+    # read) before it can act on the wrong active document. A no-op unless the+    # caller passed expectDocument. Runs after the readiness/busy gates so a+    # not-running/blocked error surfaces first. (issue #289 + read-verb BOM repro.)+    _doc_guard = _guard_expect_document(args)+    if _doc_guard is not None:+        return _doc_guard++    # 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+--- a/addin/AdomBridge/commands/modeling.py+++ b/addin/AdomBridge/commands/modeling.py@@ -1,68 +1,111 @@⋯ 21 unchanged lines ⋯ import adsk.fusion  +def _classify_modeling_error(msg: str):+    """Map common raw Fusion API exceptions to a stable errorCode + actionable hint.++    Raw adsk exceptions surface as bare 'RuntimeError: N : <text>' strings that+    honour neither the bridge's stable-errorCode convention nor its _hint one, so+    an AI driving the bridge has to infer the (non-obvious) fix. These three cost+    real session time to diagnose (bridge feedback, 2026-07-23); the Part-vs-+    Assembly one invalidates a whole modelling approach and is undocumented. This+    mirrors the dialog classifier (handlers/dialog_classify.py) one layer down.++    Returns (errorCode, hint) or (None, None) if unrecognised.+    """+    m = (msg or "").lower()+    if "can only contain one component" in m or "part design document" in m:+        return ("part_design_single_component",+                "This document was created from Fusion's PART template, which allows "+                "only ONE component (no assemblies) — that is why the operation was "+                "rejected. Create the document via the API with "+                "documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType) for an "+                "assembly-capable doc, or model with bodies instead of components. Note "+                "the API and UI defaults for new-document type differ.")+    if "root component name cannot be changed" in m:+        return ("root_component_rename",+                "The ROOT component takes the DOCUMENT's name and cannot be renamed "+                "directly. Rename on save instead (fusion_save_to_cloud with name=...), "+                "or rename a child component rather than the root.")+    if "refers to a deleted object" in m:+        return ("deleted_object_reference",+                "A handle you held now points at an object that was deleted, or whose "+                "document was closed, since you fetched it. Re-fetch the object from the "+                "live design (re-query the component/body/sketch) before using it; do "+                "not cache API proxies across edits that delete/rebuild geometry.")+    return (None, None)++ def handle_run_modeling_script(app: adsk.core.Application, args: dict) -> dict:     script = args.get("script") or args.get("code") or ""     if not script:⋯ 25 unchanged lines ⋯         with contextlib.redirect_stdout(buf):             exec(script, ns)     except Exception as e:-        return {"success": False, "error": str(e),-                "data": {"traceback": traceback.format_exc(), "stdout": buf.getvalue()},-                "_hint": "Modeling-script error (a code/geometry problem, NOT the Fusion license). "-                         "Read-only Fusion still creates geometry in memory; only cloud-save is blocked."}+        code, hint = _classify_modeling_error(str(e))+        resp = {"success": False, "error": str(e),+                "data": {"traceback": traceback.format_exc(), "stdout": buf.getvalue()}}+        if code:+            resp["errorCode"] = code+            resp["_hint"] = hint+        else:+            resp["_hint"] = ("Modeling-script error (a code/geometry problem, NOT the Fusion "+                             "license). Read-only Fusion still creates geometry in memory; only "+                             "cloud-save is blocked.")+        return resp      res = ns.get("result")     # Keep the return JSON-safe.⋯ 3 unchanged lines ⋯     return {"success": True,             "output": out or "Modeling script ran successfully.",             "data": {"result": res, "documentName": app.activeDocument.name if app.activeDocument else None}}+--- a/addin/AdomBridge/commands/design_info.py+++ b/addin/AdomBridge/commands/design_info.py@@ -1,98 +1,133 @@⋯ 52 unchanged lines ⋯     bodies = []     for i in range(comp.bRepBodies.count):         body = comp.bRepBodies.item(i)-        bodies.append({+        entry = {             "name": body.name,             "isVisible": body.isVisible,             "isSolid": body.isSolid,             "volume_cm3": body.volume if body.isSolid else None,-        })+        }+        # Geometry read-back so an AI driving the bridge can verify what it BUILT+        # without eyeballing a matte-black part on a dark canvas, where pixels are a+        # weak verification channel (bridge feedback, 2026-07-23). Two real bugs in+        # that session were invisible in the render but obvious here:+        #   - bbox_mm catches mis-placed/offset geometry (a platform built at Z-167.6+        #     instead of -83.8 because an offset plane's offset got applied twice).+        #   - cylindricalFaceCount is the cheapest "did the holes actually get cut?"+        #     signal: a symmetric cut that reaches nothing fails SILENTLY (no+        #     exception), and the cylindrical-face count is what surfaces it.+        # Units in mm (Fusion's internal unit is cm) to kill off-by-ten errors.+        # NOTE: this bbox is component-LOCAL; assembly placement verification through+        # occurrence transforms (the requested worldSpace flag) is a tracked follow-up.+        try:+            bb = body.boundingBox+            if bb:+                entry["bbox_mm"] = {+                    "x": [round(bb.minPoint.x * 10.0, 3), round(bb.maxPoint.x * 10.0, 3)],+                    "y": [round(bb.minPoint.y * 10.0, 3), round(bb.maxPoint.y * 10.0, 3)],+                    "z": [round(bb.minPoint.z * 10.0, 3), round(bb.maxPoint.z * 10.0, 3)],+                }+        except Exception:+            pass+        try:+            faces = body.faces+            entry["faceCount"] = faces.count+            cyl = 0+            for fi in range(faces.count):+                geom = faces.item(fi).geometry+                if geom and geom.surfaceType == adsk.core.SurfaceTypes.CylinderSurfaceType:+                    cyl += 1+            entry["cylindricalFaceCount"] = cyl+        except Exception:+            pass+        bodies.append(entry)      # Also check mesh bodies (STL/3MF imports create these)     mesh_bodies = []⋯ 31 unchanged lines ⋯         pass      return result+--- a/addin/AdomBridge/commands/assembly_bom.py+++ b/addin/AdomBridge/commands/assembly_bom.py@@ -1,140 +1,168 @@⋯ 117 unchanged lines ⋯         "parts": rows,     } +    # The default treatAsUnit under-collapses relative to Fusion's own Manage->BOM:+    # a PURCHASED subassembly whose name doesn't contain "with fasteners" gets+    # descended into and emitted as its modeled internals (e.g. 240 "Bearing Ball"+    # rows that Fusion's BOM never shows), and nothing signalled it (bridge feedback,+    # 2026-07-23). There is no purely structural signal separating a purchased vs an+    # organizational subassembly, so rather than widen the default (which would silently+    # change counts for everyone), make the mismatch LOUD: flag parts that look like the+    # internals of a bought unit and suggest a treatAsUnit list. Non-behaviour-changing —+    # counts are unchanged; only advisory fields are added.+    _INTERNAL_HINTS = ("bearing ball", "bearing seal", "ball bearing", "idler",+                       "pulley", "roller", "retaining ring", "circlip", "detent ball")+    flagged = sorted({r["componentName"] for r in rows+                      if any(h in r["componentName"].lower() for h in _INTERNAL_HINTS)})+    if flagged:+        out["possibleUnderCollapse"] = flagged+        out["_hint"] = (+            "Some parts look like the INTERNALS of a purchased subassembly (e.g. "+            + ", ".join(flagged[:3])+            + ") that Fusion's own Manage->BOM would show as a single collapsed row. The "+            "default treatAsUnit only collapses names containing 'with fasteners', so a "+            "purchased unit named differently is exploded into its modeled parts and your "+            "counts run high. If these belong to a bought unit, re-run with treatAsUnit "+            "including that subassembly's name (e.g. "+            "treatAsUnit=[\"with fasteners\",\"idler pulley\",\"bearing\"]) to match Fusion's "+            "BOM. partNumber can't auto-detect this — Fusion auto-fills it from the "+            "component name, so the internals carry part numbers too.")+     outpath = args.get("outputPath")     if outpath:         try:⋯ 14 unchanged lines ⋯             out["csvError"] = str(e)      return out+--- a/skills/fusion-driving/SKILL.md+++ b/skills/fusion-driving/SKILL.md@@ -1,219 +1,224 @@⋯ 62 unchanged lines ⋯  ## 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:+Every fusion verb acts on Fusion's **active document**, and a user (or an auto-expanding panel)+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); a **read** verb like+`fusion_assembly_bom` can just as silently return a completely different assembly. So across a+multi-step session, 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`.+`errorCode: "wrong_document"` with `expected`/`actual`, instead of acting on the wrong file. It is a+**name** check, so if the doc was renamed mid-session, pass the new name. Honoured on **every add-in+verb — both mutating and read** (e.g. `run_modeling_script`, `execute_text_command`, `electron_run`,+`close_document`, `close_all_documents`, `import_step`, `set_parameter`, `save_to_cloud`,+`delete_cloud_file`, and the read verbs `assembly_bom`, `physical_properties`, `board_info`,+`get_design_info`). The guard is enforced in the bridge dispatcher, so it covers a verb the moment it+proxies to the add-in.  ## Rule 1 - after EVERY mutating op, READ the popup array. Then analyze. Then proceed. ⋯ 134 unchanged lines ⋯ ```  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.