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.701 (insiders): Annotate starts in Multi mode: draw several shapes, press Enter to finish. Space toggles Multi and Single (the old Auto), M and S pick one, Esc cancels, and a hint under the toolbar shows the keys (John, 2026-10-06). Also: the taskbar progress bar on every page load, on the window being worked on (needs Adom Bridge 2.1.169); restored windows keep their videos paused (YouTube plays about 3 s first); notices when an update restarts pup's browser. Gates on winvm: smoke all pass, foreground pass, parity 55/56, jump list 11/14 (the user-raise check skipped by Windows' foreground lock).
8 files changed
+108−36
rust/Cargo.lock+12−12@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-pup-bridge"-version = "2.0.697"+version = "2.0.701" dependencies = [ "pup-browser", "pup-cdp",@@ -655,7 +655,7 @@ dependencies = [ [[package]] name = "pup-browser"-version = "2.0.697"+version = "2.0.701" dependencies = [ "pup-core", "pup-host",@@ -667,7 +667,7 @@ dependencies = [ [[package]] name = "pup-cdp"-version = "2.0.697"+version = "2.0.701" dependencies = [ "serde_json", "thiserror",@@ -677,7 +677,7 @@ dependencies = [ [[package]] name = "pup-core"-version = "2.0.697"+version = "2.0.701" dependencies = [ "serde", "serde_json",@@ -687,7 +687,7 @@ dependencies = [ [[package]] name = "pup-dashboard"-version = "2.0.697"+version = "2.0.701" dependencies = [ "pup-cdp", "pup-core",@@ -700,7 +700,7 @@ dependencies = [ [[package]] name = "pup-host"-version = "2.0.697"+version = "2.0.701" dependencies = [ "serde", "serde_json",@@ -709,14 +709,14 @@ dependencies = [ [[package]] name = "pup-host-mac"-version = "2.0.697"+version = "2.0.701" dependencies = [ "pup-host", ] [[package]] name = "pup-host-win"-version = "2.0.697"+version = "2.0.701" dependencies = [ "pup-core", "pup-host",@@ -726,7 +726,7 @@ dependencies = [ [[package]] name = "pup-identity"-version = "2.0.697"+version = "2.0.701" dependencies = [ "fontdue", "image",@@ -736,7 +736,7 @@ dependencies = [ [[package]] name = "pup-session"-version = "2.0.697"+version = "2.0.701" dependencies = [ "pup-core", "serde",@@ -746,7 +746,7 @@ dependencies = [ [[package]] name = "pup-vault"-version = "2.0.697"+version = "2.0.701" dependencies = [ "pup-core", "pup-host",@@ -757,7 +757,7 @@ dependencies = [ [[package]] name = "pup-verbs"-version = "2.0.697"+version = "2.0.701" 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.697"+version = "2.0.701" edition = "2021" license = "MIT"
rust/verbs/src/activity.rs+5−2@@ -66,7 +66,10 @@ pub fn with_progress<T>(ctx: &Ctx, sid: &str, op: &str, work: impl FnOnce() -> T /// session-keyed `with_progress` and the extension lane (which has no session), so the lane no longer /// carries a hand-rolled flat bar (John, 2026-09-13: "why ... you aren't sharing your own code"). pub fn progress_on_hwnd<T>(ctx: &Ctx, hwnd: i64, op: &str, work: impl FnOnce() -> T) -> T {- if !cfg!(windows) || mode(ctx) != "progress" || minimal_touch(ctx) { return work(); }+ // 2.0.699: minimalTouch no longer gates the bar. It is on by default, so this line made the default+ // "progress" activity paint nothing for anyone (John, 2026-10-05: "when do you animate the progress bar now?").+ // taskbarActivity alone decides, as the Settings doc always said the per-feature settings do.+ if !cfg!(windows) || mode(ctx) != "progress" { return work(); } let Some(arc) = ctx.arc() else { return work() }; let est = estimate_ms(ctx, op).max(200); let started = std::time::Instant::now(); let done = Arc::new(std::sync::atomic::AtomicBool::new(false)); let d2 = done.clone();@@ -91,7 +94,7 @@ pub fn progress_on_hwnd<T>(ctx: &Ctx, hwnd: i64, op: &str, work: impl FnOnce() - /// Node startIconApplyProgress: from open (earliest) until the taskbar icon actually lands (latest), /// timed to the running average of past icon applies; resolves the hwnd once it exists. pub fn start_icon_apply(ctx: Arc<Ctx>, sid: String) {- if !cfg!(windows) || mode(&ctx) != "progress" || minimal_touch(&ctx) { return; }+ if !cfg!(windows) || mode(&ctx) != "progress" { return; } // 2.0.699: not gated by minimalTouch (see progress_on_hwnd) let est = estimate_ms(&ctx, "icon-apply").max(200); std::thread::Builder::new().name("icon-apply-progress".into()).spawn(move || { let started = std::time::Instant::now(); let mut hwnd: Option<i64> = None; let mut landed = false;
rust/verbs/src/extdrive.rs+15−11@@ -207,10 +207,13 @@ pub fn reload_stale_worker(ctx: &Ctx) { for f in files { crate::recovery::relaunch_from_file(ctx, &f); } if !ids.is_empty() { let back: Vec<&String> = ids.iter().filter(|id| ctx.sessions.exists(id)).collect();- let missing: Vec<&str> = ids.iter().filter(|id| !ctx.sessions.exists(id)).map(|s| s.as_str()).collect();+ // 2.0.698: a window the user closed while it was being restored is final, not "still coming" (John closed two)+ let closed: Vec<&str> = ids.iter().filter(|id| !ctx.sessions.exists(id) && crate::recovery::restore_cancelled(id)).map(|s| s.as_str()).collect();+ let missing: Vec<&str> = ids.iter().filter(|id| !ctx.sessions.exists(id) && !crate::recovery::restore_cancelled(id)).map(|s| s.as_str()).collect(); let secs = t0.elapsed().as_secs();- if missing.is_empty() { toast(format!("pup updated to {to}"), format!("All {} window{} are back ({secs} s).", back.len(), if back.len() == 1 { "" } else { "s" })); }- else { toast(format!("pup updated to {to}"), format!("{} of {} windows are back. Not back yet: {}. pup keeps trying in the background.", back.len(), ids.len(), missing.join(", "))); }+ let you_closed = if closed.is_empty() { String::new() } else { format!(" You closed {}.", closed.join(", ")) };+ if missing.is_empty() { toast(format!("pup updated to {to}"), format!("{} window{} back ({secs} s).{you_closed}", back.len(), if back.len() == 1 { " is" } else { "s are" })); }+ else { toast(format!("pup updated to {to}"), format!("{} of {} windows are back.{you_closed} Not back yet: {}; pup keeps trying in the background.", back.len(), ids.len(), missing.join(", "))); } pup_core::log(format!("[extdrive] update cycle finished in {secs} s: {} of {} window(s) back{}", back.len(), ids.len(), if missing.is_empty() { String::new() } else { format!("; not yet: {}", missing.join(", ")) })); } }@@ -391,7 +394,7 @@ fn aim(c: &mut Value) { /// pup_open_window {url} — launch (or reuse) the extension-driven browser and open `url` in it. pub fn open(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {- let r = with_lane_progress(ctx, || open_inner(ctx, _caller, args));+ let r = with_lane_progress(ctx, _caller, args, || open_inner(ctx, _caller, args)); if r["ok"] != json!(false) { dress_after(ctx, _caller); } r }@@ -437,7 +440,7 @@ fn open_inner(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value { /// pup_navigate {url, tabId?} pub fn navigate(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {- let r = with_lane_progress(ctx, || navigate_inner(ctx, _caller, args));+ let r = with_lane_progress(ctx, _caller, args, || navigate_inner(ctx, _caller, args)); if r["ok"] != json!(false) { dress_after(ctx, _caller); } r }@@ -530,7 +533,7 @@ pub fn diag(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value { } /// pup_open_tab {url} — open a TAB in the extension-driven window (the lane could only make new windows). pub fn tab(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {- let r = with_lane_progress(ctx, || tab_inner(ctx, _caller, args));+ let r = with_lane_progress(ctx, _caller, args, || tab_inner(ctx, _caller, args)); if r["ok"] != json!(false) { dress_after(ctx, _caller); } r }@@ -805,11 +808,12 @@ pub(crate) fn lane_hwnd(ctx: &Ctx) -> Option<i64> { /// bars in the icon. and why do i not see the overlay icon"). Same ab calls activity.rs makes: /// an indeterminate bar while a command runs, cleared when it ends; taskbarActivity off/flash and /// minimalTouch are honoured exactly as there.-fn with_lane_progress<T>(ctx: &Ctx, work: impl FnOnce() -> T) -> T {- // ONE progress implementation, in activity.rs. This is just the lane's entry point: find the lane- // window and hand it the same filling estimated bar the CDP lane gets. My first cut hand-rolled a- // flat 35% bar and threw ab's reply away; deleted.- match lane_hwnd(ctx) {+fn with_lane_progress<T>(ctx: &Ctx, caller: &Caller, args: &Value, work: impl FnOnce() -> T) -> T {+ // ONE progress implementation, in activity.rs. This is just the lane's entry point. 2.0.699: the bar goes+ // on the window the command is FOR. It used to go on lane_hwnd, the first window Windows listed, so with+ // several threads' windows open it painted another thread's button. A window being born has no handle+ // yet: no bar here (start_icon_apply covers a new window's first seconds).+ match crate::resolve(ctx, caller, args).ok().and_then(|s| s.hwnd) { Some(hwnd) => crate::activity::progress_on_hwnd(ctx, hwnd, "ext-drive", work), None => work(), }
rust/verbs/src/lib.rs+14−6@@ -597,8 +597,8 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value { "pup_status" => pup_core::merge(status(ctx), json!({ "extensionLane": extdrive::lane_health(ctx) })), "pup_list_windows" => list_windows(ctx), "pup_my_windows" | "pup_list_sessions" => my_windows(ctx, caller),- "pup_open_window" => open_window(ctx, caller, args),- "pup_navigate" => navigate(ctx, caller, args),+ "pup_open_window" => with_bar(ctx, caller, args, "open", || open_window(ctx, caller, args)),+ "pup_navigate" => with_bar(ctx, caller, args, "navigate", || navigate(ctx, caller, args)), "pup_eval" | "pup_evaluate" => eval(ctx, caller, args), // frame: handled inside eval "pup_screenshot" => { match resolve(ctx, caller, args) { Ok(s) => { let sid = s.id.clone(); activity::with_progress(ctx, &sid, "screenshot", || screenshot(ctx, caller, args)) } Err(_) => screenshot(ctx, caller, args) } }, "pup_close_window" | "pup_close" => close_window(ctx, caller, args),@@ -606,7 +606,7 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value { // supported equivalent. close_window already implements {all:true} = the caller's own windows. "pup_close_all_windows" => { let mut a = args.clone(); if let Some(m) = a.as_object_mut() { m.insert("all".into(), json!(true)); } close_window(ctx, caller, &a) } "pup_window_info" => window_info(ctx, caller, args),- "pup_open_tab" => tabs::open_tab(ctx, caller, args),+ "pup_open_tab" => with_bar(ctx, caller, args, "open-tab", || tabs::open_tab(ctx, caller, args)), "pup_list_tabs" => tabs::list_tabs(ctx, caller, args), "pup_switch_tab" => tabs::switch_tab(ctx, caller, args), "pup_close_tab" => tabs::close_tab(ctx, caller, args),@@ -619,9 +619,9 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value { "pup_fill" => input::fill(ctx, caller, args), "pup_select" => input::select(ctx, caller, args), "pup_wait" => input::wait(ctx, caller, args),- "pup_reload" => input::reload(ctx, caller, args),- "pup_back" => input::history(ctx, caller, args, true),- "pup_forward" => input::history(ctx, caller, args, false),+ "pup_reload" => with_bar(ctx, caller, args, "navigate", || input::reload(ctx, caller, args)),+ "pup_back" => with_bar(ctx, caller, args, "navigate", || input::history(ctx, caller, args, true)),+ "pup_forward" => with_bar(ctx, caller, args, "navigate", || input::history(ctx, caller, args, false)), "pup_set_viewport" => input::set_viewport(ctx, caller, args), "pup_errors" => input::errors(ctx, caller, args), "pup_dialog" => input::dialog(ctx, caller, args),@@ -832,6 +832,14 @@ pub fn session_for_tab_url(ctx: &Ctx, url: &str) -> Option<(Session, Tab)> { best.map(|(_, s, t)| (s, t)) } +/// 2.0.699: the taskbar progress bar on the window a page-loading verb works in. Only pup_screenshot was ever+/// wrapped here; the extension-lane wrapper sat on a route these verbs no longer take, so open, navigate, new+/// tab, reload, back and forward painted nothing (John, 2026-10-05). A window with no handle yet (being born)+/// gets no bar; start_icon_apply covers its first seconds.+fn with_bar(ctx: &Ctx, caller: &Caller, args: &Value, op: &str, work: impl FnOnce() -> Value) -> Value {+ match resolve(ctx, caller, args) { Ok(s) => activity::with_progress(ctx, &s.id, op, work), Err(_) => work() }+}+ pub fn resolve(ctx: &Ctx, caller: &Caller, args: &Value) -> Result<Session, Value> { let explicit = window_arg(args); let id = match explicit.clone() {
rust/verbs/src/recovery.rs+12@@ -243,6 +243,18 @@ pub fn relaunch_from_file(ctx: &Ctx, f: &Value) { } } }+ // 2.0.698: a restored window reloads every page at once, and every video in them autoplayed together ("i get like+ // 10 videos playing all at the same time", John 2026-10-04). Pause media that starts on its own in the restored+ // tabs for 30 s; anything the user starts by clicking plays normally.+ if r["ok"] == json!(true) {+ if let Some(wid) = ctx.sessions.get(&id).filter(|s| crate::transport::is_ext(s)).and_then(|s| s.window_id) {+ let _scope = ProfileScope::enter(&profile);+ match crate::extdrive::run(json!({ "action": "quietMedia", "windowId": wid, "ms": 30000, "timeoutMs": 40000 }), Duration::from_secs(45)) {+ Ok(q) => pup_core::log(format!("[recovery] \"{id}\": restored tabs keep their media paused for 30 s ({} tab(s) guarded, {} paused at once)", q["guarded"], q["paused"])),+ Err(e) => pup_core::log(format!("[recovery] \"{id}\": could not quiet the restored tabs: {}", e["error"].as_str().unwrap_or("?"))),+ }+ }+ } // 2.0.471: a relaunch that WORKED resets the counter. It counted every relaunch in the bridge's life, so a window // brought back twice (a wake, a crash) was refused the third time as "failed to come back twice" (winvm drill, // 2026-09-11: "drill-b" back with 0 of 3 after pup_restart_browser). Only consecutive failures count now.
src/extension/annotate-overlay.js+25−4@@ -10,7 +10,8 @@ window.__pupStartAnnotate = function (initTool, initMode) { // v2.0.370 (John): two explicit capture modes. quick=true (Auto, the default) captures the // moment the mouse lifts — the one-red-circle case. quick=false (Multi) accumulates shapes — // a red circle here, a blue circle there — and captures ONCE on Done.- var S = { tool: 'pen', color: '#f85149', strokes: [], cur: null, quick: initMode !== 'multi', seq: 0 };+ // 2.0.701 (John, 2026-10-06): MULTI is the default; Single (the old Auto) is one keypress away.+ var S = { tool: 'pen', color: '#f85149', strokes: [], cur: null, quick: initMode === 'auto', seq: 0 }; var wrap = document.createElement('div'); wrap.id = '__pup_annotate'; wrap.style.cssText = 'position:fixed;inset:0;z-index:2147483646;pointer-events:none;';@@ -263,11 +264,19 @@ window.__pupStartAnnotate = function (initTool, initMode) { modeWrap.appendChild(b); tipFor(b, tip); return b; }- var mAuto = modeBtn('Auto', 'auto', true, 'One-shot (the default): draw one thing, lift the mouse, and it is captured and copied to your clipboard instantly.');- var mMulti = modeBtn('Multi', 'multi', false, 'Compose: draw several shapes and NOTHING is captured until you click Done. The color auto-advances after each shape (red, blue, yellow, green, purple) so every shape is distinct — click a swatch any time to override.');+ var mAuto = modeBtn('Single', 'auto', true, 'Single (S): draw one thing, lift the mouse, and it is captured and copied to your clipboard instantly. Space switches back to Multi.');+ var mMulti = modeBtn('Multi', 'multi', false, 'Multi (the default, M): draw several shapes and NOTHING is captured until you press Enter or click Done. The color auto-advances after each shape (red, blue, yellow, green, purple) so every shape is distinct — click a swatch any time to override.');+ // 2.0.701: a one-line hint under the toolbar that says how to finish in the current mode and how to switch.+ var hint = document.createElement('div');+ hint.style.cssText = 'position:absolute;top:100%;left:50%;transform:translateX(-50%);margin-top:6px;white-space:nowrap;background:#161b22e6;border:1px solid #30363d;border-radius:999px;padding:3px 11px;font:12px Satoshi,system-ui,sans-serif;color:#8b949e;pointer-events:none;';+ bar.style.position = bar.style.position || 'fixed'; bar.appendChild(hint);+ function kbd(k) { return '<b style="color:#c9d1d9;font-weight:600">' + k + '</b>'; } function paintMode() { mAuto.style.background = S.quick ? 'rgba(0,184,177,.13)' : 'none'; mAuto.style.color = S.quick ? '#00d4cb' : '#c9d1d9'; mMulti.style.background = !S.quick ? 'rgba(0,184,177,.13)' : 'none'; mMulti.style.color = !S.quick ? '#00d4cb' : '#c9d1d9';+ hint.innerHTML = S.quick+ ? 'Single: lift the mouse to capture \u00b7 ' + kbd('Space') + ' for Multi \u00b7 ' + kbd('Esc') + ' to cancel'+ : 'Multi: ' + kbd('Enter') + ' to finish \u00b7 ' + kbd('Space') + ' for Single \u00b7 ' + kbd('Esc') + ' to cancel'; } paintMode(); bar.appendChild(modeWrap); btn('Undo', 'Remove the last thing you drew. Click again to keep unwinding.', function () { S.strokes.pop(); redraw(); }, null, 'undo');@@ -276,7 +285,19 @@ window.__pupStartAnnotate = function (initTool, initMode) { done.style.cssText += 'background:rgba(0,184,177,.13);border:1px solid rgba(0,184,177,.55);color:#00d4cb;font-weight:600;'; btn('', 'Close the annotation toolbar and remove all drawings from the page (Esc does this too).', function () { exitAll(); }, null, 'cancel'); function exitAll() { disarmKeys(); wrap.remove(); window.__pupAnnotate = null; window.__pupAnnotateResult = { done: false, ts: Date.now() }; }- function onKey(e) { if (e.key === 'Escape' || e.keyCode === 27) { e.preventDefault(); e.stopPropagation(); exitAll(); } }+ // 2.0.701 (John): Enter finishes, Space toggles Multi/Single, M and S pick one. Esc still cancels. Keys with a+ // modifier held are left to the page and the browser.+ function onKey(e) {+ if (e.key === 'Escape' || e.keyCode === 27) { e.preventDefault(); e.stopPropagation(); exitAll(); return; }+ if (e.ctrlKey || e.metaKey || e.altKey) return;+ var k = (e.key || '').toLowerCase(), hit = true;+ if (k === 'enter') { if (S.strokes.length) emit(null, true); }+ else if (k === ' ' || k === 'spacebar') { S.quick = !S.quick; paintMode(); }+ else if (k === 'm') { S.quick = false; paintMode(); }+ else if (k === 's') { S.quick = true; paintMode(); }+ else hit = false;+ if (hit) { e.preventDefault(); e.stopPropagation(); }+ } // Esc reaches the overlay wherever focus sits: window and document in the capture phase, the canvas itself, // and the canvas takes focus when armed (a focused iframe would otherwise keep the key to itself). function armKeys() { addEventListener('keydown', onKey, true); document.addEventListener('keydown', onKey, true); cv.addEventListener('keydown', onKey, true); try { cv.tabIndex = -1; cv.style.outline = 'none'; cv.focus({ preventScroll: true }); } catch (e) {} }
src/extension/worker.js+24@@ -429,6 +429,30 @@ async function pupExtPollOnce() { await new Promise((r) => setTimeout(r, 350)); const cap = await pupCaptureTab(t); out = { id: cmd.id, ok: true, tabId: t.id, dataUrl: cap.dataUrl, via: cap.via };+ } else if (cmd.action === 'quietMedia') {+ // 2.0.698: a restored window reloads every page at once and every video autoplayed together. For `ms` after+ // each tab loads, pause media that starts on its own; once the user has clicked or typed in the page, leave it.+ const ms = Number(cmd.ms) || 30000;+ const tabs = await chrome.tabs.query({ windowId: cmd.windowId });+ const guard = (span) => {+ if (window.__pupQuiet) return 0;+ window.__pupQuiet = true;+ const stopAt = Date.now() + span;+ const user = () => !!(navigator.userActivation && navigator.userActivation.hasBeenActive);+ const pauseAll = () => { let n = 0; document.querySelectorAll('video,audio').forEach((m) => { if (!m.paused) { try { m.pause(); n++; } catch (e) {} } }); return n; };+ const onPlay = (e) => { if (Date.now() > stopAt || user()) { document.removeEventListener('play', onPlay, true); return; } try { e.target.pause(); } catch (x) {} };+ document.addEventListener('play', onPlay, true);+ const iv = setInterval(() => { if (Date.now() > stopAt || user()) { clearInterval(iv); return; } pauseAll(); }, 500);+ return pauseAll();+ };+ const results = await Promise.all(tabs.map(async (t) => {+ try {+ if (t.status !== 'complete') await pupExtWaitComplete(t.id, 15000);+ const r = await chrome.scripting.executeScript({ target: { tabId: t.id, allFrames: true }, func: guard, args: [ms] });+ return (r || []).reduce((a, x) => a + (Number(x && x.result) || 0), 0);+ } catch (e) { return -1; }+ }));+ out = { id: cmd.id, ok: true, guarded: results.filter((x) => x >= 0).length, paused: results.filter((x) => x > 0).reduce((a, x) => a + x, 0) }; } else if (cmd.action === 'tabs') { const tabs = await chrome.tabs.query({}); out = { id: cmd.id, ok: true, tabs: tabs.map((t) => ({ tabId: t.id, url: t.url, title: t.title, active: !!t.active, windowId: t.windowId, favIconUrl: t.favIconUrl || null, openerTabId: t.openerTabId ?? null })) };