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

John Lauer ·12a26b36fd ·3mo ago ·parent 2c0a599
8 files changed +328−45
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.6.25\ No newline at end of file+1.6.26\ No newline at end of file
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"-}\ No newline at end of file+  "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 @@       }     ]   }-}\ No newline at end of file+}
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.