← Commit history
CHANGELOG.md+25
@@ -1,5 +1,30 @@ # Changelog — Puppeteer Bridge +## 2.0.426 — 2026-09-06 — the Node z-order doctrine, restored (no activation at birth, never yank a window the user is using)++John: "can u explain to me how you open new pup windows with regards to dealing with background/foreground+issues? cuz you seem to be acting odd and i spent days with the node version of pup getting this right."+Read side by side, the Rust port had strayed in four places:++1. 2.0.425 called ab's `desktop_clear_window_attention` at birth to drop the Windows attention tint. ab+   documents that the target "flickers to the front for an instant"; Node never activated a window at+   birth, ever. Removed. The clear stays only where Node had it: `pup_alert_window {stop:true}`.+2. Re-parks were unconditional: navigate, reload, the wiki view switch, window-state changes and+   recording all sent the window to the bottom even when the user had it up (Node v2.0.26: "you took it+   back to the bg... that fights with the user"). Now every re-park goes through `park::bottom_unless_foreground`:+   a window the user raised (granted raise, observed foreground, or input in the last 5 s) is left alone+   and remembered for 10 minutes; a newborn window (8 s, no granted raise) is bottomed regardless,+   because Chrome self-activates new windows and the user's typing is not a click on a taskbar button+   that did not exist yet (Node v2.0.261).+3. `foreground:true` on pup_open_window was validated (reason required) but never acted on; the window+   was born in the background and stayed there unless Chrome happened to raise it. It is now a background+   birth followed by a granted raise with the caption and toast, as in Node.+4. `pup_raise_os_window` now sets the granted-raise latch that the re-parks honour.++Unchanged and matching Node: `--no-startup-window` launch, the window born at -32000 and moved on-screen+by one local CDP setWindowBounds within the 50 ms budget, the 10 px work-area inset, SetWindowPos to+z-bottom with NOACTIVATE, the re-assert after the first navigation, the caption-only foreground watch.+ ## 2.0.425 — 2026-09-06 — the taskbar activity contract, as Node had it (no orange by default)  John: "go look at how we had setup the windows taskbar flashes in the node version and what our
docs/PLAN-rust-port.md+5−1
@@ -117,7 +117,11 @@ John's words. Written 2026-09-05 against pup 2.0.396; John approved the plan the   hand is not testing the jump list. Release profile: thin LTO (28 s vs 36.5 s per leaf edit, exe +0.8 MB).   2.0.425: the taskbar activity contract restored from Node (taskbarActivity progress|flash|off, dead keys   refused, attention tint cleared after birth, icon-apply and screenshot progress bars, alert = pulse in-  progress mode, four-count flash in flash mode). John: "you seemed to have strayed from that"; he had. Jump lists: the two-right-clicks flash is ab#155+  progress mode, four-count flash in flash mode). John: "you seemed to have strayed from that"; he had.+  2.0.426: the z-order doctrine (Node v1.9.181 / v2.0.26 / v2.0.261) ported: `park.rs` with birth grace,+  the user-in-use latch (granted raise, observed foreground, recent input; 10 min memory) and+  bottom-unless-foreground at every re-park; foreground:true opens honoured; the 2.0.425 birth-time+  attention clear removed (ab flickers the window to the front for it; Node never activated at birth). Jump lists: the two-right-clicks flash is ab#155   (registry-only AUMIDs, no anchoring shortcut); `aumidShortcut` (opt-in) asks ab for the anchor;   human verification pending. Filed: ab#152 (kind:exe seed npm), ab#155 (jump-list anchor), ab#157   (forward bridge consent prompts into ah for boxes that block toasts).
rust/host-win/src/lib.rs+5
@@ -70,6 +70,11 @@ mod imp {         }         // Node: osGetForegroundWindow         fn foreground(&self) -> i64 { unsafe { GetForegroundWindow() as i64 } }+        fn idle_ms(&self) -> Option<u64> {+            #[repr(C)] struct LastInputInfo { cb_size: u32, dw_time: u32 }+            extern "system" { fn GetLastInputInfo(plii: *mut LastInputInfo) -> i32; fn GetTickCount() -> u32; }+            unsafe { let mut li = LastInputInfo { cb_size: std::mem::size_of::<LastInputInfo>() as u32, dw_time: 0 }; if GetLastInputInfo(&mut li) == 0 { return None; } Some(GetTickCount().wrapping_sub(li.dw_time) as u64) }+        }         // Node: primaryWorkArea (SystemParametersInfo SPI_GETWORKAREA)         fn work_area(&self) -> Option<(i32, i32, i32, i32)> {             let mut r = RECT { left: 0, top: 0, right: 0, bottom: 0 };
rust/host/src/lib.rs+2
@@ -44,6 +44,8 @@ pub trait HostWindows: Send + Sync {     /// Move/resize without activation or z-order change.     fn set_bounds_no_activate(&self, hwnd: i64, left: i32, top: i32, width: i32, height: i32) -> bool;     fn foreground(&self) -> i64;+    /// Milliseconds since the last keyboard/mouse input (GetLastInputInfo); None where unknown.+    fn idle_ms(&self) -> Option<u64> { None }     /// The primary monitor's work area (x, y, w, h), excluding the taskbar.     fn work_area(&self) -> Option<(i32, i32, i32, i32)>;     fn dpi_scale(&self, hwnd: i64) -> f64 { let _ = hwnd; 1.0 }
rust/session/src/lib.rs+4−1
@@ -51,6 +51,9 @@ pub struct Session {     pub progress_count: u32,     pub progress_ms: u64,     pub icon_applied: bool,+    pub born_at_ms: u64,+    pub user_active_at_ms: u64,+    pub raise_granted: bool,     pub owner_from_caller: bool,     pub disruption_notice: Option<String>,     pub fg_watch_until_ms: u64,@@ -63,7 +66,7 @@ impl Session {     /// Node: session._lastAgentUpdate = {tabId, verb, note, ts} | null.     pub fn last_agent_update(&self) -> Value { match &self.last_verb { Some(v) => json!({ "tabId": self.last_tab, "verb": v, "note": self.last_note, "ts": self.last_agent_update_ms }), None => Value::Null } }     pub fn new(id: &str, profile: &str, owner: Option<&str>, owner_container: Option<&str>, cdp_port: u16, pid: Option<u32>) -> Session {-        Session { id: id.into(), profile: profile.into(), owner: owner.map(|s| s.into()), owner_container: owner_container.filter(|s| !s.is_empty()).map(|s| s.into()), created_ms: pup_core::now_ms(), created_iso: pup_core::iso_now(), hwnd: None, pid, cdp_port, tabs: Vec::new(), active_tab: 0, foreground: false, app_id: None, last_agent_update_ms: pup_core::now_ms(), next_tab_seq: 1, emulated: None, last_note: None, pending_cred_hint: None, wiki_authed: None, wiki_view: None, last_annotation: None, last_verb: None, last_tab: None, last_foreground: None, overlay_desc: None, flash_count: 0, progress_count: 0, progress_ms: 0, icon_applied: false, owner_from_caller: false, disruption_notice: None, fg_watch_until_ms: 0, last_outer_width: None, last_url: None, last_title: None }+        Session { id: id.into(), profile: profile.into(), owner: owner.map(|s| s.into()), owner_container: owner_container.filter(|s| !s.is_empty()).map(|s| s.into()), created_ms: pup_core::now_ms(), created_iso: pup_core::iso_now(), hwnd: None, pid, cdp_port, tabs: Vec::new(), active_tab: 0, foreground: false, app_id: None, last_agent_update_ms: pup_core::now_ms(), next_tab_seq: 1, emulated: None, last_note: None, pending_cred_hint: None, wiki_authed: None, wiki_view: None, last_annotation: None, last_verb: None, last_tab: None, last_foreground: None, overlay_desc: None, flash_count: 0, progress_count: 0, progress_ms: 0, icon_applied: false, born_at_ms: pup_core::now_ms(), user_active_at_ms: 0, raise_granted: false, owner_from_caller: false, disruption_notice: None, fg_watch_until_ms: 0, last_outer_width: None, last_url: None, last_title: None }     }     pub fn next_tab_id(&mut self) -> String { let id = format!("tab-{}", self.next_tab_seq); self.next_tab_seq += 1; id }     pub fn active(&self) -> Option<&Tab> { self.tabs.get(self.active_tab) }
rust/verbs/src/input.rs+2−2
@@ -222,7 +222,7 @@ fn nav_wait(cdp: &Cdp, sid: &str, f: impl FnOnce() -> Result<Value, pup_cdp::Cdp pub fn reload(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };     let loaded = match nav_wait(&b.cdp, &t.cdp_session, || b.cdp.send(Some(&t.cdp_session), "Page.reload", json!({ "ignoreCache": args.get("ignoreCache").and_then(|x| x.as_bool()).unwrap_or(false) }), T), Duration::from_secs(25)) { Ok(l) => l, Err(e) => return e };-    if let Some(h) = s.hwnd { ctx.host.send_to_bottom_no_activate(h); }+    let _ = crate::park::bottom_unless_foreground(ctx, &s.id, true);     touch(ctx, &s.id, None);     ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "loaded": loaded, "renderCheck": if loaded { crate::render_check(&b.cdp, &t.cdp_session, None) } else { Value::Null }, "_verifyRender": crate::VERIFY_RENDER, "_next": ["pup_screenshot"] })) }@@ -325,7 +325,7 @@ pub fn maximize(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let (wid, _) = match b.cdp.window_for_target(&t.target_id) { Ok(w) => w, Err(e) => return err("window_failed", e.to_string(), None) };     if let Err(e) = b.cdp.set_window_bounds(wid, json!({ "windowState": "normal" })) { return err("window_failed", e.to_string(), None); }     if state != "normal" { if let Err(e) = b.cdp.set_window_bounds(wid, json!({ "windowState": state })) { return err("window_failed", e.to_string(), None); } }-    if let Some(h) = s.hwnd { if state != "minimized" { ctx.host.send_to_bottom_no_activate(h); } }+    if state != "minimized" { let _ = crate::park::bottom_unless_foreground(ctx, &s.id, true); }     let bounds = b.cdp.send(None, "Browser.getWindowBounds", json!({ "windowId": wid }), T).map(|v| v["bounds"].clone()).unwrap_or(Value::Null);     out(&s, &t, json!({ "state": state, "windowId": wid, "bounds": bounds, "_hint": "Window state changed via CDP without foregrounding it." })) }
rust/verbs/src/lib.rs+8−5
@@ -20,6 +20,7 @@ use std::time::{Duration, Instant}; pub mod image; pub mod events; pub mod activity;+pub mod park; pub mod tabs; pub mod input; pub mod keys;@@ -685,19 +686,21 @@ fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {         let _ = ctx.ab.call("desktop_set_window_state", json!({ "hwnd": h, "state": "bottom", "force": true }), caller, Duration::from_secs(5));         // Node: a background birth leaves the Windows 11 attention tint on the button; clear it now         // (ab's off-screen activate-and-restore, net-zero focus), then the activity signal for this mode.-        let cleared = activity::clear_attention(ctx, h, caller);-        pup_core::log(format!("[window] \"{sid}\" attention tint: {cleared}"));         activity::on_open(ctx, &sid, h, caller);         if let Some(a) = ctx.arc() { activity::start_icon_apply(a, sid.clone()); }     }     if let Some(cs) = s.tabs.last().map(|t| t.cdp_session.clone()) { inject_page_scripts(ctx, &s, &b.cdp, &cs); }     let loaded = if let Some(t) = s.tabs.last_mut() { let r = b.cdp.navigate(&t.cdp_session, &url, Duration::from_secs(20)).map(|(_, l)| l).unwrap_or(false); refresh_tab(&b.cdp, t); r } else { false };     let (auto_login_note, wiki_extra) = match s.tabs.last() { Some(t) => (vault::autofill_on_load(ctx, &s, &t.cdp_session, &url), wiki::after_navigate(ctx, &s, &b.cdp, &t.cdp_session, &url, args["wikiView"].as_str(), args["isolated"].as_bool().unwrap_or(false))), None => (None, json!({})) };-    if let Some(h) = hwnd { ctx.host.send_to_bottom_no_activate(h); }   // the first cross-origin paint can raise it: re-assert (Node: reassertBottomAfterNav)+    ctx.sessions.insert(s.clone());+    let _ = park::bottom_unless_foreground(ctx, &sid, true);   // Node reassertBottomAfterNav: the first cross-origin paint can raise it; inside the birth grace it is bottomed     let title = s.active().map(|t| t.title.clone()).unwrap_or_default();     ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s);     if let Some(a) = ctx.arc() { identity::schedule(a, sid.clone(), caller.clone(), true); }-    if args.get("foreground").and_then(|b| b.as_bool()) != Some(true) { ctx.sessions.with_mut(&sid, |x| x.fg_watch_until_ms = pup_core::now_ms() + 20_000); }  // Node: the no-fault fg watch — caption if the window fronts itself in the next 20s+    if args.get("foreground").and_then(|b| b.as_bool()) == Some(true) {+        // Node: a foreground open is a background birth followed by a GRANTED raise (caption + toast).+        let _ = window::raise(ctx, caller, &json!({ "window": sid, "foregroundReason": args["foregroundReason"] }));+    } else { ctx.sessions.with_mut(&sid, |x| x.fg_watch_until_ms = pup_core::now_ms() + 20_000); }  // Node: the no-fault fg watch — caption if the window fronts itself in the next 20s     pup_core::log(format!("[window] \"{sid}\" opened for \"{}\" on {}({}) hwnd={:?} parked={parked} loaded={loaded} in {}ms", caller.thread, b.launched.candidate.kind, b.launched.candidate.source, hwnd, t0.elapsed().as_millis()));     let cands = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft());     let strict = args["strictPermissions"].as_bool() == Some(true);@@ -734,7 +737,7 @@ fn navigate(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {         Ok((_, loaded)) => {             let mut t = tab.clone(); refresh_tab(&b.cdp, &mut t);             ctx.sessions.with_mut(&s.id, |x| { if let Some(tt) = x.tabs.iter_mut().find(|tt| tt.tab_id == t.tab_id) { *tt = t.clone(); } x.last_agent_update_ms = pup_core::now_ms(); });-            if let Some(h) = s.hwnd { ctx.host.send_to_bottom_no_activate(h); }+            let _ = park::bottom_unless_foreground(ctx, &s.id, true);   // never yank a window the user is using (Node v2.0.26)             if let Some(ss) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&ss); }             if let Some(a) = ctx.arc() { identity::schedule(a, s.id.clone(), caller.clone(), false); }             let note = vault::autofill_on_load(ctx, &s, &t.cdp_session, &t.url);
rust/verbs/src/park.rsadded+33
@@ -0,0 +1,33 @@+//! Z-order doctrine from Node (v1.9.181 userIsUsingWindow, v2.0.26/2.0.27 osBottomUnlessForeground,+//! v2.0.261 birth grace). pup must NEVER pull a window out from under the user: every re-park after a+//! navigation, reload, view switch or recording asks first whether the user is using the window.+//! A newborn window (8 s, no granted raise) cannot be "in use": Chrome self-activates a new window and+//! the user's typing is not a click on a taskbar button that did not exist yet, so it is bottomed.+//! Missing from the Rust port until 2.0.426 (John, 2026-09-06: "you seem to be acting odd").++use crate::Ctx;++pub const BIRTH_GRACE_MS: u64 = 8_000;+const USER_MEMORY_MS: u64 = 10 * 60_000;++pub fn in_birth_grace(s: &pup_session::Session) -> bool { !s.raise_granted && pup_core::now_ms().saturating_sub(s.born_at_ms) < BIRTH_GRACE_MS }+pub fn user_is_using(s: &pup_session::Session) -> bool {+    if in_birth_grace(s) { return false; }+    s.foreground || (s.user_active_at_ms > 0 && pup_core::now_ms().saturating_sub(s.user_active_at_ms) < USER_MEMORY_MS)+}++/// Node osBottomUnlessForeground. Returns "bottomed" | "foreground" | "user-foreground" | "no-hwnd".+pub fn bottom_unless_foreground(ctx: &Ctx, sid: &str, force: bool) -> &'static str {+    let Some(s) = ctx.sessions.get(sid) else { return "no-hwnd" };+    let Some(h) = s.hwnd else { return "no-hwnd" };+    let fg = ctx.host.foreground();+    if fg == h && fg != 0 {+        if !force { return "foreground"; }+        if !in_birth_grace(&s) {+            if user_is_using(&s) { ctx.sessions.with_mut(sid, |x| x.user_active_at_ms = pup_core::now_ms()); return "user-foreground"; }+            if let Some(idle) = ctx.host.idle_ms() { if idle < 5_000 { ctx.sessions.with_mut(sid, |x| x.user_active_at_ms = pup_core::now_ms()); return "user-foreground"; } }+        }+    }+    ctx.host.send_to_bottom_no_activate(h);+    "bottomed"+}
rust/verbs/src/record.rs+1−1
@@ -63,7 +63,7 @@ pub fn start(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let _ = b.cdp.send(Some(&t.cdp_session), "Page.enable", json!({}), Duration::from_secs(5));     let _ = b.cdp.send(Some(&t.cdp_session), "Page.bringToFront", json!({}), Duration::from_secs(5));     pup_core::log(format!("[{id}] screencast on session {} (target {}) url={}", &t.cdp_session[..t.cdp_session.len().min(12)], &t.target_id[..t.target_id.len().min(12)], t.url));-    if let Some(h) = s.hwnd { ctx.host.send_to_bottom_no_activate(h); }+    let _ = crate::park::bottom_unless_foreground(ctx, &s.id, true);     if let Err(e) = b.cdp.send(Some(&t.cdp_session), "Page.startScreencast", json!({ "format": "jpeg", "quality": quality, "everyNthFrame": every }), Duration::from_secs(10)) { ctx.tab_recs.lock().unwrap().remove(&s.id); return err("record_start_failed", e.to_string(), None); }     crate::highfps::grant(ctx, &s.id, args["minutes"].as_u64().unwrap_or(20));     pup_core::log(format!("[{id}] CDP screencast started @ {fps}fps on \"{}\" -> {}", s.id, file.display()));
rust/verbs/src/recovery.rs+1−1
@@ -155,7 +155,7 @@ pub fn health_tick(ctx: &Ctx) {         let now = pup_core::now_ms(); let fg = ctx.host.foreground();         for s in ctx.sessions.all() {             if s.fg_watch_until_ms > now && !s.foreground && s.hwnd == Some(fg) && fg != 0 {-                ctx.sessions.with_mut(&s.id, |x| { x.fg_watch_until_ms = 0; x.foreground = true; });+                ctx.sessions.with_mut(&s.id, |x| { x.fg_watch_until_ms = 0; x.foreground = true; x.user_active_at_ms = pup_core::now_ms(); });                 let _ = ctx.ab.call("desktop_caption", json!({ "id": "pup-notmine", "text": format!("Adom did NOT raise this \"{}\" window — the page or Chrome brought it forward.", s.id), "duration": 3000, "expiresInMs": 3000, "position": "bottom", "size": "small" }), &crate::identity::internal_caller(), Duration::from_secs(5));             }         }
rust/verbs/src/wiki.rs+1−1
@@ -70,7 +70,7 @@ pub fn set_view(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let target = if want == "public" { public_url(&url) } else { strip_public(&url) };     let loaded = b.cdp.navigate(&t.cdp_session, &target, Duration::from_secs(25)).map(|(_, l)| l).unwrap_or(false);     let extra = after_navigate(ctx, &s, &b.cdp, &t.cdp_session, &target, Some(want), false);-    if let Some(h) = s.hwnd { ctx.host.send_to_bottom_no_activate(h); }+    let _ = crate::park::bottom_unless_foreground(ctx, &s.id, true);     touch(ctx, &s.id, None);     if let Some(a) = ctx.arc() { crate::identity::schedule(a, s.id.clone(), caller.clone(), true); }     pup_core::merge(ok(json!({ "success": true, "output": json!({ "ok": true, "view": want, "url": target, "loaded": loaded }).to_string(), "view": want, "url": target, "sessionId": s.id, "window": s.id, "aiThread": s.owner })), extra)
rust/verbs/src/window.rs+1−1
@@ -36,7 +36,7 @@ pub fn raise(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let r = ctx.ab.call("desktop_bring_to_front", json!({ "hwnd": h }), caller, AB_T);     let applied = r.is_ok();     if !applied { let _ = ctx.ab.call("desktop_window_show", json!({ "hwnd": h }), caller, AB_T); }-    ctx.sessions.with_mut(&s.id, |x| x.foreground = true); touch(ctx, &s.id, Some(reason));+    ctx.sessions.with_mut(&s.id, |x| { x.foreground = true; x.raise_granted = true; x.user_active_at_ms = pup_core::now_ms(); }); touch(ctx, &s.id, Some(reason));     if let Some(x) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&x); }     ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "focused": applied, "applied": applied, "placement": "foreground", "windowEtiquette": "foreground", "reason": reason, "captioned": true, "_hint": "Raised onto the user's screen with a caption and a toast naming why. Send it back with pup_lower_os_window when the demo is over.", "_next": ["pup_lower_os_window", "pup_screenshot"] })) }