app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
v1.6.26: adopt AD 1.9.84 direct-API notify_user (cross-AD) + fix readiness needsSignin + proven sign-in playbook (disc #78)
8 files changed
+328−45
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.6.25+1.6.26
CHANGELOG.md+23@@ -3,6 +3,29 @@ All notable changes to the Adom Desktop Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/adom-desktop-fusion-bridge · GitHub backup: `adom-inc/adom-desktop-fusion-bridge` +## 1.6.26 - 2026-07-05++- **Adopted AD 1.9.84's direct API (disc #78): the bridge now calls `notify_user` ITSELF, cross-AD.**+ New `ad_client.py` talks back to AD over `$ADOM_DIRECT_API_URL/command` with the+ `X-Adom-Bridge-Token` attribution header. It **capability-probes `GET /commands`** (not hardcoded)+ so it degrades gracefully on older AD, enumerates peer ADs via `targets`, and can route any verb to+ a peer with a cross-AD `target`. **Dropped the old "AD can't notify" workaround** (returning a+ `notifyUser` payload for the driving AI to relay). On a launch that surfaces a seat/licensing+ **DECISION** dialog (a true wall that can suspend/close Fusion on the user's OTHER machine), the+ bridge now fires `notify_user` directly with `reach_user=True` — fanning out to `"all"` peers when+ it may be running unattended on a VM, so the toast lands wherever the user is. Response carries+ `notifyDelivered`. Sign-in stays automate-first (NOT auto-notified).+- **Fixed `fusion_readiness` lying `ready:true` during sign-in.** Fusion can be running yet blocked by+ the first-run Autodesk sign-in modal (main UI disabled, add-in can't serve). Readiness now detects+ the `signin_required` window via the launch classifier and returns `ready:false` + `needsSignin:true`+ instead of "READY - drive Fusion" (which sent verbs into a wall). Found live on ADOMBASELINE.+- **Baked the PROVEN sign-in sequence into the `needsSignin` hint + `fusion-onboarding` skill**+ (validated live 2026-07-05): clear stale `signin.autodesk.com` tabs (expired codes = "Sign-in+ request expired") → "Retry browser sign-in" for one fresh request → click "Open Product" with+ **`desktop_click` (SendInput=isTrusted)**, NOT `desktop_ui_click` (UIA doesn't fire the protocol) →+ tick "Always allow" + "Open" on the in-page "open Autodesk Identity Manager?" overlay (not an owned+ popup) → poll readiness. Documents the `nbrowser`/CDP headless variant and the isTrusted requirement.+ ## 1.6.25 - 2026-07-04 - **Sign-in is now AUTOMATED-FIRST, with the path baked into the verb's `_hint` + a `notifyUser`
SKILL.md+2−2@@ -25,7 +25,7 @@ Branch on the stable `errorCode`, never the prose: | `running:false` / `fusion_not_running` | installed, not launched | `fusion_start` (returns when the add-in is ready, or `stillLaunching` on a slow first launch) | | `stillLaunching:true` (fusion_start) | first launch, add-in not up yet (can take 2-4 min) | do NOT re-call `fusion_start`; POLL `fusion_readiness` every ~10s until `ready:true` | | `installed:false` / `host_app_not_installed` (AD >=1.9.79, + a `hostApp` field) | no Fusion host app | OFFER to install, then **`fusion_install_fusion`** (no shell approval; elevation-aware, streams the free trial) -> poll `fusion_readiness` (`installing:true`) until `installed:true` -> `fusion_start`. Details: [fusion-onboarding](skills/fusion-onboarding/SKILL.md) |-| `needsSignin:true` (fusion_start) | first-run Autodesk sign-in | AUTOMATE it: check `nbrowser_readiness` - if the native-browser extension drives the browser Fusion opened sign-in in, drive the OAuth via CDP (fast, beats the ~2min request expiry); else `desktop_ui_click` the Sign In / Open Product buttons FAST; only if both fail relay the response's `notifyUser` payload to `notify_user`. NEVER auto-enter password/2FA |+| `needsSignin:true` (`fusion_start` **and** `fusion_readiness`) | Fusion is running but stuck at the first-run Autodesk sign-in (main UI blocked, add-in can't serve, so `ready:false`) | AUTOMATE it (proven sequence, see [fusion-onboarding](skills/fusion-onboarding/SKILL.md)): (1) clear STALE `signin.autodesk.com` tabs (a dead tab = expired code = "Sign-in request expired"); (2) `desktop_ui_click` "Retry browser sign-in" / "Sign In" for ONE fresh request; (3) click "Open Product" with **`desktop_click` (SendInput=isTrusted)** — `desktop_ui_click` (UIA) does NOT fire the protocol; (4) on the in-page "open Autodesk Identity Manager?" overlay (NOT an owned popup) tick "Always allow" + "Open" via `desktop_click`, FAST; (5) poll `fusion_readiness`. `nbrowser` (CDP) can do 3-4 headlessly if it's on the SAME browser window. NEVER auto-enter password/2FA. To ask the user, `notify_user` works directly (AD ≥1.9.84); add a cross-AD `target` if you're on a VM | | `addinStale:true` / `addin_stale` | Fusion runs an OLD add-in that never re-synced (silent-failure risk) | `fusion_stop` then `fusion_start` — the bridge re-syncs the add-in from cache on start (Fusion must be CLOSED) | | `bridge_restarting` + `stillRunning:true` (AD ≥1.9.77) | the bridge is respawning after a `bridge_install` | NEVER a not-installed/fatal signal: poll `fusion_readiness` until `ready:true`, then retry. Read-only verbs (`fusion_readiness`/`fusion_describe`/`fusion_get_app_state`) forward straight through even mid-respawn — no sleep-and-recheck hacks needed | | `blockingDialogs:[…]` | a modal is blocking the add-in | dismiss in the BACKGROUND, then the op auto-resumes |@@ -37,7 +37,7 @@ Branch on the stable `errorCode`, never the prose: 1. **Poll readiness, don't launch blindly.** `fusion_readiness` (or AD's `bridge_readiness`) is read-only — it reports `installed`/`running`/`ready`/`addinStale` WITHOUT launching anything. Loop: readiness → act on the row above → drive. 2. **`fusion_readiness` is the standardized cold-start probe.** (Fusion is a detected HOST APP with no heavy download, so there's no `prewarm` step — readiness is the whole gate.) 3. **Core verbs live in the table below; `fusion_describe '{}'` is the full machine-readable catalog.** Read the table to act fast; hit `describe` for completeness — don't guess a verb.-4. **Background by default — but NOTIFY on every active step.** This bridge drives the AI's workspace, not the user's screen: dismiss dialogs in the BACKGROUND (`fusion_close_window`, never `fusion_send_key`/Escape, which steals focus). **The instant the user must DO something you can't (Autodesk sign-in, 2FA, a UAC/elevation prompt, accepting a license, a manual choice), fire an AD `notify_user` (a toast, and a progress toast for long installs) so they're pulled in — never silently wait on a screen they aren't looking at.** Only foreground Fusion to SHOW the user something. See [fusion-driving](skills/fusion-driving/SKILL.md) and [fusion-onboarding](skills/fusion-onboarding/SKILL.md).+4. **Background by default — automate first, NOTIFY only at a true wall.** This bridge drives the AI's workspace, not the user's screen: dismiss dialogs in the BACKGROUND (`fusion_close_window`, never `fusion_send_key`/Escape, which steals focus). AUTOMATE sign-in / dialogs yourself (see the proven sign-in sequence in [fusion-onboarding](skills/fusion-onboarding/SKILL.md)); reserve an AD **`notify_user`** for something only the user can do — a **UAC/elevation** prompt, a license accept, a **2FA/password** step you must not auto-fill, or a licensing **DECISION** that risks their other machine. **`notify_user` works directly over the bridge's direct API (AD ≥1.9.84) — call it, don't relay a payload for the AI to send.** When this bridge runs UNATTENDED (e.g. on a VM) and the user is elsewhere, add a cross-AD **`target`** (a peer clientName from the `targets` verb, or `"all"`) so the toast lands where they are. Only foreground Fusion to SHOW the user something. See [fusion-driving](skills/fusion-driving/SKILL.md) and [fusion-onboarding](skills/fusion-onboarding/SKILL.md). 5. **`ok:true` is not enough — verify.** A long op that "times out" at ~60s is usually STILL RUNNING server-side (the relay caps the request, not the work). Verify by artifacts (the saved file / a screenshot / `fusion_get_app_state`), don't blindly retry. 6. **Reuse the running Fusion — and when a restart IS needed, YOU do it, never the user.** Fusion is a singleton: `fusion_readiness` before `fusion_start`; if `ready`, just drive. But a stale/missing add-in or a wedged session needs a restart, and that is YOUR job — `fusion_stop` then `fusion_start`. NEVER tell the user "restart Fusion" / "enable the add-in in UTILITIES" — doing it for them is the entire point of this bridge. (Add-in installs land in ALL Fusion add-in dirs — 2025+ Fusion scans `%APPDATA%\Autodesk\FusionAddins`, older builds use `...\Autodesk Fusion[ 360]\API\AddIns` — so a restart always picks it up.) 7. **Surface `_hint` verbatim + branch on `errorCode`.** Every verb returns an actionable `_hint`; relay it to the user unchanged and key your logic off the stable `errorCode`, not the prose.
ad_client.pyadded+174@@ -0,0 +1,174 @@+"""Outbound calls from this bridge BACK to Adom Desktop (AD) core.++Why this exists: as of **AD 1.9.84** a bridge's loopback callback+(`POST $ADOM_DIRECT_API_URL/command`) reaches the FULL AD verb set - not just+`desktop_*`. That means this bridge can, on its own:++ * `notify_user` the user directly (it used to be "Unknown desktop command", so+ the old code returned a `notifyUser` PAYLOAD and asked the driving AI to relay+ it - a workaround, now dropped);+ * enumerate peer ADs on the relay (`targets`) and route ANY verb to one via a+ cross-AD `target` - so a bridge running on a VM can reach the user on their+ LAPTOP ("notify them where they actually are").++Everything here is BEST-EFFORT and never raises: an older AD (pre-1.9.84), a+missing env var, or a closed port just yields None/False and the caller falls+back to its prior behavior. We CAPABILITY-PROBE (`GET /commands`) rather than+hardcode, per the SDK, so this keeps working as the verb set evolves.++Auth model (from disc #78): `X-Adom-Bridge-Token` = ATTRIBUTION only (AD binds+127.0.0.1 and trusts-by-install), so calls run ungated; a stale token 403s, so we+re-read it from the env on every call.+"""++import json+import os+import urllib.request+import urllib.error++_TIMEOUT = 8+# Cache the capability set for the process lifetime (the AD verb surface doesn't+# change under a running AD). None = not-yet-probed / unreachable.+_caps_cache = None+_self_name_cache = None+++def _base_url():+ """The loopback direct-API base AD injects into our spawn env, else None."""+ return (os.environ.get("ADOM_DIRECT_API_URL") or "").rstrip("/") or None+++def _token():+ # Re-read every call: after an AD restart the old token 403s.+ return os.environ.get("ADOM_BRIDGE_TOKEN") or ""+++def _http(method, path, body=None, timeout=_TIMEOUT):+ """Best-effort JSON request to the direct API. Returns parsed dict/list or None."""+ base = _base_url()+ if not base:+ return None+ url = base + path+ data = None+ headers = {"Accept": "application/json"}+ tok = _token()+ if tok:+ headers["X-Adom-Bridge-Token"] = tok+ if body is not None:+ data = json.dumps(body).encode("utf-8")+ headers["Content-Type"] = "application/json"+ req = urllib.request.Request(url, data=data, headers=headers, method=method)+ try:+ with urllib.request.urlopen(req, timeout=timeout) as resp:+ raw = resp.read().decode("utf-8", "replace")+ except Exception:+ return None+ try:+ return json.loads(raw)+ except Exception:+ return None+++def available() -> bool:+ """True if the direct API is reachable at all (env var present + port open)."""+ if not _base_url():+ return False+ return capabilities() is not None+++def capabilities():+ """Set of AD command names from `GET /commands` (cached). None if unreachable.++ Shape-tolerant: accepts {"commands":[...]}, {"commands":[{"name":..}]}, or a+ bare list of names/objects.+ """+ global _caps_cache+ if _caps_cache is not None:+ return _caps_cache+ data = _http("GET", "/commands")+ if data is None:+ return None+ items = data.get("commands") if isinstance(data, dict) else data+ if not isinstance(items, list):+ return None+ names = set()+ for it in items:+ if isinstance(it, str):+ names.add(it)+ elif isinstance(it, dict):+ n = it.get("name") or it.get("command") or it.get("verb")+ if n:+ names.add(n)+ _caps_cache = names+ return names+++def has(command: str):+ """True/False if `command` is in the probed capability set; None if unknown+ (probe failed). Callers should try when the answer isn't an explicit False."""+ caps = capabilities()+ if caps is None:+ return None+ return command in caps+++def call(command: str, args=None, target=None, timeout=_TIMEOUT):+ """Dispatch ANY AD verb back through the direct API. Returns the parsed JSON+ response dict, or None on any failure (never raises).++ target: a peer AD clientName (from `peers()`), or "all" to fan out. Omit to+ run on THIS AD (the one hosting the bridge)."""+ payload = {"command": command}+ if args:+ payload["args"] = args+ if target:+ payload["target"] = target+ return _http("POST", "/command", body=payload, timeout=timeout)+++def self_name():+ """This AD's own clientName (to exclude it from peer routing). Best-effort."""+ global _self_name_cache+ if _self_name_cache is not None:+ return _self_name_cache or None+ st = _http("GET", "/status") or {}+ name = st.get("clientName") or st.get("name") or st.get("hostname")+ _self_name_cache = name or ""+ return name or None+++def peers():+ """clientNames of the OTHER ADs on the relay (excludes self). [] if none/unknown."""+ res = call("targets") or {}+ tlist = res.get("targets") if isinstance(res, dict) else None+ if not isinstance(tlist, list):+ return []+ me = self_name()+ out = []+ for t in tlist:+ n = (t or {}).get("name") or (t or {}).get("hostname")+ if n and n != me:+ out.append(n)+ return out+++def notify(title, body, level="info", buttons=None, target=None, reach_user=False):+ """Fire an AD `notify_user` toast. Returns the response dict, or None if the+ direct API is unreachable or AD is too old to have notify_user (caller falls+ back to returning a notifyUser payload for the driving AI to relay).++ reach_user: when this bridge may be running UNATTENDED (e.g. on a VM) while the+ user is on another machine, set True to also reach peer ADs. Since AD can't yet+ resolve which peer is "attended", we fan out to "all" when peers exist so the+ toast lands wherever the user actually is. `target` (explicit) overrides this.+ """+ if has("notify_user") is False:+ return None+ args = {"title": title, "body": body, "level": level}+ if buttons:+ args["buttons"] = buttons+ tgt = target+ if tgt is None and reach_user and peers():+ tgt = "all"+ res = call("notify_user", args, target=tgt)+ return res
adom-bridge-fusion-manifest.json+6−6@@ -1,13 +1,13 @@ { "manifest_version": 1, "name": "fusion360",- "version": "1.6.25",- "url": "https://wiki.adom.inc/download/adom/adom-desktop-fusion-bridge/1.6.25/adom-bridge-fusion-v1.6.25.zip",- "sha256": "e126d687b368b70e7e8425eacb8da0644931b64fbf152539b1f4bbcb433bca73",- "size": 317211,+ "version": "1.6.26",+ "url": "https://wiki.adom.inc/download/adom/adom-desktop-fusion-bridge/1.6.26/adom-bridge-fusion-v1.6.26.zip",+ "sha256": "b39d0c27ffda6c5c254e2c9728e40e1422ea4c35ab9034049a386d0ebad7c6b8",+ "size": 323405, "verbPrefixes": [ "fusion_" ], "healthEndpoint": "/status",- "released_at": "2026-07-05T00:30:00Z"-}+ "released_at": "2026-07-05T12:20:55Z"+}
bridge.json+2−2@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.6.25",+ "version": "1.6.26", "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.", "homepage": "https://wiki.adom.inc/adom/adom-desktop-fusion-bridge", "author": "Adom Inc.",@@ -177,4 +177,4 @@ } ] }-}+}
server.py+71−21@@ -37,6 +37,7 @@ from handlers.dialog_classify import classify_blocking_dialogs, classify_launch_ from install_addin import install as install_addin import aps import describe+import ad_client DEFAULT_PORT = 8773 ADDIN_PORT = 8774@@ -149,29 +150,52 @@ def _handle_launch(fusion_info: dict, args: dict) -> dict: if d.get("decision") or d.get("category") == "signin_required"] if _needs_user: _signin = any(d.get("category") == "signin_required" for d in _needs_user)+ _decision = any(d.get("decision") for d in _needs_user)+ notify_payload = ({"title": "Fusion needs your Autodesk sign-in",+ "body": "Fusion is at the sign-in screen. Complete it (or I will drive it) "+ "so I can keep going.", "level": "warning"} if _signin else+ {"title": "Fusion launch needs a decision",+ "body": "A licensing dialog needs your choice.", "level": "warning"})+ # A seat/licensing DECISION is a TRUE WALL (must NOT auto-pick - it can+ # suspend or shut down Fusion on the user's OTHER machine, risking its+ # unsaved work). As of AD 1.9.84 we fire notify_user OURSELVES via the+ # direct API, cross-AD (reach_user) so the toast lands on whichever machine+ # the user is at - this bridge may be running unattended on a VM. Sign-in+ # is deliberately NOT auto-notified: automate it first (see hint); the+ # payload is the last-resort escalation only.+ notified = None+ if _decision and ad_client.available():+ notified = ad_client.notify(reach_user=True, **notify_payload) return { "success": True, "needsUserDecision": True, "needsSignin": _signin, "setupDialogs": launch_dialogs, "resolvedPath": fusion_info.get("exe_path", ""),- "notifyUser": ({"title": "Fusion needs your Autodesk sign-in",- "body": "Fusion is at the sign-in screen. Complete it (or I will drive it) "- "so I can keep going.", "level": "warning"} if _signin else- {"title": "Fusion launch needs a decision",- "body": "A licensing dialog needs your choice.", "level": "warning"}),- "_hint": (("First-run Autodesk SIGN-IN required. AUTOMATE IT FIRST, don't punt to the user: "- "(1) check `nbrowser_readiness` - if the native-browser extension is ready AND "- "driving the SAME browser Fusion opened sign-in in (nbrowser_list_tabs shows an "- "autodesk.com/signin/idmgr tab), drive the OAuth via CDP (activate that tab, "- "click 'Allow'/'Open Product' in the DOM) - fast + headless, beats the "- "short-lived request expiry that defeats desktop_ui_click. (2) If nbrowser is "- "NOT on that browser, fall back to desktop_ui_click on the 'Sign In'/'Open "- "Product' buttons, done FAST (the request expires in ~2 min). (3) ONLY if both "- "fail, relay the `notifyUser` payload above to notify_user and ask for one "- "click. NEVER auto-enter the password/2FA. " if _signin else "")- + ("A licensing/seat DECISION dialog is up - do NOT auto-pick; relay `notifyUser` "- "to notify_user + ASK. " if any(d.get("decision") for d in _needs_user) else "")+ "notifyUser": notify_payload,+ "notifyDelivered": bool(notified and (notified.get("action") == "displayed"+ or notified.get("status") == "ok")),+ "_hint": (("First-run Autodesk SIGN-IN required. AUTOMATE IT FIRST, don't punt to the user. "+ "PROVEN sequence (2026-07-05): (1) if the user's Autodesk web session is already "+ "warm, clear any STALE 'signin.autodesk.com' tabs first - a dead tab holds an "+ "EXPIRED code and is the #1 cause of 'Sign-in request expired'. (2) desktop_ui_"+ "click 'Retry browser sign-in' (or 'Sign In') on the Fusion window to mint ONE "+ "fresh request. (3) On the 'You're signed in' page, click 'Open Product' with "+ "desktop_click (SendInput = isTrusted) NOT desktop_ui_click (UIA Invoke does NOT "+ "fire the protocol handler). (4) An in-page 'open Autodesk Identity Manager?' "+ "overlay appears (NOT an owned popup - screenshot catches it, popup-capture "+ "won't): tick 'Always allow' then click 'Open', both via desktop_click, FAST "+ "before the code expires. (5) poll fusion_readiness - the window flips 'Signing "+ "in' -> 'Home'. nbrowser (CDP) can drive steps 3-4 headlessly IF it's on the "+ "SAME browser window Fusion opened. NEVER auto-enter the password/2FA. If you "+ "genuinely must ask the user, notify_user works directly now (AD 1.9.84); when "+ "you're on a VM and the user is elsewhere, add `target` from the `targets` verb "+ "(or 'all') so the toast lands where they are. " if _signin else "")+ + (("A licensing/seat DECISION dialog is up - do NOT auto-pick. I already fired "+ "notify_user cross-AD (notifyDelivered=%s); ASK the user which option, then "+ "click it. " % (bool(notified and (notified.get("action") == "displayed"+ or notified.get("status") == "ok"))))+ if _decision else "") + " ".join(d["resolution"] for d in _needs_user)), } @@ -741,18 +765,40 @@ def _handle_fusion_readiness(fusion_info: dict, args: dict) -> dict: # re-detect live: an install may have JUST finished (fusion_info is from bridge start) installed = bool(detect_fusion().get("installed")) running = _is_fusion_running() if installed else False- ready = installed and running installing = (not installed) and _installer_running() + # Fusion can be RUNNING yet not drivable: the first-run Autodesk sign-in modal+ # ("Signing in - Autodesk Fusion" / "Welcome to Fusion") blocks the main thread,+ # so the add-in never serves and every modeling verb hangs. The old code reported+ # ready:True here ("READY - drive Fusion") purely on installed+running, which was+ # a lie during sign-in. Detect it and downgrade to needsSignin so the caller+ # drives the sign-in instead of firing a verb into a wall.+ needs_signin = False+ if running:+ try:+ needs_signin = any(d.get("category") == "signin_required"+ for d in classify_launch_dialogs())+ except Exception:+ needs_signin = False+ ready = installed and running and not needs_signin+ # When Fusion is up, also check the add-in isn't STALE (issue #55) so a fresh # readiness call surfaces the mismatch instead of a later verb failing silently. stale_info = {}- if running:+ if running and not needs_signin: addin_status = _check_addin_status(timeout=1.0) if addin_status is not None: stale_info = _addin_staleness(addin_status.get("version")) - if ready and stale_info.get("addinStale"):+ if needs_signin:+ hint = ("Fusion is RUNNING but stuck at the first-run Autodesk SIGN-IN (main UI blocked, "+ "add-in cannot serve) - NOT ready. AUTOMATE the sign-in (see the fusion-onboarding "+ "skill for the proven sequence: clear stale signin.autodesk.com tabs, 'Retry browser "+ "sign-in', click 'Open Product' via desktop_click/isTrusted, tick 'Always allow' + "+ "'Open' on the protocol overlay), then poll until ready:true. NEVER enter the "+ "password/2FA; to ask the user, notify_user works directly (add a cross-AD `target` "+ "if you're on a VM).")+ elif ready and stale_info.get("addinStale"): hint = stale_info["_staleHint"] elif ready: hint = "READY - drive Fusion (fusion_open_lbr / build_library_3d / etc.)."@@ -765,7 +811,7 @@ def _handle_fusion_readiness(fusion_info: dict, args: dict) -> dict: "fusion_install_fusion (no shell approval needed) - it streams the free trial and " "this verb reports installing:true until done.") - return {+ result = { "success": True, "hostApp": "Fusion 360", "installed": installed,@@ -776,6 +822,10 @@ def _handle_fusion_readiness(fusion_info: dict, args: dict) -> dict: **stale_info, "_hint": hint, }+ if needs_signin:+ result["needsSignin"] = True+ result["statusVerb"] = "fusion_readiness"+ return result COMMAND_HANDLERS = {
skills/fusion-onboarding/SKILL.md+49−13@@ -51,19 +51,55 @@ shell_execute {"command":"curl -L -o C:\\tmp\\FusionDL.exe \"https://dl.appstrea launcher by mtime). ## Step 3 — First launch + Autodesk login (free account / trial)-`fusion_start` launches it; the first run needs an Autodesk login. Drive it exactly like the-**`fusion-aps-signin`** skill (open a NEW window, fetch any email OTP via `adom-google`, handle-"Continue with Google", etc.). If they have no Autodesk account, the sign-in page has **Create-account** (free) — and Fusion offers a **free trial / personal-use** tier that's enough for-viewing and a lot of work. Don't auto-enter their password; do drive the OTP/forms.--> **NOTIFY the user the moment login is needed.** The sign-in (and any 2FA/password step you must-> not auto-fill) is an ACTIVE step only they can do. Fire an AD **`notify_user`** toast ("Fusion is-> installed and open - it needs your Autodesk sign-in to continue") the instant the login screen is-> up, so they act instead of you silently waiting on a screen they aren't watching. Same for the-> `--globalinstall` UAC/elevation prompt (Step 2) and license acceptance. Long installs: use a-> **progress** toast. This is the general rule (see the USER skill's convention 4): notify on EVERY-> step that needs the user.+`fusion_start` launches it; the first run needs an Autodesk login. If they have no Autodesk account,+the sign-in page has **Create account** (free) — and Fusion offers a **free trial / personal-use**+tier that's enough for viewing and a lot of work. **Don't auto-enter their password/2FA; do drive+everything else.**++### The mechanism (why naive clicking fails)+Fusion's "Sign In" opens the **default browser** to an Autodesk OAuth page. When auth completes the+browser lands on `signin.autodesk.com/idmgr/callback#code=…`, whose JS posts the code to Fusion's+**localhost** listener via an `adskidmgr://` protocol handoff. Three traps make this fail if you+click naively:+- **Codes expire fast.** A stale sign-in tab left open holds a dead code → "Sign-in request+ expired". Every retry that races an old tab loses.+- **`desktop_ui_click` (UIA Invoke) does NOT fire the protocol.** Browsers block protocol launches+ from untrusted (synthetic-invoke) events. You need an **isTrusted** click: **`desktop_click`**+ (SendInput coordinate click) or an `nbrowser` CDP click.+- **An in-page "This site is trying to open Autodesk Identity Manager?" overlay** gates the launch.+ It is NOT an owned popup, so `desktop_screenshot_window` sees it but popup-capture (`ownedPopupCount`)+ never will. It must be clicked "Open".++### The PROVEN sequence (validated live 2026-07-05, zero password entry by the AI)+1. **Warm the session / clear stale tabs.** If the user already authenticated Autodesk in the+ browser, the session is warm. FIRST close any dead `signin.autodesk.com` / "Sign in - Autodesk"+ tabs (foreground the Edge window, `desktop_press_key {"key":"ctrl+w"}` per tab) — they only hold+ expired codes and cause false "expired" screens.+2. **One fresh request.** `desktop_ui_click` **"Retry browser sign-in"** (or "Sign In") on the+ Fusion window to mint exactly one fresh code + open one live auth tab.+3. **Open Product, isTrusted.** On the "You're signed in" page, click **"Open Product"** with+ **`desktop_click`** (SendInput), NOT `desktop_ui_click`.+4. **Clear the protocol overlay, FAST.** When the "open Autodesk Identity Manager?" overlay appears,+ `desktop_click` the **"Always allow"** radio (so it never blocks again) then **"Open"** — before+ the code expires.+5. **Confirm.** Poll `fusion_readiness`; the Fusion window title flips **"Signing in - Autodesk+ Fusion" → "Home - Autodesk Fusion"** and `ready` goes true (`fusion_readiness` reports+ `needsSignin:true` the whole time it's blocked, so branch on that).++**Headless variant:** if `nbrowser` (the Adom browser extension, `nbrowser_readiness`) is driving+the **same** browser window Fusion opened, do steps 3-4 via CDP (`nbrowser_eval` to click the DOM+button) — CDP clicks are isTrusted and need no window focus, beating the expiry race. Caveat: the+auth tab must be in nbrowser's window; if Fusion opened a different Edge window, use the SendInput+path above.++> **NOTIFY only when you truly can't proceed.** The sign-in is something you should AUTOMATE, not+> punt. Reserve an AD **`notify_user`** toast for a genuine wall (a UAC/elevation prompt, a+> `--globalinstall` consent, license acceptance, or a 2FA/password step you must not auto-fill).+> As of **AD 1.9.84** `notify_user` works directly over the bridge's direct API — you don't relay a+> payload to the AI anymore. **If the bridge runs UNATTENDED (e.g. on a VM) and the user is on+> another machine, add a cross-AD `target`** (a peer clientName from the `targets` verb, or `"all"`)+> so the toast lands where they actually are. The bridge already fires this itself for a seat/+> licensing DECISION dialog (a true wall) — see `notifyDelivered` in the launch response. ## Step 4 — Complete the circuit: auto-install the AdomBridge add-in This is what lets the bridge inject commands into Fusion after launch.