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.694 (insiders): No more Chrome for Testing icons on the taskbar: a window that appears in pup's browser without pup opening it (a tab dragged out of a pup window, a page opening a new window) is now adopted by the thread it came from and wears that thread's tile (John, 2026-10-04). Tab ids survive a pup restart: a re-adopted window keeps the ids its tabs had instead of renumbering from tab-1, and a new tab never reuses an id already in the window (an AI's eval landed on the wrong tab). Tested on arav-rog: a page popup and a dragged-out tab were both adopted and stamped within 30 s; ids held across a restart.
6 files changed
+101−17
rust/Cargo.lock+12−12@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-pup-bridge"-version = "2.0.693"+version = "2.0.694" dependencies = [ "pup-browser", "pup-cdp",@@ -655,7 +655,7 @@ dependencies = [ [[package]] name = "pup-browser"-version = "2.0.693"+version = "2.0.694" dependencies = [ "pup-core", "pup-host",@@ -667,7 +667,7 @@ dependencies = [ [[package]] name = "pup-cdp"-version = "2.0.693"+version = "2.0.694" dependencies = [ "serde_json", "thiserror",@@ -677,7 +677,7 @@ dependencies = [ [[package]] name = "pup-core"-version = "2.0.693"+version = "2.0.694" dependencies = [ "serde", "serde_json",@@ -687,7 +687,7 @@ dependencies = [ [[package]] name = "pup-dashboard"-version = "2.0.693"+version = "2.0.694" dependencies = [ "pup-cdp", "pup-core",@@ -700,7 +700,7 @@ dependencies = [ [[package]] name = "pup-host"-version = "2.0.693"+version = "2.0.694" dependencies = [ "serde", "serde_json",@@ -709,14 +709,14 @@ dependencies = [ [[package]] name = "pup-host-mac"-version = "2.0.693"+version = "2.0.694" dependencies = [ "pup-host", ] [[package]] name = "pup-host-win"-version = "2.0.693"+version = "2.0.694" dependencies = [ "pup-core", "pup-host",@@ -726,7 +726,7 @@ dependencies = [ [[package]] name = "pup-identity"-version = "2.0.693"+version = "2.0.694" dependencies = [ "fontdue", "image",@@ -736,7 +736,7 @@ dependencies = [ [[package]] name = "pup-session"-version = "2.0.693"+version = "2.0.694" dependencies = [ "pup-core", "serde",@@ -746,7 +746,7 @@ dependencies = [ [[package]] name = "pup-vault"-version = "2.0.693"+version = "2.0.694" dependencies = [ "pup-core", "pup-host",@@ -757,7 +757,7 @@ dependencies = [ [[package]] name = "pup-verbs"-version = "2.0.693"+version = "2.0.694" 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.693"+version = "2.0.694" edition = "2021" license = "MIT"
rust/session/src/lib.rs+13−2@@ -82,7 +82,11 @@ impl Session { 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(), transport: "cdp".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, window_id: None, tabless_since_ms: 0, 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, overlay_why: None, overlay_led: None, last_paint_sig: None, last_real_fav: None, generic_retried_url: None, 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 }+ /// 2.0.694: never hands out an id a tab in this window already holds (a window rebuilt after a restart+ /// keeps its saved ids, and an AI that held "tab-3" must not get a different tab under that name).+ pub fn next_tab_id(&mut self) -> String {+ loop { let id = format!("tab-{}", self.next_tab_seq); self.next_tab_seq += 1; if !self.tabs.iter().any(|t| t.tab_id == id) { return id; } }+ } pub fn active(&self) -> Option<&Tab> { self.tabs.get(self.active_tab) } pub fn tab_by_id(&self, id: &str) -> Option<&Tab> { self.tabs.iter().find(|t| t.tab_id == id) } pub fn age_minutes(&self) -> u64 { pup_core::now_ms().saturating_sub(self.created_ms) / 60000 }@@ -99,7 +103,9 @@ impl Session { // 2.0.461: EVERY tab, not just the active one. The file used to carry a single url, so when a // window came back (a Chrome restart, a wedged browser, a crash) the other tabs were simply gone: // John lost 19 of 20 component tabs that way on 2026-09-10 and I had to retype them from a log.- "tabs": self.tabs.iter().map(|t| json!({ "url": t.url, "title": t.title })).collect::<Vec<_>>(),+ // 2.0.694: and each tab's pup id plus the browser's own tab id, so a window re-adopted after a restart+ // keeps the ids the AI already holds instead of renumbering from tab-1.+ "tabs": self.tabs.iter().map(|t| json!({ "url": t.url, "title": t.title, "tabId": t.tab_id, "browserTabId": t.target_id })).collect::<Vec<_>>(), "activeTab": self.active_tab, }) }@@ -147,6 +153,11 @@ impl Sessions { #[cfg(test)] mod tests { use super::*;+ #[test] fn tab_ids_never_repeat() {+ let mut s = Session::new("t", "adom-you", None, None, 0, None);+ for id in ["tab-1", "tab-3"] { s.tabs.push(Tab { tab_id: id.into(), target_id: id.into(), cdp_session: String::new(), url: String::new(), fav: None, title: String::new(), error_count: 0 }); }+ assert_eq!(s.next_tab_id(), "tab-2"); assert_eq!(s.next_tab_id(), "tab-4");+ } #[test] fn ids() { assert_eq!(thread_default_session_id("my thread/x"), "my-thread-x"); assert_eq!(thread_default_session_id(" "), "default"); } #[test] fn files_roundtrip() { let dir = std::env::temp_dir().join(format!("pup-sess-{}", std::process::id())); let ss = Sessions::new(&dir);
rust/verbs/src/extdrive.rs+62@@ -616,6 +616,68 @@ pub fn rebind_unbound(ctx: &Ctx) -> usize { n } +/// 2.0.694: adopt windows Chrome made outside pup. A tab dragged out of a pup window, or a page that opens a+/// new window, makes a browser window no session owns, and nothing ever stamped it, so it sat on the taskbar+/// with Chrome for Testing's stock icon (John, 2026-10-04: "why am i getting cft icons in my windows taskbar?").+/// Each such window becomes a session owned by the thread it came from: the thread whose window the tab was+/// dragged out of (pup still lists that tab there), else the thread whose tab opened it. rebind_unbound then+/// binds its OS window and stamps the thread's tile, exactly like a window pup opened itself. A window must+/// stay unowned for 5 s first, so a window pup is opening right now is never taken.+pub fn adopt_stray_windows(ctx: &Ctx) -> usize {+ static SEEN: Mutex<Option<std::collections::HashMap<i64, u64>>> = Mutex::new(None);+ if !alive(ctx) { return 0; }+ let Ok(_birth) = crate::BIRTH_LOCK.try_lock() else { return 0 };+ let Ok(r) = run(json!({ "action": "tabs" }), Duration::from_secs(10)) else { return 0 };+ let tabs = r["tabs"].as_array().cloned().unwrap_or_default();+ let sessions = ctx.sessions.all();+ let owned: std::collections::HashSet<i64> = sessions.iter().filter_map(|s| s.window_id).collect();+ let mut wins: std::collections::BTreeMap<i64, Vec<Value>> = std::collections::BTreeMap::new();+ for t in &tabs { if let Some(w) = t["windowId"].as_i64() { if !owned.contains(&w) { wins.entry(w).or_default().push(t.clone()); } } }+ let now = pup_core::now_ms();+ let mut seen = SEEN.lock().unwrap();+ let seen = seen.get_or_insert_with(Default::default);+ seen.retain(|w, _| wins.contains_key(w));+ let mut n = 0;+ for (wid, wtabs) in wins {+ // about:blank-only windows are close_orphan_blank_windows' job+ if wtabs.iter().all(|t| { let u = t["url"].as_str().unwrap_or(""); u.is_empty() || u == "about:blank" }) { continue; }+ let first = *seen.entry(wid).or_insert(now);+ if now.saturating_sub(first) < 5000 { continue; }+ let ids: Vec<String> = wtabs.iter().filter_map(|t| t["tabId"].as_i64()).map(|i| i.to_string()).collect();+ let openers: Vec<String> = wtabs.iter().filter_map(|t| t["openerTabId"].as_i64()).map(|i| i.to_string()).collect();+ let from_drag = sessions.iter().find(|s| s.tabs.iter().any(|t| ids.contains(&t.target_id)));+ let from_opener = sessions.iter().find(|s| s.tabs.iter().any(|t| openers.contains(&t.target_id)));+ let origin = from_drag.or(from_opener);+ let how = if from_drag.is_some() { "a tab dragged out of" } else if from_opener.is_some() { "a page in" } else { "nothing pup knows" };+ let base = origin.map(|o| o.id.clone()).unwrap_or_else(|| "pup-window".into());+ let mut k = 2; let mut sid = format!("{base}-{k}");+ while ctx.sessions.get(&sid).is_some() { k += 1; sid = format!("{base}-{k}"); }+ let profile = origin.map(|o| o.profile.clone()).unwrap_or_else(|| cur_profile());+ let mut s = pup_session::Session::new(&sid, &profile, origin.and_then(|o| o.owner.as_deref()), origin.and_then(|o| o.owner_container.as_deref()), 0, None);+ s.transport = "ext".into(); s.window_id = Some(wid);+ for t in &wtabs {+ let bid = t["tabId"].as_i64().unwrap_or(0);+ let mut tab = crate::transport::ext_tab(&mut s, bid, t["url"].as_str().unwrap_or(""), t["title"].as_str().unwrap_or(""));+ // a dragged-out tab keeps the id the AI knew it by+ if let Some(old) = from_drag.and_then(|o| o.tabs.iter().find(|x| x.target_id == bid.to_string())) { if !s.tabs.iter().any(|x| x.tab_id == old.tab_id) { tab.tab_id = old.tab_id.clone(); } }+ s.tabs.push(tab);+ }+ let top = s.tabs.iter().filter_map(|t| t.tab_id.strip_prefix("tab-")?.parse::<u32>().ok()).max().unwrap_or(0);+ s.next_tab_seq = s.next_tab_seq.max(top + 1);+ if let Some(i) = wtabs.iter().position(|t| t["active"] == json!(true)) { s.active_tab = i; }+ // the moved tabs leave the window they came from+ if let Some(o) = from_drag {+ ctx.sessions.with_mut(&o.id, |x| { x.tabs.retain(|t| !ids.contains(&t.target_id)); if x.active_tab >= x.tabs.len() { x.active_tab = x.tabs.len().saturating_sub(1); } });+ if let Some(o2) = ctx.sessions.get(&o.id) { ctx.sessions.save_file(&o2); }+ }+ ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s);+ pup_core::log(format!("[lane] adopted window {wid} ({} tab(s)) that Chrome opened outside pup, from {how} \"{}\": it is \"{sid}\", owned by {}", s.tabs.len(), origin.map(|o| o.id.as_str()).unwrap_or("-"), s.owner.as_deref().unwrap_or("no thread")));+ seen.remove(&wid);+ n += 1;+ }+ n+}+ /// Close windows no session owns whose every tab is about:blank: the startup window a lane relaunch or a /// concurrent open leaves behind (John, 2026-09-14: a stray "about:blank - Google Chrome for Testing" on /// his desktop). The CDP lane does the same in close_orphan_blank_targets. `keep` is a window just
rust/verbs/src/recovery.rs+12−1@@ -105,7 +105,17 @@ fn readopt_ext(ctx: &Ctx, f: &Value) -> bool { if live.is_empty() { return false; } let mut s = Session::new(&id, f["profile"].as_str().unwrap_or("adom-you"), f["owner"].as_str(), None, 0, None); s.transport = "ext".into(); s.window_id = Some(wid);- for t in &live { let tab = crate::transport::ext_tab(&mut s, t["tabId"].as_i64().unwrap_or(0), t["url"].as_str().unwrap_or(""), t["title"].as_str().unwrap_or("")); s.tabs.push(tab); }+ // 2.0.694: keep the tab ids the file saved (matched by the browser's own tab id). Renumbering from tab-1 on+ // every bridge restart sent an AI's eval to a different tab under the id it already held (2026-10-04).+ let saved: std::collections::HashMap<String, String> = f["tabs"].as_array().cloned().unwrap_or_default().iter()+ .filter_map(|t| Some((t["browserTabId"].as_str()?.to_string(), t["tabId"].as_str()?.to_string()))).collect();+ let top = saved.values().filter_map(|v| v.strip_prefix("tab-")?.parse::<u32>().ok()).max().unwrap_or(0);+ s.next_tab_seq = s.next_tab_seq.max(top + 1);+ for t in &live {+ let mut tab = crate::transport::ext_tab(&mut s, t["tabId"].as_i64().unwrap_or(0), t["url"].as_str().unwrap_or(""), t["title"].as_str().unwrap_or(""));+ if let Some(keep) = saved.get(&tab.target_id) { if !s.tabs.iter().any(|x| &x.tab_id == keep) { tab.tab_id = keep.clone(); } }+ s.tabs.push(tab);+ } if let Some(i) = live.iter().position(|t| t["active"] == json!(true)) { s.active_tab = i; } s.hwnd = f["_hwnd"].as_i64().filter(|&h| h > 0 && ctx.host.is_alive(h)); if s.hwnd.is_none() {@@ -562,6 +572,7 @@ pub fn health_tick(ctx: &Ctx) { crate::extdrive::adopt_tokenless(ctx); // 2.0.580: a token-less extension browser after a restart forget_user_closed(ctx); // 2.0.595: the user's close is final lane_gone_pass(ctx); // 2.0.595: a gone lane is forgotten unless it provably crashed+ crate::extdrive::adopt_stray_windows(ctx); // 2.0.694: windows Chrome made outside pup (a dragged-out tab, a page's popup) crate::extdrive::rebind_unbound(ctx); // ext sessions whose birth outran the hwnd wait: bind + stamp now crate::extdrive::reload_stale_worker(ctx); // a bridge_install must not wait for every thread's window to close crate::extdrive::close_orphan_blank_windows(ctx, None); // and any about:blank window no session owns
src/extension/worker.js+1−1@@ -431,7 +431,7 @@ async function pupExtPollOnce() { out = { id: cmd.id, ok: true, tabId: t.id, dataUrl: cap.dataUrl, via: cap.via }; } 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 })) };+ 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 })) }; } else if (cmd.action === 'close') { if (cmd.tabId) await chrome.tabs.remove(cmd.tabId); else if (cmd.windowId) await chrome.windows.remove(cmd.windowId);