← Commit history

0.2.5–0.2.8 source: verified foreground + visible demo bars; reaper reads ab's matches[] (was blind since 0.2.0), class-free lone-OK, licence chooser follow-through (Choose a license → Use License), License Renewal Failed by title; close watchdog ported from Colby's PR 74 (#4); install_library_bundle auto-launches a closed Altium (#5)

John Lauer ·195ea60f1c ·1mo ago ·parent 16e7a49
10 files changed +252−27
bridge-rs/Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "altium-bridge"-version = "0.2.1"+version = "0.2.8" dependencies = [  "getrandom",  "serde",
bridge-rs/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "altium-bridge"-version = "0.2.3"+version = "0.2.8" edition = "2021" description = "Adom altium-bridge — the bridge process, in Rust. Calls ab's verbs for the desktop; speaks file IPC to the resident DelphiScript agent inside Altium." license = "MIT"
bridge-rs/src/dialogs.rs+112−19
@@ -13,7 +13,13 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; use std::time::Duration; -const DIALOG_CLASSES: [&str; 5] = ["TMessageForm", "#32770", "TForm", "TMessageDialog", "TForm_RunScriptParameters"];+// TThemedConfirmDialog: Altium's licence chooser ("Connect to Workspace" / "You are not currently+// using an Altium Designer license" — Choose a license | Continue). It was invisible to the scan+// until 2026-09-02, when John was left staring at it: "you should be handling this automatically".+// TFailedToRenewWarningForm: "License Renewal Failed" (the stranded-seat re-firer). UIA enumerates+// NO buttons on it, yet its OK is clickable by name — so it is admitted by class and cleared by+// the title rule, never by button count.+const DIALOG_CLASSES: [&str; 7] = ["TMessageForm", "#32770", "TForm", "TMessageDialog", "TForm_RunScriptParameters", "TThemedConfirmDialog", "TFailedToRenewWarningForm"];  /// title/body substring -> (category, safe default action or "" = none, meaning). /// A safeAction of "" means the reaper must NEVER touch it (save prompts carry decisions).@@ -74,15 +80,26 @@ fn dialog_text(hwnd: i64) -> String {  fn buttons(hwnd: i64) -> Vec<String> {     let r = ab::call("desktop_find_controls", json!({"hwnd": hwnd, "role": "button"}), &[], 10);-    r.get("controls")-        .and_then(|a| a.as_array())-        .map(|a| {-            a.iter()-                .filter_map(|c| c.get("text").or_else(|| c.get("name")).and_then(|t| t.as_str()))-                .map(|t| t.replace('&', ""))-                .collect()-        })-        .unwrap_or_default()+    button_names(&r)+}++/// The button labels out of ab's `desktop_find_controls` reply. ab returns them under `matches`+/// (each with `name`); the first port read `controls` and so saw NO buttons on ANY dialog — the+/// lone-OK acknowledgement rule could never fire, and the VaultExplorer startup warning reached+/// John's screen again on 2026-09-02 ("u have not solved this error yet. you MUST"). Title-bar+/// controls (Close/Minimize/Maximize) are dropped so a dialog whose only real button is OK still+/// reads as lone-OK, matching what the Python bridge's child-window enumeration returned.+pub fn button_names(reply: &Value) -> Vec<String> {+    let arr = reply.get("matches").or_else(|| reply.get("controls")).and_then(|a| a.as_array());+    arr.map(|a| {+        a.iter()+            .filter_map(|c| c.get("name").or_else(|| c.get("text")).and_then(|t| t.as_str()))+            .map(|t| t.replace('&', "").trim().to_string())+            .filter(|t| !t.is_empty())+            .filter(|t| !matches!(t.to_lowercase().as_str(), "close" | "minimize" | "maximize" | "restore" | "system"))+            .collect()+    })+    .unwrap_or_default() }  /// Every dialog owned by an Altium process — classified, instance-scoped when one is selected.@@ -121,20 +138,40 @@ fn scan_impl(main_pid_only: bool, sel_pid: Option<u64>) -> Vec<Value> {             && w.get("enabled").and_then(|b| b.as_bool()).unwrap_or(true)             && pid == main_pid             && !matches!(class, "TClientBarControl" | "TThemedNotificationPopup" | "TAnimatedNotificationControl");-        if !DIALOG_CLASSES.iter().any(|c| c.eq_ignore_ascii_case(class))-            && !matches!(kind, "dialog" | "script_error_modal")-            && !modal_by_state-        {-            continue;-        }         let hwnd = match w.get("hwnd").and_then(|h| h.as_i64()) {             Some(h) => h,             None => continue,         };         let title = w.get("title").and_then(|s| s.as_str()).unwrap_or("").to_string();+        let dialog_class = DIALOG_CLASSES.iter().any(|c| c.eq_ignore_ascii_case(class))+            || matches!(kind, "dialog" | "script_error_modal")+            || modal_by_state;+        // Altium's themed licence dialogs ("License Renewal Failed", the licence chooser) do+        // not use the catalogued VCL classes, so a class allowlist let them reach John's screen+        // (2026-09-02: "you should pre-empt all of these dialog boxes btw"). The BUTTON SET is+        // the trustworthy signal: ask UIA for every titled, visible, non-main window and let a+        // lone OK qualify it regardless of class. Untitled tool windows are skipped for cost.+        if title.trim().is_empty() && !dialog_class {+            continue;+        }         let btns = buttons(hwnd);+        let lone_ok = btns.len() == 1 && btns[0].eq_ignore_ascii_case("ok");+        let title_known = classify(&title, "").0 != "unknown";+        if !dialog_class && !lone_ok && !title_known && !is_licence_chooser(&btns) {+            continue;+        }         let body = dialog_text(hwnd);-        let (category, safe_action, meaning) = classify(&title, &body);+        let (mut category, mut safe_action, mut meaning) = classify(&title, &body);+        if category == "unknown" && is_licence_chooser(&btns) {+            category = "licence_chooser".into();+            safe_action = Some("Choose a license".into());+            meaning = "Altium's 'You are not currently using an Altium Designer license' prompt (Choose a license | Continue). Choose a license opens the License Management page; the reaper then presses Use License via UIA. 'Continue' would leave Altium in Viewer mode, where scripting is refused — never the right answer for a bridge user.".into();+        }+        if category == "unknown" && title.to_lowercase().contains("renewal failed") {+            category = "licence_seat_stranded".into();+            safe_action = Some("OK".into());+            meaning = "License Renewal Failed: the seat is leased to a session that is gone (an Altium that was killed, not closed). Dismissing is safe; it re-fires on a timer until the lease expires or the user signs out and back in.".into();+        }         let owned_by_main = main_pid.is_some() && pid == main_pid;         if main_pid_only && !owned_by_main {             continue;@@ -148,6 +185,12 @@ fn scan_impl(main_pid_only: bool, sel_pid: Option<u64>) -> Vec<Value> {     out } +/// The licence chooser is recognisable by its BUTTONS even though its text is painted.+fn is_licence_chooser(btns: &[String]) -> bool {+    let l: Vec<String> = btns.iter().map(|b| b.to_lowercase()).collect();+    l.iter().any(|b| b == "choose a license") && l.iter().any(|b| b == "continue")+}+ /// Press a named button on a dialog through UIA (no focus steal, no cursor). pub fn press(hwnd: i64, button: &str) -> Value {     let r = ab::call("desktop_ui_click", json!({"hwnd": hwnd, "name": button, "role": "button"}), &[], 15);@@ -187,6 +230,16 @@ static REAPED: Mutex<Vec<Value>> = Mutex::new(Vec::new()); static REAPED_HWNDS: Mutex<Vec<i64>> = Mutex::new(Vec::new()); static REAPER_RUNNING: AtomicBool = AtomicBool::new(false); +/// Record a service event (close watchdog etc.) in the same ring `altium_dialog_log` shows.+pub fn note_event(mut ev: Value) {+    ev["at"] = json!(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|x| x.as_secs()).unwrap_or(0));+    if let Ok(mut g) = REAPED.lock() {+        g.push(ev);+        let len = g.len();+        if len > 200 { g.drain(..len - 200); }+    }+}+ pub fn reaped_log(limit: usize) -> Vec<Value> {     REAPED         .lock()@@ -233,6 +286,25 @@ pub fn reap_once() -> usize {             continue;         }         let ok = press(hwnd, &act).get("success").and_then(|b| b.as_bool()).unwrap_or(false);+        let mut followup = Value::Null;+        if ok && cat == "licence_chooser" {+            // The chooser only opens the License Management page; the seat is claimed by the+            // page's UseLicenseButton (UIA, invokable — measured 2026-09-01). Give the WebView2+            // a moment to render, then press it on the SAME instance's main window.+            std::thread::sleep(Duration::from_millis(2500));+            let pid = d.get("pid").and_then(|p| p.as_u64());+            let main_hwnd = instance::altium_windows().into_iter()+                .find(|w| w.get("kind").and_then(|k| k.as_str()) == Some("main") && w.get("pid").and_then(|p| p.as_u64()) == pid)+                .and_then(|w| w.get("hwnd").and_then(|h| h.as_i64()));+            followup = match main_hwnd {+                Some(mh) => {+                    let r = ab::call("desktop_ui_click", json!({"hwnd": mh, "automationId": "UseLicenseButton", "name": "Use License", "role": "button"}), &[], 45);+                    json!({"useLicense": r.get("success").and_then(|b| b.as_bool()).unwrap_or(false),+                           "note": "if the seat is stranded (held by a killed Altium) this claim fails with 'License maximum usage reached' — the bridge keeps that dialog off the screen, but only a sign-out/sign-in or the lease expiring frees the seat"})+                }+                None => json!({"useLicense": false, "note": "no main window found for the chooser's pid"}),+            };+        }         if let Ok(mut g) = REAPED_HWNDS.lock() {             g.push(hwnd);             let len = g.len();@@ -243,7 +315,7 @@ pub fn reap_once() -> usize {         if let Ok(mut g) = REAPED.lock() {             g.push(json!({"at": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|x| x.as_secs()).unwrap_or(0),                           "title": d.get("title").cloned().unwrap_or(Value::Null), "category": cat,-                          "pressed": act, "ok": ok,+                          "pressed": act, "ok": ok, "followup": followup,                           "note": "pre-empted by the standing reaper — the user never had to deal with it"}));             let len = g.len();             if len > 200 {@@ -271,7 +343,28 @@ pub fn start_reaper() -> bool {  #[cfg(test)] mod tests {-    use super::classify;+    use super::{button_names, classify};+    use serde_json::json;++    /// The exact reply shape ab gave for the VaultExplorer warning (2026-09-02): buttons live+    /// under `matches`, not `controls`. Reading the wrong key made the reaper blind.+    #[test]+    fn button_names_read_ab_matches_shape() {+        let reply = json!({"count": 1, "hwnd": 69126, "matches": [+            {"automationId": "WinXPButtons.TXPBitBtn", "invokable": true, "name": "OK", "nth": 0, "role": "Button", "value": "OK"}+        ]});+        assert_eq!(button_names(&reply), vec!["OK".to_string()]);+        // title-bar controls never count toward the lone-OK decision+        let reply = json!({"matches": [{"name": "OK"}, {"name": "Close"}, {"name": "Minimize"}]});+        assert_eq!(button_names(&reply), vec!["OK".to_string()]);+        // a real decision dialog keeps all its buttons+        let reply = json!({"matches": [{"name": "Save"}, {"name": "Don't Save"}, {"name": "Cancel"}]});+        assert_eq!(button_names(&reply).len(), 3);+        // legacy shape still parses+        let reply = json!({"controls": [{"text": "&OK"}]});+        assert_eq!(button_names(&reply), vec!["OK".to_string()]);+        assert!(button_names(&json!({"success": false})).is_empty());+    }      #[test]     fn catalog_classification() {
bridge-rs/src/instance.rs+7
@@ -99,6 +99,13 @@ fn snapshot() -> (Vec<Value>, Vec<Value>) {     (procs, windows) } +/// Every live X2.exe pid (main window or not) — the close watchdog needs "process alive,+/// window gone", which instances() alone cannot express.+pub fn x2_pids() -> Vec<u64> {+    let (procs, _) = snapshot();+    procs.iter().filter_map(|p| p.get("pid").and_then(|v| v.as_u64())).collect()+}+ pub fn invalidate_snapshot() {     if let Ok(mut g) = SNAP.lock() {         *g = None;
bridge-rs/src/library.rs+17−1
@@ -467,6 +467,22 @@ fn valid_lib_name(name: &str) -> bool { /// folder), install into the user's Installed Libraries, optionally return a proof frame. /// Hardened for untrusted zips: size caps, zip-slip refusal. pub fn install_library_bundle(args: &Value) -> Value {+    // One-click parity with the KiCad/Fusion lanes (Colby, adom/altium-bridge#5): from a cold+    // state, launch Altium first (measured cold start on the taskbar bar), then register. The+    // resident agent bootstraps on the first agent call. Safe to ship now that the close+    // watchdog (#4) exits the agent on a user-initiated close.+    let mut launched = Value::Null;+    if crate::instance::licence_state().get("running").and_then(|b| b.as_bool()) != Some(true) {+        let lr = crate::misc::launch(&json!({"waitSec": 240}));+        if lr.get("success").and_then(|b| b.as_bool()) != Some(true) {+            let mut out = lr;+            out["failedAt"] = json!("launch");+            out["_hint"] = json!("Altium was closed and could not be launched for the install — see error; altium_launch to retry.");+            return out;+        }+        launched = json!({"coldStartSec": lr.get("startedInSec"), "note": "Altium was closed; launched it for the install"});+        crate::instance::invalidate_snapshot();+    }     if let Some(g) = support::need_licensed() {         return g;     }@@ -570,7 +586,7 @@ pub fn install_library_bundle(args: &Value) -> Value {     let st = agent::call("state", &[], None, Duration::from_secs(30));     let running = st.get("windows").map(|w| !w.is_null()).unwrap_or(false)         || st.get("data").and_then(|d| d.get("windows")).map(|w| !w.is_null()).unwrap_or(false);-    let mut out = json!({"success": true,+    let mut out = json!({"success": true, "launched": launched,         "library": if name.is_empty() { stem } else { name },         "symbols": schlibs.len(), "footprints": pcblibs.len(), "intlibs": intlibs.len(),         "installed": installed, "altiumRunning": running, "restartNeeded": running,
bridge-rs/src/lifecycle.rs+71
@@ -570,6 +570,77 @@ pub fn launch(wait: Duration) -> Value { // NOTE: application close lives in misc::close (the seat-guarded verb); the old lifecycle-level // close was removed when it landed — one close path, one seat rule. +// ---- user-initiated-close watchdog (Colby Knox, adom/altium-bridge#4 + PR 74, ported) -------+//+// altium_close tears the agent down BEFORE its WM_CLOSE, but a user pressing the window X goes+// straight to Altium: shutdown then unloads the scripting engine under a LIVE agent loop, and the+// close can wedge — X2 lingered headless and hard-froze the desktop until it was killed+// (2026-09-02). Standing service: every 2 s, for every workspace with a live heartbeat, detect+// the CLOSING state (that instance's main window gone while an X2 process still lives) and drop+// the stop file THAT INSTANT so the loop's next tick exits it during the shutdown instead of+// wedging under it. Read-only detection plus a file drop: no Altium call, no focus. Honest limit:+// a 2 s poll race, not a guarantee — the complete fix is the agent noticing shutdown itself.++static CLOSE_WATCHDOG: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);++fn close_watch_once(seen: &mut std::collections::BTreeSet<String>) -> Vec<String> {+    let root = agent::cache_dir().join("agent");+    let mut workspaces: Vec<(String, PathBuf)> = vec![("legacy".into(), root.clone())];+    if let Ok(rd) = std::fs::read_dir(&root) {+        for e in rd.flatten() {+            if e.path().is_dir() {+                workspaces.push((e.file_name().to_string_lossy().to_string(), e.path()));+            }+        }+    }+    let live: Vec<(String, PathBuf)> = workspaces.into_iter().filter(|(_, ws)| {+        std::fs::metadata(ws.join("hb.txt")).and_then(|m| m.modified()).ok()+            .and_then(|t| std::time::SystemTime::now().duration_since(t).ok())+            .map(|d| d < Duration::from_secs(agent::ALIVE_SEC)).unwrap_or(false)+    }).collect();+    if live.is_empty() {+        return Vec::new();+    }+    crate::instance::invalidate_snapshot();+    let keys_with_windows: std::collections::BTreeSet<String> = crate::instance::instances().iter()+        .filter_map(|i| i.get("key").and_then(|k| k.as_str()).map(str::to_string)).collect();+    let any_x2 = !crate::instance::x2_pids().is_empty();+    let mut dropped = Vec::new();+    for (key, ws) in live {+        let has_window = if key == "legacy" { !keys_with_windows.is_empty() } else { keys_with_windows.contains(&key) };+        if has_window {+            seen.insert(key);+            continue;+        }+        // window gone. Only a workspace we have SEEN with a window counts as closing — a cold+        // start (pid up, window not yet created) must never fire this.+        if seen.contains(&key) && any_x2 {+            let _ = std::fs::write(ws.join("stop"), "1");+            seen.remove(&key);+            dropped.push(key);+        }+    }+    dropped+}++pub fn start_close_watchdog() -> bool {+    if CLOSE_WATCHDOG.swap(true, std::sync::atomic::Ordering::SeqCst) {+        return false;+    }+    std::thread::Builder::new().name("altium-agent-close-watchdog".into()).spawn(|| {+        let mut seen = std::collections::BTreeSet::new();+        loop {+            std::thread::sleep(Duration::from_secs(2));+            let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {+                for key in close_watch_once(&mut seen) {+                    crate::dialogs::note_event(json!({"closeWatchdog": key,+                        "note": "main window gone while X2 still alive — stop file dropped so the resident agent exits during the shutdown instead of wedging under it (adom/altium-bridge#4)"}));+                }+            }));+        }+    }).is_ok()+}+ // ---- sha256 (no external crate) ----------------------------------------------  pub fn sha256_hex(data: &[u8]) -> String {
bridge-rs/src/main.rs+2−1
@@ -24,7 +24,7 @@ mod verbs; use std::time::Instant; use tiny_http::{Header, Method, Response, Server}; -pub const BRIDGE_VERSION: &str = "0.2.3";+pub const BRIDGE_VERSION: &str = "0.2.8";  fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {     let data = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());@@ -50,6 +50,7 @@ fn main() {     // ever showing up"), the idle watcher clears the taskbar overlay after 5 quiet minutes.     dialogs::start_reaper();     taskbar::start_idle_watcher();+    lifecycle::start_close_watchdog(); // adom/altium-bridge#4: exit the agent the instant a user-initiated close begins     for mut req in server.incoming_requests() {         let t0 = Instant::now();         let path = req.url().to_string();
bridge-rs/src/progress.rs+1
@@ -33,6 +33,7 @@ fn default_seconds(phase: &str) -> Option<f64> {         "run_outjob" => 60.0,         "export" => 8.0,         "demo" => 210.0,+        "demo_live_stage" => 12.0,         _ => return None,     }) }
bridge-rs/src/surfaces.rs+26−3
@@ -357,9 +357,21 @@ pub fn foreground(args: &Value) -> Value {     };     let hwnd = mw.get("hwnd").cloned().unwrap_or(Value::Null);     let r = crate::ab::call("desktop_raise_window", json!({"hwnd": hwnd, "reason": reason}), &[], 15);+    // VERIFY, don't assume: a raise can "succeed" while another fullscreen window (the user's+    // chat window, a remote-desktop layer) still covers Altium — John watched a whole show that+    // never became visible because his Claude window was fullscreen on top (2026-09-02).+    let top = crate::ab::call("desktop_list_windows", json!({}), &[], 10);+    let top_hwnd = top.get("data").and_then(|d| d.get("windows")).or_else(|| top.get("windows"))+        .and_then(|w| w.as_array()).and_then(|a| a.first())+        .and_then(|w| w.get("hwnd")).cloned().unwrap_or(Value::Null);+    let visibly_on_top = top_hwnd == hwnd;     json!({"success": r.get("success").and_then(|b| b.as_bool()).unwrap_or(true),-        "hwnd": hwnd, "windowEtiquette": "foreground",-        "_hint": "Altium is now in front of the user. Hand their screen back when the show ends (their own window regains focus when they click; the bridge never needs to force it)."})+        "hwnd": hwnd, "windowEtiquette": "foreground", "visiblyOnTop": visibly_on_top,+        "_hint": if visibly_on_top {+            "Altium is now visibly in front of the user. Hand their screen back when the show ends (their own window regains focus when they click; the bridge never needs to force it)."+        } else {+            "The raise was issued but another window still covers Altium (often the user's own fullscreen chat window — if they are typing to YOU, your raise loses to their activity, correctly). Do not re-raise in a loop; TELL the user Altium is ready behind their current window."+        }}) }  /// One choreography step -> (process, params) for the given lane, or None for an unknown step.@@ -577,7 +589,17 @@ pub fn demo_live(args: &Value) -> Value {     for (proc_, params) in b.setup {         agent::call("run_process", &[("process", proc_.to_string()), ("params", params.to_string())], None, Duration::from_secs(60));     }-    foreground(&json!({"reason": "The user asked to WATCH this tour live — keeping Altium in front"}));+    // Foreground ONCE, at the first stop (John, 2026-09-02: "just foreground altium once. then+    // don't try to foreground again for the rest of the demo"). The opening raise is an+    // invitation; re-raising every stop would drag back a user who deliberately switched away+    // mid-show. Documents keep opening in Altium either way.+    let mut fg_note = Value::Null;+    if stage == 0 {+        let fg = foreground(&json!({"reason": "The user asked to WATCH this tour live — bringing Altium in front once, at the start"}));+        if fg.get("visiblyOnTop").and_then(|b| b.as_bool()) == Some(false) {+            fg_note = json!("Altium was raised but another window still covers it (often the user's own fullscreen chat window). Do NOT re-raise; tell the user the show is running behind their current window.");+        }+    }     let anim = animate_view(&json!({"lane": b.lane, "preset": b.preset}));     let next_stage = stage + 1;     let next_narrate = beats.get(next_stage).map(|nb| nb.narrate);@@ -586,6 +608,7 @@ pub fn demo_live(args: &Value) -> Value {         "nextNarrate": next_narrate,         "animation": {"preset": b.preset, "stepsRun": anim.get("stepsRun"), "elapsedSec": anim.get("elapsedSec")},         "elapsedSec": support::elapsed_round(t0), "windowEtiquette": "foreground",+        "foregroundNote": fg_note,         "nextCall": {"verb": "altium_demo", "args": {"live": true, "stage": next_stage}},         "_hint": "SYNC MODEL (best): pre-cache the whole narration once with `adom-tts load` (one marker per stop), then per stop fire `adom-tts cue <id> <n> --confirm` in the background and IMMEDIATELY make nextCall — the prefetched cue starts instantly and this verb's in-bridge choreography (~5-17s) runs under the voice; advance when BOTH finish. Fallback without 1.9.47: say --play detached per stop. On the last call (done:true) restore the user's own document."}) }
bridge-rs/src/verbs.rs+14−1
@@ -177,11 +177,19 @@ pub fn handle(command: &str, args: &Value, forwarded: &[(String, String)]) -> Va     let driving = !matches!(cmd, "readiness" | "status" | "describe" | "diagnostics" | "window_info"         | "check_dialogs" | "progress" | "agent_status" | "list_commands" | "state" | "instances" | "dialog_log")         && !cmd.starts_with("a365_");+    // The pacing phase: a live demo STAGE is a ~12s beat, not the 210s full background tour —+    // pacing it by the "demo" seed left the taskbar bar crawling 0→7% per stop, which John read+    // as no bar at all (2026-09-02).+    let phase = if cmd == "demo" && args.get("live").and_then(|b| b.as_bool()) == Some(true) {+        "demo_live_stage"+    } else {+        cmd+    };     let mut bar_started = false;     if driving {         taskbar::set_driving_badge();         taskbar::note_driving(); // feeds the 5-min idle clock that auto-clears the overlay-        let (est, conf) = progress::estimate(cmd, "warm");+        let (est, conf) = progress::estimate(phase, "warm");         if est >= 3.0 {             taskbar::start_activity_progress(cmd, Some(est), &conf);             bar_started = true;@@ -193,6 +201,11 @@ pub fn handle(command: &str, args: &Value, forwarded: &[(String, String)]) -> Va     progress::log_run(cmd, t0.elapsed().as_secs_f64(), ok);     if bar_started {         taskbar::end_activity_progress(cmd, !ok);+        if ok {+            // feed the SAME key the estimate reads, so bars converge on this machine's truth+            // (measured > typical > unknown — the ladder only climbs if we record here)+            progress::record(phase, t0.elapsed().as_secs_f64(), "warm");+        }     }     out }