app
Altium - the Altium Bridge
Public Made by Adomby adom
Adom Bridge for Altium Designer: a resident DelphiScript agent gives the AI eval, the 1,100-command process launcher, all 8 EDA surfaces with screenshots, compile/ERC, DRC, OutJob outputs, native SchLib/PcbLib authoring with STEP 3D bodies, and dialog handling by PID.
← 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)
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 }