← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.354\ No newline at end of file+1.9.355\ 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.9.354",+  "version": "1.9.355",   "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+36−3
@@ -66,6 +66,20 @@       "summary": "Fusion's OWN alert toasts, read from its log. Fusion draws error/warning toasts in CEF: they are invisible to Win32 enumeration AND to UIA, so a verb can look successful while Fusion is visibly complaining. This reads them, newest last.",       "timeoutSeconds": 30     },+    {+      "example": {},+      "input": {},+      "longRunning": false,+      "name": "fusion_apply_fusion_update",+      "output": {+        "applied": "bool",+        "button": "str|null",+        "why": "str"+      },+      "statusVerb": null,+      "summary": "Readiness EXECUTOR (Rust port, Phase 3): click Fusion's update offer IN THE BACKGROUND when auto-update is on (sticky pref; fusion_set_auto_update). Never the exit-class buttons while a host op is active. Answers {applied, button, why}.",+      "timeoutSeconds": 60+    },     {       "example": {},       "input": {@@ -708,14 +722,18 @@     },     {       "example": {},-      "input": {},+      "input": {+        "categories": "optional [str] dialog categories to act on (default: all blocking dialogs)"+      },       "longRunning": false,       "name": "fusion_dismiss_blocking_dialogs",       "output": {-        "dialogs": "[...]"+        "count": "int",+        "dialogs": "[...]",+        "dismissed": "[{title, category, answeredWith, closed}]"       },       "statusVerb": null,-      "summary": "Detect + classify (and optionally dismiss) blocking Fusion dialogs.",+      "summary": "Detect + classify (and optionally dismiss) blocking Fusion dialogs. Answers every one with the safe button (No/Cancel via WM_CLOSE, never Yes, never focus-stolen). {categories:[...]} narrows the sweep to those categories (e.g. [\"crash_report\"], the launch janitor's scope).",       "timeoutSeconds": 30     },     {@@ -1910,6 +1928,21 @@       "summary": "Bring Fusion back to a clean slate between runs: closes named documents AND the Untitled scratch the library/bind flow leaves behind (page issue #59). Fusion always keeps ONE blank document alive, so a single remaining Untitled is correct rather than a failure. MODIFIED Untitled docs are left untouched unless force:true, because those can hold real unsaved work.",       "timeoutSeconds": 90     },+    {+      "example": {},+      "input": {+        "maxClicks": "optional int (default 3)"+      },+      "longRunning": false,+      "name": "fusion_resolve_seat_dialog",+      "output": {+        "clicks": "int",+        "resolved": "bool"+      },+      "statusVerb": null,+      "summary": "Readiness EXECUTOR (Rust port, Phase 3): detect + resolve the Autodesk seat/licensing modal ('Active Sessions Exceeded' / 'Suspend Remote Session') in the background, at most 3 UIA clicks per call, screenshot-verified. Idempotent: nothing up answers clicks:0. fusion_readiness calls this itself when it sees the dialog; call it directly only when you are driving the launch by hand.",+      "timeoutSeconds": 60+    },     {       "example": {         "save": true
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.9.354"+version = "1.9.355" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.354"+version = "1.9.355" edition = "2021"  [dependencies]
describe.py+4−2
@@ -235,6 +235,8 @@ _T = [     ("fusion_show_project", "Eight-surfaces contract: bring the PROJECT/design to its top level. No args = activate this session's generated test project (fusion_make_test_project). query = cloud design name (already-open match wins, else fire-and-poll open). urn = open_by_urn passthrough. Background; returns a screenshot path.", {"capture": "optional bool - ONE call = ONE consent: the bridge settles the surface (waits for the add-in main thread + a paint beat) and returns proof inline as shot:{title,image,thumbnailDataUrl,maxWidth} (base64 frame + a ready-to-use data: URL for BROWSER callers who cannot pull a file; set captureMaxWidth to shrink it - 320 keeps a /command body small, default 640. The bridge encodes PNG; the relay may transcode to WebP, so sniff rather than assume). Use this instead of polling fusion_state after a show - page-side settle polling costs one human consent per poll (issue #30).", "query": "optional str (cloud design name)", "urn": "optional str", "fileName": "alias of query"}, {"success": "bool", "view": "str", "document": "str", "screenshot": "str"}, 240, "fusion_get_app_state", True, {"query": "BQ25792"}),     ("fusion_set_window_sizing", "Read/toggle auto-sizing Fusion's WINDOW after launch (STICKY, default ON). Fusion opens at whatever geometry it last had, which after an automated launch is often a small default; the bridge grows it to ~92% of the monitor work area, centered, using SetWindowPos with NOACTIVATE (never ShowWindow/maximize) so focus is never stolen. A window the user maximized, or already sized to >=60% of the work area, is left alone. No args = read.", {"enabled": "optional bool (omit to read)"}, {"success": "bool", "sizeFusionWindow": "bool"}, 15, "fusion_state", False, {}),     ("fusion_set_driving_badge", "Read/toggle the 'Adom is driving Fusion' TASKBAR OVERLAY BADGE (STICKY per user, default ON). While the bridge drives Fusion, its Windows taskbar button wears a small Adom mark so the user knows at a glance who is in control; it sheds automatically after 5 minutes of driving-verb idle. No args = read. Turning it off clears any badge immediately.", {"enabled": "optional bool (omit to read)"}, {"success": "bool", "showDrivingBadge": "bool"}, 15, "fusion_state", False, {}),+    ("fusion_resolve_seat_dialog", "Readiness EXECUTOR (Rust port, Phase 3): detect + resolve the Autodesk seat/licensing modal ('Active Sessions Exceeded' / 'Suspend Remote Session') in the background, at most 3 UIA clicks per call, screenshot-verified. Idempotent: nothing up answers clicks:0. fusion_readiness calls this itself when it sees the dialog; call it directly only when you are driving the launch by hand.", {"maxClicks": "optional int (default 3)"}, {"clicks": "int", "resolved": "bool"}, 60, None, False, {}),+    ("fusion_apply_fusion_update", "Readiness EXECUTOR (Rust port, Phase 3): click Fusion's update offer IN THE BACKGROUND when auto-update is on (sticky pref; fusion_set_auto_update). Never the exit-class buttons while a host op is active. Answers {applied, button, why}.", {}, {"applied": "bool", "button": "str|null", "why": "str"}, 60, None, False, {}),     ("fusion_set_seat_takeover", "Read/toggle the mid-session Autodesk seat-takeover sweep (STICKY per user, default ON). When Autodesk pops 'Active Sessions Exceeded' mid-session, the bridge clicks Continue through BOTH dialogs in the background: this machine takes the seat, the other machine's Fusion suspends RESUMABLY, the user gets one toast. No args = read current value. Gates only the passive sweep - a verb-driven fusion_start still grabs the seat (the user asked for Fusion, so the seat is implied).", {"enabled": "optional bool (omit to read)"}, {"success": "bool", "autoTakeOverSeat": "bool"}, 15, "fusion_readiness", False, {}),     ("fusion_state", "The RENDER-ME verb (kicad_state parity, issues #28/#30): running + window + dialog + busy state PLUS a fresh background screenshot of whatever Fusion is showing - the one verb that makes every background fusion_show_* result visible. SERVER-SIDE ONLY by design: it answers even while the add-in main thread is busy or wedged (activeDocument is best-effort with a short budget and skipped when busy). blockingDialogs carries the CLASSIFIED modal list (cause + resolution). imageAs:base64 inlines the PNG (auto-refused over 4MB - pull the screenshot path via pull_file instead).", {"screenshot": "optional bool (default true)", "imageAs": "optional str path|base64 (default path)"}, {"success": "bool", "running": "bool", "activeDocument": "str", "mainThreadBusy": "dict|null", "blockingDialogs": "list", "focusEvents": "list - the QUIETNESS AUDIT LOG (SDK canonical): every time this bridge took the user's foreground, or refused a bare foreground:true, as {at, verb, outcome: foreground|refused, reason}. Audit how quiet the bridge actually was instead of trusting that it was.", "timings": "dict - measured launch history on THIS machine (p50/p90 per phase, cold vs warm)", "screenshot": "str path", "screenshotBase64": "str (imageAs:base64 only)"}, 60, None, False, {}),     ("fusion_export_iges", "Export to IGES.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/board.iges"}),@@ -259,8 +261,8 @@ _T = [      {"fab": "optional str vendorSlug (jlcpcb|pcbway|osh-park|seeed-fusion|eurocircuits|aisler|elecrow|advanced-circuits-4pcb|sierra-circuits|macrofab|nextpcb|allpcb|adom-instapcb)", "process": "optional str (the vendor's tier: standard|advanced|proto|enig|hasl|...; default standard)", "layers": "optional str (auto|2|4|6)", "druPath": "optional str local .edru/.dru to load as-is", "action": "optional str (apply|list|export|show)"}, {"activeWorkspace": "str", "description": "str", "pack": "dict {fab, vendor, process, layers, file, record}"}, 90, None, False, {"fab": "jlcpcb", "layers": "2"}),     ("fusion_detect_layers", "Detect 2-layer vs 4-layer board (ULP + CAM comparison).",      {}, {"layerCount": "int", "copperLayers": "[int]"}, 90, None, False, {}),-    ("fusion_dismiss_blocking_dialogs", "Detect + classify (and optionally dismiss) blocking Fusion dialogs.",-     {}, {"dialogs": "[...]"}, 30, None, False, {}),+    ("fusion_dismiss_blocking_dialogs", "Detect + classify (and optionally dismiss) blocking Fusion dialogs. Answers every one with the safe button (No/Cancel via WM_CLOSE, never Yes, never focus-stolen). {categories:[...]} narrows the sweep to those categories (e.g. [\"crash_report\"], the launch janitor's scope).",+     {"categories": "optional [str] dialog categories to act on (default: all blocking dialogs)"}, {"dialogs": "[...]", "dismissed": "[{title, category, answeredWith, closed}]", "count": "int"}, 30, None, False, {}),     ("fusion_electron_run", "Run an EAGLE/Electron command in the open board (the generic extension point, e.g. RUN <ulp>).",      {"command": "required str"}, {"success": "bool", "output": "str"}, 90, None, False, {"command": "set confirm yes;RUN 'C:/tmp/jlcpcb_smta_exporter.ulp' 'C:/tmp/jlc'"}),     ("fusion_electron_zoom", "Smoothly ZOOM the schematic/2D board for a recording (the whole motion is one call). factor>1 zooms in, <1 out; or fit:true. The loop runs in the add-in with a repaint per frame.",
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.9.354"+DASH_VERSION="1.9.355" 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.9.354",+  "version": "1.9.355",   "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+11−27
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.354",+  "version": "1.9.355",   "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",@@ -75,7 +79,10 @@     "build a basic parts library",     "add a 3d model to a component",     "make a fusion 360 library with 3d",-    "set up the fusion bridge"+    "set up the fusion bridge",+    "fusion dashboard",+    "fusion status page",+    "open the fusion launch page"   ],   "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).",   "brief": "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.",@@ -104,28 +111,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.9.354"+version = "1.9.355" edition = "2021" license = "MIT" 
rust/PORTING.md+46
@@ -126,3 +126,49 @@ in the roadmap's ab list); `desktop_wait_for_window` replaces the four polling l   Python bridge (`installed`, `exe_path`, `addins_dir`, `addin_installed`, `running`, the   add-in probe, led / summary / tooltip), cached 5 s; with a sidecar present it rides as   `nativeSnapshot` beside the sidecar's answer. Proven identical on ConfRoomROG.+- **Phase 3 layer 2 landed (2026-09-03):** window facts from ab's `desktop_list_windows`+  (owner, pid, image, kind, rect: no ab change was needed): the sign-in title and the seat+  dialog shape rule, with the field cases as unit tests. Porting it found a live false+  positive shared with Python: Fusion's 640x196 message tray is an OWNED, LANDSCAPE tool+  window titled "Fusion360", so both rules called it the seat dialog and readiness sat at+  ready:false on a healthy Fusion. Fixed on both sides in 1.9.354 (a tool window is never a+  modal); ConfRoomROG went ready:true the moment it installed.+- **Layer 2 proven under ab (2026-09-03, ConfRoomROG):** with the exe spawned by ab (so it+  holds the bridge token that exempts a bridge's own calls from ab's caller-identity gate),+  the native window facts came back live: no sign-in, no seat dialog, the real main title;+  the Python readiness agreed. A hand-launched exe cannot pass that gate and reports the+  facts as `windowFactsDegraded` with the refusal text, by design.+- **Flipping a test box to the exe by hand:** copy the exe into the bridge cache dir, edit+  ONLY the two `spawn` strings in `bridge.json` as text (a JSON round-trip through PowerShell+  changed the file's shape and ab fell back to its bundled 1.6.89 seed), then+  `refresh_bridges` (ab's registry re-reads bridge.json only on a rescan or an install) and+  `bridge_kill {confirm:true}`. The next `bridge_install` restores the manifest's shape.+- **Readiness in shadow (2026-09-03):** `fusion_readiness` is ported as a pure computation+  (`fusion-core::readiness`, sixteen Python-generated golden scenarios: result, hint text,+  pref writes), and the exe computes it from real probes beside the sidecar's answer as+  `nativeReadiness {result, shadowDiff}`. On a live ready Fusion the diff was only the+  second of the `lastReadyAt` / `lastSignedInAt` stamps (two writers). Side effects come+  back as `actions` and are not run until the exe owns readiness.+- **The sidecar's port must be outside ab's allocator range.** ab persists the port it+  assigned and, on a respawn, probes it first and routes to whatever answers there. The+  sidecar's ephemeral pick landed on that exact port once (Windows returns the last freed+  port) and ab bypassed the exe for a whole session. The sidecar now binds in 20000-29999;+  `/status` carries `exePort` and `sidecarPort` so the two are never confused again.+- **ab's bundled-bridge collapse can adopt the sidecar.** `BUNDLED_SPECS` calls any+  `python.exe` whose `/health` mentions Fusion "the fusion bridge" and re-pins routing to it,+  which bypassed the exe for a session. The sidecar therefore runs as+  `adom-fusion-sidecar.exe` (a copy of ab's python launcher beside its DLLs) and binds in+  20000-29999. Proven stable on ConfRoomROG; ab ask filed (adom-bridge, collapse should key+  on `spawn.kind`). Diagnose a bypass in one call: `fusion_shell_info` answers+  `shell: rust` only when the exe is in the path.+- **Shadow readiness across a real stop/start (ConfRoomROG, 2026-09-03):** identical at+  every stable state (ready, stopped, ready again). The one divergence was the poll at the+  instant the add-in came up: Python's 1.0 s add-in probe timed out under launch load and+  said "add-in not responding", while the exe's probe (1.5 s) got the add-in's answer and the+  round-trip passed. A boundary race, not a logic difference; the exe's answer was the true one.+- **Every request on its own thread (2026-09-03).** The shell's request loop was sequential,+  so a long verb (`fusion_start`, a bind) blocked ab's health poll and ab reaped and+  respawned the exe twice during one Fusion launch, orphaning the run: the same lesson the+  Python bridge learned on 2026-08-29. Now each request runs on its own thread (tiny_http+  hands them out concurrently); a blocking `fusion_start` through the exe produced no+  respawn and readiness agreed afterwards.
rust/bridge/src/main.rs+115−13
@@ -111,8 +111,22 @@ impl Sidecar {         found.pop().map(|f| f.1)     } +    /// The sidecar runs under its OWN image name. ab's bundled-bridge collapse scans every+    /// loopback listener and adopts any `python.exe` whose /health body mentions Fusion as+    /// THE fusion bridge, re-pinning the route to it; that sent every verb straight to the+    /// sidecar and bypassed this exe for a whole session. python.exe is a tiny launcher that+    /// loads python3xx.dll from its own directory, so a copy named adom-fusion-sidecar.exe+    /// beside it runs identically and is invisible to that scan.+    fn sidecar_image(py: &std::path::Path) -> std::path::PathBuf {+        let Some(dir) = py.parent() else { return py.to_path_buf() };+        if !py.file_name().map(|n| n.to_string_lossy().eq_ignore_ascii_case("python.exe")).unwrap_or(false) { return py.to_path_buf(); }+        let alias = dir.join("adom-fusion-sidecar.exe");+        let fresh = match (std::fs::metadata(&alias), std::fs::metadata(py)) { (Ok(a), Ok(b)) => a.len() == b.len(), _ => false };+        if fresh || std::fs::copy(py, &alias).is_ok() { alias } else { py.to_path_buf() }+    }+     fn spawn(bind: &str, log_dir: &std::path::Path) -> Option<Sidecar> {-        let py = Self::python_exe()?;+        let py = Self::sidecar_image(&Self::python_exe()?);         let here = std::env::current_exe().ok()?.parent()?.to_path_buf();         // The sidecar's server.py sits beside this exe in the bridge dir during the port.         let server_py = here.join("server.py");@@ -169,7 +183,7 @@ fn native_status(h: &dyn HostBoth) -> Value {     let addin_ok = addin.is_object();     // layer 2: the window facts, from ab (never local Win32)     let facts = if running {-        match fusion_core::ab::Loopback::from_env() { Some(ab) => fusion_core::windows_facts::facts_from_ab(&ab, &Caller::default()), None => fusion_core::windows_facts::WindowFacts { degraded: true, ..Default::default() } }+        match fusion_core::ab::Loopback::from_env() { Some(ab) => fusion_core::windows_facts::facts_from_ab(&ab, &Caller::default()), None => fusion_core::windows_facts::WindowFacts { degraded: true, error: Some("ADOM_DIRECT_API_URL not set".into()), ..Default::default() } }     } else { fusion_core::windows_facts::WindowFacts::default() };     let (led, summary, tooltip) = if !cfg!(windows) {         ("yellow", "Unsupported OS", "Bridge running, but this host is not Windows. Fusion 360 verbs need a Windows host with Fusion 360 installed.")@@ -190,13 +204,24 @@ fn native_status(h: &dyn HostBoth) -> Value {                    "addin_installed": addin_installed, "running": running},         "addin": addin, "shell": "rust",         "needsSignin": facts.needs_signin, "licensingDialog": facts.licensing_dialog.is_some(),-        "windowTitle": facts.main_title, "windowFactsDegraded": facts.degraded,+        "windowTitle": facts.main_title, "windowFactsDegraded": facts.degraded, "windowFactsError": facts.error,     });     *SNAPSHOT.lock().unwrap() = Some(Snapshot { at: Instant::now(), body: body.clone() });     body } +/// A port for the SIDECAR that ab can never mistake for the bridge's. ab allocates bridge+/// ports from the OS ephemeral range (49152+), persists the last one, and on a respawn probes+/// that port FIRST and routes to whatever answers /health there. An ephemeral pick for the+/// sidecar landed on exactly that persisted port once (Windows hands back the last freed+/// port), and ab bypassed the exe for the rest of the session. So the sidecar lives in+/// 20000..29999, which ab's allocator never touches. fn pick_free_port() -> u16 {+    let seed = std::process::id() as u16 % 5000;+    for i in 0..5000u16 {+        let port = 20000 + (seed + i * 7) % 10000;+        if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() { return port; }+    }     std::net::TcpListener::bind("127.0.0.1:0").and_then(|l| l.local_addr()).map(|a| a.port()).unwrap_or(8773) } @@ -227,8 +252,8 @@ fn main() {     let port: u16 = args.iter().position(|a| a == "--port")         .and_then(|i| args.get(i + 1)).and_then(|p| p.parse().ok()).unwrap_or(8773);     let bind = std::env::var("ADOM_BIND_HOST").unwrap_or_else(|_| "127.0.0.1".into());-    let catalog = fusion_core::contract::load().expect("frozen verb catalog");-    let h = host();+    let catalog = Arc::new(fusion_core::contract::load().expect("frozen verb catalog"));+    let h: Arc<Box<dyn HostBoth>> = Arc::new(host());     let sidecar = Arc::new(Mutex::new(Sidecar::spawn(&bind, &h.bridge_state_dir())));     // ab stops this bridge with a hard kill (bridge_install, supervisor respawn): the sidecar     // must go with it, or every respawn leaves one more Python on a random port.@@ -242,8 +267,18 @@ fn main() {               catalog.frozen,               sidecar.lock().unwrap().as_ref().map(|s| format!("python on :{}", s.port)).unwrap_or("absent".into())); -    for mut req in server.incoming_requests() {-        let path = req.url().split('?').next().unwrap_or("/").to_string();+    // EVERY REQUEST ON ITS OWN THREAD. ab polls /status every few seconds and reaps a bridge+    // whose health poll stalls; a sequential loop let one long verb (fusion_start, a seven-+    // minute bind) block the poll, and ab respawned this exe twice during a single Fusion+    // launch, orphaning the run (ConfRoomROG, 2026-09-03). The Python bridge learned the+    // same lesson on 2026-08-29. tiny_http hands out requests concurrently; we take them.+    for req in server.incoming_requests() {+        let (h, catalog, sidecar) = (h.clone(), catalog.clone(), sidecar.clone());+        let port_now = port;+        std::thread::spawn(move || {+            let mut req = req;+            let port = port_now;+            let path = req.url().split('?').next().unwrap_or("/").to_string();         // A panic in a handler must cost one request (tiny_http answers 500 for the dropped         // request), never the process: ab paints the chip red the moment the port goes dark.         let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {@@ -253,8 +288,9 @@ fn main() {                 // this process is up regardless, so the answer is ALWAYS 2xx.                 // Without a sidecar this IS the answer; with one, the sidecar's rich status                 // wins and the native snapshot rides beside it for parity checks.-                let native = native_status(h.as_ref());+                let native = native_status(&**h);                 let mut body = native.clone();+                body["exePort"] = json!(port);                 if let Some(sc) = sidecar.lock().unwrap().as_mut() {                     if let Some(code) = sc.exited() {                         body["led"] = json!("red"); body["status"] = json!("error");@@ -263,10 +299,10 @@ fn main() {                         body["sidecarExit"] = json!(code);                     } else if let Ok(r) = ureq::get(&sc.url("/status")).timeout(Duration::from_secs(4)).call() {                         if let Ok(mut v) = r.into_json::<Value>() {-                            v["shell"] = json!("rust"); v["sidecarPort"] = json!(sc.port);+                            v["shell"] = json!("rust"); v["sidecarPort"] = json!(sc.port); v["exePort"] = json!(port);                             v["sidecarUptimeSec"] = json!(sc.started.elapsed().as_secs());                             v["nativeSnapshot"] = json!({"led": native["led"], "summary": native["summary"], "fusion": native["fusion"], "addinOk": native["addin"].is_object(),-                                                         "needsSignin": native["needsSignin"], "licensingDialog": native["licensingDialog"], "windowTitle": native["windowTitle"], "windowFactsDegraded": native["windowFactsDegraded"]});+                                                         "needsSignin": native["needsSignin"], "licensingDialog": native["licensingDialog"], "windowTitle": native["windowTitle"], "windowFactsDegraded": native["windowFactsDegraded"], "windowFactsError": native["windowFactsError"]});                             body = v;                         }                     }@@ -280,16 +316,28 @@ fn main() {                 let parsed: Value = serde_json::from_str(&raw).unwrap_or(json!({}));                 let verb = parsed.get("command").and_then(|c| c.as_str()).unwrap_or("").to_string();                 let reply = match verb.as_str() {+                    // Phase 3 shadow: the sidecar still answers; the exe computes its own readiness+                    // from real probes and rides beside it with a diff, until parity holds.+                    "fusion_readiness" | "readiness" => {+                        let mut r = proxy(&sidecar, &raw, &caller);+                        let native = shadow_readiness(&**h, &caller);+                        if let Some(obj) = r.as_object_mut() {+                            let keys = ["installed", "installing", "running", "ready", "needsSignin", "licensingDialog", "updating", "addinStale", "addinVersion", "lastReadyAt", "lastSignedInAt", "_hint", "statusVerb", "kicadIngest"];+                            let diff: Vec<String> = keys.iter().filter(|k| obj.get(**k) != native.get(**k)).map(|k| k.to_string()).collect();+                            obj.insert("nativeReadiness".into(), json!({"result": native, "shadowDiff": diff}));+                        }+                        r+                    }                     // Native since Phase 2: Fusion's own alert toasts from its AppLogFile, the                     // same answer the Python bridge gives (parser golden-tested against it).                     "fusion_alerts" | "alerts" => {                         let since = parsed["args"]["sinceSec"].as_f64().unwrap_or(300.0).max(1.0);-                        native_alerts(h.as_ref(), since)+                        native_alerts(&**h, since)                     }                     // Native since Phase 2: the background-action ledger, a file the bridge appends.-                    "fusion_action_ledger" | "action_ledger" => native_action_ledger(h.as_ref(), &parsed["args"]),+                    "fusion_action_ledger" | "action_ledger" => native_action_ledger(&**h, &parsed["args"]),                     // Native since Phase 2: the measured run-time report from the verb log.-                    "fusion_verb_times" | "verb_times" => native_verb_times(h.as_ref(), parsed["args"]["limit"].as_u64().unwrap_or(40) as usize),+                    "fusion_verb_times" | "verb_times" => native_verb_times(&**h, parsed["args"]["limit"].as_u64().unwrap_or(40) as usize),                     // Native since Phase 2: offline library checks on the golden-tested .lbr engine.                     "fusion_library_parts" | "library_parts" => native_library_parts(&parsed["args"]),                     "fusion_preflight_lbr" | "preflight_lbr" => native_preflight_lbr(&parsed["args"]),@@ -316,6 +364,7 @@ fn main() {         }         }));         if outcome.is_err() { eprintln!("[fusion-bridge] a handler panicked on {path}; the shell stays up"); }+        });     } } @@ -795,3 +844,56 @@ fn native_aps_status(sidecar: &Arc<Mutex<Option<Sidecar>>>, caller: &Caller) ->     else { ("Ready. Use fusion_aps_search {\"query\":\"...\"}.".into(), "ready") };     json!({"success": true, "output": summary, "data": data, "_hint": hint, "shell": "rust"}) }+++/// Gather every input the readiness computation needs from real probes (no side effects),+/// and compute the native answer. Shadow mode: nothing is written, nothing is acted on.+fn shadow_readiness(h: &dyn HostBoth, caller: &Caller) -> Value {+    use fusion_core::readiness::{compute, timings_stats, timings_summary, Inputs};+    let state = h.bridge_state_dir();+    let read_json = |p: std::path::PathBuf| -> Value { std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({})) };+    let prefs = read_json(state.join("prefs.json")).as_object().cloned().unwrap_or_default();+    let timings = read_json(state.join("timings.json"));+    let installed_raw = h.fusion_executable().is_some();+    let streaming = h.image_running(&["streamer.exe", "FusionDL.exe", "Fusion Client Downloader.exe"]) || h.fusion_install_streaming();+    let running = installed_raw && !h.fusion_processes().is_empty();+    let addin = if running { addin_probe() } else { Value::Null };+    let facts = if running { match fusion_core::ab::Loopback::from_env() { Some(ab) => fusion_core::windows_facts::facts_from_ab(&ab, caller), None => Default::default() } } else { Default::default() };+    let expected = std::env::current_exe().ok().and_then(|e| e.parent().map(|d| d.join("addin").join("AdomBridge").join("AdomBridge.manifest")))+        .and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str::<Value>(&t).ok()).and_then(|m| m["version"].as_str().map(str::to_string)).unwrap_or_else(|| "unknown".into());+    let aps = fusion_core::aps::ApsStore::default_location();+    let aps_configured = !aps.client_id().is_empty();+    let aps_signed = aps.valid_access_token().is_some();+    let mcp = std::net::TcpStream::connect_timeout(&"127.0.0.1:27182".parse().unwrap(), Duration::from_millis(400)).is_ok();+    let addin_ok = addin.is_object();+    let provisional_ready = installed_raw && running && addin_ok && !facts.needs_signin && facts.licensing_dialog.is_none();+    let roundtrip_ok = if provisional_ready {+        ureq::post("http://127.0.0.1:8774/command").timeout(Duration::from_secs(4)).send_json(json!({"command": "get_app_state", "args": {}})).ok()+            .and_then(|r| r.into_json::<Value>().ok()).map(|v| v["success"].as_bool().unwrap_or(false)).unwrap_or(false)+    } else { true };+    let home = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_default();+    let kicad_ingest = std::env::var("PATH").unwrap_or_default().split(';').any(|d| std::path::Path::new(d).join("adom-lbr.exe").is_file() || std::path::Path::new(d).join("adom-lbr").is_file())+        || std::env::var("LOCALAPPDATA").map(|la| std::path::Path::new(&la).join("Programs").join("adom-lbr").join("adom-lbr.exe").is_file() || std::path::Path::new(&la).join("adom-lbr").join("adom-lbr.exe").is_file()).unwrap_or(false);+    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0);+    let now_iso = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();+    let marker = std::path::Path::new(&std::env::var("TEMP").unwrap_or(home.clone())).join("adom-fusion").join("addin-start-failed.json");+    let addin_start_failed = std::fs::read_to_string(&marker).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).map(|mut m| {+        m["markerPath"] = json!(marker.to_string_lossy());+        m["_hint"] = json!(format!("Fusion loaded a BROKEN copy of the add-in from {}. The bridge re-syncs every AddIns dir on start; restart Fusion with fusion_restart_for_addin {{save:true}} so it loads the repaired copy. If it keeps failing, delete that folder and restart Fusion: the bridge reinstalls it.", m["addinDir"].as_str().unwrap_or("")));+        m });+    let inputs = Inputs {+        installed_raw, streaming, running, addin_version: addin["version"].as_str().map(str::to_string).or(if addin_ok { Some("unknown".into()) } else { None }),+        expected_addin_version: expected, needs_signin: facts.needs_signin, licensing_dialog: facts.licensing_dialog.is_some(),+        updating: h.fusion_update_in_progress(), aps_configured, aps_signed_in: aps_signed, mcp_port_open: mcp,+        auto_update_enabled: prefs.get("autoUpdateFusion").and_then(|v| v.as_bool()).unwrap_or(true), roundtrip_ok, kicad_ingest,+        fusion_user: None, modeling_orientation: None, bridge_version: VERSION.into(), prefs, now, now_iso,+        timings_launch: timings_summary(&timings)["launch"].clone(), launch_stats: timings_stats(&timings, "launch", "cold"),+        progress_override: None, addin_start_failed, crash_dialogs_cleared: 0, update_applied_button: None, update_offered: false,+    };+    let out = compute(&inputs);+    let mut r = out.result;+    r["shadow"] = json!(true);+    r["actions"] = serde_json::to_value(&out.actions).unwrap_or(Value::Null);+    r["windowFactsDegraded"] = json!(facts.degraded);+    r+}
rust/core/src/ab.rs+11−1
@@ -46,8 +46,18 @@ pub struct Loopback {  impl Loopback {     pub fn from_env() -> Option<Loopback> {-        let base = std::env::var("ADOM_DIRECT_API_URL").ok()?.trim().trim_end_matches('/').to_string();+        let mut base = std::env::var("ADOM_DIRECT_API_URL").unwrap_or_default().trim().trim_end_matches('/').to_string();+        if base.is_empty() {+            // ab's fallback for a bridge spawned without the env var: ~/.adom/direct-api-port+            let home = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_default();+            if let Ok(port) = std::fs::read_to_string(std::path::Path::new(&home).join(".adom").join("direct-api-port")) {+                let port = port.trim();+                if !port.is_empty() { base = format!("http://127.0.0.1:{port}"); }+            }+        }         if base.is_empty() { return None; }+        // ab exempts a bridge's own calls from its caller-identity gate through this token; it+        // is injected at spawn and is the reason a hand-launched exe reports degraded window facts.         Some(Loopback { base, token: std::env::var("ADOM_BRIDGE_TOKEN").unwrap_or_default() })     } }
rust/core/src/host.rs+11
@@ -39,6 +39,10 @@ pub trait HostProcess: Send + Sync {     /// Whether the bridge runs elevated (drives the installer's --globalinstall decision).     fn is_elevated(&self) -> bool; +    /// Is any process with one of these image names running (case-insensitive)? The Fusion+    /// streamer / installer check on the readiness poll. In-process, like `fusion_processes`.+    fn image_running(&self, _names: &[&str]) -> bool { false }+     /// Tie a child (the Python sidecar during the port) to THIS process's lifetime, so a hard     /// kill of the bridge by ab (every bridge_install, every supervisor respawn) takes the     /// child with it instead of leaving an orphan on a random port. A Windows job object@@ -62,4 +66,11 @@ pub trait HostPaths: Send + Sync {     fn bridge_state_dir(&self) -> PathBuf;     /// A scratch dir for staged libraries, STEPs and screenshots.     fn scratch_dir(&self) -> PathBuf;++    /// A fresh install is streaming: some webdeploy dir has the launcher but no complete+    /// Fusion360.exe exists anywhere yet (disk state, process-independent).+    fn fusion_install_streaming(&self) -> bool { false }+    /// Fusion is applying an auto-update: two or more launcher builds and one of them+    /// touched in the last ~20 minutes (stale leftover builds never trigger it).+    fn fusion_update_in_progress(&self) -> bool { false } }
rust/core/src/lib.rs+1
@@ -12,6 +12,7 @@ pub mod host; pub mod alerts; pub mod aps; pub mod windows_facts;+pub mod readiness; pub mod ab;  pub use host::{HostPaths, HostProcess};
rust/core/src/readiness.rsadded+243
@@ -0,0 +1,243 @@+//! Phase 3: `fusion_readiness`, the launch observer, ported as a PURE computation over probed+//! inputs. The probes (Toolhelp, the add-in HTTP status, ab's window rows, APS, prefs, the+//! timings store) are gathered by the exe; this module turns them into the exact result the+//! Python handler produces, hint text included (`readiness_hints.json` is derived from the+//! Python goldens, never hand-typed). Side effects (seat auto-resolve, crash janitor, launch+//! self-heal, auto-update) are NOT here: they are returned as `Actions` for the caller to+//! run when it owns readiness, and the pref writes come back as `prefs_after`.+//!+//! AI hint: `tests/golden/readiness.json` was generated by running the Python handler with+//! every helper stubbed per scenario. A difference from it is a bug here until proven+//! otherwise; regenerate the goldens only when server.py's handler changes on purpose.++use serde_json::{json, Map, Value};++/// Python's `%r` of a str (single-quoted unless it holds a single quote and no double).+fn py_repr_str(s: &str) -> String { if s.contains('\'') && !s.contains('"') { format!("\"{s}\"") } else { format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")) } }++const HINTS: &str = include_str!("readiness_hints.json");+fn hint(key: &str) -> String {+    let h: Value = serde_json::from_str(HINTS).expect("readiness_hints.json");+    h[key].as_str().unwrap_or("").to_string()+}++/// What the exe probed, the same facts the Python handler gathers itself.+#[derive(Debug, Clone, Default)]+pub struct Inputs {+    pub installed_raw: bool,+    /// installer process seen OR a launcher dir with no complete app anywhere+    pub streaming: bool,+    pub running: bool,+    /// the add-in's /status version when it answered, None when silent+    pub addin_version: Option<String>,+    pub expected_addin_version: String,+    pub needs_signin: bool,+    pub licensing_dialog: bool,+    pub updating: bool,+    pub aps_configured: bool,+    pub aps_signed_in: bool,+    pub mcp_port_open: bool,+    pub auto_update_enabled: bool,+    /// `get_app_state` round-trip through the add-in succeeded (only asked when ready)+    pub roundtrip_ok: bool,+    pub kicad_ingest: bool,+    pub fusion_user: Option<Value>,+    pub modeling_orientation: Option<String>,+    pub bridge_version: String,+    pub prefs: Map<String, Value>,+    pub now: f64,+    pub now_iso: String,+    /// `timings.summary()["launch"]` and `timings.stats("launch","cold")`+    pub timings_launch: Value,+    pub launch_stats: Value,+    /// A caller-supplied progress block for tests; production builds one from timings.+    pub progress_override: Option<Value>,+    pub addin_start_failed: Option<Value>,+    pub crash_dialogs_cleared: usize,+    /// The owner ran the silent update click before computing: the button it clicked.+    pub update_applied_button: Option<String>,+    /// Fusion is offering an update right now (the sidecar's `_LAST_UPDATE_OFFER`).+    pub update_offered: bool,+}++/// The side effects the Python handler would have taken, for the owner to run.+#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]+pub struct Actions {+    #[serde(rename = "resolveSeatDialog")] pub resolve_seat_dialog: bool,+    #[serde(rename = "applyUpdateSilently")] pub apply_update_silently: bool,+    #[serde(rename = "healLaunch")] pub heal_launch: Option<u64>,+    #[serde(rename = "recordLaunchSeconds")] pub record_launch_seconds: Option<f64>,+}++pub struct Outcome { pub result: Value, pub prefs_after: Map<String, Value>, pub actions: Actions }++fn pct(sorted: &[f64], q: f64) -> Option<f64> {+    if sorted.is_empty() { return None; }+    let i = (q * (sorted.len() as f64 - 1.0)).round_ties_even() as i64;+    Some(sorted[i.clamp(0, sorted.len() as i64 - 1) as usize])+}++/// `timings.summary()` over the store's JSON ({"phase|kind": {samples, lastAt}}).+pub fn timings_summary(store: &Value) -> Value {+    let mut out = Map::new();+    if let Some(m) = store.as_object() {+        for (k, row) in m {+            let (phase, kind) = match k.find('|') { Some(i) => (&k[..i], &k[i + 1..]), None => (k.as_str(), "") };+            let mut s: Vec<f64> = row["samples"].as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();+            if s.is_empty() { continue; }+            s.sort_by(|a, b| a.partial_cmp(b).unwrap());+            let entry = out.entry(phase.to_string()).or_insert_with(|| json!({}));+            entry[kind] = json!({"p50": pct(&s, 0.5), "p90": pct(&s, 0.9), "count": s.len(), "lastAt": row["lastAt"]});+        }+    }+    Value::Object(out)+}++/// `timings.stats(phase, kind)`.+pub fn timings_stats(store: &Value, phase: &str, kind: &str) -> Value {+    let mut s: Vec<f64> = store[format!("{phase}|{kind}")]["samples"].as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();+    s.sort_by(|a, b| a.partial_cmp(b).unwrap());+    json!({"p50": pct(&s, 0.5), "p90": pct(&s, 0.9), "count": s.len()})+}++/// `timings.progress_block` for the launch phase (percent derived from elapsed / estimate).+pub fn progress_block(stats: &Value, started: f64, now: f64, label: &str, fallback: f64) -> Value {+    let elapsed = (now - started).max(0.0);+    let (count, p50) = (stats["count"].as_u64().unwrap_or(0), stats["p50"].as_f64());+    let (est0, mut conf) = match (count, p50) { (c, Some(p)) if c >= 2 && p > 0.0 => (p, "measured"), (1, Some(p)) if p > 0.0 => (p, "typical"), _ => (fallback, "typical") };+    let mut est = est0;+    let overdue = est0 > 0.0 && elapsed > est0;+    if overdue {+        est = elapsed * 1.25;+        if conf == "measured" { conf = "typical"; }+        if elapsed > 2.0 * est0 { conf = "unknown"; }+    }+    let mut percent: i64 = if est0 <= 0.0 { 0 } else if !overdue { (80.0 * elapsed / est0) as i64 } else { let over = elapsed - est0; (80.0 + 15.0 * (over / (over + est0))) as i64 };+    percent = percent.clamp(0, if est > 0.0 { 99 } else { 0 });+    let eta = if est > 0.0 { Some(((est - elapsed).round() as i64).max(0)) } else { None };+    json!({"phase": "launch", "stepLabel": label, "percent": percent, "elapsedSec": elapsed.round() as i64,+           "estimatedSec": if est > 0.0 { json!(est.round() as i64) } else { Value::Null }, "etaSec": eta,+           "confidence": if est > 0.0 { conf } else { "unknown" }, "startKind": "cold", "overdue": overdue})+}++pub fn compute(i: &Inputs) -> Outcome {+    let mut prefs = i.prefs.clone();+    let mut actions = Actions::default();+    let (installed, installing) = if i.streaming && !i.running { (false, true) } else { (i.installed_raw, !i.installed_raw && i.streaming) };+    let running = if i.installed_raw { i.running } else { false };+    let addin_ok = i.addin_version.is_some();+    let needs_signin = running && i.needs_signin;+    let mut ready = installed && running && addin_ok && !needs_signin;+    let licensing = running && i.licensing_dialog;+    if licensing { actions.resolve_seat_dialog = true; ready = false; }+    let updating = if installed && !ready { i.updating } else { false };+    let mut stale = Map::new();+    if addin_ok {+        let reported = i.addin_version.clone().unwrap_or_default();+        let is_stale = reported != "unknown" && i.expected_addin_version != "unknown" && reported != i.expected_addin_version;+        stale.insert("addinVersion".into(), json!(reported));+        stale.insert("expectedAddinVersion".into(), json!(i.expected_addin_version));+        stale.insert("addinStale".into(), json!(is_stale));+        if is_stale { stale.insert("_staleHint".into(), json!(hint("stale_template").replace("{reported}", &reported).replace("{expected}", &i.expected_addin_version))); }+    }+    let mut hint_text = if updating && !ready { hint("updating") }+        else if needs_signin { hint("needs_signin") }+        else if ready && stale["addinStale"] == json!(true) { stale["_staleHint"].as_str().unwrap_or("").to_string() }+        else if ready {+            actions.apply_update_silently = true;+            let mut msg = if i.mcp_port_open { hint("mcp_on") } else { hint("mcp_off") };+            if let Some(b) = &i.update_applied_button { msg.push_str(&hint("upd_applied_template").replace("{button}", &py_repr_str(b))); }+            else if !i.auto_update_enabled { msg.push_str(&hint("upd_off")); }+            let base = if i.aps_signed_in { hint("ready_signed") } else if i.aps_configured { hint("ready_configured") } else { hint("ready_unconfigured") };+            base + &msg+        }+        else if installed && running && licensing { hint("licensing") }+        else if installed && running { hint("addin_silent") }+        else if installed { hint("installed_not_running") }+        else if installing { hint("installing") } else { hint("not_installed") };+    let mut note: Option<String> = None;+    if ready && !i.roundtrip_ok { ready = false; note = Some(hint("roundtrip_note")); }+    if let Some(n) = &note { hint_text = format!("{hint_text}{}{n}", hint("roundtrip_note_prefix")); }+    let orient_note = if i.modeling_orientation.as_deref() == Some("yup") {+        " ⚠ Default Modeling Orientation is Y-UP on this account, but Adom is a Z-UP environment (a fresh Autodesk account defaults to Y-up). Set it with fusion_set_preference {\"modelingOrientation\":\"zup\"} - no restart needed." } else { "" };+    let mut result = Map::new();+    result.insert("success".into(), json!(true));+    result.insert("hostApp".into(), json!("Fusion 360"));+    result.insert("installed".into(), json!(installed));+    result.insert("installing".into(), json!(installing));+    result.insert("running".into(), json!(running));+    result.insert("ready".into(), json!(ready));+    result.insert("kicadIngest".into(), json!(i.kicad_ingest));+    result.insert("fusionUser".into(), i.fusion_user.clone().unwrap_or(Value::Null));+    result.insert("modelingOrientation".into(), i.modeling_orientation.clone().map(Value::String).unwrap_or(Value::Null));+    result.insert("bridgeVersion".into(), json!(i.bridge_version));+    for (k, v) in &stale { result.insert(k.clone(), v.clone()); }+    result.insert("_hint".into(), json!(format!("{hint_text}{orient_note}")));+    // the launch observer+    result.insert("timings".into(), i.timings_launch.clone());+    let now = i.now;+    let mut lsa = prefs.get("launchStartedAt").and_then(|v| v.as_f64());+    if let Some(s) = lsa { if !ready && now - s > 900.0 { prefs.insert("launchStartedAt".into(), json!(now)); prefs.insert("launchHealedAt".into(), Value::Null); lsa = Some(now); } }+    if lsa.is_none() && running && !ready { prefs.insert("launchStartedAt".into(), json!(now)); lsa = Some(now); }+    if !ready && running && i.crash_dialogs_cleared > 0 {+        result.insert("crashReportCleared".into(), json!(i.crash_dialogs_cleared));+        result.insert("fusionCrashed".into(), json!(true));+        prefs.insert("launchHealedAt".into(), Value::Null); prefs.insert("launchStartedAt".into(), json!(now)); lsa = Some(now);+    }+    let healed = prefs.get("launchHealedAt").map(|v| !v.is_null()).unwrap_or(false);+    if !ready && running && !needs_signin && !licensing && !updating && lsa.is_some() && !healed {+        let p90 = i.launch_stats["p90"].as_f64().unwrap_or(0.0);+        let overdue_by = (2.0 * p90).max(180.0);+        if now - lsa.unwrap() > overdue_by {+            prefs.insert("launchHealedAt".into(), json!(now));+            result.insert("addinRestartTriggered".into(), json!(true));+            let attempt = prefs.get("launchHealAttempts").and_then(|v| v.as_u64()).unwrap_or(0) + 1;+            if attempt > 4 {+                result.insert("addinRestartExhausted".into(), json!(true));+                result.insert("_hint".into(), json!(format!("Fusion has been restarted {} times and the add-in still does not serve. This is no longer a slow launch: screenshot the Fusion hwnd and read screenshots[] for a blocking dialog, or check whether Fusion is crashing at startup (fusionCrashed / crash_report).", attempt - 1)));+            } else {+                prefs.insert("launchHealAttempts".into(), json!(attempt));+                result.insert("addinRestartAttempt".into(), json!(attempt));+                actions.heal_launch = Some(attempt);+            }+        }+    }+    if ready {+        prefs.insert("launchHealedAt".into(), Value::Null);+        prefs.insert("launchHealAttempts".into(), json!(0));+        if let Some(s) = lsa {+            let el = now - s;+            if (3.0..=3600.0).contains(&el) { actions.record_launch_seconds = Some(el); }+            prefs.insert("launchStartedAt".into(), Value::Null);+        }+    } else if let Some(s) = lsa {+        if now - s < 900.0 {+            let label = if needs_signin { "Signing in to Autodesk" } else if licensing { "Clearing the Autodesk seat dialog" } else if updating { "Applying a Fusion auto-update" } else if running { "Fusion is up - waiting for the Adom add-in to serve" } else { "Fusion is starting" };+            let mut pb = match &i.progress_override { Some(v) => { let mut v = v.clone(); v["stepLabel"] = json!(label); v }, None => progress_block(&i.launch_stats, s, now, label, 150.0) };+            if pb["overdue"] == json!(true) && pb["confidence"] == json!("unknown") {+                pb["stepLabel"] = json!(format!("{label} - this is longer than this machine has ever taken; restarting Fusion, since the add-in only loads at launch"));+            }+            result.insert("progress".into(), pb);+            result.insert("statusVerb".into(), json!("fusion_readiness"));+        }+    }+    if updating { result.insert("updating".into(), json!(true)); result.insert("statusVerb".into(), json!("fusion_readiness")); }+    if needs_signin { result.insert("needsSignin".into(), json!(true)); result.insert("statusVerb".into(), json!("fusion_readiness")); }+    if ready && running { prefs.insert("lastReadyAt".into(), json!(i.now_iso)); }+    if running && !needs_signin && ready { prefs.insert("lastSignedInAt".into(), json!(i.now_iso)); }+    result.insert("lastReadyAt".into(), prefs.get("lastReadyAt").cloned().unwrap_or(Value::Null));+    result.insert("lastSignedInAt".into(), prefs.get("lastSignedInAt").cloned().unwrap_or(Value::Null));+    if licensing { result.insert("licensingDialog".into(), json!(true)); result.insert("statusVerb".into(), json!("fusion_readiness")); }+    if i.update_offered {+        result.insert("fusionUpdateOffered".into(), json!(true));+        let handled = i.update_applied_button.is_some();+        result.insert("fusionUpdateHandled".into(), json!(handled));+        result.insert("fusionUpdateHint".into(), json!(if handled { hint("fusion_update_hint_handled") } else { hint("fusion_update_hint_pending_template").replace("{state}", if !i.auto_update_enabled { "opted out" } else { "pending" }) }));+    }+    if let Some(asf) = &i.addin_start_failed {+        result.insert("addinStartFailed".into(), asf.clone());+        let h = format!("{} {}", asf["_hint"].as_str().unwrap_or(""), result["_hint"].as_str().unwrap_or(""));+        result.insert("_hint".into(), json!(h));+    }+    Outcome { result: Value::Object(result), prefs_after: prefs, actions }+}
rust/core/src/windows_facts.rs+3−1
@@ -28,6 +28,8 @@ pub struct WindowFacts {     #[serde(rename = "licensingDialog")] pub licensing_dialog: Option<Value>,     /// ab could not be asked (loopback missing or refused): facts unknown, not "none".     pub degraded: bool,+    /// Why, when degraded (the ab error text), so a smoke test can tell 403 from absent.+    #[serde(skip_serializing_if = "Option::is_none")] pub error: Option<String>, }  fn area(w: &Value) -> i64 { w["rect"]["width"].as_i64().unwrap_or(0) * w["rect"]["height"].as_i64().unwrap_or(0) }@@ -68,7 +70,7 @@ pub fn facts_from_ab(ab: &dyn AbClient, caller: &Caller) -> WindowFacts {             let rows = v["data"]["windows"].as_array().or_else(|| v["windows"].as_array()).cloned().unwrap_or_default();             facts_from_rows(&rows)         }-        Err(_) => WindowFacts { degraded: true, ..Default::default() },+        Err(e) => WindowFacts { degraded: true, error: Some(e.to_string()), ..Default::default() },     } } 
rust/os-win/src/lib.rs+43
@@ -118,6 +118,33 @@ fn find_fusion_launcher() -> Option<PathBuf> {     None } +#[cfg(windows)]+fn snapshot_images() -> Vec<(u32, u32, String)> {+    use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};+    use windows_sys::Win32::System::Diagnostics::ToolHelp::{+        CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,+    };+    let mut out = Vec::new();+    // SAFETY: same Toolhelp32 walk as snapshot_fusion, over a handle we own and close.+    unsafe {+        let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);+        if snap == INVALID_HANDLE_VALUE || snap.is_null() { return out; }+        let mut pe: PROCESSENTRY32W = std::mem::zeroed();+        pe.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;+        if Process32FirstW(snap, &mut pe) != 0 {+            loop {+                let len = pe.szExeFile.iter().position(|&c| c == 0).unwrap_or(pe.szExeFile.len());+                out.push((pe.th32ProcessID, pe.th32ParentProcessID, String::from_utf16_lossy(&pe.szExeFile[..len])));+                if Process32NextW(snap, &mut pe) == 0 { break; }+            }+        }+        CloseHandle(snap);+    }+    out+}+#[cfg(not(windows))]+fn snapshot_images() -> Vec<(u32, u32, String)> { Vec::new() }+ #[cfg(windows)] fn snapshot_fusion() -> Vec<FusionProcess> {     use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};@@ -178,6 +205,10 @@ fn start_shim(_exe: &Path) -> Result<u32, HostError> { impl HostProcess for WindowsHost {     fn fusion_processes(&self) -> Vec<FusionProcess> { snapshot_fusion() } +    fn image_running(&self, names: &[&str]) -> bool {+        snapshot_images().iter().any(|(_, _, img)| names.iter().any(|n| n.eq_ignore_ascii_case(img)))+    }+     fn launch_fusion(&self) -> Result<FusionProcess, HostError> {         let exe = self.fusion_executable().ok_or_else(|| HostError::NotInstalled(             "no webdeploy hash dir holds a complete Fusion360.exe".into()))?;@@ -273,6 +304,18 @@ impl HostPaths for WindowsHost {      fn bridge_state_dir(&self) -> PathBuf { home().join(".adom").join("fusion-bridge") } +    fn fusion_install_streaming(&self) -> bool {+        if any_app_complete() { return false; }+        webdeploy_bases().iter().any(|b| subdirs(b).iter().any(|s| s.join("FusionLauncher.exe").is_file()))+    }++    fn fusion_update_in_progress(&self) -> bool {+        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);+        let mut mtimes = Vec::new();+        for b in webdeploy_bases() { for s in subdirs(&b) { if s.join("FusionLauncher.exe").is_file() { mtimes.push(mtime_secs(&s)); } } }+        mtimes.len() >= 2 && mtimes.iter().any(|m| now.saturating_sub(*m) < 20 * 60)+    }+     fn scratch_dir(&self) -> PathBuf {         env_dir("TEMP").unwrap_or_else(|| local_app_data().join("Temp")).join("adom-fusion")     }
server.py+40−1
@@ -5381,9 +5381,14 @@ def _handle_dismiss_blocking_dialogs(fusion_info: dict, args: dict) -> dict:     guess, and never gets a stray Yes.     """     classified = classify_blocking_dialogs()+    # `categories` narrows the sweep (the Rust readiness asks for ["crash_report"] only, the+    # janitor's exact scope); no filter keeps the full dismiss-everything behaviour.+    _want = set(a.strip() for a in (args.get("categories") or []) if str(a).strip())+    if _want:+        classified = [d for d in classified if d.get("category") in _want]     if not classified:         return {"success": True, "dismissed": [], "count": 0,-                "_hint": "No blocking Fusion dialog is open."}+                "_hint": "No blocking Fusion dialog is open." if not _want else "No blocking dialog in the requested categories."}     answered, upload_active = [], False     for d in classified:         hwnd, cat = d.get("hwnd"), d.get("category")@@ -14764,6 +14769,40 @@ def _handle_set_design_rules(fusion_info: dict, args: dict) -> dict:  COMMAND_HANDLERS["set_design_rules"] = _handle_set_design_rules ++# ── Readiness executors (Phase 3 of the Rust port) ───────────────────────────────────────+# The Rust exe computes readiness itself and asks THIS process to perform the two side+# effects that still live here (UIA + screenshot verification against Fusion's dialogs).+def _handle_resolve_seat_dialog(fusion_info: dict, args: dict) -> dict:+    """Detect + resolve the seat/licensing modal in the background (max 3 clicks per call,+    screenshot-verified). Idempotent: nothing up -> {clicks: 0}."""+    try:+        r = _resolve_seat_dialog(max_clicks=int(args.get("maxClicks") or 3)) or {}+    except Exception as e:+        return {"success": False, "error": str(e)[:200]}+    still = False+    try:+        still = find_licensing_dialog() is not None+    except Exception:+        pass+    return {"success": True, "clicks": int(r.get("clicks", 0) or 0), "resolved": not still, "detail": r,+            "_hint": "Readiness executor: the seat dialog is clicked Continue (Suspend pre-selected) and verified gone by screenshot; poll fusion_readiness."}+++def _handle_apply_fusion_update(fusion_info: dict, args: dict) -> dict:+    """Click Fusion's update offer in the background when auto-update is on (never the+    exit-class buttons while a host op is active). Returns {applied, button, why}."""+    try:+        r = _apply_fusion_update_silently() or {}+    except Exception as e:+        return {"success": False, "error": str(e)[:200]}+    return {"success": True, **r, "offered": bool(_LAST_UPDATE_OFFER.get("offered")),+            "autoUpdateEnabled": _auto_update_enabled()}+++COMMAND_HANDLERS["resolve_seat_dialog"] = _handle_resolve_seat_dialog+COMMAND_HANDLERS["apply_fusion_update"] = _handle_apply_fusion_update+ def _handle_reset_workspace(args: dict) -> dict:     """fusion_reset_workspace - bring Fusion back to a clean slate between runs (#59). 
tools/check-naming.sh

  
tools/check-portable.sh

  
tools/promote.sh

  
tools/release.sh