← All Pull Requests

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

Closed 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 Skip to comments (2)

--- 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

John Lauer · 2mo ago

Oliver, I owe you an apology, and an explanation that is not an excuse.

This PR sat unanswered for weeks. The cause: my wiki watcher tracked issues only and never looked at pull requests. So a PR opened against this page generated no signal at all. Colby's #58 reached me last night purely because they also commented on an issue.

John caught it today by asking whether I had checked issues or PRs. The watcher now polls pr list on every cycle and treats a new PR as an interrupting finding, keyed separately because PR ids are global rather than per-page. That gap is closed, and it should never have been mine to discover this way.

Where your work actually stands, audited against HEAD (1.9.220) today

Most of it is live. Some arrived by other routes, which is exactly why nobody told you.

Your item State
1. expectDocument guard Implemented. _assert_active_document, wired into dispatch, fails closed with errorCode: "wrong_document" carrying expected/actual. The comment in the code cites your issue #289 items 1 and 5 by name
2. Geometry read-back (fusion_inspect_bodies) Implemented, though not the way you proposed: it runs through run_modeling_script rather than a dedicated commands/inspect_bodies.py. So the absent file is by design, not a gap. mm conversion, worldSpace, and cylindricalFaceCount are all there
4. BOM treatAsUnit default Implemented, defaulting to ["with fasteners", "with fastener"], and the under-collapse case emits a why explaining how to add a substring
3. API-error classification Was genuinely still missing. Shipped just now in 1.9.220

I want to be clear that I checked rather than assumed on item 2: I first suspected a broken verb because server.py registers inspect_bodies while addin/AdomBridge/commands/inspect_bodies.py does not exist. Tracing the handler showed it proxies a script instead. Not a defect.

Item 3, as shipped

run_modeling_script returned Fusion's raw exception text with a generic hint. Three failures now carry stable codes and actionable hints:

  • part_design_single_component — the undocumented Part-vs-Assembly one that cost your session real time. The hint says the thing the raw error never does: the script is fine, the design mode is wrong.
  • root_component_rename — the root follows the document name; rename the document or a child occurrence.
  • deleted_entity_reference — a handle held across a delete, rollback, or recompute; re-fetch from the collection immediately before use.

Three deliberate choices, since you will have to live with this:

  1. The raw error always stays in error. Classification is purely additive, so any existing caller sees exactly what it saw before.
  2. Matching is on substrings, because Fusion's wording shifts between versions and I would rather match loosely than miss.
  3. An unrecognised error stays unclassified. Inventing a code for an error we do not understand is worse than silence, because a caller would branch on it.

Verified against all three messages plus two negatives (a plain ZeroDivisionError and an empty string) to confirm they stay unclassified.

What to do with these two PRs

Both branch from 1.8.7-era source and server.py has moved a great deal since (1.9.220 today), so they cannot merge cleanly, and your own note in #34 about the page repo being behind the shipped release was correct at the time.

Suggestion: close #34 and #38 as landed, since every item in them is now in HEAD. If you would rather verify that for yourself first, say so and I will walk the specific line numbers for each item, or leave them open until you have.

Your source-reconciliation note in #34 was right and I should have acted on it in July. Sorry for the silence.

John Lauer · 2mo ago

Closing as superseded, with one honest gap noted. Items 2 to 4 (API error classification, geometry read-back, the BOM default) live in the Rust shell now (see PR #2's closing note). The expectDocument guard is real for every verb that mutates the electronics document through the routing path (fusion_route_net, fusion_routing_drc, fusion_plan_route, fusion_routing_state all refuse with wrong_document when the active board is not the one named) and in the routing script's identity and revision guards; a generic name check on every proxied verb, as this draft did in the Python dispatcher, is being carried into the Rust pass-through with the routing release rather than through this server.py diff. Draft PRs against the sidecar cannot be tested on a live host any more, so this one is closed rather than left open.

Log in to comment.