app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
← Commit history
v2.0.634 (insiders): background windows proven: default ext lane gets --no-startup-window + --keep-alive-for-test (was stock-only), no url argument at cold launch, birth.js token binding (CSP), SWP_NOOWNERZORDER park; tools/foreground-proof.sh is a ship gate (PASS x2 on ConfRoomROG); Google sign-in verbs; ext-lane credential capture; user close final; pup kill != user close
23 files changed
+541−38
dev-skills/pup-bridge-dev/SKILL.md+4dev-skills/pup-the-goal/SKILL.md+70dev-skills/pup-user-foreground/SKILL.md+31rust/Cargo.lock+12−12rust/Cargo.toml+1−1rust/bridge/src/main.rs+9rust/browser/src/ext_assets.rs+3rust/browser/src/lib.rs+23−2rust/host-win/src/lib.rs+21−2rust/host/src/lib.rs+4rust/verbs/src/extdrive.rs+20rust/verbs/src/google.rs+31rust/verbs/src/import.rs+5rust/verbs/src/lib.rs+131−11rust/verbs/src/park.rs+6−1rust/verbs/src/transport.rs+5rust/verbs/src/vault.rs+21−1rust/verbs/src/window.rs+8−1src/extension/birth.html+9src/extension/birth.js+5src/extension/capture-login.js+64src/extension/manifest.json+1−1src/extension/worker.js+57−6dev-skills/pup-bridge-dev/SKILL.md+4@@ -7,6 +7,10 @@ Parent skill: **pup-bridge** # pup-bridge-dev — building & shipping the Puppeteer bridge +> **Before you touch anything here, read `pup-the-goal`.** It is what pup is FOR, in John's own+> words, and every rule in it has already been broken once: passwords he should never have to type,+> windows that must never land in front of him, and finishing the job instead of deferring it.+ The canonical source of truth for the pup bridge is **this wiki repo** (`wiki.adom.inc/adom/pup-bridge`), extracted from `adom-bridge/plugins/puppeteer` on 2026-06-29 to be cloud-owned like the kicad/fusion bridges. Read `CLAUDE.md` (ownership boundary)
dev-skills/pup-the-goal/SKILL.mdadded+70@@ -0,0 +1,70 @@+---+name: pup-the-goal+description: "What pup is FOR, in John's own words, and the standing rules that follow from it. Read this BEFORE any pup work and before answering him about pup. The goal: pup makes his life easier, it signs him in so he never types passwords, it never puts a window in front of him, and it gets finished rather than deferred. Trigger words: pup goal, why pup exists, pup purpose, make pup perfect, pup standing rules, password typing, shoving windows, foreground rude, pup doctrine, before touching pup."+---++# pup: what it is FOR (John, read before every change)++> "the whole fucking goal is to make pup perfect"++Everything below is his, verbatim, from the sessions where I got it wrong. It is not background+colour. Each line is a rule that a change can violate, and each one HAS been violated.++## 1. He should never type a password++> "cuz the whole point of pup is that it makes my life easier and i stopp having to type in damn+> passwords"++- **Capture is the product, not a feature.** A password typed into ANY pup window, on ANY lane, is+ stored. If capture is off on a lane, pup is broken, whatever else works. It shipped broken on the+ DEFAULT lane for weeks because capture lived only on the CDP rail and nobody checked after the lane+ switch (2.0.602 fixed it; see `pup-credentials`).+- **Never ask him to type a password to you.** Serve it from the vault, import it from his own+ browsers (`pup_import_browser_logins`, no `hostFilter` the first time), or sync it from another of+ his desktops. He offered to paste them in chat; the answer is to make pup capture them instead.+- **Verify a capture, never assume it.** `pup_credentials {}` must show the host straight after the+ sign-in. "It should have been captured" is how two of his passwords were lost.+- **Tell him what pup can do.** > "you should be telling me you can do that cuz it should be in your+ ai hints that you even return to yourself cuz no other adom users would even know that ab has that+ feature". Capability that only shows up when an AI already knows to ask for it does not exist.++## 2. His screen is his++> "why do you keep shoving windows in front of me without warning me? that's super rude"+> "you shoved the window in my face while i was busy typing in another window"++- A pup window is BEHIND his work from the instant it exists. Chrome creates it unfocused at z1,+ which is on top: park it the moment the handle resolves, and on a cold launch watch for Chrome's own+ startup window from the spawn (2.0.604, 2.0.605).+- **Warn BEFORE, never after, and make the warning do something.** > "when i just clicked that ab+ toast, i would have expected its action when i clicked the toast to open your pup window". Toast+ with `focus:{hwnd}` so the click raises it, then wait a beat, and prefer letting him click over+ raising at all.+- **Do not re-run a window-opening flow while he is working.** Debug in ONE window, or on winvm or the+ conference room box, never by opening a window per attempt on his laptop.+- Full doctrine: `pup-user-foreground`.++## 3. Finish it++> "so why the fuck are you saying 'but I'm not putting a window in front of you again tonight to+> chase it.'"++Backing off politely is not courtesy, it is leaving pup broken. If the remaining step needs his+screen, make the step safe (parked window, clickable toast) and DO it. The goal is a finished,+correct pup, not a tidy status report. Equally: know what time it is before telling him what time it+is.++## 4. Never lose his windows, never bring back what he closed++> "pup windows are supposed to be fleeting ... the user closes the window and doesn't care about them+> anymore ... you should NEVER bring back those windows"++A window he closed is gone for good; a browser PUP ended itself is restored. Those are different+things and conflating them deleted live windows (2.0.600). See `pup-user-foreground`.++## 5. When he says it is broken, he is describing reality++He said his passwords were saved in Chrome and I told him they were not. He was right that pup had+once kept them; the regression was mine. Go and MEASURE on his box (his Chrome's own `Login Data`,+pup's vault, the log) before explaining to him why something cannot work. An explanation that+contradicts what he experiences is almost always the wrong explanation.
dev-skills/pup-user-foreground/SKILL.md+31@@ -161,6 +161,37 @@ Proof (winvm, `proof-user-close.sh`): close one of two ext windows by WM_CLOSE, within one tick and stays gone for 4 min; close the last one, the lane ends with exit code 0 and every session is forgotten; kill the browser process from outside (nonzero exit), the windows come back. +## A NEW WINDOW MUST BE BEHIND BEFORE ITS FIRST PAINT (2.0.604, John 2026-09-17)++> "why do you keep shoving windows in front of me without warning me? that's super rude." ... "you shoved+> the window in my face while i was busy typing in another window"++Chrome creates the window UNFOCUSED but at z1, directly on top of the user's work. Parking it is therefore+not a finishing touch, it is the first thing that must happen. The park used to run at the END of the open,+after the deferred navigate, the consumer-brand pass, the orphan sweep, the fit and the jump-list commit:+**4.0 seconds of a pup window sitting over his screen, on every open** (measured from his log: shown at++636 ms, parked at +4 s). Repeat that across a flow that opens a window per attempt and the desktop is+unusable.++Rules:+1. **Park the instant the handle resolves**, before any identity, fit or navigation work. It is one Win32+ call with no activation, cheap enough to hold the birth lock across.+2. **The late park is only a re-assert, and only when the window is not foreground.** Between the two, a+ human may have clicked it; a blind second park would yank their click away.+3. **Never measure "background" by the create flag.** `focused:false` is not behind, it is z1.++## Raising is a WARNING FIRST, and the warning carries the window (2.0.604)++> "i realize you just put an ab toast up warning me, but that was like a minute after you shoved the+> window in my face" ... "when i just clicked that ab toast, i would have expected its action when i+> clicked the toast to open your pup window. i believe ab lets you attach those actions now."++`pup_raise_os_window` toasted with `focus:false` and raised in the same breath, so the notification could+not be acted on and arrived with the window rather than ahead of it. Now: the toast carries+`focus:{hwnd}` (a click raises that window), it is level `warning`, and the raise waits 1.2 s behind it so+the warning reaches the screen first. An AI should prefer the toast ALONE and let the human click it,+rather than raising at all, whenever the task can wait for them.+ ## The birth grace may override FLAGS, never INPUT (2.0.584, John 2026-09-15) R6's birth grace (8 s, no granted raise) skipped BOTH user signals inside the shared primitive, on the
rust/Cargo.lock+12−12@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-pup-bridge"-version = "2.0.600"+version = "2.0.634" dependencies = [ "pup-browser", "pup-cdp",@@ -655,7 +655,7 @@ dependencies = [ [[package]] name = "pup-browser"-version = "2.0.600"+version = "2.0.634" dependencies = [ "pup-core", "pup-host",@@ -667,7 +667,7 @@ dependencies = [ [[package]] name = "pup-cdp"-version = "2.0.600"+version = "2.0.634" dependencies = [ "serde_json", "thiserror",@@ -677,7 +677,7 @@ dependencies = [ [[package]] name = "pup-core"-version = "2.0.600"+version = "2.0.634" dependencies = [ "serde", "serde_json",@@ -687,7 +687,7 @@ dependencies = [ [[package]] name = "pup-dashboard"-version = "2.0.600"+version = "2.0.634" dependencies = [ "pup-cdp", "pup-core",@@ -700,7 +700,7 @@ dependencies = [ [[package]] name = "pup-host"-version = "2.0.600"+version = "2.0.634" dependencies = [ "serde", "serde_json",@@ -709,14 +709,14 @@ dependencies = [ [[package]] name = "pup-host-mac"-version = "2.0.600"+version = "2.0.634" dependencies = [ "pup-host", ] [[package]] name = "pup-host-win"-version = "2.0.600"+version = "2.0.634" dependencies = [ "pup-core", "pup-host",@@ -725,7 +725,7 @@ dependencies = [ [[package]] name = "pup-identity"-version = "2.0.600"+version = "2.0.634" dependencies = [ "fontdue", "image",@@ -735,7 +735,7 @@ dependencies = [ [[package]] name = "pup-session"-version = "2.0.600"+version = "2.0.634" dependencies = [ "pup-core", "serde",@@ -745,7 +745,7 @@ dependencies = [ [[package]] name = "pup-vault"-version = "2.0.600"+version = "2.0.634" dependencies = [ "pup-core", "pup-host",@@ -756,7 +756,7 @@ dependencies = [ [[package]] name = "pup-verbs"-version = "2.0.600"+version = "2.0.634" dependencies = [ "base64", "png 0.17.16",
rust/Cargo.toml+1−1@@ -4,7 +4,7 @@ members = ["core", "host", "host-win", "host-mac", "cdp", "browser", "session", # One version for the whole bridge, in lockstep with src/BRIDGE_VERSION (tools/ship.sh bumps both). [workspace.package]-version = "2.0.600"+version = "2.0.634" edition = "2021" license = "MIT"
rust/bridge/src/main.rs+9@@ -139,6 +139,15 @@ fn main() { let removed: Vec<i64> = q.split('&').find_map(|kv| kv.strip_prefix("removed=")).unwrap_or("").split(',').filter_map(|x| x.parse().ok()).collect(); let _ = req.respond(json_response(200, &pup_verbs::extdrive::take_command_for(&lane, &removed))); }+ // 2.0.602: the extension lane's login-capture rail. The CDP lane got captures through a+ // DevTools binding; the default lane had none, so logins typed in normal pup windows were lost.+ (Method::Post, "/extcred") => {+ let mut raw = String::new();+ let mut req = req;+ let _ = req.as_reader().read_to_string(&mut raw);+ pup_verbs::vault::on_capture_ext(&c, &raw);+ let _ = req.respond(json_response(200, &json!({ "ok": true })));+ } (Method::Post, "/exthello") => { let mut raw = String::new(); let mut req = req;
rust/browser/src/ext_assets.rs+3@@ -4,6 +4,9 @@ pub static EXTENSIONS: &[(&str, &str, &[(&str, &[u8])])] = &[ ("extension", "pup-extension", &[ ("annotate-overlay.js", include_bytes!("../../../src/extension/annotate-overlay.js")),+ ("birth.html", include_bytes!("../../../src/extension/birth.html")),+ ("birth.js", include_bytes!("../../../src/extension/birth.js")),+ ("capture-login.js", include_bytes!("../../../src/extension/capture-login.js")), ("content.js", include_bytes!("../../../src/extension/content.js")), ("fg-500.woff2", include_bytes!("../../../src/extension/fg-500.woff2")), ("fg-700.woff2", include_bytes!("../../../src/extension/fg-700.woff2")),
rust/browser/src/lib.rs+23−2@@ -658,6 +658,14 @@ pub fn launch_extdrive_opts(host: &dyn Host, cand: &Candidate, profile_dir: &Pat format!("--user-data-dir={}", profile_dir.display()), "--no-first-run".into(), "--no-default-browser-check".into(), "--disable-quic".into(), "--media-router=0".into(),+ // 2.0.617 (John, 2026-09-18: "being asked even that once is too much"). The cold start is now+ // INVISIBLE: no startup window at all, so Chrome never draws or activates one, and the first pup+ // window is created by the extension, minimized, and shown already at the bottom. 2.0.611 had to+ // revert --no-startup-window because a windowless Chrome EXITED before its worker called home+ // (measured on his laptop: dead within 6 s; it survived on the conference-room box). The missing+ // half is --keep-alive-for-test, which holds the browser process open with no windows: measured on+ // the same laptop, alive at 6 s with the flag, dead without it. Both flags, or neither.+ "--no-startup-window".into(), "--keep-alive-for-test".into(), // 2.0.576 CalculateNativeWinOcclusion: stock Chrome stops painting a window that is COMPLETELY covered, // so every capture of a pup window buried under the user's maximized browser failed (0/20, 2026-09-14), // and the debugger screencast produced no frame either. The CDP lane has always disabled it (see@@ -681,7 +689,11 @@ pub fn launch_extdrive_opts(host: &dyn Host, cand: &Candidate, profile_dir: &Pat // profile and raise the "Action required" bubble; their settings live in MAC-protected Secure // Preferences so pre-acknowledging is impossible. Keep ONLY pup's own extensions enabled. args.push(format!("--disable-extensions-except={}", extensions.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(","))); }- args.push(url.to_string());+ // 2.0.632: NO url on the command line when there is none. A url argument OVERRIDES --no-startup-window+ // (the CDP lane's note said "Never pass a URL here"), and the cold lane was launched with "about:blank":+ // that made an unowned about:blank window that sat ON TOP for 18 s until the orphan sweep closed it,+ // on every cold start (measured, 2.0.631).+ if !url.is_empty() { args.push(url.to_string()); } return host.spawn_detached(&cand.executable_path, &args, None).map_err(|e| format!("{}: {e}", cand.kind)); } let _ = fs::create_dir_all(profile_dir);@@ -700,6 +712,12 @@ pub fn launch_extdrive_opts(host: &dyn Host, cand: &Candidate, profile_dir: &Pat // bot-detection reason and costs nothing: infobars are browser UI, invisible to the page. "--start-maximized".into(), "--disable-infobars".into(), "--silent-debugger-extension-api".into(), // 2.0.574: see the stock list above+ // 2.0.634: the 2.0.617 pair went into the STOCK branch only, and stock is off by default, so this+ // (the default) lane still got Chrome's real startup window: a New Tab page, activated by Chrome,+ // on top of the user for the whole session at every cold start (measured on ConfRoomROG, 2.0.633).+ // Same contract as the stock list: no startup window, and keep-alive so a windowless Chrome does+ // not exit before its worker calls home (AdomLapper: dead within 6 s without it). Both or neither.+ "--no-startup-window".into(), "--keep-alive-for-test".into(), // 2.0.493: keep the NETWORK-QUIETING flags. Dropping them (2.0.488) re-introduced the Windows Firewall // prompt "Do you want to allow public and private networks to access this app? — Google Chrome for // Testing" on winvm (John, 2026-09-12): Chrome's mDNS/Cast discovery binds a UDP port on ALL interfaces@@ -721,7 +739,10 @@ pub fn launch_extdrive_opts(host: &dyn Host, cand: &Candidate, profile_dir: &Pat args.push(format!("--disable-extensions-except={}", extensions.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(","))); }- args.push(url.to_string());+ // 2.0.633: same rule as the stock branch above (2.0.632 only patched that one; this is the DEFAULT branch).+ // An empty argument was handed to Chrome as "" and it opened its working directory as a file listing,+ // on top of the user, for the whole session (measured on ConfRoomROG: "Index of C:\Users\john\.cache\puppeteer\").+ if !url.is_empty() { args.push(url.to_string()); } host.spawn_detached(&cand.executable_path, &args, None).map_err(|e| format!("{}: {e}", cand.kind)) }
rust/host-win/src/lib.rs+21−2@@ -16,7 +16,7 @@ mod imp { use windows_sys::Win32::UI::HiDpi::GetDpiForWindow; use windows_sys::Win32::UI::WindowsAndMessaging::{ EnumWindows, GetClassNameW, GetForegroundWindow, GetPropW, GetWindow, GetWindowLongPtrW, GetWindowRect, GetWindowTextW, GetWindowThreadProcessId, IsWindow, IsWindowVisible,- LoadImageW, PostMessageW, SetPropW, SetWindowPos, SystemParametersInfoW, GWL_STYLE, GW_OWNER, HWND_BOTTOM, IMAGE_ICON, LR_LOADFROMFILE, SPI_GETWORKAREA, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, WM_SETICON, WS_CAPTION,+ LoadImageW, PostMessageW, SetPropW, SetWindowPos, SystemParametersInfoW, GWL_STYLE, GW_OWNER, HWND_BOTTOM, IMAGE_ICON, LR_LOADFROMFILE, SPI_GETWORKAREA, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOOWNERZORDER, SWP_NOSIZE, SWP_NOZORDER, WM_SETICON, WS_CAPTION, }; fn wide(s: &str) -> Vec<u16> { s.encode_utf16().chain(std::iter::once(0)).collect() }@@ -126,8 +126,27 @@ mod imp { extern "system" { fn ShowWindow(hwnd: HWND, ncmd: i32) -> i32; } unsafe { ShowWindow(hwnd as HWND, if visible { 4 /* SW_SHOWNOACTIVATE */ } else { 0 /* SW_HIDE */ }); true } }+ fn show_at_bottom_no_activate(&self, hwnd: i64) -> bool {+ extern "system" { fn ShowWindow(hwnd: HWND, ncmd: i32) -> i32; }+ const SWP_SHOWWINDOW: u32 = 0x0040;+ unsafe {+ // Z-ORDER FIRST, then draw. The window arrives here hidden (the lane watcher) or minimized+ // (chrome.windows.create state:minimized), and both restore ON TOP if you show them first.+ // Bottom it while it is still not on screen, restore it without activating, then bottom it+ // once more in case the restore re-ordered it. The user never sees a frame of it in front.+ SetWindowPos(hwnd as HWND, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_NOOWNERZORDER);+ ShowWindow(hwnd as HWND, 4 /* SW_SHOWNOACTIVATE */);+ SetWindowPos(hwnd as HWND, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_NOOWNERZORDER | SWP_SHOWWINDOW) != 0+ }+ }+ // 2.0.626: THE flag. MEASURED on the conference-room box, 2026-09-18, straight from a fresh process:+ // SetWindowPos(HWND_BOTTOM, NOMOVE|NOSIZE|NOACTIVATE) on a pup Chrome window returned TRUE and moved it to+ // rank 0, THE TOP, where it stayed; the same call with SWP_NOOWNERZORDER put it at rank 25, the bottom, and+ // it stayed. Chrome's frame owns helper windows, and without SWP_NOOWNERZORDER Windows re-orders the owner+ // together with them. So every "parked at z-bottom: true" this week was a RAISE: the birth flash, the+ // "navigate surfaces it", the lower that "succeeded" with the window still on top. One flag. fn send_to_bottom_no_activate(&self, hwnd: i64) -> bool {- unsafe { SetWindowPos(hwnd as HWND, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE) != 0 }+ unsafe { SetWindowPos(hwnd as HWND, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_NOOWNERZORDER) != 0 } } // Node: healOffscreenWindow / setSessionWindowBounds (SetWindowPos 0x14 = NOACTIVATE|NOZORDER) fn set_bounds_no_activate(&self, hwnd: i64, left: i32, top: i32, width: i32, height: i32) -> bool {
rust/host/src/lib.rs+4@@ -51,6 +51,10 @@ pub trait HostWindows: Send + Sync { /// ShowWindow(SW_HIDE) / ShowWindow(SW_SHOWNOACTIVATE): a hidden window has no taskbar button; showing it /// creates the button ONCE with whatever identity the window carries by then. fn set_visible_no_activate(&self, hwnd: i64, visible: bool) -> bool { let _ = (hwnd, visible); false }+ /// 2.0.612: SetWindowPos(HWND_BOTTOM, SWP_SHOWWINDOW | SWP_NOACTIVATE): make a hidden window visible+ /// ALREADY at the bottom, in ONE call. Showing first and lowering after leaves a frame where the user+ /// sees it in front, which is the whole complaint.+ fn show_at_bottom_no_activate(&self, hwnd: i64) -> bool { let _ = hwnd; false } /// ITaskbarList::DeleteTab / AddTab: remove or (re)create the taskbar button WITHOUT touching the /// window (no hide, so Chrome queues no activation). AddTab creates the button fresh with whatever /// AUMID/icon the window carries at that moment.
rust/verbs/src/extdrive.rs+20@@ -561,6 +561,26 @@ fn window_id_for_hwnd(ctx: &Ctx, sid: &str) -> Option<i64> { r["tabs"].as_array()?.iter().find(|t| t["tabId"].as_i64() == Some(want)).and_then(|t| t["windowId"].as_i64()) } +/// 2.0.630: bind a newborn by its birth token, deterministically (pup-window-targeting R4, pup-no-wiggle). The+/// extension creates every window on birth.html, whose title is "PUPBIRTH-<token>", so the one lane window+/// carrying that title IS this session's, however many windows are being born at once and whatever placeholder+/// Chrome makes at launch. No width filter: the window is still MINIMIZED here, and a minimized window reports+/// a tiny rect at -32000, which is exactly the shape the geometry filters exclude.+pub(crate) fn hwnd_by_birth_token(ctx: &Ctx, token: &str, taken: &std::collections::HashSet<i64>, wait: Duration) -> Option<i64> {+ let want = format!("PUPBIRTH-{token}");+ let t0 = std::time::Instant::now();+ loop {+ let pids = crate::extdrive_pids();+ if !pids.is_empty() {+ let m: Vec<i64> = ctx.host.windows_for_pids(&pids, Some("Chrome_WidgetWin_1")).into_iter()+ .filter(|w| w.title.starts_with(&want) && !taken.contains(&w.hwnd)).map(|w| w.hwnd).collect();+ if m.len() == 1 { return Some(m[0]); }+ }+ if t0.elapsed() >= wait { return None; }+ std::thread::sleep(Duration::from_millis(25));+ }+}+ pub(crate) fn lane_hwnd_for(ctx: &Ctx, s: &pup_session::Session) -> Option<i64> { let pids = crate::extdrive_pids(); if pids.is_empty() { return None; }
rust/verbs/src/google.rs+31@@ -229,6 +229,21 @@ fn open_on(ctx: &Ctx, caller: &Caller, args: &Value, url: &str) -> Result<Opened Ok(Opened { sid, tab, new_window }) } +/// 2.0.603 (John, 2026-09-17: "whenever you need the users attention for a password, you should do it+/// that way" ... "i didn't even realize you were waiting on me. why did you not ab toast me?"). Every+/// answer that parks the flow on the human toasts, and the toast CARRIES THE WINDOW: ab's `focus` makes a+/// click bring that pup window to the front, so the user lands on the exact page that is waiting.+pub(crate) fn toast_waiting(ctx: &Ctx, caller: &Caller, sid: &str, title: &str, body: &str) {+ let hwnd = ctx.sessions.get(sid).and_then(|s| s.hwnd).filter(|h| *h > 0);+ let mut args = json!({ "title": title, "body": body, "level": "warning" });+ match hwnd {+ Some(h) => args["focus"] = json!({ "hwnd": h }),+ None => args["focus"] = json!({ "app": "chrome.exe" }),+ }+ let _ = ctx.ab.call("notify_user", args, caller, Duration::from_secs(8));+ pup_core::log(format!("[cred] toasted the user: {title} (click opens window \"{sid}\")"));+}+ fn account_line(a: &Value) -> String { let labels: Vec<String> = a["labels"].as_array().map(|l| l.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect()).unwrap_or_default(); let mut parts = vec![a["kind"].as_str().unwrap_or("?").to_string()];@@ -257,6 +272,15 @@ fn finish(ctx: &Ctx, caller: &Caller, sid: &str, account: Option<&str>, cont: &s } let p = phase(&st); let acct = account.unwrap_or("the account");+ if matches!(p, "choose" | "code" | "challenge" | "wrong_password") {+ let (t, b) = match p {+ "code" => ("pup needs the code Google just sent", format!("Google sent a verification code for {acct}. Click here to open the pup window, or tell your AI thread the code.")),+ "choose" => ("Google wants a second step", format!("Pick how to verify {acct}. Click here to open the pup window.")),+ "wrong_password" => ("pup's saved password is out of date", format!("Google rejected the stored password for {acct}. Click here to open the pup window and sign in once; pup will save the new one.")),+ _ => ("Google is waiting for you", format!("Approve the sign-in for {acct} on your phone, or click here to open the pup window.")),+ };+ toast_waiting(ctx, caller, sid, t, &b);+ } let base = json!({ "account": account, "window": sid, "url": st["url"], "step": p }); match p { "left" => {@@ -353,6 +377,11 @@ pub fn google_signin(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { } fn signin_impl(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+ // 2.0.621: no screen handback (pup-user-foreground: deleted in August, it foregrounded the wrong window).+ signin_inner(ctx, caller, args)+}++fn signin_inner(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let want = args["account"].as_str().map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty()); // finishing a verification step in the window a previous call left on it if args.get("code").and_then(|c| c.as_str()).is_some() || args.get("challenge").and_then(|c| c.as_str()).is_some() {@@ -410,6 +439,8 @@ fn signin_impl(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let Some(cred) = ctx.vault.get_user(&*ctx.host, "https://accounts.google.com/", &account) else { let found_in: Vec<String> = info["browsers"].as_array().map(|b| b.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect()).unwrap_or_default();+ toast_waiting(ctx, caller, &sid, "pup needs you to sign in once",+ &format!("Sign in as {account} in the pup window (click here to open it). pup saves the password and will not ask again.")); return pup_core::merge(err("google_password_missing", format!("{account} is not signed into pup and pup has no saved password for it"), Some(&format!("{} Get the password into pup's vault once, then call pup_google_signin again: pup_import_browser_logins {{hostFilter:\"google.com\"}} with NO hostFilter reads the user's own saved Chrome/Edge/Brave logins from EVERY profile into pup's vault: tell the user to approve the Adom Bridge consent (inside Hydrogen it arrives as a notification and a card, not a window) and then a Windows admin prompt; no one sees the password, and if the call answers consent_pending, call it again to collect the result. Know the limit before you promise it: a browser does NOT save the password of the account it is itself signed in as, so a user's everyday Google account is usually absent however wide the import (measured on John's laptop: 828 logins imported, 240 of them under [email protected], and not one for google.com). For those accounts the only route is the user signing in once in this pup window, which pup captures on submit, or the user signs in once in window \"{sid}\" and pup captures it. Never ask the user to type the password to you.", if found_in.is_empty() { String::new() } else { format!("The account is signed into {} on this desktop, so its browser most likely has the password saved.", found_in.join(", ")) }))), json!({ "account": account, "window": sid, "foundIn": found_in }));
rust/verbs/src/import.rs+5@@ -57,6 +57,11 @@ pub fn import_browser_logins(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let mut g = IMPORT_JOB.lock().unwrap(); if let Some(r) = g.as_ref().and_then(|j| j.result.clone()) { *g = None; return r; } } std::thread::sleep(Duration::from_millis(500)); }+ // 2.0.603: say it on the desktop, not only in the answer. The consent arrives as a Bridge notification+ // inside Hydrogen, which is easy to miss: John sat unaware twice while pup waited (2026-09-16).+ let _ = ctx.ab.call("notify_user", json!({ "title": "pup is waiting for your approval",+ "body": "Approve the Adom Bridge request to read your saved browser logins, then the Windows prompt. pup uses them to sign you in without asking for passwords.",+ "level": "warning", "focus": { "host": true } }), caller, Duration::from_secs(8)); let other = IMPORT_JOB.lock().unwrap().as_ref().map(|j| j.key != key).unwrap_or(false); pup_core::merge(err("consent_pending", "Waiting for the user to approve the import on their desktop", Some(&format!("{}The user approves an Adom Bridge consent (when Bridge runs inside Hydrogen it arrives as a notification and a card in Hydrogen, not a window), then a Windows admin prompt. Tell the user exactly that, then call pup_import_browser_logins again with the same arguments: it re-attaches to this request and returns the result, it does not raise a second prompt. After 2 minutes with no answer Bridge counts it as declined.", if other { "An import with different arguments is already waiting; this call attached to it. " } else { "" }))), json!({ "pending": true, "output": json!({ "ok": false, "pending": true }).to_string() }))
rust/verbs/src/lib.rs+131−11@@ -153,6 +153,28 @@ pub fn lane_shim(ctx: &Ctx) -> Option<String> { shim } +/// 2.0.628: the doctrine's bounded re-assert inside the birth grace (pup-user-foreground rule 2, the CDP lane's+/// +2.5 s pass since v1.9.201), for the two moments a window on this lane is measured to settle on top after+/// pup parked it: the first window of a freshly started browser, and a window bound late because the birth+/// caught a placeholder. Force is on, so the primitive applies the idle-hands rule: no input since birth means+/// Chrome's own raise and it is bottomed; any input means a person, and it answers user-foreground and stops.+fn schedule_birth_reassert(ctx: &Ctx, sid: &str, why: &'static str) {+ let Some(a) = ctx.arc() else { return };+ let sid2 = sid.to_string();+ std::thread::Builder::new().name("birth-reassert".into()).spawn(move || {+ // 2.0.630: the first passes land within tens of milliseconds. Measured on 2.0.629: a warm birth still+ // showed on top for ONE half-second sample, the gap between pup's restore and the first pass at 300 ms.+ // Then dense through the first second, where the fit and the identity pass run, then sparse; ~6 s in all.+ let waits = [40u64, 60, 100, 150, 250, 400, 600, 1000, 1500, 2500];+ for (i, wait) in waits.iter().enumerate() {+ std::thread::sleep(Duration::from_millis(*wait));+ let r = park::bottom_unless_foreground(&a, &sid2, true);+ if r != "bottomed" || i + 1 == waits.len() { pup_core::log(format!("[park] \"{sid2}\" {why} re-assert {}/{}: {r}", i + 1, waits.len())); }+ if r == "user-foreground" || r == "no-hwnd" { break; }+ }+ }).ok();+}+ pub fn launch_extdrive(ctx: &Ctx, url: &str) -> Result<u32, Value> { // 2.0.517: the lane runs on adom-you, the ONE durable jar, because John is right that nothing should // ever use anything else: a separate adom-you-ext jar had none of his cookies, logins or history,@@ -225,6 +247,15 @@ pub fn launch_extdrive(ctx: &Ctx, url: &str) -> Result<u32, Value> { if stock { pup_core::log("[lane] laneStockFlags is ON: launching with only the firewall guards, everything else stock".to_string()); } let trials = ctx.settings.get("laneFieldTrials", "on") != "off"; if stock && !trials { pup_core::log("[lane] laneFieldTrials is OFF: --disable-field-trial-config added to the stock launch".to_string()); }+ // 2.0.605 (John, 2026-09-17: "why do you keep shoving pup windows in front of me? that's rude").+ // A COLD launch is the one case the birth park cannot cover: Chrome paints its OWN startup window the+ // moment the process starts, seconds before pup has a handle to park (measured on his laptop right after+ // he asked me to close everything, which forced exactly this path). So watch for it from the instant of+ // the spawn: poll every 25 ms and bottom any Chrome-for-Testing window on the profile as soon as it+ // exists. send_to_bottom_no_activate never activates and never hides, so the window still renders.+ // 2.0.621: no watcher, no hiding. The lane launches with --no-startup-window, so there is no startup+ // window to catch, and a window held hidden "until its session is ready" is the staging state the+ // visibility invariant forbids (pup-user-foreground rule 7): an interrupted birth would strand it. match pup_browser::launch_extdrive_opts(&*ctx.host, &cand, &dir, &exts, url, stock, trials) { Ok(pid) => { let mut owned = vec![pid];@@ -1321,8 +1352,19 @@ fn navigate_ext(ctx: &Ctx, caller: &Caller, s: &Session, url: &str) -> Value { let (u, title, loaded) = match transport::navigate(&lane, &tab, url) { Ok(x) => x, Err(e) => return e }; ctx.sessions.with_mut(&s.id, |x| { if let Some(tt) = x.tabs.iter_mut().find(|tt| tt.tab_id == tab.tab_id) { tt.url = u.clone(); tt.title = title.clone(); } x.last_agent_update_ms = pup_core::now_ms(); }); if let Some(ss) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&ss); }- if let Some(h) = s.hwnd { let _ = activity::clear_attention(ctx, h, caller); }+ // 2.0.621: NO attention clear here. ab implements desktop_clear_window_attention by RAISING the window+ // (adom-bridge#199, measured z16 -> z1 on every call), and it ran after EVERY navigate, which is the+ // "navigate puts the window on top" that flashed pup at John through every Google sign-in. Nothing needs+ // clearing after a navigate; the birth tint is cleared once, at birth. identity::overlay(ctx, &s.id, caller);+ // 2.0.607 (John, 2026-09-17: "why did you just shove a pup window to the foreground? that was rude").+ // An AI navigating a window the user never asked to see must leave it where it was: BEHIND. Measured on+ // his laptop, a background window came forward on the navigate, 21 s before the toast that explained it.+ // Narrow on purpose: never when the window is foreground, and never when he has touched it recently+ // (park::user_is_using), so this can never pull away a window HE raised from a toast.+ // 2.0.629: force, so a navigation inside the birth grace that Chrome ACTIVATED is still bottomed under the+ // idle-hands rule; outside the grace, or with any input since birth, the primitive stands down as before.+ let _ = park::bottom_unless_foreground(ctx, &s.id, true); ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": tab.tab_id, "url": u, "title": title, "loaded": loaded, "transport": "ext" })) } @@ -1473,6 +1515,9 @@ pub fn open_window_ext(ctx: &Ctx, caller: &Caller, args: &Value, sid: &str, owne } // R4 / pup-window-targeting rule 2. The ext lane is ONE Chrome holding MANY threads' windows, so an // unlocked set-diff is exactly how all-engineering, NSF and pup-bridge all ended up on hwnd 590484.+ #[allow(unused_assignments)]+ let mut birth_move_ms: Option<i64> = None; // 2.0.621: R1's on-screen move, measured by the extension; set by whichever open arm runs+ let mut birth_token = String::new(); // 2.0.630: the birth page's title token, the deterministic handle let birth_guard = BIRTH_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let taken = taken_hwnds(ctx, sid); let before: std::collections::HashSet<i64> = ctx.host.frame_windows_all(Some("Chrome_WidgetWin_1")).into_iter().map(|w| w.hwnd).collect();@@ -1489,8 +1534,17 @@ pub fn open_window_ext(ctx: &Ctx, caller: &Caller, args: &Value, sid: &str, owne // home (a Chrome still shutting down after the last close makes the new one forward to it and exit). // Answering `starting:true` with no session made the caller's very next verb fail with // session_not_found (parity's pup_open_tab, and other threads' "hard time opening pup", 2026-09-15).+ // 2.0.613 (John, 2026-09-17, shouting, and right): "IF YOU CAN'T FUCKING CREATE A PUP WINDOW IN THE+ // BACKGROUND THEN FUCKING WARN ME AS THE USER YOU'RE ABOUT TO SHOVE A WINDOW IN FRONT OF MY FACE".+ // A warm open is invisible now (created minimized, shown already at the bottom). A COLD one starts the+ // browser, and Chrome draws its own startup window before pup owns anything, so it can still flash.+ // That case gets a warning BEFORE it happens, with a beat for him to see it, never an apology after.+ // 2.0.617: no prompt. The cold start draws nothing now (--no-startup-window + --keep-alive-for-test),+ // so there is nothing to ask about: "being asked even that once is too much" (John, 2026-09-18). for attempt in 1..=2u32 {- if let Err(e) = launch_extdrive(ctx, "about:blank") { if attempt == 2 { return e; } else { continue; } }+ // 2.0.632: no url. "about:blank" here overrode --no-startup-window and made an unowned about:blank+ // window that sat on top of the user's work for 18 s on every cold start (measured, 2.0.631).+ if let Err(e) = launch_extdrive(ctx, "") { if attempt == 2 { return e; } else { continue; } } let t0 = std::time::Instant::now(); while t0.elapsed() < Duration::from_secs(25) { if extdrive::connected_ms_ago() < 4000 { break; } std::thread::sleep(Duration::from_millis(300)); } if extdrive::alive(ctx) { break; }@@ -1504,9 +1558,12 @@ pub fn open_window_ext(ctx: &Ctx, caller: &Caller, args: &Value, sid: &str, owne // the tab and Chrome window for this session: the launch URL's tab on a cold start, a NEW window otherwise let (chrome_tab, chrome_win, got_url, got_title) = if cold { extdrive::apply_consumer_brands(ctx);- match extdrive::run(json!({ "action": "tabs" }), Duration::from_secs(10)) {+ // 2.0.617: the lane launches with --no-startup-window, so there is no tab to adopt. Create the first+ // window the same way as every later one: through the extension, minimized, shown at the bottom.+ match extdrive::run(json!({ "action": "open", "url": "about:blank" }), Duration::from_secs(25)) { Ok(r) => {- let t = r["tabs"].as_array().and_then(|a| a.last().cloned()).unwrap_or(Value::Null);+ birth_move_ms = r["birthMoveMs"].as_i64(); birth_token = r["token"].as_str().unwrap_or("").to_string();+ let t = r.clone(); let id = t["tabId"].as_i64().unwrap_or(0); // The real page is loaded AFTER the birth lock is released (below): a 25 s navigate held // under the lock would serialize every other thread's window birth behind this one.@@ -1516,11 +1573,19 @@ pub fn open_window_ext(ctx: &Ctx, caller: &Caller, args: &Value, sid: &str, owne } } else { match extdrive::run(json!({ "action": "open", "url": url }), Duration::from_secs(20)) {- Ok(r) => (r["tabId"].as_i64().unwrap_or(0), r["windowId"].as_i64(), url.to_string(), String::new()),+ Ok(r) => { birth_move_ms = r["birthMoveMs"].as_i64(); birth_token = r["token"].as_str().unwrap_or("").to_string(); (r["tabId"].as_i64().unwrap_or(0), r["windowId"].as_i64(), url.to_string(), String::new()) } Err(e) => { // 2.0.577: a lane whose worker polled within the last few seconds is BUSY, not dead: relaunching it // here killed every thread's windows because one open waited behind a long capture (NSF's open, // 18:28 on 2026-09-14). Fail THIS open and say so; only a silent worker gets the relaunch.+ // 2.0.622: only a NON-ANSWER is "busy". The worker answering with an error (measured: Chrome+ // refusing a window's bounds) was being relabelled ext_busy with a "retry in a few seconds"+ // hint, which is a lie that sends a caller into a retry loop against a deterministic failure.+ let answered = e["errorCode"].as_str().map(|c| c != "ext_no_answer").unwrap_or(false) && e["error"].as_str().map(|m| !m.contains("did not answer")).unwrap_or(true);+ if answered {+ pup_core::log(format!("[lane] warm open refused by the browser: {}", e["error"].as_str().unwrap_or("?")));+ return pup_core::merge(e, json!({ "errorCode": "ext_open_failed", "_hint": "The browser answered and refused to create the window; the error is Chrome's own. This is not a busy lane and a retry will fail the same way." }));+ } if extdrive::connected_ms_ago() < 8000 { pup_core::log(format!("[lane] warm open not answered but the worker polled {} ms ago: busy, not relaunching", extdrive::connected_ms_ago())); return pup_core::merge(e, json!({ "errorCode": "ext_busy", "_hint": "The extension lane is alive but did not answer in time (another verb is holding it). Retry the open in a few seconds; pup did NOT restart anything." }));@@ -1529,6 +1594,13 @@ pub fn open_window_ext(ctx: &Ctx, caller: &Caller, args: &Value, sid: &str, owne pup_core::log("[lane] the extension channel did not answer a warm open and the worker is silent; relaunching the lane".to_string()); extdrive::mark_dead(); let mut a = args.clone(); a["lane"] = json!("ext");+ // 2.0.623: RELEASE THE BIRTH LOCK before re-entering. This call re-enters open_window_ext, whose+ // first act is to take BIRTH_LOCK, which is not re-entrant: the open deadlocked on itself and every+ // later open queued behind it forever (measured on the conference-room box, 2026-09-18: exact+ // 60 s timeouts on every open, a lane that read dead, and not one launch line in the log). It bites+ // whenever the browser dies between a poll and an open, which is exactly what the foreground+ // gate does on purpose and what a user closing the browser does by accident.+ drop(birth_guard); return open_window_ext(ctx, caller, &a, sid, owner, url); } }@@ -1538,11 +1610,51 @@ pub fn open_window_ext(ctx: &Ctx, caller: &Caller, args: &Value, sid: &str, owne s.owner_from_caller = s.owner.as_deref() == Some(caller.thread.as_str()) && !caller.thread.is_empty() && args.get("owner").is_none(); let t = transport::ext_tab(&mut s, chrome_tab, &got_url, &got_title); s.tabs.push(t.clone()); s.active_tab = 0; let mut early_stamped = false;- if let Some((h, stamped)) = birth_report(watcher, sid) { s.hwnd = Some(h); early_stamped = stamped; }- if s.hwnd.is_none() { if let Some(h) = extdrive::lane_hwnd_for(ctx, &s) { s.hwnd = Some(h); } }+ // 2.0.630: bind by the birth TOKEN first. The set-diff bound a placeholder Chrome makes at launch and pup+ // parked that while the real window came up unparked (measured: 4.5 s on top on every cold start). The+ // set-diff handle is only a fallback now, and is trusted only when it agrees with the token.+ let by_token = if birth_token.is_empty() { None } else { extdrive::hwnd_by_birth_token(ctx, &birth_token, &taken, Duration::from_secs(3)) };+ match (by_token, birth_report(watcher, sid)) {+ (Some(h), Some((wh, stamped))) => { s.hwnd = Some(h); early_stamped = stamped && wh == h; if wh != h { pup_core::log(format!("[window] \"{sid}\" the set-diff caught window {wh}, the birth token names {h}; bound by token")); } }+ (Some(h), None) => { s.hwnd = Some(h); }+ (None, Some((wh, stamped))) => { s.hwnd = Some(wh); early_stamped = stamped; pup_core::log(format!("[window] \"{sid}\" birth token {birth_token} not found within 3 s; bound the set-diff handle {wh}")); }+ (None, None) => { if let Some(h) = extdrive::lane_hwnd_for(ctx, &s) { s.hwnd = Some(h); } }+ }+ // 2.0.604 (John, 2026-09-17: "why do you keep shoving windows in front of me without warning me?+ // ... you shoved the window in my face while i was busy typing in another window"). Chrome creates the+ // window unfocused but at z1, ON TOP of the user's work, and the park used to run AFTER the deferred+ // navigate, the consumer-brand pass, the orphan sweep, the fit and the jump-list commit: measured 4.0 s+ // of a pup window sitting over his screen, every open. Park the INSTANT the handle exists, before any of+ // that. This is one Win32 call with no activation, so it is safe to hold the birth lock across.+ if let Some(h) = s.hwnd {+ let t_park = Instant::now();+ // 2.0.624: this lane's R1 in two halves. The extension birthed the window MINIMIZED at its final rect+ // (the API refuses off-screen, and a visible birth lands on top: measured). This one call orders it to+ // the bottom while it is still minimized, restores it without activation, and orders it again, so the+ // first frame it is ever drawn in is already behind the user's work. Minimized was a transient measured+ // in the hundreds of milliseconds (the handle wait); it can never strand, a taskbar click restores it.+ let parked = ctx.host.show_at_bottom_no_activate(h);+ pup_core::log(format!("[window] \"{sid}\" birth: off-screen create, on-screen move {} (budget 50 ms), parked at z-bottom {}ms after the handle resolved (ext lane): {parked}",+ match birth_move_ms { Some(m) if m >= 0 => format!("{m} ms"), _ => "unmeasured".into() }, t_park.elapsed().as_millis()));+ if birth_move_ms.map(|m| m > 50).unwrap_or(false) { pup_core::log(format!("[window] \"{sid}\" DEFECT: the on-screen move took {} ms, over the 50 ms rule (pup-bridge-dev R1)", birth_move_ms.unwrap_or(0))); }+ // 2.0.627: the FIRST window of a freshly started browser settles ON TOP after every park pup makes+ // at birth (measured on the conference-room box with the fixed bottom call: a warm birth ended at z11,+ // behind, the cold one at z0 and stayed). Something in Chrome raises its first window after pup is+ // done. The doctrine's answer is the bounded re-assert inside the birth grace, which the CDP lane has+ // run since v1.9.201 and this lane opted out of on the claim that Chrome never self-raises an+ // unfocused window; true for a warm birth, false for a cold one. Safe by construction: the primitive+ // never touches a window that is foreground, so a human who clicked it is left alone.+ // 2.0.629: EVERY birth, not only a cold one. Measured: with the cold re-assert in place the cold window was+ // pulled back down within seconds, and the WARM windows (B, C) came up on top and stayed, because they got+ // no re-assert. "Chrome never self-activates an unfocused-created window on this lane" is false for a+ // minimized birth restored by pup; the fit and the identity work that follow the park can raise it too.+ schedule_birth_reassert(ctx, sid, if cold { "cold birth" } else { "birth" });+ } drop(birth_guard); // hwnd resolved (or refused): the next thread's birth may start // the deferred cold navigate, now that no other birth is blocked behind it- if cold && url != "about:blank" {+ // 2.0.630: EVERY birth navigates from the birth page to its real URL here, after the window is bound and+ // parked, so the page the user cares about never loads into a window pup does not yet hold.+ if url != "about:blank" { // 2.0.595: the session OWNS its window from here. It used to be inserted only after this navigate, so for // up to 25 s the lane's only window was an unowned about:blank, and the health tick's orphan sweep closed // it: Chrome exited with its last window, the worker went silent, and 190 s later the "dead lane" path@@ -1582,11 +1694,14 @@ pub fn open_window_ext(ctx: &Ctx, caller: &Caller, args: &Value, sid: &str, owne // the window at z1, right under the user's active window and above everything else (measured: z1 of 17, // still z1 twenty seconds later). Park it to the bottom once, now. No +2.5 s re-assert on this lane: Chrome // never self-activates an unfocused-created window (measured), so a re-assert could only undo a human click.- let parked = ctx.host.send_to_bottom_no_activate(h);- pup_core::log(format!("[window] \"{sid}\" parked at z-bottom at birth (ext lane): {parked}"));+ // the early park above already put it behind. Re-assert ONLY if it is still on top and the user has+ // not taken it: a blind re-assert here would undo a click the human made in the meantime.+ if ctx.host.foreground() != h { let _ = ctx.host.send_to_bottom_no_activate(h); } activity::on_open(ctx, sid, h, caller); let _ = activity::clear_attention(ctx, h, caller); } if let Ok(lane) = transport::lane_for(ctx, &s) { let mut s2 = s.clone(); transport::refresh_all(&lane, &mut s2); ctx.sessions.insert(s2.clone()); ctx.sessions.save_file(&s2); s = s2; }+ // 2.0.621: no focus handback anywhere. pup-user-foreground deleted it in August because it foregrounded+ // the wrong window; R2: if a window fronts anyway, let the flaw exist and log it, never fight it. // Get the icon right AT BIRTH, not on a later tick (John, 2026-09-14). The birth wait can bind the // transient about:blank launch window Chrome replaces; the stamp above then lands on a dead handle. // Now that the real page is up: if the bound hwnd is not a live window, bind the live lane window@@ -1595,7 +1710,12 @@ pub fn open_window_ext(ctx: &Ctx, caller: &Caller, args: &Value, sid: &str, owne if let Some(h) = extdrive::lane_hwnd_for(ctx, &s) { ctx.sessions.with_mut(sid, |x| x.hwnd = Some(h)); s.hwnd = Some(h); ctx.sessions.save_file(&s); identity::stamp(ctx, sid, caller, true); activity::on_open(ctx, sid, h, caller);- pup_core::log(format!("[ext] birth bound a transient window; rebound \"{sid}\" to the live window {h} and stamped it"));+ // 2.0.628: the birth park ran on the placeholder, so this window has never been parked. Measured: it+ // came up on top and stayed there (the placeholder was rebound 6 s after the park, after every+ // re-assert had fired on the dead handle). Park it NOW and re-assert from here.+ let parked = ctx.host.send_to_bottom_no_activate(h);+ pup_core::log(format!("[ext] birth bound a transient window; rebound \"{sid}\" to the live window {h}, stamped it and parked it at z-bottom: {parked}"));+ schedule_birth_reassert(ctx, sid, "late rebind"); } } let t = s.active().cloned().unwrap_or(t);
rust/verbs/src/park.rs+6−1@@ -41,7 +41,12 @@ pub fn bottom_unless_foreground(ctx: &Ctx, sid: &str, force: bool) -> &'static s let idle = ctx.host.idle_ms().unwrap_or(u64::MAX); let since_birth = pup_core::now_ms().saturating_sub(s.born_at_ms); let remembered = s.user_active_at_ms > 0 && pup_core::now_ms().saturating_sub(s.user_active_at_ms) < USER_MEMORY_MS;- let chrome_self_raise = in_birth_grace(&s) && !crate::transport::is_ext(&s) && !remembered && idle >= since_birth;+ // 2.0.628: the extension lane is no longer excluded. The exclusion rested on "Chrome never self-activates+ // an unfocused-created window on this lane", which is true for a warm birth and FALSE for the first+ // window of a freshly started browser: measured 2026-09-18, the cold-birth re-assert answered+ // "foreground" with no input since birth, while a Notepad window held the keyboard a second earlier.+ // The idle-hands rule is lane-independent: no input since birth means Chrome raised it, not a person.+ let chrome_self_raise = in_birth_grace(&s) && !remembered && idle >= since_birth; if chrome_self_raise { pup_core::log(format!("[park] \"{sid}\" is foreground {since_birth} ms after birth with no input since birth: Chrome's self-raise, bottoming")); } else {
rust/verbs/src/transport.rs+5@@ -40,6 +40,11 @@ pub fn navigate(lane: &Lane, t: &Tab, url: &str) -> Result<(String, String, bool }, Lane::Ext => { let r = run(json!({ "action": "navigate", "url": url, "tabId": ext_id(t) }), 60)?;+ // 2.0.619: say WHICH path loaded the page. chrome.tabs.update surfaces the window (measured), so+ // Page.navigate over DevTools is preferred and a fallback to the tab API is worth seeing in the log.+ if r["navVia"].as_str() == Some("tabs") {+ pup_core::log(format!("[nav] fell back to the tab API (the window may surface): {}", r["navErr"].as_str().unwrap_or("no reason given")));+ } Ok((r["url"].as_str().unwrap_or(url).to_string(), r["title"].as_str().unwrap_or("").to_string(), true)) } }
rust/verbs/src/vault.rs+21−1@@ -16,6 +16,22 @@ pub const CAPTURE_LOGIN_JS: &str = include_str!("../assets/capture-login.js"); pub fn capture_login_script() -> String { CAPTURE_LOGIN_JS.replace("${BRIDGE_VERSION}", pup_core::version()) } +/// POST /extcred from the extension lane's capture script (2.0.602). Same record shape as the CDP+/// binding, minus a CDP session id: the session hint is resolved by host instead.+pub fn on_capture_ext(ctx: &Ctx, raw: &str) {+ if ctx.settings.get("captureLogins", "on") == "off" { return; }+ let rec: Value = serde_json::from_str(raw).unwrap_or(json!({}));+ let (host, user, pass) = (rec["host"].as_str().unwrap_or(""), rec["u"].as_str().unwrap_or(""), rec["p"].as_str().unwrap_or(""));+ if host.is_empty() || pass.is_empty() { return; }+ if let Some(c) = ctx.vault.get_for_url(&*ctx.host, &format!("https://{host}/"), if user.is_empty() { None } else { Some(user) }) {+ if c.password == pass && (user.is_empty() || c.username == user) { ctx.vault.touch_used_user(&c.host, &c.username); return; }+ }+ match ctx.vault.set(&*ctx.host, host, if user.is_empty() { "(unknown)" } else { user }, pass) {+ Ok(_) => pup_core::log(format!("[cred] captured login for {host}{} (extension lane) → OS keychain", if user.is_empty() { String::new() } else { format!(" ({user})") })),+ Err(e) => pup_core::log(format!("[cred] capture store failed: {e}")),+ }+}+ /// Runtime.bindingCalled "__pupCredCapture" from the capture script: store the login. pub fn on_capture(ctx: &Ctx, sid_cdp: &str, payload: &str) { if ctx.settings.get("captureLogins", "on") == "off" { return; }@@ -281,7 +297,11 @@ fn login_ext(ctx: &Ctx, caller: &Caller, args: &Value, s: Session) -> Value { (Some(u), Some(p)) if !p.is_empty() => { if args["save"].as_bool() == Some(true) { let _ = ctx.vault.set(&*ctx.host, &live_host, u, p); } (u.into(), p.into(), "args".into()) } _ => match ctx.vault.get_for_url(&*ctx.host, &cur_url, args["preferAccount"].as_str()) { Some(c) => (c.username, c.password, format!("vault:{}", c.host)),- None => return err("no_credential", format!("no saved login matches {live_host}"), Some(IMPORT_HINT)),+ None => {+ crate::google::toast_waiting(ctx, caller, &s.id, "pup needs a login it does not have",+ &format!("Sign in to {live_host} once in the pup window (click here to open it) and pup will remember it."));+ return err("no_credential", format!("no saved login matches {live_host}"), Some(IMPORT_HINT));+ } }, }; let submit = args["submit"].as_bool() == Some(true);
rust/verbs/src/window.rs+8−1@@ -32,7 +32,14 @@ pub fn raise(ctx: &Ctx, caller: &Caller, args: &Value) -> Value { let ms: u64 = ctx.settings.get("fgCaptionMs", "3500").parse().unwrap_or(3500); let _ = ctx.ab.call("desktop_caption", json!({ "text": format!("{thread}: {reason}"), "duration": ms, "expiresInMs": ms, "position": "bottom", "size": "medium", "id": format!("pup-fg-{}", s.id) }), caller, AB_T); }- let _ = ctx.ab.call("notify_user", json!({ "title": format!("pup: {thread} is showing you a window"), "body": reason, "focus": false, "level": "info" }), caller, AB_T);+ // 2.0.604 (John, 2026-09-17: "why do you keep shoving windows in front of me without warning me?+ // that's super rude" / "i realize you just put an ab toast up warning me, but that was like a minute+ // after you shoved the window in my face while i was busy typing in another window"). The toast used to+ // carry focus:false, so clicking it did nothing, and it landed in the same instant as the window, which+ // is not a warning at all. Now it CARRIES THE WINDOW (a click raises it) and the raise waits a beat+ // behind it, so the notification reaches the screen first.+ let _ = ctx.ab.call("notify_user", json!({ "title": format!("pup: {thread} needs this window on your screen"), "body": reason, "focus": { "hwnd": h }, "level": "warning" }), caller, AB_T);+ std::thread::sleep(Duration::from_millis(1200)); 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); }
src/extension/birth.htmladded+9@@ -0,0 +1,9 @@+<!doctype html>+<!-- pup birth page (2.0.630). Every extension-lane window is CREATED on this page, minimized, with a unique+ token in the query string; the page's <title> is that token, so the OS window title is+ "PUPBIRTH-<token> - Google Chrome for Testing" from the first instant, and pup binds the window by that+ title alone: deterministic, no set-diff, no geometry (pup-no-wiggle, pup-window-targeting R4). The real+ URL is navigated to only after the window is bound and parked at the bottom of the z-order. -->+<html><head><meta charset="utf-8"><title>PUPBIRTH</title>+<style>html,body{margin:0;background:#161b22;color:#8b949e;font:14px system-ui}</style>+<script src="birth.js"></script></head><body></body></html>
src/extension/birth.jsadded+5@@ -0,0 +1,5 @@+// pup birth page script (2.0.632). Lives in its own file because an extension page's default content+// security policy refuses inline scripts: an inline version left every title at plain "PUPBIRTH", so pup's+// token lookup timed out on every open and the window showed for the 3 s it waited (measured, 2.0.631).+const t = new URLSearchParams(location.search).get('token') || '';+if (t) document.title = 'PUPBIRTH-' + t;
src/extension/capture-login.jsadded+64@@ -0,0 +1,64 @@+// pup login capture on the EXTENSION lane (2.0.602).+//+// John, 2026-09-17, after typing his Google password into a pup window and finding pup had not kept it:+// "are you fucking kidding me? i did type in my fucking password ... the whole point of pup is that it+// makes my life easier and i stop having to type in damn passwords".+//+// The capture rail existed ONLY on the CDP lane (Page.addScriptToEvaluateOnNewDocument plus the+// __pupCredCapture binding, installed in lib.rs tab-setup and recovery). The extension lane has no CDP+// page instrumentation, and it is the DEFAULT lane, so every login typed in a normal pup window was+// thrown away. This is the same detection logic as rust/verbs/assets/capture-login.js, delivering through+// the service worker instead of a DevTools binding.+//+// Runs in the ISOLATED world (chrome.runtime is needed to reach the worker); it still reads the page's+// DOM, so input.value is available. The MAIN-world HTMLFormElement.prototype.submit hook from the CDP+// copy cannot be installed from here, so a purely programmatic form.submit() is covered by the blur,+// change, keydown and pagehide nets instead.+(() => {+ if (window !== window.top) return;+ if (window.__pupExtCapV === '1') return;+ window.__pupExtCapV = '1';+ const vis = (el) => !!el && !el.disabled && el.offsetParent !== null && el.getClientRects().length > 0;+ const isPwd = (el) => el && el.tagName === 'INPUT' && (el.type === 'password' || el.autocomplete === 'current-password' || el.autocomplete === 'new-password' || /(^|[^a-z])password([^a-z]|$)/i.test(el.name || '') || /(^|[^a-z])password([^a-z]|$)/i.test(el.id || ''));+ let last = '';+ function grab(form) {+ try {+ const scope = form || document;+ const pwd = [...scope.querySelectorAll('input')].filter(vis).filter(isPwd)[0];+ if (!pwd || !pwd.value) return;+ const all = [...scope.querySelectorAll('input')];+ const pi = all.indexOf(pwd);+ let user = [...scope.querySelectorAll('input[autocomplete=username], input[type=email]')].filter(vis).filter((e) => !isPwd(e))[0];+ if (!user) {+ const cands = [...scope.querySelectorAll('input[type=text], input[type=tel], input:not([type])')].filter(vis).filter((e) => !isPwd(e));+ user = cands.slice().reverse().filter((c) => all.indexOf(c) < pi)[0] || cands[0] || null;+ }+ // Google asks for the email on one page and the password on the next, so the password page has no+ // username field at all. Take the address Google prints on it (measured on the real sign-in page).+ let u = user ? user.value : '';+ if (!u && /(^|\.)google\.com$/.test(location.hostname)) {+ const m = (document.body.innerText || '').match(/[\w.+-]+@[\w.-]+\.\w+/);+ if (m) u = m[0];+ }+ const rec = { host: location.hostname, u, p: pwd.value, ts: Date.now() };+ const key = rec.host + '|' + rec.u + '|' + rec.p;+ if (key === last) return; // blur then submit then pagehide all fire for one login+ last = key;+ chrome.runtime.sendMessage({ t: 'pup-cred', rec }, () => void chrome.runtime.lastError);+ } catch (e) {}+ }+ const formHasPwd = (f) => { try { return !!f && [...f.querySelectorAll('input')].some(isPwd); } catch (e) { return false; } };+ addEventListener('submit', (e) => grab(e.target && e.target.tagName === 'FORM' ? e.target : null), true);+ addEventListener('click', (e) => {+ try {+ const t = e.target && e.target.closest && e.target.closest('button, input[type=submit], [role=button], a');+ if (!t) return;+ const f = t.form || (t.closest && t.closest('form'));+ if (formHasPwd(f)) grab(f); else if (!f) grab(null);+ } catch (e2) {}+ }, true);+ addEventListener('change', (e) => { try { if (isPwd(e.target) && e.target.value) grab(e.target.form || null); } catch (e2) {} }, true);+ addEventListener('blur', (e) => { try { if (isPwd(e.target) && e.target.value) grab(e.target.form || null); } catch (e2) {} }, true);+ addEventListener('keydown', (e) => { try { if (e.key === 'Enter' && isPwd(document.activeElement) && document.activeElement.value) grab(document.activeElement.form || null); } catch (e2) {} }, true);+ addEventListener('pagehide', () => { try { grab(null); } catch (e2) {} }, true);+})();
src/extension/manifest.json+1−1@@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Adom Pup",- "version": "0.4.4",+ "version": "0.4.14", "description": "Toolbar for Adom Pup browser windows: annotate, snip, view toggle, dashboard, owner info. Thin trigger surface - the pup bridge is the engine.", "action": { "default_popup": "popup.html",
src/extension/worker.js+57−6@@ -84,6 +84,16 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { sendResponse({ enabled: !!found && tb.enabled !== false && tb.floaty !== false }); return; }+ if (msg && msg.t === 'pup-cred' && msg.rec) {+ const found = await findPort();+ if (found) {+ await fetch(`http://127.0.0.1:${found.port}/extcred?lane=${encodeURIComponent(_pkgLane || '')}`, {+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(msg.rec),+ }).catch(() => {});+ }+ sendResponse({ ok: true });+ return;+ } if (msg && msg.t === 'pup-annotate') { const found = await findPort(); if (!found || !sender.tab || !sender.tab.url) { sendResponse({ ok: false }); return; }@@ -104,6 +114,11 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { chrome.tabs.onUpdated.addListener((tabId, info, tab) => { if (info.status !== 'complete' || !tab || !tab.url || !/^https?:/.test(tab.url)) return; chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] }).catch(() => {});+ // 2.0.602: the login-capture rail. It lived only on the CDP lane, so a password typed in a normal+ // (extension-lane) pup window was never stored: pup's whole "sign in once and I remember" promise was+ // off on the DEFAULT lane. Injected the same way as content.js because declarative content_scripts do+ // not fire under --load-extension (see the note above).+ chrome.scripting.executeScript({ target: { tabId }, files: ['capture-login.js'] }).catch(() => {}); }); chrome.commands.onCommand.addListener(async (command) => {@@ -226,16 +241,48 @@ async function pupExtPollOnce() { out = await Promise.race([deadline, (async () => { let out = { id: cmd.id, ok: false, error: `unsupported action ${cmd.action}` }; if (cmd.action === 'open') {- const w = await chrome.windows.create({ url: cmd.url || 'about:blank', focused: false });+ // 2.0.621: R1 of pup-bridge-dev, the rule the CDP lane has followed since v2.0.276 and this lane never+ // got: BIRTH OFF-SCREEN, ON-SCREEN WITHIN 50 ms, UNCONDITIONALLY. Chrome does not activate a window+ // that is nowhere, so nothing is stolen; the move is immediate and never gated on a proof, so nothing+ // can strand. Normal state, not minimized (pup-user-foreground rule 1: minimized-by-pup is a state the+ // user reads as "put away"). The move happens HERE, in the same async stretch as the create, so it is+ // one Chrome call away instead of a poll-channel round trip. Some boxes clamp the coordinate+ // on-screen; that is fine, --no-startup-window keeps the browser without foreground rights (R2).+ // 2.0.624: this lane's R1. The extension API REFUSES an off-screen birth ("Bounds must be at least+ // 50% within visible screen space"), and 2.0.622 measured that an in-place visible birth ends up ON TOP+ // and stays there even unfocused with --no-startup-window (42 of 52 samples), so R2's promise does not+ // hold here. Born MINIMIZED instead, at its final rect: Chrome cannot raise or activate a minimized+ // window, pup shows it at the very bottom of the z-order the moment it has the handle (measured 617:+ // behind the pinned window, z1), and unlike off-screen it can never strand, because a taskbar click+ // restores a minimized window with no help from pup. A transient, never a resting state.+ // No bounds with the minimized state: Chrome refuses the combination (2.0.624 measured every open+ // failing at creation once a rect was added; 2.0.610 to 617, minimized with no rect, worked). The window+ // gets its work-area rect from pup's fit step right after birth, as every lane window always has.+ // 2.0.630: born on the extension's own birth page with a unique token as its TITLE, so pup can bind the+ // OS window by title from the first instant (pup-window-targeting R4: a unique transient title, never a+ // set-diff). The set-diff bound a placeholder window Chrome makes at launch and parked THAT, while the+ // real window came up unparked (measured: 4.5 s on top on every cold start). The caller navigates to+ // the real URL after the window is bound and parked; `url` here only says where it will go.+ const token = Math.random().toString(36).slice(2, 10) + Date.now().toString(36);+ const w = await chrome.windows.create({ url: chrome.runtime.getURL('birth.html') + '?token=' + token, focused: false, state: 'minimized' });+ const birthMoveMs = 0; const tab = w.tabs && w.tabs[0];- if (tab) await pupExtWaitComplete(tab.id, cmd.waitMs || 30000);- out = { id: cmd.id, ok: true, tabId: tab && tab.id, windowId: w.id };+ if (tab) await pupExtWaitComplete(tab.id, 8000);+ out = { id: cmd.id, ok: true, tabId: tab && tab.id, windowId: w.id, birthMoveMs, token, target: cmd.url || 'about:blank' }; } else if (cmd.action === 'navigate') { const tab = await pupExtTab(cmd);- await chrome.tabs.update(tab.id, { url: cmd.url });+ // 2.0.618 (John, 2026-09-18: "my orig window comes back cuz i alt_tab immediately you idiot"). MEASURED+ // on the test box with a window pinned in front: the pup window sat at z1, behind it, and ONE+ // chrome.tabs.update({url}) put it at z0, on top, where every later call inherited it. That is why a+ // sign-in kept flashing at him: the flow navigates several times. Page.navigate over the DevTools+ // channel loads the page without going through Chrome's tab UI, so the window is never surfaced.+ // chrome.tabs.update stays as the fallback for a tab the debugger cannot attach to.+ let navVia = 'page', navErr = '';+ try { await nbe.raw(tab.id, 'Page.navigate', { url: cmd.url }); }+ catch (e) { navVia = 'tabs'; navErr = String((e && e.message) || e).slice(0, 120); await chrome.tabs.update(tab.id, { url: cmd.url }); } await pupExtWaitComplete(tab.id, cmd.waitMs || 30000); const t2 = await chrome.tabs.get(tab.id);- out = { id: cmd.id, ok: true, tabId: tab.id, windowId: t2.windowId, url: t2.url, title: t2.title };+ out = { id: cmd.id, ok: true, tabId: tab.id, windowId: t2.windowId, url: t2.url, title: t2.title, navVia, navErr }; } else if (cmd.action === 'eval') { const tab = await pupExtTab(cmd); const res = await chrome.scripting.executeScript({@@ -435,7 +482,11 @@ async function pupExtPollOnce() { try { // 2.0.584: the standard inset frame (pup-bridge-dev: work area inset 10 px on every side), so a parked // window reads as deliberately placed, never as a maximize that lost. z-order untouched.- await chrome.windows.update(w.id, { state: 'normal', left: wa.left + 10, top: wa.top + 10, width: Math.max(320, wa.width - 20), height: Math.max(240, wa.height - 20) });+ // 2.0.629: bounds ONLY, never state. state:'normal' RESTORES a minimized window, which is a raise+ // (pup restores its own births itself, at the bottom), and it un-minimized windows the user had put+ // away, which pup-user-foreground rule 7 forbids. A minimized window is left exactly as it is.+ if (w.state === 'minimized') continue;+ await chrome.windows.update(w.id, { left: wa.left + 10, top: wa.top + 10, width: Math.max(320, wa.width - 20), height: Math.max(240, wa.height - 20) }); fitted++; } catch (e) {} }