← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.72\ No newline at end of file+1.9.73
adom-bridge-fusion-manifest.json+5−5
@@ -1,13 +1,13 @@ {     "manifest_version": 1,     "name": "fusion360",-    "version": "1.9.72",-    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.72/adom-bridge-fusion-v1.9.72.zip",-    "sha256": "18b4365ec71d90c26ef39554eba77a863efdad5161ff7dcc0ad3ef29976011bb",-    "size": 409290,+    "version": "1.9.73",+    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.85/adom-bridge-fusion-v1.9.73.zip",+    "sha256": "7d16614d4e1870208aa18e4870a9d376a6fc88a133b23151902ee6b6841bf939",+    "size": 414344,     "verbPrefixes": [         "fusion_"     ],     "healthEndpoint": "/status",-    "released_at": "2026-08-16T00:00:00Z"+    "released_at": "2026-08-19T00:00:00Z" }\ 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.59",+  "version": "1.9.73",   "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.",
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.84"+version = "1.9.85" edition = "2021"  [dependencies]
dashboard/src/main.rs+41−7
@@ -557,10 +557,28 @@ fn wait_ready_with_heal(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool {             app.hub.broadcast("state", &gather_status(&ai, target));             return true;         }-        if !announced && t0.elapsed() > Duration::from_secs(45) {-            announced = true;-            app.hub.broadcast("toast", &serde_json::json!({-                "message": "Still waiting on Fusion (a cold start takes a minute or two)", "kind": ""}));+        // MEASURED, NEVER A SHRUG (page issue #32, John 2026-08-19): the bridge now returns a+        // `progress` block seeded from THIS machine's rolling launch history, so the wait is+        // reported with a real percent + ETA instead of "a minute or two". Pushed on the live+        // channel every tick so the LED and its caption narrow as the launch proceeds.+        let rd = unwrap_data(&bridge(&ai, target, "fusion_readiness", "{}"));+        if let Some(p) = rd.get("progress").filter(|p| !p.is_null()) {+            app.hub.broadcast("progress", &serde_json::json!({+                "target": target, "phase": p["phase"].clone(), "percent": p["percent"].clone(),+                "etaSec": p["etaSec"].clone(), "elapsedSec": p["elapsedSec"].clone(),+                "stepLabel": p["stepLabel"].clone(), "confidence": p["confidence"].clone(),+                "startKind": p["startKind"].clone()}));+            if !announced {+                announced = true;+                let eta = p["etaSec"].as_i64();+                app.hub.broadcast("toast", &serde_json::json!({+                    "message": match eta {+                        Some(s) => format!("{} - about {}s left ({})",+                                           p["stepLabel"].as_str().unwrap_or("Fusion is starting"), s,+                                           p["confidence"].as_str().unwrap_or("typical")),+                        None => format!("{}", p["stepLabel"].as_str().unwrap_or("Fusion is starting")),+                    }, "kind": ""}));+            }         }     }     false@@ -569,6 +587,21 @@ fn wait_ready_with_heal(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool { fn auto_green(app: &Arc<App>, target: &str) -> bool { auto_green_forced(app, target, false) }  /// The doctrine entry point: make the box ready, never tell the user to do it.+/// How long to wait for a launch on THIS box, from the bridge's own MEASURED history+/// (page issue #32) rather than a literal. p90 with generous headroom, because giving up+/// early on a genuinely-slow-but-healthy cold start hands the user a failure the machine+/// was about to resolve (ralph, 2026-08-19: a 4-minute cap failed a box that needed more).+/// Falls back to `floor` when this machine has no history yet.+fn ready_budget(ai: &str, target: &str, floor_s: u64) -> u64 {+    let st = unwrap_data(&bridge(ai, target, "fusion_state", "{\"screenshot\":false}"));+    let cold = st["timings"]["launch"]["cold"]["p90"].as_f64()+        .or_else(|| st["timings"]["launch"]["cold"]["p50"].as_f64());+    match cold {+        Some(s) if s > 0.0 => ((s * 2.0) as u64 + 120).max(floor_s),+        _ => floor_s,+    }+}+ fn ensure_ready(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool {     let ai = app.ai_thread.clone();     if unwrap_data(&bridge(&ai, target, "fusion_readiness", "{}"))["ready"].as_bool().unwrap_or(false) {@@ -578,7 +611,8 @@ fn ensure_ready(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool {         "message": format!("Getting {} ready first - launching Fusion and waiting for the bridge add-in", target),         "kind": ""}));     let _ = auto_green(app, target);            // false when one is already in flight for this box-    wait_ready_with_heal(app, target, max_wait_s)+    let budget = ready_budget(&ai, target, max_wait_s);+    wait_ready_with_heal(app, target, budget) }  // ── SURFACES DEMO (John, 2026-08-18) ─────────────────────────────────────────────────────@@ -608,7 +642,7 @@ fn stage_demo_part(app: &Arc<App>, target: &str, page: &str) -> Result<(String,     };     // GET it ready rather than refusing: a click means "show me this", not "audit my setup".     if !ensure_ready(app, target, 240) {-        return Err(format!("Fusion did not become ready on {} within 4 minutes. Open the Settings and check the status cards - a sign-in wall or a stuck launch will say so there.", target));+        return Err(format!("Fusion did not become ready on {} within the measured launch budget for that machine. Open the Settings and check the status cards - a sign-in wall or a stuck launch will say so there.", target));     }     toast(&format!("Staging {} from the wiki (clone -> send -> bind 3D -> generate project)", page), "");     let tmp = format!("/tmp/fd-surfaces-{}", std::process::id());@@ -1007,7 +1041,7 @@ fn handle_conn(app: &Arc<App>, mut stream: TcpStream) {                 if !ensure_ready(app, &t, 240) {                     respond_json(&mut stream, &serde_json::json!({                         "success": false,-                        "error": format!("Fusion did not become ready on {} within 4 minutes", t),+                        "error": format!("Fusion did not become ready on {} within its measured launch budget", t),                         "_hint": "Open Settings and read the status cards - a sign-in wall or a stuck launch is reported there."}));                     return;                 }
dashboard/ui/index.html+30−1
@@ -46,6 +46,19 @@ h2{font-family:'Familjen Grotesk',sans-serif;font-size:17px;color:var(--text);ma .led.off{background:var(--red)} .led.warn{background:var(--yellow)} .led.starting{background:var(--yellow);animation:ledpulse 1.1s ease-in-out infinite}+/* MEASURED progress (page issue #32): a real bar with a real ETA - never "a couple of+   minutes". Width is driven by the bridge's progress.percent, which is monotonic. */+#launchbar{display:none;margin:10px 0 2px;padding:10px 12px;border:1px solid var(--line);+  border-radius:10px;background:var(--panel)}+#launchbar.show{display:block}+#launchbar .lbtop{display:flex;justify-content:space-between;align-items:baseline;gap:12px;+  font-size:13px;margin-bottom:7px}+#launchbar .lbeta{color:var(--text-2);font-family:'JetBrains Mono',monospace;font-size:12px;+  white-space:nowrap}+#launchbar .lbtrack{height:6px;border-radius:3px;background:#20262e;overflow:hidden}+#launchbar .lbfill{height:100%;width:0;border-radius:3px;background:var(--accent);+  transition:width .5s ease}+#launchbar .lbnote{margin-top:6px;font-size:11px;color:var(--text-2)} @keyframes ledpulse{0%,100%{opacity:1;box-shadow:0 0 0 0 rgba(210,153,34,.55)}50%{opacity:.55;box-shadow:0 0 10px 3px rgba(210,153,34,.35)}} .led-label{font-weight:500;color:var(--text)} .led-sub{font-size:12.5px;color:var(--text-2);margin-top:6px}@@ -469,7 +482,7 @@ function renderSurfaces(){   g.querySelectorAll('button[data-surface]').forEach(b=>{     b.onclick=async()=>{       const key=b.getAttribute('data-surface');-      const st=$('#ss-'+key);st.className='sstate';st.textContent='opening (staging on first run - up to a couple of minutes)...';+      const st=$('#ss-'+key);st.className='sstate';st.textContent='opening\u2026';       try{         const r=await api('api/surface',{method:'POST',headers:{'Content-Type':'application/json'},           body:JSON.stringify({surface:key,target:curTarget||undefined})});@@ -503,6 +516,22 @@ function connectSSE(){   const es=new EventSource('events');   es.addEventListener('hello',()=>{sseUp=true;console.log('sse connected');});   es.addEventListener('state',e=>{try{const st=JSON.parse(e.data);if(!curTarget||st.target===curTarget){lastStatus=st;renderLeds(st);}}catch(_){}});+  // The bridge's measured progress block, rendered verbatim - no local guessing.+  es.addEventListener('progress',e=>{try{+    const p=JSON.parse(e.data);+    if(curTarget && p.target && p.target!==curTarget) return;+    const box=$('#launchbar');box.classList.add('show');+    $('#lbLabel').textContent=p.stepLabel||'Fusion is starting';+    const pct=Math.max(0,Math.min(100,p.percent||0));+    $('#lbFill').style.width=pct+'%';+    $('#lbEta').textContent=(p.etaSec!=null?('~'+p.etaSec+'s left'):'')+'  '+pct+'%';+    $('#lbNote').textContent=(p.startKind?(p.startKind+' start'):'')++      (p.confidence?(' \u00b7 '+p.confidence+(p.confidence==='measured'?' from this machine\u2019s own history':'')):'')++      (p.elapsedSec!=null?(' \u00b7 '+p.elapsedSec+'s elapsed'):'');+    clearTimeout(window.__lbHide);+    window.__lbHide=setTimeout(()=>box.classList.remove('show'),25000);+    if(pct>=100) setTimeout(()=>box.classList.remove('show'),1500);+  }catch(_){}});   es.addEventListener('toast',e=>{try{const t=JSON.parse(e.data);toast(t.message,t.kind==='info'?'':t.kind);}catch(_){}});   es.addEventListener('click',e=>{try{     const {target}=JSON.parse(e.data);const sel=CLICK_TARGETS[target];
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.84"+DASH_VERSION="1.9.85" 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.84",+  "version": "1.9.85",   "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−8
@@ -1,16 +1,20 @@ {   "slug": "fusion-bridge",-  "version": "1.9.84",+  "version": "1.9.85",   "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": {-    "adom/adom-desktop": "*"+    "adom/adom-desktop": "^1.9.0"   },   "scripts": {     "install": "./install.sh",     "uninstall": "./uninstall.sh"   },   "tags": [+    "ab",+    "bridge",+    "fusion",+    "desktop-app",     "fusion360",     "autodesk",     "cad",@@ -70,7 +74,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.",@@ -99,9 +106,5 @@       "label": "Part volume",       "prompt": "How much material does each printed part in this assembly use?"     }-  ],-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"-  }+  ] }\ No newline at end of file
server.py+128−15
@@ -549,13 +549,27 @@ def _handle_launch(fusion_info: dict, args: dict) -> dict:             "_hint": "Fusion 360 isn't installed. Do NOT tell the user to install it themselves - OFFER to install it FOR them and do it on a yes: the fusion-onboarding skill silent-installs Fusion + drives the Autodesk sign-in. (For a plain 'what EDA tools are installed' check, AD's bridge_readiness reports this cleanly without erroring.)",         } +    import time as _t0+    import timings as _tm+    _warm_start = _t0.time()     if _is_fusion_running():         addin_ok = wait_for_addin(timeout=5)+        # MEASURED, not asserted (issue #32): a warm start is a real sample too, and+        # it is the number that lets a UI say "3s warm" instead of hedging with the+        # cold-start worst case on every single launch.+        _el = _t0.time() - _warm_start+        if addin_ok:+            _tm.record("launch", _el, "warm")         return {             "success": True,             "output": "Fusion 360 is already running.",             "addinConnected": addin_ok,             "alreadyRunning": True,+            "startKind": "warm",+            "progress": _tm.terminal_block("launch", _warm_start,+                                           "Fusion is already running - reusing it",+                                           start_kind="warm", failed=not addin_ok),+            "timings": _tm.summary().get("launch"),             "resolvedPath": fusion_info.get("exe_path", ""),         } @@ -578,6 +592,13 @@ def _handle_launch(fusion_info: dict, args: dict) -> dict:     # clean stillLaunching response telling the caller to POLL fusion_readiness.     import time as _t     _start = _t.time()+    # POLLABLE (issue #32 section 3): stamp the launch so fusion_readiness - the verb the+    # caller polls while this one returns stillLaunching - can report the SAME measured+    # progress block. Sticky in prefs so a bridge respawn mid-launch does not lose the bar.+    try:+        _set_bridge_pref("launchStartedAt", _start)+    except Exception:+        pass     err = ensure_fusion_running(fusion_info, wait_addin=False)     if err:         return err@@ -647,10 +668,19 @@ def _handle_launch(fusion_info: dict, args: dict) -> dict:     if not addin_ok:         # Add-in not up within our budget. On a first launch this is EXPECTED (still         # initializing / signing in), not a failure and not a retry-able timeout.+        # ISSUE #32: this is THE response John quoted. It now carries a measured+        # progress block (percent + eta + cold/warm, seeded from this machine's own+        # rolling history) so the caller draws a real bar instead of printing a shrug.+        _prog = _tm.progress_block("launch", _start,+                                   "Fusion is starting - waiting for the Adom add-in to serve",+                                   start_kind="cold", fallback=150)         return {             "success": True,             "stillLaunching": True,             "addinConnected": False,+            "startKind": "cold",+            "progress": _prog,+            "timings": _tm.summary().get("launch"),             "statusVerb": "fusion_readiness",             "recoveryFilesRelocated": reloc["moved"],             "resolvedPath": fusion_info.get("exe_path", ""),@@ -661,8 +691,14 @@ def _handle_launch(fusion_info: dict, args: dict) -> dict:                 "steal the user's focus - do NOT foreground it. "                 + ("A seat 'Active Sessions Exceeded' dialog appeared and was AUTO-RESOLVED in the "                    "background via UIA (no foreground). " if _seat_clicks else "")-                + "The add-in isn't up yet - a first launch / fresh sign-in can take 2-4 min. This is "-                "NOT a failure and NOT a retry-able timeout: do NOT re-call fusion_start. WHAT HAPPENS "+                + ((_tm.sentence() + ". ") if _tm.sentence() else "")+                + (("The add-in isn't up yet - about %ss remaining by this machine's own measured "+                    "history (read progress.percent / progress.etaSec on this response; the estimate "+                    "is revised as the launch proceeds). This is " % _prog.get("etaSec"))+                   if _prog.get("etaSec") is not None else+                   "The add-in isn't up yet and this machine has no measured launch history yet, so "+                   "progress.confidence is 'typical' until it learns. This is ")+                + "NOT a failure and NOT a retry-able timeout: do NOT re-call fusion_start. WHAT HAPPENS "                 "NEXT: POLL fusion_readiness every ~10s; it returns needsSignin/licensingDialog/updating "                 "if something is mid-flight (the bridge handles the seat dialog itself), and ready:true "                 "when it is drivable - then run your fusion_* verbs. NEVER poll blind past ~2 min: "@@ -686,6 +722,15 @@ def _handle_launch(fusion_info: dict, args: dict) -> dict:     if addin_ok:         dialog_blocking = _check_main_thread_blocked() +    # TERMINAL FRAME CARRIES THE ACTUALS (issue #32 section 4): the real total goes+    # into this machine's rolling history, which is what makes the NEXT launch's+    # estimate "measured" instead of a literal someone typed.+    _tm.record("launch", _t.time() - _start, "cold")+    try:+        _set_bridge_pref("launchStartedAt", None)+    except Exception:+        pass+     output = "Fusion 360 launched successfully."     if reloc["moved"] > 0:         output += (@@ -710,6 +755,10 @@ def _handle_launch(fusion_info: dict, args: dict) -> dict:         "recoveryDialogDismissed": recovery_dismissed,         "recoveryFilesRelocated": reloc["moved"],         "recoveryFilesPath": reloc["dest"],+        "startKind": "cold",+        "progress": _tm.terminal_block("launch", _start, "Fusion is up and the add-in is serving",+                                       start_kind="cold", failed=not addin_ok),+        "timings": _tm.summary().get("launch"),         "resolvedPath": fusion_info.get("exe_path", ""),     } @@ -1532,6 +1581,27 @@ def _handle_fusion_readiness(fusion_info: dict, args: dict) -> dict:         **stale_info,         "_hint": hint,     }+    # MEASURED PROGRESS WHILE LAUNCHING (issue #32): the poll verb must render the same+    # bar the launch verb started, or the caller is back to a spinner with no end.+    try:+        import time as _t_mod+        import timings as _tm+        result["timings"] = _tm.summary().get("launch")+        _lsa = _get_bridge_pref("launchStartedAt", None)+        if ready:+            if _lsa:+                _set_bridge_pref("launchStartedAt", None)+        elif _lsa and (_t_mod.time() - float(_lsa)) < 900:+            _label = ("Signing in to Autodesk" if needs_signin else+                      "Clearing the Autodesk seat dialog" if licensing else+                      "Applying a Fusion auto-update" if updating else+                      "Fusion is up - waiting for the Adom add-in to serve" if running else+                      "Fusion is starting")+            result["progress"] = _tm.progress_block(+                "launch", float(_lsa), _label, start_kind="cold", fallback=150)+            result["statusVerb"] = "fusion_readiness"+    except Exception:+        pass     if updating:         result["updating"] = True         result["statusVerb"] = "fusion_readiness"@@ -4531,6 +4601,24 @@ def _handle_fusion_state(fusion_info: dict, args: dict) -> dict:     except Exception:         result["addinResponding"] = False     result["mainThreadBusy"] = busy+    # WHAT TO EXPECT, BEFORE THE CLICK (issue #32 section 2): this machine's own measured+    # launch history, so a UI can say "typically 45s cold, 3s warm here" instead of making+    # the user discover it by waiting.+    try:+        import time as _tnow+        import timings as _tm+        result["timings"] = _tm.summary()+        _s = _tm.sentence()+        if _s:+            result["timingsSentence"] = _s+        _lsa = _get_bridge_pref("launchStartedAt", None)+        if _lsa and not result.get("addinResponding") and (_tnow.time() - float(_lsa)) < 900:+            result["progress"] = _tm.progress_block(+                "launch", float(_lsa),+                "Fusion is starting - waiting for the Adom add-in to serve",+                start_kind="cold", fallback=150)+    except Exception:+        pass     try:         from handlers.taskbar_overlay import badge_active, last_error as _badge_err         result["drivingBadge"] = {"active": badge_active(),@@ -6626,24 +6714,28 @@ def _orchestrate_demo_tour(args: dict) -> dict:         cold = not probe.get("success")     except Exception:         pass-    steps = ([("launch", "Starting Fusion (cold start)", 120)] if cold else []) + [-        ("stage", "Staging the sample library", 3),-        ("library", "Opening the electronics library", 35),-        ("symbol", "Symbol: full pinout in the symbol editor", 8),-        ("footprint", "Footprint: pads + layers in the footprint editor", 8),-        ("device", "Component view: symbol + package mapping", 8),-        ("3d-package", "3D chip: the Package3D environment", 30),+    import timings as _tm+    def _est(phase, seeded):+        """Measured p50 for this phase on THIS machine, else the seeded literal."""+        return int(round(_tm.estimate(phase, seeded, "cold" if phase == "launch" else None)[0]))+    steps = ([("launch", "Starting Fusion (cold start)", _est("launch", 120))] if cold else []) + [+        ("stage", "Staging the sample library", _est("stage", 3)),+        ("library", "Opening the electronics library", _est("library", 35)),+        ("symbol", "Symbol: full pinout in the symbol editor", _est("symbol", 8)),+        ("footprint", "Footprint: pads + layers in the footprint editor", _est("footprint", 8)),+        ("device", "Component view: symbol + package mapping", _est("device", 8)),+        ("3d-package", "3D chip: the Package3D environment", _est("3d-package", 30)),     ]     if include_design:         # Design side is SELF-SUFFICIENT by default (John, 2026-08-15: customers have         # only a component page, never a canonical cloud board): generate + import a         # test project around the sample part. A query switches to the cloud-open path.-        steps += ([("open-design", "Opening the cloud electronics design", 150)] if query-                  else [("make-project", "Generating + importing a test project around the sample part", 60)])+        steps += ([("open-design", "Opening the cloud electronics design", _est("open-design", 150))] if query+                  else [("make-project", "Generating + importing a test project around the sample part", _est("make-project", 60))])         steps += [-            ("schematic", "Schematic view", 12),-            ("2d-board", "2D board layout", 12),-            ("3d-board", "3D board", 20),+            ("schematic", "Schematic view", _est("schematic", 12)),+            ("2d-board", "2D board layout", _est("2d-board", 12)),+            ("3d-board", "3D board", _est("3d-board", 20)),         ]     est = sum(s[2] for s in steps)     state = {"demoId": demo_id, "percent": 0, "elapsedSec": 0, "estimatedSec": est,@@ -6692,9 +6784,23 @@ def _orchestrate_demo_tour(args: dict) -> dict:                     # while they typed in other threads (John, live during ab's demo,                     # 2026-08-16). Nesting is refcounted, so re-arming is free.                     begin_background_envelope(float(dur * 3 + 240))+                _step_started = time.time()+                # LIVE, per-step ETA (issue #32): the tour reports the same contract block+                # every other slow verb does, so ONE renderer draws every bar in Adom.                 state.update(step=key, stepLabel=label,                              percent=int(100 * done_est / est),-                             elapsedSec=int(time.time() - state["started"]))+                             elapsedSec=int(time.time() - state["started"]),+                             estimatedSec=est,+                             etaSec=max(0, int(est - (time.time() - state["started"]))),+                             confidence=_tm.estimate(key, dur)[1],+                             startKind=("cold" if cold else "warm"),+                             progress={"phase": key, "stepLabel": label,+                                       "percent": int(100 * done_est / est),+                                       "elapsedSec": int(time.time() - state["started"]),+                                       "estimatedSec": est,+                                       "etaSec": max(0, int(est - (time.time() - state["started"]))),+                                       "confidence": _tm.estimate(key, dur)[1],+                                       "startKind": ("cold" if cold else "warm")})                 if key == "stage" and args.get("sampleLbr"):                     pass  # handled in the stage branch below                 if key == "launch":@@ -6855,6 +6961,13 @@ def _orchestrate_demo_tour(args: dict) -> dict:                     else:                         state.setdefault("failedSteps", []).append(key)                         state.setdefault("warnings", []).append("%s: %s" % (key, str(r.get("error"))[:120]))+                # Every finished step is a MEASURED sample - this is what turns the next run's+                # estimate from a literal into this machine's own p50 (issue #32).+                try:+                    _tm.record(key, time.time() - _step_started,+                               "cold" if key == "launch" and cold else None)+                except Exception:+                    pass                 done_est += dur                 state.update(percent=int(100 * done_est / est),                              elapsedSec=int(time.time() - state["started"]))
timings.pyadded+202
@@ -0,0 +1,202 @@+"""Measured timing history + the `progress` contract block (page issue #32).++John, 2026-08-19: a slow verb must never answer "can take a couple of minutes".+That string is a shrug - not measured, never narrowing, impossible to draw a bar+from. This module is the other half of the fix: it keeps a ROLLING MEASURED+HISTORY per (phase, startKind) on THIS machine, so an estimate is the p50 of what+actually happened here rather than a literal someone typed once.++Shape returned by `progress_block()` is exactly the contract in issue #32:++    {"phase","stepLabel","percent","elapsedSec","estimatedSec","etaSec",+     "confidence","startKind"}++Rules the callers rely on:+  - `percent` is MONOTONIC (enforced here via `floor`), never walks backwards.+  - `estimatedSec` is REVISED as the run proceeds: once elapsed passes the+    estimate, the estimate grows rather than pinning eta at 0 and lying.+  - `confidence` is "measured" ONLY when this machine has real samples for that+    phase; "typical" when we are running on a seeded literal; "unknown" when we+    genuinely cannot say.++Storage: ~/.adom/fusion-bridge/timings.json, last _KEEP samples per key, written+atomically. A corrupt or unwritable store degrades to in-memory - timing history+must never be able to break a launch.+"""++import json+import os+import pathlib+import threading+import time++_KEEP = 10                      # rolling samples per (phase, startKind)+_LOCK = threading.Lock()+_MEM = {}                       # fallback when the file cannot be read/written+++def _path():+    d = pathlib.Path(os.path.expanduser("~")) / ".adom" / "fusion-bridge"+    try:+        d.mkdir(parents=True, exist_ok=True)+    except Exception:+        pass+    return d / "timings.json"+++def _load():+    try:+        with open(_path(), "r", encoding="utf-8") as f:+            d = json.load(f)+        return d if isinstance(d, dict) else {}+    except Exception:+        return dict(_MEM)+++def _save(d):+    global _MEM+    _MEM = dict(d)+    try:+        p = _path()+        tmp = str(p) + ".tmp"+        with open(tmp, "w", encoding="utf-8") as f:+            json.dump(d, f, indent=1)+        os.replace(tmp, str(p))+    except Exception:+        pass                    # in-memory copy still serves this process+++def _key(phase, start_kind=None):+    return "%s|%s" % (phase, start_kind or "any")+++def record(phase, seconds, start_kind=None):+    """Add one MEASURED sample. Call it on success AND on a clean failure that+    still consumed real time - both are truth about how long this box takes."""+    try:+        seconds = float(seconds)+    except Exception:+        return+    if seconds < 0 or seconds > 7200:+        return                  # a clock jump is not a sample+    with _LOCK:+        d = _load()+        k = _key(phase, start_kind)+        row = d.get(k) or {}+        samples = [s for s in (row.get("samples") or []) if isinstance(s, (int, float))]+        samples.append(round(seconds, 1))+        row["samples"] = samples[-_KEEP:]+        row["lastAt"] = int(time.time())+        d[k] = row+        _save(d)+++def _pct(samples, q):+    if not samples:+        return None+    s = sorted(samples)+    i = min(len(s) - 1, max(0, int(round(q * (len(s) - 1)))))+    return s[i]+++def stats(phase, start_kind=None):+    """{'p50','p90','count'} of real samples on THIS machine, or count 0."""+    row = _load().get(_key(phase, start_kind)) or {}+    s = [x for x in (row.get("samples") or []) if isinstance(x, (int, float))]+    return {"p50": _pct(s, 0.5), "p90": _pct(s, 0.9), "count": len(s)}+++def estimate(phase, fallback, start_kind=None):+    """Seconds this phase should take here: measured p50 when we have samples,+    else the caller's seeded literal. Returns (seconds, confidence)."""+    st = stats(phase, start_kind)+    if st["count"] >= 2 and st["p50"]:+        return float(st["p50"]), "measured"+    if st["count"] == 1 and st["p50"]:+        # one sample beats a literal but is not yet a distribution+        return float(st["p50"]), "typical"+    try:+        return float(fallback), "typical"+    except Exception:+        return 0.0, "unknown"+++def progress_block(phase, started, step_label, percent=None, start_kind=None,+                   fallback=None, floor=None):+    """Build the issue-#32 progress block. `started` is a time.time() stamp.++    percent=None derives a percentage from elapsed/estimate - honest for a phase+    with no internal checkpoints, and it still moves, which is the whole point.+    `floor` keeps it monotonic across polls (pass the last percent you reported).+    """+    elapsed = max(0.0, time.time() - float(started))+    est, conf = estimate(phase, fallback if fallback is not None else 0, start_kind)+    # REVISE rather than lie: a run that outlives its estimate gets a bigger+    # estimate (and a non-zero eta), never a bar pinned at 100% or an eta of 0.+    if est and elapsed > est:+        est = elapsed * 1.25+        if conf == "measured":+            conf = "typical"    # we are past what this machine has ever measured+    if percent is None:+        percent = int(100 * elapsed / est) if est else 0+    percent = max(0, min(99 if est else 0, int(percent)))+    if floor is not None:+        percent = max(int(floor), percent)+    eta = max(0, int(round(est - elapsed))) if est else None+    return {+        "phase": phase,+        "stepLabel": step_label,+        "percent": percent,+        "elapsedSec": int(round(elapsed)),+        "estimatedSec": int(round(est)) if est else None,+        "etaSec": eta,+        "confidence": conf if est else "unknown",+        "startKind": start_kind,+    }+++def terminal_block(phase, started, step_label, start_kind=None, failed=False):+    """The completion frame: percent 100 (or the phase it died in) + the ACTUALS,+    so a caller can log truth and a bar can turn red in the right place."""+    elapsed = max(0.0, time.time() - float(started))+    return {+        "phase": phase,+        "stepLabel": step_label,+        "percent": 100 if not failed else None,+        "elapsedSec": int(round(elapsed)),+        "estimatedSec": int(round(elapsed)),+        "etaSec": 0 if not failed else None,+        "confidence": "measured",+        "startKind": start_kind,+        "failed": bool(failed),+        "measured": {"phase": phase, "seconds": round(elapsed, 1), "startKind": start_kind},+    }+++def summary():+    """Per-machine history for describe/status: what a caller can tell the user+    BEFORE they click ("typically 45s cold, 3s warm on THIS machine")."""+    d = _load()+    out = {}+    for k, row in d.items():+        phase, _, kind = k.partition("|")+        s = [x for x in (row.get("samples") or []) if isinstance(x, (int, float))]+        if not s:+            continue+        out.setdefault(phase, {})[kind] = {+            "p50": _pct(s, 0.5), "p90": _pct(s, 0.9), "count": len(s),+            "lastAt": row.get("lastAt"),+        }+    return out+++def sentence():+    """One human line for a UI/hint, built only from what we actually measured."""+    s = summary().get("launch") or {}+    bits = []+    for kind in ("cold", "warm"):+        if s.get(kind, {}).get("p50") is not None:+            bits.append("%s %ss" % (kind, int(s[kind]["p50"])))+    if not bits:+        return None+    return "on this machine Fusion starts in " + ", ".join(bits) + " (measured)"
tools/release-files.txt+1
@@ -34,6 +34,7 @@ adom-bridge-fusion-manifest.json aps.py bridge.json describe.py+timings.py eagle_lbr.py fusion_detect.py fusion_helper.py