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.
master
John Lauer
v2.0.735 (insiders): x.com and other sites that refuse a fresh sign-in from pup's browser (John, 2026-10-27, pup-bridge…
55f65e3
14d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993
//! pup-bridge#109: EXTENSION-DRIVEN windows — pup drives a Chrome that carries NO CDP debug port.
//!
//! Measured on AdomLapper 2026-09-12, same box and minute:
//! pup over CDP st.com + mouser ERR_HTTP2_PROTOCOL_ERROR, arrow Access Denied
//! extension-driven st.com + mouser + arrow ALL LOAD
//! and the extension-driven browser was the SAME Chrome for Testing binary reporting raw
//! Chromium-148 brands with no "Google Chrome" brand at all — the exact identity that was being
//! walled over CDP. So the discriminator is the DevTools debug port, not the browser identity.
//! (analog.com stays denied either way: it denies real Chrome driven by the real extension on a
//! profile without established trust, so that one is profile trust, not a driver problem.)
//!
//! The channel: pup queues ONE command; the extension's service worker polls `/extcmd`, runs it
//! with chrome.tabs / chrome.scripting, and POSTs the result to `/extresult`.
use pup_core::{err, ok, Caller};
use serde_json::{json, Value};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::Ctx;
/// The command channel, 2.0.570: a QUEUE. It was one slot (one pending command, one result), so two
/// callers at once (the annotate watcher's read every 500 ms and a pup_eval, or the health tick's overlay
/// and a verb) overwrote each other, one of them timed out and the annotate watcher died on its first
/// read. Commands queue by id, results are keyed by id, the worker drains the queue on every poll, and
/// everything the worker had taken is re-queued when a fresh hello says it restarted.
/// 2.0.659 (pup-bridge#113): the lane is PER PROFILE. The default lane (adom-you, the one every user runs)
/// keeps exactly the state it always had; every other profile (a throwaway iso-<id> for isolated:true, or a
/// named profile) gets its own browser, its own staged extension with its own lane token, its own queue.
/// Which lane a call means comes from a thread-local set by `enter_profile` (the dispatcher resets it per
/// request; `transport::lane_for` sets it from the session's profile), so every existing call site is unchanged.
pub struct LaneState {
pub profile: String,
pub token: Mutex<String>,
pub pids: Mutex<Vec<u32>>,
pub pending: Mutex<std::collections::VecDeque<Value>>,
pub results: Mutex<Option<std::collections::HashMap<u64, Value>>>,
pub taken: Mutex<Option<std::collections::HashMap<u64, (Value, u64)>>>,
pub last_poll: Mutex<u64>,
pub removed: Mutex<Vec<i64>>,
pub worker: Mutex<String>,
pub cur: Mutex<(i64, i64)>,
pub handle: std::sync::Mutex<Option<std::sync::Arc<crate::BrowserHandle>>>,
}
impl LaneState {
pub fn new(profile: &str) -> LaneState { LaneState { profile: profile.to_string(), token: Mutex::new(String::new()), pids: Mutex::new(Vec::new()), pending: Mutex::new(std::collections::VecDeque::new()), results: Mutex::new(None), taken: Mutex::new(None), last_poll: Mutex::new(0), removed: Mutex::new(Vec::new()), worker: Mutex::new(String::new()), cur: Mutex::new((0, 0)), handle: std::sync::Mutex::new(None) } }
}
static DEFAULT_LANE: std::sync::OnceLock<std::sync::Arc<LaneState>> = std::sync::OnceLock::new();
static LANES: Mutex<Vec<std::sync::Arc<LaneState>>> = Mutex::new(Vec::new());
/// Set once restore_extdrive has read every lane file at boot; adoption of unknown lanes waits for it.
pub static RESTORED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
thread_local! { static CUR_PROFILE: std::cell::RefCell<String> = std::cell::RefCell::new(String::new()); }
pub fn default_lane() -> std::sync::Arc<LaneState> { DEFAULT_LANE.get_or_init(|| std::sync::Arc::new(LaneState::new("adom-you"))).clone() }
pub fn st() -> std::sync::Arc<LaneState> {
let p = CUR_PROFILE.with(|c| c.borrow().clone());
if p.is_empty() || p == "adom-you" { return default_lane(); }
// 2.0.723: never fall back to the adom-you lane for another profile. A lane dropped mid-call (the idle sweep of
// an isolated profile) made a close clear the adom-you lane and end the user's main browser.
ensure_lane(&p)
}
pub fn cur_profile() -> String { let p = CUR_PROFILE.with(|c| c.borrow().clone()); if p.is_empty() { "adom-you".into() } else { p } }
pub fn enter_profile(profile: &str) {
if profile.is_empty() || profile == "adom-you" { CUR_PROFILE.with(|c| c.borrow_mut().clear()); return; }
if lane_for_profile(profile).is_none() { LANES.lock().unwrap().push(std::sync::Arc::new(LaneState::new(profile))); }
CUR_PROFILE.with(|c| *c.borrow_mut() = profile.to_string());
}
pub fn leave_profile() { CUR_PROFILE.with(|c| c.borrow_mut().clear()); }
/// The lane for `profile`, created if pup has none yet (the default lane for adom-you).
pub fn ensure_lane(profile: &str) -> std::sync::Arc<LaneState> {
if let Some(l) = lane_for_profile(profile) { return l; }
let l = std::sync::Arc::new(LaneState::new(profile)); LANES.lock().unwrap().push(l.clone()); l
}
pub fn lane_for_profile(profile: &str) -> Option<std::sync::Arc<LaneState>> {
if profile.is_empty() || profile == "adom-you" { return Some(default_lane()); }
LANES.lock().unwrap().iter().find(|l| l.profile == profile).cloned()
}
pub fn lane_for_token(token: &str) -> Option<std::sync::Arc<LaneState>> {
if token.is_empty() { return None; }
if *default_lane().token.lock().unwrap() == token { return Some(default_lane()); }
LANES.lock().unwrap().iter().find(|l| *l.token.lock().unwrap() == token).cloned()
}
pub fn drop_lane_for_profile(profile: &str) { LANES.lock().unwrap().retain(|l| l.profile != profile); }
pub fn secondary_lanes() -> Vec<std::sync::Arc<LaneState>> { LANES.lock().unwrap().clone() }
static SEQ: Mutex<u64> = Mutex::new(0);
pub fn take_removed() -> Vec<i64> { std::mem::take(&mut *st().removed.lock().unwrap()) }
/// GET /extcmd — the extension asks for work. Returns `{}` when there is nothing to do.
pub fn take_command_for(lane: &str, removed: &[i64], wait_ms: u64) -> Value {
// 2.0.502: only a browser carrying a lane token pup issued may take work. 2.0.659: the token names WHICH
// lane (default adom-you, or a per-profile lane), so several browsers can be driven at once.
let Some(l) = lane_for_token(lane) else { return json!({}) };
if !removed.is_empty() { l.removed.lock().unwrap().extend_from_slice(removed); }
let cmd = take_command_on(&l);
if cmd["id"].is_u64() || wait_ms == 0 { return cmd; }
// 2.0.693: a LONG poll. The worker used to ask every 1.2 s, so every command waited up to 1.2 s just to
// be picked up, and a two-step verb like Annotate (inject the overlay, then start it) took 1.5 to 2.2 s
// before the user could draw (John, 2026-10-04). Now the request waits here and run() wakes it the moment
// a command is queued. Capped well under the MV3 worker's 30 s idle limit and alive()'s 20 s heartbeat.
let deadline = Instant::now() + Duration::from_millis(wait_ms.min(10_000));
let mut g = QUEUED.0.lock().unwrap();
while l.pending.lock().unwrap().is_empty() {
let now = Instant::now();
if now >= deadline { break; }
g = QUEUED.1.wait_timeout(g, deadline - now).unwrap().0;
}
drop(g);
take_command_on(&l)
}
/// Rung by run() whenever a command is queued, so a waiting /extcmd long poll returns at once.
static QUEUED: (Mutex<()>, std::sync::Condvar) = (Mutex::new(()), std::sync::Condvar::new());
pub fn take_command() -> Value { take_command_on(&st()) }
fn take_command_on(l: &LaneState) -> Value {
let first = *l.last_poll.lock().unwrap() == 0;
*l.last_poll.lock().unwrap() = pup_core::now_ms();
if first { pup_core::log(format!("[extdrive] the pup extension reached /extcmd (lane profile {}) — the control channel is live", l.profile)); }
let cmd = l.pending.lock().unwrap().pop_front().unwrap_or(json!({}));
if let Some(id) = cmd["id"].as_u64() { l.taken.lock().unwrap().get_or_insert_with(Default::default).insert(id, (cmd.clone(), pup_core::now_ms())); }
cmd
}
/// Called on a matched hello: if the previous worker instance took a command and never answered, hand it
/// to this one. Returns how many were re-queued.
fn requeue_taken_on(l: &LaneState, reason: &str) -> usize {
let now = pup_core::now_ms();
let mut taken = l.taken.lock().unwrap();
let Some(m) = taken.as_mut() else { return 0 };
let mut ids: Vec<u64> = m.iter().filter(|(_, (_, at))| now.saturating_sub(*at) >= 1500).map(|(id, _)| *id).collect();
ids.sort();
let mut n = 0;
let mut pending = l.pending.lock().unwrap();
let results = l.results.lock().unwrap();
for id in ids.into_iter().rev() { // oldest ends up at the front
if results.as_ref().map(|r| r.contains_key(&id)).unwrap_or(false) { m.remove(&id); continue; }
if let Some((cmd, _)) = m.remove(&id) {
pup_core::log(format!("[extdrive] re-issued command {id} ({}) after {reason}", cmd["action"].as_str().unwrap_or("?")));
pending.push_front(cmd); n += 1;
}
}
n
}
#[allow(dead_code)]
fn requeue_taken(reason: &str) -> usize { requeue_taken_on(&st(), reason) }
/// POST /exthello — an extension announces itself, whatever lane it holds. Throttled to one line per
/// distinct (lane, match) per 30s so a 1.2s poll loop cannot flood the log.
/// The worker version the running lane browser reported in its last hello ("2-0-541" from /worker.2-0-541.js).
// LANE_WORKER lives in LaneState.worker since 2.0.659
/// 2.0.569: after a bridge_install the running extension browser keeps the OLD worker until it relaunches,
/// which only happens when no thread has an ext window open (measured: a 2.0.565 bridge driving a 2.0.562
/// worker for an hour). On each health tick, if the lane's worker is behind, re-stage the extension folder
/// in place and ask the worker to chrome.runtime.reload(): it comes back on the new files with the same lane
/// token, and the bridge re-queues any command it had in flight.
pub fn reload_stale_worker(ctx: &Ctx) {
static ASKED: Mutex<Option<String>> = Mutex::new(None);
if !alive(ctx) { return; }
let Some((have, want)) = lane_worker_stale() else { return };
if ASKED.lock().unwrap().as_deref() == Some(want.as_str()) { return; }
let lane = st().token.lock().unwrap().clone();
if lane.is_empty() { return; }
// 2.0.690: never cycle a lane pup cannot end yet. Right after a restart the lane's processes and windows are
// re-attached a few seconds AFTER the worker's first hello; cycling in that gap killed 0 processes, marked the
// version done, and left John's laptop on the 2.0.686 extension under pup 2.0.689 with no retry (2026-10-03).
if st().pids.lock().unwrap().is_empty() {
// a lane re-attached by its hello (not from lane.json) has a token but no process list: find the processes
// running its profile folder, as the orphan sweep does. Still none: nothing pup can end yet.
let found = crate::pup_browser_pids_by_profile(ctx).get(&cur_profile()).cloned().unwrap_or_default();
if found.is_empty() { return; }
pup_core::log(format!("[extdrive] the lane's process list was empty; found {} process(es) running profile \"{}\"", found.len(), cur_profile()));
*st().pids.lock().unwrap() = found;
}
let pending = ctx.sessions.read_files().iter().filter(|f| f["transport"].as_str() == Some("ext")).filter_map(|f| f["sessionId"].as_str().map(|x| x.to_string())).any(|id| !ctx.sessions.exists(&id));
if pending { return; }
// 2.0.695: and never while a window the browser holds has no session yet. adopt_stray_windows gives it one
// (and a file) within 5 s; cycling first closed such windows for good.
if STRAYS_WAITING.load(std::sync::atomic::Ordering::Relaxed) != 0 { return; }
let shim = crate::lane_shim(ctx);
let staged = pup_browser::stage_extensions_lane(&*ctx.host, ctx.port, "adom-you", true, Some(&lane), shim);
if staged.is_empty() { pup_core::log("[extdrive] worker reload skipped: re-staging the extension produced nothing".to_string()); return; }
// 2.0.646/647 (pup-bridge#121): NEVER reload in place (chrome.runtime.reload() on the running worker broke the
// channel: every verb answered browser_gone while the windows were alive). And never a bare kill either: 2.0.646
// killed the lane and recovery FORGOT every window, because the kill had already cleared the pid list that
// recovery uses to tell pup's own kill from the user closing the last window (AdomLapper, 2026-09-19). This is
// the crash-restore path, driven on purpose: reserve every extension window, end the lane, relaunch each
// window from its file on the re-staged extension, in the background, at its last page.
let hwnds: Vec<i64> = ctx.sessions.all().into_iter().filter(|s| s.transport == "ext").filter_map(|s| s.hwnd).collect(); // 2.0.651: from the live sessions (the file has no hwnd)
let files: Vec<Value> = ctx.sessions.all().into_iter().filter(|s| s.transport == "ext").map(|s| s.file_json()).collect();
let ids: Vec<String> = files.iter().filter_map(|f| f["sessionId"].as_str().map(|s| s.to_string())).collect();
crate::recovery::reserve(&ids); crate::recovery::mark_lost(&ids); // 2.0.682: pup ends this browser itself
// 2.0.696: say so. The windows vanish for about a minute and come back one by one, and with no word from pup
// that read as a crash (John, 2026-10-04: "i just thought you had crashed").
let to = want.replace('-', ".");
let toast = |title: String, body: String| {
let r = ctx.ab.call("notify_user", json!({ "title": title.clone(), "body": body, "focus": false }), &crate::identity::internal_caller(), Duration::from_secs(8));
pup_core::log(format!("[extdrive] update notice \"{title}\": {}", match r { Ok(_) => "shown".to_string(), Err(e) => format!("NOT shown ({e})") }));
};
if !ids.is_empty() {
toast(format!("pup is updating to {to}"), format!("Your {} pup window{} will close and come back in about a minute, on the same pages. Nothing has crashed.", ids.len(), if ids.len() == 1 { "" } else { "s" }));
}
for id in &ids { ctx.sessions.remove(id); }
crate::recovery::mark_browser_restarted(&cur_profile()); // 2.0.717: re-adopted windows get the media guard too
let n = crate::kill_extdrive(ctx);
if n > 0 { *ASKED.lock().unwrap() = Some(want.clone()); } // 2.0.690: done only once the old browser really ended
// 2.0.650: WAIT for the windows to be gone before relaunching. The kill is asynchronous; relaunch_from_file saw
// every window still "alive on glass", declined ("will re-adopt"), and the sessions were lost when the browser
// finished dying (AdomLapper, 2.0.649 upgrade: 5 windows, 4 lost).
let t0 = std::time::Instant::now();
while t0.elapsed() < Duration::from_secs(8) && hwnds.iter().any(|h| ctx.host.is_alive(*h)) { std::thread::sleep(Duration::from_millis(200)); }
pup_core::log(format!("[extdrive] lane worker {} is behind pup {}: re-staged the extension and cycled the lane browser ({n} process(es), windows gone after {} ms); relaunching its {} window(s) from their files on the new extension", have.replace('-', "."), want.replace('-', "."), t0.elapsed().as_millis(), ids.len()));
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();
// 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();
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(", ")) }));
}
}
/// 2.0.647: a lane that ends takes its worker version with it; a fresh browser reports its own at hello.
pub fn reset_worker_version() { st().worker.lock().unwrap().clear(); }
pub fn lane_worker_stale() -> Option<(String, String)> {
let w = st().worker.lock().unwrap().clone();
if w.is_empty() { return None; }
let want = pup_core::version().replace('.', "-");
if w == want { None } else { Some((w, want)) }
}
pub fn hello(v: Value) {
static LAST: Mutex<Option<std::collections::HashMap<String, u64>>> = Mutex::new(None);
let lane = v["lane"].as_str().unwrap_or("").to_string();
let target = lane_for_token(&lane);
if let (Some(l), Some(w)) = (target.as_ref(), v["worker"].as_str().and_then(|p| p.rsplit('/').next()).and_then(|f| f.strip_prefix("worker.")).and_then(|f| f.strip_suffix(".js"))) {
*l.worker.lock().unwrap() = w.to_string();
}
let want = default_lane().token.lock().unwrap().clone();
let matched = target.is_some();
if let Some(l) = target.as_ref() { requeue_taken_on(l, "the lane worker restarted (fresh hello)"); }
let key = format!("{lane}|{matched}");
{
let mut g = LAST.lock().unwrap();
let m = g.get_or_insert_with(std::collections::HashMap::new);
let now = pup_core::now_ms();
if let Some(t) = m.get(&key) { if now.saturating_sub(*t) < 30_000 { return; } }
m.insert(key, now);
}
let dl = default_lane();
// 2.0.670: adopt an unknown lane browser ONLY into the lane of the profile it says it runs. It used to become the
// adom-you lane whatever its jar: on AdomLapper (2026-09-21) the "services" browser was adopted after an upgrade
// and adom-you windows opened inside it with the wrong cookies. A worker too old to report its profile is adopted
// as adom-you only while pup knows no other profile's lane.
let hp = v["profile"].as_str().unwrap_or("").to_string();
// 2.0.670: the HTTP server answers before the boot restore has read the lane files; an adoption in that gap
// took the svc-test browser as adom-you (CRR, 2026-09-21 13:34:43). Wait for the restore; the hello repeats.
if !matched && !lane.is_empty() && !RESTORED.load(std::sync::atomic::Ordering::SeqCst) { return; }
if !matched && !lane.is_empty() {
let dest = if !hp.is_empty() { Some(ensure_lane(&hp)) } else if LANES.lock().unwrap().is_empty() { Some(dl.clone()) } else { None };
match dest {
Some(d) if *d.last_poll.lock().unwrap() == 0 => {
*d.token.lock().unwrap() = lane.clone();
pup_core::log(format!("[extdrive] adopted the running extension browser (lane {lane}) as the {} lane{}; pup had lost track of it", d.profile, if hp.is_empty() { " (its worker does not report a profile)" } else { "" }));
return;
}
None => { pup_core::log(format!("[extdrive] a pup extension with an unknown lane {lane:?} and no reported profile said hello; NOT adopted (pup runs more than one profile's lane and cannot tell which jar it is)")); return; }
_ => {}
}
}
if !matched && lane.is_empty() && *dl.last_poll.lock().unwrap() == 0 {
*TOKENLESS_HELLO_AT.lock().unwrap() = pup_core::now_ms();
pup_core::log(format!("[extdrive] a pup extension is polling with no lane token{}: the next health tick adopts it if pup's own browser is gone", if want.is_empty() { String::new() } else { format!(" while pup wants {want}") }));
return;
}
if let Some(l) = target { pup_core::log(format!("[extdrive] hello from the lane browser (lane {lane}, profile {}) — this one may take work", l.profile)); }
else { pup_core::log(format!("[extdrive] hello from a pup extension carrying lane {:?} (pup wants {:?}), port.json read={} pkg={} — NOT served", lane, want, v["read"], v["pkg"])); }
}
pub fn put_result(v: Value) {
let Some(id) = v["id"].as_u64() else { return };
let mut lanes = vec![default_lane()]; lanes.extend(secondary_lanes());
let owner = lanes.iter().find(|l| l.taken.lock().unwrap().as_ref().map(|m| m.contains_key(&id)).unwrap_or(false)).cloned().unwrap_or_else(|| st());
if let Some(m) = owner.taken.lock().unwrap().as_mut() { m.remove(&id); }
owner.results.lock().unwrap().get_or_insert_with(Default::default).insert(id, v);
}
/// A killed lane must read as dead at once. `alive()` is heartbeat-based, and a browser killed 3 s after its
/// last poll still counted as alive for 7 more seconds: the next open took the WARM path and waited 60 s on a
/// dead channel until ab timed out (2026-09-13, the first cold open of the 20-site proof).
pub(crate) fn mark_dead() { *st().last_poll.lock().unwrap() = 0; }
/// The lane as pup_status reports it. There are no lane verbs: a lane window is a pup window.
pub fn lane_health(ctx: &Ctx) -> Value {
let ago = connected_ms_ago();
let stale = lane_worker_stale();
// 2.0.583: LIVE lane windows on glass, not the session count (a dead lane reported "1 window" for 14 h);
// and an EMPTY pid list means zero, because to the enumerator it means "no filter" (it counted the user's own Chrome and Edge: 7)
let live_windows = { let p = crate::extdrive_pids(); if p.is_empty() { 0 } else { ctx.host.windows_for_pids(&p, Some("Chrome_WidgetWin_1")).into_iter().filter(|w| w.visible && w.rect.2 - w.rect.0 > 200).count() } };
json!({ "connected": alive(ctx), "lastPollMsAgo": if ago > 86_400_000 { Value::Null } else { json!(ago) },
"processes": crate::extdrive_pids().len(), "profile": "adom-you", "debugPort": false,
"workerVersion": st().worker.lock().unwrap().replace('-', "."), "stale": stale.is_some(),
"_staleHint": stale.map(|(w, want)| format!("the lane browser still runs extension {} while pup is {}; it cycles by itself the moment no extension-lane window is open, or pup_close_window {{all:true}} from every thread cycles it now", w.replace('-', "."), want.replace('-', "."))),
"sessions": ctx.sessions.all().iter().filter(|s| crate::transport::is_ext(s)).count(),
"windows": live_windows })
}
pub fn connected_ms_ago() -> u64 { pup_core::now_ms().saturating_sub(*st().last_poll.lock().unwrap()) }
pub fn last_poll_ms() -> u64 { *st().last_poll.lock().unwrap() }
/// 2.0.580: a pup extension polled with NO lane token while pup holds none either (both sides empty after a
/// restart whose lane.json did not survive). Refusing it forever left an orphan browser (2026-09-14 20:57).
/// The health tick adopts it: mint a token, stamp it into port.json (the worker re-reads it every poll),
/// and learn the lane's pids from Chrome for Testing frames only, never the user's own Chrome.
static TOKENLESS_HELLO_AT: Mutex<u64> = Mutex::new(0);
pub fn adopt_tokenless(ctx: &Ctx) {
let at = *TOKENLESS_HELLO_AT.lock().unwrap();
if at == 0 || pup_core::now_ms().saturating_sub(at) > 90_000 { return; }
// 2.0.599: pup holding a token is NOT a reason to refuse forever. On the conference room PC pup kept
// wanting "ext-1789600049390" while the live extension said lane "" on every hello, so every poll was
// refused and only a pup restart fixed it (measured 2026-09-16). The token belongs to a browser that is
// GONE: adopt the live one. Still never while the browser pup minted that token for is alive (a cold
// launch sets the token, then waits for its first poll: an old browser must not be able to hijack that).
if last_poll_ms() != 0 { return; }
let mine = crate::extdrive_pids();
if !mine.is_empty() && mine.iter().any(|p| ctx.host.alive(*p)) { return; }
let had = st().token.lock().unwrap().clone();
let lane = format!("ext-{}", pup_core::now_ms());
let staged = pup_browser::stage_extensions_lane(&*ctx.host, ctx.port, "adom-you", true, Some(&lane), crate::lane_shim(ctx));
if staged.is_empty() { return; }
// 2.0.600: never the pids of a CDP browser pup already owns on another profile. Learning them here made
// the next kill_extdrive take the ab-smoke window's browser down with the lane (ConfRoomROG, 2026-09-16).
let cdp: Vec<u32> = ctx.browsers.lock().unwrap().values().map(|b| b.launched.pid).filter(|p| *p > 0).collect();
let pids: Vec<u32> = { let mut v: Vec<u32> = ctx.host.frame_windows_all(Some("Chrome_WidgetWin_1")).into_iter().filter(|w| w.title.ends_with("Google Chrome for Testing") && !cdp.contains(&w.pid)).map(|w| w.pid).collect(); v.sort(); v.dedup(); v };
if pids.is_empty() { return; }
*st().token.lock().unwrap() = lane.clone();
crate::set_extdrive_pids(pids.clone());
crate::persist_lane(ctx);
*TOKENLESS_HELLO_AT.lock().unwrap() = 0;
pup_core::log(format!("[extdrive] adopted a token-less extension browser: minted lane {lane}, stamped it into port.json, {} Chrome for Testing process(es) learned from its windows{}", pids.len(), if had.is_empty() { String::new() } else { format!(" (dropping the stale token {had}, whose browser is gone)") }));
}
/// Queue one command and block until the extension answers it (or the deadline passes).
pub(crate) fn run(cmd: Value, wait: Duration) -> Result<Value, Value> {
IN_FLIGHT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let r = run_inner(cmd, wait);
IN_FLIGHT.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
r
}
fn run_inner(cmd: Value, wait: Duration) -> Result<Value, Value> {
let id = { let mut s = SEQ.lock().unwrap(); *s += 1; *s };
let mut c = cmd;
c["id"] = json!(id);
st().pending.lock().unwrap().push_back(c);
{ let _g = QUEUED.0.lock().unwrap(); QUEUED.1.notify_all(); }
let t0 = Instant::now();
while t0.elapsed() < wait {
let hit = st().results.lock().unwrap().as_mut().and_then(|m| m.remove(&id));
if let Some(r) = hit {
if r["ok"] == json!(true) { return Ok(r); }
return Err(err("ext_command_failed", r["error"].as_str().unwrap_or("the extension could not run it").to_string(), None));
}
std::thread::sleep(Duration::from_millis(60));
}
st().pending.lock().unwrap().retain(|c| c["id"].as_u64() != Some(id));
if let Some(m) = st().taken.lock().unwrap().as_mut() { m.remove(&id); }
Err(pup_core::merge(
err("ext_no_answer", format!("the pup extension did not answer within {}s", wait.as_secs()),
Some("Is an extension-driven window open? pup_open_window starts one. The extension only answers while its browser is running; a normal CDP window does not serve this channel.")),
json!({ "lastPollMsAgo": connected_ms_ago() }),
))
}
/// Liveness is the extension's own heartbeat, NOT the spawned pid: Chrome's launcher process exits
/// immediately and hands off to a new pid, so the pid we spawned is dead within a second while the
/// browser runs fine (that is why pup's normal launch reads the pid back over CDP instead).
/// 2.0.532: a command in flight counts as alive. The worker does not poll while it executes a command (a
/// slow site's tab open holds it for up to 30 s), so the heartbeat stopped, alive() read the lane as dead
/// and the next three tab opens were refused with "browser not running" while the browser was fine
/// (the 20-site proof, 2026-09-13: nordicsemi held the worker; newark, rs-online and infineon were refused).
static IN_FLIGHT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
pub(crate) fn alive(_ctx: &Ctx) -> bool { connected_ms_ago() < 20_000 || (IN_FLIGHT.load(std::sync::atomic::Ordering::Relaxed) > 0) }
/// The window/tab pup is driving, so navigate/eval do not wander to another window.
// CUR lives in LaneState.cur since 2.0.659
fn pin(r: &Value) {
let w = r["windowId"].as_i64().unwrap_or(0);
let t = r["tabId"].as_i64().unwrap_or(0);
if w > 0 || t > 0 { *st().cur.lock().unwrap() = (w, t); }
}
fn aim(c: &mut Value) {
let (w, t) = *st().cur.lock().unwrap();
if t > 0 { c["tabId"] = json!(t); }
if w > 0 { c["windowId"] = json!(w); }
}
/// 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, _caller, args, || open_inner(ctx, _caller, args));
if r["ok"] != json!(false) { dress_after(ctx, _caller); }
r
}
fn open_inner(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {
let url = args.get("url").and_then(|u| u.as_str()).unwrap_or("about:blank").to_string();
if !alive(ctx) {
if let Err(e) = crate::launch_extdrive(ctx, &url) { return e; }
// 2.0.493: return WELL INSIDE the caller's request window. Waiting 45 s here for the extension to call
// home blew past ab's ~60 s relay on a slower box, so every pup_open_window on ConfRoomROG came back
// "Request timed out" while the browser was in fact starting fine. Wait briefly; if the channel is not
// up yet, answer `starting` (NOT an error) and let the caller poll pup_status, the same contract
// pup_restart_browser uses.
let t0 = Instant::now();
while t0.elapsed() < Duration::from_secs(20) {
if connected_ms_ago() < 4000 { break; }
std::thread::sleep(Duration::from_millis(300));
}
if connected_ms_ago() > 8000 {
dress_window(ctx, _caller);
return pup_core::merge(ok(json!({ "starting": true, "opened": false, "url": url })), json!({
"statusVerb": "pup_status", "_hint": "The extension-driven browser is starting; its extension has not called home yet. Poll pup_status until extensionConnected is true (usually a few seconds, longer on a cold Chrome for Testing), then pup_navigate / pup_eval. This is NOT a failure.",
}));
}
dress_window(ctx, _caller);
fit_now();
return match run(json!({ "action": "tabs" }), Duration::from_secs(20)) {
Ok(r) => {
if let Some(t) = r["tabs"].as_array().and_then(|a| a.iter().find(|t| t["url"].as_str().map(|u| u.starts_with("http")).unwrap_or(false))) { pin(t); }
pup_core::merge(ok(json!({ "opened": true, "url": url, "tabs": r["tabs"] })),
json!({ "_hint": "Extension-driven window: NO CDP debug port, so vendor edges that wall automation (st.com, mouser, arrow) load here. Drive it with pup_navigate / pup_eval. Trusted input (pup_click, pup_type, pup_input_dispatch), element and full-page screenshots, and annotate all work here, and pup_devtools {method, params} sends any DevTools command to this window." }))
}
Err(e) => e,
};
}
dress_window(ctx, _caller);
fit_now();
match run(json!({ "action": "open", "url": url }), Duration::from_secs(60)) {
Ok(r) => pup_core::merge({ pin(&r); ok(json!({ "opened": true, "url": url, "tabId": r["tabId"], "windowId": r["windowId"] })) },
json!({ "_hint": "Opened in the existing extension-driven browser. pup_eval reads it; pup_navigate moves it." })),
Err(e) => e,
}
}
/// pup_navigate {url, tabId?}
pub fn navigate(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {
let r = with_lane_progress(ctx, _caller, args, || navigate_inner(ctx, _caller, args));
if r["ok"] != json!(false) { dress_after(ctx, _caller); }
r
}
fn navigate_inner(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {
dress_window(ctx, _caller);
if !alive(ctx) { return not_running(); }
let mut c = json!({ "action": "navigate", "url": args.get("url").and_then(|u| u.as_str()).unwrap_or("about:blank") });
aim(&mut c);
if let Some(t) = args.get("tabId") { c["tabId"] = t.clone(); }
match crate::transport::with_frames(|| run(c, Duration::from_secs(60))) {
Ok(r) => { pin(&r); ok(json!({ "url": r["url"], "title": r["title"], "tabId": r["tabId"], "_hint": "Navigated through the extension (no CDP). Read it with pup_eval." })) }
Err(e) => e,
}
}
/// pup_eval {expr, tabId?} — runs in the page's MAIN world; non-strings come back JSON-encoded.
pub fn eval(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {
if !alive(ctx) { return not_running(); }
let mut c = json!({ "action": "eval", "expr": args.get("expr").and_then(|e| e.as_str()).unwrap_or("") });
aim(&mut c);
if let Some(t) = args.get("tabId") { c["tabId"] = t.clone(); }
match run(c, Duration::from_secs(45)) {
Ok(r) => { pin(&r); ok(json!({ "result": r["result"], "tabId": r["tabId"], "url": r["url"] })) }
Err(e) => e,
}
}
/// pup_list_tabs {}
pub fn tabs(ctx: &Ctx, _caller: &Caller, _args: &Value) -> Value {
if !alive(ctx) { return not_running(); }
match run(json!({ "action": "tabs" }), Duration::from_secs(25)) { Ok(r) => ok(json!({ "tabs": r["tabs"] })), Err(e) => e }
}
/// pup_close_window {tabId?} — a tab, or the whole extension-driven browser when nothing is named.
pub fn close(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {
if !alive(ctx) { return ok(json!({ "closed": false, "_hint": "No extension-driven browser was running." })); }
if let Some(t) = args.get("tabId") {
return match run(json!({ "action": "close", "tabId": t }), Duration::from_secs(20)) { Ok(_) => ok(json!({ "closed": true })), Err(e) => e };
}
let via_ext = run(json!({ "action": "closeAll" }), Duration::from_secs(20)).is_ok();
*st().cur.lock().unwrap() = (0, 0);
let n = crate::kill_extdrive(ctx);
ok(json!({ "closed": true, "viaExtension": via_ext, "processesEnded": n, "_hint": "The extension-driven browser is closed. pup_open_window starts a fresh one." }))
}
/// pup_status {}
pub fn status(ctx: &Ctx, _caller: &Caller, _args: &Value) -> Value {
let a = alive(ctx);
let ago = connected_ms_ago();
ok(json!({
"browserRunning": a,
"extensionConnected": a && ago < 8000,
"lastPollMsAgo": if *st().last_poll.lock().unwrap() == 0 { Value::Null } else { json!(ago) },
"profile": "adom-you",
"_hint": "Extension-driven mode carries NO CDP debug port. Measured 2026-09-12: st.com, mouser and arrow load here and fail over CDP on the same box. analog.com stays denied (that one is profile trust, and denies the real extension on a cold profile too).",
"_next": ["pup_close_window"],
}))
}
fn not_running() -> Value {
err("ext_not_running", "no extension-driven browser is open", Some("pup_open_window {url} starts one (Chrome for Testing, pup-owned profile, no debug port)."))
}
/// pup_status {} — read the pup extension's SERVICE WORKER directly over CDP in the normal (CDP) browser:
/// is the worker even running, what port did it read from port.json, and what does its own findPort() return.
/// This answers "the extension never called home" from inside the extension instead of by guessing.
pub fn diag(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {
let profile = args.get("profile").and_then(|p| p.as_str()).unwrap_or("adom-you");
let Some(b) = crate::browser_for(ctx, profile) else { return err("no_cdp_browser", format!("no CDP browser on profile \"{profile}\""), Some("Open a normal pup window first (pup_open_window); the diagnostic attaches to the extension worker inside that browser.")) };
let targets = b.cdp.targets().unwrap_or_default();
let ext_id = pup_browser::EXT_IDS.iter().find(|(n, _)| *n == "pup-extension").map(|(_, id)| *id).unwrap_or("");
let workers: Vec<Value> = targets.iter().filter(|t| t["type"] == json!("service_worker") && t["url"].as_str().map(|u| u.contains(ext_id)).unwrap_or(false)).cloned().collect();
let all_ext: Vec<Value> = targets.iter().filter(|t| t["url"].as_str().map(|u| u.starts_with("chrome-extension://")).unwrap_or(false)).map(|t| json!({ "type": t["type"], "url": t["url"] })).collect();
if workers.is_empty() {
return pup_core::merge(ok(json!({ "workerRunning": false, "extensionTargets": all_ext })), json!({ "_hint": "The pup extension's service worker is NOT running in this browser: nothing to poll pup with. Either the extension did not load (--load-extension path, a manifest error) or the worker was never started." }));
}
let tid = workers[0]["targetId"].as_str().unwrap_or("").to_string();
let sid = match b.cdp.attach(&tid) { Ok(s) => s, Err(e) => return err("attach_failed", e.to_string(), None) };
let expr = "(async () => { const o = { hasPackagedPort: typeof packagedPort, hasFindPort: typeof findPort, hasPoll: typeof pupExtPoll }; try { o.pkg = await packagedPort(); } catch (e) { o.pkgErr = String(e); } try { const f = await findPort(); o.found = f ? f.port : null; } catch (e) { o.findErr = String(e); } try { const r = await fetch(chrome.runtime.getURL('port.json')); o.portJsonStatus = r.status; o.portJsonText = (await r.text()).slice(0, 120); } catch (e) { o.portJsonErr = String(e); } try { o.storagePort = (await chrome.storage.local.get('pupPort')).pupPort; } catch (e) {} return JSON.stringify(o); })()";
let r = b.cdp.send(Some(&sid), "Runtime.evaluate", json!({ "expression": expr, "awaitPromise": true, "returnByValue": true }), Duration::from_secs(20));
let _ = b.cdp.send(None, "Target.detachFromTarget", json!({ "sessionId": sid }), Duration::from_secs(5));
match r {
Ok(v) => {
let raw = v["result"]["value"].as_str().unwrap_or("").to_string();
let parsed: Value = serde_json::from_str(&raw).unwrap_or(json!({ "raw": raw, "exception": v["exceptionDetails"] }));
ok(json!({ "workerRunning": true, "workerUrl": workers[0]["url"], "insideWorker": parsed, "pupPort": ctx.port, "_hint": "insideWorker is the extension's OWN view: pkg = the port it read from port.json, found = the port its findPort() settled on (null = it cannot reach pup), portJsonStatus/Err = whether it can even read its packaged port.json." }))
}
Err(e) => err("worker_eval_failed", e.to_string(), None),
}
}
/// 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, _caller, args, || tab_inner(ctx, _caller, args));
if r["ok"] != json!(false) { dress_after(ctx, _caller); }
r
}
fn tab_inner(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {
dress_window(ctx, _caller);
let url = args.get("url").and_then(|u| u.as_str()).unwrap_or("about:blank").to_string();
if !alive(ctx) { return open(ctx, _caller, args); }
let mut c = json!({ "action": "newtab", "url": url });
aim(&mut c);
match run(c, Duration::from_secs(70)) {
Ok(r) => { let w = r["windowId"].as_i64().unwrap_or(0); if w > 0 { st().cur.lock().unwrap().0 = w; }
ok(json!({ "opened": true, "url": r["url"], "title": r["title"], "tabId": r["tabId"], "windowId": r["windowId"],
"_hint": "Tab opened in the extension-driven window (no CDP debug port). pup_eval {tabId} reads it." })) }
Err(e) => e,
}
}
/// pup_screenshot {tabId?} — PNG of a tab via chrome.tabs.captureVisibleTab (no CDP).
pub fn screenshot(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {
if !alive(ctx) { return not_running(); }
let mut c = json!({ "action": "screenshot" });
aim(&mut c);
if let Some(t) = args.get("tabId") { c["tabId"] = t.clone(); }
match run(c, Duration::from_secs(60)) {
Ok(r) => {
let data = r["dataUrl"].as_str().unwrap_or("");
let b64 = data.split(',').nth(1).unwrap_or("");
match pup_cdp::base64_decode(b64) {
Ok(bytes) => {
let path = ctx.shots_dir().join(format!("ext-{}.png", pup_core::now_ms()));
let _ = std::fs::write(&path, &bytes);
ok(json!({ "filePath": path.to_string_lossy().replace('\\', "/"), "sizeKB": bytes.len() / 1024,
"tabId": r["tabId"], "_hint": "PNG of the extension-driven tab (captureVisibleTab: the VISIBLE area of the active tab, not a full-page CDP capture). READ the file before asserting what it shows." }))
}
Err(e) => err("screenshot_decode_failed", e.to_string(), None),
}
}
Err(e) => e,
}
}
/// pup_switch_tab {tabId} — bring a tab to the front of the extension-driven window.
pub fn activate(ctx: &Ctx, _caller: &Caller, args: &Value) -> Value {
dress_window(ctx, _caller);
if !alive(ctx) { return not_running(); }
let mut c = json!({ "action": "activate" });
aim(&mut c);
if let Some(t) = args.get("tabId") { c["tabId"] = t.clone(); }
match run(c, Duration::from_secs(30)) { Ok(r) => ok(json!({ "tabId": r["tabId"] })), Err(e) => e }
}
/// 2.0.503: give the extension-driven window the SAME taskbar identity and placement a normal pup window
/// gets. Both live in the CDP birth path, so shipping this lane without them regressed the user experience:
/// John saw a raw "Chrome for Testing" taskbar icon and a window about 60% of his screen width. Neither
/// needs CDP — pup stamps identity through ab on a plain HWND, and ab can place the window.
/// Give every extension-driven window the same look a CDP pup window has: MAXIMIZED, wearing the
/// owning thread's taskbar tile. Idempotent (a window is dressed once), so it is cheap to call from
/// every verb rather than only from the cold-launch path.
///
/// 2.0.504, after John: "why is your window sizing so screwed up ... your pup window is like 60% the
/// width of my screen?" and "why is your windows taskbar icon still wrong?". Three real bugs, all here:
/// 1. `launch_extdrive` never passed `--start-maximized` (the CDP lane's `chrome_switches` does), so
/// Chrome opened at whatever size its profile remembered: measured 1575px of a 2582px work area.
/// 2. this ran ONLY on the cold-launch path of `open`, and that path returns early with
/// `starting:true`, so on a warm browser it never ran at all: pup.log had ZERO stamp lines.
/// 3. the placement call named its args `width`/`height` while ab takes `w`/`h`, and the result was
/// swallowed with `let _ =`, so the rejection was invisible. Maximizing needs no bounds math and
/// no DPI correction (this exe is DPI-unaware, so SPI_GETWORKAREA lies about the work area).
/// Put the lane's window at the work area in the NORMAL state. Run on every open: dress_window fits a
/// window only the first time it dresses it, so a window that later ends up minimized (John has now hit
/// this three times: pup said the lane was up and there was nothing on his screen) never got re-asserted.
/// chrome.windows.update with state normal also restores a minimized window, which ab geometry must not
/// do here because it would raise the window.
/// Late rebind. An ext session whose birth outran open_window_ext's hwnd wait keeps hwnd None for
/// ever, so identity::stamp has nothing to stamp and the button wears Chrome's STOCK icon (John,
/// 2026-09-14: kicad's aiflow-overlay window, "did you regress on the windows taskbar icons?"). The
/// 2.0.545 birth (about:blank, identity shim, then the real URL) can take longer than that wait on a
/// cold browser. Runs every health tick and on pup_rescan: bind every unbound ext session to the lane
/// window and stamp it, exactly as a timely birth would have.
pub fn rebind_unbound(ctx: &Ctx) -> usize {
// Three ways an ext window ends up wearing Chrome's stock icon, all seen 2026-09-14 in pup.log:
// born bound to a TRANSIENT hwnd (the about:blank launch window Chrome replaces: "HWND 13510448 is not
// a valid window"), re-adopted by recovery after a bridge restart with the right hwnd but NO stamp,
// or never bound at all. One test covers them: unbound, bound to a dead hwnd, or bound and unstamped.
let unbound: Vec<String> = ctx.sessions.all().into_iter()
.filter(|s| crate::transport::is_ext(s) && (s.hwnd.is_none() || s.app_id.is_none() || !s.hwnd.map(|h| ctx.host.is_alive(h)).unwrap_or(false)))
.map(|s| s.id).collect();
if unbound.is_empty() { return 0; }
let caller = crate::identity::internal_caller();
let mut n = 0;
for sid in unbound {
let Some(sess) = ctx.sessions.get(&sid) else { continue };
let Some(h) = lane_hwnd_for(ctx, &sess) else { continue };
// Bind the OS handle AND Chrome's own windowId: windowId is exact and survives title changes, so a
// later pass can verify this binding instead of re-deriving it from a title.
let wid = window_id_for_hwnd(ctx, &sid);
ctx.sessions.with_mut(&sid, |x| { x.hwnd = Some(h); if x.window_id.is_none() { x.window_id = wid; } });
if let Some(s) = ctx.sessions.get(&sid) { ctx.sessions.save_file(&s); }
crate::identity::stamp(ctx, &sid, &caller, true);
crate::activity::on_open(ctx, &sid, h, &caller);
pup_core::log(format!("[ext] bound window {h} to session \"{sid}\" and stamped its identity (late rebind)"));
n += 1;
}
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.
/// How many unowned windows adopt_stray_windows saw but has not adopted yet (they are inside their 5 s wait).
/// reload_stale_worker never cycles the browser while this is above zero: a window with no session has no file,
/// so the cycle would close it for good (John's laptop, 2.0.694 install: an "AI Flow" page and a YouTube video).
pub static STRAYS_WAITING: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(usize::MAX); // MAX = not scanned yet
/// 2.0.724: a pup window whose tab is still on the birth page 20 s after it was born never reached its address (the
/// navigate after birth failed or timed out). It sat on John's taskbar titled PUPBIRTH-... (2026-10-17). Send it to the
/// address its session recorded; at most 3 tries per tab, a minute apart.
/// pup's own birth page. Never recorded as a tab's address: it would replace the address the window was opened for,
/// and rescue_birth_tabs could then never send a stuck window on (found testing 2.0.724 on ConfRoomROG).
pub fn is_birth_url(u: &str) -> bool { u.starts_with("chrome-extension://") && u.contains("/birth.html") }
/// Record a live address for a tab, unless it is the birth page and the tab already holds a real address.
pub fn keep_url(cur: &mut String, live: &str) { if live.is_empty() || (is_birth_url(live) && !cur.is_empty() && !is_birth_url(cur)) { return; } *cur = live.to_string(); }
pub fn rescue_birth_tabs(ctx: &Ctx) -> usize {
static TRIED: Mutex<Option<std::collections::HashMap<i64, (u32, u64)>>> = Mutex::new(None);
if !alive(ctx) { 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 now = pup_core::now_ms();
let sessions = ctx.sessions.all();
let mut tried = TRIED.lock().unwrap();
let tried = tried.get_or_insert_with(Default::default);
let mut n = 0;
for t in &tabs {
let u = t["url"].as_str().unwrap_or("");
if !is_birth_url(u) { continue; }
let (Some(tab), Some(wid)) = (t["tabId"].as_i64(), t["windowId"].as_i64()) else { continue };
let Some(s) = sessions.iter().find(|s| s.window_id == Some(wid)) else { continue };
if now.saturating_sub(s.born_at_ms.max(s.created_ms)) < 20_000 { continue; }
let target = s.tabs.iter().find(|x| x.target_id == tab.to_string()).or_else(|| s.tabs.get(0)).map(|x| x.url.clone()).unwrap_or_default();
if target.is_empty() || is_blank_or_birth(&target) { continue; }
let e = tried.entry(tab).or_insert((0, 0));
if e.0 >= 3 || now.saturating_sub(e.1) < 60_000 { continue; }
*e = (e.0 + 1, now);
let ok = run(json!({ "action": "navigate", "url": target, "tabId": tab, "waitMs": 20000 }), Duration::from_secs(25)).is_ok();
pup_core::log(format!("[birth] \"{}\" tab {tab} was still on the birth page {} s after birth; sent it to {target} (try {}): {}", s.id, now.saturating_sub(s.born_at_ms.max(s.created_ms)) / 1000, e.0, if ok { "ok" } else { "failed" }));
if ok { n += 1; }
}
n
}
pub static FAST_ADOPT_UNTIL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// 2.0.735: after a click on an extension-lane window, give a popup it opened up to ~3 s to appear and adopt it now.
/// Returns the new pup window ids (for the click's answer).
pub fn adopt_popups_after_click(ctx: &Ctx, sid: &str) -> Vec<String> {
let before: std::collections::HashSet<String> = ctx.sessions.all().iter().map(|s| s.id.clone()).collect();
for _ in 0..6 {
std::thread::sleep(Duration::from_millis(500));
FAST_ADOPT_UNTIL.store(pup_core::now_ms() + 1500, std::sync::atomic::Ordering::Relaxed);
if adopt_stray_windows(ctx) > 0 { break; }
}
ctx.sessions.all().iter().map(|s| s.id.clone()).filter(|id| !before.contains(id) && id.starts_with(sid)).collect()
}
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;
let mut waiting = 0;
for (wid, wtabs) in wins {
// about:blank-only windows and pup's own unfinished births (birth.html) are close_orphan_blank_windows' job.
// 2.0.719: births too. A birth that never finished was adopted as "pup-window-2" with no thread and sat on John's
// taskbar titled PUPBIRTH-... (2026-10-13).
if wtabs.iter().all(|t| is_blank_or_birth(t["url"].as_str().unwrap_or(""))) { continue; }
let first = *seen.entry(wid).or_insert(now);
// 2.0.735: right after a pup_click (FAST_ADOPT_UNTIL), a window a pup page opened is adopted at once, so the
// click's answer can name the popup (pup-bridge#136: the AI waited 0 s, saw nothing, and reached for the mouse).
let fast = now < FAST_ADOPT_UNTIL.load(std::sync::atomic::Ordering::Relaxed) && wtabs.iter().any(|t| t["openerTabId"].as_i64().is_some());
if !fast && now.saturating_sub(first) < 5000 { waiting += 1; 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;
}
STRAYS_WAITING.store(waiting, std::sync::atomic::Ordering::Relaxed);
n
}
/// An empty page, or pup's own birth page (a window being born, or one whose birth never finished).
fn is_blank_or_birth(u: &str) -> bool { u.is_empty() || u == "about:blank" || (u.starts_with("chrome-extension://") && u.contains("/birth.html")) }
/// Close windows no session owns whose every tab is about:blank (or pup's own unfinished birth page): 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
/// created for a session that has not been recorded yet.
pub(crate) fn close_orphan_blank_windows(ctx: &Ctx, keep: Option<i64>) -> usize {
if !alive(ctx) { return 0; }
// 2.0.595: never while a window is being born (a cold open's window is about:blank until its deferred
// navigate), and never the lane's LAST window: closing it exits Chrome and takes the worker with it.
if crate::BIRTH_LOCK.try_lock().is_err() { return 0; }
let Ok(r) = run(json!({ "action": "tabs" }), Duration::from_secs(10)) else { return 0 };
let owned: std::collections::HashSet<i64> = ctx.sessions.all().iter().filter_map(|s| s.window_id).collect();
let mut wins: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new();
for t in r["tabs"].as_array().cloned().unwrap_or_default() {
if let Some(w) = t["windowId"].as_i64() { wins.entry(w).or_default().push(t["url"].as_str().unwrap_or("").to_string()); }
}
let mut n = 0;
if wins.len() <= 1 { return 0; }
for (w, urls) in wins {
if Some(w) == keep || owned.contains(&w) || urls.is_empty() { continue; }
if urls.iter().all(|u| is_blank_or_birth(u)) {
if run(json!({ "action": "close", "windowId": w }), Duration::from_secs(10)).is_ok() { n += 1; pup_core::log(format!("[lane] closed orphan about:blank window {w} that no session owns")); }
}
}
n
}
/// The OS window that belongs to ONE ext session. The lane is one browser, but several threads' windows
/// live in it, so "the lane window" is ambiguous (2026-09-14: the rebind bound kicad's session to another
/// thread's window and then to a dead handle). Chrome titles a window "<active tab title> - Google Chrome
/// for Testing", so match on the session's active tab title; with a single lane window there is nothing
/// to disambiguate and that window is it.
/// Chrome's own windowId for the window holding this session's active tab, read from the extension.
fn window_id_for_hwnd(ctx: &Ctx, sid: &str) -> Option<i64> {
let s = ctx.sessions.get(sid)?;
let tab = s.active()?;
let want = tab.target_id.parse::<i64>().ok()?;
let r = run(json!({ "action": "tabs" }), Duration::from_secs(10)).ok()?;
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; }
// A handle another session already owns is NEVER a candidate. Handing one handle to two sessions is
// what put all-engineering, NSF and pup-bridge on hwnd 590484, each stamping its tile over the last
// (John, 2026-09-14). This check is why readopt_ext never produced the bug and this path did.
let taken = crate::taken_hwnds(ctx, &s.id);
let wins: Vec<pup_host::HostWindow> = ctx.host.windows_for_pids(&pids, Some("Chrome_WidgetWin_1")).into_iter()
.filter(|w| w.visible && w.rect.2 - w.rect.0 > 200 && !taken.contains(&w.hwnd)).collect();
// The sole-window shortcut is pup-window-targeting rule 4 ("sole window OF A PROFILE"), and it is only
// sound when this really is the only ext window in play. The ext lane is one profile shared by MANY
// threads, so applying it per-session handed every asking session the same window. With owned handles
// already excluded, one remaining candidate and exactly one claimant is the honest version of that rule.
let ext_sessions = ctx.sessions.all().iter().filter(|x| crate::transport::is_ext(x)).count();
if wins.len() == 1 && ext_sessions <= 1 { return wins.first().map(|w| w.hwnd); }
let title = s.active().map(|t| t.title.trim().to_string()).unwrap_or_default();
if !title.is_empty() {
let m: Vec<&pup_host::HostWindow> = wins.iter().filter(|w| w.title.trim_start().starts_with(&title)).collect();
// exactly one title match, or it is not a match: two windows on the same page are indistinguishable
// by title and picking either is a coin flip that clobbers the loser's taskbar button.
if m.len() == 1 { return Some(m[0].hwnd); }
if m.len() > 1 { pup_core::log(format!("[ext] \"{}\": {} lane windows share the title \"{}\" - refusing to guess which is this thread's; leaving it BARE (bare > wrong)", s.id, m.len(), title)); return None; }
}
// no unambiguous match: never guess another thread's window; leave it unbound for the next pass
None
}
/// The lane window's hwnd (there is one lane window; several tabs live in it).
pub(crate) fn lane_hwnd(ctx: &Ctx) -> Option<i64> {
let pids = crate::extdrive_pids();
if pids.is_empty() { return None; }
ctx.host.windows_for_pids(&pids, Some("Chrome_WidgetWin_1")).into_iter()
.filter(|w| w.visible && w.rect.2 - w.rect.0 > 200).map(|w| w.hwnd).next()
}
/// The taskbar contract the CDP lane keeps and this lane skipped (John, 2026-09-13: "why are you
/// causing flashing of the windows taskbar icon ... you're supposed to default to showing progress
/// 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, 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(),
}
}
/// After an open / navigate / tab change on this lane: clear the attention tint and paint the overlay
/// for every session on the lane window, through the ONE composer (identity::overlay, in the binary
/// since 2.0.568). The in-page JS composer this used to carry is gone.
fn dress_after(ctx: &Ctx, caller: &Caller) {
let Some(hwnd) = lane_hwnd(ctx) else { return };
crate::activity::clear_attention_later(ctx, hwnd); // 2.0.637: never ab's clear here (it raises + activates)
if ctx.settings.get("overlayBadges", "on") == "off" { return; }
for s in ctx.sessions.all().into_iter().filter(|s| crate::transport::is_ext(s) && s.hwnd.is_some()) { crate::identity::overlay(ctx, &s.id, caller); }
}
pub fn fit_now() {
match run(json!({ "action": "fit" }), Duration::from_secs(10)) {
Ok(r) => pup_core::log(format!("[extdrive] fit {} window(s) to the work area {}", r["fitted"], r["workArea"])),
Err(e) => pup_core::log(format!("[extdrive] fit failed: {e}")),
}
}
/// Once per lane browser: send real Chrome's brand list on every request (consumerBrands, default on).
pub fn apply_consumer_brands(ctx: &Ctx) {
// 6 s, not 10: on a cold start this runs before the first page and every second here is on the open's clock.
static DONE_LANE: Mutex<String> = Mutex::new(String::new());
let lane = st().token.lock().unwrap().clone();
if lane.is_empty() || *DONE_LANE.lock().unwrap() == lane { return; }
let on = ctx.settings.get("consumerBrands", "on") != "off";
let mut cmd = json!({ "action": "setBrands", "on": on });
if on { match crate::consumer_identity_strings(ctx) { Some(v) => { for (k, val) in v.as_object().unwrap() { cmd[k] = val.clone(); } } None => { pup_core::log("[lane] consumer identity: no installed Chrome version known; leaving CfT's own".to_string()); return; } } }
match run(cmd, Duration::from_secs(6)) {
Ok(r) => { *DONE_LANE.lock().unwrap() = lane; pup_core::log(format!("[lane] consumer identity {}: ua={} brands={} err={}", if on { "ON" } else { "off" }, r["ua"].as_str().unwrap_or("-"), r["brands"].as_str().unwrap_or("-"), r["error"].as_str().unwrap_or("none"))); }
Err(e) => pup_core::log(format!("[lane] consumer brands not applied: {e}")),
}
}
pub fn dress_window(ctx: &Ctx, caller: &Caller) {
apply_consumer_brands(ctx);
static DRESSED: Mutex<Option<std::collections::HashSet<i64>>> = Mutex::new(None);
let pids = crate::extdrive_pids();
if pids.is_empty() { return; }
let wins = ctx.host.windows_for_pids(&pids, Some("Chrome_WidgetWin_1"));
let name = if caller.thread.is_empty() { "pup".to_string() } else { caller.thread.clone() };
let mut fresh = 0;
for w in wins.iter().filter(|w| w.visible && w.rect.2 - w.rect.0 > 200) {
{
let mut g = DRESSED.lock().unwrap();
let set = g.get_or_insert_with(std::collections::HashSet::new);
if !set.insert(w.hwnd) { continue; }
}
fresh += 1;
crate::identity::stamp_hwnd(ctx, caller, "pup-extdrive", &name, w.hwnd);
}
// Geometry goes through the EXTENSION (chrome.windows.update, plain bounds, logical px both
// sides), never through ab: pup-bridge-dev's rule is "CDP bounds only, NEVER windowState
// maximized, NEVER ab desktop_set_window_bounds - both RAISE the window", and this lane's
// stand-in for CDP is its own extension channel.
if fresh > 0 {
match run(json!({ "action": "fit" }), Duration::from_secs(10)) {
Ok(r) => pup_core::log(format!("[extdrive] fit {} window(s) to the work area {}", r["fitted"], r["workArea"])),
Err(e) => pup_core::log(format!("[extdrive] fit failed: {e}")),
}
}
}
/// 2.0.578: a browser handle for the extension lane whose Cdp forwards every command to the extension's
/// DevTools driver (nbe's cdp.js, via the worker's `cdp` action). Verbs written against BrowserHandle
/// (click, hover, type, press_key, scroll, fill, element/full-res screenshots) run unchanged on this lane.
// EXT_HANDLE lives in LaneState.handle since 2.0.659
pub fn ext_handle() -> std::sync::Arc<crate::BrowserHandle> {
let lane_st = st(); let mut g = lane_st.handle.lock().unwrap();
if let Some(h) = g.as_ref() { return h.clone(); }
let via: pup_cdp::Via = Box::new(|session: Option<&str>, method: &str, params: Value, timeout: Duration| {
let tab = session.and_then(|s| s.strip_prefix("ext:")).and_then(|x| x.parse::<i64>().ok());
let mut cmd = json!({ "action": "cdp", "method": method, "params": params, "timeoutMs": timeout.as_millis() as u64 });
if let Some(t) = tab { cmd["tabId"] = json!(t); }
match run(cmd, timeout + Duration::from_secs(3)) {
Ok(r) => Ok(r.get("result").cloned().unwrap_or(Value::Null)),
Err(e) => Err(pup_cdp::CdpError::Protocol { method: method.into(), code: 0, message: e["error"].as_str().unwrap_or("the extension lane did not answer").to_string() }),
}
});
let h = std::sync::Arc::new(crate::BrowserHandle { cdp: pup_cdp::Cdp::via(via), launched: pup_browser::Launched { pid: 0, cdp_port: 0, candidate: pup_browser::Candidate { kind: "extension-lane".into(), source: "nbe driver".into(), executable_path: std::path::PathBuf::new(), build_id: None }, profile_dir: std::path::PathBuf::new(), reused: true } });
*g = Some(h.clone()); h
}
#[cfg(test)]
mod tests {
// 2.0.723: a lane dropped while its thread still works in it (the idle sweep during a 20 s close) must never
// resolve to the adom-you lane; that wiped the user's main browser record and ended it.
#[test] fn birth_page_never_replaces_a_real_address() {
let b = "chrome-extension://liomcaladmooagjiebkfgegciadapnpi/birth.html?token=x1";
let mut u = "https://example.com/".to_string(); super::keep_url(&mut u, b); assert_eq!(u, "https://example.com/");
super::keep_url(&mut u, "https://example.org/"); assert_eq!(u, "https://example.org/");
let mut e = String::new(); super::keep_url(&mut e, b); assert_eq!(e, b);
super::keep_url(&mut u, ""); assert_eq!(u, "https://example.org/");
}
#[test] fn dropped_lane_never_aliases_adom_you() {
super::default_lane().token.lock().unwrap().clear();
*super::default_lane().token.lock().unwrap() = "keep-me".into();
super::enter_profile("iso-test-723");
super::drop_lane_for_profile("iso-test-723");
let l = super::st();
assert_eq!(l.profile, "iso-test-723");
l.token.lock().unwrap().clear();
assert_eq!(*super::default_lane().token.lock().unwrap(), "keep-me");
super::leave_profile();
super::drop_lane_for_profile("iso-test-723");
}
#[test] fn blank_or_birth() {
assert!(super::is_blank_or_birth("")); assert!(super::is_blank_or_birth("about:blank"));
assert!(super::is_blank_or_birth("chrome-extension://liomcaladmooagjiebkfgegciadapnpi/birth.html?token=b1y2"));
assert!(!super::is_blank_or_birth("https://example.com/birth.html")); assert!(!super::is_blank_or_birth("https://wiki.adom.inc/adom/pup-bridge"));
}
}