← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.11.0\ No newline at end of file+1.11.1\ No newline at end of file
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.11.0",+  "version": "1.11.1",   "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/fusion-bridge",   "author": "Adom Inc.",
contracts/describe.json+2
@@ -2979,7 +2979,9 @@     {       "example": {},       "input": {+        "account": "optional str email: which account to pick in a provider chooser (e.g. the Google account list); remembered with the method",         "auto": "optional bool (do click+capture+reopen in one on-box call - recommended)",+        "method": "optional str google|apple|microsoft|facebook|password (1.11.1): HOW this Autodesk account signs in. With it the bridge drives the sign-in page itself (clicks the provider, picks the account, clicks Open Product) and REMEMBERS it, so later calls need nothing. Without one and none remembered, the reply is stage method_required with methods[]: ASK THE USER, never guess",         "profile": "optional str (chrome:<email>/edge:<email> - the profile your Autodesk account is in; remembered)",         "waitSec": "optional int (server-side poll budget for the fresh URL, default 32)"       },
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.11.0"+version = "1.11.1" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.11.0"+version = "1.11.1" edition = "2021"  [dependencies]
describe.py+1−1
@@ -62,7 +62,7 @@ _T = [      {"title": "Fusion needs you", "body": "Please complete the Autodesk 2FA prompt on winvm - everything else is done."}),      ("fusion_signin", "SELF-DRIVING as of 1.9.32: launches Fusion if needed, clicks Sign In, opens the OAuth in the right profile, TYPES THE EMAIL and advances to the exact remaining wall (password/2FA/handoff), toasting the user at that step only - see signinWall + _hint in the response, and the fusion-autodesk-signin skill. Sign Fusion in through the CORRECT browser profile - the fix for Fusion's dumb OAuth that fires at the OS-DEFAULT browser and lands in the wrong Autodesk account. Fusion opens its sign-in in whatever browser is default (e.g. a personal Chrome), but a user's Autodesk seat often lives in a different profile (work). This verb reads the FULL authorize URL (client_id/PKCE/state/request_id) that Fusion wrote into that wrong browser's HISTORY and re-opens it in the RIGHT profile IN THE BACKGROUND; the URL completes back to the running Fusion via the idmgr/callback + autodesk:// handoff, so the browser no longer has to be the OS default. Pass {profile:'chrome:[email protected]'} to fix + remember your Autodesk profile; else it probes each profile for a live Autodesk session (or asks). Staged: if Fusion hasn't opened the browser yet it returns stage:'click_signin' so you click Fusion's Sign In first. BEST: pass {auto:true} to run the WHOLE chain in ONE server-side call - it clicks Fusion's Sign In, polls the browser history on-box for the fresh authorize URL, and re-opens it in the target profile, all without extra remote round-trips, so it fits inside Fusion's ~2-min OAuth request expiry even on a slow/flaky relay (the manual staged path is the fallback). Fusion's request EXPIRES in ~2 min, so a stale captured URL returns stage:'stale' - restart Fusion (fusion_stop/start) for a fresh request and retry auto PROMPTLY. See the 'fusion-multiprofile-signin' skill.",-     {"profile": "optional str (chrome:<email>/edge:<email> - the profile your Autodesk account is in; remembered)", "auto": "optional bool (do click+capture+reopen in one on-box call - recommended)", "waitSec": "optional int (server-side poll budget for the fresh URL, default 32)"},+     {"profile": "optional str (chrome:<email>/edge:<email> - the profile your Autodesk account is in; remembered)", "auto": "optional bool (do click+capture+reopen in one on-box call - recommended)", "method": "optional str google|apple|microsoft|facebook|password (1.11.1): HOW this Autodesk account signs in. With it the bridge drives the sign-in page itself (clicks the provider, picks the account, clicks Open Product) and REMEMBERS it, so later calls need nothing. Without one and none remembered, the reply is stage method_required with methods[]: ASK THE USER, never guess", "account": "optional str email: which account to pick in a provider chooser (e.g. the Google account list); remembered with the method", "waitSec": "optional int (server-side poll budget for the fresh URL, default 32)"},      {"stage": "str", "done": "bool", "signinWhere": "str", "signinProfile": "str", "signinProfileReason": "str", "signinWrongBrowser": "str", "signinAuthUrl": "str", "signinOpenedVia": "str (extension|extension_free)", "signinWall": "str|null (twofactor_email_code|credentials|protocol_handoff)", "notifyDelivered": "bool", "signinWallHint": "str|null", "narrate": "str"},      120, "fusion_signin", True, {}),     ("fusion_mcp_status", "Is Autodesk's Fusion MCP server up, and what does it expose? Autodesk + Anthropic ship a LOCAL MCP server at http://127.0.0.1:27182/mcp exposing Fusion's own text-to-CAD surface (read geometry, execute/update features, read Electronics design data). It binds LOOPBACK on the user's machine and was built for Claude Desktop running there, so a CLOUD AI cannot reach it at all - this bridge runs on that machine and proxies it for you. Returns enabled, serverInfo and the live tool list. OFF by default: if it reports enabled:false, call fusion_mcp_enable and the bridge turns it on for the user.",
install.sh+1−1
@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.11.0"+DASH_VERSION="1.11.1" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.11.0",+  "version": "1.11.1",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {
page.json+10−26
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.11.0",+  "version": "1.11.1",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {@@ -11,6 +11,10 @@     "uninstall": "./uninstall.sh"   },   "tags": [+    "ab",+    "bridge",+    "fusion",+    "desktop-app",     "fusion360",     "autodesk",     "cad",@@ -76,6 +80,9 @@     "add a 3d model to a component",     "make a fusion 360 library with 3d",     "set up the fusion bridge",+    "fusion dashboard",+    "fusion status page",+    "open the fusion launch page",     "aps not configured",     "share aps client id across my org",     "set up fusion cloud search for my company",@@ -108,28 +115,5 @@       "label": "Part volume",       "prompt": "How much material does each printed part in this assembly use?"     }-  ],-  "agent_permissions": {-    "allow_bash": [],-    "allow": [-      "adom-desktop-fusion-bridge read-only verbs (via the adom-bridge CLI): fusion_readiness, fusion_get_app_state, fusion_addin_status, fusion_check_dialogs - state queries; they change nothing.",-      "adom-desktop-fusion-bridge read-only verbs: fusion_alerts (reads Fusion's own log for toasts), fusion_document_info, fusion_window_info, fusion_board_info, fusion_library_parts - inspect only.",-      "adom-desktop-fusion-bridge read-only verbs: fusion_prove_result (a persisted run report), fusion_plan_library_import (report-only preflight), fusion_list_cloud_projects, fusion_describe.",-      "adom-desktop-fusion-bridge evidence captures fusion_screenshot_fusion and fusion_capture_library_views read pixels and write local image files only."-    ],-    "soft_deny": [-      "adom-desktop-fusion-bridge lifecycle verbs fusion_stop, fusion_kill, fusion_restart_for_addin close or kill the Fusion application - keep the human prompt.",-      "adom-desktop-fusion-bridge fusion_install_fusion silently installs Autodesk Fusion on the user's machine.",-      "adom-desktop-fusion-bridge fusion_cleanup_cloud_files deletes files from the user's Autodesk cloud hub.",-      "adom-desktop-fusion-bridge managed-library verbs (fusion_managed_library_push/pull/create/link/unlink) publish to or overwrite the user's library.io libraries."-    ],-    "environment": [-      "adom-desktop-fusion-bridge is the Fusion 360 bridge for Adom Bridge; its fusion_* verbs are invoked through the adom-bridge CLI and drive Autodesk Fusion on the user's own desktop.",-      "adom-desktop-fusion-bridge desktop actions also pass Adom Bridge's own human-approval gate (caller identity, per-call reason, Activity Log) - a container approval is the first of two gates."-    ]-  },-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"-  }-}\ No newline at end of file+  ]+}
rust/Cargo.toml+1−1
@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"]  # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.11.0"+version = "1.11.1" edition = "2021" license = "MIT" 
rust/bridge/src/janitor.rs+8−2
@@ -37,9 +37,15 @@ fn tick(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value) {     if running && pref_on(&sd, "autoTakeOverSeat") && facts.licensing_dialog.is_some() {         let r = crate::readiness_exec::resolve_seat_dialog(ctx, 3);         if r["clicks"].as_u64().unwrap_or(0) > 0 {+            // Toast only a SURPRISE takeover. When an AI moved the seat on purpose (fusion_claim_seat+            // or fusion_release_seat here or on a peer within the last 10 minutes) the user already+            // knows, and two boxes trading the seat used to toast them repeatedly (John, 2026-09-23).+            let intentional = crate::seat::recent_intentional_move(&sd, 600.0);             let mut last = LAST_SEAT_NOTICE.lock().unwrap();-            if now() - *last > 120.0 { *last = now(); let _ = crate::small_verbs::notify_owner(&sd, &json!({"title": "Fusion seat taken over on this machine", "body": "Autodesk flagged your account's session limit, so I clicked through the 'Active Sessions Exceeded' dialogs in the background and took the seat here. Fusion on your other machine is SUSPENDED (resumable, nothing lost) - using Fusion there will simply grab the seat back the same way.", "level": "info"}), &ctx.caller); }-            eprintln!("[fusion-bridge] seat sweep: took over the session (clicks={}, verified={})", r["clicks"], r["verified"]);+            if !intentional && now() - *last > 3600.0 {+                *last = now();+                let _ = crate::small_verbs::notify_owner(&sd, &json!({"title": "Fusion seat taken over on this machine", "body": "Autodesk flagged your account's session limit, so I clicked through the 'Active Sessions Exceeded' dialogs in the background and took the seat here. Fusion on your other machine is SUSPENDED (resumable).", "level": "info"}), &ctx.caller);+            }            eprintln!("[fusion-bridge] seat sweep: took over the session (clicks={}, verified={})", r["clicks"], r["verified"]);             crate::fusion_prefs::action_ledger(&sd, "seat_takeover", json!({"clicks": r["clicks"], "verified": r["verified"]}));         }     }
rust/bridge/src/seat.rs+24−2
@@ -7,8 +7,23 @@ //! `fusion_account` says who is signed in and whether this box holds the seat, `fusion_claim_seat` //! moves it here, `fusion_release_seat` gives it up so another box can take it without a dialog. use serde_json::{json, Value};+use std::path::Path; use std::time::{Duration, Instant}; +fn now_f() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }++/// A seat move the AI asked for, so the passive sweep does not toast the user about a surprise.+fn note_intentional_move(state_dir: &Path, what: &str) {+    let _ = std::fs::create_dir_all(state_dir);+    let _ = std::fs::write(state_dir.join("seat-move.json"), json!({"at": now_f(), "what": what}).to_string());+}+/// True when this box claimed or released the seat within `secs`.+pub fn recent_intentional_move(state_dir: &Path, secs: f64) -> bool {+    std::fs::read_to_string(state_dir.join("seat-move.json")).ok()+        .and_then(|t| serde_json::from_str::<Value>(&t).ok())+        .and_then(|v| v["at"].as_f64()).map(|at| now_f() - at < secs).unwrap_or(false)+}+ /// Autodesk's licence blob, read through Fusion's own `Application.LicenseInformation`. fn license_info(pass: &dyn Fn(&str, Value) -> Value) -> Value {     let r = pass("execute_text_command", json!({"command": "Application.LicenseInformation"}));@@ -38,11 +53,15 @@ pub fn account(ctx: &crate::addin::Ctx, pass: &dyn Fn(&str, Value) -> Value) ->     let who = pass("run_modeling_script", json!({"script": "import adsk.core\napp = adsk.core.Application.get()\nu = app.currentUser\nresult = {\"email\": u.email if u else None, \"name\": u.displayName if u else None, \"userId\": u.userId if u else None, \"hub\": app.data.activeHub.name if app.data.activeHub else None, \"offline\": app.isOffLine}"}));     let r = if who["data"]["result"].is_object() { who["data"]["result"].clone() } else { who["result"].clone() };     let lic = license_info(pass);-    // the seat is HELD here when Fusion is signed in, authorized and not showing the seat modal+    // "Holds the seat" is only ever a LOCAL reading: Autodesk hands each machine a licence token+    // that stays valid until its own reauthorize time, so two boxes can both answer authorized+    // for a while after a move (seen live, ConfRoomROG and arav-rog, 2026-09-23). Signed in ++    // authorized + no seat modal is the strongest signal one box has; the box that LOST the seat+    // shows the modal (seatDialogOpen) or stops being authorized at its next reauthorize.     let holds = r["email"].is_string() && lic["authorized"].as_bool() != Some(false) && facts.licensing_dialog.is_none();     json!({"success": true, "running": true, "signedIn": r["email"].is_string(), "account": r, "license": lic,         "holdsSeat": holds, "seatDialogOpen": facts.licensing_dialog.is_some(), "windowTitle": facts.main_title,-        "_hint": if holds { "This box holds the Autodesk seat. Another box's fusion_claim_seat takes it from here and suspends this Fusion resumably." }+        "_hint": if holds { "This box is signed in and licensed right now (the strongest local reading of 'holds the seat'; Autodesk lets a just-moved box stay authorized until its next reauthorize, so ask both boxes before concluding). Another box's fusion_claim_seat takes the seat from here." }                  else { "Signed in, but this box does not hold the seat right now (another machine has it, or the seat modal is up). fusion_claim_seat takes it." }}) } @@ -50,6 +69,7 @@ pub fn account(ctx: &crate::addin::Ctx, pass: &dyn Fn(&str, Value) -> Value) -> pub fn claim_seat(ctx: &crate::addin::Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {     let t0 = Instant::now();     let mut steps = Vec::new();+    note_intentional_move(&ctx.state_dir, "claim");     if ctx.host.fusion_processes().is_empty() {         let r = crate::launch::start(ctx, &json!({}));         steps.push(json!({"step": "start Fusion", "ok": r["success"].as_bool() != Some(false)}));@@ -75,11 +95,13 @@ pub fn claim_seat(ctx: &crate::addin::Ctx, args: &Value, pass: &dyn Fn(&str, Val     }     json!({"success": acct["holdsSeat"].as_bool() == Some(true), "steps": steps, "seatDialogAnswered": resolved["clicks"],         "account": acct["account"], "license": acct["license"], "holdsSeat": acct["holdsSeat"], "durationMs": t0.elapsed().as_millis() as u64,+        "seatSweepWarning": "The box you took it FROM keeps its own passive takeover sweep: if its Fusion is still open it can grab the seat straight back. fusion_release_seat there (or fusion_set_seat_takeover {enabled:false}) before a long test run.",         "_hint": "This box now holds the Autodesk seat; any other machine signed into the same account suspended RESUMABLY (its documents are kept, it resumes when it takes the seat back). fusion_account on the other box confirms it lost the seat."}) }  /// `fusion_release_seat`: give up the seat from THIS box so another can take it cleanly. pub fn release_seat(ctx: &crate::addin::Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+    note_intentional_move(&ctx.state_dir, "release");     if ctx.host.fusion_processes().is_empty() {         return json!({"success": true, "released": true, "wasRunning": false, "_hint": "Fusion was not running here, so this box held no seat. Claim it on the box you want with fusion_claim_seat."});     }
rust/bridge/src/signin.rs+122−1
@@ -66,6 +66,102 @@ pub fn click_background_first(ctx: &Ctx, hwnd: i64, name: Option<&str>, shot: Op     }     json!({"clicked": false, "via": null, "announced": false, "why": "no UIA node and no shot coords given"}) }+/// The sign-in METHODS Autodesk offers. John signs into Fusion with Google; other people use a+/// password or another provider, so the method is an input, remembered per user, never a guess.+pub const METHODS: &[&str] = &["google", "apple", "microsoft", "facebook", "password"];++fn method_pref_path(state_dir: &std::path::Path) -> std::path::PathBuf { state_dir.join("signin-method.json") }+pub fn remembered_method(state_dir: &std::path::Path) -> Option<String> {+    std::fs::read_to_string(method_pref_path(state_dir)).ok()+        .and_then(|t| serde_json::from_str::<Value>(&t).ok())+        .and_then(|v| v["method"].as_str().map(str::to_string))+}+fn remember_method(state_dir: &std::path::Path, method: &str, account: Option<&str>) {+    let _ = std::fs::create_dir_all(state_dir);+    let _ = std::fs::write(method_pref_path(state_dir), json!({"method": method, "account": account}).to_string());+}++/// Read the sign-in page through the browser extension (a window the BRIDGE opened, so it is+/// drivable): what stage it is at, which methods it offers, which accounts a chooser lists.+fn probe_page(ctx: &Ctx, session: &str) -> Value {+    let js = r#"(() => { const t = e => (e.innerText || e.textContent || '').trim();+      const els = [...document.querySelectorAll('button,a,[role=button]')];+      const labels = els.map(t).filter(Boolean);+      const methods = labels.filter(x => /^continue with /i.test(x)).map(x => x.replace(/^continue with /i, '').toLowerCase());+      const accounts = [...document.querySelectorAll('[data-identifier],li,div')].map(t).filter(x => /@/.test(x) && x.length < 120).slice(0, 10);+      return { url: location.href, title: document.title, methods, accounts,+               hasPassword: !!document.querySelector('input[type=password]'),+               hasEmail: !!document.querySelector('input[type=email],input[name=username],input[name=UserName]'),+               openProduct: labels.some(x => /open product/i.test(x)),+               signedIn: /you're signed in|youre signed in/i.test(document.body.innerText || '') };+    })()"#;+    let r = ab_call(ctx, "nbrowser_eval", json!({"sessionId": session, "expression": js}), 30);+    let d = if r["data"].is_object() { r["data"].clone() } else { r.clone() };+    d["result"].clone().take_if_object().unwrap_or(d["value"].clone())+}++trait TakeIfObject { fn take_if_object(self) -> Option<Value>; }+impl TakeIfObject for Value { fn take_if_object(self) -> Option<Value> { if self.is_object() { Some(self) } else { None } } }++/// Click an element by its visible TEXT in the extension-driven window (no coordinates, no OCR).+fn click_text(ctx: &Ctx, session: &str, needle: &str) -> bool {+    let js = format!(r#"(() => {{ const n = {needle:?}.toLowerCase();+      const els = [...document.querySelectorAll('button,a,[role=button],[data-identifier],li,div')];+      const hit = els.find(e => {{ const t = (e.innerText || e.textContent || '').trim().toLowerCase(); return t && t.includes(n) && t.length < 200 && e.offsetParent !== null; }});+      if (!hit) return false; hit.click(); return true; }})()"#);+    let r = ab_call(ctx, "nbrowser_eval", json!({"sessionId": session, "expression": js}), 30);+    let d = if r["data"].is_object() { r["data"].clone() } else { r.clone() };+    d["result"].as_bool().or(d["value"].as_bool()).unwrap_or(false)+}++/// Drive the Autodesk sign-in page with the METHOD the user signs in with. Returns a typed+/// outcome; it never types a password and never guesses which method or which account.+pub fn drive_with_method(ctx: &Ctx, session: &str, method: Option<&str>, account: Option<&str>) -> Value {+    let mut steps: Vec<String> = Vec::new();+    for _ in 0..10 {+        let p = probe_page(ctx, session);+        if !p.is_object() { return json!({"stage": "unreadable", "steps": steps, "_hint": "The sign-in page could not be read through the browser extension. Fall back to the window on screen."}); }+        let methods: Vec<String> = p["methods"].as_array().cloned().unwrap_or_default().iter().filter_map(|m| m.as_str().map(str::to_string)).collect();+        if p["signedIn"].as_bool() == Some(true) || p["openProduct"].as_bool() == Some(true) {+            let clicked = click_text(ctx, session, "open product");+            steps.push(format!("signed in; clicked Open Product: {clicked}"));+            return json!({"stage": "signed_in", "steps": steps, "openProductClicked": clicked,+                "_hint": "Autodesk says signed in and the handoff to Fusion is running. Chrome may ask 'Open Autodesk Identity Manager?': tick Always allow and click Open (desktop_ui_click, or desktop_click on the dialog). Then poll fusion_account until it reports the account."});+        }+        if p["hasPassword"].as_bool() == Some(true) {+            return json!({"stage": "password_required", "steps": steps, "method": "password",+                "_hint": "The page is on the PASSWORD step. The bridge never types a password. Either the user types it in the window, or a stored credential fills it (pup_login / nbrowser_autologin on a profile that has it). If the user signs in with Google instead, call fusion_signin {method:\"google\"} and the bridge takes the Google route."});+        }+        // a Google/Apple/Microsoft account chooser+        if p["url"].as_str().map(|u| u.contains("accounts.google.com") || u.contains("login.microsoftonline") || u.contains("appleid.apple.com")).unwrap_or(false) {+            let accounts: Vec<String> = p["accounts"].as_array().cloned().unwrap_or_default().iter().filter_map(|a| a.as_str().map(str::to_string)).collect();+            let Some(want) = account else {+                return json!({"stage": "account_choice_required", "accounts": accounts, "steps": steps,+                    "_hint": "The provider is asking WHICH account. Ask the user, then call fusion_signin {method, account:\"<email>\"}; the bridge remembers both."});+            };+            if click_text(ctx, session, want) { steps.push(format!("picked account {want}")); std::thread::sleep(std::time::Duration::from_secs(5)); continue; }+            return json!({"stage": "account_not_listed", "accounts": accounts, "wanted": want, "steps": steps,+                "_hint": "That account is not signed into this browser profile, so the provider cannot offer it. Either the user signs into it in that browser once, or pick one of accounts[]."});+        }+        // the Autodesk method page+        let Some(m) = method else {+            return json!({"stage": "method_required", "methods": if methods.is_empty() { json!(METHODS) } else { json!(methods) }, "steps": steps,+                "_hint": "ASK THE USER which sign-in method this Autodesk account uses (Google, Apple, Microsoft, Facebook or a password), then call fusion_signin {method:\"<it>\", account:\"<email>\"}. The bridge remembers it, so every later box just works."});+        };+        if m == "password" {+            if let Some(a) = account { let _ = click_text(ctx, session, "email"); steps.push(format!("password route for {a}")); }+            let _ = click_text(ctx, session, "next");+            std::thread::sleep(std::time::Duration::from_secs(4));+            continue;+        }+        let label = format!("continue with {m}");+        if click_text(ctx, session, &label) { steps.push(format!("clicked '{label}'")); std::thread::sleep(std::time::Duration::from_secs(5)); continue; }+        return json!({"stage": "method_not_on_page", "methods": methods, "wanted": m, "steps": steps,+            "_hint": "That method is not offered on this page. methods[] is what Autodesk shows; ask the user which one to use."});+    }+    json!({"stage": "gave_up", "steps": steps, "_hint": "The sign-in page did not reach a signed-in state within the bridge's attempts. Read fusion_state and the window on screen."})+}+ /// `_advance_credentials_page`: drive the Autodesk credentials page as far as code can go /// WITHOUT secrets: set the email, invoke Next, classify what the page became. Never a password. fn advance_credentials_page(ctx: &Ctx, hwnd: i64, email: Option<&str>) -> Value {@@ -250,6 +346,12 @@ fn drive_sso_signin(ctx: &Ctx, profile: &str, tab: &Value, email: Option<&str>,  /// `fusion_signin`. pub fn signin(ctx: &Ctx, args: &Value, env: &Env) -> Value {+    // The sign-in METHOD is an input, remembered per user (John signs in with Google). With it,+    // the bridge drives the page itself; without it, the page answers method_required and the AI+    // asks the user once. No toast until a HUMAN step is actually reached (John, 2026-09-23).+    let method: Option<String> = args["method"].as_str().map(|m| m.trim().to_lowercase()).filter(|m| METHODS.contains(&m.as_str())).or_else(|| remembered_method(&ctx.state_dir));+    let account_arg: Option<String> = args["account"].as_str().map(|a| a.trim().to_string()).filter(|a| a.contains('@'));+    if let Some(m) = args["method"].as_str().map(|m| m.trim().to_lowercase()).filter(|m| METHODS.contains(&m.as_str())) { remember_method(&ctx.state_dir, &m, account_arg.as_deref()); }     let mut steps: Vec<String> = Vec::new();     let auto = args["auto"].as_bool().unwrap_or(true);     let wait_sec = args["waitSec"].as_u64().filter(|w| *w > 0).unwrap_or(32);@@ -319,7 +421,7 @@ pub fn signin(ctx: &Ctx, args: &Value, env: &Env) -> Value {     let sess = format!("fusion-signin-{}", now() as i64);     let target_dir = chosen.as_ref().and_then(|c| c["profileDir"].as_str().or(c["dir"].as_str()).map(str::to_string));     let email = args["email"].as_str().map(str::to_string).or_else(|| chosen.as_ref().and_then(|c| c["email"].as_str().filter(|e| !e.is_empty()).map(str::to_string)));-    if let Some(td) = &target_dir { if !src_dir.is_empty() && td.trim().to_lowercase() == src_dir.trim().to_lowercase() {+    if let Some(td) = &target_dir { if method.is_none() && !src_dir.is_empty() && td.trim().to_lowercase() == src_dir.trim().to_lowercase() {         steps.push(format!("Fusion already opened the sign-in in {} (profile dir {}) - no re-open needed", target, fusion_lbr::py_repr_str(td)));         let mut wall0 = detect_signin_wall(ctx, &[]);         if wall0["wall"].is_string() { wall0 = resolve_and_advance_wall(ctx, wall0, email.as_deref()); }@@ -341,6 +443,25 @@ pub fn signin(ctx: &Ctx, args: &Value, env: &Env) -> Value {     }     steps.push(if opened { format!("re-opened it in {}{}", tdesc, if open_via == Some("extension_free") { " (extension-free)" } else { "" }) } else { format!("could not open {} (raw: {})", tdesc, r.to_string().chars().take(160).collect::<String>()) });     if opened { track_signin_window(&sess, &target, "fusion"); }+    if opened && open_via == Some("extension") {+        std::thread::sleep(std::time::Duration::from_secs(3));+        let acct = account_arg.clone().or_else(|| email.clone());+        let out = drive_with_method(ctx, &sess, method.as_deref(), acct.as_deref());+        let stage = out["stage"].as_str().unwrap_or("").to_string();+        // AI-answerable stages come back WITHOUT a toast; only a real human step toasts+        let human = matches!(stage.as_str(), "password_required" | "gave_up" | "unreadable");+        let notified = if human { notify_signin_wall(ctx, if stage == "password_required" { "password" } else { "credentials" }) } else { false };+        if stage != "unreadable" {+            let mut r = out.clone();+            r["success"] = json!(stage == "signed_in" || !human);+            r["done"] = json!(stage == "signed_in");+            r["method"] = json!(method); r["account"] = json!(acct);+            r["signinProfile"] = json!(target); r["signinSession"] = json!(sess); r["notifyDelivered"] = json!(notified);+            r["stepsBefore"] = json!(steps);+            return r;+        }+        steps.push("the extension could not read the page; falling back to the window on screen".into());+    }     if opened && open_via == Some("extension") && args["driveSso"].as_bool().unwrap_or(true) {         let tabs = unwrap(&ab_call(ctx, "nbrowser_list_tabs", json!({"profile": target}), 45))["tabs"].as_array().cloned().unwrap_or_default();         let my_tab = tabs.iter().filter(|t| t["url"].as_str().unwrap_or("").to_lowercase().contains("autodesk") && t["owner"].as_str() == Some("agent-opened")).last().map(|t| if t["tabId"].is_null() { t["id"].clone() } else { t["tabId"].clone() });
skills/fusion-license-seat/SKILL.md+11−4
@@ -47,10 +47,17 @@ automatable controls for the account menu, and **the bridge never types a creden 1. `fusion_account` tells you who it is. 2. To change it, the person signs out in Fusion (the avatar, top right, then Sign out) or signs in    on the sign-in screen.-3. `fusion_signin {auto:true, profile:"<browser>:<email>"}` drives everything up to the wall:-   it opens the OAuth in the right browser profile and types the email. A password or 2FA step-   comes back as `signinWall` for the person, and `fusion_signin_2fa {code}` finishes a 2FA if you-   can read the code (adom-google can, from their Gmail).+3. `fusion_signin {auto:true, method:"google", account:"<email>"}` signs Fusion in. The METHOD is+   how that Autodesk account signs in (google, apple, microsoft, facebook or password). The bridge+   opens the sign-in in a window it can drive, clicks the provider, picks the account in the+   provider's chooser, clicks Open Product, and remembers the method and account for next time.+   - No method given and none remembered: the reply is `stage: method_required` with `methods[]`.+     **Ask the user once** which method they use; do not guess.+   - `account_choice_required` / `account_not_listed`: the provider needs to know which account,+     or that account is not signed into this browser. Ask; no toast is sent for these.+   - `password_required`: the only step that toasts the person. The bridge never types a password.+   - After Open Product, Chrome may ask "Open Autodesk Identity Manager?": tick Always allow once+     per browser profile and click Open. Then `fusion_account` shows the account. 4. A box whose Fusion sits on the sign-in screen answers `needsSignin` from `fusion_account` and    from `fusion_claim_seat`, never a made-up failure. 
tools/check-naming.sh

  
tools/check-portable.sh

  
tools/promote.sh

  
tools/release.sh